Compare commits
600 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report
|
||||
title: ''
|
||||
labels: bug
|
||||
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,10 @@
|
||||
---
|
||||
name: Installation Issue
|
||||
about: Do you need help to install Miniflux?
|
||||
title: ''
|
||||
labels: installation issue
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Question / Discussion
|
||||
about: Open discussions
|
||||
title: ''
|
||||
labels: question / discussion
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
paths-ignore:
|
||||
- ./http/client/testdata
|
||||
@@ -0,0 +1,37 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
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
|
||||
+10
-50
@@ -1,40 +1,12 @@
|
||||
name: CI Workflow
|
||||
permissions: read-all
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
jobs:
|
||||
|
||||
linters:
|
||||
name: Linter Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.13
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v1
|
||||
with:
|
||||
fetch-depth: 3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install linters
|
||||
run: |
|
||||
cd /tmp && go get -u golang.org/x/lint/golint
|
||||
sudo npm install -g jshint
|
||||
env:
|
||||
GO111MODULE: off
|
||||
- name: Run golint
|
||||
run: |
|
||||
export PATH=/home/runner/go/bin:$PATH
|
||||
make lint
|
||||
- name: Run jshint
|
||||
run: jshint ui/static/js/*.js
|
||||
|
||||
unit-tests:
|
||||
name: Unit Tests
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -42,20 +14,14 @@ jobs:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
go-version: [1.11, 1.12, 1.13]
|
||||
go-version: [1.18, 1.19]
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v1
|
||||
with:
|
||||
fetch-depth: 3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: actions/checkout@v3
|
||||
- name: Run unit tests
|
||||
run: make test
|
||||
|
||||
@@ -74,19 +40,13 @@ jobs:
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.13
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
go-version: 1.19
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v1
|
||||
with:
|
||||
fetch-depth: 3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: actions/checkout@v3
|
||||
- name: Install Postgres client
|
||||
run: sudo apt-get install -y postgresql-client
|
||||
run: sudo apt update && sudo apt install -y postgresql-client
|
||||
- name: Run integration tests
|
||||
run: make integration-test
|
||||
env:
|
||||
|
||||
@@ -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,81 @@
|
||||
name: Docker
|
||||
permissions: read-all
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 1 * * *'
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
jobs:
|
||||
docker-images:
|
||||
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_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
DOCKER_VERSION=${GITHUB_REF#refs/tags/}
|
||||
TAGS="${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},${DOCKER_IMAGE}:latest,ghcr.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_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
DOCKER_VERSION=${GITHUB_REF#refs/tags/}-distroless
|
||||
TAGS="${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},${DOCKER_IMAGE}:latest-distroless,ghcr.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.CR_PAT }}
|
||||
|
||||
- name: Build and Push Alpine images
|
||||
uses: docker/build-push-action@v3
|
||||
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@v3
|
||||
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@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
- 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
|
||||
+4
-1
@@ -1,2 +1,5 @@
|
||||
miniflux-*
|
||||
miniflux
|
||||
miniflux
|
||||
*.rpm
|
||||
*.deb
|
||||
.idea
|
||||
@@ -1,3 +1,558 @@
|
||||
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)
|
||||
-----------------------------
|
||||
|
||||
@@ -116,7 +671,7 @@ Version 2.0.13 (Nov 25, 2018)
|
||||
|
||||
* Add man page
|
||||
* Add support for Systemd Socket Activation (experimental)
|
||||
* Add the possiblity to listen on Unix sockets
|
||||
* 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)
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
FROM golang:1.13-alpine3.10 as build
|
||||
ENV GO111MODULE=on
|
||||
WORKDIR /go/src/app
|
||||
RUN apk add --no-cache --update build-base git
|
||||
COPY . .
|
||||
RUN make linux-__MINIFLUX_ARCH__ VERSION=__MINIFLUX_VERSION__
|
||||
|
||||
FROM __BASEIMAGE_ARCH__/alpine:3.10.1
|
||||
EXPOSE 8080
|
||||
ENV LISTEN_ADDR 0.0.0.0:8080
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
COPY --from=build /go/src/app/miniflux-linux-__MINIFLUX_ARCH__ /usr/bin/miniflux
|
||||
USER nobody
|
||||
CMD ["/usr/bin/miniflux"]
|
||||
@@ -1,22 +1,25 @@
|
||||
APP := miniflux
|
||||
APP := miniflux
|
||||
DOCKER_IMAGE := miniflux/miniflux
|
||||
VERSION := $(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.BuildDate=$(BUILD_DATE)'"
|
||||
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
||||
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
|
||||
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
|
||||
|
||||
export GO111MODULE=on
|
||||
export PGPASSWORD := postgres
|
||||
|
||||
.PHONY: generate \
|
||||
.PHONY: \
|
||||
miniflux \
|
||||
linux-amd64 \
|
||||
linux-armv8 \
|
||||
linux-arm64 \
|
||||
linux-armv7 \
|
||||
linux-armv6 \
|
||||
linux-armv5 \
|
||||
linux-x86 \
|
||||
darwin-amd64 \
|
||||
darwin-arm64 \
|
||||
freebsd-amd64 \
|
||||
freebsd-x86 \
|
||||
openbsd-amd64 \
|
||||
@@ -32,84 +35,94 @@ export GO111MODULE=on
|
||||
lint \
|
||||
integration-test \
|
||||
clean-integration-test \
|
||||
docker-image \
|
||||
docker-image-distroless \
|
||||
docker-images \
|
||||
docker-manifest
|
||||
rpm \
|
||||
debian \
|
||||
debian-packages
|
||||
|
||||
generate:
|
||||
@ go generate -mod=vendor
|
||||
miniflux:
|
||||
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
|
||||
|
||||
miniflux: generate
|
||||
@ go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP) main.go
|
||||
linux-amd64:
|
||||
@ GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-amd64 main.go
|
||||
|
||||
linux-amd64: generate
|
||||
@ GOOS=linux GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-amd64 main.go
|
||||
linux-arm64:
|
||||
@ GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-arm64 main.go
|
||||
|
||||
linux-armv8: generate
|
||||
@ GOOS=linux GOARCH=arm64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv8 main.go
|
||||
linux-armv7:
|
||||
@ GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv7 main.go
|
||||
|
||||
linux-armv7: generate
|
||||
@ GOOS=linux GOARCH=arm GOARM=7 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv7 main.go
|
||||
linux-armv6:
|
||||
@ GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv6 main.go
|
||||
|
||||
linux-armv6: generate
|
||||
@ GOOS=linux GOARCH=arm GOARM=6 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv6 main.go
|
||||
linux-armv5:
|
||||
@ GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv5 main.go
|
||||
|
||||
linux-armv5: generate
|
||||
@ GOOS=linux GOARCH=arm GOARM=5 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv5 main.go
|
||||
darwin-amd64:
|
||||
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-darwin-amd64 main.go
|
||||
|
||||
darwin-amd64: generate
|
||||
@ GOOS=darwin GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-darwin-amd64 main.go
|
||||
darwin-arm64:
|
||||
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-darwin-arm64 main.go
|
||||
|
||||
freebsd-amd64: generate
|
||||
@ GOOS=freebsd GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-amd64 main.go
|
||||
freebsd-amd64:
|
||||
@ GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-amd64 main.go
|
||||
|
||||
openbsd-amd64: generate
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-openbsd-amd64 main.go
|
||||
openbsd-amd64:
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-openbsd-amd64 main.go
|
||||
|
||||
windows-amd64: generate
|
||||
@ GOOS=windows GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-windows-amd64 main.go
|
||||
windows-amd64:
|
||||
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-windows-amd64 main.go
|
||||
|
||||
build: linux-amd64 linux-armv8 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 freebsd-amd64 openbsd-amd64 windows-amd64
|
||||
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: generate
|
||||
@ GOOS=netbsd GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-netbsd-amd64 main.go
|
||||
netbsd-amd64:
|
||||
@ GOOS=netbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-netbsd-amd64 main.go
|
||||
|
||||
linux-x86: generate
|
||||
@ GOOS=linux GOARCH=386 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-x86 main.go
|
||||
linux-x86:
|
||||
@ GOOS=linux GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-x86 main.go
|
||||
|
||||
freebsd-x86: generate
|
||||
@ GOOS=freebsd GOARCH=386 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-x86 main.go
|
||||
freebsd-x86:
|
||||
@ GOOS=freebsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-x86 main.go
|
||||
|
||||
netbsd-x86: generate
|
||||
@ GOOS=netbsd GOARCH=386 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-netbsd-x86 main.go
|
||||
netbsd-x86:
|
||||
@ GOOS=netbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-netbsd-x86 main.go
|
||||
|
||||
openbsd-x86: generate
|
||||
@ GOOS=openbsd GOARCH=386 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-x86 main.go
|
||||
openbsd-x86:
|
||||
@ GOOS=openbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-x86 main.go
|
||||
|
||||
windows-x86: generate
|
||||
@ GOOS=windows GOARCH=386 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-windows-x86 main.go
|
||||
windows-x86:
|
||||
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-windows-x86 main.go
|
||||
|
||||
run: generate
|
||||
@ go run -mod=vendor main.go -debug
|
||||
run:
|
||||
@ LOG_DATE_TIME=1 DEBUG=1 RUN_MIGRATIONS=1 go run main.go
|
||||
|
||||
clean:
|
||||
@ rm -f $(APP)-* $(APP)
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb
|
||||
|
||||
test:
|
||||
go test -mod=vendor -cover -race -count=1 ./...
|
||||
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 -mod=vendor main.go -migrate
|
||||
DATABASE_URL=$(DB_URL) ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run -mod=vendor main.go -create-admin
|
||||
go build -mod=vendor -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 -mod=vendor -v -tags=integration -count=1 miniflux.app/tests || cat /tmp/miniflux.log
|
||||
go build -o miniflux-test main.go
|
||||
|
||||
DATABASE_URL=$(DB_URL) \
|
||||
ADMIN_USERNAME=admin \
|
||||
ADMIN_PASSWORD=test123 \
|
||||
CREATE_ADMIN=1 \
|
||||
RUN_MIGRATIONS=1 \
|
||||
DEBUG=1 \
|
||||
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
|
||||
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`
|
||||
@@ -117,39 +130,38 @@ clean-integration-test:
|
||||
@ rm miniflux-test
|
||||
@ psql -U postgres -c 'drop database if exists miniflux_test;'
|
||||
|
||||
docker-images:
|
||||
for arch in amd64 arm32v6 arm64v8; do \
|
||||
case $${arch} in \
|
||||
amd64 ) miniflux_arch="amd64";; \
|
||||
arm32v6 ) miniflux_arch="armv6";; \
|
||||
arm32v7 ) miniflux_arch="armv7";; \
|
||||
arm64v8 ) miniflux_arch="armv8";; \
|
||||
esac ;\
|
||||
cp Dockerfile Dockerfile.$${arch} && \
|
||||
sed -i"" -e "s|__BASEIMAGE_ARCH__|$${arch}|g" Dockerfile.$${arch} && \
|
||||
sed -i"" -e "s|__MINIFLUX_VERSION__|$(VERSION)|g" Dockerfile.$${arch} && \
|
||||
sed -i"" -e "s|__MINIFLUX_ARCH__|$${miniflux_arch}|g" Dockerfile.$${arch} && \
|
||||
docker build --pull -f Dockerfile.$${arch} -t $(DOCKER_IMAGE):$${arch}-$(VERSION) . && \
|
||||
docker tag $(DOCKER_IMAGE):$${arch}-${VERSION} $(DOCKER_IMAGE):$${arch}-latest && \
|
||||
rm -f Dockerfile.$${arch}* ;\
|
||||
done
|
||||
docker-image:
|
||||
docker build -t $(DOCKER_IMAGE):$(VERSION) -f packaging/docker/alpine/Dockerfile .
|
||||
|
||||
docker-manifest:
|
||||
for version in $(VERSION) latest; do \
|
||||
docker push $(DOCKER_IMAGE):amd64-$${version} && \
|
||||
docker push $(DOCKER_IMAGE):arm32v6-$${version} && \
|
||||
docker push $(DOCKER_IMAGE):arm32v7-$${version} && \
|
||||
docker push $(DOCKER_IMAGE):arm64v8-$${version} && \
|
||||
docker manifest create --amend $(DOCKER_IMAGE):$${version} \
|
||||
$(DOCKER_IMAGE):amd64-$${version} \
|
||||
$(DOCKER_IMAGE):arm32v6-$${version} \
|
||||
$(DOCKER_IMAGE):arm32v7-$${version} \
|
||||
$(DOCKER_IMAGE):arm64v8-$${version} && \
|
||||
docker manifest annotate $(DOCKER_IMAGE):$${version} \
|
||||
$(DOCKER_IMAGE):arm32v6-$${version} --os linux --arch arm --variant v6 && \
|
||||
docker manifest annotate $(DOCKER_IMAGE):$${version} \
|
||||
$(DOCKER_IMAGE):arm32v7-$${version} --os linux --arch arm --variant v7 && \
|
||||
docker manifest annotate $(DOCKER_IMAGE):$${version} \
|
||||
$(DOCKER_IMAGE):arm64v8-$${version} --os linux --arch arm64 --variant v8 && \
|
||||
docker manifest push --purge $(DOCKER_IMAGE):$${version} ;\
|
||||
done
|
||||
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,6 +1,5 @@
|
||||
Miniflux 2
|
||||
==========
|
||||
[](https://godoc.org/miniflux.app)
|
||||
|
||||
Miniflux is a minimalist and opinionated feed reader:
|
||||
|
||||
@@ -51,5 +50,5 @@ 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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest stable version is supported.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Send an email to `security AT miniflux DOT net` with all the steps to reproduce the problem.
|
||||
+51
-31
@@ -5,43 +5,63 @@
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
"miniflux.app/reader/feed"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/worker"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
store *storage.Storage
|
||||
pool *worker.Pool
|
||||
}
|
||||
|
||||
// Serve declares API routes for the application.
|
||||
func Serve(router *mux.Router, store *storage.Storage, feedHandler *feed.Handler) {
|
||||
handler := &handler{store, feedHandler}
|
||||
func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
handler := &handler{store, pool}
|
||||
|
||||
sr := router.PathPrefix("/v1").Subrouter()
|
||||
sr.Use(newMiddleware(store).serve)
|
||||
sr.HandleFunc("/users", handler.createUser).Methods("POST")
|
||||
sr.HandleFunc("/users", handler.users).Methods("GET")
|
||||
sr.HandleFunc("/users/{userID:[0-9]+}", handler.userByID).Methods("GET")
|
||||
sr.HandleFunc("/users/{userID:[0-9]+}", handler.updateUser).Methods("PUT")
|
||||
sr.HandleFunc("/users/{userID:[0-9]+}", handler.removeUser).Methods("DELETE")
|
||||
sr.HandleFunc("/users/{username}", handler.userByUsername).Methods("GET")
|
||||
sr.HandleFunc("/me", handler.currentUser).Methods("GET")
|
||||
sr.HandleFunc("/categories", handler.createCategory).Methods("POST")
|
||||
sr.HandleFunc("/categories", handler.getCategories).Methods("GET")
|
||||
sr.HandleFunc("/categories/{categoryID}", handler.updateCategory).Methods("PUT")
|
||||
sr.HandleFunc("/categories/{categoryID}", handler.removeCategory).Methods("DELETE")
|
||||
sr.HandleFunc("/discover", handler.getSubscriptions).Methods("POST")
|
||||
sr.HandleFunc("/feeds", handler.createFeed).Methods("POST")
|
||||
sr.HandleFunc("/feeds", handler.getFeeds).Methods("GET")
|
||||
sr.HandleFunc("/feeds/{feedID}/refresh", handler.refreshFeed).Methods("PUT")
|
||||
sr.HandleFunc("/feeds/{feedID}", handler.getFeed).Methods("GET")
|
||||
sr.HandleFunc("/feeds/{feedID}", handler.updateFeed).Methods("PUT")
|
||||
sr.HandleFunc("/feeds/{feedID}", handler.removeFeed).Methods("DELETE")
|
||||
sr.HandleFunc("/feeds/{feedID}/icon", handler.feedIcon).Methods("GET")
|
||||
sr.HandleFunc("/export", handler.exportFeeds).Methods("GET")
|
||||
sr.HandleFunc("/import", handler.importFeeds).Methods("POST")
|
||||
sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntries).Methods("GET")
|
||||
sr.HandleFunc("/feeds/{feedID}/entries/{entryID}", handler.getFeedEntry).Methods("GET")
|
||||
sr.HandleFunc("/entries", handler.getEntries).Methods("GET")
|
||||
sr.HandleFunc("/entries", handler.setEntryStatus).Methods("PUT")
|
||||
sr.HandleFunc("/entries/{entryID}", handler.getEntry).Methods("GET")
|
||||
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleBookmark).Methods("PUT")
|
||||
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}/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)
|
||||
}
|
||||
|
||||
+51
-18
@@ -5,33 +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"
|
||||
)
|
||||
|
||||
func (h *handler) createCategory(w http.ResponseWriter, r *http.Request) {
|
||||
category, err := decodeCategoryPayload(r.Body)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
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 := h.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 := h.store.CreateCategory(category); err != nil {
|
||||
category, err := h.store.CreateCategory(userID, &categoryRequest)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -40,21 +39,32 @@ func (h *handler) createCategory(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
|
||||
}
|
||||
|
||||
categoryRequest.Patch(category)
|
||||
err = h.store.UpdateCategory(category)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
@@ -64,6 +74,29 @@ func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
json.Created(w, r, category)
|
||||
}
|
||||
|
||||
func (h *handler) markCategoryAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
|
||||
category, err := h.store.Category(userID, categoryID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getCategories(w http.ResponseWriter, r *http.Request) {
|
||||
categories, err := h.store.Categories(request.UserID(r))
|
||||
if err != nil {
|
||||
@@ -78,7 +111,7 @@ func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
|
||||
if !h.store.CategoryExists(userID, categoryID) {
|
||||
if !h.store.CategoryIDExists(userID, categoryID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
-10
@@ -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 api implements API endpoints for the application.
|
||||
|
||||
*/
|
||||
package api // import "miniflux.app/api"
|
||||
+134
-92
@@ -5,16 +5,35 @@
|
||||
package api // import "miniflux.app/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"miniflux.app/http/request"
|
||||
"miniflux.app/http/response/json"
|
||||
"miniflux.app/model"
|
||||
"miniflux.app/reader/processor"
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/validator"
|
||||
)
|
||||
|
||||
func 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
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -23,18 +42,18 @@ func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
|
||||
json.OK(w, r, entry)
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -42,104 +61,68 @@ func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
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 := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithFeedID(feedID)
|
||||
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
|
||||
}
|
||||
|
||||
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
|
||||
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) {
|
||||
status := request.QueryStringParam(r, "status", "")
|
||||
if status != "" {
|
||||
if err := model.ValidateEntryStatus(status); err != nil {
|
||||
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 := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithStatus(status)
|
||||
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
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithStatuses(statuses)
|
||||
builder.WithOrder(order)
|
||||
builder.WithDirection(direction)
|
||||
builder.WithOffset(offset)
|
||||
@@ -162,18 +145,18 @@ func (h *handler) getEntries(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *handler) 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 {
|
||||
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.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
|
||||
}
|
||||
@@ -191,29 +174,88 @@ func (h *handler) 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", "")
|
||||
|
||||
+92
-44
@@ -5,61 +5,38 @@
|
||||
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"
|
||||
)
|
||||
|
||||
func (h *handler) createFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedInfo, err := decodeFeedCreationPayload(r.Body)
|
||||
if err != nil {
|
||||
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 h.store.FeedURLExists(userID, feedInfo.FeedURL) {
|
||||
json.BadRequest(w, r, errors.New("This feed_url already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.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 := h.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})
|
||||
}
|
||||
|
||||
func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -71,7 +48,7 @@ func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.feedHandler.RefreshFeed(userID, feedID)
|
||||
err := feedHandler.RefreshFeed(h.store, userID, feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -80,15 +57,30 @@ func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) 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 := h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
@@ -101,13 +93,12 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
feedChanges.Update(originalFeed)
|
||||
|
||||
if !h.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
|
||||
}
|
||||
|
||||
feedModificationRequest.Patch(originalFeed)
|
||||
if err := h.store.UpdateFeed(originalFeed); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -122,6 +113,53 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
json.Created(w, r, originalFeed)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
json.OK(w, r, feeds)
|
||||
}
|
||||
|
||||
func (h *handler) getFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
feeds, err := h.store.Feeds(request.UserID(r))
|
||||
if err != nil {
|
||||
@@ -132,6 +170,16 @@ func (h *handler) getFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
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 := h.store.FeedByID(request.UserID(r), feedID)
|
||||
|
||||
@@ -1,15 +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"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
store *storage.Storage
|
||||
feedHandler *feed.Handler
|
||||
}
|
||||
+1
-1
@@ -30,7 +30,7 @@ func (h *handler) 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(),
|
||||
|
||||
+69
-8
@@ -21,40 +21,101 @@ type middleware struct {
|
||||
func newMiddleware(s *storage.Storage) *middleware {
|
||||
return &middleware{s}
|
||||
}
|
||||
|
||||
// BasicAuth handles HTTP basic authentication.
|
||||
func (m *middleware) serve(next http.Handler) http.Handler {
|
||||
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")
|
||||
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] No authentication headers sent")
|
||||
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] [ClientIP=%s] Invalid username or password: %s", clientIP, username)
|
||||
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] %v", err)
|
||||
logger.Error("[API][BasicAuth] %v", err)
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
logger.Error("[API] [ClientIP=%s] User not found: %s", clientIP, username)
|
||||
logger.Error("[API][BasicAuth] [ClientIP=%s] User not found: %s", clientIP, username)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("[API] User authenticated: %s", username)
|
||||
logger.Info("[API][BasicAuth] [ClientIP=%s] User authenticated: %s", clientIP, username)
|
||||
m.store.SetLastLogin(user.ID)
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
+3
-207
@@ -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,206 +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"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if f.Disabled != nil {
|
||||
feed.Disabled = *f.Disabled
|
||||
}
|
||||
}
|
||||
|
||||
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,219 +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 TestUpdateFeedDisabled(t *testing.T) {
|
||||
valueTrue := true
|
||||
valueFalse := false
|
||||
scenarios := []struct {
|
||||
changes *feedModification
|
||||
feed *model.Feed
|
||||
expected bool
|
||||
}{
|
||||
{&feedModification{}, &model.Feed{Disabled: true}, true},
|
||||
{&feedModification{Disabled: &valueTrue}, &model.Feed{Disabled: true}, true},
|
||||
{&feedModification{Disabled: &valueFalse}, &model.Feed{Disabled: true}, false},
|
||||
{&feedModification{}, &model.Feed{Disabled: false}, false},
|
||||
{&feedModification{Disabled: &valueTrue}, &model.Feed{Disabled: false}, true},
|
||||
{&feedModification{Disabled: &valueFalse}, &model.Feed{Disabled: false}, false},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.changes.Update(scenario.feed)
|
||||
if scenario.feed.Disabled != scenario.expected {
|
||||
t.Errorf(`Unexpected result, got %v, want: %v`,
|
||||
scenario.feed.Disabled,
|
||||
scenario.expected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
-8
@@ -5,24 +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"
|
||||
)
|
||||
|
||||
func (h *handler) 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)
|
||||
|
||||
+50
-24
@@ -5,11 +5,14 @@
|
||||
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"
|
||||
)
|
||||
|
||||
func (h *handler) currentUser(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -28,48 +31,38 @@ func (h *handler) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
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 h.store.UserExists(user.Username) {
|
||||
json.BadRequest(w, r, errors.New("This user already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
err = h.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)
|
||||
}
|
||||
|
||||
func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
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 := 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
|
||||
}
|
||||
|
||||
@@ -78,12 +71,24 @@ func (h *handler) 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
|
||||
}
|
||||
|
||||
userModificationRequest.Patch(originalUser)
|
||||
if err = h.store.UpdateUser(originalUser); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -92,6 +97,26 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
json.Created(w, r, originalUser)
|
||||
}
|
||||
|
||||
func (h *handler) markUserAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
if userID != request.UserID(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.MarkAllAsRead(userID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
@@ -169,10 +194,11 @@ func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.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)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func askCredentials() (string, string) {
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
||||
if !terminal.IsTerminal(fd) {
|
||||
if !term.IsTerminal(fd) {
|
||||
fmt.Fprintf(os.Stderr, "This is not a terminal, exiting.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -28,9 +28,9 @@ func askCredentials() (string, string) {
|
||||
|
||||
fmt.Print("Enter Password: ")
|
||||
|
||||
state, _ := terminal.GetState(fd)
|
||||
defer terminal.Restore(fd, state)
|
||||
bytePassword, _ := terminal.ReadPassword(fd)
|
||||
state, _ := term.GetState(fd)
|
||||
defer term.Restore(fd, state)
|
||||
bytePassword, _ := term.ReadPassword(fd)
|
||||
|
||||
fmt.Printf("\n")
|
||||
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
|
||||
|
||||
+49
-8
@@ -10,8 +10,10 @@ import (
|
||||
|
||||
"miniflux.app/config"
|
||||
"miniflux.app/database"
|
||||
"miniflux.app/locale"
|
||||
"miniflux.app/logger"
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/ui/static"
|
||||
"miniflux.app/version"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,7 @@ const (
|
||||
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.
|
||||
@@ -42,6 +45,7 @@ func Parse() {
|
||||
flagDebugMode bool
|
||||
flagConfigFile string
|
||||
flagConfigDump bool
|
||||
flagHealthCheck string
|
||||
)
|
||||
|
||||
flag.BoolVar(&flagInfo, "info", false, flagInfoHelp)
|
||||
@@ -57,6 +61,7 @@ func Parse() {
|
||||
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.NewParser()
|
||||
@@ -86,6 +91,11 @@ func Parse() {
|
||||
logger.EnableDebug()
|
||||
}
|
||||
|
||||
if flagHealthCheck != "" {
|
||||
doHealthCheck(flagHealthCheck)
|
||||
return
|
||||
}
|
||||
|
||||
if flagInfo {
|
||||
info()
|
||||
return
|
||||
@@ -100,22 +110,47 @@ func Parse() {
|
||||
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 connect to the database: %v", err)
|
||||
logger.Fatal("Unable to initialize database connection pool: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if flagMigrate {
|
||||
database.Migrate(db)
|
||||
return
|
||||
store := storage.NewStorage(db)
|
||||
|
||||
if err := store.Ping(); err != nil {
|
||||
logger.Fatal("Unable to connect to the database: %v", err)
|
||||
}
|
||||
|
||||
store := storage.NewStorage(db)
|
||||
if flagMigrate {
|
||||
if err := database.Migrate(db); err != nil {
|
||||
logger.Fatal(`%v`, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if flagResetFeedErrors {
|
||||
store.ResetFeedErrors()
|
||||
@@ -137,12 +172,18 @@ func Parse() {
|
||||
return
|
||||
}
|
||||
|
||||
// Run migrations and start the deamon.
|
||||
// Run migrations and start the daemon.
|
||||
if config.Opts.RunMigrations() {
|
||||
database.Migrate(db)
|
||||
if err := database.Migrate(db); err != nil {
|
||||
logger.Fatal(`%v`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create admin user and start the deamon.
|
||||
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)
|
||||
}
|
||||
|
||||
+16
-13
@@ -8,32 +8,35 @@ 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.Fprintf(os.Stderr, "%v\n", 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
+41
-19
@@ -9,16 +9,16 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"miniflux.app/config"
|
||||
"miniflux.app/logger"
|
||||
"miniflux.app/reader/feed"
|
||||
"miniflux.app/metric"
|
||||
"miniflux.app/service/httpd"
|
||||
"miniflux.app/service/scheduler"
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/systemd"
|
||||
"miniflux.app/worker"
|
||||
)
|
||||
|
||||
@@ -29,18 +29,51 @@ func startDaemon(store *storage.Storage) {
|
||||
signal.Notify(stop, os.Interrupt)
|
||||
signal.Notify(stop, syscall.SIGTERM)
|
||||
|
||||
feedHandler := feed.NewFeedHandler(store)
|
||||
pool := worker.NewPool(feedHandler, config.Opts.WorkerPoolSize())
|
||||
pool := worker.NewPool(store, config.Opts.WorkerPoolSize())
|
||||
|
||||
go showProcessStatistics()
|
||||
|
||||
if config.Opts.HasSchedulerService() {
|
||||
if config.Opts.HasSchedulerService() && !config.Opts.HasMaintenanceMode() {
|
||||
scheduler.Serve(store, pool)
|
||||
}
|
||||
|
||||
var httpServer *http.Server
|
||||
if config.Opts.HasHTTPService() {
|
||||
httpServer = httpd.Serve(store, pool, feedHandler)
|
||||
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
|
||||
@@ -54,14 +87,3 @@ func startDaemon(store *storage.Storage) {
|
||||
|
||||
logger.Info("Process gracefully stopped")
|
||||
}
|
||||
|
||||
func showProcessStatistics() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"miniflux.app/model"
|
||||
"miniflux.app/storage"
|
||||
"miniflux.app/validator"
|
||||
)
|
||||
|
||||
func resetPassword(store *storage.Storage) {
|
||||
@@ -24,12 +26,15 @@ func resetPassword(store *storage.Storage) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
user.Password = password
|
||||
if err := user.ValidatePassword(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
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 := store.UpdateUser(user); err != nil {
|
||||
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
|
||||
|
||||
+139
-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,32 @@ 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))
|
||||
}
|
||||
|
||||
// Feeds gets all feeds.
|
||||
@@ -240,7 +273,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 +305,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 +326,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 +334,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 +398,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 +470,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 +496,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 +563,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"
|
||||
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
DisplayMode *string `json:"display_mode"`
|
||||
DefaultReadingSpeed *int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed *int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage *string `json:"default_home_page"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
+30
-8
@@ -10,7 +10,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+282
-73
@@ -5,7 +5,6 @@
|
||||
package config // import "miniflux.app/config"
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
@@ -409,41 +408,6 @@ func TestDefaultCertDomainValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertCache(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("CERT_CACHE", "foobar")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "foobar"
|
||||
result := opts.CertCache()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected CERT_CACHE value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCertCacheValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := defaultCertCache
|
||||
result := opts.CertCache()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected CERT_CACHE value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCleanupFrequencyHoursValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
@@ -480,24 +444,6 @@ func TestCleanupFrequencyHours(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeprecatedCleanupFrequencyHoursVar(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("CLEANUP_FREQUENCY", "42")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := 42
|
||||
result := opts.CleanupFrequencyHours()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected CLEANUP_FREQUENCY value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCleanupArchiveReadDaysValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
@@ -534,24 +480,6 @@ func TestCleanupArchiveReadDays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeprecatedCleanupArchiveReadDaysVar(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("ARCHIVE_READ_DAYS", "7")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := 7
|
||||
result := opts.CleanupArchiveReadDays()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected ARCHIVE_READ_DAYS value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCleanupRemoveSessionsDaysValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
@@ -692,6 +620,129 @@ func TestBatchSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautPollingSchedulerValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := defaultPollingScheduler
|
||||
result := opts.PollingScheduler()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected POLLING_SCHEDULER value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollingScheduler(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_SCHEDULER", "entry_count_based")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "entry_count_based"
|
||||
result := opts.PollingScheduler()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected POLLING_SCHEDULER value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautSchedulerCountBasedMaxIntervalValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := defaultSchedulerEntryFrequencyMaxInterval
|
||||
result := opts.SchedulerEntryFrequencyMaxInterval()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautSchedulerCountBasedMaxInterval(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", "30")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := 30
|
||||
result := opts.SchedulerEntryFrequencyMaxInterval()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautSchedulerCountBasedMinIntervalValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := defaultSchedulerEntryFrequencyMinInterval
|
||||
result := opts.SchedulerEntryFrequencyMinInterval()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautSchedulerCountBasedMinInterval(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", "30")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := 30
|
||||
result := opts.SchedulerEntryFrequencyMinInterval()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollingParsingErrorLimit(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_PARSING_ERROR_LIMIT", "100")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := 100
|
||||
result := opts.PollingParsingErrorLimit()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected POLLING_SCHEDULER value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuth2UserCreationWhenUnset(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
@@ -832,6 +883,41 @@ func TestDefaultOAuth2RedirectURLValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuth2OidcDiscoveryEndpoint(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("OAUTH2_OIDC_DISCOVERY_ENDPOINT", "http://example.org")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "http://example.org"
|
||||
result := opts.OAuth2OidcDiscoveryEndpoint()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected OAUTH2_OIDC_DISCOVERY_ENDPOINT value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultOAuth2OidcDiscoveryEndpointValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := defaultOAuth2OidcDiscoveryEndpoint
|
||||
result := opts.OAuth2OidcDiscoveryEndpoint()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected OAUTH2_REDIRECT_URL value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuth2Provider(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("OAUTH2_PROVIDER", "google")
|
||||
@@ -1222,7 +1308,7 @@ DEBUG = yes
|
||||
Invalid text
|
||||
`)
|
||||
|
||||
tmpfile, err := ioutil.TempFile(".", "miniflux.*.unit_test.conf")
|
||||
tmpfile, err := os.CreateTemp(".", "miniflux.*.unit_test.conf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1257,3 +1343,126 @@ Invalid text
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthProxyHeader(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("AUTH_PROXY_HEADER", "X-Forwarded-User")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "X-Forwarded-User"
|
||||
result := opts.AuthProxyHeader()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected AUTH_PROXY_HEADER value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAuthProxyHeaderValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := defaultAuthProxyHeader
|
||||
result := opts.AuthProxyHeader()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected AUTH_PROXY_HEADER value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthProxyUserCreationWhenUnset(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := false
|
||||
result := opts.IsAuthProxyUserCreationAllowed()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected AUTH_PROXY_USER_CREATION value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthProxyUserCreationAdmin(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("AUTH_PROXY_USER_CREATION", "1")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := true
|
||||
result := opts.IsAuthProxyUserCreationAllowed()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected AUTH_PROXY_USER_CREATION value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchYouTubeWatchTime(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("FETCH_YOUTUBE_WATCH_TIME", "1")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := true
|
||||
result := opts.FetchYouTubeWatchTime()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected FETCH_YOUTUBE_WATCH_TIME value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigDumpOutput(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
wantOpts := NewOptions()
|
||||
wantOpts.adminUsername = "my-username"
|
||||
|
||||
serialized := wantOpts.String()
|
||||
tmpfile, err := os.CreateTemp(".", "miniflux.*.unit_test.conf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := tmpfile.Write([]byte(serialized)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
parser := NewParser()
|
||||
parsedOpts, err := parser.ParseFile(tmpfile.Name())
|
||||
if err != nil {
|
||||
t.Errorf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
if parsedOpts.AdminUsername() != wantOpts.AdminUsername() {
|
||||
t.Fatalf(`Unexpected ADMIN_USERNAME value, got %q instead of %q`, parsedOpts.AdminUsername(), wantOpts.AdminUsername())
|
||||
}
|
||||
|
||||
if err := tmpfile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.Remove(tmpfile.Name()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
|
||||
Package config handles configuration management for the application.
|
||||
|
||||
*/
|
||||
package config // import "miniflux.app/config"
|
||||
|
||||
+397
-141
@@ -6,121 +6,199 @@ package config // import "miniflux.app/config"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/version"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPS = false
|
||||
defaultLogDateTime = false
|
||||
defaultHSTS = true
|
||||
defaultHTTPService = true
|
||||
defaultSchedulerService = true
|
||||
defaultDebug = false
|
||||
defaultBaseURL = "http://localhost"
|
||||
defaultRootURL = "http://localhost"
|
||||
defaultBasePath = ""
|
||||
defaultWorkerPoolSize = 5
|
||||
defaultPollingFrequency = 60
|
||||
defaultBatchSize = 10
|
||||
defaultRunMigrations = false
|
||||
defaultDatabaseURL = "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
|
||||
defaultDatabaseMaxConns = 20
|
||||
defaultDatabaseMinConns = 1
|
||||
defaultListenAddr = "127.0.0.1:8080"
|
||||
defaultCertFile = ""
|
||||
defaultKeyFile = ""
|
||||
defaultCertDomain = ""
|
||||
defaultCertCache = "/tmp/cert_cache"
|
||||
defaultCleanupFrequencyHours = 24
|
||||
defaultCleanupArchiveReadDays = 60
|
||||
defaultCleanupRemoveSessionsDays = 30
|
||||
defaultProxyImages = "http-only"
|
||||
defaultCreateAdmin = false
|
||||
defaultOAuth2UserCreation = false
|
||||
defaultOAuth2ClientID = ""
|
||||
defaultOAuth2ClientSecret = ""
|
||||
defaultOAuth2RedirectURL = ""
|
||||
defaultOAuth2Provider = ""
|
||||
defaultPocketConsumerKey = ""
|
||||
defaultHTTPClientTimeout = 20
|
||||
defaultHTTPClientMaxBodySize = 15
|
||||
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
|
||||
defaultProxyImages = "http-only"
|
||||
defaultProxyImageUrl = ""
|
||||
defaultFetchYouTubeWatchTime = false
|
||||
defaultCreateAdmin = false
|
||||
defaultAdminUsername = ""
|
||||
defaultAdminPassword = ""
|
||||
defaultOAuth2UserCreation = false
|
||||
defaultOAuth2ClientID = ""
|
||||
defaultOAuth2ClientSecret = ""
|
||||
defaultOAuth2RedirectURL = ""
|
||||
defaultOAuth2OidcDiscoveryEndpoint = ""
|
||||
defaultOAuth2Provider = ""
|
||||
defaultPocketConsumerKey = ""
|
||||
defaultHTTPClientTimeout = 20
|
||||
defaultHTTPClientMaxBodySize = 15
|
||||
defaultHTTPClientProxy = ""
|
||||
defaultAuthProxyHeader = ""
|
||||
defaultAuthProxyUserCreation = false
|
||||
defaultMaintenanceMode = false
|
||||
defaultMaintenanceMessage = "Miniflux is currently under maintenance"
|
||||
defaultMetricsCollector = false
|
||||
defaultMetricsRefreshInterval = 60
|
||||
defaultMetricsAllowedNetworks = "127.0.0.1/8"
|
||||
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
|
||||
baseURL string
|
||||
rootURL string
|
||||
basePath string
|
||||
databaseURL string
|
||||
databaseMaxConns int
|
||||
databaseMinConns int
|
||||
runMigrations bool
|
||||
listenAddr string
|
||||
certFile string
|
||||
certDomain string
|
||||
certCache string
|
||||
certKeyFile string
|
||||
cleanupFrequencyHours int
|
||||
cleanupArchiveReadDays int
|
||||
cleanupRemoveSessionsDays int
|
||||
pollingFrequency int
|
||||
batchSize int
|
||||
workerPoolSize int
|
||||
createAdmin bool
|
||||
proxyImages string
|
||||
oauth2UserCreationAllowed bool
|
||||
oauth2ClientID string
|
||||
oauth2ClientSecret string
|
||||
oauth2RedirectURL string
|
||||
oauth2Provider string
|
||||
pocketConsumerKey string
|
||||
httpClientTimeout int
|
||||
httpClientMaxBodySize int64
|
||||
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
|
||||
proxyImages string
|
||||
proxyImageUrl 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
|
||||
authProxyHeader string
|
||||
authProxyUserCreation bool
|
||||
maintenanceMode bool
|
||||
maintenanceMessage string
|
||||
metricsCollector bool
|
||||
metricsRefreshInterval int
|
||||
metricsAllowedNetworks []string
|
||||
watchdog bool
|
||||
invidiousInstance string
|
||||
}
|
||||
|
||||
// NewOptions returns Options with default values.
|
||||
func NewOptions() *Options {
|
||||
return &Options{
|
||||
HTTPS: defaultHTTPS,
|
||||
logDateTime: defaultLogDateTime,
|
||||
hsts: defaultHSTS,
|
||||
httpService: defaultHTTPService,
|
||||
schedulerService: defaultSchedulerService,
|
||||
debug: defaultDebug,
|
||||
baseURL: defaultBaseURL,
|
||||
rootURL: defaultRootURL,
|
||||
basePath: defaultBasePath,
|
||||
databaseURL: defaultDatabaseURL,
|
||||
databaseMaxConns: defaultDatabaseMaxConns,
|
||||
databaseMinConns: defaultDatabaseMinConns,
|
||||
runMigrations: defaultRunMigrations,
|
||||
listenAddr: defaultListenAddr,
|
||||
certFile: defaultCertFile,
|
||||
certDomain: defaultCertDomain,
|
||||
certCache: defaultCertCache,
|
||||
certKeyFile: defaultKeyFile,
|
||||
cleanupFrequencyHours: defaultCleanupFrequencyHours,
|
||||
cleanupArchiveReadDays: defaultCleanupArchiveReadDays,
|
||||
cleanupRemoveSessionsDays: defaultCleanupRemoveSessionsDays,
|
||||
pollingFrequency: defaultPollingFrequency,
|
||||
batchSize: defaultBatchSize,
|
||||
workerPoolSize: defaultWorkerPoolSize,
|
||||
createAdmin: defaultCreateAdmin,
|
||||
proxyImages: defaultProxyImages,
|
||||
oauth2UserCreationAllowed: defaultOAuth2UserCreation,
|
||||
oauth2ClientID: defaultOAuth2ClientID,
|
||||
oauth2ClientSecret: defaultOAuth2ClientSecret,
|
||||
oauth2RedirectURL: defaultOAuth2RedirectURL,
|
||||
oauth2Provider: defaultOAuth2Provider,
|
||||
pocketConsumerKey: defaultPocketConsumerKey,
|
||||
httpClientTimeout: defaultHTTPClientTimeout,
|
||||
httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024,
|
||||
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,
|
||||
proxyImages: defaultProxyImages,
|
||||
proxyImageUrl: defaultProxyImageUrl,
|
||||
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,
|
||||
authProxyHeader: defaultAuthProxyHeader,
|
||||
authProxyUserCreation: defaultAuthProxyUserCreation,
|
||||
maintenanceMode: defaultMaintenanceMode,
|
||||
maintenanceMessage: defaultMaintenanceMessage,
|
||||
metricsCollector: defaultMetricsCollector,
|
||||
metricsRefreshInterval: defaultMetricsRefreshInterval,
|
||||
metricsAllowedNetworks: []string{defaultMetricsAllowedNetworks},
|
||||
watchdog: defaultWatchdog,
|
||||
invidiousInstance: defaultInvidiousInstance,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,11 +207,26 @@ 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
|
||||
@@ -169,6 +262,11 @@ 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
|
||||
@@ -189,11 +287,6 @@ func (o *Options) CertDomain() string {
|
||||
return o.certDomain
|
||||
}
|
||||
|
||||
// CertCache returns the directory to use for Let's Encrypt session cache.
|
||||
func (o *Options) CertCache() string {
|
||||
return o.certCache
|
||||
}
|
||||
|
||||
// CleanupFrequencyHours returns the interval in hours for cleanup jobs.
|
||||
func (o *Options) CleanupFrequencyHours() int {
|
||||
return o.cleanupFrequencyHours
|
||||
@@ -204,6 +297,16 @@ 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
|
||||
@@ -224,6 +327,26 @@ 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
|
||||
@@ -244,6 +367,11 @@ 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
|
||||
@@ -264,11 +392,32 @@ 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
|
||||
}
|
||||
|
||||
// ProxyImages returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
|
||||
func (o *Options) ProxyImages() string {
|
||||
return o.proxyImages
|
||||
}
|
||||
|
||||
// ProxyImageUrl returns a string of a URL to use to proxy image requests
|
||||
func (o *Options) ProxyImageUrl() string {
|
||||
return o.proxyImageUrl
|
||||
}
|
||||
|
||||
// HasHTTPService returns true if the HTTP service is enabled.
|
||||
func (o *Options) HasHTTPService() bool {
|
||||
return o.httpService
|
||||
@@ -297,41 +446,148 @@ func (o *Options) HTTPClientMaxBodySize() int64 {
|
||||
return o.httpClientMaxBodySize
|
||||
}
|
||||
|
||||
// HTTPClientProxy returns the proxy URL for HTTP client.
|
||||
func (o *Options) HTTPClientProxy() string {
|
||||
return o.httpClientProxy
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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_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,
|
||||
"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_IMAGES": o.proxyImages,
|
||||
"PROXY_IMAGE_URL": o.proxyImageUrl,
|
||||
"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
|
||||
builder.WriteString(fmt.Sprintf("LOG_DATE_TIME: %v\n", o.logDateTime))
|
||||
builder.WriteString(fmt.Sprintf("DEBUG: %v\n", o.debug))
|
||||
builder.WriteString(fmt.Sprintf("HTTP_SERVICE: %v\n", o.httpService))
|
||||
builder.WriteString(fmt.Sprintf("SCHEDULER_SERVICE: %v\n", o.schedulerService))
|
||||
builder.WriteString(fmt.Sprintf("HTTPS: %v\n", o.HTTPS))
|
||||
builder.WriteString(fmt.Sprintf("HSTS: %v\n", o.hsts))
|
||||
builder.WriteString(fmt.Sprintf("BASE_URL: %v\n", o.baseURL))
|
||||
builder.WriteString(fmt.Sprintf("ROOT_URL: %v\n", o.rootURL))
|
||||
builder.WriteString(fmt.Sprintf("BASE_PATH: %v\n", o.basePath))
|
||||
builder.WriteString(fmt.Sprintf("LISTEN_ADDR: %v\n", o.listenAddr))
|
||||
builder.WriteString(fmt.Sprintf("DATABASE_URL: %v\n", o.databaseURL))
|
||||
builder.WriteString(fmt.Sprintf("DATABASE_MAX_CONNS: %v\n", o.databaseMaxConns))
|
||||
builder.WriteString(fmt.Sprintf("DATABASE_MIN_CONNS: %v\n", o.databaseMinConns))
|
||||
builder.WriteString(fmt.Sprintf("RUN_MIGRATIONS: %v\n", o.runMigrations))
|
||||
builder.WriteString(fmt.Sprintf("CERT_FILE: %v\n", o.certFile))
|
||||
builder.WriteString(fmt.Sprintf("KEY_FILE: %v\n", o.certKeyFile))
|
||||
builder.WriteString(fmt.Sprintf("CERT_DOMAIN: %v\n", o.certDomain))
|
||||
builder.WriteString(fmt.Sprintf("CERT_CACHE: %v\n", o.certCache))
|
||||
builder.WriteString(fmt.Sprintf("CLEANUP_FREQUENCY_HOURS: %v\n", o.cleanupFrequencyHours))
|
||||
builder.WriteString(fmt.Sprintf("CLEANUP_ARCHIVE_READ_DAYS: %v\n", o.cleanupArchiveReadDays))
|
||||
builder.WriteString(fmt.Sprintf("CLEANUP_REMOVE_SESSIONS_DAYS: %v\n", o.cleanupRemoveSessionsDays))
|
||||
builder.WriteString(fmt.Sprintf("WORKER_POOL_SIZE: %v\n", o.workerPoolSize))
|
||||
builder.WriteString(fmt.Sprintf("POLLING_FREQUENCY: %v\n", o.pollingFrequency))
|
||||
builder.WriteString(fmt.Sprintf("BATCH_SIZE: %v\n", o.batchSize))
|
||||
builder.WriteString(fmt.Sprintf("PROXY_IMAGES: %v\n", o.proxyImages))
|
||||
builder.WriteString(fmt.Sprintf("CREATE_ADMIN: %v\n", o.createAdmin))
|
||||
builder.WriteString(fmt.Sprintf("POCKET_CONSUMER_KEY: %v\n", o.pocketConsumerKey))
|
||||
builder.WriteString(fmt.Sprintf("OAUTH2_USER_CREATION: %v\n", o.oauth2UserCreationAllowed))
|
||||
builder.WriteString(fmt.Sprintf("OAUTH2_CLIENT_ID: %v\n", o.oauth2ClientID))
|
||||
builder.WriteString(fmt.Sprintf("OAUTH2_CLIENT_SECRET: %v\n", o.oauth2ClientSecret))
|
||||
builder.WriteString(fmt.Sprintf("OAUTH2_REDIRECT_URL: %v\n", o.oauth2RedirectURL))
|
||||
builder.WriteString(fmt.Sprintf("OAUTH2_PROVIDER: %v\n", o.oauth2Provider))
|
||||
builder.WriteString(fmt.Sprintf("HTTP_CLIENT_TIMEOUT: %v\n", o.httpClientTimeout))
|
||||
builder.WriteString(fmt.Sprintf("HTTP_CLIENT_MAX_BODY_SIZE: %v\n", o.httpClientMaxBodySize))
|
||||
|
||||
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 "******"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
+91
-22
@@ -6,6 +6,7 @@ package config // import "miniflux.app/config"
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,8 +14,6 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/logger"
|
||||
)
|
||||
|
||||
// Parser handles configuration parsing.
|
||||
@@ -77,6 +76,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
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 {
|
||||
@@ -88,10 +89,14 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
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":
|
||||
@@ -108,56 +113,92 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
p.opts.certKeyFile = parseString(value, defaultKeyFile)
|
||||
case "CERT_DOMAIN":
|
||||
p.opts.certDomain = parseString(value, defaultCertDomain)
|
||||
case "CERT_CACHE":
|
||||
p.opts.certCache = parseString(value, defaultCertCache)
|
||||
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 "CLEANUP_FREQUENCY":
|
||||
logger.Error("[Config] CLEANUP_FREQUENCY has been deprecated in favor of CLEANUP_FREQUENCY_HOURS.")
|
||||
|
||||
if p.opts.cleanupFrequencyHours != defaultCleanupFrequencyHours {
|
||||
logger.Error("[Config] Ignoring CLEANUP_FREQUENCY as CLEANUP_FREQUENCY_HOURS is already specified.")
|
||||
} else {
|
||||
p.opts.cleanupFrequencyHours = parseInt(value, defaultCleanupFrequencyHours)
|
||||
}
|
||||
case "ARCHIVE_READ_DAYS":
|
||||
logger.Error("[Config] ARCHIVE_READ_DAYS has been deprecated in favor of CLEANUP_ARCHIVE_READ_DAYS.")
|
||||
|
||||
if p.opts.cleanupArchiveReadDays != defaultCleanupArchiveReadDays {
|
||||
logger.Error("[Config] Ignoring ARCHIVE_READ_DAYS as CLEANUP_ARCHIVE_READ_DAYS is already specified.")
|
||||
} else {
|
||||
p.opts.cleanupArchiveReadDays = parseInt(value, defaultCleanupArchiveReadDays)
|
||||
}
|
||||
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)
|
||||
case "PROXY_IMAGES":
|
||||
p.opts.proxyImages = parseString(value, defaultProxyImages)
|
||||
case "PROXY_IMAGE_URL":
|
||||
p.opts.proxyImageUrl = parseString(value, defaultProxyImageUrl)
|
||||
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 "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 "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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,12 +219,12 @@ func parseBaseURL(value string) (string, string, string, error) {
|
||||
|
||||
url, err := url_parser.Parse(value)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("Invalid BASE_URL: %v", err)
|
||||
return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(url.Scheme)
|
||||
if scheme != "https" && scheme != "http" {
|
||||
return "", "", "", errors.New("Invalid BASE_URL: scheme must be http or https")
|
||||
return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
|
||||
}
|
||||
|
||||
basePath := url.Path
|
||||
@@ -223,3 +264,31 @@ func parseString(value string, fallback string) string {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseStringList(value string, fallback []string) []string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var strList []string
|
||||
items := strings.Split(value, ",")
|
||||
for _, item := range items {
|
||||
strList = append(strList, strings.TrimSpace(item))
|
||||
}
|
||||
|
||||
return strList
|
||||
}
|
||||
|
||||
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,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,13 @@
|
||||
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 db
|
||||
docker-compose -f basic.yml up
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
version: '3.4'
|
||||
services:
|
||||
miniflux:
|
||||
image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}
|
||||
container_name: miniflux
|
||||
restart: always
|
||||
ports:
|
||||
- "80:8080"
|
||||
depends_on:
|
||||
- db
|
||||
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:latest
|
||||
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 @@
|
||||
version: '3.4'
|
||||
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
|
||||
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:latest
|
||||
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 @@
|
||||
version: '3.4'
|
||||
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
|
||||
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:latest
|
||||
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"
|
||||
|
||||
+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 = 25
|
||||
|
||||
// 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,619 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
-216
@@ -1,216 +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_22": `update entries set document_vectors = setweight(to_tsvector(substring(coalesce(title, '') for 1000000)), 'A') || setweight(to_tsvector(substring(coalesce(content, '') for 1000000)), 'B');`,
|
||||
"schema_version_23": `alter table users add column keyboard_shortcuts boolean default 't';`,
|
||||
"schema_version_24": `alter table feeds add column disabled boolean default 'f';`,
|
||||
"schema_version_25": `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';
|
||||
`,
|
||||
"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_22": "51ed5fbcae9877e57274511f0ef8c61d254ebd78dfbcbc043a2acd30f4c93ca3",
|
||||
"schema_version_23": "cb3512d328436447f114e305048c0daa8af7505cfe5eab02778b0de1156081b2",
|
||||
"schema_version_24": "1224754c5b9c6b4038599852bbe72656d21b09cb018d3970bd7c00f0019845bf",
|
||||
"schema_version_25": "5262d2d4c88d637b6603a1fcd4f68ad257bd59bd1adf89c58a18ee87b12050d7",
|
||||
"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 +0,0 @@
|
||||
update entries set document_vectors = setweight(to_tsvector(substring(coalesce(title, '') for 1000000)), 'A') || setweight(to_tsvector(substring(coalesce(content, '') for 1000000)), 'B');
|
||||
@@ -1 +0,0 @@
|
||||
alter table users add column keyboard_shortcuts boolean default 't';
|
||||
@@ -1 +0,0 @@
|
||||
alter table feeds add column disabled boolean default 'f';
|
||||
@@ -1,4 +0,0 @@
|
||||
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';
|
||||
@@ -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 '';
|
||||
@@ -1 +0,0 @@
|
||||
alter table feeds add column rewrite_rules text default '';
|
||||
@@ -1 +0,0 @@
|
||||
alter table feeds add column crawler boolean default 'f';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user