Compare commits
849 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c0c658152 | |||
| 88062ab9f9 | |||
| 85856baf13 | |||
| 2d33b7df6e | |||
| 5d8a8878d5 | |||
| 8d2dab44d8 | |||
| d435e67a36 | |||
| 33c4b5188c | |||
| 8161085714 | |||
| 6493239484 | |||
| a143681af3 | |||
| 5a88e0465e | |||
| 30bb901d7c | |||
| 40418fcf6f | |||
| ad85e5be80 | |||
| aa9b18a8d6 | |||
| 8b6dd3e599 | |||
| 49d2596fc6 | |||
| 9a826bbe6f | |||
| acc9186a59 | |||
| 7d252ea45b | |||
| 140a40acaf | |||
| 56efba66f5 | |||
| 7e612cddd3 | |||
| 034e46700c | |||
| ac8f64d7a1 | |||
| b536e05fee | |||
| 6eed037186 | |||
| 5912400dee | |||
| ab209df78f | |||
| 11a352dcfd | |||
| 9ae6922bdc | |||
| ea8c3c801a | |||
| eb9508502c | |||
| b46b5dfb2a | |||
| 877dbed5e8 | |||
| 79ff381c4c | |||
| f6a672738a | |||
| e4964d6933 | |||
| 755c9af47d | |||
| 02e4b8eadc | |||
| aaa1625724 | |||
| bb5f3ec6a8 | |||
| 8804eb9a78 | |||
| 2c2700a31d | |||
| 8f9ccc6540 | |||
| ff8d68c151 | |||
| 1fb0bc29db | |||
| a1593b8942 | |||
| 20c4cb770e | |||
| db7a4ae7e9 | |||
| aabb766fad | |||
| 8dce3099d9 | |||
| fb2b43176f | |||
| 2f6034c63c | |||
| 67190fc988 | |||
| e4c0495646 | |||
| a7508b2746 | |||
| 34408b50a7 | |||
| 48acd1feca | |||
| 8d51fd8ff5 | |||
| a44ba4abcb | |||
| b338c9b3c2 | |||
| 08f7835f5d | |||
| d38fc80bad | |||
| b2fd84e0d3 | |||
| e64f488654 | |||
| 8017ed2cf6 | |||
| 65febebd40 | |||
| 2e047dff98 | |||
| 6612e42668 | |||
| 2956bbad8d | |||
| 3285a00ebc | |||
| c0c8e47344 | |||
| 3fc02df70f | |||
| c071201e37 | |||
| 690d66ce0b | |||
| ef312ef770 | |||
| c0bed53b42 | |||
| c0ee3ed375 | |||
| ce35b46fee | |||
| e12c263fc9 | |||
| 281c9be6c8 | |||
| b92debf9c9 | |||
| b6c368c39c | |||
| de92e3e472 | |||
| d9777f1439 | |||
| b4643fd27f | |||
| d9cf3f9c38 | |||
| dfd6b769b9 | |||
| 68780de6fc | |||
| 93715b542c | |||
| de1a06e3e8 | |||
| bfebf946c2 | |||
| 6094130f61 | |||
| d1bd0073cc | |||
| 789e30fdff | |||
| 04507a8a34 | |||
| 1ded3f8bc7 | |||
| 3d64162f06 | |||
| 79357a527f | |||
| 1e16e19d11 | |||
| efa8bfcf0e | |||
| 1351761f81 | |||
| 962a0460cc | |||
| 10207967c4 | |||
| 242eeaf07e | |||
| 550e7d0415 | |||
| 3f14d08095 | |||
| 206be5ba15 | |||
| 83e1f154b5 | |||
| ec47106c26 | |||
| 74b69a4c7c | |||
| 84d5a9a354 | |||
| eb86773039 | |||
| 44ca5b8591 | |||
| 95e125dfc7 | |||
| 7f6ce16d85 | |||
| aa47789f55 | |||
| 7a961babd4 | |||
| d947b0194b | |||
| cd7f01f573 | |||
| 159abf7b7d | |||
| 1b6eafa7d9 | |||
| 42928e0a9d | |||
| 70c58bd346 | |||
| 556d88c96d | |||
| e0aa9459c9 | |||
| e8fe4ed881 | |||
| 98c166c950 | |||
| 3f64e4b943 | |||
| 133a1f83e3 | |||
| c50a7d9938 | |||
| 753940e0cf | |||
| 138fd926ee | |||
| 183cb491b3 | |||
| ee070de860 | |||
| bbc087d2ad | |||
| b8c3153b75 | |||
| d7f978da49 | |||
| 0afad251e0 | |||
| 4d847c6a74 | |||
| f404ddde91 | |||
| c8a3d953cf | |||
| f851ecac78 | |||
| 1c1fcdcd3d | |||
| 594e9a6039 | |||
| ffafa810da | |||
| 1ca35e9364 | |||
| 4dc79833da | |||
| e8c4ce995c | |||
| 9daaffa23f | |||
| 63bada09e0 | |||
| 612e86cf86 | |||
| 5abf8c6798 | |||
| 992422c91f | |||
| 32ae3c4d4b | |||
| 9ee9c0579f | |||
| 39a8c8c10d | |||
| 49c2d3e34d | |||
| bcef6e2de4 | |||
| 26d1b8eb4c | |||
| cecab91298 | |||
| 3eb3ac06b6 | |||
| aefadfeed4 | |||
| 13fa08ad39 | |||
| 071e9106a8 | |||
| 405d4febd9 | |||
| 36df7b36ec | |||
| 9ba15e9649 | |||
| bd1dc3149e | |||
| 03a1cfcd5e | |||
| 45a9fd5af6 | |||
| 224584312a | |||
| 5f29af30b0 | |||
| 15268ef4f6 | |||
| 3ac5095776 | |||
| d7cf782019 | |||
| 055097fdeb | |||
| 9805654247 | |||
| 6e50ce3293 | |||
| 3a0aaddafd | |||
| 2659883ce5 | |||
| b1f6f2e7ac | |||
| cf96ab45c1 | |||
| abe568b5b3 | |||
| c0eab5ebc5 | |||
| f0a698c6fe | |||
| 806a069785 | |||
| d85908e3de | |||
| 9c6ea92122 | |||
| 229774d65d | |||
| aba2f83371 | |||
| 69dca2b619 | |||
| fc4bfb2dc1 | |||
| a904c634b8 | |||
| 938ae6828c | |||
| 79df37ac1e | |||
| f546fc3060 | |||
| 6bf89f4b32 | |||
| 5a07fd8932 | |||
| 9fa086e471 | |||
| 1658db7f10 | |||
| 780c9e3696 | |||
| 74fb430697 | |||
| d118aa8649 | |||
| 953c1742e0 | |||
| a88996fb3f | |||
| 0014aac201 | |||
| 8e6babe5d4 | |||
| cdeb2a8fc0 | |||
| a6e9f88e7a | |||
| ab3fdf509f | |||
| fb585d0086 | |||
| fa8431c5c6 | |||
| ec2b911881 | |||
| 0f2b29741e | |||
| 2f26b581f4 | |||
| ad266e45ca | |||
| f6825c1c60 | |||
| 40f958c5ef | |||
| 02e975d3f3 | |||
| 40b54b458d | |||
| ca60f3cff5 | |||
| c1ece47532 | |||
| b2e5eda89e | |||
| 1eb01b39e7 | |||
| c9e0f0b3e4 | |||
| 1e357d3ced | |||
| 4831acba39 | |||
| 658b8591b6 | |||
| 950b001675 | |||
| 0b5b41acd9 | |||
| 6b4b9e9cff | |||
| da89831116 | |||
| 3243d88c9b | |||
| 824fc310a9 | |||
| c891ab2588 | |||
| 33e9b26fe9 | |||
| 6e402f60dc | |||
| 4b56c84a8c | |||
| cee70e04db | |||
| 808635e314 | |||
| 7a1429bcc1 | |||
| 21bbaf2691 | |||
| 50c5850f0d | |||
| d22c6c535e | |||
| 6a41eb382f | |||
| aa70ebcdca | |||
| 7111b3749d | |||
| 93277f4307 | |||
| 9e3ce3f3a4 | |||
| a98e479e71 | |||
| 897d8644c5 | |||
| 2f7ad3ac73 | |||
| 20318e3a79 | |||
| cc3e65dd3c | |||
| 7b0a4a7803 | |||
| bab7734582 | |||
| 3dda84bf9a | |||
| f18ded6117 | |||
| 41d47244af | |||
| b113af8472 | |||
| 2309b27458 | |||
| 8329e9b46c | |||
| 5879404fd2 | |||
| bb0d2bf675 | |||
| dcf87bd642 | |||
| 4445cdd053 | |||
| 0b3c3a1f28 | |||
| 4b6e46d9ab | |||
| 2935aaef45 | |||
| 33fd0a617e | |||
| 2fedd8f234 | |||
| 527c5f49cb | |||
| 10d2c88e88 | |||
| fd8e95c878 | |||
| 686dd92cb9 | |||
| 28d036434f | |||
| 4b12043cea | |||
| 0f6f4c8c60 | |||
| 47b47cc32c | |||
| ec4e8710f2 | |||
| b3dae675d9 | |||
| c1c3624593 | |||
| b21f12015e | |||
| ca87894ab2 | |||
| 42dcc52ed1 | |||
| 45bfbb8a2f | |||
| d6ad9a471b | |||
| b585dab6b4 | |||
| d45e1b3bed | |||
| 03fd403e38 | |||
| 2dcabc840c | |||
| 87d58987a6 | |||
| 5f9d6fd81b | |||
| c2c9db737b | |||
| 4565dd406b | |||
| 22bbe1ac50 | |||
| 72fbbe311a | |||
| 423e06cbe8 | |||
| 1f3a9dabc2 | |||
| 937899194d | |||
| cf1939f063 | |||
| edee11931d | |||
| 612f9cdbc8 | |||
| fcd18f0b9c | |||
| 78f6bbe93d | |||
| 27d170cbec | |||
| 49119eff00 | |||
| 34dd358eb0 | |||
| 93596c1218 | |||
| 9fbcfc213b | |||
| 01f678c3b1 | |||
| 2f6895e118 | |||
| b7c229f30f | |||
| b88d46ee7f | |||
| b58a46455b | |||
| ce6fa4c0fc | |||
| 9965abccfb | |||
| 8bc06a5d65 | |||
| 7f1c17e396 | |||
| c5594fdae0 | |||
| b8b16c3bdf | |||
| fc766de02d | |||
| 79810413bd | |||
| dc21991599 | |||
| cc41065d9b | |||
| e877800779 | |||
| d5efd776b7 | |||
| 164bcc2f4a | |||
| 21ffd79c63 | |||
| 638643cda7 | |||
| 6703e03ce6 | |||
| 56c3f1193f | |||
| d189df95db | |||
| 0bcfc81b1f | |||
| 571d7bf17c | |||
| 93fd98f8d4 | |||
| 238a3ee9be | |||
| 897cd7cb34 | |||
| 409d05eec8 | |||
| 4b2a25eed4 | |||
| 1fc95a83b6 | |||
| cc888e2a55 | |||
| d9f91fd919 | |||
| dd3f496d06 | |||
| 09be3d2bac | |||
| 1fd4c4ef13 | |||
| 20cd023c07 | |||
| 1655ca235d | |||
| 77dc6aaf27 | |||
| b4cdef6875 | |||
| 36868e648c | |||
| c119a2c011 | |||
| e0557d8961 | |||
| 36d3b1e9fb | |||
| 012eb61c52 | |||
| c4a56105ca | |||
| 1005fb973e | |||
| 75ac58abdf | |||
| e46b438b06 | |||
| 264f4db567 | |||
| 0c56b4d580 | |||
| 32439ca2f0 | |||
| fa49bcaf8b | |||
| 89e8e94bf1 | |||
| 5b8eb4735c | |||
| 1c9f000576 | |||
| de53d8762f | |||
| daa56efe27 | |||
| f1db010a68 | |||
| dbb5906767 | |||
| 18e414ec45 | |||
| fc00a1b3a8 | |||
| 556b74f4fc | |||
| 6e2e2d1665 | |||
| 9242350f0e | |||
| b0c14aa8f9 | |||
| 4b2516532e | |||
| 1aec8d727c | |||
| e60e0ba3c4 | |||
| 5877048749 | |||
| c8c1f05328 | |||
| 96f3e888cf | |||
| 14888f1cb8 | |||
| 49171c5e8c | |||
| 51fb9495ad | |||
| 89c1b3b4d8 | |||
| 1d80c12e18 | |||
| d092fc8436 | |||
| 8a812cd8ec | |||
| ae13b4e420 | |||
| 548c4d4efe | |||
| f6ed2feab4 | |||
| b730aa520d | |||
| 3d16c3e4e6 | |||
| 0d935a863f | |||
| 053b1d0f8d | |||
| b247f3f089 | |||
| 20ec435d6d | |||
| 53cd369b05 | |||
| ec3c604a83 | |||
| c3f871b49b | |||
| e5b2eab727 | |||
| bbf93430b7 | |||
| 3cb04b2c56 | |||
| e3c28a6c96 | |||
| c2571f9f47 | |||
| 4855fbd13f | |||
| 9569666259 | |||
| 42edd357bc | |||
| b33706006a | |||
| 0de80c2ff0 | |||
| 5d65a85bdb | |||
| a352aff93b | |||
| 713d575bad | |||
| 421bb467d0 | |||
| dde2ca06c3 | |||
| 091308787b | |||
| 04f9c456d5 | |||
| 6b7ffe0e26 | |||
| f29940d784 | |||
| 0413daf76b | |||
| 5043749b9f | |||
| 793f475edd | |||
| 03cfbfe4ac | |||
| e8d0360e64 | |||
| 05fd83bd6f | |||
| 864dd9f219 | |||
| 9a9a271b1f | |||
| 89d17107af | |||
| b6ddaae82a | |||
| 0bece2df7d | |||
| 4464802947 | |||
| 7c44238bae | |||
| b45c1cf327 | |||
| f1d7385294 | |||
| 77f5b911b5 | |||
| f8ca825524 | |||
| 6377f3a8b3 | |||
| 533d5dfc98 | |||
| 1ed3816d6f | |||
| 4ff52bd730 | |||
| 02a4c9db53 | |||
| 433de17562 | |||
| 23e4183bec | |||
| 926ddf6d09 | |||
| a6017a8411 | |||
| 608443b77f | |||
| 11e110bc7d | |||
| 806b9545a9 | |||
| b35fece3d5 | |||
| 4468ef1410 | |||
| e45cc2d2aa | |||
| 291bf96d15 | |||
| f0610bdd9c | |||
| ab82c4b300 | |||
| 94eaf0e254 | |||
| 9a6c80f562 | |||
| 9a4b045bdc | |||
| d3cfa6396d | |||
| 60a7362327 | |||
| 7be9f5989e | |||
| 651ee02c11 | |||
| fd9eaa3e83 | |||
| 83f3ccab0e | |||
| ae74f94655 | |||
| 989ca163a3 | |||
| 5b74083b5f | |||
| f88e93a0b9 | |||
| 2cf9bde1af | |||
| e589a35c67 | |||
| 4e5240c5ac | |||
| 5922a7a051 | |||
| 70effdc706 | |||
| a72aca1d05 | |||
| 351d157533 | |||
| 9493fb111f | |||
| a026efce6e | |||
| 1908c84fbe | |||
| f722fd1208 | |||
| 49feb1958c | |||
| e17d395ae7 | |||
| eeeea74bf1 | |||
| 52a626d7b9 | |||
| b8b6c74d86 | |||
| de7a613098 | |||
| b1c9977711 | |||
| a108cb7808 | |||
| 4f4f573955 | |||
| eb77fcfb72 | |||
| 5e59fcbd4f | |||
| 246a48359c | |||
| 40e983664c | |||
| 1eb239bf88 | |||
| d5afdf7691 | |||
| b539eb8575 | |||
| b8f7f44bb0 | |||
| 2249de439e | |||
| 4f358aa0f3 | |||
| 2f3708d40c | |||
| 46c13b5185 | |||
| d70b4d39ab | |||
| a37a2dd043 | |||
| 519fbcf581 | |||
| e74bf260c6 | |||
| 99f20868ad | |||
| b30a045a4e | |||
| b50778d3eb | |||
| 5ed1d7537a | |||
| a2021b74ba | |||
| 80b68fb126 | |||
| f87b05c90f | |||
| 73d73dc61e | |||
| 578cd8b24b | |||
| bae68520a2 | |||
| 6e20d37dda | |||
| cc8eb89ce0 | |||
| 84b83fc3c8 | |||
| 3afdf25012 | |||
| 5c3e78f605 | |||
| 6117a2f3cd | |||
| 997006cdd7 | |||
| 37bc451741 | |||
| 93f90dae68 | |||
| 7da72cf89a | |||
| ded70cd250 | |||
| 31435ef83e | |||
| d75ff0c5ab | |||
| c0eb66fe22 | |||
| c394a61a4e | |||
| 16b7b3bc3e | |||
| 065331c77f | |||
| 2b74c6779a | |||
| 0d66f2c6d3 | |||
| 6d25fb715b | |||
| 0ddfa5f7d9 | |||
| b015887b02 | |||
| 75b11d5744 | |||
| eb026ae4ac | |||
| 38d3b28445 | |||
| 0d0395b4e3 | |||
| e6c6ee441a | |||
| bfb96d536e | |||
| ad66250734 | |||
| acd318b640 | |||
| 55fad7ea27 | |||
| debbf5a5b0 | |||
| dd3078dc69 | |||
| d9f52bb634 | |||
| 04c4890124 | |||
| 1c103337fe | |||
| 13c89c29c5 | |||
| df7a6e18fd | |||
| a8de067810 | |||
| cf7712acea | |||
| 0f258fd55b | |||
| bf2f7d1e16 | |||
| e1b1618015 | |||
| e5d09491d4 | |||
| e1c9e6ccb4 | |||
| 5947e2a664 | |||
| e95c6ce8f4 | |||
| c1e3783272 | |||
| eaf1fc896f | |||
| e97ebfd8c2 | |||
| cc281f3533 | |||
| 4e07b549a2 | |||
| 019cf79704 | |||
| a102843789 | |||
| bf443a65e2 | |||
| 5ac55518ab | |||
| 514f518d2a | |||
| fc75b0cd8e | |||
| 7380c64141 | |||
| 943e7a7317 | |||
| 8fb71366f8 | |||
| 74c95ed34b | |||
| 82b3ea31e6 | |||
| 1b5f217e9c | |||
| ee5a8a05c9 | |||
| 499fb1f7df | |||
| 5f266319a3 | |||
| e32fa059e5 | |||
| 3371b15631 | |||
| c0e407e156 | |||
| d2f4ed93df | |||
| 1d6b0491a7 | |||
| c70bebb2aa | |||
| f7273e5cdf | |||
| 8304666261 | |||
| ec18abc97f | |||
| adbebd4de8 | |||
| e44b4b2540 | |||
| 248cb38390 | |||
| 506c2ab1c9 | |||
| 43bf6696d5 | |||
| f52f00abcf | |||
| da4ea18003 | |||
| 5e5615a2d1 | |||
| fe8347f32a | |||
| d2b360f7b3 | |||
| c9618458cb | |||
| ca918bc7e3 | |||
| c820197667 | |||
| 6c6ca69141 | |||
| 57f62b4797 | |||
| 1811522473 | |||
| 8e500e8bef | |||
| a2abd38ccf | |||
| 7fb0bdc9a5 | |||
| 7e5157f218 | |||
| cead85b165 | |||
| 25d4b9fc0c | |||
| 4cdfd64db0 | |||
| 70c2568af8 | |||
| 0683074b8b | |||
| 8f6c07afd6 | |||
| 3b2f0a633c | |||
| c920a8607c | |||
| eb8d8e84f2 | |||
| 619aa58fb3 | |||
| 7accdc4416 | |||
| ae8e5c4dbc | |||
| 454eb590ce | |||
| 9698c5e40a | |||
| 48680577a8 | |||
| a170e6454f | |||
| 25f90bc625 | |||
| c3f3cc085a | |||
| 9871e4f5d0 | |||
| 84229f1af9 | |||
| 3c3241f93c | |||
| 53dd2c1f1d | |||
| 592151bdb6 | |||
| e494d6e381 | |||
| 05b88c8add | |||
| 7a397c3d13 | |||
| bd5aed7b90 | |||
| 59c8dac851 | |||
| 41a2b7e58e | |||
| 1b86913c00 | |||
| 3e1e0b604f | |||
| 54602b55bb | |||
| 086b985383 | |||
| 25cc0d2447 | |||
| d1afe13a1c | |||
| 9974e0f458 | |||
| 7b32f59281 | |||
| 7389c79c52 | |||
| d5adf8b9f6 | |||
| 47de317b65 | |||
| 9e3252d1b0 | |||
| 2570c3410b | |||
| dc4240e702 | |||
| 997e9422eb | |||
| cb494ac416 | |||
| 078f886f39 | |||
| 15727f716a | |||
| 476898b1d4 | |||
| 1a78e3a5bc | |||
| 86e71c48f5 | |||
| 6ebb29cd22 | |||
| e2315e6a54 | |||
| 61f0c8aa66 | |||
| bf632fad2e | |||
| 8cebd985a2 | |||
| 6ff617a733 | |||
| 4d9956cf65 | |||
| ac3c936820 | |||
| 08fc32b0e1 | |||
| d8ad49d14d | |||
| 3debf75eb9 | |||
| 200b1c304b | |||
| 1b33bb3d1c | |||
| 33fdb2c489 | |||
| cfb6ddfcea | |||
| 8e1ed8bef3 | |||
| 30f22fbd78 | |||
| a155ab6deb | |||
| a4ebb33cd5 | |||
| 64fa1aa8cf | |||
| 120d6ec7d8 | |||
| 6291b59849 | |||
| a96e966911 | |||
| 69aa650203 | |||
| 8028cc764f | |||
| b3869a7833 | |||
| 912a98788e | |||
| f90e9dfab0 | |||
| c43c9458a9 | |||
| 90064a8cf0 | |||
| bacab20f4a | |||
| 7b995044b4 | |||
| e878dca3d7 | |||
| fad9ad2be4 | |||
| 15fe9c20df | |||
| 789ad8257a | |||
| 63a549f0e3 | |||
| d422d4e067 | |||
| 881c0f406b | |||
| ee84731153 | |||
| b62ecc6699 | |||
| 21f8b03483 | |||
| 03406c68d0 | |||
| d3883126bf | |||
| e38333e272 | |||
| 2eb2441f2b | |||
| 7409bba0d8 | |||
| 70940e768c | |||
| 37f93d5127 | |||
| 5517eebafe | |||
| bf357a0e2d | |||
| 02dbe3ef2e | |||
| bf2ceded96 | |||
| 97d1c6f02b | |||
| e0b600b651 | |||
| 3379429329 | |||
| bdca3372f9 | |||
| ca9cc692e4 | |||
| afe1faf214 | |||
| d610d091fe | |||
| 36d7732234 | |||
| ca48f7612a | |||
| 7d50cc40de | |||
| 934385ff55 | |||
| fb9a1a6129 | |||
| 3a60abbac0 | |||
| 8d8f78241d | |||
| 937492f6f5 | |||
| b94160df72 | |||
| 456ebaf423 | |||
| 2d27f53941 | |||
| b6f3160dbc | |||
| 77125f45cc | |||
| d882bbca85 | |||
| ac45307da6 | |||
| ea2b6e3608 | |||
| 3a39d110f0 | |||
| 6d39f2a4cb | |||
| f82736641f | |||
| 3d19313a7f | |||
| 17aae72583 | |||
| a634839678 | |||
| ac3693562b | |||
| 99149d9f2a | |||
| 2b6e17c1ef | |||
| 34421dcd49 | |||
| 48abf57218 | |||
| 867dcb0004 | |||
| c840268678 | |||
| f783b135c7 | |||
| a04fe1aa7e | |||
| d01a2dd651 | |||
| ec8fa56c95 | |||
| 91508c50b5 | |||
| f7b7b63e3f | |||
| bb720c87c1 | |||
| 228862fefa | |||
| 04d85b3c63 | |||
| 5f487e8c79 | |||
| 0dff432337 | |||
| 4295a86e55 | |||
| 3c8cc0b2b6 | |||
| 34df45da2f | |||
| a6068aae1d | |||
| 8ee1a09dc7 | |||
| f593dc8463 | |||
| 129f1bf3da | |||
| 304b43cb30 | |||
| 3a483be258 | |||
| 220d9e3d56 | |||
| fc473f1d11 | |||
| 22b68eefd8 | |||
| 6764a420b0 | |||
| 45df254fe7 | |||
| f3fc8b7072 | |||
| 1634e267b8 | |||
| 267b706544 | |||
| ed6ae7e0d2 | |||
| c71681b197 | |||
| 04b6eb509c | |||
| 7897d8a8ad | |||
| 6378ad2734 | |||
| 6f5ef10553 | |||
| 28ba09e952 | |||
| a86a7dce57 | |||
| 27e79a4693 | |||
| 0cdcec10ca | |||
| 15505ee4a2 | |||
| 31e2669c4d | |||
| 362fc5c2ff | |||
| 79e7d007a4 | |||
| a9fad093e6 | |||
| 56efd2eb3f | |||
| 078cd0050a | |||
| e367f4ca21 | |||
| 0b63e05fe2 | |||
| cf25efb4d9 | |||
| 012138179c | |||
| 8b40778ee1 | |||
| 6ae935309a | |||
| 43ccaf52af | |||
| 82e08d0f69 | |||
| 266d97ad63 | |||
| 6869c6fe6f | |||
| 59d8e9b95e | |||
| 61bfb3cfa8 | |||
| 87648490fd | |||
| 3e392dc3ae | |||
| 35b890aadc | |||
| 1bc8535dbb | |||
| 6f5d93cbbe | |||
| 311a133ab8 | |||
| 52de36b158 | |||
| b51e7ea448 | |||
| 154b6a3bf7 | |||
| 04220ca1e7 | |||
| 8d9e6a2c62 | |||
| e47188eab2 | |||
| 0be619355f | |||
| af4c68feac | |||
| 7838870a0c | |||
| cb037910df | |||
| f3bff76aa1 | |||
| 70be08eaf8 | |||
| 3d04d92aa2 | |||
| 5cd7152ae7 | |||
| 9f85f67031 | |||
| 1315282c7f | |||
| becd086865 | |||
| 487852f07e | |||
| ca45765c46 | |||
| 5a69a61d48 | |||
| 0925899cee | |||
| a9f98adb07 | |||
| 25c12053a6 | |||
| 36564ffbcd | |||
| 2f6e241417 | |||
| 186f2b11c3 | |||
| 3b6e44c331 | |||
| 1ff9950a55 | |||
| a70e9d03ff | |||
| ae1dc1a91e | |||
| 5ff0630726 | |||
| 7d1b471d88 | |||
| 054fc8ef99 | |||
| 03d0114dcd |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Miniflux",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"remoteUser": "vscode",
|
||||
"forwardPorts": [
|
||||
8080
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"go.toolsManagement.checkForUpdates": "local",
|
||||
"go.useLanguageServer": true,
|
||||
"go.gopath": "/go"
|
||||
},
|
||||
"extensions": [
|
||||
"ms-azuretools.vscode-docker",
|
||||
"golang.go"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/go
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
command: sleep infinity
|
||||
network_mode: service:db
|
||||
environment:
|
||||
- CREATE_ADMIN=1
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=test123
|
||||
db:
|
||||
image: postgres:15
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
hostname: postgres
|
||||
environment:
|
||||
POSTGRES_DB: miniflux2
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
postgres-data: null
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report
|
||||
title: ''
|
||||
labels: bug, question / discussion
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
- [ ] I have read this document: https://miniflux.app/opinionated.html#feature-request
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Feed Problems
|
||||
about: Problems with a feed or a website
|
||||
title: ''
|
||||
labels: feed problems
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Improvement
|
||||
about: Do you have an idea to improve an existing feature?
|
||||
title: ''
|
||||
labels: improvements
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
paths-ignore:
|
||||
- ./http/client/testdata
|
||||
@@ -0,0 +1,37 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/packaging/docker/alpine"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/packaging/docker/distroless"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
@@ -0,0 +1,4 @@
|
||||
Do you follow the guidelines?
|
||||
|
||||
- [ ] I have tested my changes
|
||||
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
|
||||
@@ -0,0 +1,55 @@
|
||||
name: CI Workflow
|
||||
permissions: read-all
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: Unit Tests
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
go-version: ["1.19", "1.20"]
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Run unit tests
|
||||
run: make test
|
||||
|
||||
integration-tests:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:9.5
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.20"
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- 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
|
||||
@@ -0,0 +1,42 @@
|
||||
name: "CodeQL"
|
||||
|
||||
permissions: read-all
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '45 22 * * 3'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go', 'javascript' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Docker
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 1 * * *'
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
jobs:
|
||||
docker-images:
|
||||
permissions:
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate Alpine Docker tag
|
||||
id: docker_alpine_tag
|
||||
run: |
|
||||
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
|
||||
DOCKER_VERSION=dev
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
DOCKER_VERSION=nightly
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
DOCKER_VERSION=${GITHUB_REF#refs/tags/}
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest,ghcr.io/${DOCKER_IMAGE}:latest,quay.io/${DOCKER_IMAGE}:latest"
|
||||
fi
|
||||
echo ::set-output name=tags::${TAGS}
|
||||
|
||||
- name: Generate Distroless Docker tag
|
||||
id: docker_distroless_tag
|
||||
run: |
|
||||
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
|
||||
DOCKER_VERSION=dev-distroless
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
DOCKER_VERSION=nightly-distroless
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
DOCKER_VERSION=${GITHUB_REF#refs/tags/}-distroless
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest-distroless,ghcr.io/${DOCKER_IMAGE}:latest-distroless,quay.io/${DOCKER_IMAGE}:latest-distroless"
|
||||
fi
|
||||
echo ::set-output name=tags::${TAGS}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Quay Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
|
||||
- name: Build and Push Alpine images
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/alpine/Dockerfile
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.docker_alpine_tag.outputs.tags }}
|
||||
|
||||
- name: Build and Push Distroless images
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/distroless/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.docker_distroless_tag.outputs.tags }}
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Linters
|
||||
permissions: read-all
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
jshint:
|
||||
name: Javascript Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install jshint
|
||||
run: |
|
||||
sudo npm install -g jshint@2.13.3
|
||||
- name: Run jshint
|
||||
run: jshint ui/static/js/*.js
|
||||
|
||||
golangci:
|
||||
name: Golang Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.20"
|
||||
- uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
args: --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Debian and RPM Package Builders
|
||||
permissions: read-all
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
jobs:
|
||||
debian-package-builder:
|
||||
name: Build Debian Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
- name: Available Docker Platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
- name: Build Debian Packages
|
||||
run: make debian-packages
|
||||
- name: List generated files
|
||||
run: ls -l *.deb
|
||||
- name: Upload packages to repository
|
||||
env:
|
||||
FURY_TOKEN: ${{ secrets.FURY_TOKEN }}
|
||||
run: for f in *.deb; do curl -F package=@$f https://$FURY_TOKEN@push.fury.io/miniflux/; done
|
||||
rpm-package-builder:
|
||||
name: Build RPM Package
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
run: make rpm
|
||||
- name: List generated files
|
||||
run: ls -l *.rpm
|
||||
- name: Upload package to repository
|
||||
env:
|
||||
FURY_TOKEN: ${{ secrets.FURY_TOKEN }}
|
||||
run: for f in *.rpm; do curl -F package=@$f https://$FURY_TOKEN@push.fury.io/miniflux/; done
|
||||
@@ -1 +1,5 @@
|
||||
miniflux-*
|
||||
miniflux
|
||||
*.rpm
|
||||
*.deb
|
||||
.idea
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
notifications:
|
||||
email: false
|
||||
services:
|
||||
- postgresql
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
language: go
|
||||
go_import_path: "miniflux.app"
|
||||
go:
|
||||
- "1.11"
|
||||
before_install:
|
||||
- npm install -g jshint
|
||||
- go get -u golang.org/x/lint/golint
|
||||
script:
|
||||
- jshint ui/static/js/*.js
|
||||
- make lint
|
||||
- make test
|
||||
- make integration-test
|
||||
@@ -1,3 +1,813 @@
|
||||
Version 2.0.44 (May 6, 2023)
|
||||
----------------------------
|
||||
|
||||
* Add link to the URL rewrite rules documentation
|
||||
* Update scraping rules for `ilpost.it`
|
||||
* Update rewrite rules for `theverge.com`
|
||||
* Add a rewrite rule to remove clickbait titles
|
||||
* Make sure `PROXY_IMAGES` option is backward compatible with `PROXY_OPTION` and `PROXY_MEDIA_TYPES`
|
||||
* Add new rule to remove tables
|
||||
* Add support for searching well-known URLs in subdirectory
|
||||
* Add CSS `word-wrap` rule to break very long entry title into multiple lines
|
||||
* Add swipe as option for gesture navigation between entries. There are now 3 possible choices: `none`, `double-tap`, and `swipe`.
|
||||
* Prefer typographic punctuation in English translation
|
||||
* Process older entries first:
|
||||
- Feed entries are usually ordered from most to least recent.
|
||||
- Processing older entries first ensures that their creation timestamp
|
||||
is lower than that of newer entries.
|
||||
- This is useful when we order by creation, because then we get a
|
||||
consistent timeline.
|
||||
* Fix Grafana dashboard
|
||||
* Push Docker images to `Quay.io` (RedHat)
|
||||
* Bump `golang.org/x/*`, `github.com/lib/pq`, `mvdan.cc/xurls/v2` and `github.com/prometheus/client_golang` dependencies
|
||||
|
||||
Version 2.0.43 (March 16, 2023)
|
||||
-------------------------------
|
||||
|
||||
* Avoid XSS when opening a broken image due to unescaped ServerError in proxy handler (CVE-2023-27592)
|
||||
|
||||
Creating an RSS feed item with the inline description containing an `<img>` tag
|
||||
with a `srcset` attribute pointing to an invalid URL like
|
||||
`http:a<script>alert(1)</script>`, we can coerce the proxy handler into an error
|
||||
condition where the invalid URL is returned unescaped and in full.
|
||||
|
||||
This results in JavaScript execution on the Miniflux instance as soon as the
|
||||
user is convinced to open the broken image.
|
||||
|
||||
* Use `r.RemoteAddr` to check `/metrics` endpoint network access (CVE-2023-27591)
|
||||
|
||||
HTTP headers like `X-Forwarded-For` or `X-Real-Ip` can be easily spoofed. As
|
||||
such, it cannot be used to test if the client IP is allowed.
|
||||
|
||||
The recommendation is to use HTTP Basic authentication to protect the
|
||||
metrics endpoint, or run Miniflux behind a trusted reverse-proxy.
|
||||
|
||||
* Add HTTP Basic authentication for `/metrics` endpoint
|
||||
* Add proxy support for several media types
|
||||
* Parse feed categories from RSS, Atom and JSON feeds
|
||||
* Ignore empty link when discovering feeds
|
||||
* Disable CGO explicitly to make sure the binary is statically linked
|
||||
* Add CSS classes to differentiate between category/feed/entry view and icons
|
||||
* Add rewrite and scraper rules for `blog.cloudflare.com`
|
||||
* Add `color-scheme` to themes
|
||||
* Add new keyboard shortcut to toggle open/close entry attachments section
|
||||
* Sanitizer: allow `id` attribute in `<sup>` element
|
||||
* Add Indonesian Language
|
||||
* Update translations
|
||||
* Update Docker Compose examples:
|
||||
- Run the application in one command
|
||||
- Bring back the health check condition to `depends_on`
|
||||
- Remove deprecated `version` element
|
||||
* Update scraping rules for `ilpost.it`
|
||||
* Bump `github.com/PuerkitoBio/goquery` from `1.8.0` to `1.8.1`
|
||||
* Bump `github.com/tdewolff/minify/v2` from `2.12.4` to `2.12.5`
|
||||
* Bump `github.com/yuin/goldmark` from `1.5.3` to `1.5.4`
|
||||
* Bump `golang.org/x/*` dependencies
|
||||
|
||||
Version 2.0.42 (January 29, 2023)
|
||||
---------------------------------
|
||||
|
||||
* Fix header items wrapping
|
||||
* Add option to enable or disable double tap
|
||||
* Improve PWA display mode label in settings page
|
||||
* Bump `golang.org/x/*` dependencies
|
||||
* Update translations
|
||||
* Add scraping rule for `ilpost.it`
|
||||
* Update reading time HTML element after fetching the original web page
|
||||
* Add category feeds refresh feature
|
||||
|
||||
Version 2.0.41 (December 10, 2022)
|
||||
----------------------------------
|
||||
|
||||
* Reverted PR #1290 (follow the only link) because it leads to several panics/segfaults that prevent feed updates
|
||||
* Disable double-tap mobile gesture if swipe gesture is disabled
|
||||
* Skip integrations if there are no entries to push
|
||||
* Enable TLS-ALPN-01 challenge for ACME
|
||||
- This type of challenge works purely at the TLS layer and is compatible
|
||||
with SNI proxies. The existing HTTP-01 challenge support has been left
|
||||
as-is.
|
||||
* Preconfigure Miniflux for GitHub Codespaces
|
||||
* Updated `golang.org/x/net/*` dependencies
|
||||
|
||||
Version 2.0.40 (November 13, 2022)
|
||||
----------------------------------
|
||||
|
||||
* Update dependencies
|
||||
* Pin Postgres image version in Docker Compose examples to avoid unexpected upgrades
|
||||
* Make English and Spanish translation more consistent:
|
||||
- Use "Feed" everywhere instead of "Subscription"
|
||||
- Use "Entry" instead of "Article"
|
||||
* Allow Content-Type and Accept headers in CORS policy
|
||||
* Use dirs file for Debian package
|
||||
* Use custom home page in PWA manifest
|
||||
* Fix scraper rule that could be incorrect when there is a redirect
|
||||
* Improve web scraper to fetch the only link present as workaround to some landing pages
|
||||
* Add Matrix bot integration
|
||||
* Proxify images in API responses
|
||||
* Add new options in user preferences to configure sorting of entries in the category page
|
||||
* Remove dependency on `github.com/mitchellh/go-server-timing`
|
||||
* Add support for the `continuation` parameter and result for Google Reader API ID calls
|
||||
* Use automatic variable for build target file names
|
||||
* Add rewrite rule for `recalbox.com`
|
||||
* Improve Dutch translation
|
||||
|
||||
Version 2.0.39 (October 16, 2022)
|
||||
---------------------------------
|
||||
|
||||
* Add support for date filtering in Google Reader API item ID calls
|
||||
* Handle RSS entries with only a GUID permalink
|
||||
* Go API Client: Accept endpoint URLs ending with `/v1/`
|
||||
* CORS API headers: Allow `Basic` authorization header
|
||||
* Log feed URL when submitting a subscription that returns an error
|
||||
* Update `make run` command to execute migrations automatically
|
||||
* Add option to send only the URL to Wallabag
|
||||
* Do not convert anchors to absolute links
|
||||
* Add config option to use a custom image proxy URL
|
||||
* Allow zoom on mobile devices
|
||||
* Add scraping rules for `theverge.com`, `royalroad.com`, `swordscomic.com`, and `smbc-comics.com`
|
||||
* Add Ukrainian translation
|
||||
* Update `golang.org/x/*` dependencies
|
||||
* Bump `github.com/tdewolff/minify/v2` from `2.12.0` to `2.12.4`
|
||||
* Bump `github.com/yuin/goldmark` from `1.4.13` to `1.5.2`
|
||||
* Bump `github.com/lib/pq` from `1.10.6` to `1.10.7`
|
||||
|
||||
Version 2.0.38 (August 13, 2022)
|
||||
--------------------------------
|
||||
|
||||
* Rename default branch from master to main
|
||||
* Update GitHub Actions
|
||||
* Bump `github.com/prometheus/client_golang` from `1.12.2` to `1.13.0`
|
||||
* Fix some linter issues
|
||||
* Handle Atom links with a text/html type defined
|
||||
* Add `parse_markdown` rewrite function
|
||||
* Build RPM and Debian packages automatically using GitHub Actions
|
||||
* Add `explosm.net` scraper rule
|
||||
* Make default home page configurable
|
||||
* Add title attribute to entry links because text could be truncated
|
||||
* Highlight categories with unread entries
|
||||
* Allow option to order by title and author in API entry endpoint
|
||||
* Update Russian translation
|
||||
* Make reading speed user-configurable
|
||||
* Added translation for Hindi language used in India
|
||||
* Add rewrite rules for article URL before fetching content
|
||||
* Bump `github.com/tdewolff/minify/v2` from `2.11.7` to `2.12.0`
|
||||
* Support other repo owners in GitHub Docker Action
|
||||
* Proxify empty URL should not crash
|
||||
* Avoid stretched image if specified width is larger than Miniflux's layout
|
||||
* Add support for OPML files with several nested outlines
|
||||
* sanitizer: handle image URLs in `srcset` attribute with comma
|
||||
* Allow `width` and `height` attributes for `img` tags
|
||||
* Document that `-config-dump` command line argument shows sensitive info
|
||||
* Add System-V init service in contrib folder
|
||||
* Fix syntax error in `RequestBuilder.getCsrfToken()` method
|
||||
|
||||
Version 2.0.37 (May 27, 2022)
|
||||
-----------------------------
|
||||
|
||||
* Add rewrite rule to decode base64 content
|
||||
* Add Linkding integration
|
||||
* Add comment button to Telegram message
|
||||
* Add API endpoint to fetch unread and read counters
|
||||
* Fixes logic bug in Google Reader API sanity check
|
||||
* Reduce number of CORS preflight check to save network brandwidth
|
||||
* Add Espial integration
|
||||
* Allow API search for entries which are not starred
|
||||
* Try to use outermost element text when title is empty
|
||||
* Make swipe gestures feel more natural
|
||||
- Removes opacity transition when swiping an article read/unread
|
||||
- Adds "resistance" to the swiped entry when the 75px threshold is
|
||||
reached
|
||||
- Fixes an issue in which a swiped article couldn't be moved <15px
|
||||
* Add support for feed streams to Google Reader API IDs API
|
||||
* Fix invalid parsing of icon data URL
|
||||
* Add Traditional Chinese translation
|
||||
* Add distroless Docker image variant
|
||||
* Add Go 1.18 to GitHub Action
|
||||
* Bump `github.com/tdewolff/minify/v2` from `2.10.0` to `2.11`
|
||||
* Bump `github.com/prometheus/client_golang` from `1.12.1` to `1.12.2`
|
||||
* Bump `github.com/lib/pq` from `1.10.4` to `1.10.6`
|
||||
|
||||
Version 2.0.36 (March 8, 2022)
|
||||
------------------------------
|
||||
|
||||
* Gray out pagination buttons when they are not applicable
|
||||
* Use truncated entry description as title if unavailable
|
||||
* Do not fallback to InnerXML if XHTML title is empty
|
||||
* Add `+` keyboard shortcut for new subscription page
|
||||
* Add `(+)` action next to Feeds to quickly add new feeds
|
||||
* Fix unstar not working via Google Reader API
|
||||
* Remove circles in front of page header list items
|
||||
* Fix CSS hover style for links styled as buttons
|
||||
* Avoid showing `undefined` when clicking on read/unread
|
||||
* Add new keyboard shortcut `M` to toggle read/unread, and go to previous item
|
||||
* Add several icons to menus according to their roles
|
||||
* Add missing event argument to `onClick()` function call
|
||||
* Add links to scraper/rewrite/filtering docs when editing feeds
|
||||
* Add a rewrite rule for Castopod episodes
|
||||
* Fix regression: reset touch-item if not in `/unread` page
|
||||
* Add API endpoint to fetch original article
|
||||
* Show the category first in feed settings
|
||||
* Add pagination on top of all entries
|
||||
* Display Go version in "About" page
|
||||
* Bump `mvdan.cc/xurls/v2` from 2.3.0 to 2.4.0
|
||||
* Bump `github.com/prometheus/client_golang` from 1.11.0 to 1.12.1
|
||||
* Bump `github.com/tdewolff/minify/v2` from 2.9.28 to 2.10.0
|
||||
|
||||
Version 2.0.35 (January 21, 2022)
|
||||
---------------------------------
|
||||
|
||||
* Set `read-all` permission to `GITHUB_TOKEN` for GitHub Actions
|
||||
* Pin `jshint` version in linter job
|
||||
* Fix incorrect conversion between integer types
|
||||
* Add new GitHub Actions workflows: CodeQL and Scorecards analysis
|
||||
* Handle Atom feeds with space around CDATA
|
||||
* Bump `github.com/tdewolff/minify/v2` from 2.9.22 to 2.9.28
|
||||
* Add Documentation directive to Systemd service
|
||||
* Do not reset `touch-item` if successfully swiped
|
||||
* Add support for multiple authors in Atom feeds
|
||||
* Omit `User-Agent` header in image proxy to avoid being blocked
|
||||
* Use custom feed user agent to fetch website icon
|
||||
* Make default Invidious instance configurable
|
||||
* Add new rewrite rule `add_youtube_video_from_id` to add Youtube videos in Quanta articles
|
||||
* Add scrape and rewrite rules for `quantamagazine.org`
|
||||
* Expose entry unshare link in the entry and list views
|
||||
* Add Google Reader API implementation (experimental)
|
||||
* Add `Content-Security-Policy` header to feed icon and image proxy endpoints
|
||||
- SVG images could contain Javascript. This CSP blocks inline script.
|
||||
- Feed icons are served using `<img>` tag and Javascript is not interpreted.
|
||||
* Add Finnish translation
|
||||
* Add scraper rule for `ikiwiki.iki.fi`
|
||||
* Remove `SystemCallFilter` from `miniflux.service`
|
||||
* Fix minor typo in French translation
|
||||
|
||||
Version 2.0.34 (December 16, 2021)
|
||||
----------------------------------
|
||||
|
||||
* Add rewrite rule for comics website http://monkeyuser.com
|
||||
* Add `<head>` tag to OPML export
|
||||
* Tighten Systemd sandboxing and update comments in `miniflux.service`
|
||||
* Add `RuntimeDirectory` to Systemd service
|
||||
* Order disabled feeds at the end of the list
|
||||
* Add support for theme color based on preferred color scheme of OS
|
||||
* Bump `github.com/lib/pq` from 1.10.3 to 1.10.4
|
||||
* Bump `github.com/PuerkitoBio/goquery` from 1.7.1 to 1.8.0
|
||||
* Fix typos in `model/icon.go`
|
||||
* Add `data-srcset` support to `add_dynamic_image rewrite` rewrite rule
|
||||
* Fix Docker Compose example files compatibility to v3
|
||||
* Added the `role="article"` to `<article>` elements for better accessibility with screen readers
|
||||
* Redact secrets shown on the about page
|
||||
* Handle `srcset` images with no space after comma
|
||||
* Hide the logout link when using auth proxy
|
||||
* Fix wrong CSS variable
|
||||
* Change `-config-dump` command to use `KEY=VALUE` format
|
||||
|
||||
Version 2.0.33 (September 25, 2021)
|
||||
-----------------------------------
|
||||
|
||||
* Build RPM and Debian package with PIE mode enabled
|
||||
* Add CSS rule to hide `<template>` tag in old browsers
|
||||
* Bump `github.com/tdewolff/minify/v2 from 2.9.21 to 2.9.22`
|
||||
* Bump `github.com/lib/pq from 1.10.2 to 1.10.3`
|
||||
* Remove `RequestURI()` hack
|
||||
* Improve `zh_CN` translation
|
||||
* Add ability to change entry sort order in the UI
|
||||
* Add minor improvements in integration package
|
||||
* Add Telegram integration
|
||||
* Add rewrite rule to remove DOM elements
|
||||
* Add proxy argument to `scraper.Fetch()`
|
||||
* Add mime type `application/feed+json` to discover JSON Feed v1.1
|
||||
* Update scraper rule for `theregister.com`
|
||||
* Add Go 1.17 to GitHub Actions
|
||||
* Display option to hide feed only when category is not already hidden
|
||||
* Add option to hide feeds from the global Unread list
|
||||
|
||||
Version 2.0.32 (August 14, 2021)
|
||||
--------------------------------
|
||||
|
||||
* Bump `github.com/tdewolff/minify/v2` from 2.9.17 to 2.9.21
|
||||
* Bump `mvdan.cc/xurls/v2` from 2.2.0 to 2.3.0
|
||||
* Bump `github.com/PuerkitoBio/goquery` from 1.6.1 to 1.7.1
|
||||
* Bump `github.com/prometheus/client_golang` from 1.10.0 to 1.11.0
|
||||
* Add `/rss/` to the list of well known URLs during feed discovery
|
||||
* Use `authors` entry for JSON 1.1 feeds
|
||||
* Added Greek translation
|
||||
* Added the ability to mark an entire category as read in the web ui
|
||||
* Added "in" in "logged in" for en_US `tooltip.logged_user`
|
||||
* Added option to hide categories from the global unread list
|
||||
* Show "saving" labels for entry status button
|
||||
* Golang client: Try to parse response body on `InternalServerError` errors
|
||||
* contrib: Add support for a $MINIFLUX_IMAGE env var in docker-compose
|
||||
* contrib: Bump docker-compose version to 3.4
|
||||
|
||||
Version 2.0.31 (June 6, 2021)
|
||||
-----------------------------
|
||||
|
||||
* Expose comments_url entry field in Golang API client
|
||||
* Use unique file names for cache busting instead of query string
|
||||
* Highlight and sort feeds with unread entries in feeds list
|
||||
* Mark items as read on click/middle click of external links
|
||||
* Fix: Firefox on Windows does not show the active link as bold
|
||||
* Avoid extra HTTP request for fetching custom stylesheet
|
||||
* Remove invalid CSRF HTML meta tag
|
||||
* Add lang attribute to root HTML tag
|
||||
* Use runes instead of bytes to truncate JSON feed titles (avoid breaking Unicode strings)
|
||||
* Expose changed_at time through the API
|
||||
* Add new config option CLEANUP_ARCHIVE_BATCH_SIZE
|
||||
* Add new option DATABASE_CONNECTION_LIFETIME
|
||||
* Add database stats to Prometheus exporter
|
||||
* Add Systemd watchdog
|
||||
* Avoid custom stylesheet to be cached by third-party CDN
|
||||
* Update a shared entry label translation in zh_CN
|
||||
* Bump github.com/tdewolff/minify/v2 from 2.9.16 to 2.9.17
|
||||
* Bump github.com/lib/pq from 1.10.1 to 1.10.2
|
||||
|
||||
Version 2.0.30 (May 7, 2021)
|
||||
----------------------------
|
||||
|
||||
* Security fix: any user can delete any feed (Regression introduced in commit 51fb949)
|
||||
* Fix password reset via CLI
|
||||
* Increase default batch size value
|
||||
* Handle RSS feed title with encoded Unicode entities
|
||||
* Show number of unread per category in category list instead of number of feeds
|
||||
* Bump github.com/lib/pq from 1.10.0 to 1.10.1
|
||||
* Filtering doesn't work when selecting from multiple found feeds
|
||||
* Bump github.com/tdewolff/minify/v2 from 2.9.15 to 2.9.16
|
||||
* Use an appropriate color for visited links on dark theme
|
||||
* Fix typo in reader/json/doc.go
|
||||
* Create SECURITY.md
|
||||
* Setup golangci-lint Github Action
|
||||
* Add per feed cookies option
|
||||
* Bump github.com/prometheus/client_golang from 1.9.0 to 1.10.0
|
||||
* Bump github.com/tdewolff/minify/v2 from 2.9.13 to 2.9.15
|
||||
|
||||
Version 2.0.29 (Mar 21, 2021)
|
||||
-----------------------------
|
||||
|
||||
* Miniflux requires at least Go 1.16 now
|
||||
* Improved support of Atom text constructs
|
||||
- Improve handling of CDATA in text elements
|
||||
- Omit XHTML root element because it should not be part of the content
|
||||
- Fix incorrect parsing of HTML elements
|
||||
* Handle RDF feed with HTML encoded entry title
|
||||
* Add Turkish language
|
||||
* Improve deletion of feeds with lots of entries
|
||||
* Add support of Systemd readiness notification using the sd_notify protocol
|
||||
* Remove feed_icons service worker cache because it's causing more problems than it solves (and HTTP cache seems faster)
|
||||
* Add basic PWA offline page
|
||||
- Add basic offline mode when using the service worker
|
||||
- Starting in Chrome 93, offline mode is going to be a requirement to install the PWA
|
||||
* Replace icon for "Add to home screen" button
|
||||
* Use SVG icons for "toast" notifications
|
||||
* Use SVG sprite for icons instead of inline elements
|
||||
* Reset scroll position on mark page as read
|
||||
* Add link to mark all feed entries as read
|
||||
* Make web app display mode configurable (The change is visible after reinstalling the web app)
|
||||
* Handle RSS feeds with CDATA in author item element
|
||||
* Add read time on the article page
|
||||
* Avoid showing a broken image when there is no feed icon
|
||||
* Add option to allow self-signed or invalid certificates
|
||||
* Add new config option to scrape YouTube's website to get video duration as read time (disabled by default)
|
||||
* Send full article content to Wallabag
|
||||
* Add more extensive health check support
|
||||
- Improve endpoint to test database connection
|
||||
- Add new cli argument: -healthcheck
|
||||
- Update Docker Compose examples
|
||||
* Update iframe "allow list" to support Bilibili videos
|
||||
* Remove completely generated files and use embed package to bundle JS/CSS/Images/Translations files into the final binary
|
||||
* Remove deprecated io/ioutil package
|
||||
* Show Postgres version in "About" page
|
||||
|
||||
Version 2.0.28 (Feb 15, 2021)
|
||||
-----------------------------
|
||||
|
||||
* Add HTTP header "Referrer-Policy: no-referrer"
|
||||
* Handle entry title with double encoded entities
|
||||
* Add Open Containers annotations to Docker image
|
||||
* Remove iframe inner HTML contents (iframe element never has fallback content)
|
||||
* Update date parser to fix another time zone issue
|
||||
* Update German translation for blocklist and keeplist
|
||||
* Validate Keep list and Block list rules syntax
|
||||
* Add support for IPv6 with zone index
|
||||
* Allow images with data URLs
|
||||
* Limit full-text search indexation to first 500K characters (tsvector has a size limit of 1MB)
|
||||
* Change PWA display mode to standalone
|
||||
* ETag value is not set correctly in HTTP client (regression)
|
||||
* Add database backed Let's Encrypt certificate cache
|
||||
* Add global option POLLING_PARSING_ERROR_LIMIT
|
||||
* Update systemd service file comments to use `systemctl edit` for editing
|
||||
* Update Go version to 1.15 in go.mod
|
||||
* Don't discard the "Fetch via Proxy" option
|
||||
* Update man page to show the default values
|
||||
* Add PostgreSQL indices
|
||||
* Add API endpoints to get feeds and entries of a category
|
||||
* Create feed query builder
|
||||
* Bump github.com/PuerkitoBio/goquery from 1.6.0 to 1.6.1
|
||||
* Show global options in the about page
|
||||
* Update man page to mention -1 can be used for CLEANUP_ARCHIVE_* options
|
||||
|
||||
Version 2.0.27 (Jan 9, 2021)
|
||||
----------------------------
|
||||
|
||||
* Add spellcheck="false" to input fields
|
||||
* Refactoring of entry, feed, category, and user validation
|
||||
* Avoid stripping tags for entry title
|
||||
* Add the possibility to subscribe to feeds with the Android Share menu
|
||||
* API improvements:
|
||||
- Change feed creation request to allow setting most fields via API
|
||||
- Allow regular users to change settings via API
|
||||
- Make user fields editable via API
|
||||
- Renaming non-existent category via API should return a 404
|
||||
* Update Systemd service file:
|
||||
- Add capability CAP_NET_BIND_SERVICE (allow the process to listen on privileged ports)
|
||||
- Enable a private /tmp for $CERT_CACHE (required when using Let's Encrypt)
|
||||
* Update read/star icons to SVGs
|
||||
* Add autocomplete="username" to HTML forms
|
||||
* Improve user mass delete to use fewer Goroutines
|
||||
* Use SQL transaction when creating user sessions and users
|
||||
* Remove extra column (HSTORE field) from users table and migrate key/value pairs to specific columns
|
||||
* Bump github.com/prometheus/client_golang from 1.8.0 to 1.9.0
|
||||
* Bump github.com/lib/pq from 1.8.0 to 1.9.0
|
||||
* Add styles for <abbr> HTML tag
|
||||
* Refactor SQL migrations:
|
||||
- Avoid embedding SQL files into binary
|
||||
- Allow more flexible changes by using Go functions
|
||||
* Add Server-Timing header to unread page
|
||||
* Show correct User Agent in input placeholders
|
||||
* Add autocomplete attribute to login form
|
||||
* Add Grafana dashboard in contrib folder
|
||||
|
||||
Version 2.0.26 (Dec 5, 2020)
|
||||
----------------------------
|
||||
|
||||
* Use created_at instead of published_at for archiving entries
|
||||
* Add created_at field for entries
|
||||
* Handle invalid feeds with relative URLs
|
||||
* Add API routes for "mark all as read"
|
||||
* Add support for setting a global default User-Agent
|
||||
* Add rewrite rule "replace" for custom search and replace
|
||||
* Calculate reading time during feed processing
|
||||
* Handle various invalid dates
|
||||
* systemd: keep /run writeable
|
||||
* debian package: add missing post-install script
|
||||
* Do not follow redirects when trying known feed URLs
|
||||
* Trim spaces around icon URLs
|
||||
* Reinstate EXPOSE instruction in Dockerfile
|
||||
* Update German and Portuguese translations
|
||||
|
||||
Version 2.0.25 (Nov 3, 2020)
|
||||
----------------------------
|
||||
|
||||
* Rename "original" link to be more explicit
|
||||
* Do not escape HTML for Atom 1.0 text content during parsing (Avoid HTML entities issues)
|
||||
* Do not use charset.NewReader if the body is a valid UTF-8 document
|
||||
* Restore the ability to use a proxy for all HTTP requests (see https://golang.org/pkg/net/http/#ProxyFromEnvironment)
|
||||
* Show Git commit in about page
|
||||
* Publish Docker images to GitHub Container Registry
|
||||
* Added few Docker Compose examples in contrib folder
|
||||
* Added Ansible Role + Playbook for Miniflux in contrib folder
|
||||
* Add rewrite rule to use noscript content for images rendered with Javascript
|
||||
* Bump github.com/prometheus/client_golang from 1.7.1 to 1.8.0
|
||||
* Update contributor link and Godoc badge for API client
|
||||
* Move Debian package builder to main repository
|
||||
* Move RPM build files to main repository
|
||||
* Add GitHub Action to generate Docker images
|
||||
* Build multi-platform images with Docker Buildx
|
||||
* Add keyboard shortcut to scroll current item to the top
|
||||
* Add feed filters (Keeplist and Blocklist)
|
||||
* Do not proxy image with a data URL
|
||||
* Bump github.com/PuerkitoBio/goquery from 1.5.1 to 1.6.0
|
||||
* Proxify articles crawled manually
|
||||
* Proxify images defined in srcset attribute
|
||||
* Remove plaintext Fever password from database
|
||||
* Add keyboard shortcut to jump to an item's feed page
|
||||
* Add option for swipe gesture on entries on mobile
|
||||
|
||||
Version 2.0.24 (Oct 3, 2020)
|
||||
----------------------------
|
||||
|
||||
* Add rewrite rule to fix Medium.com images
|
||||
* Update sanitizer to support responsive images:
|
||||
- Add support for picture HTML tag
|
||||
- Add support for srcset, media, and sizes attributes to img and source tags
|
||||
* Enhance man page formatting
|
||||
* Add Prometheus exporter
|
||||
* Remove dependency on global config options in HTTP client
|
||||
* API:
|
||||
- Avoid database lookup if empty credentials are provided
|
||||
- Add the possibility to filter entries by category ID
|
||||
- Add the possibility to filter entries by a list of statuses
|
||||
* Add Feed ID in worker error logs
|
||||
* Tweak default HTTP client transport timeout values to reduce the number of file descriptors
|
||||
* CSS tweaks and optimizations:
|
||||
- Prevent sub and sup from affecting line-height
|
||||
- Set touch-action on .items to prevent browser navigation
|
||||
- Move font-family specific CSS to the appropriate file
|
||||
- Update primary font-family for UI to be even more compatible with various operating systems
|
||||
- Make .entry-content font-weight variable depending on font-family used
|
||||
* Avoid Javascript minifier to break keyboard shortcuts
|
||||
* Rename service worker script to avoid being blocked by uBlock
|
||||
* Update date parser to handle Pacific Daylight Time in addition to Pacific Standard Time
|
||||
* Create index to speed up bookmark page
|
||||
* Do not try to update a duplicated feed after a refresh
|
||||
* Use a transaction to refresh and create entries
|
||||
* Speed up entries clean up with an index and a goroutine
|
||||
* Avoid the accumulation of enclosures by keeping only what is referenced in the feed
|
||||
* Add workarounds for parsing an invalid date
|
||||
* Archive older entries first
|
||||
* Update API client to support more filters
|
||||
* Avoid duplication between get feed entries and get entries API endpoints
|
||||
* Enable strict slash to avoid a page not found (404) when using a trailing slash in the URLs
|
||||
* Add a submit button to each section of the integration page
|
||||
* Reload page after making page as read when showing unread entries
|
||||
* Add option to archive unread entries
|
||||
* Add option to enable maintenance mode
|
||||
* Add HTTP proxy option for subscriptions
|
||||
* Make add_invidious_video rule applicable for different invidious instances
|
||||
* Fix reading time for jp, ko and zh languages
|
||||
* Update POLLING_SCHEDULER description in man page
|
||||
* Bump gorilla/mux from 1.7.4 to 1.8.0
|
||||
* Add link to mark a feed as read
|
||||
|
||||
Version 2.0.23 (Aug 15, 2020)
|
||||
-----------------------------
|
||||
|
||||
* Try known URLs when discovering subscriptions
|
||||
* Add workarounds to find YouTube channel feeds (YouTube doesn't expose RSS links anymore for new-style URLs)
|
||||
* Increase HTTP server timeout values
|
||||
* Use stdlib constants for HTTP methods instead of strings
|
||||
* Add support for RTL feed content
|
||||
* Ignore <media:title> to avoid overriding the default title if they are different
|
||||
* Add support for secret keys exposed as a file (useful for containerized environments)
|
||||
* Display recent entries first in search results
|
||||
* Do not archive shared items
|
||||
* Add option to change the number of entries per page
|
||||
* Add Brazilian Portuguese (pt_BR) translation
|
||||
* Add reading time for entries
|
||||
* Redirect to login page if CSRF token is expired
|
||||
* Fever API:
|
||||
- Use getEntryIDs instead of getEntries to reduce memory consumption
|
||||
- Fix max_id argument logic to follow the specs
|
||||
- Improve logging
|
||||
- Do not send articles to external services when unsaving an item
|
||||
- Create index to speed up API calls
|
||||
- Log client IP in middleware
|
||||
* API client: Do not return body for response with no content
|
||||
* REST API:
|
||||
- Delete users asynchronously (Deleting large users might lock the tables)
|
||||
- Add CORS support
|
||||
* Align entry actions to the left
|
||||
- Attempt to avoid awkward alignment on smartphone screens
|
||||
- Keep the read/star actions aligned to the left
|
||||
- Remove CSS flex to allow easier override with custom CSS
|
||||
* Upgrade Postgres client library
|
||||
* Upgrade CI checks to Go 1.15
|
||||
|
||||
Version 2.0.22 (Jun 19, 2020)
|
||||
-----------------------------
|
||||
|
||||
* Remove child-src CSP policy (deprecated)
|
||||
* Add /version endpoint
|
||||
* Add the ability to use custom css
|
||||
* Handle more invalid dates
|
||||
* Add CSS styles for textarea
|
||||
* Add index to speed up slow query
|
||||
* Speed up feed list page rendering
|
||||
* Add alternative scheduler based on the number of entries
|
||||
* Setup Dependabot on GitHub
|
||||
* Update Docker image to Alpine 3.12
|
||||
* Add feed option to ignore HTTP cache
|
||||
* Fix some Italian and German translations
|
||||
* Added scraper rule for RayWenderlich.com, TheOatmeal.com, financialsamurai.com, dilbert.com and turnoff.us
|
||||
* Replace link to categories by a link to the list of entries in "Category > Feeds" page
|
||||
* Change feed title to a link to the original website
|
||||
* Add icons to feeds and categories list
|
||||
* Update dependencies and remove vendor folder
|
||||
|
||||
Version 2.0.21 (Mar 28, 2020)
|
||||
-----------------------------
|
||||
|
||||
* Add SVG icons to entry actions
|
||||
* Add support for Invidious
|
||||
- Embed Invidious player for invidio.us feeds
|
||||
- Add new rewrite rule to use Invidious player for Youtube feeds
|
||||
* Check during application startup if the database schema is up to date
|
||||
* Change default theme for public pages to "System Serif"
|
||||
* Add feature to share an article (create a public link of a feed entry)
|
||||
* Fix SQL injection in full-text search rank ordering
|
||||
* Add generic OpenID Connect provider (OAuth2)
|
||||
* Use more secure TLS configuration for autocert server (increase SSL Labs score from B to A+)
|
||||
* Add feature to create per-application API Keys
|
||||
* Add Go 1.14 to GitHub Actions
|
||||
* Add scraper rule for wdwnt.com
|
||||
* Add API client function to refresh all feeds
|
||||
* Add API endpoint to refresh all feeds
|
||||
* Add Auth Proxy authentication
|
||||
* Use rel=prev/next on pagination links
|
||||
|
||||
Version 2.0.20 (Feb 15, 2020)
|
||||
-----------------------------
|
||||
|
||||
* Add Japanese translation
|
||||
* History: show entries in the order in which they were read
|
||||
* Add button to add to Home screen
|
||||
* Ignore enclosures without URL
|
||||
* Correct spelling of "toggle"
|
||||
* List view: align information to the left side, and the actionable buttons to the right
|
||||
* Redirect to /unread when getting a 404 for an unread expired entry
|
||||
* Do not advance to the next item when using the 'v' shortcut on the list of starred items
|
||||
* Wrap around when navigating with keyboard shortcuts on a list view
|
||||
* Remove unused Feed.Entries and Entry.Category from API client
|
||||
* Add comments link keyboard shortcut
|
||||
* Allow application/xhtml+xml links as comments URL in Atom replies
|
||||
* Allow only absolute URLs in comments URL
|
||||
* Use internal XML workarounds to detect feed format
|
||||
* Make menu consistent across feed pages
|
||||
* Make sure external URLs are not encoded incorrectly by Go template engine
|
||||
* Make sure whitelisted URI schemes are handled properly by the sanitizer
|
||||
* Use white background for favicon (Improve legibility when using a dark theme)
|
||||
* Remove dependency on Sed to build Docker images
|
||||
* Normalize URL query string before executing HTTP requests
|
||||
* Improve Dublin Core support for RDF feeds
|
||||
* Improve Podcast support (iTunes and Google Play feeds)
|
||||
* Add support for Atom 0.3
|
||||
* Add support for Atom "replies" link relation
|
||||
* Return outer HTML when scraping elements
|
||||
* Update scraper rule for "Le Monde"
|
||||
* Filter valid XML characters for UTF-8 XML documents before decoding
|
||||
* Trim spaces for RDF entry links
|
||||
|
||||
Version 2.0.19 (Dec 1, 2019)
|
||||
----------------------------
|
||||
|
||||
* Add shortcut "V" to open original link in current tab
|
||||
* Add the possibility to add rules during feed creation
|
||||
* Wrap attachments into <details> disclosure element
|
||||
* Show attachment size on entry page
|
||||
* Add support of RSS Media elements (group, description, peer link, and thumbnails)
|
||||
* Add rewrite functions: convert_text_link and nl2br
|
||||
* Add scraper rule for openingsource.org
|
||||
* Add Makefile target to build only amd64 Docker image
|
||||
* Make sure to remove integration settings when removing a user
|
||||
* Add API parameter to filter entries by category
|
||||
* Display list of feeds per category
|
||||
* Show the number of read and unread entries for each feed
|
||||
* Make sure settings menu is consistent
|
||||
* Remove fixed table-layout for entry content
|
||||
* Update autocert lib because ACME v1 is EOL
|
||||
* Do not lighten blockquote font color
|
||||
* Update de_DE translation
|
||||
* Send a response when changing status of removed entries in Fever API
|
||||
* Add meta tag to disable Google Translate
|
||||
* Improve storage module
|
||||
* Improve XML decoder to remove illegal characters
|
||||
* Compare Fever token case-insensitively
|
||||
* Make sure integration tests are marked as failed in Github Actions
|
||||
* Add new formats to date parser
|
||||
* Add notification message when using keyboard shortcuts: f, s, and m.
|
||||
* Avoid keyboard shortcuts to conflict with Firefox’s "Find as you type" feature
|
||||
|
||||
Version 2.0.18 (Sep 25, 2019)
|
||||
-----------------------------
|
||||
|
||||
* Add Docker image variant for arm32v7
|
||||
* Add theme variants
|
||||
- Use CSS variables instead of inherence
|
||||
- Rename default theme to "Light - Serif"
|
||||
- Rename Black theme to "Dark - Serif"
|
||||
- Rename "Sans-Serif" theme to "Light - Sans Serif"
|
||||
- Add "System" theme that use system preferences: Dark or Light
|
||||
- Add Serif and Sans-Serif variants for each color theme
|
||||
* Avoid constraint error when having duplicate entries during feed creation
|
||||
* Disable strict XML parsing
|
||||
* Ignore invalid content type
|
||||
* Update man page
|
||||
* Replace Travis by GitHub Actions
|
||||
* Rename cleanup config variables and deprecate old ones
|
||||
- CLEANUP_FREQUENCY_HOURS instead of CLEANUP_FREQUENCY
|
||||
- CLEANUP_ARCHIVE_READ_DAYS instead of ARCHIVE_READ_DAYS
|
||||
* Make configurable the number of days to remove old sessions
|
||||
* Add native lazy loading for images and iframes
|
||||
* Do not buffer responses in the image proxy
|
||||
* Update dependencies
|
||||
* Add Go 1.13 to test matrix
|
||||
* Replace link border by outline to avoid slight content shift
|
||||
* New rewrite function: add_mailto_subject
|
||||
* Import OPML from URL
|
||||
* Fix HTML injection in addImageTitle
|
||||
* Accept HTML entities when parsing XML
|
||||
|
||||
Version 2.0.17 (Aug 3, 2019)
|
||||
----------------------------
|
||||
|
||||
* Update Docker image to Alpine Linux 3.10.1
|
||||
* Pass auth header to manifest request (crossorigin attribute)
|
||||
* Sort feed categories before serialization
|
||||
* Fix syntax errors in man page
|
||||
* Add .search margin-right
|
||||
* Ask for confirmation before flushing history, marking page as read, and mark all as read
|
||||
* Add option to disable feeds
|
||||
|
||||
Version 2.0.16 (Jun 8, 2019)
|
||||
----------------------------
|
||||
|
||||
* Add option to toggle date/time in log messages
|
||||
* Add optional config file parser in addition to environment variables
|
||||
* Make HTTP Client timeout and max body size configurable
|
||||
* Refactor config package:
|
||||
- Parse configuration only once during startup time
|
||||
- Store configuration values in a global variable
|
||||
* Flip behavior of j and k keyboard shortcuts
|
||||
* Bump Postgresql client library to v1.1.1 to bring in SCRAM-SHA-256 authentication
|
||||
* Add option to enable/disable keyboard shortcuts
|
||||
* Add missing translation
|
||||
* Improve page reload when showing unread/all entries:
|
||||
- Show only unread entries = refresh current page
|
||||
- Show all entries = go to next page
|
||||
* Always display feed entries even when there is a feed error
|
||||
* Use loading label instead of saving when submitting login form
|
||||
* Add OPML v1 support during importation
|
||||
* Add 'allow-popups' to iframe sandbox permissions
|
||||
|
||||
Version 2.0.15 (Mar 16, 2019)
|
||||
-----------------------------
|
||||
|
||||
* Move Dockerfile to main repo
|
||||
* Change location of the binary from /usr/local/bin to /usr/bin in Docker image
|
||||
* Add double tap detection for next/previous page navigation
|
||||
* Allow users to disable auto-remove
|
||||
* Make parser compatible with Go 1.12
|
||||
* Add Golang 1.12 to CI
|
||||
* Use feed ID instead of user ID to check entry URLs presence
|
||||
* Fix typo in stylesheet
|
||||
* Sort search results by relevance
|
||||
* Use preferably the published date for Atom feeds
|
||||
* Add Spanish translation
|
||||
* Rename session cookies
|
||||
* Handle the case when application session is expired and not user session
|
||||
|
||||
Version 2.0.14 (Jan 13, 2019)
|
||||
-----------------------------
|
||||
|
||||
* Only attempt to change password if the confirmation field is filled in (Firefox)
|
||||
* Remove URL from client user agent
|
||||
* Make the feed list order case-insensitive
|
||||
* Handle XHTML Summary elements for Atom feeds
|
||||
* Make UTF-8 the default encoding for XML feeds
|
||||
* Add more targets to Makefile
|
||||
* Add -mod=vendor in Makefile
|
||||
* Move health check endpoint from ui package to httpd service
|
||||
* Add workaround for non GMT dates (RFC822, RFC850, and RFC1123)
|
||||
* Make sure `<strong>` elements are bold
|
||||
* Show scrollbars only when necessary for <pre> elements
|
||||
* Add Italian translation
|
||||
* Allow to switch between unread only and all entries on category/feed views
|
||||
* Add function storage.UpdateFeedError()
|
||||
* Add BBC News scraping rule
|
||||
* Ignore JSON feeds from EnsureUnicode()
|
||||
* Preserve category selection when no feed is found
|
||||
* Update XML encoding regex to take single quotes into consideration
|
||||
* Send cli errors to stderr
|
||||
* Update dependencies
|
||||
* Make password prompt compatible with Windows
|
||||
* Make configurable the number of days to archive read items
|
||||
* Change log level to debug when starting workers
|
||||
* Do not show $DATABASE_URL warning when showing application info
|
||||
* Move image proxy filter to template functions
|
||||
* Update scraper rule for lemonde.fr
|
||||
* Refactor manual entry scraper
|
||||
* Apply rewriter rules on manual "Fetch Original Content"
|
||||
* Add Makefile target for current OS and architecture
|
||||
* Improve Makefile
|
||||
|
||||
Version 2.0.13 (Nov 25, 2018)
|
||||
-----------------------------
|
||||
|
||||
* Add man page
|
||||
* Add support for Systemd Socket Activation (experimental)
|
||||
* Add the possibility to listen on Unix sockets
|
||||
* Add config options to disable HTTP and scheduler services
|
||||
* Archive more read entries in cleanup job
|
||||
* Change default database connection string (backward compatible)
|
||||
* Improve logging messages in ui package
|
||||
* Improve overall Simplified Chinese translations
|
||||
* Improve time since post date displays:
|
||||
- "15 days" now is "15 days" rather than "3 weeks" ago
|
||||
- "32 days" is now "1 month" rather than "2 months" ago
|
||||
- "366 days" is now "1 year" rather than "2 years" ago
|
||||
* Allow the scraper to parse XHTML documents
|
||||
* Remove charset=utf-8 from JSON responses
|
||||
* Ignore hotkeys containing Control, Alt or Meta keys
|
||||
* Handle more encoding conversion edge cases
|
||||
* Disable go test caching
|
||||
* Avoid duplication of ldflags in Makefile
|
||||
* Fix wrong translation key for category pages
|
||||
* Code refactoring:
|
||||
- Simplify application HTTP middlewares
|
||||
- Replace daemon and scheduler package with service package
|
||||
- Move UI middlewares and routes to ui package
|
||||
- Move API middleware and routes to api package
|
||||
- Move Fever middleware and routes to fever package
|
||||
|
||||
Version 2.0.12 (Oct 26, 2018)
|
||||
-----------------------------
|
||||
|
||||
|
||||
@@ -1,70 +1,167 @@
|
||||
APP := miniflux
|
||||
VERSION=$(shell git rev-parse --short HEAD)
|
||||
BUILD_DATE=`date +%FT%T%z`
|
||||
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
||||
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
|
||||
APP := miniflux
|
||||
DOCKER_IMAGE := miniflux/miniflux
|
||||
VERSION := $(shell git describe --tags --abbrev=0)
|
||||
COMMIT := $(shell git rev-parse --short HEAD)
|
||||
BUILD_DATE := `date +%FT%T%z`
|
||||
LD_FLAGS := "-s -w -X 'miniflux.app/version.Version=$(VERSION)' -X 'miniflux.app/version.Commit=$(COMMIT)' -X 'miniflux.app/version.BuildDate=$(BUILD_DATE)'"
|
||||
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
||||
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
|
||||
DEB_IMG_ARCH := amd64
|
||||
|
||||
.PHONY: linux-amd64 linux-armv8 linux-armv7 linux-armv6 linux-armv5 darwin freebsd openbsd build run clean test lint integration-test clean-integration-test
|
||||
export PGPASSWORD := postgres
|
||||
|
||||
.PHONY: \
|
||||
miniflux \
|
||||
linux-amd64 \
|
||||
linux-arm64 \
|
||||
linux-armv7 \
|
||||
linux-armv6 \
|
||||
linux-armv5 \
|
||||
linux-x86 \
|
||||
darwin-amd64 \
|
||||
darwin-arm64 \
|
||||
freebsd-amd64 \
|
||||
freebsd-x86 \
|
||||
openbsd-amd64 \
|
||||
openbsd-x86 \
|
||||
netbsd-x86 \
|
||||
netbsd-amd64 \
|
||||
windows-amd64 \
|
||||
windows-x86 \
|
||||
build \
|
||||
run \
|
||||
clean \
|
||||
test \
|
||||
lint \
|
||||
integration-test \
|
||||
clean-integration-test \
|
||||
docker-image \
|
||||
docker-image-distroless \
|
||||
docker-images \
|
||||
rpm \
|
||||
debian \
|
||||
debian-packages
|
||||
|
||||
miniflux:
|
||||
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
|
||||
|
||||
linux-amd64:
|
||||
@ go generate
|
||||
@ GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X 'miniflux.app/version.Version=$(VERSION)' -X 'miniflux.app/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-amd64 main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
linux-armv8:
|
||||
@ go generate
|
||||
@ GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X 'miniflux.app/version.Version=$(VERSION)' -X 'miniflux.app/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv8 main.go
|
||||
linux-arm64:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
linux-armv7:
|
||||
@ go generate
|
||||
@ GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X 'miniflux.app/version.Version=$(VERSION)' -X 'miniflux.app/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv7 main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
linux-armv6:
|
||||
@ go generate
|
||||
@ GOOS=linux GOARCH=arm GOARM=6 go build -ldflags="-s -w -X 'miniflux.app/version.Version=$(VERSION)' -X 'miniflux.app/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv6 main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
linux-armv5:
|
||||
@ go generate
|
||||
@ GOOS=linux GOARCH=arm GOARM=5 go build -ldflags="-s -w -X 'miniflux.app/version.Version=$(VERSION)' -X 'miniflux.app/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv5 main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
darwin:
|
||||
@ go generate
|
||||
@ GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X 'miniflux.app/version.Version=$(VERSION)' -X 'miniflux.app/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-darwin-amd64 main.go
|
||||
darwin-amd64:
|
||||
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
freebsd:
|
||||
@ go generate
|
||||
@ GOOS=freebsd GOARCH=amd64 go build -ldflags="-s -w -X 'miniflux.app/version.Version=$(VERSION)' -X 'miniflux.app/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-freebsd-amd64 main.go
|
||||
darwin-arm64:
|
||||
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
openbsd:
|
||||
@ go generate
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags="-s -w -X 'miniflux.app/version.Version=$(VERSION)' -X 'miniflux.app/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-openbsd-amd64 main.go
|
||||
freebsd-amd64:
|
||||
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
build: linux-amd64 linux-armv8 linux-armv7 linux-armv6 linux-armv5 darwin freebsd openbsd
|
||||
openbsd-amd64:
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
windows-amd64:
|
||||
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
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)-$@ main.go
|
||||
|
||||
run:
|
||||
@ go generate
|
||||
@ go run main.go -debug
|
||||
@ LOG_DATE_TIME=1 DEBUG=1 RUN_MIGRATIONS=1 go run main.go
|
||||
|
||||
clean:
|
||||
@ rm -f $(APP)-*
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb
|
||||
|
||||
test:
|
||||
go test -cover -race ./...
|
||||
go test -cover -race -count=1 ./...
|
||||
|
||||
lint:
|
||||
@ golint -set_exit_status ${PKG_LIST}
|
||||
golint -set_exit_status ${PKG_LIST}
|
||||
|
||||
integration-test:
|
||||
psql -U postgres -c 'drop database if exists miniflux_test;'
|
||||
psql -U postgres -c 'create database miniflux_test;'
|
||||
DATABASE_URL=$(DB_URL) go run main.go -migrate
|
||||
DATABASE_URL=$(DB_URL) ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go -create-admin
|
||||
go build -o miniflux-test main.go
|
||||
DATABASE_URL=$(DB_URL) ./miniflux-test -debug >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
while ! echo exit | nc localhost 8080; do sleep 1; done >/dev/null
|
||||
go test -v -tags=integration miniflux.app/tests || cat /tmp/miniflux.log
|
||||
|
||||
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"
|
||||
|
||||
while ! nc -z localhost 8080; do sleep 1; done
|
||||
go test -v -tags=integration -count=1 miniflux.app/tests
|
||||
|
||||
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:
|
||||
docker build -t $(DOCKER_IMAGE):$(VERSION) -f packaging/docker/alpine/Dockerfile .
|
||||
|
||||
docker-image-distroless:
|
||||
docker build -t $(DOCKER_IMAGE):$(VERSION) -f packaging/docker/distroless/Dockerfile .
|
||||
|
||||
docker-images:
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 \
|
||||
--file packaging/docker/alpine/Dockerfile \
|
||||
--tag $(DOCKER_IMAGE):$(VERSION) \
|
||||
--push .
|
||||
|
||||
rpm: clean
|
||||
@ docker build \
|
||||
-t miniflux-rpm-builder \
|
||||
-f packaging/rpm/Dockerfile \
|
||||
.
|
||||
@ docker run --rm \
|
||||
-v ${PWD}:/root/rpmbuild/RPMS/x86_64 miniflux-rpm-builder \
|
||||
rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec
|
||||
|
||||
debian:
|
||||
@ docker build --load \
|
||||
--build-arg BASE_IMAGE_ARCH=$(DEB_IMG_ARCH) \
|
||||
-t $(DEB_IMG_ARCH)/miniflux-deb-builder \
|
||||
-f packaging/debian/Dockerfile \
|
||||
.
|
||||
@ docker run --rm \
|
||||
-v ${PWD}:/pkg $(DEB_IMG_ARCH)/miniflux-deb-builder
|
||||
|
||||
debian-packages: clean
|
||||
$(MAKE) debian DEB_IMG_ARCH=amd64
|
||||
$(MAKE) debian DEB_IMG_ARCH=arm64v8
|
||||
$(MAKE) debian DEB_IMG_ARCH=arm32v7
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
Miniflux 2
|
||||
==========
|
||||
[](https://travis-ci.org/miniflux/miniflux)
|
||||
[](https://godoc.org/miniflux.app)
|
||||
[](https://docs.miniflux.app/)
|
||||
|
||||
Miniflux is a minimalist and opinionated feed reader:
|
||||
|
||||
@@ -21,39 +18,37 @@ Official website: <https://miniflux.app>
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
The Miniflux documentation is available here: <https://docs.miniflux.app/>
|
||||
The Miniflux documentation is available here: <https://miniflux.app/docs/> ([Man page](https://miniflux.app/miniflux.1.html))
|
||||
|
||||
- [Opinionated?](https://docs.miniflux.app/en/latest/opinionated.html)
|
||||
- [Features](https://docs.miniflux.app/en/latest/features.html)
|
||||
- [Requirements](https://docs.miniflux.app/en/latest/requirements.html)
|
||||
- [Installation Instructions](https://docs.miniflux.app/en/latest/installation.html)
|
||||
- [Installation Tutorials](https://docs.miniflux.app/en/latest/tutorials.html)
|
||||
- [Upgrading to a New Version](https://docs.miniflux.app/en/latest/upgrade.html)
|
||||
- [Configuration](https://docs.miniflux.app/en/latest/configuration.html)
|
||||
- [Command Line Usage](https://docs.miniflux.app/en/latest/cli.html)
|
||||
- [User Interface Usage](https://docs.miniflux.app/en/latest/usage.html)
|
||||
- [Keyboard Shortcuts](https://docs.miniflux.app/en/latest/keyboard_shortcuts.html)
|
||||
- [Integration with External Services](https://docs.miniflux.app/en/latest/integration.html)
|
||||
- [Scraper Rules](https://docs.miniflux.app/en/latest/scraper_rules.html)
|
||||
- [Rewrite Rules](https://docs.miniflux.app/en/latest/rewrite_rules.html)
|
||||
- [REST API](https://docs.miniflux.app/en/latest/api.html)
|
||||
- [Development](https://docs.miniflux.app/en/latest/development.html)
|
||||
- [Internationalization](https://docs.miniflux.app/en/latest/i18n.html)
|
||||
- [Frequently Asked Questions](https://docs.miniflux.app/en/latest/faq.html)
|
||||
- [Opinionated?](https://miniflux.app/opinionated.html)
|
||||
- [Features](https://miniflux.app/features.html)
|
||||
- [Requirements](https://miniflux.app/docs/requirements.html)
|
||||
- [Installation Instructions](https://miniflux.app/docs/installation.html)
|
||||
- [Upgrading to a New Version](https://miniflux.app/docs/upgrade.html)
|
||||
- [Configuration](https://miniflux.app/docs/configuration.html)
|
||||
- [Command Line Usage](https://miniflux.app/docs/cli.html)
|
||||
- [User Interface Usage](https://miniflux.app/docs/ui.html)
|
||||
- [Keyboard Shortcuts](https://miniflux.app/docs/keyboard_shortcuts.html)
|
||||
- [Integration with External Services](https://miniflux.app/docs/services.html)
|
||||
- [Rewrite and Scraper Rules](https://miniflux.app/docs/rules.html)
|
||||
- [API Reference](https://miniflux.app/docs/api.html)
|
||||
- [Development](https://miniflux.app/docs/development.html)
|
||||
- [Internationalization](https://miniflux.app/docs/i18n.html)
|
||||
- [Frequently Asked Questions](https://miniflux.app/faq.html)
|
||||
|
||||
Screenshots
|
||||
-----------
|
||||
|
||||
Default theme:
|
||||
|
||||

|
||||

|
||||
|
||||
Dark theme when using keyboard naviguation:
|
||||
Dark theme when using keyboard navigation:
|
||||
|
||||

|
||||

|
||||
|
||||
Credits
|
||||
-------
|
||||
|
||||
- Authors: Frédéric Guillot - [List of contributors](https://github.com/miniflux/miniflux/graphs/contributors)
|
||||
- Authors: Frédéric Guillot - [List of contributors](https://github.com/miniflux/v2/graphs/contributors)
|
||||
- Distributed under Apache 2.0 License
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest stable version is supported.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Preferably, [report the vulnerability privately using GitHub](https://github.com/miniflux/v2/security/advisories/new) ([documentation](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability)).
|
||||
|
||||
If you do not want to use GitHub, send an email to `security AT miniflux DOT net` with all the steps to reproduce the problem.
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/worker"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
store *storage.Storage
|
||||
pool *worker.Pool
|
||||
router *mux.Router
|
||||
}
|
||||
|
||||
// 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()
|
||||
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.feedIcon).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}/bookmark", handler.toggleBookmark).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
|
||||
}
|
||||
+75
-29
@@ -5,34 +5,32 @@
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
json_parser "encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/http/request"
|
||||
"miniflux.app/http/response/json"
|
||||
"miniflux.app/model"
|
||||
"miniflux.app/validator"
|
||||
)
|
||||
|
||||
// CreateCategory is the API handler to create a new category.
|
||||
func (c *Controller) CreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
category, err := decodeCategoryPayload(r.Body)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
func (h *handler) createCategory(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
category.UserID = userID
|
||||
if err := category.ValidateCategoryCreation(); err != nil {
|
||||
|
||||
var categoryRequest model.CategoryRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&categoryRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if c, err := c.store.CategoryByTitle(userID, category.Title); err != nil || c != nil {
|
||||
json.BadRequest(w, r, errors.New("This category already exists"))
|
||||
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.CreateCategory(category); err != nil {
|
||||
category, err := h.store.CreateCategory(userID, &categoryRequest)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -40,24 +38,34 @@ func (c *Controller) CreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
json.Created(w, r, category)
|
||||
}
|
||||
|
||||
// UpdateCategory is the API handler to update a category.
|
||||
func (c *Controller) UpdateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
|
||||
category, err := decodeCategoryPayload(r.Body)
|
||||
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
|
||||
}
|
||||
|
||||
category.UserID = request.UserID(r)
|
||||
category.ID = categoryID
|
||||
if err := category.ValidateCategoryModification(); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.UpdateCategory(category)
|
||||
categoryRequest.Patch(category)
|
||||
err = h.store.UpdateCategory(category)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -66,9 +74,31 @@ func (c *Controller) UpdateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
json.Created(w, r, category)
|
||||
}
|
||||
|
||||
// GetCategories is the API handler to get a list of categories for a given user.
|
||||
func (c *Controller) GetCategories(w http.ResponseWriter, r *http.Request) {
|
||||
categories, err := c.store.Categories(request.UserID(r))
|
||||
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) {
|
||||
categories, err := h.store.Categories(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -77,20 +107,36 @@ func (c *Controller) GetCategories(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, categories)
|
||||
}
|
||||
|
||||
// RemoveCategory is the API handler to remove a category.
|
||||
func (c *Controller) RemoveCategory(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
|
||||
if !c.store.CategoryExists(userID, categoryID) {
|
||||
if !h.store.CategoryIDExists(userID, categoryID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.RemoveCategory(userID, categoryID); err != nil {
|
||||
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")
|
||||
|
||||
jobs, err := h.store.NewCategoryBatch(userID, categoryID, h.store.CountFeeds(userID))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
h.pool.Push(jobs)
|
||||
}()
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
"miniflux.app/reader/feed"
|
||||
"miniflux.app/storage"
|
||||
)
|
||||
|
||||
// Controller holds all handlers for the API.
|
||||
type Controller struct {
|
||||
store *storage.Storage
|
||||
feedHandler *feed.Handler
|
||||
}
|
||||
|
||||
// NewController creates a new controller.
|
||||
func NewController(store *storage.Storage, feedHandler *feed.Handler) *Controller {
|
||||
return &Controller{store: store, feedHandler: feedHandler}
|
||||
}
|
||||
+170
-109
@@ -5,97 +5,149 @@
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/config"
|
||||
"miniflux.app/http/request"
|
||||
"miniflux.app/http/response/json"
|
||||
"miniflux.app/model"
|
||||
"miniflux.app/proxy"
|
||||
"miniflux.app/reader/processor"
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/url"
|
||||
"miniflux.app/validator"
|
||||
)
|
||||
|
||||
// GetFeedEntry is the API handler to get a single feed entry.
|
||||
func (c *Controller) GetFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
entry.Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content)
|
||||
proxyOption := config.Opts.ProxyOption()
|
||||
|
||||
for i := range entry.Enclosures {
|
||||
if proxyOption == "all" || proxyOption != "none" && !url.IsHTTPS(entry.Enclosures[i].URL) {
|
||||
for _, mediaType := range config.Opts.ProxyMediaTypes() {
|
||||
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
|
||||
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json.OK(w, r, entry)
|
||||
}
|
||||
|
||||
func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
|
||||
builder := c.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, entry)
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
// GetEntry is the API handler to get a single entry.
|
||||
func (c *Controller) GetEntry(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
builder := c.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, entry)
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
// GetFeedEntries is the API handler to get all feed entries.
|
||||
func (c *Controller) GetFeedEntries(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
status := request.QueryStringParam(r, "status", "")
|
||||
if status != "" {
|
||||
if err := model.ValidateEntryStatus(status); err != nil {
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
func (h *handler) getFeedEntries(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
h.findEntries(w, r, feedID, 0)
|
||||
}
|
||||
|
||||
func (h *handler) getCategoryEntries(w http.ResponseWriter, r *http.Request) {
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
h.findEntries(w, r, 0, categoryID)
|
||||
}
|
||||
|
||||
func (h *handler) getEntries(w http.ResponseWriter, r *http.Request) {
|
||||
h.findEntries(w, r, 0, 0)
|
||||
}
|
||||
|
||||
func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int64, categoryID int64) {
|
||||
statuses := request.QueryStringParamList(r, "status")
|
||||
for _, status := range statuses {
|
||||
if err := validator.ValidateEntryStatus(status); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
|
||||
if err := model.ValidateEntryOrder(order); err != nil {
|
||||
if err := validator.ValidateEntryOrder(order); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
|
||||
if err := model.ValidateDirection(direction); err != nil {
|
||||
if err := validator.ValidateDirection(direction); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
limit := request.QueryIntParam(r, "limit", 100)
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
if err := model.ValidateRange(offset, limit); err != nil {
|
||||
if err := validator.ValidateRange(offset, limit); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
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"))
|
||||
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"))
|
||||
return
|
||||
}
|
||||
|
||||
tags := request.QueryStringParamList(r, "tags")
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithStatus(status)
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithStatuses(statuses)
|
||||
builder.WithOrder(order)
|
||||
builder.WithDirection(direction)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(limit)
|
||||
builder.WithTags(tags)
|
||||
configureFilters(builder, r)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
@@ -110,75 +162,26 @@ func (c *Controller) GetFeedEntries(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
|
||||
}
|
||||
|
||||
// GetEntries is the API handler to fetch entries.
|
||||
func (c *Controller) GetEntries(w http.ResponseWriter, r *http.Request) {
|
||||
status := request.QueryStringParam(r, "status", "")
|
||||
if status != "" {
|
||||
if err := model.ValidateEntryStatus(status); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
|
||||
if err := model.ValidateEntryOrder(order); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
|
||||
if err := model.ValidateDirection(direction); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
limit := request.QueryIntParam(r, "limit", 100)
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
if err := model.ValidateRange(offset, limit); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithStatus(status)
|
||||
builder.WithOrder(order)
|
||||
builder.WithDirection(direction)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(limit)
|
||||
configureFilters(builder, r)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
for i := range entries {
|
||||
entries[i].Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entries[i].Content)
|
||||
}
|
||||
|
||||
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
|
||||
}
|
||||
|
||||
// SetEntryStatus is the API handler to change the status of entries.
|
||||
func (c *Controller) SetEntryStatus(w http.ResponseWriter, r *http.Request) {
|
||||
entryIDs, status, err := decodeEntryStatusPayload(r.Body)
|
||||
if err != nil {
|
||||
json.BadRequest(w , r, errors.New("Invalid JSON payload"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.ValidateEntryStatus(status); err != nil {
|
||||
func (h *handler) setEntryStatus(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)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.SetEntriesStatus(request.UserID(r), entryIDs, status); err != nil {
|
||||
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -186,10 +189,9 @@ func (c *Controller) SetEntryStatus(w http.ResponseWriter, r *http.Request) {
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
// ToggleBookmark is the API handler to toggle bookmark status.
|
||||
func (c *Controller) ToggleBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) toggleBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if err := c.store.ToggleBookmark(request.UserID(r), entryID); err != nil {
|
||||
if err := h.store.ToggleBookmark(request.UserID(r), entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -197,29 +199,88 @@ func (c *Controller) ToggleBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(entry.UserID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
|
||||
feedBuilder.WithFeedID(entry.FeedID)
|
||||
feed, err := feedBuilder.GetFeed()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, map[string]string{"content": entry.Content})
|
||||
}
|
||||
|
||||
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
|
||||
beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0)
|
||||
if beforeEntryID != 0 {
|
||||
if beforeEntryID > 0 {
|
||||
builder.BeforeEntryID(beforeEntryID)
|
||||
}
|
||||
|
||||
afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0)
|
||||
if afterEntryID != 0 {
|
||||
if afterEntryID > 0 {
|
||||
builder.AfterEntryID(afterEntryID)
|
||||
}
|
||||
|
||||
beforeTimestamp := request.QueryInt64Param(r, "before", 0)
|
||||
if beforeTimestamp != 0 {
|
||||
if beforeTimestamp > 0 {
|
||||
builder.BeforeDate(time.Unix(beforeTimestamp, 0))
|
||||
}
|
||||
|
||||
afterTimestamp := request.QueryInt64Param(r, "after", 0)
|
||||
if afterTimestamp != 0 {
|
||||
if afterTimestamp > 0 {
|
||||
builder.AfterDate(time.Unix(afterTimestamp, 0))
|
||||
}
|
||||
|
||||
categoryID := request.QueryInt64Param(r, "category_id", 0)
|
||||
if categoryID > 0 {
|
||||
builder.WithCategoryID(categoryID)
|
||||
}
|
||||
|
||||
if request.HasQueryParam(r, "starred") {
|
||||
builder.WithStarred()
|
||||
starred, err := strconv.ParseBool(r.URL.Query().Get("starred"))
|
||||
if err == nil {
|
||||
builder.WithStarred(starred)
|
||||
}
|
||||
}
|
||||
|
||||
searchQuery := request.QueryStringParam(r, "search", "")
|
||||
|
||||
+105
-63
@@ -5,75 +5,50 @@
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
json_parser "encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/http/request"
|
||||
"miniflux.app/http/response/json"
|
||||
"miniflux.app/model"
|
||||
feedHandler "miniflux.app/reader/handler"
|
||||
"miniflux.app/validator"
|
||||
)
|
||||
|
||||
// CreateFeed is the API handler to create a new feed.
|
||||
func (c *Controller) CreateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedInfo, err := decodeFeedCreationPayload(r.Body)
|
||||
if err != nil {
|
||||
func (h *handler) createFeed(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)
|
||||
return
|
||||
}
|
||||
|
||||
if feedInfo.FeedURL == "" {
|
||||
json.BadRequest(w, r, errors.New("The feed_url is required"))
|
||||
if validationErr := validator.ValidateFeedCreation(h.store, userID, &feedCreationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if feedInfo.CategoryID <= 0 {
|
||||
json.BadRequest(w, r, errors.New("The category_id is required"))
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
|
||||
if c.store.FeedURLExists(userID, feedInfo.FeedURL) {
|
||||
json.BadRequest(w, r, errors.New("This feed_url already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
if !c.store.CategoryExists(userID, feedInfo.CategoryID) {
|
||||
json.BadRequest(w, r, errors.New("This category_id doesn't exists or doesn't belongs to this user"))
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := c.feedHandler.CreateFeed(
|
||||
userID,
|
||||
feedInfo.CategoryID,
|
||||
feedInfo.FeedURL,
|
||||
feedInfo.Crawler,
|
||||
feedInfo.UserAgent,
|
||||
feedInfo.Username,
|
||||
feedInfo.Password,
|
||||
)
|
||||
feed, err := feedHandler.CreateFeed(h.store, userID, &feedCreationRequest)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
type result struct {
|
||||
FeedID int64 `json:"feed_id"`
|
||||
}
|
||||
|
||||
json.Created(w, r, &result{FeedID: feed.ID})
|
||||
json.Created(w, r, &feedCreationResponse{FeedID: feed.ID})
|
||||
}
|
||||
|
||||
// RefreshFeed is the API handler to refresh a feed.
|
||||
func (c *Controller) RefreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
userID := request.UserID(r)
|
||||
|
||||
if !c.store.FeedExists(userID, feedID) {
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err := c.feedHandler.RefreshFeed(userID, feedID)
|
||||
err := feedHandler.RefreshFeed(h.store, userID, feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -82,18 +57,32 @@ func (c *Controller) RefreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
// UpdateFeed is the API handler that is used to update a feed.
|
||||
func (c *Controller) UpdateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
feedChanges, err := decodeFeedModificationPayload(r.Body)
|
||||
func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
jobs, err := h.store.NewUserBatch(userID, h.store.CountFeeds(userID))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
h.pool.Push(jobs)
|
||||
}()
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
var feedModificationRequest model.FeedModificationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&feedModificationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
originalFeed, err := c.store.FeedByID(userID, feedID)
|
||||
originalFeed, err := h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
@@ -104,19 +93,18 @@ func (c *Controller) UpdateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
feedChanges.Update(originalFeed)
|
||||
|
||||
if !c.store.CategoryExists(userID, originalFeed.Category.ID) {
|
||||
json.BadRequest(w, r, errors.New("This category_id doesn't exists or doesn't belongs to this user"))
|
||||
if validationErr := validator.ValidateFeedModification(h.store, userID, &feedModificationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.UpdateFeed(originalFeed); err != nil {
|
||||
feedModificationRequest.Patch(originalFeed)
|
||||
if err := h.store.UpdateFeed(originalFeed); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
originalFeed, err = c.store.FeedByID(userID, feedID)
|
||||
originalFeed, err = h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -125,9 +113,45 @@ func (c *Controller) UpdateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
json.Created(w, r, originalFeed)
|
||||
}
|
||||
|
||||
// GetFeeds is the API handler that get all feeds that belongs to the given user.
|
||||
func (c *Controller) GetFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
feeds, err := c.store.Feeds(request.UserID(r))
|
||||
func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
userID := request.UserID(r)
|
||||
|
||||
feed, err := h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.MarkFeedAsRead(userID, feedID, time.Now()); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getCategoryFeeds(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
|
||||
}
|
||||
|
||||
feeds, err := h.store.FeedsByCategoryWithCounters(userID, categoryID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -136,10 +160,29 @@ func (c *Controller) GetFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, feeds)
|
||||
}
|
||||
|
||||
// GetFeed is the API handler to get a feed.
|
||||
func (c *Controller) GetFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
feeds, err := h.store.Feeds(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, feeds)
|
||||
}
|
||||
|
||||
func (h *handler) fetchCounters(w http.ResponseWriter, r *http.Request) {
|
||||
counters, err := h.store.FetchCounters(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, counters)
|
||||
}
|
||||
|
||||
func (h *handler) getFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
feed, err := c.store.FeedByID(request.UserID(r), feedID)
|
||||
feed, err := h.store.FeedByID(request.UserID(r), feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -153,17 +196,16 @@ func (c *Controller) GetFeed(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, feed)
|
||||
}
|
||||
|
||||
// RemoveFeed is the API handler to remove a feed.
|
||||
func (c *Controller) RemoveFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) removeFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
userID := request.UserID(r)
|
||||
|
||||
if !c.store.FeedExists(userID, feedID) {
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.RemoveFeed(userID, feedID); err != nil {
|
||||
if err := h.store.RemoveFeed(userID, feedID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
+4
-5
@@ -11,16 +11,15 @@ import (
|
||||
"miniflux.app/http/response/json"
|
||||
)
|
||||
|
||||
// FeedIcon returns a feed icon.
|
||||
func (c *Controller) FeedIcon(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) feedIcon(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
if !c.store.HasIcon(feedID) {
|
||||
if !h.store.HasIcon(feedID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
icon, err := c.store.IconByFeedID(request.UserID(r), feedID)
|
||||
icon, err := h.store.IconByFeedID(request.UserID(r), feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -31,7 +30,7 @@ func (c *Controller) FeedIcon(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, &feedIcon{
|
||||
json.OK(w, r, &feedIconResponse{
|
||||
ID: icon.ID,
|
||||
MimeType: icon.MimeType,
|
||||
Data: icon.DataURL(),
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/http/request"
|
||||
"miniflux.app/http/response/json"
|
||||
"miniflux.app/logger"
|
||||
"miniflux.app/storage"
|
||||
)
|
||||
|
||||
type middleware struct {
|
||||
store *storage.Storage
|
||||
}
|
||||
|
||||
func newMiddleware(s *storage.Storage) *middleware {
|
||||
return &middleware{s}
|
||||
}
|
||||
func (m *middleware) handleCORS(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)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *middleware) apiKeyAuth(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")
|
||||
|
||||
if token == "" {
|
||||
logger.Debug("[API][TokenAuth] [ClientIP=%s] No API Key provided, go to the next middleware", clientIP)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.store.UserByAPIKey(token)
|
||||
if err != nil {
|
||||
logger.Error("[API][TokenAuth] %v", err)
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
logger.Error("[API][TokenAuth] [ClientIP=%s] No user found with the given API key", clientIP)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("[API][TokenAuth] [ClientIP=%s] User authenticated: %s", clientIP, user.Username)
|
||||
m.store.SetLastLogin(user.ID)
|
||||
m.store.SetAPIKeyUsedTimestamp(user.ID, token)
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if request.IsAuthenticated(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
|
||||
clientIP := request.ClientIP(r)
|
||||
username, password, authOK := r.BasicAuth()
|
||||
if !authOK {
|
||||
logger.Debug("[API][BasicAuth] [ClientIP=%s] No authentication headers sent", clientIP)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
logger.Error("[API][BasicAuth] [ClientIP=%s] Empty username or password", clientIP)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.store.CheckPassword(username, password); err != nil {
|
||||
logger.Error("[API][BasicAuth] [ClientIP=%s] Invalid username or password: %s", clientIP, username)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
logger.Error("[API][BasicAuth] %v", err)
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
logger.Error("[API][BasicAuth] [ClientIP=%s] User not found: %s", clientIP, username)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("[API][BasicAuth] [ClientIP=%s] User authenticated: %s", clientIP, 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))
|
||||
})
|
||||
}
|
||||
+4
-6
@@ -13,9 +13,8 @@ import (
|
||||
"miniflux.app/reader/opml"
|
||||
)
|
||||
|
||||
// Export is the API handler that export feeds to OPML.
|
||||
func (c *Controller) Export(w http.ResponseWriter, r *http.Request) {
|
||||
opmlHandler := opml.NewHandler(c.store)
|
||||
func (h *handler) exportFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
opmlHandler := opml.NewHandler(h.store)
|
||||
opml, err := opmlHandler.Export(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
@@ -25,9 +24,8 @@ func (c *Controller) Export(w http.ResponseWriter, r *http.Request) {
|
||||
xml.OK(w, r, opml)
|
||||
}
|
||||
|
||||
// Import is the API handler that import an OPML file.
|
||||
func (c *Controller) Import(w http.ResponseWriter, r *http.Request) {
|
||||
opmlHandler := opml.NewHandler(c.store)
|
||||
func (h *handler) importFeeds(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 {
|
||||
|
||||
+3
-202
@@ -5,14 +5,10 @@
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"miniflux.app/model"
|
||||
)
|
||||
|
||||
type feedIcon struct {
|
||||
type feedIconResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Data string `json:"data"`
|
||||
@@ -23,201 +19,6 @@ type entriesResponse struct {
|
||||
Entries model.Entries `json:"entries"`
|
||||
}
|
||||
|
||||
type feedCreation struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
CategoryID int64 `json:"category_id"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Crawler bool `json:"crawler"`
|
||||
}
|
||||
|
||||
type subscriptionDiscovery struct {
|
||||
URL string `json:"url"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type feedModification struct {
|
||||
FeedURL *string `json:"feed_url"`
|
||||
SiteURL *string `json:"site_url"`
|
||||
Title *string `json:"title"`
|
||||
ScraperRules *string `json:"scraper_rules"`
|
||||
RewriteRules *string `json:"rewrite_rules"`
|
||||
Crawler *bool `json:"crawler"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
}
|
||||
|
||||
func (f *feedModification) Update(feed *model.Feed) {
|
||||
if f.FeedURL != nil && *f.FeedURL != "" {
|
||||
feed.FeedURL = *f.FeedURL
|
||||
}
|
||||
|
||||
if f.SiteURL != nil && *f.SiteURL != "" {
|
||||
feed.SiteURL = *f.SiteURL
|
||||
}
|
||||
|
||||
if f.Title != nil && *f.Title != "" {
|
||||
feed.Title = *f.Title
|
||||
}
|
||||
|
||||
if f.ScraperRules != nil {
|
||||
feed.ScraperRules = *f.ScraperRules
|
||||
}
|
||||
|
||||
if f.RewriteRules != nil {
|
||||
feed.RewriteRules = *f.RewriteRules
|
||||
}
|
||||
|
||||
if f.Crawler != nil {
|
||||
feed.Crawler = *f.Crawler
|
||||
}
|
||||
|
||||
if f.UserAgent != nil {
|
||||
feed.UserAgent = *f.UserAgent
|
||||
}
|
||||
|
||||
if f.Username != nil {
|
||||
feed.Username = *f.Username
|
||||
}
|
||||
|
||||
if f.Password != nil {
|
||||
feed.Password = *f.Password
|
||||
}
|
||||
|
||||
if f.CategoryID != nil && *f.CategoryID > 0 {
|
||||
feed.Category.ID = *f.CategoryID
|
||||
}
|
||||
}
|
||||
|
||||
type userModification 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"`
|
||||
}
|
||||
|
||||
func (u *userModification) Update(user *model.User) {
|
||||
if u.Username != nil {
|
||||
user.Username = *u.Username
|
||||
}
|
||||
|
||||
if u.Password != nil {
|
||||
user.Password = *u.Password
|
||||
}
|
||||
|
||||
if u.IsAdmin != nil {
|
||||
user.IsAdmin = *u.IsAdmin
|
||||
}
|
||||
|
||||
if u.Theme != nil {
|
||||
user.Theme = *u.Theme
|
||||
}
|
||||
|
||||
if u.Language != nil {
|
||||
user.Language = *u.Language
|
||||
}
|
||||
|
||||
if u.Timezone != nil {
|
||||
user.Timezone = *u.Timezone
|
||||
}
|
||||
|
||||
if u.EntryDirection != nil {
|
||||
user.EntryDirection = *u.EntryDirection
|
||||
}
|
||||
}
|
||||
|
||||
func decodeUserModificationPayload(r io.ReadCloser) (*userModification, error) {
|
||||
defer r.Close()
|
||||
|
||||
var user userModification
|
||||
decoder := json.NewDecoder(r)
|
||||
if err := decoder.Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("Unable to decode user modification JSON object: %v", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func decodeUserCreationPayload(r io.ReadCloser) (*model.User, error) {
|
||||
defer r.Close()
|
||||
|
||||
var user model.User
|
||||
decoder := json.NewDecoder(r)
|
||||
if err := decoder.Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("Unable to decode user modification JSON object: %v", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func decodeURLPayload(r io.ReadCloser) (*subscriptionDiscovery, error) {
|
||||
defer r.Close()
|
||||
|
||||
var s subscriptionDiscovery
|
||||
decoder := json.NewDecoder(r)
|
||||
if err := decoder.Decode(&s); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON payload: %v", err)
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func decodeEntryStatusPayload(r io.ReadCloser) ([]int64, string, error) {
|
||||
type payload struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
var p payload
|
||||
decoder := json.NewDecoder(r)
|
||||
defer r.Close()
|
||||
if err := decoder.Decode(&p); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid JSON payload: %v", err)
|
||||
}
|
||||
|
||||
return p.EntryIDs, p.Status, nil
|
||||
}
|
||||
|
||||
func decodeFeedCreationPayload(r io.ReadCloser) (*feedCreation, error) {
|
||||
defer r.Close()
|
||||
|
||||
var fc feedCreation
|
||||
decoder := json.NewDecoder(r)
|
||||
if err := decoder.Decode(&fc); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON payload: %v", err)
|
||||
}
|
||||
|
||||
return &fc, nil
|
||||
}
|
||||
|
||||
func decodeFeedModificationPayload(r io.ReadCloser) (*feedModification, error) {
|
||||
defer r.Close()
|
||||
|
||||
var feed feedModification
|
||||
decoder := json.NewDecoder(r)
|
||||
if err := decoder.Decode(&feed); err != nil {
|
||||
return nil, fmt.Errorf("Unable to decode feed modification JSON object: %v", err)
|
||||
}
|
||||
|
||||
return &feed, nil
|
||||
}
|
||||
|
||||
func decodeCategoryPayload(r io.ReadCloser) (*model.Category, error) {
|
||||
var category model.Category
|
||||
|
||||
decoder := json.NewDecoder(r)
|
||||
defer r.Close()
|
||||
if err := decoder.Decode(&category); err != nil {
|
||||
return nil, fmt.Errorf("Unable to decode category JSON object: %v", err)
|
||||
}
|
||||
|
||||
return &category, nil
|
||||
type feedCreationResponse struct {
|
||||
FeedID int64 `json:"feed_id"`
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"miniflux.app/model"
|
||||
)
|
||||
|
||||
func TestUpdateFeedURL(t *testing.T) {
|
||||
feedURL := "http://example.com/"
|
||||
changes := &feedModification{FeedURL: &feedURL}
|
||||
feed := &model.Feed{FeedURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.FeedURL != feedURL {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.FeedURL, feedURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedURLWithEmptyString(t *testing.T) {
|
||||
feedURL := ""
|
||||
changes := &feedModification{FeedURL: &feedURL}
|
||||
feed := &model.Feed{FeedURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.FeedURL == feedURL {
|
||||
t.Fatal(`The FeedURL should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedURLWhenNotSet(t *testing.T) {
|
||||
changes := &feedModification{}
|
||||
feed := &model.Feed{FeedURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.FeedURL != "http://example.org/" {
|
||||
t.Fatal(`The FeedURL should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedSiteURL(t *testing.T) {
|
||||
siteURL := "http://example.com/"
|
||||
changes := &feedModification{SiteURL: &siteURL}
|
||||
feed := &model.Feed{SiteURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.SiteURL != siteURL {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.SiteURL, siteURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedSiteURLWithEmptyString(t *testing.T) {
|
||||
siteURL := ""
|
||||
changes := &feedModification{FeedURL: &siteURL}
|
||||
feed := &model.Feed{SiteURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.SiteURL == siteURL {
|
||||
t.Fatal(`The FeedURL should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedSiteURLWhenNotSet(t *testing.T) {
|
||||
changes := &feedModification{}
|
||||
feed := &model.Feed{SiteURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.SiteURL != "http://example.org/" {
|
||||
t.Fatal(`The SiteURL should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedTitle(t *testing.T) {
|
||||
title := "Example 2"
|
||||
changes := &feedModification{Title: &title}
|
||||
feed := &model.Feed{Title: "Example"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Title != title {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.Title, title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedTitleWithEmptyString(t *testing.T) {
|
||||
title := ""
|
||||
changes := &feedModification{Title: &title}
|
||||
feed := &model.Feed{Title: "Example"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Title == title {
|
||||
t.Fatal(`The Title should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedTitleWhenNotSet(t *testing.T) {
|
||||
changes := &feedModification{}
|
||||
feed := &model.Feed{Title: "Example"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Title != "Example" {
|
||||
t.Fatal(`The Title should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedUsername(t *testing.T) {
|
||||
username := "Alice"
|
||||
changes := &feedModification{Username: &username}
|
||||
feed := &model.Feed{Username: "Bob"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Username != username {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.Username, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedUsernameWithEmptyString(t *testing.T) {
|
||||
username := ""
|
||||
changes := &feedModification{Username: &username}
|
||||
feed := &model.Feed{Username: "Bob"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Username != "" {
|
||||
t.Fatal(`The Username should be empty now`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedUsernameWhenNotSet(t *testing.T) {
|
||||
changes := &feedModification{}
|
||||
feed := &model.Feed{Username: "Alice"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Username != "Alice" {
|
||||
t.Fatal(`The Username should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedCategory(t *testing.T) {
|
||||
categoryID := int64(1)
|
||||
changes := &feedModification{CategoryID: &categoryID}
|
||||
feed := &model.Feed{Category: &model.Category{ID: 42}}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Category.ID != categoryID {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.Username, categoryID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedCategoryWithZero(t *testing.T) {
|
||||
categoryID := int64(0)
|
||||
changes := &feedModification{CategoryID: &categoryID}
|
||||
feed := &model.Feed{Category: &model.Category{ID: 42}}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Category.ID != 42 {
|
||||
t.Fatal(`The CategoryID should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedCategoryWhenNotSet(t *testing.T) {
|
||||
changes := &feedModification{}
|
||||
feed := &model.Feed{Category: &model.Category{ID: 42}}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Category.ID != 42 {
|
||||
t.Fatal(`The CategoryID should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserTheme(t *testing.T) {
|
||||
theme := "Example 2"
|
||||
changes := &userModification{Theme: &theme}
|
||||
user := &model.User{Theme: "Example"}
|
||||
changes.Update(user)
|
||||
|
||||
if user.Theme != theme {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, user.Theme, theme)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserThemeWhenNotSet(t *testing.T) {
|
||||
changes := &userModification{}
|
||||
user := &model.User{Theme: "Example"}
|
||||
changes.Update(user)
|
||||
|
||||
if user.Theme != "Example" {
|
||||
t.Fatal(`The user Theme should not be modified`)
|
||||
}
|
||||
}
|
||||
+19
-9
@@ -5,25 +5,35 @@
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/http/response/json"
|
||||
"miniflux.app/model"
|
||||
"miniflux.app/reader/subscription"
|
||||
"miniflux.app/validator"
|
||||
)
|
||||
|
||||
// GetSubscriptions is the API handler to find subscriptions.
|
||||
func (c *Controller) GetSubscriptions(w http.ResponseWriter, r *http.Request) {
|
||||
subscriptionInfo, bodyErr := decodeURLPayload(r.Body)
|
||||
if bodyErr != nil {
|
||||
json.BadRequest(w, r, bodyErr)
|
||||
func (h *handler) discoverSubscriptions(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)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateSubscriptionDiscovery(&subscriptionDiscoveryRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
subscriptions, finderErr := subscription.FindSubscriptions(
|
||||
subscriptionInfo.URL,
|
||||
subscriptionInfo.UserAgent,
|
||||
subscriptionInfo.Username,
|
||||
subscriptionInfo.Password,
|
||||
subscriptionDiscoveryRequest.URL,
|
||||
subscriptionDiscoveryRequest.UserAgent,
|
||||
subscriptionDiscoveryRequest.Cookie,
|
||||
subscriptionDiscoveryRequest.Username,
|
||||
subscriptionDiscoveryRequest.Password,
|
||||
subscriptionDiscoveryRequest.FetchViaProxy,
|
||||
subscriptionDiscoveryRequest.AllowSelfSignedCertificates,
|
||||
)
|
||||
if finderErr != nil {
|
||||
json.ServerError(w, r, finderErr)
|
||||
|
||||
+64
-45
@@ -5,16 +5,18 @@
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/http/request"
|
||||
"miniflux.app/http/response/json"
|
||||
"miniflux.app/model"
|
||||
"miniflux.app/validator"
|
||||
)
|
||||
|
||||
// CurrentUser is the API handler to retrieve the authenticated user.
|
||||
func (c *Controller) CurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := c.store.UserByID(request.UserID(r))
|
||||
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
|
||||
@@ -23,56 +25,44 @@ func (c *Controller) CurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, user)
|
||||
}
|
||||
|
||||
// CreateUser is the API handler to create a new user.
|
||||
func (c *Controller) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := decodeUserCreationPayload(r.Body)
|
||||
if err != nil {
|
||||
var userCreationRequest model.UserCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&userCreationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.ValidateUserCreation(); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
if validationErr := validator.ValidateUserCreationWithPassword(h.store, &userCreationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if c.store.UserExists(user.Username) {
|
||||
json.BadRequest(w, r, errors.New("This user already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.CreateUser(user)
|
||||
user, err := h.store.CreateUser(&userCreationRequest)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = ""
|
||||
json.Created(w, r, user)
|
||||
}
|
||||
|
||||
// UpdateUser is the API handler to update the given user.
|
||||
func (c *Controller) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
userChanges, err := decodeUserModificationPayload(r.Body)
|
||||
if err != nil {
|
||||
|
||||
var userModificationRequest model.UserModificationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
originalUser, err := c.store.UserByID(userID)
|
||||
originalUser, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, errors.New("Unable to fetch this user from the database"))
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,13 +71,25 @@ func (c *Controller) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
userChanges.Update(originalUser)
|
||||
if err := originalUser.ValidateUserModification(); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err = c.store.UpdateUser(originalUser); err != nil {
|
||||
userModificationRequest.Patch(originalUser)
|
||||
if err = h.store.UpdateUser(originalUser); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -95,14 +97,33 @@ func (c *Controller) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
json.Created(w, r, originalUser)
|
||||
}
|
||||
|
||||
// Users is the API handler to get the list of users.
|
||||
func (c *Controller) Users(w http.ResponseWriter, r *http.Request) {
|
||||
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) users(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := c.store.Users()
|
||||
users, err := h.store.Users()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -112,15 +133,14 @@ func (c *Controller) Users(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, users)
|
||||
}
|
||||
|
||||
// UserByID is the API handler to fetch the given user by the ID.
|
||||
func (c *Controller) UserByID(w http.ResponseWriter, r *http.Request) {
|
||||
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 := c.store.UserByID(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
|
||||
@@ -135,15 +155,14 @@ func (c *Controller) UserByID(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, user)
|
||||
}
|
||||
|
||||
// UserByUsername is the API handler to fetch the given user by the username.
|
||||
func (c *Controller) UserByUsername(w http.ResponseWriter, r *http.Request) {
|
||||
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 := c.store.UserByUsername(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
|
||||
@@ -157,15 +176,14 @@ func (c *Controller) UserByUsername(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, user)
|
||||
}
|
||||
|
||||
// RemoveUser is the API handler to remove an existing user.
|
||||
func (c *Controller) RemoveUser(w http.ResponseWriter, r *http.Request) {
|
||||
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 := c.store.UserByID(userID)
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -176,10 +194,11 @@ func (c *Controller) RemoveUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.RemoveUser(user.ID); err != nil {
|
||||
json.BadRequest(w, r, errors.New("Unable to remove this user from the database"))
|
||||
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)
|
||||
}
|
||||
|
||||
+13
-3
@@ -10,17 +10,27 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func askCredentials() (string, string) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
||||
if !term.IsTerminal(fd) {
|
||||
fmt.Fprintf(os.Stderr, "This is not a terminal, exiting.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Print("Enter Username: ")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
username, _ := reader.ReadString('\n')
|
||||
|
||||
fmt.Print("Enter Password: ")
|
||||
bytePassword, _ := terminal.ReadPassword(0)
|
||||
|
||||
state, _ := term.GetState(fd)
|
||||
defer term.Restore(fd, state)
|
||||
bytePassword, _ := term.ReadPassword(fd)
|
||||
|
||||
fmt.Printf("\n")
|
||||
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
|
||||
|
||||
+109
-32
@@ -9,35 +9,43 @@ import (
|
||||
"fmt"
|
||||
|
||||
"miniflux.app/config"
|
||||
"miniflux.app/daemon"
|
||||
"miniflux.app/database"
|
||||
"miniflux.app/locale"
|
||||
"miniflux.app/logger"
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/ui/static"
|
||||
"miniflux.app/version"
|
||||
)
|
||||
|
||||
const (
|
||||
flagInfoHelp = "Show application information"
|
||||
flagVersionHelp = "Show application version"
|
||||
flagMigrateHelp = "Run SQL migrations"
|
||||
flagFlsuhSessionsHelp = "Flush all sessions (disconnect users)"
|
||||
flagCreateAdminHelp = "Create admin user"
|
||||
flagResetPasswordHelp = "Reset user password"
|
||||
flagInfoHelp = "Show application information"
|
||||
flagVersionHelp = "Show application version"
|
||||
flagMigrateHelp = "Run SQL migrations"
|
||||
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
|
||||
flagCreateAdminHelp = "Create admin user"
|
||||
flagResetPasswordHelp = "Reset user password"
|
||||
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
|
||||
flagDebugModeHelp = "Show debug logs"
|
||||
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).`
|
||||
)
|
||||
|
||||
// Parse parses command line arguments.
|
||||
func Parse() {
|
||||
var (
|
||||
flagInfo bool
|
||||
flagVersion bool
|
||||
flagMigrate bool
|
||||
flagFlushSessions bool
|
||||
flagCreateAdmin bool
|
||||
flagResetPassword bool
|
||||
err error
|
||||
flagInfo bool
|
||||
flagVersion bool
|
||||
flagMigrate bool
|
||||
flagFlushSessions bool
|
||||
flagCreateAdmin bool
|
||||
flagResetPassword bool
|
||||
flagResetFeedErrors bool
|
||||
flagDebugMode bool
|
||||
flagDebugMode bool
|
||||
flagConfigFile string
|
||||
flagConfigDump bool
|
||||
flagHealthCheck string
|
||||
)
|
||||
|
||||
flag.BoolVar(&flagInfo, "info", false, flagInfoHelp)
|
||||
@@ -45,26 +53,48 @@ func Parse() {
|
||||
flag.BoolVar(&flagVersion, "version", false, flagVersionHelp)
|
||||
flag.BoolVar(&flagVersion, "v", false, flagVersionHelp)
|
||||
flag.BoolVar(&flagMigrate, "migrate", false, flagMigrateHelp)
|
||||
flag.BoolVar(&flagFlushSessions, "flush-sessions", false, flagFlsuhSessionsHelp)
|
||||
flag.BoolVar(&flagFlushSessions, "flush-sessions", false, flagFlushSessionsHelp)
|
||||
flag.BoolVar(&flagCreateAdmin, "create-admin", false, flagCreateAdminHelp)
|
||||
flag.BoolVar(&flagResetPassword, "reset-password", false, flagResetPasswordHelp)
|
||||
flag.BoolVar(&flagResetFeedErrors, "reset-feed-errors", false, flagResetFeedErrorsHelp)
|
||||
flag.BoolVar(&flagDebugMode,"debug", false, flagDebugModeHelp)
|
||||
flag.BoolVar(&flagDebugMode, "debug", false, flagDebugModeHelp)
|
||||
flag.StringVar(&flagConfigFile, "config-file", "", flagConfigFileHelp)
|
||||
flag.StringVar(&flagConfigFile, "c", "", flagConfigFileHelp)
|
||||
flag.BoolVar(&flagConfigDump, "config-dump", false, flagConfigDumpHelp)
|
||||
flag.StringVar(&flagHealthCheck, "healthcheck", "", flagHealthCheckHelp)
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.NewConfig()
|
||||
cfg := config.NewParser()
|
||||
|
||||
if flagDebugMode || cfg.HasDebugMode() {
|
||||
if flagConfigFile != "" {
|
||||
config.Opts, err = cfg.ParseFile(flagConfigFile)
|
||||
if err != nil {
|
||||
logger.Fatal("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
config.Opts, err = cfg.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
logger.Fatal("%v", err)
|
||||
}
|
||||
|
||||
if flagConfigDump {
|
||||
fmt.Print(config.Opts)
|
||||
return
|
||||
}
|
||||
|
||||
if config.Opts.LogDateTime() {
|
||||
logger.EnableDateTime()
|
||||
}
|
||||
|
||||
if flagDebugMode || config.Opts.HasDebugMode() {
|
||||
logger.EnableDebug()
|
||||
}
|
||||
|
||||
db, err := database.NewConnectionPool(cfg.DatabaseURL(), cfg.DatabaseMinConns(), cfg.DatabaseMaxConns())
|
||||
if err != nil {
|
||||
logger.Fatal("Unable to connect to the database: %v", err)
|
||||
if flagHealthCheck != "" {
|
||||
doHealthCheck(flagHealthCheck)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
store := storage.NewStorage(db)
|
||||
|
||||
if flagInfo {
|
||||
info()
|
||||
@@ -76,8 +106,49 @@ func Parse() {
|
||||
return
|
||||
}
|
||||
|
||||
if config.Opts.IsDefaultDatabaseURL() {
|
||||
logger.Info("The default value for DATABASE_URL is used")
|
||||
}
|
||||
|
||||
logger.Debug("Loading translations...")
|
||||
if err := locale.LoadCatalogMessages(); err != nil {
|
||||
logger.Fatal("Unable to load translations: %v", err)
|
||||
}
|
||||
|
||||
logger.Debug("Loading static assets...")
|
||||
if err := static.CalculateBinaryFileChecksums(); err != nil {
|
||||
logger.Fatal("Unable to calculate binary files checksum: %v", err)
|
||||
}
|
||||
|
||||
if err := static.GenerateStylesheetsBundles(); err != nil {
|
||||
logger.Fatal("Unable to generate Stylesheet bundles: %v", err)
|
||||
}
|
||||
|
||||
if err := static.GenerateJavascriptBundles(); err != nil {
|
||||
logger.Fatal("Unable to generate Javascript bundles: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.NewConnectionPool(
|
||||
config.Opts.DatabaseURL(),
|
||||
config.Opts.DatabaseMinConns(),
|
||||
config.Opts.DatabaseMaxConns(),
|
||||
config.Opts.DatabaseConnectionLifetime(),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Fatal("Unable to initialize database connection pool: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
store := storage.NewStorage(db)
|
||||
|
||||
if err := store.Ping(); err != nil {
|
||||
logger.Fatal("Unable to connect to the database: %v", err)
|
||||
}
|
||||
|
||||
if flagMigrate {
|
||||
database.Migrate(db)
|
||||
if err := database.Migrate(db); err != nil {
|
||||
logger.Fatal(`%v`, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,15 +172,21 @@ func Parse() {
|
||||
return
|
||||
}
|
||||
|
||||
// Run migrations and start the deamon.
|
||||
if cfg.RunMigrations() {
|
||||
database.Migrate(db)
|
||||
// Run migrations and start the daemon.
|
||||
if config.Opts.RunMigrations() {
|
||||
if err := database.Migrate(db); err != nil {
|
||||
logger.Fatal(`%v`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create admin user and start the deamon.
|
||||
if cfg.CreateAdmin() {
|
||||
if err := database.IsSchemaUpToDate(db); err != nil {
|
||||
logger.Fatal(`You must run the SQL migrations, %v`, err)
|
||||
}
|
||||
|
||||
// Create admin user and start the daemon.
|
||||
if config.Opts.CreateAdmin() {
|
||||
createAdmin(store)
|
||||
}
|
||||
|
||||
daemon.Run(cfg, store)
|
||||
startDaemon(store)
|
||||
}
|
||||
|
||||
+17
-14
@@ -8,33 +8,36 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"miniflux.app/config"
|
||||
"miniflux.app/logger"
|
||||
"miniflux.app/model"
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/validator"
|
||||
)
|
||||
|
||||
func createAdmin(store *storage.Storage) {
|
||||
user := model.NewUser()
|
||||
user.Username = os.Getenv("ADMIN_USERNAME")
|
||||
user.Password = os.Getenv("ADMIN_PASSWORD")
|
||||
user.IsAdmin = true
|
||||
|
||||
if user.Username == "" || user.Password == "" {
|
||||
user.Username, user.Password = askCredentials()
|
||||
userCreationRequest := &model.UserCreationRequest{
|
||||
Username: config.Opts.AdminUsername(),
|
||||
Password: config.Opts.AdminPassword(),
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
if err := user.ValidateUserCreation(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
if userCreationRequest.Username == "" || userCreationRequest.Password == "" {
|
||||
userCreationRequest.Username, userCreationRequest.Password = askCredentials()
|
||||
}
|
||||
|
||||
if store.UserExists(user.Username) {
|
||||
logger.Info(`User %q already exists, skipping creation`, user.Username)
|
||||
if store.UserExists(userCreationRequest.Username) {
|
||||
logger.Info(`User %q already exists, skipping creation`, userCreationRequest.Username)
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.CreateUser(user); err != nil {
|
||||
fmt.Println(err)
|
||||
if validationErr := validator.ValidateUserCreationWithPassword(store, userCreationRequest); validationErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", validationErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if _, err := store.CreateUser(userCreationRequest); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cli // import "miniflux.app/cli"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"miniflux.app/config"
|
||||
"miniflux.app/logger"
|
||||
"miniflux.app/metric"
|
||||
"miniflux.app/service/httpd"
|
||||
"miniflux.app/service/scheduler"
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/systemd"
|
||||
"miniflux.app/worker"
|
||||
)
|
||||
|
||||
func startDaemon(store *storage.Storage) {
|
||||
logger.Info("Starting Miniflux...")
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt)
|
||||
signal.Notify(stop, syscall.SIGTERM)
|
||||
|
||||
pool := worker.NewPool(store, config.Opts.WorkerPoolSize())
|
||||
|
||||
if config.Opts.HasSchedulerService() && !config.Opts.HasMaintenanceMode() {
|
||||
scheduler.Serve(store, pool)
|
||||
}
|
||||
|
||||
var httpServer *http.Server
|
||||
if config.Opts.HasHTTPService() {
|
||||
httpServer = httpd.Serve(store, pool)
|
||||
}
|
||||
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
collector := metric.NewCollector(store, config.Opts.MetricsRefreshInterval())
|
||||
go collector.GatherStorageMetrics()
|
||||
}
|
||||
|
||||
if systemd.HasNotifySocket() {
|
||||
logger.Info("Sending readiness notification to Systemd")
|
||||
|
||||
if err := systemd.SdNotify(systemd.SdNotifyReady); err != nil {
|
||||
logger.Error("Unable to send readiness notification to systemd: %v", err)
|
||||
}
|
||||
|
||||
if config.Opts.HasWatchdog() && systemd.HasSystemdWatchdog() {
|
||||
logger.Info("Activating Systemd watchdog")
|
||||
|
||||
go func() {
|
||||
interval, err := systemd.WatchdogInterval()
|
||||
if err != nil {
|
||||
logger.Error("Unable to parse watchdog interval from systemd: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
err := store.Ping()
|
||||
if err != nil {
|
||||
logger.Error(`Systemd Watchdog: %v`, err)
|
||||
} else {
|
||||
systemd.SdNotify(systemd.SdNotifyWatchdog)
|
||||
}
|
||||
|
||||
time.Sleep(interval / 3)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
<-stop
|
||||
logger.Info("Shutting down the process...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if httpServer != nil {
|
||||
httpServer.Shutdown(ctx)
|
||||
}
|
||||
|
||||
logger.Info("Process gracefully stopped")
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
|
||||
Package cli implements command line arguments for Miniflux application.
|
||||
|
||||
*/
|
||||
package cli // import "miniflux.app/cli"
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func flushSessions(store *storage.Storage) {
|
||||
fmt.Println("Flushing all sessions (disconnect users)")
|
||||
if err := store.FlushAllSessions(); err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2021 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cli // import "miniflux.app/cli"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/config"
|
||||
"miniflux.app/logger"
|
||||
)
|
||||
|
||||
func doHealthCheck(healthCheckEndpoint string) {
|
||||
if healthCheckEndpoint == "auto" {
|
||||
healthCheckEndpoint = "http://" + config.Opts.ListenAddr() + config.Opts.BasePath() + "/healthcheck"
|
||||
}
|
||||
|
||||
logger.Debug(`Executing health check on %s`, healthCheckEndpoint)
|
||||
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(healthCheckEndpoint)
|
||||
if err != nil {
|
||||
logger.Fatal(`Health check failure: %v`, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
logger.Fatal(`Health check failed with status code %d`, resp.StatusCode)
|
||||
}
|
||||
|
||||
logger.Debug(`Health check is OK`)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
func info() {
|
||||
fmt.Println("Version:", version.Version)
|
||||
fmt.Println("Commit:", version.Commit)
|
||||
fmt.Println("Build Date:", version.BuildDate)
|
||||
fmt.Println("Go Version:", runtime.Version())
|
||||
fmt.Println("Compiler:", runtime.Compiler)
|
||||
|
||||
+13
-8
@@ -8,30 +8,35 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"miniflux.app/model"
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/validator"
|
||||
)
|
||||
|
||||
func resetPassword(store *storage.Storage) {
|
||||
username, password := askCredentials()
|
||||
user, err := store.UserByUsername(username)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
fmt.Println("User not found!")
|
||||
fmt.Fprintf(os.Stderr, "User not found!\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
userModificationRequest := &model.UserModificationRequest{
|
||||
Password: &password,
|
||||
}
|
||||
if validationErr := validator.ValidateUserModification(store, user.ID, userModificationRequest); validationErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", validationErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
user.Password = password
|
||||
if err := user.ValidatePassword(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := store.UpdateUser(user); err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -1,6 +1,8 @@
|
||||
Miniflux API Client
|
||||
===================
|
||||
|
||||
[](https://pkg.go.dev/miniflux.app/client)
|
||||
|
||||
Client library for Miniflux REST API.
|
||||
|
||||
Installation
|
||||
@@ -18,14 +20,18 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
miniflux "miniflux.app/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Authentication with username/password:
|
||||
client := miniflux.New("https://api.example.org", "admin", "secret")
|
||||
|
||||
// Authentication with an API Key:
|
||||
client := miniflux.New("https://api.example.org", "my-secret-token")
|
||||
|
||||
// Fetch all feeds.
|
||||
feeds, err := client.Feeds()
|
||||
if err != nil {
|
||||
@@ -41,7 +47,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile("opml.xml", opml, 0644)
|
||||
err = os.WriteFile("opml.xml", opml, 0644)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
|
||||
+145
-57
@@ -8,9 +8,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Client holds API procedure calls.
|
||||
@@ -18,6 +18,19 @@ type Client struct {
|
||||
request *request
|
||||
}
|
||||
|
||||
// New returns a new Miniflux client.
|
||||
func New(endpoint string, credentials ...string) *Client {
|
||||
// Web gives "API Endpoint = https://miniflux.app/v1/", it doesn't work (/v1/v1/me)
|
||||
endpoint = strings.TrimSuffix(endpoint, "/")
|
||||
endpoint = strings.TrimSuffix(endpoint, "/v1")
|
||||
// trim to https://miniflux.app
|
||||
|
||||
if len(credentials) == 2 {
|
||||
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
|
||||
}
|
||||
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
|
||||
}
|
||||
|
||||
// Me returns the logged user information.
|
||||
func (c *Client) Me() (*User, error) {
|
||||
body, err := c.request.Get("/v1/me")
|
||||
@@ -88,7 +101,11 @@ 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", &User{Username: username, Password: password, IsAdmin: isAdmin})
|
||||
body, err := c.request.Post("/v1/users", &UserCreationRequest{
|
||||
Username: username,
|
||||
Password: password,
|
||||
IsAdmin: isAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -104,7 +121,7 @@ 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 *UserModification) (*User, error) {
|
||||
func (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest) (*User, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/users/%d", userID), userChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -122,12 +139,13 @@ func (c *Client) UpdateUser(userID int64, userChanges *UserModification) (*User,
|
||||
|
||||
// DeleteUser removes a user from the system.
|
||||
func (c *Client) DeleteUser(userID int64) error {
|
||||
body, err := c.request.Delete(fmt.Sprintf("/v1/users/%d", userID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body.Close()
|
||||
return nil
|
||||
return c.request.Delete(fmt.Sprintf("/v1/users/%d", userID))
|
||||
}
|
||||
|
||||
// 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)
|
||||
return err
|
||||
}
|
||||
|
||||
// Discover try to find subscriptions from a website.
|
||||
@@ -169,7 +187,6 @@ func (c *Client) CreateCategory(title string) (*Category, error) {
|
||||
body, err := c.request.Post("/v1/categories", map[string]interface{}{
|
||||
"title": title,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -189,7 +206,6 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), map[string]interface{}{
|
||||
"title": title,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -204,15 +220,38 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// DeleteCategory removes a category.
|
||||
func (c *Client) DeleteCategory(categoryID int64) error {
|
||||
body, err := c.request.Delete(fmt.Sprintf("/v1/categories/%d", categoryID))
|
||||
// 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)
|
||||
return err
|
||||
}
|
||||
|
||||
// CategoryFeeds gets feeds of a category.
|
||||
func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
return nil
|
||||
var feeds Feeds
|
||||
decoder := json.NewDecoder(body)
|
||||
if err := decoder.Decode(&feeds); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
// DeleteCategory removes a category.
|
||||
func (c *Client) DeleteCategory(categoryID int64) error {
|
||||
return c.request.Delete(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)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feeds gets all feeds.
|
||||
@@ -240,7 +279,7 @@ func (c *Client) Export() ([]byte, error) {
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
opml, err := ioutil.ReadAll(body)
|
||||
opml, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -272,11 +311,8 @@ func (c *Client) Feed(feedID int64) (*Feed, error) {
|
||||
}
|
||||
|
||||
// CreateFeed creates a new feed.
|
||||
func (c *Client) CreateFeed(url string, categoryID int64) (int64, error) {
|
||||
body, err := c.request.Post("/v1/feeds", map[string]interface{}{
|
||||
"feed_url": url,
|
||||
"category_id": categoryID,
|
||||
})
|
||||
func (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, error) {
|
||||
body, err := c.request.Post("/v1/feeds", feedCreationRequest)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -296,7 +332,7 @@ func (c *Client) CreateFeed(url string, categoryID int64) (int64, error) {
|
||||
}
|
||||
|
||||
// UpdateFeed updates a feed.
|
||||
func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModification) (*Feed, error) {
|
||||
func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -304,32 +340,34 @@ func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModification) (*Feed,
|
||||
defer body.Close()
|
||||
|
||||
var f *Feed
|
||||
decoder := json.NewDecoder(body)
|
||||
if err := decoder.Decode(&f); err != nil {
|
||||
if err := json.NewDecoder(body).Decode(&f); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// RefreshFeed refresh a feed.
|
||||
// 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)
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshAllFeeds refreshes all feeds.
|
||||
func (c *Client) RefreshAllFeeds() error {
|
||||
_, err := c.request.Put("/v1/feeds/refresh", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshFeed refreshes a feed.
|
||||
func (c *Client) RefreshFeed(feedID int64) error {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body.Close()
|
||||
return nil
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteFeed removes a feed.
|
||||
func (c *Client) DeleteFeed(feedID int64) error {
|
||||
body, err := c.request.Delete(fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body.Close()
|
||||
return nil
|
||||
return c.request.Delete(fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
}
|
||||
|
||||
// FeedIcon gets a feed icon.
|
||||
@@ -366,6 +404,23 @@ func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// 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))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var entry *Entry
|
||||
decoder := json.NewDecoder(body)
|
||||
if err := decoder.Decode(&entry); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Entry gets a single entry.
|
||||
func (c *Client) Entry(entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d", entryID))
|
||||
@@ -421,6 +476,25 @@ func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, err
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CategoryEntries fetch entries of a category.
|
||||
func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
path := buildFilterQueryString(fmt.Sprintf("/v1/categories/%d/entries", categoryID), filter)
|
||||
|
||||
body, err := c.request.Get(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var result EntryResultSet
|
||||
decoder := json.NewDecoder(body)
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UpdateEntries updates the status of a list of entries.
|
||||
func (c *Client) UpdateEntries(entryIDs []int64, status string) error {
|
||||
type payload struct {
|
||||
@@ -428,29 +502,31 @@ func (c *Client) UpdateEntries(entryIDs []int64, status string) error {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
body, err := c.request.Put("/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body.Close()
|
||||
|
||||
return nil
|
||||
_, err := c.request.Put("/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
|
||||
return err
|
||||
}
|
||||
|
||||
// ToggleBookmark toggles entry bookmark value.
|
||||
func (c *Client) ToggleBookmark(entryID int64) error {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/bookmark", entryID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body.Close()
|
||||
|
||||
return nil
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/bookmark", entryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// New returns a new Miniflux client.
|
||||
func New(endpoint, username, password string) *Client {
|
||||
return &Client{request: &request{endpoint: endpoint, username: username, password: password}}
|
||||
// FetchCounters
|
||||
func (c *Client) FetchCounters() (*FeedCounters, error) {
|
||||
body, err := c.request.Get("/v1/feeds/counters")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var result FeedCounters
|
||||
decoder := json.NewDecoder(body)
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func buildFilterQueryString(path string, filter *Filter) string {
|
||||
@@ -493,14 +569,26 @@ func buildFilterQueryString(path string, filter *Filter) string {
|
||||
values.Set("before_entry_id", strconv.FormatInt(filter.BeforeEntryID, 10))
|
||||
}
|
||||
|
||||
if filter.Starred {
|
||||
values.Set("starred", "1")
|
||||
if filter.Starred != "" {
|
||||
values.Set("starred", filter.Starred)
|
||||
}
|
||||
|
||||
if filter.Search != "" {
|
||||
values.Set("search", filter.Search)
|
||||
}
|
||||
|
||||
if filter.CategoryID > 0 {
|
||||
values.Set("category_id", strconv.FormatInt(filter.CategoryID, 10))
|
||||
}
|
||||
|
||||
if filter.FeedID > 0 {
|
||||
values.Set("feed_id", strconv.FormatInt(filter.FeedID, 10))
|
||||
}
|
||||
|
||||
for _, status := range filter.Statuses {
|
||||
values.Add("status", status)
|
||||
}
|
||||
|
||||
path = fmt.Sprintf("%s?%s", path, values.Encode())
|
||||
}
|
||||
|
||||
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package client // import "miniflux.app/client"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry statuses.
|
||||
const (
|
||||
EntryStatusUnread = "unread"
|
||||
EntryStatusRead = "read"
|
||||
EntryStatusRemoved = "removed"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
}
|
||||
|
||||
func (u User) String() string {
|
||||
return fmt.Sprintf("#%d - %s (admin=%v)", u.ID, u.Username, u.IsAdmin)
|
||||
}
|
||||
|
||||
// UserModification is used to update a user.
|
||||
type UserModification 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"`
|
||||
}
|
||||
|
||||
// Users represents a list of users.
|
||||
type Users []User
|
||||
|
||||
// Category represents a category in the system.
|
||||
type Category struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
func (c Category) String() string {
|
||||
return fmt.Sprintf("#%d %s", c.ID, c.Title)
|
||||
}
|
||||
|
||||
// Categories represents a list of categories.
|
||||
type Categories []*Category
|
||||
|
||||
// Subscription represents a feed subscription.
|
||||
type Subscription struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (s Subscription) String() string {
|
||||
return fmt.Sprintf(`Title="%s", URL="%s", Type="%s"`, s.Title, s.URL, s.Type)
|
||||
}
|
||||
|
||||
// Subscriptions represents a list of subscriptions.
|
||||
type Subscriptions []*Subscription
|
||||
|
||||
// Feed represents a Miniflux feed.
|
||||
type Feed struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
Title string `json:"title"`
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
EtagHeader string `json:"etag_header,omitempty"`
|
||||
LastModifiedHeader string `json:"last_modified_header,omitempty"`
|
||||
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
|
||||
ParsingErrorCount int `json:"parsing_error_count,omitempty"`
|
||||
ScraperRules string `json:"scraper_rules"`
|
||||
RewriteRules string `json:"rewrite_rules"`
|
||||
Crawler bool `json:"crawler"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Category *Category `json:"category,omitempty"`
|
||||
Entries Entries `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
// FeedModification represents changes for a feed.
|
||||
type FeedModification struct {
|
||||
FeedURL *string `json:"feed_url"`
|
||||
SiteURL *string `json:"site_url"`
|
||||
Title *string `json:"title"`
|
||||
ScraperRules *string `json:"scraper_rules"`
|
||||
RewriteRules *string `json:"rewrite_rules"`
|
||||
Crawler *bool `json:"crawler"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
}
|
||||
|
||||
// FeedIcon represents the feed icon.
|
||||
type FeedIcon struct {
|
||||
ID int64 `json:"id"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// Feeds represents a list of feeds.
|
||||
type Feeds []*Feed
|
||||
|
||||
// Entry represents a subscription item in the system.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
Status string `json:"status"`
|
||||
Hash string `json:"hash"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Date time.Time `json:"published_at"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
Starred bool `json:"starred"`
|
||||
Enclosures Enclosures `json:"enclosures,omitempty"`
|
||||
Feed *Feed `json:"feed,omitempty"`
|
||||
Category *Category `json:"category,omitempty"`
|
||||
}
|
||||
|
||||
// Entries represents a list of entries.
|
||||
type Entries []*Entry
|
||||
|
||||
// Enclosure represents an attachment.
|
||||
type Enclosure struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
EntryID int64 `json:"entry_id"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// Enclosures represents a list of attachments.
|
||||
type Enclosures []*Enclosure
|
||||
|
||||
// Filter is used to filter entries.
|
||||
type Filter struct {
|
||||
Status string
|
||||
Offset int
|
||||
Limit int
|
||||
Order string
|
||||
Direction string
|
||||
Starred bool
|
||||
Before int64
|
||||
After int64
|
||||
BeforeEntryID int64
|
||||
AfterEntryID int64
|
||||
Search string
|
||||
}
|
||||
|
||||
// EntryResultSet represents the response when fetching entries.
|
||||
type EntryResultSet struct {
|
||||
Total int `json:"total"`
|
||||
Entries Entries `json:"entries"`
|
||||
}
|
||||
+1
-3
@@ -3,10 +3,9 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
|
||||
Package client implements a client library for the Miniflux REST API.
|
||||
|
||||
Examples
|
||||
# Examples
|
||||
|
||||
This code snippet fetch the list of users:
|
||||
|
||||
@@ -30,6 +29,5 @@ This one discover subscriptions on a website:
|
||||
return
|
||||
}
|
||||
fmt.Println(subscriptions)
|
||||
|
||||
*/
|
||||
package client // import "miniflux.app/client"
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package client // import "miniflux.app/client"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry statuses.
|
||||
const (
|
||||
EntryStatusUnread = "unread"
|
||||
EntryStatusRead = "read"
|
||||
EntryStatusRemoved = "removed"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (u User) String() string {
|
||||
return fmt.Sprintf("#%d - %s (admin=%v)", u.ID, u.Username, u.IsAdmin)
|
||||
}
|
||||
|
||||
// UserCreationRequest represents the request to create a user.
|
||||
type UserCreationRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
GoogleID string `json:"google_id"`
|
||||
OpenIDConnectID string `json:"openid_connect_id"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// Users represents a list of users.
|
||||
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"`
|
||||
}
|
||||
|
||||
func (c Category) String() string {
|
||||
return fmt.Sprintf("#%d %s", c.ID, c.Title)
|
||||
}
|
||||
|
||||
// Categories represents a list of categories.
|
||||
type Categories []*Category
|
||||
|
||||
// Subscription represents a feed subscription.
|
||||
type Subscription struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (s Subscription) String() string {
|
||||
return fmt.Sprintf(`Title="%s", URL="%s", Type="%s"`, s.Title, s.URL, s.Type)
|
||||
}
|
||||
|
||||
// Subscriptions represents a list of subscriptions.
|
||||
type Subscriptions []*Subscription
|
||||
|
||||
// Feed represents a Miniflux feed.
|
||||
type Feed struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
Title string `json:"title"`
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
EtagHeader string `json:"etag_header,omitempty"`
|
||||
LastModifiedHeader string `json:"last_modified_header,omitempty"`
|
||||
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
|
||||
ParsingErrorCount int `json:"parsing_error_count,omitempty"`
|
||||
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"`
|
||||
BlocklistRules string `json:"blocklist_rules"`
|
||||
KeeplistRules string `json:"keeplist_rules"`
|
||||
Crawler bool `json:"crawler"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Cookie string `json:"cookie"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Category *Category `json:"category,omitempty"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
}
|
||||
|
||||
// FeedCreationRequest represents the request to create a feed.
|
||||
type FeedCreationRequest struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
CategoryID int64 `json:"category_id"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Cookie string `json:"cookie"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Crawler bool `json:"crawler"`
|
||||
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"`
|
||||
BlocklistRules string `json:"blocklist_rules"`
|
||||
KeeplistRules string `json:"keeplist_rules"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
}
|
||||
|
||||
// FeedModificationRequest represents the request to update a feed.
|
||||
type FeedModificationRequest struct {
|
||||
FeedURL *string `json:"feed_url"`
|
||||
SiteURL *string `json:"site_url"`
|
||||
Title *string `json:"title"`
|
||||
ScraperRules *string `json:"scraper_rules"`
|
||||
RewriteRules *string `json:"rewrite_rules"`
|
||||
BlocklistRules *string `json:"blocklist_rules"`
|
||||
KeeplistRules *string `json:"keeplist_rules"`
|
||||
Crawler *bool `json:"crawler"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
Cookie *string `json:"cookie"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy *bool `json:"fetch_via_proxy"`
|
||||
HideGlobally *bool `json:"hide_globally"`
|
||||
}
|
||||
|
||||
// FeedIcon represents the feed icon.
|
||||
type FeedIcon struct {
|
||||
ID int64 `json:"id"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type FeedCounters struct {
|
||||
ReadCounters map[int64]int `json:"reads"`
|
||||
UnreadCounters map[int64]int `json:"unreads"`
|
||||
}
|
||||
|
||||
// Feeds represents a list of feeds.
|
||||
type Feeds []*Feed
|
||||
|
||||
// Entry represents a subscription item in the system.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
Status string `json:"status"`
|
||||
Hash string `json:"hash"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
CommentsURL string `json:"comments_url"`
|
||||
Date time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
ShareCode string `json:"share_code"`
|
||||
Starred bool `json:"starred"`
|
||||
ReadingTime int `json:"reading_time"`
|
||||
Enclosures Enclosures `json:"enclosures,omitempty"`
|
||||
Feed *Feed `json:"feed,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// Entries represents a list of entries.
|
||||
type Entries []*Entry
|
||||
|
||||
// Enclosure represents an attachment.
|
||||
type Enclosure struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
EntryID int64 `json:"entry_id"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// Enclosures represents a list of attachments.
|
||||
type Enclosures []*Enclosure
|
||||
|
||||
const (
|
||||
FilterNotStarred = "0"
|
||||
FilterOnlyStarred = "1"
|
||||
)
|
||||
|
||||
// Filter is used to filter entries.
|
||||
type Filter struct {
|
||||
Status string
|
||||
Offset int
|
||||
Limit int
|
||||
Order string
|
||||
Direction string
|
||||
Starred string
|
||||
Before int64
|
||||
After int64
|
||||
BeforeEntryID int64
|
||||
AfterEntryID int64
|
||||
Search string
|
||||
CategoryID int64
|
||||
FeedID int64
|
||||
Statuses []string
|
||||
}
|
||||
|
||||
// EntryResultSet represents the response when fetching entries.
|
||||
type EntryResultSet struct {
|
||||
Total int `json:"total"`
|
||||
Entries Entries `json:"entries"`
|
||||
}
|
||||
+31
-9
@@ -10,7 +10,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -18,7 +17,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
userAgent = "Miniflux Client Library <https://github.com/miniflux/miniflux-go>"
|
||||
userAgent = "Miniflux Client Library"
|
||||
defaultTimeout = 80
|
||||
)
|
||||
|
||||
@@ -38,6 +37,7 @@ type request struct {
|
||||
endpoint string
|
||||
username string
|
||||
password string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func (r *request) Get(path string) (io.ReadCloser, error) {
|
||||
@@ -56,8 +56,9 @@ func (r *request) Put(path string, data interface{}) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPut, path, data)
|
||||
}
|
||||
|
||||
func (r *request) Delete(path string) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodDelete, path, nil)
|
||||
func (r *request) Delete(path string) error {
|
||||
_, err := r.execute(http.MethodDelete, path, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *request) execute(method, path string, data interface{}) (io.ReadCloser, error) {
|
||||
@@ -75,14 +76,17 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
|
||||
Method: method,
|
||||
Header: r.buildHeaders(),
|
||||
}
|
||||
request.SetBasicAuth(r.username, r.password)
|
||||
|
||||
if r.username != "" && r.password != "" {
|
||||
request.SetBasicAuth(r.username, r.password)
|
||||
}
|
||||
|
||||
if data != nil {
|
||||
switch data.(type) {
|
||||
switch data := data.(type) {
|
||||
case io.ReadCloser:
|
||||
request.Body = data.(io.ReadCloser)
|
||||
request.Body = data
|
||||
default:
|
||||
request.Body = ioutil.NopCloser(bytes.NewBuffer(r.toJSON(data)))
|
||||
request.Body = io.NopCloser(bytes.NewBuffer(r.toJSON(data)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,13 +98,27 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
|
||||
|
||||
switch response.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
response.Body.Close()
|
||||
return nil, ErrNotAuthorized
|
||||
case http.StatusForbidden:
|
||||
response.Body.Close()
|
||||
return nil, ErrForbidden
|
||||
case http.StatusInternalServerError:
|
||||
return nil, ErrServerError
|
||||
defer response.Body.Close()
|
||||
|
||||
var resp errorResponse
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
// If we failed to decode, just return a generic ErrServerError
|
||||
if err := decoder.Decode(&resp); err != nil {
|
||||
return nil, ErrServerError
|
||||
}
|
||||
return nil, errors.New("miniflux: internal server error: " + resp.ErrorMessage)
|
||||
case http.StatusNotFound:
|
||||
response.Body.Close()
|
||||
return nil, ErrNotFound
|
||||
case http.StatusNoContent:
|
||||
response.Body.Close()
|
||||
return nil, nil
|
||||
case http.StatusBadRequest:
|
||||
defer response.Body.Close()
|
||||
|
||||
@@ -114,6 +132,7 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
|
||||
}
|
||||
|
||||
if response.StatusCode > 400 {
|
||||
response.Body.Close()
|
||||
return nil, fmt.Errorf("miniflux: status code=%d", response.StatusCode)
|
||||
}
|
||||
|
||||
@@ -131,6 +150,9 @@ func (r *request) buildHeaders() http.Header {
|
||||
headers.Add("User-Agent", userAgent)
|
||||
headers.Add("Content-Type", "application/json")
|
||||
headers.Add("Accept", "application/json")
|
||||
if r.apiKey != "" {
|
||||
headers.Add("X-Auth-Token", r.apiKey)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
|
||||
+2
-252
@@ -4,255 +4,5 @@
|
||||
|
||||
package config // import "miniflux.app/config"
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "http://localhost"
|
||||
defaultDatabaseURL = "postgres://postgres:postgres@localhost/miniflux2?sslmode=disable"
|
||||
defaultWorkerPoolSize = 5
|
||||
defaultPollingFrequency = 60
|
||||
defaultBatchSize = 10
|
||||
defaultDatabaseMaxConns = 20
|
||||
defaultDatabaseMinConns = 1
|
||||
defaultListenAddr = "127.0.0.1:8080"
|
||||
defaultCertFile = ""
|
||||
defaultKeyFile = ""
|
||||
defaultCertDomain = ""
|
||||
defaultCertCache = "/tmp/cert_cache"
|
||||
defaultCleanupFrequency = 24
|
||||
defaultProxyImages = "http-only"
|
||||
defaultOAuth2ClientID = ""
|
||||
defaultOAuth2ClientSecret = ""
|
||||
defaultOAuth2RedirectURL = ""
|
||||
defaultOAuth2Provider = ""
|
||||
)
|
||||
|
||||
// Config manages configuration parameters.
|
||||
type Config struct {
|
||||
IsHTTPS bool
|
||||
baseURL string
|
||||
rootURL string
|
||||
basePath string
|
||||
}
|
||||
|
||||
func (c *Config) parseBaseURL() {
|
||||
baseURL := os.Getenv("BASE_URL")
|
||||
if baseURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if baseURL[len(baseURL)-1:] == "/" {
|
||||
baseURL = baseURL[:len(baseURL)-1]
|
||||
}
|
||||
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
logger.Error("Invalid BASE_URL: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "https" && scheme != "http" {
|
||||
logger.Error("Invalid BASE_URL: scheme must be http or https")
|
||||
return
|
||||
}
|
||||
|
||||
c.baseURL = baseURL
|
||||
c.basePath = u.Path
|
||||
|
||||
u.Path = ""
|
||||
c.rootURL = u.String()
|
||||
}
|
||||
|
||||
// HasDebugMode returns true if debug mode is enabled.
|
||||
func (c *Config) HasDebugMode() bool {
|
||||
return getBooleanValue("DEBUG")
|
||||
}
|
||||
|
||||
// BaseURL returns the application base URL with path.
|
||||
func (c *Config) BaseURL() string {
|
||||
return c.baseURL
|
||||
}
|
||||
|
||||
// RootURL returns the base URL without path.
|
||||
func (c *Config) RootURL() string {
|
||||
return c.rootURL
|
||||
}
|
||||
|
||||
// BasePath returns the application base path according to the base URL.
|
||||
func (c *Config) BasePath() string {
|
||||
return c.basePath
|
||||
}
|
||||
|
||||
// DatabaseURL returns the database URL.
|
||||
func (c *Config) DatabaseURL() string {
|
||||
value, exists := os.LookupEnv("DATABASE_URL")
|
||||
if !exists {
|
||||
logger.Info("The environment variable DATABASE_URL is not configured (the default value is used instead)")
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
value = defaultDatabaseURL
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// DatabaseMaxConns returns the maximum number of database connections.
|
||||
func (c *Config) DatabaseMaxConns() int {
|
||||
return getIntValue("DATABASE_MAX_CONNS", defaultDatabaseMaxConns)
|
||||
}
|
||||
|
||||
// DatabaseMinConns returns the minimum number of database connections.
|
||||
func (c *Config) DatabaseMinConns() int {
|
||||
return getIntValue("DATABASE_MIN_CONNS", defaultDatabaseMinConns)
|
||||
}
|
||||
|
||||
// ListenAddr returns the listen address for the HTTP server.
|
||||
func (c *Config) ListenAddr() string {
|
||||
if port := os.Getenv("PORT"); port != "" {
|
||||
return ":" + port
|
||||
}
|
||||
|
||||
return getStringValue("LISTEN_ADDR", defaultListenAddr)
|
||||
}
|
||||
|
||||
// CertFile returns the SSL certificate filename if any.
|
||||
func (c *Config) CertFile() string {
|
||||
return getStringValue("CERT_FILE", defaultCertFile)
|
||||
}
|
||||
|
||||
// KeyFile returns the private key filename for custom SSL certificate.
|
||||
func (c *Config) KeyFile() string {
|
||||
return getStringValue("KEY_FILE", defaultKeyFile)
|
||||
}
|
||||
|
||||
// CertDomain returns the domain to use for Let's Encrypt certificate.
|
||||
func (c *Config) CertDomain() string {
|
||||
return getStringValue("CERT_DOMAIN", defaultCertDomain)
|
||||
}
|
||||
|
||||
// CertCache returns the directory to use for Let's Encrypt session cache.
|
||||
func (c *Config) CertCache() string {
|
||||
return getStringValue("CERT_CACHE", defaultCertCache)
|
||||
}
|
||||
|
||||
// CleanupFrequency returns the interval for cleanup jobs.
|
||||
func (c *Config) CleanupFrequency() int {
|
||||
return getIntValue("CLEANUP_FREQUENCY", defaultCleanupFrequency)
|
||||
}
|
||||
|
||||
// WorkerPoolSize returns the number of background worker.
|
||||
func (c *Config) WorkerPoolSize() int {
|
||||
return getIntValue("WORKER_POOL_SIZE", defaultWorkerPoolSize)
|
||||
}
|
||||
|
||||
// PollingFrequency returns the interval to refresh feeds in the background.
|
||||
func (c *Config) PollingFrequency() int {
|
||||
return getIntValue("POLLING_FREQUENCY", defaultPollingFrequency)
|
||||
}
|
||||
|
||||
// BatchSize returns the number of feeds to send for background processing.
|
||||
func (c *Config) BatchSize() int {
|
||||
return getIntValue("BATCH_SIZE", defaultBatchSize)
|
||||
}
|
||||
|
||||
// IsOAuth2UserCreationAllowed returns true if user creation is allowed for OAuth2 users.
|
||||
func (c *Config) IsOAuth2UserCreationAllowed() bool {
|
||||
return getBooleanValue("OAUTH2_USER_CREATION")
|
||||
}
|
||||
|
||||
// OAuth2ClientID returns the OAuth2 Client ID.
|
||||
func (c *Config) OAuth2ClientID() string {
|
||||
return getStringValue("OAUTH2_CLIENT_ID", defaultOAuth2ClientID)
|
||||
}
|
||||
|
||||
// OAuth2ClientSecret returns the OAuth2 client secret.
|
||||
func (c *Config) OAuth2ClientSecret() string {
|
||||
return getStringValue("OAUTH2_CLIENT_SECRET", defaultOAuth2ClientSecret)
|
||||
}
|
||||
|
||||
// OAuth2RedirectURL returns the OAuth2 redirect URL.
|
||||
func (c *Config) OAuth2RedirectURL() string {
|
||||
return getStringValue("OAUTH2_REDIRECT_URL", defaultOAuth2RedirectURL)
|
||||
}
|
||||
|
||||
// OAuth2Provider returns the name of the OAuth2 provider configured.
|
||||
func (c *Config) OAuth2Provider() string {
|
||||
return getStringValue("OAUTH2_PROVIDER", defaultOAuth2Provider)
|
||||
}
|
||||
|
||||
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
|
||||
func (c *Config) HasHSTS() bool {
|
||||
return !getBooleanValue("DISABLE_HSTS")
|
||||
}
|
||||
|
||||
// RunMigrations returns true if the environment variable RUN_MIGRATIONS is not empty.
|
||||
func (c *Config) RunMigrations() bool {
|
||||
return getBooleanValue("RUN_MIGRATIONS")
|
||||
}
|
||||
|
||||
// CreateAdmin returns true if the environment variable CREATE_ADMIN is not empty.
|
||||
func (c *Config) CreateAdmin() bool {
|
||||
return getBooleanValue("CREATE_ADMIN")
|
||||
}
|
||||
|
||||
// PocketConsumerKey returns the Pocket Consumer Key if defined as environment variable.
|
||||
func (c *Config) PocketConsumerKey(defaultValue string) string {
|
||||
return getStringValue("POCKET_CONSUMER_KEY", defaultValue)
|
||||
}
|
||||
|
||||
// ProxyImages returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
|
||||
func (c *Config) ProxyImages() string {
|
||||
return getStringValue("PROXY_IMAGES", defaultProxyImages)
|
||||
}
|
||||
|
||||
// NewConfig returns a new Config.
|
||||
func NewConfig() *Config {
|
||||
cfg := &Config{
|
||||
baseURL: defaultBaseURL,
|
||||
rootURL: defaultBaseURL,
|
||||
IsHTTPS: getBooleanValue("HTTPS"),
|
||||
}
|
||||
|
||||
cfg.parseBaseURL()
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getBooleanValue(key string) bool {
|
||||
value := strings.ToLower(os.Getenv(key))
|
||||
if value == "1" || value == "yes" || value == "true" || value == "on" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getStringValue(key, fallback string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func getIntValue(key string, fallback int) int {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
// Opts holds parsed configuration options.
|
||||
var Opts *Options
|
||||
|
||||
+1135
-259
File diff suppressed because it is too large
Load Diff
+2
-4
@@ -1,10 +1,8 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Copyright 2019 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
|
||||
Package config handles configuration values for the application.
|
||||
|
||||
Package config handles configuration management for the application.
|
||||
*/
|
||||
package config // import "miniflux.app/config"
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
// Copyright 2019 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package config // import "miniflux.app/config"
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/version"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPS = false
|
||||
defaultLogDateTime = false
|
||||
defaultHSTS = true
|
||||
defaultHTTPService = true
|
||||
defaultSchedulerService = true
|
||||
defaultDebug = false
|
||||
defaultTiming = false
|
||||
defaultBaseURL = "http://localhost"
|
||||
defaultRootURL = "http://localhost"
|
||||
defaultBasePath = ""
|
||||
defaultWorkerPoolSize = 5
|
||||
defaultPollingFrequency = 60
|
||||
defaultBatchSize = 100
|
||||
defaultPollingScheduler = "round_robin"
|
||||
defaultSchedulerEntryFrequencyMinInterval = 5
|
||||
defaultSchedulerEntryFrequencyMaxInterval = 24 * 60
|
||||
defaultPollingParsingErrorLimit = 3
|
||||
defaultRunMigrations = false
|
||||
defaultDatabaseURL = "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
|
||||
defaultDatabaseMaxConns = 20
|
||||
defaultDatabaseMinConns = 1
|
||||
defaultDatabaseConnectionLifetime = 5
|
||||
defaultListenAddr = "127.0.0.1:8080"
|
||||
defaultCertFile = ""
|
||||
defaultKeyFile = ""
|
||||
defaultCertDomain = ""
|
||||
defaultCleanupFrequencyHours = 24
|
||||
defaultCleanupArchiveReadDays = 60
|
||||
defaultCleanupArchiveUnreadDays = 180
|
||||
defaultCleanupArchiveBatchSize = 10000
|
||||
defaultCleanupRemoveSessionsDays = 30
|
||||
defaultProxyHTTPClientTimeout = 120
|
||||
defaultProxyOption = "http-only"
|
||||
defaultProxyMediaTypes = "image"
|
||||
defaultProxyUrl = ""
|
||||
defaultFetchYouTubeWatchTime = false
|
||||
defaultCreateAdmin = false
|
||||
defaultAdminUsername = ""
|
||||
defaultAdminPassword = ""
|
||||
defaultOAuth2UserCreation = false
|
||||
defaultOAuth2ClientID = ""
|
||||
defaultOAuth2ClientSecret = ""
|
||||
defaultOAuth2RedirectURL = ""
|
||||
defaultOAuth2OidcDiscoveryEndpoint = ""
|
||||
defaultOAuth2Provider = ""
|
||||
defaultPocketConsumerKey = ""
|
||||
defaultHTTPClientTimeout = 20
|
||||
defaultHTTPClientMaxBodySize = 15
|
||||
defaultHTTPClientProxy = ""
|
||||
defaultHTTPServerTimeout = 300
|
||||
defaultAuthProxyHeader = ""
|
||||
defaultAuthProxyUserCreation = false
|
||||
defaultMaintenanceMode = false
|
||||
defaultMaintenanceMessage = "Miniflux is currently under maintenance"
|
||||
defaultMetricsCollector = false
|
||||
defaultMetricsRefreshInterval = 60
|
||||
defaultMetricsAllowedNetworks = "127.0.0.1/8"
|
||||
defaultMetricsUsername = ""
|
||||
defaultMetricsPassword = ""
|
||||
defaultWatchdog = true
|
||||
defaultInvidiousInstance = "yewtu.be"
|
||||
)
|
||||
|
||||
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
|
||||
|
||||
// Option contains a key to value map of a single option. It may be used to output debug strings.
|
||||
type Option struct {
|
||||
Key string
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
// Options contains configuration options.
|
||||
type Options struct {
|
||||
HTTPS bool
|
||||
logDateTime bool
|
||||
hsts bool
|
||||
httpService bool
|
||||
schedulerService bool
|
||||
debug bool
|
||||
serverTimingHeader bool
|
||||
baseURL string
|
||||
rootURL string
|
||||
basePath string
|
||||
databaseURL string
|
||||
databaseMaxConns int
|
||||
databaseMinConns int
|
||||
databaseConnectionLifetime int
|
||||
runMigrations bool
|
||||
listenAddr string
|
||||
certFile string
|
||||
certDomain string
|
||||
certKeyFile string
|
||||
cleanupFrequencyHours int
|
||||
cleanupArchiveReadDays int
|
||||
cleanupArchiveUnreadDays int
|
||||
cleanupArchiveBatchSize int
|
||||
cleanupRemoveSessionsDays int
|
||||
pollingFrequency int
|
||||
batchSize int
|
||||
pollingScheduler string
|
||||
schedulerEntryFrequencyMinInterval int
|
||||
schedulerEntryFrequencyMaxInterval int
|
||||
pollingParsingErrorLimit int
|
||||
workerPoolSize int
|
||||
createAdmin bool
|
||||
adminUsername string
|
||||
adminPassword string
|
||||
proxyHTTPClientTimeout int
|
||||
proxyOption string
|
||||
proxyMediaTypes []string
|
||||
proxyUrl string
|
||||
fetchYouTubeWatchTime bool
|
||||
oauth2UserCreationAllowed bool
|
||||
oauth2ClientID string
|
||||
oauth2ClientSecret string
|
||||
oauth2RedirectURL string
|
||||
oauth2OidcDiscoveryEndpoint string
|
||||
oauth2Provider string
|
||||
pocketConsumerKey string
|
||||
httpClientTimeout int
|
||||
httpClientMaxBodySize int64
|
||||
httpClientProxy string
|
||||
httpClientUserAgent string
|
||||
httpServerTimeout int
|
||||
authProxyHeader string
|
||||
authProxyUserCreation bool
|
||||
maintenanceMode bool
|
||||
maintenanceMessage string
|
||||
metricsCollector bool
|
||||
metricsRefreshInterval int
|
||||
metricsAllowedNetworks []string
|
||||
metricsUsername string
|
||||
metricsPassword string
|
||||
watchdog bool
|
||||
invidiousInstance string
|
||||
proxyPrivateKey []byte
|
||||
}
|
||||
|
||||
// NewOptions returns Options with default values.
|
||||
func NewOptions() *Options {
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
|
||||
return &Options{
|
||||
HTTPS: defaultHTTPS,
|
||||
logDateTime: defaultLogDateTime,
|
||||
hsts: defaultHSTS,
|
||||
httpService: defaultHTTPService,
|
||||
schedulerService: defaultSchedulerService,
|
||||
debug: defaultDebug,
|
||||
serverTimingHeader: defaultTiming,
|
||||
baseURL: defaultBaseURL,
|
||||
rootURL: defaultRootURL,
|
||||
basePath: defaultBasePath,
|
||||
databaseURL: defaultDatabaseURL,
|
||||
databaseMaxConns: defaultDatabaseMaxConns,
|
||||
databaseMinConns: defaultDatabaseMinConns,
|
||||
databaseConnectionLifetime: defaultDatabaseConnectionLifetime,
|
||||
runMigrations: defaultRunMigrations,
|
||||
listenAddr: defaultListenAddr,
|
||||
certFile: defaultCertFile,
|
||||
certDomain: defaultCertDomain,
|
||||
certKeyFile: defaultKeyFile,
|
||||
cleanupFrequencyHours: defaultCleanupFrequencyHours,
|
||||
cleanupArchiveReadDays: defaultCleanupArchiveReadDays,
|
||||
cleanupArchiveUnreadDays: defaultCleanupArchiveUnreadDays,
|
||||
cleanupArchiveBatchSize: defaultCleanupArchiveBatchSize,
|
||||
cleanupRemoveSessionsDays: defaultCleanupRemoveSessionsDays,
|
||||
pollingFrequency: defaultPollingFrequency,
|
||||
batchSize: defaultBatchSize,
|
||||
pollingScheduler: defaultPollingScheduler,
|
||||
schedulerEntryFrequencyMinInterval: defaultSchedulerEntryFrequencyMinInterval,
|
||||
schedulerEntryFrequencyMaxInterval: defaultSchedulerEntryFrequencyMaxInterval,
|
||||
pollingParsingErrorLimit: defaultPollingParsingErrorLimit,
|
||||
workerPoolSize: defaultWorkerPoolSize,
|
||||
createAdmin: defaultCreateAdmin,
|
||||
proxyHTTPClientTimeout: defaultProxyHTTPClientTimeout,
|
||||
proxyOption: defaultProxyOption,
|
||||
proxyMediaTypes: []string{defaultProxyMediaTypes},
|
||||
proxyUrl: defaultProxyUrl,
|
||||
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
|
||||
oauth2UserCreationAllowed: defaultOAuth2UserCreation,
|
||||
oauth2ClientID: defaultOAuth2ClientID,
|
||||
oauth2ClientSecret: defaultOAuth2ClientSecret,
|
||||
oauth2RedirectURL: defaultOAuth2RedirectURL,
|
||||
oauth2OidcDiscoveryEndpoint: defaultOAuth2OidcDiscoveryEndpoint,
|
||||
oauth2Provider: defaultOAuth2Provider,
|
||||
pocketConsumerKey: defaultPocketConsumerKey,
|
||||
httpClientTimeout: defaultHTTPClientTimeout,
|
||||
httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024,
|
||||
httpClientProxy: defaultHTTPClientProxy,
|
||||
httpClientUserAgent: defaultHTTPClientUserAgent,
|
||||
httpServerTimeout: defaultHTTPServerTimeout,
|
||||
authProxyHeader: defaultAuthProxyHeader,
|
||||
authProxyUserCreation: defaultAuthProxyUserCreation,
|
||||
maintenanceMode: defaultMaintenanceMode,
|
||||
maintenanceMessage: defaultMaintenanceMessage,
|
||||
metricsCollector: defaultMetricsCollector,
|
||||
metricsRefreshInterval: defaultMetricsRefreshInterval,
|
||||
metricsAllowedNetworks: []string{defaultMetricsAllowedNetworks},
|
||||
metricsUsername: defaultMetricsUsername,
|
||||
metricsPassword: defaultMetricsPassword,
|
||||
watchdog: defaultWatchdog,
|
||||
invidiousInstance: defaultInvidiousInstance,
|
||||
proxyPrivateKey: randomKey,
|
||||
}
|
||||
}
|
||||
|
||||
// LogDateTime returns true if the date/time should be displayed in log messages.
|
||||
func (o *Options) LogDateTime() bool {
|
||||
return o.logDateTime
|
||||
}
|
||||
|
||||
// HasMaintenanceMode returns true if maintenance mode is enabled.
|
||||
func (o *Options) HasMaintenanceMode() bool {
|
||||
return o.maintenanceMode
|
||||
}
|
||||
|
||||
// MaintenanceMessage returns maintenance message.
|
||||
func (o *Options) MaintenanceMessage() string {
|
||||
return o.maintenanceMessage
|
||||
}
|
||||
|
||||
// HasDebugMode returns true if debug mode is enabled.
|
||||
func (o *Options) HasDebugMode() bool {
|
||||
return o.debug
|
||||
}
|
||||
|
||||
// HasServerTimingHeader returns true if server-timing headers enabled.
|
||||
func (o *Options) HasServerTimingHeader() bool {
|
||||
return o.serverTimingHeader
|
||||
}
|
||||
|
||||
// BaseURL returns the application base URL with path.
|
||||
func (o *Options) BaseURL() string {
|
||||
return o.baseURL
|
||||
}
|
||||
|
||||
// RootURL returns the base URL without path.
|
||||
func (o *Options) RootURL() string {
|
||||
return o.rootURL
|
||||
}
|
||||
|
||||
// BasePath returns the application base path according to the base URL.
|
||||
func (o *Options) BasePath() string {
|
||||
return o.basePath
|
||||
}
|
||||
|
||||
// IsDefaultDatabaseURL returns true if the default database URL is used.
|
||||
func (o *Options) IsDefaultDatabaseURL() bool {
|
||||
return o.databaseURL == defaultDatabaseURL
|
||||
}
|
||||
|
||||
// DatabaseURL returns the database URL.
|
||||
func (o *Options) DatabaseURL() string {
|
||||
return o.databaseURL
|
||||
}
|
||||
|
||||
// DatabaseMaxConns returns the maximum number of database connections.
|
||||
func (o *Options) DatabaseMaxConns() int {
|
||||
return o.databaseMaxConns
|
||||
}
|
||||
|
||||
// DatabaseMinConns returns the minimum number of database connections.
|
||||
func (o *Options) DatabaseMinConns() int {
|
||||
return o.databaseMinConns
|
||||
}
|
||||
|
||||
// DatabaseConnectionLifetime returns the maximum amount of time a connection may be reused.
|
||||
func (o *Options) DatabaseConnectionLifetime() time.Duration {
|
||||
return time.Duration(o.databaseConnectionLifetime) * time.Minute
|
||||
}
|
||||
|
||||
// ListenAddr returns the listen address for the HTTP server.
|
||||
func (o *Options) ListenAddr() string {
|
||||
return o.listenAddr
|
||||
}
|
||||
|
||||
// CertFile returns the SSL certificate filename if any.
|
||||
func (o *Options) CertFile() string {
|
||||
return o.certFile
|
||||
}
|
||||
|
||||
// CertKeyFile returns the private key filename for custom SSL certificate.
|
||||
func (o *Options) CertKeyFile() string {
|
||||
return o.certKeyFile
|
||||
}
|
||||
|
||||
// CertDomain returns the domain to use for Let's Encrypt certificate.
|
||||
func (o *Options) CertDomain() string {
|
||||
return o.certDomain
|
||||
}
|
||||
|
||||
// CleanupFrequencyHours returns the interval in hours for cleanup jobs.
|
||||
func (o *Options) CleanupFrequencyHours() int {
|
||||
return o.cleanupFrequencyHours
|
||||
}
|
||||
|
||||
// CleanupArchiveReadDays returns the number of days after which marking read items as removed.
|
||||
func (o *Options) CleanupArchiveReadDays() int {
|
||||
return o.cleanupArchiveReadDays
|
||||
}
|
||||
|
||||
// CleanupArchiveUnreadDays returns the number of days after which marking unread items as removed.
|
||||
func (o *Options) CleanupArchiveUnreadDays() int {
|
||||
return o.cleanupArchiveUnreadDays
|
||||
}
|
||||
|
||||
// CleanupArchiveBatchSize returns the number of entries to archive for each interval.
|
||||
func (o *Options) CleanupArchiveBatchSize() int {
|
||||
return o.cleanupArchiveBatchSize
|
||||
}
|
||||
|
||||
// CleanupRemoveSessionsDays returns the number of days after which to remove sessions.
|
||||
func (o *Options) CleanupRemoveSessionsDays() int {
|
||||
return o.cleanupRemoveSessionsDays
|
||||
}
|
||||
|
||||
// WorkerPoolSize returns the number of background worker.
|
||||
func (o *Options) WorkerPoolSize() int {
|
||||
return o.workerPoolSize
|
||||
}
|
||||
|
||||
// PollingFrequency returns the interval to refresh feeds in the background.
|
||||
func (o *Options) PollingFrequency() int {
|
||||
return o.pollingFrequency
|
||||
}
|
||||
|
||||
// BatchSize returns the number of feeds to send for background processing.
|
||||
func (o *Options) BatchSize() int {
|
||||
return o.batchSize
|
||||
}
|
||||
|
||||
// PollingScheduler returns the scheduler used for polling feeds.
|
||||
func (o *Options) PollingScheduler() string {
|
||||
return o.pollingScheduler
|
||||
}
|
||||
|
||||
// SchedulerEntryFrequencyMaxInterval returns the maximum interval in minutes for the entry frequency scheduler.
|
||||
func (o *Options) SchedulerEntryFrequencyMaxInterval() int {
|
||||
return o.schedulerEntryFrequencyMaxInterval
|
||||
}
|
||||
|
||||
// SchedulerEntryFrequencyMinInterval returns the minimum interval in minutes for the entry frequency scheduler.
|
||||
func (o *Options) SchedulerEntryFrequencyMinInterval() int {
|
||||
return o.schedulerEntryFrequencyMinInterval
|
||||
}
|
||||
|
||||
// PollingParsingErrorLimit returns the limit of errors when to stop polling.
|
||||
func (o *Options) PollingParsingErrorLimit() int {
|
||||
return o.pollingParsingErrorLimit
|
||||
}
|
||||
|
||||
// IsOAuth2UserCreationAllowed returns true if user creation is allowed for OAuth2 users.
|
||||
func (o *Options) IsOAuth2UserCreationAllowed() bool {
|
||||
return o.oauth2UserCreationAllowed
|
||||
}
|
||||
|
||||
// OAuth2ClientID returns the OAuth2 Client ID.
|
||||
func (o *Options) OAuth2ClientID() string {
|
||||
return o.oauth2ClientID
|
||||
}
|
||||
|
||||
// OAuth2ClientSecret returns the OAuth2 client secret.
|
||||
func (o *Options) OAuth2ClientSecret() string {
|
||||
return o.oauth2ClientSecret
|
||||
}
|
||||
|
||||
// OAuth2RedirectURL returns the OAuth2 redirect URL.
|
||||
func (o *Options) OAuth2RedirectURL() string {
|
||||
return o.oauth2RedirectURL
|
||||
}
|
||||
|
||||
// OAuth2OidcDiscoveryEndpoint returns the OAuth2 OIDC discovery endpoint.
|
||||
func (o *Options) OAuth2OidcDiscoveryEndpoint() string {
|
||||
return o.oauth2OidcDiscoveryEndpoint
|
||||
}
|
||||
|
||||
// OAuth2Provider returns the name of the OAuth2 provider configured.
|
||||
func (o *Options) OAuth2Provider() string {
|
||||
return o.oauth2Provider
|
||||
}
|
||||
|
||||
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
|
||||
func (o *Options) HasHSTS() bool {
|
||||
return o.hsts
|
||||
}
|
||||
|
||||
// RunMigrations returns true if the environment variable RUN_MIGRATIONS is not empty.
|
||||
func (o *Options) RunMigrations() bool {
|
||||
return o.runMigrations
|
||||
}
|
||||
|
||||
// CreateAdmin returns true if the environment variable CREATE_ADMIN is not empty.
|
||||
func (o *Options) CreateAdmin() bool {
|
||||
return o.createAdmin
|
||||
}
|
||||
|
||||
// AdminUsername returns the admin username if defined.
|
||||
func (o *Options) AdminUsername() string {
|
||||
return o.adminUsername
|
||||
}
|
||||
|
||||
// AdminPassword returns the admin password if defined.
|
||||
func (o *Options) AdminPassword() string {
|
||||
return o.adminPassword
|
||||
}
|
||||
|
||||
// FetchYouTubeWatchTime returns true if the YouTube video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchYouTubeWatchTime() bool {
|
||||
return o.fetchYouTubeWatchTime
|
||||
}
|
||||
|
||||
// ProxyOption returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
|
||||
func (o *Options) ProxyOption() string {
|
||||
return o.proxyOption
|
||||
}
|
||||
|
||||
// ProxyMediaTypes returns a slice of media types to proxy.
|
||||
func (o *Options) ProxyMediaTypes() []string {
|
||||
return o.proxyMediaTypes
|
||||
}
|
||||
|
||||
// ProxyUrl returns a string of a URL to use to proxy image requests
|
||||
func (o *Options) ProxyUrl() string {
|
||||
return o.proxyUrl
|
||||
}
|
||||
|
||||
// ProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
|
||||
func (o *Options) ProxyHTTPClientTimeout() int {
|
||||
return o.proxyHTTPClientTimeout
|
||||
}
|
||||
|
||||
// HasHTTPService returns true if the HTTP service is enabled.
|
||||
func (o *Options) HasHTTPService() bool {
|
||||
return o.httpService
|
||||
}
|
||||
|
||||
// HasSchedulerService returns true if the scheduler service is enabled.
|
||||
func (o *Options) HasSchedulerService() bool {
|
||||
return o.schedulerService
|
||||
}
|
||||
|
||||
// PocketConsumerKey returns the Pocket Consumer Key if configured.
|
||||
func (o *Options) PocketConsumerKey(defaultValue string) string {
|
||||
if o.pocketConsumerKey != "" {
|
||||
return o.pocketConsumerKey
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// HTTPClientTimeout returns the time limit in seconds before the HTTP client cancel the request.
|
||||
func (o *Options) HTTPClientTimeout() int {
|
||||
return o.httpClientTimeout
|
||||
}
|
||||
|
||||
// HTTPClientMaxBodySize returns the number of bytes allowed for the HTTP client to transfer.
|
||||
func (o *Options) HTTPClientMaxBodySize() int64 {
|
||||
return o.httpClientMaxBodySize
|
||||
}
|
||||
|
||||
// HTTPClientProxy returns the proxy URL for HTTP client.
|
||||
func (o *Options) HTTPClientProxy() string {
|
||||
return o.httpClientProxy
|
||||
}
|
||||
|
||||
// HTTPServerTimeout returns the time limit in seconds before the HTTP server cancel the request.
|
||||
func (o *Options) HTTPServerTimeout() int {
|
||||
return o.httpServerTimeout
|
||||
}
|
||||
|
||||
// HasHTTPClientProxyConfigured returns true if the HTTP proxy is configured.
|
||||
func (o *Options) HasHTTPClientProxyConfigured() bool {
|
||||
return o.httpClientProxy != ""
|
||||
}
|
||||
|
||||
// AuthProxyHeader returns an HTTP header name that contains username for
|
||||
// authentication using auth proxy.
|
||||
func (o *Options) AuthProxyHeader() string {
|
||||
return o.authProxyHeader
|
||||
}
|
||||
|
||||
// IsAuthProxyUserCreationAllowed returns true if user creation is allowed for
|
||||
// users authenticated using auth proxy.
|
||||
func (o *Options) IsAuthProxyUserCreationAllowed() bool {
|
||||
return o.authProxyUserCreation
|
||||
}
|
||||
|
||||
// HasMetricsCollector returns true if metrics collection is enabled.
|
||||
func (o *Options) HasMetricsCollector() bool {
|
||||
return o.metricsCollector
|
||||
}
|
||||
|
||||
// MetricsRefreshInterval returns the refresh interval in seconds.
|
||||
func (o *Options) MetricsRefreshInterval() int {
|
||||
return o.metricsRefreshInterval
|
||||
}
|
||||
|
||||
// MetricsAllowedNetworks returns the list of networks allowed to connect to the metrics endpoint.
|
||||
func (o *Options) MetricsAllowedNetworks() []string {
|
||||
return o.metricsAllowedNetworks
|
||||
}
|
||||
|
||||
func (o *Options) MetricsUsername() string {
|
||||
return o.metricsUsername
|
||||
}
|
||||
|
||||
func (o *Options) MetricsPassword() string {
|
||||
return o.metricsPassword
|
||||
}
|
||||
|
||||
// HTTPClientUserAgent returns the global User-Agent header for miniflux.
|
||||
func (o *Options) HTTPClientUserAgent() string {
|
||||
return o.httpClientUserAgent
|
||||
}
|
||||
|
||||
// HasWatchdog returns true if the systemd watchdog is enabled.
|
||||
func (o *Options) HasWatchdog() bool {
|
||||
return o.watchdog
|
||||
}
|
||||
|
||||
// InvidiousInstance returns the invidious instance used by miniflux
|
||||
func (o *Options) InvidiousInstance() string {
|
||||
return o.invidiousInstance
|
||||
}
|
||||
|
||||
// ProxyPrivateKey returns the private key used by the media proxy
|
||||
func (o *Options) ProxyPrivateKey() []byte {
|
||||
return o.proxyPrivateKey
|
||||
}
|
||||
|
||||
// SortedOptions returns options as a list of key value pairs, sorted by keys.
|
||||
func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
var keyValues = map[string]interface{}{
|
||||
"ADMIN_PASSWORD": redactSecretValue(o.adminPassword, redactSecret),
|
||||
"ADMIN_USERNAME": o.adminUsername,
|
||||
"AUTH_PROXY_HEADER": o.authProxyHeader,
|
||||
"AUTH_PROXY_USER_CREATION": o.authProxyUserCreation,
|
||||
"BASE_PATH": o.basePath,
|
||||
"BASE_URL": o.baseURL,
|
||||
"BATCH_SIZE": o.batchSize,
|
||||
"CERT_DOMAIN": o.certDomain,
|
||||
"CERT_FILE": o.certFile,
|
||||
"CLEANUP_ARCHIVE_READ_DAYS": o.cleanupArchiveReadDays,
|
||||
"CLEANUP_ARCHIVE_UNREAD_DAYS": o.cleanupArchiveUnreadDays,
|
||||
"CLEANUP_ARCHIVE_BATCH_SIZE": o.cleanupArchiveBatchSize,
|
||||
"CLEANUP_FREQUENCY_HOURS": o.cleanupFrequencyHours,
|
||||
"CLEANUP_REMOVE_SESSIONS_DAYS": o.cleanupRemoveSessionsDays,
|
||||
"CREATE_ADMIN": o.createAdmin,
|
||||
"DATABASE_MAX_CONNS": o.databaseMaxConns,
|
||||
"DATABASE_MIN_CONNS": o.databaseMinConns,
|
||||
"DATABASE_CONNECTION_LIFETIME": o.databaseConnectionLifetime,
|
||||
"DATABASE_URL": redactSecretValue(o.databaseURL, redactSecret),
|
||||
"DEBUG": o.debug,
|
||||
"DISABLE_HSTS": !o.hsts,
|
||||
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
|
||||
"DISABLE_HTTP_SERVICE": !o.httpService,
|
||||
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
|
||||
"HTTPS": o.HTTPS,
|
||||
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
|
||||
"HTTP_CLIENT_PROXY": o.httpClientProxy,
|
||||
"HTTP_CLIENT_TIMEOUT": o.httpClientTimeout,
|
||||
"HTTP_CLIENT_USER_AGENT": o.httpClientUserAgent,
|
||||
"HTTP_SERVER_TIMEOUT": o.httpServerTimeout,
|
||||
"HTTP_SERVICE": o.httpService,
|
||||
"KEY_FILE": o.certKeyFile,
|
||||
"INVIDIOUS_INSTANCE": o.invidiousInstance,
|
||||
"LISTEN_ADDR": o.listenAddr,
|
||||
"LOG_DATE_TIME": o.logDateTime,
|
||||
"MAINTENANCE_MESSAGE": o.maintenanceMessage,
|
||||
"MAINTENANCE_MODE": o.maintenanceMode,
|
||||
"METRICS_ALLOWED_NETWORKS": strings.Join(o.metricsAllowedNetworks, ","),
|
||||
"METRICS_COLLECTOR": o.metricsCollector,
|
||||
"METRICS_REFRESH_INTERVAL": o.metricsRefreshInterval,
|
||||
"METRICS_USERNAME": o.metricsUsername,
|
||||
"METRICS_PASSWORD": redactSecretValue(o.metricsPassword, redactSecret),
|
||||
"OAUTH2_CLIENT_ID": o.oauth2ClientID,
|
||||
"OAUTH2_CLIENT_SECRET": redactSecretValue(o.oauth2ClientSecret, redactSecret),
|
||||
"OAUTH2_OIDC_DISCOVERY_ENDPOINT": o.oauth2OidcDiscoveryEndpoint,
|
||||
"OAUTH2_PROVIDER": o.oauth2Provider,
|
||||
"OAUTH2_REDIRECT_URL": o.oauth2RedirectURL,
|
||||
"OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed,
|
||||
"POCKET_CONSUMER_KEY": redactSecretValue(o.pocketConsumerKey, redactSecret),
|
||||
"POLLING_FREQUENCY": o.pollingFrequency,
|
||||
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
|
||||
"POLLING_SCHEDULER": o.pollingScheduler,
|
||||
"PROXY_HTTP_CLIENT_TIMEOUT": o.proxyHTTPClientTimeout,
|
||||
"PROXY_PRIVATE_KEY": redactSecretValue(string(o.proxyPrivateKey), redactSecret),
|
||||
"PROXY_MEDIA_TYPES": o.proxyMediaTypes,
|
||||
"PROXY_OPTION": o.proxyOption,
|
||||
"PROXY_URL": o.proxyUrl,
|
||||
"ROOT_URL": o.rootURL,
|
||||
"RUN_MIGRATIONS": o.runMigrations,
|
||||
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval,
|
||||
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL": o.schedulerEntryFrequencyMinInterval,
|
||||
"SCHEDULER_SERVICE": o.schedulerService,
|
||||
"SERVER_TIMING_HEADER": o.serverTimingHeader,
|
||||
"WORKER_POOL_SIZE": o.workerPoolSize,
|
||||
"WATCHDOG": o.watchdog,
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(keyValues))
|
||||
for key := range keyValues {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var sortedOptions []*Option
|
||||
for _, key := range keys {
|
||||
sortedOptions = append(sortedOptions, &Option{Key: key, Value: keyValues[key]})
|
||||
}
|
||||
return sortedOptions
|
||||
}
|
||||
|
||||
func (o *Options) String() string {
|
||||
var builder strings.Builder
|
||||
|
||||
for _, option := range o.SortedOptions(false) {
|
||||
fmt.Fprintf(&builder, "%s=%v\n", option.Key, option.Value)
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func redactSecretValue(value string, redactSecret bool) string {
|
||||
if redactSecret && value != "" {
|
||||
return "<secret>"
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Copyright 2019 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package config // import "miniflux.app/config"
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
url_parser "net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Parser handles configuration parsing.
|
||||
type Parser struct {
|
||||
opts *Options
|
||||
}
|
||||
|
||||
// NewParser returns a new Parser.
|
||||
func NewParser() *Parser {
|
||||
return &Parser{
|
||||
opts: NewOptions(),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseEnvironmentVariables loads configuration values from environment variables.
|
||||
func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
|
||||
err := p.parseLines(os.Environ())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.opts, nil
|
||||
}
|
||||
|
||||
// ParseFile loads configuration values from a local file.
|
||||
func (p *Parser) ParseFile(filename string) (*Options, error) {
|
||||
fp, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fp.Close()
|
||||
|
||||
err = p.parseLines(p.parseFileContent(fp))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.opts, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
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_DATE_TIME":
|
||||
p.opts.logDateTime = parseBool(value, defaultLogDateTime)
|
||||
case "DEBUG":
|
||||
p.opts.debug = parseBool(value, defaultDebug)
|
||||
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 "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 "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 "POLLING_PARSING_ERROR_LIMIT":
|
||||
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
|
||||
// kept for compatibility purpose
|
||||
case "PROXY_IMAGES":
|
||||
p.opts.proxyOption = parseString(value, defaultProxyOption)
|
||||
case "PROXY_HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.proxyHTTPClientTimeout = parseInt(value, defaultProxyHTTPClientTimeout)
|
||||
case "PROXY_OPTION":
|
||||
p.opts.proxyOption = parseString(value, defaultProxyOption)
|
||||
case "PROXY_MEDIA_TYPES":
|
||||
p.opts.proxyMediaTypes = parseStringList(value, []string{defaultProxyMediaTypes})
|
||||
// kept for compatibility purpose
|
||||
case "PROXY_IMAGE_URL":
|
||||
p.opts.proxyUrl = parseString(value, defaultProxyUrl)
|
||||
case "PROXY_URL":
|
||||
p.opts.proxyUrl = parseString(value, defaultProxyUrl)
|
||||
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.oauth2OidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
|
||||
case "OAUTH2_PROVIDER":
|
||||
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
|
||||
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_YOUTUBE_WATCH_TIME":
|
||||
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
|
||||
case "WATCHDOG":
|
||||
p.opts.watchdog = parseBool(value, defaultWatchdog)
|
||||
case "INVIDIOUS_INSTANCE":
|
||||
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
|
||||
case "PROXY_PRIVATE_KEY":
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
p.opts.proxyPrivateKey = parseBytes(value, randomKey)
|
||||
}
|
||||
}
|
||||
|
||||
if port != "" {
|
||||
p.opts.listenAddr = ":" + port
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBaseURL(value string) (string, string, string, error) {
|
||||
if value == "" {
|
||||
return defaultBaseURL, defaultRootURL, "", nil
|
||||
}
|
||||
|
||||
if value[len(value)-1:] == "/" {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
|
||||
url, err := url_parser.Parse(value)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(url.Scheme)
|
||||
if scheme != "https" && scheme != "http" {
|
||||
return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
|
||||
}
|
||||
|
||||
basePath := url.Path
|
||||
url.Path = ""
|
||||
return value, url.String(), basePath, nil
|
||||
}
|
||||
|
||||
func parseBool(value string, fallback bool) bool {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
value = strings.ToLower(value)
|
||||
if value == "1" || value == "yes" || value == "true" || value == "on" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func parseInt(value string, fallback int) int {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func parseString(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseStringList(value string, fallback []string) []string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var strList []string
|
||||
strMap := 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)
|
||||
}
|
||||
}
|
||||
|
||||
return strList
|
||||
}
|
||||
|
||||
func parseBytes(value string, fallback []byte) []byte {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return []byte(value)
|
||||
}
|
||||
|
||||
func readSecretFile(filename, fallback string) string {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
value := string(bytes.TrimSpace(data))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2019 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package config // import "miniflux.app/config"
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringValueWithUnsetVariable(t *testing.T) {
|
||||
if parseString("", "defaultValue") != "defaultValue" {
|
||||
t.Errorf(`Unset variables should returns the default value`)
|
||||
}
|
||||
}
|
||||
|
||||
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`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValue(t *testing.T) {
|
||||
if parseInt("2018", 42) != 2018 {
|
||||
t.Errorf(`Defined variables should returns the specified value`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
The contrib directory contains various useful things contributed by the community.
|
||||
|
||||
Community contributions are not officially supported by the maintainers.
|
||||
There is no guarantee whatsoever that anything in this folder works.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
miniflux_linux_user: miniflux
|
||||
miniflux_db_user_name: miniflux_db_user
|
||||
miniflux_db_user_password: miniflux_db_user_password
|
||||
miniflux_db: miniflux_db
|
||||
miniflux_admin_name: admin
|
||||
miniflux_admin_passwort: miniflux_admin_password
|
||||
miniflux_port: 8080
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
- hosts: miniflux
|
||||
roles:
|
||||
- { role: mgrote.miniflux, tags: "miniflux" }
|
||||
@@ -0,0 +1,23 @@
|
||||
## mgrote.miniflux
|
||||
|
||||
### Details
|
||||
Installs and configures Miniflux v2 with ansible
|
||||
|
||||
### Works on...
|
||||
- [x] Ubuntu (>=18.04)
|
||||
|
||||
### Variables and Defaults
|
||||
##### Linux User
|
||||
miniflux_linux_user: miniflux
|
||||
##### DB User
|
||||
miniflux_db_user_name: miniflux_db_user
|
||||
##### DB Password
|
||||
miniflux_db_user_password: qqqqqqqqqqqqq
|
||||
##### Database
|
||||
miniflux_db: miniflux_db
|
||||
##### Username Miniflux Admin
|
||||
miniflux_admin_name: admin
|
||||
##### Password Miniflux Admin
|
||||
miniflux_admin_passwort: hallowelt
|
||||
##### Port for Miniflux Frontend
|
||||
miniflux_port: 8080
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
- name: start_miniflux.service
|
||||
become: yes
|
||||
systemd:
|
||||
name: miniflux
|
||||
state: restarted
|
||||
enabled: yes
|
||||
# wait 15 seconds(for systemd)
|
||||
- name: miniflux_wait
|
||||
wait_for:
|
||||
timeout: 15
|
||||
@@ -0,0 +1,43 @@
|
||||
- name: add Apt-key for miniflux-repo
|
||||
become: yes
|
||||
apt_key:
|
||||
url: https://apt.miniflux.app/KEY.gpg
|
||||
state: present
|
||||
|
||||
- name: add miniflux-repo
|
||||
become: yes
|
||||
apt_repository:
|
||||
repo: 'deb https://apt.miniflux.app/ /'
|
||||
state: present
|
||||
filename: miniflux_repo
|
||||
update_cache: yes
|
||||
|
||||
- name: install miniflux
|
||||
become: yes
|
||||
apt:
|
||||
name: miniflux
|
||||
state: present
|
||||
|
||||
- name: add miniflux linux_user
|
||||
become: yes
|
||||
user:
|
||||
name: "{{ miniflux_linux_user }}"
|
||||
home: "/var/empty"
|
||||
create_home: "no"
|
||||
system: "yes"
|
||||
shell: "/bin/false"
|
||||
|
||||
- name: create directory "/etc/miniflux.d"
|
||||
become: yes
|
||||
file:
|
||||
path: /etc/miniflux.d
|
||||
state: directory
|
||||
|
||||
- name: copy miniflux.conf
|
||||
become: yes
|
||||
template:
|
||||
src: "miniflux.conf"
|
||||
dest: "/etc/miniflux.conf"
|
||||
notify:
|
||||
- start_miniflux.service
|
||||
- miniflux_wait
|
||||
@@ -0,0 +1,18 @@
|
||||
# See https://docs.miniflux.app/
|
||||
|
||||
LISTEN_ADDR=0.0.0.0:{{ miniflux_port }}
|
||||
DATABASE_URL=user={{ miniflux_db_user_name }} password={{ miniflux_db_user_password }} dbname={{ miniflux_db }} sslmode=disable
|
||||
|
||||
POLLING_FREQUENCY=15
|
||||
PROXY_IMAGES=http-only
|
||||
|
||||
# Run SQL migrations automatically:
|
||||
RUN_MIGRATIONS=1
|
||||
|
||||
CREATE_ADMIN=1
|
||||
ADMIN_USERNAME={{ miniflux_admin_name }}
|
||||
ADMIN_PASSWORD={{ miniflux_admin_passwort }}
|
||||
|
||||
POLLING_FREQUENCY=10
|
||||
|
||||
# Options: https://miniflux.app/miniflux.1.html
|
||||
@@ -0,0 +1,2 @@
|
||||
miniflux.example.org
|
||||
reverse_proxy miniflux:8080
|
||||
@@ -0,0 +1,12 @@
|
||||
Docker-Compose Examples
|
||||
=======================
|
||||
|
||||
Here are few Docker Compose examples:
|
||||
|
||||
- `basic.yml`: Basic example
|
||||
- `caddy.yml`: Use Caddy as reverse-proxy with automatic HTTPS
|
||||
- `traefik.yml`: Use Traefik as reverse-proxy with automatic HTTPS
|
||||
|
||||
```bash
|
||||
docker compose -f basic.yml up -d
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
miniflux:
|
||||
image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}
|
||||
container_name: miniflux
|
||||
restart: always
|
||||
ports:
|
||||
- "80:8080"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
|
||||
- RUN_MIGRATIONS=1
|
||||
- CREATE_ADMIN=1
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=test123
|
||||
- DEBUG=1
|
||||
# Optional health check:
|
||||
# healthcheck:
|
||||
# test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
|
||||
db:
|
||||
image: postgres:15
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
start_period: 30s
|
||||
volumes:
|
||||
miniflux-db:
|
||||
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2
|
||||
container_name: caddy
|
||||
depends_on:
|
||||
- miniflux
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- $PWD/Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
miniflux:
|
||||
image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}
|
||||
container_name: miniflux
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
|
||||
- RUN_MIGRATIONS=1
|
||||
- CREATE_ADMIN=1
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=test123
|
||||
- BASE_URL=https://miniflux.example.org
|
||||
db:
|
||||
image: postgres:15
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
start_period: 30s
|
||||
volumes:
|
||||
miniflux-db:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -0,0 +1,52 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v2.3"
|
||||
container_name: traefik
|
||||
command:
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
|
||||
- "--certificatesresolvers.myresolver.acme.email=postmaster@example.com"
|
||||
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
|
||||
depends_on:
|
||||
- miniflux
|
||||
ports:
|
||||
- "443:443"
|
||||
volumes:
|
||||
- "./letsencrypt:/letsencrypt"
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
miniflux:
|
||||
image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}
|
||||
container_name: miniflux
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
- DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
|
||||
- RUN_MIGRATIONS=1
|
||||
- CREATE_ADMIN=1
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=test123
|
||||
- BASE_URL=https://miniflux.example.org
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.miniflux.rule=Host(`miniflux.example.org`)"
|
||||
- "traefik.http.routers.miniflux.entrypoints=websecure"
|
||||
- "traefik.http.routers.miniflux.tls.certresolver=myresolver"
|
||||
db:
|
||||
image: postgres:15
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
start_period: 30s
|
||||
volumes:
|
||||
miniflux-db:
|
||||
@@ -0,0 +1 @@
|
||||
Grafana Dashboard for Miniflux
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
|
||||
System-V init for e.g. http://devuan.org
|
||||
|
||||
Assumes an executable `/usr/local/bin/miniflux`.
|
||||
|
||||
Configure in `etc/default/miniflux`
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# sourced by /etc/init.d/miniflux
|
||||
# see cluster port in pg_lsclusters and ls -Al /var/run/postgresql/
|
||||
export DATABASE_URL='host=/var/run/postgresql/ port=5433 user=miniflux password=<my secrect db password> dbname=miniflux sslmode=disable'
|
||||
export LISTEN_ADDR='127.0.0.1:8081'
|
||||
export BASE_URL='https://<my miniflux domain> and path/'
|
||||
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#! /bin/sh
|
||||
|
||||
### BEGIN INIT INFO
|
||||
# Provides: miniflux
|
||||
# Required-Start: $syslog $network
|
||||
# Required-Stop: $syslog
|
||||
# Should-Start: postgresql
|
||||
# Should-Stop: postgresql
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: A rss reader
|
||||
# Description: A RSS reader
|
||||
### END INIT INFO
|
||||
|
||||
# Author: Danny Boisvert
|
||||
|
||||
# Do NOT "set -e"
|
||||
|
||||
# PATH should only include /usr/* if it runs after the mountnfs.sh script
|
||||
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||
DESC="Miniflux"
|
||||
NAME=miniflux
|
||||
SERVICEVERBOSE=yes
|
||||
PIDFILE=/var/run/$NAME.pid
|
||||
SCRIPTNAME=/etc/init.d/$NAME
|
||||
WORKINGDIR=/usr/local/bin
|
||||
DAEMON=$WORKINGDIR/$NAME
|
||||
DAEMON_ARGS=""
|
||||
USER=nobody
|
||||
|
||||
# Read configuration variable file if it is present
|
||||
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
|
||||
|
||||
# Exit if the package is not installed
|
||||
[ -x "$DAEMON" ] || exit 0
|
||||
|
||||
# Load the VERBOSE setting and other rcS variables
|
||||
. /lib/init/vars.sh
|
||||
|
||||
# Define LSB log_* functions.
|
||||
# Depend on lsb-base (>= 3.2-14) to ensure that this file is present
|
||||
# and status_of_proc is working.
|
||||
. /lib/lsb/init-functions
|
||||
|
||||
#
|
||||
# Function that starts the daemon/service
|
||||
#
|
||||
do_start()
|
||||
{
|
||||
# Return
|
||||
# 0 if daemon has been started
|
||||
# 1 if daemon was already running
|
||||
# 2 if daemon could not be started
|
||||
sh -c "USER=$USER start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile \\
|
||||
--test --chdir $WORKINGDIR --chuid $USER \\
|
||||
--exec $DAEMON -- $DAEMON_ARGS > /dev/null \\
|
||||
|| return 1"
|
||||
sh -c "USER=$USER start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile \\
|
||||
--background --chdir $WORKINGDIR --chuid $USER \\
|
||||
--exec $DAEMON -- $DAEMON_ARGS \\
|
||||
|| return 2"
|
||||
}
|
||||
|
||||
#
|
||||
# Function that stops the daemon/service
|
||||
#
|
||||
do_stop()
|
||||
{
|
||||
# Return
|
||||
# 0 if daemon has been stopped
|
||||
# 1 if daemon was already stopped
|
||||
# 2 if daemon could not be stopped
|
||||
# other if a failure occurred
|
||||
start-stop-daemon --stop --quiet --retry=TERM/1/KILL/5 --pidfile $PIDFILE --name $NAME
|
||||
RETVAL="$?"
|
||||
[ "$RETVAL" = 2 ] && return 2
|
||||
start-stop-daemon --stop --quiet --oknodo --retry=0/1/KILL/5 --exec $DAEMON
|
||||
[ "$?" = 2 ] && return 2
|
||||
# Many daemons don't delete their pidfiles when they exit.
|
||||
rm -f $PIDFILE
|
||||
return "$RETVAL"
|
||||
}
|
||||
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
[ "$SERVICEVERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME"
|
||||
do_start
|
||||
case "$?" in
|
||||
0|1) [ "$SERVICEVERBOSE" != no ] && log_end_msg 0 ;;
|
||||
2) [ "$SERVICEVERBOSE" != no ] && log_end_msg 1 ;;
|
||||
esac
|
||||
;;
|
||||
stop)
|
||||
[ "$SERVICEVERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME"
|
||||
do_stop
|
||||
case "$?" in
|
||||
0|1) [ "$SERVICEVERBOSE" != no ] && log_end_msg 0 ;;
|
||||
2) [ "$SERVICEVERBOSE" != no ] && log_end_msg 1 ;;
|
||||
esac
|
||||
;;
|
||||
status)
|
||||
status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $?
|
||||
;;
|
||||
restart|force-reload)
|
||||
log_daemon_msg "Restarting $DESC" "$NAME"
|
||||
do_stop
|
||||
case "$?" in
|
||||
0|1)
|
||||
do_start
|
||||
case "$?" in
|
||||
0) log_end_msg 0 ;;
|
||||
1) log_end_msg 1 ;; # Old process is still running
|
||||
*) log_end_msg 1 ;; # Failed to start
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
# Failed to stop
|
||||
log_end_msg 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2
|
||||
exit 3
|
||||
;;
|
||||
esac
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
@@ -36,3 +37,8 @@ func GenerateRandomBytes(size int) []byte {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
|
||||
Package crypto implements helpers related to cryptography.
|
||||
|
||||
*/
|
||||
package crypto // import "miniflux.app/crypto"
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package daemon // import "miniflux.app/daemon"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"miniflux.app/config"
|
||||
"miniflux.app/logger"
|
||||
"miniflux.app/reader/feed"
|
||||
"miniflux.app/scheduler"
|
||||
"miniflux.app/storage"
|
||||
)
|
||||
|
||||
// Run starts the daemon.
|
||||
func Run(cfg *config.Config, store *storage.Storage) {
|
||||
logger.Info("Starting Miniflux...")
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt)
|
||||
signal.Notify(stop, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
logger.Debug("Sys=%vK, InUse=%vK, HeapInUse=%vK, StackSys=%vK, StackInUse=%vK, GoRoutines=%d, NumCPU=%d",
|
||||
m.Sys/1024, (m.Sys-m.HeapReleased)/1024, m.HeapInuse/1024, m.StackSys/1024, m.StackInuse/1024,
|
||||
runtime.NumGoroutine(), runtime.NumCPU())
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
feedHandler := feed.NewFeedHandler(store)
|
||||
pool := scheduler.NewWorkerPool(feedHandler, cfg.WorkerPoolSize())
|
||||
server := newServer(cfg, store, pool, feedHandler)
|
||||
|
||||
scheduler.NewFeedScheduler(
|
||||
store,
|
||||
pool,
|
||||
cfg.PollingFrequency(),
|
||||
cfg.BatchSize(),
|
||||
)
|
||||
|
||||
scheduler.NewCleanupScheduler(store, cfg.CleanupFrequency())
|
||||
|
||||
<-stop
|
||||
logger.Info("Shutting down the server...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
server.Shutdown(ctx)
|
||||
logger.Info("Server gracefully stopped")
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package daemon // import "miniflux.app/daemon"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/api"
|
||||
"miniflux.app/config"
|
||||
"miniflux.app/fever"
|
||||
"miniflux.app/middleware"
|
||||
"miniflux.app/reader/feed"
|
||||
"miniflux.app/scheduler"
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/template"
|
||||
"miniflux.app/ui"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handler, pool *scheduler.WorkerPool) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
templateEngine := template.NewEngine(cfg, router)
|
||||
apiController := api.NewController(store, feedHandler)
|
||||
feverController := fever.NewController(cfg, store)
|
||||
uiController := ui.NewController(cfg, store, pool, feedHandler, templateEngine, router)
|
||||
middleware := middleware.New(cfg, store, router)
|
||||
|
||||
if cfg.BasePath() != "" {
|
||||
router = router.PathPrefix(cfg.BasePath()).Subrouter()
|
||||
}
|
||||
|
||||
router.Use(middleware.ClientIP)
|
||||
router.Use(middleware.HeaderConfig)
|
||||
router.Use(middleware.Logging)
|
||||
|
||||
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("User-agent: *\nDisallow: /"))
|
||||
})
|
||||
|
||||
feverRouter := router.PathPrefix("/fever").Subrouter()
|
||||
feverRouter.Use(middleware.FeverAuth)
|
||||
feverRouter.HandleFunc("/", feverController.Handler).Name("feverEndpoint")
|
||||
|
||||
apiRouter := router.PathPrefix("/v1").Subrouter()
|
||||
apiRouter.Use(middleware.BasicAuth)
|
||||
apiRouter.HandleFunc("/users", apiController.CreateUser).Methods("POST")
|
||||
apiRouter.HandleFunc("/users", apiController.Users).Methods("GET")
|
||||
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.UserByID).Methods("GET")
|
||||
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.UpdateUser).Methods("PUT")
|
||||
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.RemoveUser).Methods("DELETE")
|
||||
apiRouter.HandleFunc("/users/{username}", apiController.UserByUsername).Methods("GET")
|
||||
apiRouter.HandleFunc("/me", apiController.CurrentUser).Methods("GET")
|
||||
apiRouter.HandleFunc("/categories", apiController.CreateCategory).Methods("POST")
|
||||
apiRouter.HandleFunc("/categories", apiController.GetCategories).Methods("GET")
|
||||
apiRouter.HandleFunc("/categories/{categoryID}", apiController.UpdateCategory).Methods("PUT")
|
||||
apiRouter.HandleFunc("/categories/{categoryID}", apiController.RemoveCategory).Methods("DELETE")
|
||||
apiRouter.HandleFunc("/discover", apiController.GetSubscriptions).Methods("POST")
|
||||
apiRouter.HandleFunc("/feeds", apiController.CreateFeed).Methods("POST")
|
||||
apiRouter.HandleFunc("/feeds", apiController.GetFeeds).Methods("GET")
|
||||
apiRouter.HandleFunc("/feeds/{feedID}/refresh", apiController.RefreshFeed).Methods("PUT")
|
||||
apiRouter.HandleFunc("/feeds/{feedID}", apiController.GetFeed).Methods("GET")
|
||||
apiRouter.HandleFunc("/feeds/{feedID}", apiController.UpdateFeed).Methods("PUT")
|
||||
apiRouter.HandleFunc("/feeds/{feedID}", apiController.RemoveFeed).Methods("DELETE")
|
||||
apiRouter.HandleFunc("/feeds/{feedID}/icon", apiController.FeedIcon).Methods("GET")
|
||||
apiRouter.HandleFunc("/export", apiController.Export).Methods("GET")
|
||||
apiRouter.HandleFunc("/import", apiController.Import).Methods("POST")
|
||||
apiRouter.HandleFunc("/feeds/{feedID}/entries", apiController.GetFeedEntries).Methods("GET")
|
||||
apiRouter.HandleFunc("/feeds/{feedID}/entries/{entryID}", apiController.GetFeedEntry).Methods("GET")
|
||||
apiRouter.HandleFunc("/entries", apiController.GetEntries).Methods("GET")
|
||||
apiRouter.HandleFunc("/entries", apiController.SetEntryStatus).Methods("PUT")
|
||||
apiRouter.HandleFunc("/entries/{entryID}", apiController.GetEntry).Methods("GET")
|
||||
apiRouter.HandleFunc("/entries/{entryID}/bookmark", apiController.ToggleBookmark).Methods("PUT")
|
||||
|
||||
uiRouter := router.NewRoute().Subrouter()
|
||||
uiRouter.Use(middleware.AppSession)
|
||||
uiRouter.Use(middleware.UserSession)
|
||||
uiRouter.HandleFunc("/stylesheets/{name}.css", uiController.Stylesheet).Name("stylesheet").Methods("GET")
|
||||
uiRouter.HandleFunc("/{name}.js", uiController.Javascript).Name("javascript").Methods("GET")
|
||||
uiRouter.HandleFunc("/favicon.ico", uiController.Favicon).Name("favicon").Methods("GET")
|
||||
uiRouter.HandleFunc("/icon/{filename}", uiController.AppIcon).Name("appIcon").Methods("GET")
|
||||
uiRouter.HandleFunc("/manifest.json", uiController.WebManifest).Name("webManifest").Methods("GET")
|
||||
uiRouter.HandleFunc("/subscribe", uiController.AddSubscription).Name("addSubscription").Methods("GET")
|
||||
uiRouter.HandleFunc("/subscribe", uiController.SubmitSubscription).Name("submitSubscription").Methods("POST")
|
||||
uiRouter.HandleFunc("/subscriptions", uiController.ChooseSubscription).Name("chooseSubscription").Methods("POST")
|
||||
uiRouter.HandleFunc("/mark-all-as-read", uiController.MarkAllAsRead).Name("markAllAsRead").Methods("GET")
|
||||
uiRouter.HandleFunc("/unread", uiController.ShowUnreadPage).Name("unread").Methods("GET")
|
||||
uiRouter.HandleFunc("/history", uiController.ShowHistoryPage).Name("history").Methods("GET")
|
||||
uiRouter.HandleFunc("/starred", uiController.ShowStarredPage).Name("starred").Methods("GET")
|
||||
uiRouter.HandleFunc("/search", uiController.ShowSearchEntries).Name("searchEntries").Methods("GET")
|
||||
uiRouter.HandleFunc("/search/entry/{entryID}", uiController.ShowSearchEntry).Name("searchEntry").Methods("GET")
|
||||
uiRouter.HandleFunc("/feed/{feedID}/refresh", uiController.RefreshFeed).Name("refreshFeed").Methods("GET")
|
||||
uiRouter.HandleFunc("/feed/{feedID}/edit", uiController.EditFeed).Name("editFeed").Methods("GET")
|
||||
uiRouter.HandleFunc("/feed/{feedID}/remove", uiController.RemoveFeed).Name("removeFeed").Methods("POST")
|
||||
uiRouter.HandleFunc("/feed/{feedID}/update", uiController.UpdateFeed).Name("updateFeed").Methods("POST")
|
||||
uiRouter.HandleFunc("/feed/{feedID}/entries", uiController.ShowFeedEntries).Name("feedEntries").Methods("GET")
|
||||
uiRouter.HandleFunc("/feeds", uiController.ShowFeedsPage).Name("feeds").Methods("GET")
|
||||
uiRouter.HandleFunc("/feeds/refresh", uiController.RefreshAllFeeds).Name("refreshAllFeeds").Methods("GET")
|
||||
uiRouter.HandleFunc("/unread/entry/{entryID}", uiController.ShowUnreadEntry).Name("unreadEntry").Methods("GET")
|
||||
uiRouter.HandleFunc("/history/entry/{entryID}", uiController.ShowReadEntry).Name("readEntry").Methods("GET")
|
||||
uiRouter.HandleFunc("/history/flush", uiController.FlushHistory).Name("flushHistory").Methods("GET")
|
||||
uiRouter.HandleFunc("/feed/{feedID}/entry/{entryID}", uiController.ShowFeedEntry).Name("feedEntry").Methods("GET")
|
||||
uiRouter.HandleFunc("/category/{categoryID}/entry/{entryID}", uiController.ShowCategoryEntry).Name("categoryEntry").Methods("GET")
|
||||
uiRouter.HandleFunc("/starred/entry/{entryID}", uiController.ShowStarredEntry).Name("starredEntry").Methods("GET")
|
||||
uiRouter.HandleFunc("/entry/status", uiController.UpdateEntriesStatus).Name("updateEntriesStatus").Methods("POST")
|
||||
uiRouter.HandleFunc("/entry/save/{entryID}", uiController.SaveEntry).Name("saveEntry").Methods("POST")
|
||||
uiRouter.HandleFunc("/entry/download/{entryID}", uiController.FetchContent).Name("fetchContent").Methods("POST")
|
||||
uiRouter.HandleFunc("/entry/bookmark/{entryID}", uiController.ToggleBookmark).Name("toggleBookmark").Methods("POST")
|
||||
uiRouter.HandleFunc("/categories", uiController.CategoryList).Name("categories").Methods("GET")
|
||||
uiRouter.HandleFunc("/category/create", uiController.CreateCategory).Name("createCategory").Methods("GET")
|
||||
uiRouter.HandleFunc("/category/save", uiController.SaveCategory).Name("saveCategory").Methods("POST")
|
||||
uiRouter.HandleFunc("/category/{categoryID}/entries", uiController.CategoryEntries).Name("categoryEntries").Methods("GET")
|
||||
uiRouter.HandleFunc("/category/{categoryID}/edit", uiController.EditCategory).Name("editCategory").Methods("GET")
|
||||
uiRouter.HandleFunc("/category/{categoryID}/update", uiController.UpdateCategory).Name("updateCategory").Methods("POST")
|
||||
uiRouter.HandleFunc("/category/{categoryID}/remove", uiController.RemoveCategory).Name("removeCategory").Methods("POST")
|
||||
uiRouter.HandleFunc("/feed/icon/{iconID}", uiController.ShowIcon).Name("icon").Methods("GET")
|
||||
uiRouter.HandleFunc("/proxy/{encodedURL}", uiController.ImageProxy).Name("proxy").Methods("GET")
|
||||
uiRouter.HandleFunc("/users", uiController.ShowUsers).Name("users").Methods("GET")
|
||||
uiRouter.HandleFunc("/user/create", uiController.CreateUser).Name("createUser").Methods("GET")
|
||||
uiRouter.HandleFunc("/user/save", uiController.SaveUser).Name("saveUser").Methods("POST")
|
||||
uiRouter.HandleFunc("/users/{userID}/edit", uiController.EditUser).Name("editUser").Methods("GET")
|
||||
uiRouter.HandleFunc("/users/{userID}/update", uiController.UpdateUser).Name("updateUser").Methods("POST")
|
||||
uiRouter.HandleFunc("/users/{userID}/remove", uiController.RemoveUser).Name("removeUser").Methods("POST")
|
||||
uiRouter.HandleFunc("/about", uiController.About).Name("about").Methods("GET")
|
||||
uiRouter.HandleFunc("/settings", uiController.ShowSettings).Name("settings").Methods("GET")
|
||||
uiRouter.HandleFunc("/settings", uiController.UpdateSettings).Name("updateSettings").Methods("POST")
|
||||
uiRouter.HandleFunc("/bookmarklet", uiController.Bookmarklet).Name("bookmarklet").Methods("GET")
|
||||
uiRouter.HandleFunc("/integrations", uiController.ShowIntegrations).Name("integrations").Methods("GET")
|
||||
uiRouter.HandleFunc("/integration", uiController.UpdateIntegration).Name("updateIntegration").Methods("POST")
|
||||
uiRouter.HandleFunc("/integration/pocket/authorize", uiController.PocketAuthorize).Name("pocketAuthorize").Methods("GET")
|
||||
uiRouter.HandleFunc("/integration/pocket/callback", uiController.PocketCallback).Name("pocketCallback").Methods("GET")
|
||||
uiRouter.HandleFunc("/sessions", uiController.ShowSessions).Name("sessions").Methods("GET")
|
||||
uiRouter.HandleFunc("/sessions/{sessionID}/remove", uiController.RemoveSession).Name("removeSession").Methods("POST")
|
||||
uiRouter.HandleFunc("/export", uiController.Export).Name("export").Methods("GET")
|
||||
uiRouter.HandleFunc("/import", uiController.Import).Name("import").Methods("GET")
|
||||
uiRouter.HandleFunc("/upload", uiController.UploadOPML).Name("uploadOPML").Methods("POST")
|
||||
uiRouter.HandleFunc("/oauth2/{provider}/unlink", uiController.OAuth2Unlink).Name("oauth2Unlink").Methods("GET")
|
||||
uiRouter.HandleFunc("/oauth2/{provider}/redirect", uiController.OAuth2Redirect).Name("oauth2Redirect").Methods("GET")
|
||||
uiRouter.HandleFunc("/oauth2/{provider}/callback", uiController.OAuth2Callback).Name("oauth2Callback").Methods("GET")
|
||||
uiRouter.HandleFunc("/login", uiController.CheckLogin).Name("checkLogin").Methods("POST")
|
||||
uiRouter.HandleFunc("/logout", uiController.Logout).Name("logout").Methods("GET")
|
||||
uiRouter.HandleFunc("/", uiController.ShowLoginPage).Name("login").Methods("GET")
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package daemon // import "miniflux.app/daemon"
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/config"
|
||||
"miniflux.app/logger"
|
||||
"miniflux.app/reader/feed"
|
||||
"miniflux.app/scheduler"
|
||||
"miniflux.app/storage"
|
||||
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
func newServer(cfg *config.Config, store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler) *http.Server {
|
||||
certFile := cfg.CertFile()
|
||||
keyFile := cfg.KeyFile()
|
||||
certDomain := cfg.CertDomain()
|
||||
certCache := cfg.CertCache()
|
||||
server := &http.Server{
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
Addr: cfg.ListenAddr(),
|
||||
Handler: routes(cfg, store, feedHandler, pool),
|
||||
}
|
||||
|
||||
if certDomain != "" && certCache != "" {
|
||||
cfg.IsHTTPS = true
|
||||
server.Addr = ":https"
|
||||
certManager := autocert.Manager{
|
||||
Cache: autocert.DirCache(certCache),
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(certDomain),
|
||||
}
|
||||
|
||||
// Handle http-01 challenge.
|
||||
s := &http.Server{
|
||||
Handler: certManager.HTTPHandler(nil),
|
||||
Addr: ":http",
|
||||
}
|
||||
go s.ListenAndServe()
|
||||
|
||||
go func() {
|
||||
logger.Info(`Listening on "%s" by using auto-configured certificate for "%s"`, server.Addr, certDomain)
|
||||
if err := server.Serve(certManager.Listener()); err != http.ErrServerClosed {
|
||||
logger.Fatal(`Server failed to start: %v`, err)
|
||||
}
|
||||
}()
|
||||
} else if certFile != "" && keyFile != "" {
|
||||
cfg.IsHTTPS = true
|
||||
|
||||
// See https://blog.cloudflare.com/exposing-go-on-the-internet/
|
||||
// And https://wiki.mozilla.org/Security/Server_Side_TLS
|
||||
server.TLSConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
PreferServerCipherSuites: true,
|
||||
CurvePreferences: []tls.CurveID{
|
||||
tls.CurveP256,
|
||||
tls.X25519,
|
||||
},
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
},
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.Info(`Listening on "%s" by using certificate "%s" and key "%s"`, server.Addr, certFile, keyFile)
|
||||
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
|
||||
logger.Fatal(`Server failed to start: %v`, err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
go func() {
|
||||
logger.Info(`Listening on "%s" without TLS`, server.Addr)
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
logger.Fatal(`Server failed to start: %v`, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
+54
-1
@@ -6,13 +6,15 @@ package database // import "miniflux.app/database"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// Postgresql driver import
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// NewConnectionPool configures the database connection pool.
|
||||
func NewConnectionPool(dsn string, minConnections, maxConnections int) (*sql.DB, error) {
|
||||
func NewConnectionPool(dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) {
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -20,6 +22,57 @@ func NewConnectionPool(dsn string, minConnections, maxConnections int) (*sql.DB,
|
||||
|
||||
db.SetMaxOpenConns(maxConnections)
|
||||
db.SetMaxIdleConns(minConnections)
|
||||
db.SetConnMaxLifetime(connectionLifetime)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Migrate executes database migrations.
|
||||
func Migrate(db *sql.DB) error {
|
||||
var currentVersion int
|
||||
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
|
||||
|
||||
fmt.Println("-> Current schema version:", currentVersion)
|
||||
fmt.Println("-> Latest schema version:", schemaVersion)
|
||||
|
||||
for version := currentVersion; version < schemaVersion; version++ {
|
||||
newVersion := version + 1
|
||||
fmt.Println("* Migrating to version:", newVersion)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
|
||||
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 {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES ($1)`, newVersion); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSchemaUpToDate checks if the database schema is up to date.
|
||||
func IsSchemaUpToDate(db *sql.DB) error {
|
||||
var currentVersion int
|
||||
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
|
||||
if currentVersion < schemaVersion {
|
||||
return fmt.Errorf(`the database schema is not up to date: current=v%d expected=v%d`, currentVersion, schemaVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
|
||||
Package database provides functions related to the database connection.
|
||||
|
||||
*/
|
||||
package database // import "miniflux.app/database"
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package database // import "miniflux.app/database"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"miniflux.app/logger"
|
||||
)
|
||||
|
||||
const schemaVersion = 21
|
||||
|
||||
// Migrate executes database migrations.
|
||||
func Migrate(db *sql.DB) {
|
||||
var currentVersion int
|
||||
db.QueryRow(`select version from schema_version`).Scan(¤tVersion)
|
||||
|
||||
fmt.Println("Current schema version:", currentVersion)
|
||||
fmt.Println("Latest schema version:", schemaVersion)
|
||||
|
||||
for version := currentVersion + 1; version <= schemaVersion; version++ {
|
||||
fmt.Println("Migrating to version:", version)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
logger.Fatal("[Migrate] %v", err)
|
||||
}
|
||||
|
||||
rawSQL := SqlMap["schema_version_"+strconv.Itoa(version)]
|
||||
// fmt.Println(rawSQL)
|
||||
_, err = tx.Exec(rawSQL)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
logger.Fatal("[Migrate] %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`delete from schema_version`); err != nil {
|
||||
tx.Rollback()
|
||||
logger.Fatal("[Migrate] %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`insert into schema_version (version) values($1)`, version); err != nil {
|
||||
tx.Rollback()
|
||||
logger.Fatal("[Migrate] %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
logger.Fatal("[Migrate] %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
// Copyright 2020 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package database // import "miniflux.app/database"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
var schemaVersion = len(migrations)
|
||||
|
||||
// Order is important. Add new migrations at the end of the list.
|
||||
var migrations = []func(tx *sql.Tx) error{
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE schema_version (
|
||||
version text not null
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id serial not null,
|
||||
username text not null unique,
|
||||
password text,
|
||||
is_admin bool default 'f',
|
||||
language text default 'en_US',
|
||||
timezone text default 'UTC',
|
||||
theme text default 'default',
|
||||
last_login_at timestamp with time zone,
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id serial not null,
|
||||
user_id int not null,
|
||||
token text not null unique,
|
||||
created_at timestamp with time zone default now(),
|
||||
user_agent text,
|
||||
ip text,
|
||||
primary key (id),
|
||||
unique (user_id, token),
|
||||
foreign key (user_id) references users(id) on delete cascade
|
||||
);
|
||||
|
||||
CREATE TABLE categories (
|
||||
id serial not null,
|
||||
user_id int not null,
|
||||
title text not null,
|
||||
primary key (id),
|
||||
unique (user_id, title),
|
||||
foreign key (user_id) references users(id) on delete cascade
|
||||
);
|
||||
|
||||
CREATE TABLE feeds (
|
||||
id bigserial not null,
|
||||
user_id int not null,
|
||||
category_id int not null,
|
||||
title text not null,
|
||||
feed_url text not null,
|
||||
site_url text not null,
|
||||
checked_at timestamp with time zone default now(),
|
||||
etag_header text default '',
|
||||
last_modified_header text default '',
|
||||
parsing_error_msg text default '',
|
||||
parsing_error_count int default 0,
|
||||
primary key (id),
|
||||
unique (user_id, feed_url),
|
||||
foreign key (user_id) references users(id) on delete cascade,
|
||||
foreign key (category_id) references categories(id) on delete cascade
|
||||
);
|
||||
|
||||
CREATE TYPE entry_status as enum('unread', 'read', 'removed');
|
||||
|
||||
CREATE TABLE entries (
|
||||
id bigserial not null,
|
||||
user_id int not null,
|
||||
feed_id bigint not null,
|
||||
hash text not null,
|
||||
published_at timestamp with time zone not null,
|
||||
title text not null,
|
||||
url text not null,
|
||||
author text,
|
||||
content text,
|
||||
status entry_status default 'unread',
|
||||
primary key (id),
|
||||
unique (feed_id, hash),
|
||||
foreign key (user_id) references users(id) on delete cascade,
|
||||
foreign key (feed_id) references feeds(id) on delete cascade
|
||||
);
|
||||
|
||||
CREATE INDEX entries_feed_idx on entries using btree(feed_id);
|
||||
|
||||
CREATE TABLE enclosures (
|
||||
id bigserial not null,
|
||||
user_id int not null,
|
||||
entry_id bigint not null,
|
||||
url text not null,
|
||||
size int default 0,
|
||||
mime_type text default '',
|
||||
primary key (id),
|
||||
foreign key (user_id) references users(id) on delete cascade,
|
||||
foreign key (entry_id) references entries(id) on delete cascade
|
||||
);
|
||||
|
||||
CREATE TABLE icons (
|
||||
id bigserial not null,
|
||||
hash text not null unique,
|
||||
mime_type text not null,
|
||||
content bytea not null,
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
CREATE TABLE feed_icons (
|
||||
feed_id bigint not null,
|
||||
icon_id bigint not null,
|
||||
primary key(feed_id, icon_id),
|
||||
foreign key (feed_id) references feeds(id) on delete cascade,
|
||||
foreign key (icon_id) references icons(id) on delete cascade
|
||||
);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE EXTENSION IF NOT EXISTS hstore;
|
||||
ALTER TABLE users ADD COLUMN extra hstore;
|
||||
CREATE INDEX users_extra_idx ON users using gin(extra);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE tokens (
|
||||
id text not null,
|
||||
value text not null,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
primary key(id, value)
|
||||
);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE entry_sorting_direction AS enum('asc', 'desc');
|
||||
ALTER TABLE users ADD COLUMN entry_direction entry_sorting_direction default 'asc';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE integrations (
|
||||
user_id int not null,
|
||||
pinboard_enabled bool default 'f',
|
||||
pinboard_token text default '',
|
||||
pinboard_tags text default 'miniflux',
|
||||
pinboard_mark_as_unread bool default 'f',
|
||||
instapaper_enabled bool default 'f',
|
||||
instapaper_username text default '',
|
||||
instapaper_password text default '',
|
||||
fever_enabled bool default 'f',
|
||||
fever_username text default '',
|
||||
fever_password text default '',
|
||||
fever_token text default '',
|
||||
primary key(user_id)
|
||||
);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN scraper_rules text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN rewrite_rules text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN crawler boolean default 'f'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE sessions rename to user_sessions`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
DROP TABLE tokens;
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id text not null,
|
||||
data jsonb not null,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
primary key(id)
|
||||
);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_url text default '';
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_client_id text default '';
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_client_secret text default '';
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_username text default '';
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_password text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE entries ADD COLUMN starred bool default 'f'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE INDEX entries_user_status_idx ON entries(user_id, status);
|
||||
CREATE INDEX feeds_user_category_idx ON feeds(user_id, category_id);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN nunux_keeper_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN nunux_keeper_url text default '';
|
||||
ALTER TABLE integrations ADD COLUMN nunux_keeper_api_key text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE enclosures ALTER COLUMN size SET DATA TYPE bigint`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE entries ADD COLUMN comments_url text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN pocket_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN pocket_access_token text default '';
|
||||
ALTER TABLE integrations ADD COLUMN pocket_consumer_key text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE user_sessions ALTER COLUMN ip SET DATA TYPE inet using ip::inet;
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds ADD COLUMN username text default '';
|
||||
ALTER TABLE feeds ADD COLUMN password text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN document_vectors tsvector;
|
||||
UPDATE entries SET document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
|
||||
CREATE INDEX document_vectors_idx ON entries USING gin(document_vectors);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN user_agent text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
UPDATE
|
||||
entries
|
||||
SET
|
||||
document_vectors = setweight(to_tsvector(substring(coalesce(title, '') for 1000000)), 'A') || setweight(to_tsvector(substring(coalesce(content, '') for 1000000)), 'B')
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN keyboard_shortcuts boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN disabled boolean default 'f';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE users ALTER COLUMN theme SET DEFAULT 'light_serif';
|
||||
UPDATE users SET theme='light_serif' WHERE theme='default';
|
||||
UPDATE users SET theme='light_sans_serif' WHERE theme='sansserif';
|
||||
UPDATE users SET theme='dark_serif' WHERE theme='black';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN changed_at timestamp with time zone;
|
||||
UPDATE entries SET changed_at = published_at;
|
||||
ALTER TABLE entries ALTER COLUMN changed_at SET not null;
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE api_keys (
|
||||
id serial not null,
|
||||
user_id int not null references users(id) on delete cascade,
|
||||
token text not null unique,
|
||||
description text not null,
|
||||
last_used_at timestamp with time zone,
|
||||
created_at timestamp with time zone default now(),
|
||||
primary key(id),
|
||||
unique (user_id, description)
|
||||
);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN share_code text not null default '';
|
||||
CREATE UNIQUE INDEX entries_share_code_idx ON entries USING btree(share_code) WHERE share_code <> '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds ADD COLUMN next_check_at timestamp with time zone default now();
|
||||
CREATE INDEX entries_user_feed_idx ON entries (user_id, feed_id);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN ignore_http_cache bool default false`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN entries_per_page int default 100`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN show_reading_time boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `CREATE INDEX entries_id_user_status_idx ON entries USING btree (id, user_id, status)`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN fetch_via_proxy bool default false`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `CREATE INDEX entries_feed_id_status_hash_idx ON entries USING btree (feed_id, status, hash)`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `CREATE INDEX entries_user_id_status_starred_idx ON entries (user_id, status, starred)`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN entry_swipe boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE integrations DROP COLUMN fever_password`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds
|
||||
ADD COLUMN blocklist_rules text not null default '',
|
||||
ADD COLUMN keeplist_rules text not null default ''
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE entries ADD COLUMN reading_time int not null default 0`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN created_at timestamp with time zone not null default now();
|
||||
UPDATE entries SET created_at = published_at;
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users
|
||||
ADD column stylesheet text not null default '',
|
||||
ADD column google_id text not null default '',
|
||||
ADD column openid_connect_id text not null default ''
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
DECLARE my_cursor CURSOR FOR
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(extra->'custom_css', '') as custom_css,
|
||||
COALESCE(extra->'google_id', '') as google_id,
|
||||
COALESCE(extra->'oidc_id', '') as oidc_id
|
||||
FROM users
|
||||
FOR UPDATE
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Exec("CLOSE my_cursor")
|
||||
|
||||
for {
|
||||
var (
|
||||
userID int64
|
||||
customStylesheet string
|
||||
googleID string
|
||||
oidcID string
|
||||
)
|
||||
|
||||
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := tx.Exec(
|
||||
`UPDATE
|
||||
users
|
||||
SET
|
||||
stylesheet=$2,
|
||||
google_id=$3,
|
||||
openid_connect_id=$4
|
||||
WHERE
|
||||
id=$1
|
||||
`,
|
||||
userID, customStylesheet, googleID, oidcID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users DROP COLUMN extra;
|
||||
CREATE UNIQUE INDEX users_google_id_idx ON users(google_id) WHERE google_id <> '';
|
||||
CREATE UNIQUE INDEX users_openid_connect_id_idx ON users(openid_connect_id) WHERE openid_connect_id <> '';
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
CREATE INDEX entries_feed_url_idx ON entries(feed_id, url);
|
||||
CREATE INDEX entries_user_status_feed_idx ON entries(user_id, status, feed_id);
|
||||
CREATE INDEX entries_user_status_changed_idx ON entries(user_id, status, changed_at);
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE acme_cache (
|
||||
key varchar(400) not null primary key,
|
||||
data bytea not null,
|
||||
updated_at timestamptz not null
|
||||
);
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN allow_self_signed_certificates boolean not null default false
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE webapp_display_mode AS enum('fullscreen', 'standalone', 'minimal-ui', 'browser');
|
||||
ALTER TABLE users ADD COLUMN display_mode webapp_display_mode default 'standalone';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN cookie text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE categories ADD COLUMN hide_globally boolean not null default false
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN hide_globally boolean not null default false
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN telegram_bot_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN telegram_bot_token text default '';
|
||||
ALTER TABLE integrations ADD COLUMN telegram_bot_chat_id text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE entry_sorting_order AS enum('published_at', 'created_at');
|
||||
ALTER TABLE users ADD COLUMN entry_order entry_sorting_order default 'published_at';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN googlereader_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN googlereader_username text default '';
|
||||
ALTER TABLE integrations ADD COLUMN googlereader_password text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN espial_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN espial_url text default '';
|
||||
ALTER TABLE integrations ADD COLUMN espial_api_key text default '';
|
||||
ALTER TABLE integrations ADD COLUMN espial_tags text default 'miniflux';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN linkding_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN linkding_url text default '';
|
||||
ALTER TABLE integrations ADD COLUMN linkding_api_key text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN url_rewrite_rules text not null default ''
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users ADD COLUMN default_reading_speed int default 265;
|
||||
ALTER TABLE users ADD COLUMN cjk_reading_speed int default 500;
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users ADD COLUMN default_home_page text default 'unread';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_only_url bool default 'f';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users ADD COLUMN categories_sorting_order text not null default 'unread_count';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN matrix_bot_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN matrix_bot_user text default '';
|
||||
ALTER TABLE integrations ADD COLUMN matrix_bot_password text default '';
|
||||
ALTER TABLE integrations ADD COLUMN matrix_bot_url text default '';
|
||||
ALTER TABLE integrations ADD COLUMN matrix_bot_chat_id text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN double_tap boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE entries ADD COLUMN tags text[] default '{}';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE users RENAME double_tap TO gesture_nav;
|
||||
ALTER TABLE users ALTER COLUMN gesture_nav SET DATA TYPE text using case when gesture_nav = true then 'tap' when gesture_nav = false then 'none' end;
|
||||
ALTER TABLE users ALTER COLUMN gesture_nav SET default 'tap';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
}
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
// Code generated by go generate; DO NOT EDIT.
|
||||
|
||||
package database // import "miniflux.app/database"
|
||||
|
||||
var SqlMap = map[string]string{
|
||||
"schema_version_1": `create table schema_version (
|
||||
version text not null
|
||||
);
|
||||
|
||||
create table users (
|
||||
id serial not null,
|
||||
username text not null unique,
|
||||
password text,
|
||||
is_admin bool default 'f',
|
||||
language text default 'en_US',
|
||||
timezone text default 'UTC',
|
||||
theme text default 'default',
|
||||
last_login_at timestamp with time zone,
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
create table sessions (
|
||||
id serial not null,
|
||||
user_id int not null,
|
||||
token text not null unique,
|
||||
created_at timestamp with time zone default now(),
|
||||
user_agent text,
|
||||
ip text,
|
||||
primary key (id),
|
||||
unique (user_id, token),
|
||||
foreign key (user_id) references users(id) on delete cascade
|
||||
);
|
||||
|
||||
create table categories (
|
||||
id serial not null,
|
||||
user_id int not null,
|
||||
title text not null,
|
||||
primary key (id),
|
||||
unique (user_id, title),
|
||||
foreign key (user_id) references users(id) on delete cascade
|
||||
);
|
||||
|
||||
create table feeds (
|
||||
id bigserial not null,
|
||||
user_id int not null,
|
||||
category_id int not null,
|
||||
title text not null,
|
||||
feed_url text not null,
|
||||
site_url text not null,
|
||||
checked_at timestamp with time zone default now(),
|
||||
etag_header text default '',
|
||||
last_modified_header text default '',
|
||||
parsing_error_msg text default '',
|
||||
parsing_error_count int default 0,
|
||||
primary key (id),
|
||||
unique (user_id, feed_url),
|
||||
foreign key (user_id) references users(id) on delete cascade,
|
||||
foreign key (category_id) references categories(id) on delete cascade
|
||||
);
|
||||
|
||||
create type entry_status as enum('unread', 'read', 'removed');
|
||||
|
||||
create table entries (
|
||||
id bigserial not null,
|
||||
user_id int not null,
|
||||
feed_id bigint not null,
|
||||
hash text not null,
|
||||
published_at timestamp with time zone not null,
|
||||
title text not null,
|
||||
url text not null,
|
||||
author text,
|
||||
content text,
|
||||
status entry_status default 'unread',
|
||||
primary key (id),
|
||||
unique (feed_id, hash),
|
||||
foreign key (user_id) references users(id) on delete cascade,
|
||||
foreign key (feed_id) references feeds(id) on delete cascade
|
||||
);
|
||||
|
||||
create index entries_feed_idx on entries using btree(feed_id);
|
||||
|
||||
create table enclosures (
|
||||
id bigserial not null,
|
||||
user_id int not null,
|
||||
entry_id bigint not null,
|
||||
url text not null,
|
||||
size int default 0,
|
||||
mime_type text default '',
|
||||
primary key (id),
|
||||
foreign key (user_id) references users(id) on delete cascade,
|
||||
foreign key (entry_id) references entries(id) on delete cascade
|
||||
);
|
||||
|
||||
create table icons (
|
||||
id bigserial not null,
|
||||
hash text not null unique,
|
||||
mime_type text not null,
|
||||
content bytea not null,
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
create table feed_icons (
|
||||
feed_id bigint not null,
|
||||
icon_id bigint not null,
|
||||
primary key(feed_id, icon_id),
|
||||
foreign key (feed_id) references feeds(id) on delete cascade,
|
||||
foreign key (icon_id) references icons(id) on delete cascade
|
||||
);
|
||||
`,
|
||||
"schema_version_10": `drop table tokens;
|
||||
|
||||
create table sessions (
|
||||
id text not null,
|
||||
data jsonb not null,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
primary key(id)
|
||||
);`,
|
||||
"schema_version_11": `alter table integrations add column wallabag_enabled bool default 'f';
|
||||
alter table integrations add column wallabag_url text default '';
|
||||
alter table integrations add column wallabag_client_id text default '';
|
||||
alter table integrations add column wallabag_client_secret text default '';
|
||||
alter table integrations add column wallabag_username text default '';
|
||||
alter table integrations add column wallabag_password text default '';`,
|
||||
"schema_version_12": `alter table entries add column starred bool default 'f';`,
|
||||
"schema_version_13": `create index entries_user_status_idx on entries(user_id, status);
|
||||
create index feeds_user_category_idx on feeds(user_id, category_id);
|
||||
`,
|
||||
"schema_version_14": `alter table integrations add column nunux_keeper_enabled bool default 'f';
|
||||
alter table integrations add column nunux_keeper_url text default '';
|
||||
alter table integrations add column nunux_keeper_api_key text default '';`,
|
||||
"schema_version_15": `alter table enclosures alter column size set data type bigint;`,
|
||||
"schema_version_16": `alter table entries add column comments_url text default '';`,
|
||||
"schema_version_17": `alter table integrations add column pocket_enabled bool default 'f';
|
||||
alter table integrations add column pocket_access_token text default '';
|
||||
alter table integrations add column pocket_consumer_key text default '';
|
||||
`,
|
||||
"schema_version_18": `alter table user_sessions alter column ip set data type inet using ip::inet;`,
|
||||
"schema_version_19": `alter table feeds add column username text default '';
|
||||
alter table feeds add column password text default '';`,
|
||||
"schema_version_2": `create extension if not exists hstore;
|
||||
alter table users add column extra hstore;
|
||||
create index users_extra_idx on users using gin(extra);
|
||||
`,
|
||||
"schema_version_20": `alter table entries add column document_vectors tsvector;
|
||||
update entries set document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
|
||||
create index document_vectors_idx on entries using gin(document_vectors);`,
|
||||
"schema_version_21": `alter table feeds add column user_agent text default '';`,
|
||||
"schema_version_3": `create table tokens (
|
||||
id text not null,
|
||||
value text not null,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
primary key(id, value)
|
||||
);`,
|
||||
"schema_version_4": `create type entry_sorting_direction as enum('asc', 'desc');
|
||||
alter table users add column entry_direction entry_sorting_direction default 'asc';
|
||||
`,
|
||||
"schema_version_5": `create table integrations (
|
||||
user_id int not null,
|
||||
pinboard_enabled bool default 'f',
|
||||
pinboard_token text default '',
|
||||
pinboard_tags text default 'miniflux',
|
||||
pinboard_mark_as_unread bool default 'f',
|
||||
instapaper_enabled bool default 'f',
|
||||
instapaper_username text default '',
|
||||
instapaper_password text default '',
|
||||
fever_enabled bool default 'f',
|
||||
fever_username text default '',
|
||||
fever_password text default '',
|
||||
fever_token text default '',
|
||||
primary key(user_id)
|
||||
)
|
||||
`,
|
||||
"schema_version_6": `alter table feeds add column scraper_rules text default '';
|
||||
`,
|
||||
"schema_version_7": `alter table feeds add column rewrite_rules text default '';
|
||||
`,
|
||||
"schema_version_8": `alter table feeds add column crawler boolean default 'f';
|
||||
`,
|
||||
"schema_version_9": `alter table sessions rename to user_sessions;`,
|
||||
}
|
||||
|
||||
var SqlMapChecksums = map[string]string{
|
||||
"schema_version_1": "00b2fa9e945565625c93ef9d4242a8b6583dc3cd7edf38d2fc95c0f3f7b926ae",
|
||||
"schema_version_10": "8faf15ddeff7c8cc305e66218face11ed92b97df2bdc2d0d7944d61441656795",
|
||||
"schema_version_11": "dc5bbc302e01e425b49c48ddcd8e29e3ab2bb8e73a6cd1858a6ba9fbec0b5243",
|
||||
"schema_version_12": "a95abab6cdf64811fc744abd37457e2928939d999c5ef00d2bdd9398e16f32fb",
|
||||
"schema_version_13": "9073fae1e796936f4a43a8120ebdb4218442fe7d346ace6387556a357c2d7edf",
|
||||
"schema_version_14": "4622e42c4a5a88b6fe1e61f3d367b295968f7260ab5b96481760775ba9f9e1fe",
|
||||
"schema_version_15": "13ff91462bdf4cda5a94a4c7a09f757761b0f2c32b4be713ba4786a4837750e4",
|
||||
"schema_version_16": "9d006faca62fd7ab787f64aef0e0a5933d142466ec4cab0e096bb920d2797e34",
|
||||
"schema_version_17": "b9f15d6217275fedcf6d948dd85ebe978b869bf37f42a86fd5b50a51919fa0e1",
|
||||
"schema_version_18": "c0ec24847612c7f2dc326cf735baffba79391a56aedd73292371a39f38724a71",
|
||||
"schema_version_19": "a83f77b41cc213d282805a5b518f15abbf96331599119f0ef4aca4be037add7b",
|
||||
"schema_version_2": "e8e9ff32478df04fcddad10a34cba2e8bb1e67e7977b5bd6cdc4c31ec94282b4",
|
||||
"schema_version_20": "5d414c0cfc0da2863c641079afa58b7ff42dccb0f0e01c822ad435c3e3aa9201",
|
||||
"schema_version_21": "77da01ee38918ff4fe33985fbb20ed3276a717a7584c2ca9ebcf4d4ab6cb6910",
|
||||
"schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12",
|
||||
"schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9",
|
||||
"schema_version_5": "46397e2f5f2c82116786127e9f6a403e975b14d2ca7b652a48cd1ba843e6a27c",
|
||||
"schema_version_6": "9d05b4fb223f0e60efc716add5048b0ca9c37511cf2041721e20505d6d798ce4",
|
||||
"schema_version_7": "33f298c9aa30d6de3ca28e1270df51c2884d7596f1283a75716e2aeb634cd05c",
|
||||
"schema_version_8": "9922073fc4032d8922617ec6a6a07ae8d4817846c138760fb96cb5608ab83bfc",
|
||||
"schema_version_9": "de5ba954752fe808a993feef5bf0c6f808e0a4ced5379de8bec8342678150892",
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
create table schema_version (
|
||||
version text not null
|
||||
);
|
||||
|
||||
create table users (
|
||||
id serial not null,
|
||||
username text not null unique,
|
||||
password text,
|
||||
is_admin bool default 'f',
|
||||
language text default 'en_US',
|
||||
timezone text default 'UTC',
|
||||
theme text default 'default',
|
||||
last_login_at timestamp with time zone,
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
create table sessions (
|
||||
id serial not null,
|
||||
user_id int not null,
|
||||
token text not null unique,
|
||||
created_at timestamp with time zone default now(),
|
||||
user_agent text,
|
||||
ip text,
|
||||
primary key (id),
|
||||
unique (user_id, token),
|
||||
foreign key (user_id) references users(id) on delete cascade
|
||||
);
|
||||
|
||||
create table categories (
|
||||
id serial not null,
|
||||
user_id int not null,
|
||||
title text not null,
|
||||
primary key (id),
|
||||
unique (user_id, title),
|
||||
foreign key (user_id) references users(id) on delete cascade
|
||||
);
|
||||
|
||||
create table feeds (
|
||||
id bigserial not null,
|
||||
user_id int not null,
|
||||
category_id int not null,
|
||||
title text not null,
|
||||
feed_url text not null,
|
||||
site_url text not null,
|
||||
checked_at timestamp with time zone default now(),
|
||||
etag_header text default '',
|
||||
last_modified_header text default '',
|
||||
parsing_error_msg text default '',
|
||||
parsing_error_count int default 0,
|
||||
primary key (id),
|
||||
unique (user_id, feed_url),
|
||||
foreign key (user_id) references users(id) on delete cascade,
|
||||
foreign key (category_id) references categories(id) on delete cascade
|
||||
);
|
||||
|
||||
create type entry_status as enum('unread', 'read', 'removed');
|
||||
|
||||
create table entries (
|
||||
id bigserial not null,
|
||||
user_id int not null,
|
||||
feed_id bigint not null,
|
||||
hash text not null,
|
||||
published_at timestamp with time zone not null,
|
||||
title text not null,
|
||||
url text not null,
|
||||
author text,
|
||||
content text,
|
||||
status entry_status default 'unread',
|
||||
primary key (id),
|
||||
unique (feed_id, hash),
|
||||
foreign key (user_id) references users(id) on delete cascade,
|
||||
foreign key (feed_id) references feeds(id) on delete cascade
|
||||
);
|
||||
|
||||
create index entries_feed_idx on entries using btree(feed_id);
|
||||
|
||||
create table enclosures (
|
||||
id bigserial not null,
|
||||
user_id int not null,
|
||||
entry_id bigint not null,
|
||||
url text not null,
|
||||
size int default 0,
|
||||
mime_type text default '',
|
||||
primary key (id),
|
||||
foreign key (user_id) references users(id) on delete cascade,
|
||||
foreign key (entry_id) references entries(id) on delete cascade
|
||||
);
|
||||
|
||||
create table icons (
|
||||
id bigserial not null,
|
||||
hash text not null unique,
|
||||
mime_type text not null,
|
||||
content bytea not null,
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
create table feed_icons (
|
||||
feed_id bigint not null,
|
||||
icon_id bigint not null,
|
||||
primary key(feed_id, icon_id),
|
||||
foreign key (feed_id) references feeds(id) on delete cascade,
|
||||
foreign key (icon_id) references icons(id) on delete cascade
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
drop table tokens;
|
||||
|
||||
create table sessions (
|
||||
id text not null,
|
||||
data jsonb not null,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
primary key(id)
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
alter table integrations add column wallabag_enabled bool default 'f';
|
||||
alter table integrations add column wallabag_url text default '';
|
||||
alter table integrations add column wallabag_client_id text default '';
|
||||
alter table integrations add column wallabag_client_secret text default '';
|
||||
alter table integrations add column wallabag_username text default '';
|
||||
alter table integrations add column wallabag_password text default '';
|
||||
@@ -1 +0,0 @@
|
||||
alter table entries add column starred bool default 'f';
|
||||
@@ -1,2 +0,0 @@
|
||||
create index entries_user_status_idx on entries(user_id, status);
|
||||
create index feeds_user_category_idx on feeds(user_id, category_id);
|
||||
@@ -1,3 +0,0 @@
|
||||
alter table integrations add column nunux_keeper_enabled bool default 'f';
|
||||
alter table integrations add column nunux_keeper_url text default '';
|
||||
alter table integrations add column nunux_keeper_api_key text default '';
|
||||
@@ -1 +0,0 @@
|
||||
alter table enclosures alter column size set data type bigint;
|
||||
@@ -1 +0,0 @@
|
||||
alter table entries add column comments_url text default '';
|
||||
@@ -1,3 +0,0 @@
|
||||
alter table integrations add column pocket_enabled bool default 'f';
|
||||
alter table integrations add column pocket_access_token text default '';
|
||||
alter table integrations add column pocket_consumer_key text default '';
|
||||
@@ -1 +0,0 @@
|
||||
alter table user_sessions alter column ip set data type inet using ip::inet;
|
||||
@@ -1,2 +0,0 @@
|
||||
alter table feeds add column username text default '';
|
||||
alter table feeds add column password text default '';
|
||||
@@ -1,3 +0,0 @@
|
||||
create extension if not exists hstore;
|
||||
alter table users add column extra hstore;
|
||||
create index users_extra_idx on users using gin(extra);
|
||||
@@ -1,3 +0,0 @@
|
||||
alter table entries add column document_vectors tsvector;
|
||||
update entries set document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
|
||||
create index document_vectors_idx on entries using gin(document_vectors);
|
||||
@@ -1 +0,0 @@
|
||||
alter table feeds add column user_agent text default '';
|
||||
@@ -1,6 +0,0 @@
|
||||
create table tokens (
|
||||
id text not null,
|
||||
value text not null,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
primary key(id, value)
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
create type entry_sorting_direction as enum('asc', 'desc');
|
||||
alter table users add column entry_direction entry_sorting_direction default 'asc';
|
||||
@@ -1,15 +0,0 @@
|
||||
create table integrations (
|
||||
user_id int not null,
|
||||
pinboard_enabled bool default 'f',
|
||||
pinboard_token text default '',
|
||||
pinboard_tags text default 'miniflux',
|
||||
pinboard_mark_as_unread bool default 'f',
|
||||
instapaper_enabled bool default 'f',
|
||||
instapaper_username text default '',
|
||||
instapaper_password text default '',
|
||||
fever_enabled bool default 'f',
|
||||
fever_username text default '',
|
||||
fever_password text default '',
|
||||
fever_token text default '',
|
||||
primary key(user_id)
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
alter table feeds add column scraper_rules text default '';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user