Compare commits
716 Commits
v0.12.0rc1
...
develop
Author | SHA1 | Date |
---|---|---|
Matt Baer | 038a80c25e | |
Matt Baer | 9ece6682ef | |
Matt Baer | 41e1989345 | |
Matt Baer | 34d902062f | |
dependabot[bot] | ed9ff51b68 | |
Riley Chang | 83ffea7fa0 | |
dependabot[bot] | 3dd0a9b8dc | |
Matt Baer | 427f4980b9 | |
Matt Baer | e34a58d0ef | |
Matt Baer | 6d547040ef | |
Matt Baer | 216f36f47b | |
Andrew M McCall | a352a3518a | |
Big Squirrel | 1a3f3f0ec6 | |
charlocharlie | 306ca173c6 | |
Matt Baer | 22de459a72 | |
Matt Baer | 5be1f2451c | |
Matt Baer | 3a53353ed8 | |
dependabot[bot] | 56cad35b19 | |
Matt Baer | ff84c7aa4d | |
Andreas Sander | 4c6169d55d | |
Matt Baer | ab1b2922cc | |
dependabot[bot] | 9401d047d6 | |
Matt Baer | 54b46b61db | |
Matt Baer | 235a3ee143 | |
Matt Baer | f4accd5064 | |
Matt Baer | bc00ae1963 | |
dependabot[bot] | 775d86cb00 | |
Matt Baer | 90e564870d | |
dependabot[bot] | 62c26e78ba | |
dependabot[bot] | 69002fdcbf | |
dependabot[bot] | 4acf08d9e9 | |
Matt Baer | df7fee2018 | |
Matt Baer | c64c7c77ae | |
dependabot[bot] | e788b90b04 | |
Matt Baer | 66f049cc39 | |
Matt Baer | ff07c447ee | |
Matt Baer | d33a556732 | |
Matt Baer | 737d76176a | |
dependabot[bot] | 8e6ddc1993 | |
Matt Baer | b85afa1ea6 | |
dependabot[bot] | 6b8cc591cc | |
dependabot[bot] | 859a4b37e5 | |
dependabot[bot] | 3caa33b9bf | |
Matt Baer | e932467ac9 | |
d4rklynk | aac4514577 | |
d4rklynk | 21f5073717 | |
d4rklynk | 64d1a2f536 | |
Matt Baer | e4e059cb13 | |
Matt Baer | feab841609 | |
Matt Baer | 3e7d236c6d | |
Matt Baer | 289730e24a | |
Matt Baer | a1becfdc83 | |
Matt Baer | 0bf0b425ee | |
dependabot[bot] | 10994c532f | |
Matt Baer | ae70c2dbe4 | |
dependabot[bot] | cdb1ffd1da | |
Matt Baer | d467fdf158 | |
dependabot[bot] | 643d025381 | |
Kyra | ee485e0488 | |
Matt Baer | 5204b3b752 | |
Matt Baer | 45ca9c4c2b | |
Matt Baer | 71fd25870d | |
Matt Baer | dd797c8145 | |
Matt Baer | 3870749e5e | |
Brennan Lujan | 87b3585c44 | |
Matt Baer | bf213cd0b0 | |
Matt Baer | 815500ab78 | |
Matt Baer | 4aad0338bf | |
Matt Baer | 711cb387a5 | |
Matt Baer | e3323d11c8 | |
Matt Baer | 076c4ae2f2 | |
Matt Baer | 530a36fc53 | |
Matt Baer | 8207a25fa9 | |
Matt Baer | 7b84dafea7 | |
Matt Baer | ed60aea39e | |
Matt Baer | 8f02449ee8 | |
Matt Baer | 1e37f60d50 | |
Matt Baer | c18987705c | |
Matt Baer | 7db4b699e2 | |
Matt Baer | 26ba79ff02 | |
Matt Baer | b232e7efd7 | |
Matt Baer | 64dcb56793 | |
Matt Baer | 273267343a | |
Matt Baer | 27e82f0409 | |
Matt Baer | 167971771e | |
Matt Baer | 2275a288b9 | |
Matt Baer | f96f8268f0 | |
Matt Baer | 74f3ded250 | |
Matt Baer | c1609cdb90 | |
Matt Baer | e96e657430 | |
Matt Baer | f404f7b928 | |
Matt Baer | 7dda53146d | |
Matt Baer | e2fde518ca | |
Matt Baer | c75507ca8f | |
Matt Baer | 82e7dcd3f3 | |
Matt Baer | 361c887e2c | |
Matt Baer | 13ca890709 | |
Matt Baer | c6323dba8c | |
Matt Baer | dcc6f036c6 | |
Matt Baer | d7d44cb4e1 | |
Matt Baer | 2a496bd000 | |
Matt Baer | 15047b7288 | |
Matt Baer | d1afa44a2e | |
Matt Baer | ac40b2f733 | |
Matt Baer | e2b2ba4577 | |
Matt Baer | cc75be1eb5 | |
Matt Baer | 221d0d7dbb | |
Matt Baer | cc9705447d | |
Matt Baer | 06968e7341 | |
Matt Baer | 62f9b2948e | |
Matt Baer | a8afa18ab2 | |
Matt Baer | b291b89904 | |
Matt Baer | 96eb800eaa | |
Matt Baer | 36f4e30595 | |
Matt Baer | 177cbf2e57 | |
Matt Baer | 334d499fb3 | |
Matt Baer | 322d0d618a | |
Matt Baer | c9dc8d5a90 | |
Matt Baer | d48262a6df | |
Matt Baer | 83f230ddaf | |
Matt Baer | efe669b874 | |
Matt Baer | aa72bcba50 | |
Matt Baer | 8626aa12cc | |
Matt Baer | 264bef03b1 | |
Matt Baer | e0c165ff1e | |
Matt Baer | 2986f83121 | |
Matt Baer | 3d8b8ecc93 | |
Matt Baer | 5d4ebb59c7 | |
Matt Baer | 2b5318e7a6 | |
Matt Baer | baf1d76475 | |
Matt Baer | 94bb566e4f | |
Matt Baer | d3f312a1e2 | |
Matt Baer | ebeb45ac5a | |
Matt Baer | 3dc515c249 | |
dependabot[bot] | 10a415a7ec | |
Matt Baer | a8c5468f65 | |
Matt Baer | 43ba111e21 | |
Matt Baer | 299686c13e | |
Matt Baer | dff01a6136 | |
Matt Baer | 8f03da0ec1 | |
Matt Baer | 142c5d6cec | |
Matt Baer | 526db318c4 | |
Matt Baer | fe1f821422 | |
Matt Baer | 2fde648519 | |
Matt Baer | 3e21ecb53c | |
Matt Baer | 3ba29aaa2c | |
dependabot[bot] | c60d135060 | |
dependabot[bot] | 4c48733a3a | |
Matt Baer | f2474798bb | |
Matt Baer | 9c9fa8bf62 | |
dependabot[bot] | 3981b6dddb | |
Matt Baer | da3e5d0606 | |
Matt Baer | 51c46621d8 | |
dependabot[bot] | 21a1c738d1 | |
Matt Baer | 0814ec28dc | |
lstellway | c7729a0432 | |
dependabot[bot] | a408f0f9ea | |
dependabot[bot] | e9b03c9350 | |
Matt Baer | 65ec6b44e1 | |
dependabot[bot] | 21efde71f7 | |
Matt Baer | 8755f1706c | |
dependabot[bot] | 41138e4ab2 | |
Matt Baer | 0860d1db1f | |
dependabot[bot] | b54de10663 | |
guoguangwu | 78e59b749b | |
guoguangwu | 20fec65e6b | |
guoguangwu | cf53730f6c | |
Matt Baer | dbdbcfd100 | |
Matt Baer | 54eb2db14d | |
Matt Baer | e65086b635 | |
Matt Baer | b753d41964 | |
Matt Baer | 5d5a8536c8 | |
Matt Baer | 9580cffb3d | |
Matt Baer | 1aee7ed125 | |
Matt Baer | 989d7eb2fc | |
dependabot[bot] | ba8aebaa6f | |
Matt Baer | 949f13bf66 | |
Matt Baer | f92f7b13cb | |
dependabot[bot] | 98790ee371 | |
Matt Baer | a9733c30cf | |
dependabot[bot] | d3f935f693 | |
Matt Baer | 3eb3146ae9 | |
Matt Baer | 229607a5ab | |
Matt Baer | d476c3b2f7 | |
Matt Baer | 6946d3b785 | |
Matt Baer | e0372979d9 | |
dependabot[bot] | 639770be4d | |
dependabot[bot] | b0b166e827 | |
dependabot[bot] | e2237653bb | |
dependabot[bot] | 77823a382b | |
dependabot[bot] | b6d17a9594 | |
dependabot[bot] | e1e05e5f29 | |
Matt Baer | 67dbc9b22b | |
Matt Baer | 3f5fd6e2d2 | |
Matt Baer | 3a7554abe8 | |
Matt Baer | e350b7ce8a | |
Matt Baer | 1a61128dfc | |
Matt Baer | ddabab041a | |
Matt Baer | 2ba840634b | |
Matt Baer | ac9c53cfff | |
dependabot[bot] | 1a4845aca8 | |
Matt Baer | 7c0e69cf41 | |
dependabot[bot] | cdaa13a260 | |
dependabot[bot] | 0dcfd1809d | |
dependabot[bot] | ad6c8f30bc | |
Matt Baer | 86c76b0442 | |
dependabot[bot] | 43176ed7ea | |
Matt Baer | 64772aa203 | |
Matt Baer | 40b9c08c86 | |
Josh Soref | ea81e2c839 | |
Josh Soref | 02fb079a9f | |
Josh Soref | 0746ec8567 | |
Josh Soref | 7e5d60043d | |
Josh Soref | af875b4d87 | |
Josh Soref | 8dd7b40c02 | |
Josh Soref | 8834253502 | |
Josh Soref | 7feea370ed | |
Josh Soref | 680f0d1e20 | |
Josh Soref | bc53300e33 | |
Josh Soref | af0927cf5c | |
dependabot[bot] | ee665c0c68 | |
Abdullah | 83765d5cbc | |
İlteriş Yağıztegin Eroğlu | 77cc1cc816 | |
Matt Baer | 118eb732f4 | |
Matt Baer | 99d72881cf | |
Timshel | fc5a79a6bc | |
Matt Baer | a0f1e1821f | |
Matt Baer | f84b4b0f74 | |
Matt Baer | 7a84d27dca | |
Matt Baer | 3e6669828c | |
Matt Baer | bbcb61bc53 | |
Matt Baer | 8684ff04a4 | |
Matt Baer | 93d5fd152d | |
mathew | 6903dd4349 | |
dependabot[bot] | b5021f2b0c | |
Matt Baer | 29c898867a | |
Matt Baer | 17614b5e02 | |
Matt Baer | 950090c0d7 | |
Matt Baer | 01c920b253 | |
Matt Baer | 4c1678f91e | |
Matt Baer | 4b33c51ece | |
Matt Baer | 99d17e5e97 | |
dependabot[bot] | 6347301867 | |
Matt Baer | 7f83bb2706 | |
Matt Baer | 02383768ed | |
Matt Baer | f85241e037 | |
dependabot[bot] | a080e51aaa | |
Matt Baer | 57b12f31c9 | |
Matt Baer | c58eedba7d | |
Matt Baer | 9767910b1f | |
Matt Baer | ac1b947b18 | |
dependabot[bot] | a5c80b98e7 | |
dependabot[bot] | 7b5326ada9 | |
Matt Baer | 2c644dd262 | |
Matt Baer | 7687341512 | |
dependabot[bot] | beb964a9f1 | |
dependabot[bot] | 42c7e22b98 | |
dependabot[bot] | 4f2b17ddb1 | |
Matt Baer | 63eb682a60 | |
Matt Baer | ccef3bfdc7 | |
Matt Baer | 2c44fb780a | |
Matt Baer | f43a3a8bfa | |
Matt Baer | 61d1537fce | |
Matt Baer | d08f067e9c | |
dependabot[bot] | 3696483d91 | |
dependabot[bot] | 11266dd87e | |
dependabot[bot] | de0c1085b4 | |
dependabot[bot] | 2cf7693a8e | |
Matt Baer | b74fd70ab5 | |
dependabot[bot] | 915351c4af | |
Matt Baer | 92504b6721 | |
davralin | 16c6788b62 | |
davralin | 2433745504 | |
davralin | 17c8e78a5c | |
davralin | 84fea7abba | |
davralin | 59767804a9 | |
davralin | dec0142a5b | |
davralin | f2dce539f4 | |
davralin | ea0703949d | |
davralin | 35ac24223d | |
ltdk | 0a19dc1ec2 | |
ltdk | baaf0580f5 | |
Matt Baer | e5103d555f | |
Matt Baer | cab7fc8647 | |
Matt Baer | face603a0e | |
Matt Baer | 9a45030911 | |
Matt Baer | 4680e2e046 | |
Matt Baer | c3ae4e6d3c | |
Darius Kazemi | dd88083b2a | |
Matt Baer | fd44bc5707 | |
Matt Baer | 9ee83ae885 | |
Matt Baer | e92c33aae4 | |
Matt Baer | 0d554ce180 | |
Matt Baer | a0e936ee1b | |
Matt Baer | 46bb8e65a1 | |
Isaac Su | df7be46417 | |
Matt Baer | d1e6daee16 | |
Matt Baer | 43ca80f3eb | |
Matt Baer | 1530bf37ef | |
Matt Baer | 401c8c1f4c | |
Eli Mellen | b190a1508b | |
Eli Mellen | 27f68ef0cf | |
Matt Baer | 69ab0d34e0 | |
gytisrepecka | 97a5121924 | |
Matt Baer | 129f428bfa | |
Matt Baer | 8c1785b904 | |
Matt Baer | a2f9642238 | |
Matt Baer | 5b3d25b5cc | |
Matt Baer | 6e5f7e87d2 | |
Matt Baer | e91748c0bc | |
Matt Baer | 414d5b0a1c | |
Matt Baer | c4b124e37c | |
Matt Baer | f4977c7a34 | |
Matt Baer | 6ad1f41cf4 | |
Matt Baer | 3270470b68 | |
Matt Baer | 2a0298cd46 | |
Matt Baer | a122e4e98a | |
Matt Baer | 44bfd4573e | |
HeartDev | cc69f9f2f1 | |
mnlg | ae7e42e24e | |
Matt Baer | fc8e209def | |
Matt Baer | e963755393 | |
Matt Baer | 2288ccf2a2 | |
Micha Gläß-Stöcker | a58180543e | |
mnlg | 5be1938a8a | |
Matt Baer | c42439886c | |
Matt Baer | adb4fdc5fe | |
Matt Baer | b7f732b915 | |
Matt Baer | 940d220bf3 | |
Matt Baer | 48075fc183 | |
Matt Baer | 577bdf14aa | |
Matt Baer | 672fa10b94 | |
Matt Baer | de5e91cb71 | |
Matt Baer | 6291f4f155 | |
Matt Baer | 273c9cf418 | |
Matt Baer | fbb3000e4d | |
Matt Baer | 6b336e22aa | |
Matt Baer | cbc2427475 | |
Matt Baer | 276304d5b8 | |
Matt Baer | 65bc73e527 | |
Matt Baer | d37ab544e8 | |
Matt Baer | 1bdcf7096a | |
Matt Baer | ed771380fb | |
Matt Baer | 720a8c1975 | |
Matt Baer | f933b36170 | |
Matt Baer | e91ffe2dcb | |
Matt Baer | 3008668a7d | |
Matt Baer | 0ddca40529 | |
Matt Baer | 2ea235f0c4 | |
Matt Baer | e983c4527f | |
Matt Baer | 25e4d6448b | |
Matt Baer | 230c736583 | |
Matt Baer | e7245536f3 | |
Matt Baer | 42db4b38f6 | |
Matt Baer | c05f7056c4 | |
Matt Baer | e42ba392c6 | |
Matt Baer | 9341784c0c | |
Matt Baer | f0697fd555 | |
Matt Baer | 7695f8c2e4 | |
Matt Baer | 85fb2a952b | |
Matt Baer | 6740fbe097 | |
Matt Baer | 2938bba15a | |
Matt Baer | ddc7087d1e | |
Matt Baer | b010484493 | |
Matt Baer | 73e0b72878 | |
Matt Baer | 14f5100d6a | |
Matt Baer | 5c89812764 | |
Matt Baer | 7a71731274 | |
Matt Baer | b0f792c211 | |
Matt Baer | 73450a50e3 | |
Matt Baer | 895e04c8c4 | |
Matt Baer | 4565c6dd90 | |
Matt Baer | a7c4a318f3 | |
Matt Baer | 7c32dc1045 | |
Matt Baer | 2903c86875 | |
Matt Baer | e5347dd924 | |
Matt Baer | c9c2adde0f | |
Matt Baer | b2c6c6c167 | |
Matt Baer | 5a4ff2a9de | |
Matt Baer | c01fb585ba | |
Matt Baer | affcd270bb | |
Matt Baer | 14a8961457 | |
Matt Baer | 4e0912b32a | |
Matt Baer | 02bb5013a7 | |
Matt Baer | 7257af2905 | |
Matt Baer | 36455eea2b | |
Matt Baer | 967ee9679c | |
Matt Baer | d3d77cee54 | |
Matt Baer | 7c1c1218b1 | |
Matt Baer | b092421f6e | |
Matt Baer | a6c93c37da | |
Matt Baer | 1d8facfe1c | |
Matt Baer | f689706baa | |
Matt Baer | f06ab629d1 | |
Matt Baer | e4164cbf67 | |
Matt Baer | 3b58d77e67 | |
Matt Baer | c0fdd8af49 | |
Matt Baer | c06a739f9b | |
Matt Baer | 4ec8ffa699 | |
Matt Baer | e0a0d71c84 | |
Matt Baer | 3ab21f7834 | |
Matt Baer | 61974fadc0 | |
Matt Baer | 439f8bd262 | |
Matt Baer | 63fa8d299a | |
Matt Baer | 27b43ac2f1 | |
Matt Baer | 51a83069c4 | |
Matt Baer | ac7583eadb | |
Colin Axnér | 8ac2d0b310 | |
Colin Axnér | 866a585119 | |
Matt Baer | 4228761eb3 | |
Donald Feury | 68297acb74 | |
Matt Baer | de601e16ac | |
Matt Baer | 484d2736ce | |
Matt Baer | f8888df746 | |
Matt Baer | 0c7aba1f53 | |
Matt Baer | 02490c798c | |
Matt Baer | 11e636359d | |
Matt Baer | 50c4e944a4 | |
Matt Baer | e58e457b25 | |
Matt Baer | af4e0b4f1c | |
Matt Baer | ed74228795 | |
Matt Baer | 2c1d3a51af | |
Matt Baer | 23818c6104 | |
Matt Baer | 5510ef15b5 | |
Matt Baer | 5ecf613cb5 | |
dependabot[bot] | 9cbd254d64 | |
Matt Baer | 733301d364 | |
Matt Baer | f1eae4007e | |
dependabot[bot] | f70fc0c4e2 | |
dependabot[bot] | 2a9aa84366 | |
Matt Baer | 64f1d71524 | |
Matt Baer | 5a3e8d59b6 | |
Matt Baer | 6f665e7e4b | |
Matt Baer | d7c9f56b40 | |
Matt Baer | 47aa436caa | |
Matt Baer | 424bd55816 | |
Matt Baer | 3e282e4c85 | |
Matt Baer | 85efbcccfc | |
Matt Baer | 4a58a94e26 | |
Matt Baer | 9aa5fc4420 | |
Matt Baer | 636c9b35c0 | |
Matt Baer | a6a4bd38c1 | |
Matt Baer | 811f996e84 | |
Matt Baer | 3984042905 | |
Matt Baer | 321c1af607 | |
Matt Baer | 9f525876f4 | |
Matt Baer | 9b336dee8c | |
Matt Baer | 9aeeb52bdb | |
Matt Baer | 9484880bca | |
Colin Axnér | f2e3cd8bd7 | |
Colin Axnér | 00f2152c2b | |
Matt Baer | 4cf9500704 | |
Matt Baer | fbb67bc9ef | |
Matt Baer | 4f32af2d7f | |
Matt Baer | 97242cd5ec | |
Matt Baer | bd77145bf3 | |
dependabot[bot] | 1ea728b1e9 | |
Matt Baer | c813d08230 | |
dependabot[bot] | 1ac5c4ab4d | |
Matt Baer | ff976a950e | |
Matt Baer | 1f6d0e2e70 | |
dependabot[bot] | 5b2c350b5d | |
Matt Baer | b3dd06c79b | |
dependabot[bot] | 71b211b11e | |
Matt Baer | 33d47ca420 | |
Matt Baer | c2c6b69044 | |
Matt Baer | 706ae9cc77 | |
Matt Baer | 1abc9b643f | |
Matt Baer | 8a8288d2af | |
Matt Baer | e36e39cb73 | |
v | 583693ed8d | |
v | 19beabe2d1 | |
Donald Feury | ebdb932090 | |
Donald Feury | 4c0fcdf7c6 | |
Donald Feury | 9ed2687543 | |
Donald Feury | 530439772d | |
Matt Baer | 33cf9263f5 | |
Matt Baer | a10827cd50 | |
Matt Baer | 65caaca659 | |
Matt Baer | 2d38e8b65e | |
Viktor Vaczi | 8c0978419f | |
CJ Eller | 391844fab9 | |
CJ Eller | e6c36fc2ef | |
Donald Feury | e6417d911c | |
x4e | 795748457c | |
funkyduck | 6c1ab93717 | |
Viktor Vaczi | 6049213661 | |
Viktor Vaczi | 9a55d38e4b | |
Viktor Vaczi | 676b673c94 | |
Viktor Vaczi | b1cea637cb | |
Matt Baer | f31e4d650d | |
Matt Baer | 53ea85dc86 | |
Colin Axner | fcf01a6039 | |
Colin Axner | 30fc088cec | |
Colin Axner | 3aa621ee36 | |
Matt Baer | d52e2826f8 | |
dependabot[bot] | ed00417d8d | |
Conor Flynn | 9f925c8138 | |
Conor Flynn | 0eb1a2deec | |
Darius Kazemi | b262fa144c | |
Darius Kazemi | 0aafd0c368 | |
Matt Baer | 3493921837 | |
Matt Baer | 7d4df23d3c | |
Matt Baer | 3b91400b62 | |
Matt Baer | bb008aa66c | |
Darius Kazemi | 667cbb97ed | |
Matt Baer | e1cde913e2 | |
Matt Baer | 345313200e | |
Matt Baer | 211b02c209 | |
Matt Baer | b1e22795b1 | |
Matt Baer | cf0403d955 | |
Matt Baer | c1aed45388 | |
dependabot[bot] | 083d8c4d67 | |
Matt Baer | e3c7a8ac3a | |
dependabot[bot] | 454e781ed4 | |
Matt Baer | 1b6f9b6742 | |
Marcel van der Boom | 5961eb8f27 | |
dependabot[bot] | f5f28550fb | |
dependabot[bot] | c22a751ab7 | |
Matt Baer | 2768ea9414 | |
Matt Baer | 13a3a68d54 | |
Matt Baer | ec7b299fd3 | |
Matt Baer | f534ee1dec | |
Colin Axner | 678653ac30 | |
Colin Axner | 75a79d49bd | |
Matt Baer | 2908080b52 | |
Matt Baer | d6d510aec9 | |
Colin Axner | 5ba0ea2b04 | |
Matt Baer | a96d4474ef | |
Matt Baer | a7190795f7 | |
Matt Baer | 70dbfcfba4 | |
Matt Baer | da8c08668f | |
Matt Baer | 61daca2b0d | |
Dami | f847ade1ef | |
Dami | 3a789f5a00 | |
Dami | 79715891fb | |
Josip Antoliš | eb76faa129 | |
Matt Baer | 7c1244e6b1 | |
Matt Baer | c31a87fb76 | |
Josip Antoliš | 1b1d3064c9 | |
Josip Antoliš | 3f36ede885 | |
Matt Baer | f821ead3a1 | |
Matt Baer | 8be71481c8 | |
Matt Baer | a8a6525006 | |
Matt Baer | 98d88b9a4b | |
Matt Baer | ac90cb2c80 | |
dependabot[bot] | 00a5a4f7ab | |
Matt Baer | 505b124db7 | |
Matt Baer | f75d4cb75d | |
dependabot[bot] | 21579cfa71 | |
dependabot[bot] | 1779aeaf8c | |
Matt Baer | 62d29166f4 | |
dependabot[bot] | e60398f0b4 | |
Matt Baer | ce69117c79 | |
Matt Baer | d8019bba0d | |
Matt Baer | 820c5ae557 | |
Matt Baer | 3a915ad8ea | |
Matt Baer | 8d27ee6d99 | |
Matt Baer | 6f8d70043f | |
Matt Baer | 9d0ba2bed4 | |
Matt Baer | cef51a7797 | |
Matt Baer | 0ed9c9c746 | |
Matt Baer | 217430e56b | |
Matt Baer | 7a09a47de2 | |
Matt Baer | 455e50c9a8 | |
Matt Baer | a78b36b871 | |
Matt Baer | 00cceca104 | |
Matt Baer | 4db2cb8986 | |
Matt Baer | a773d94dc7 | |
Matt Baer | 04d404e61f | |
Matt Baer | 21e9b4a667 | |
Matt Baer | 63f023ea98 | |
Matt Baer | ab32caa49c | |
Matt Baer | 13eb51913e | |
Matt Baer | 95273697f4 | |
Matt Baer | dfa14c9c92 | |
prichier | ab285644a0 | |
Pascal Richier | d3f1e40010 | |
Matt Baer | 7e3eb9a87b | |
Matt Baer | 7fa78c2255 | |
Matt Baer | c16414843a | |
Matt Baer | b2382b5422 | |
gytisrepecka | 731d4e8efe | |
Matt Baer | fd3a6399b3 | |
Matt Baer | 8b243e119f | |
Matt Baer | 0c8b779afb | |
Matt Baer | 5f52c23a65 | |
Matt Baer | e37bec6aa1 | |
Matt Baer | 121d83d94d | |
Dami | 9b614bc922 | |
Matt Baer | 09e70e07f8 | |
Matt Baer | 7eeba4dc9e | |
Matt Baer | 849e5b8503 | |
Matt Baer | fee44e7c8d | |
Matt Baer | a32fc44153 | |
Matt Baer | bd387c6dec | |
Matt Baer | cd6ccd257b | |
dependabot[bot] | 9c835a2b9d | |
Matt Baer | 55ffb86ac2 | |
Matt Baer | 78e0d98589 | |
Matt Baer | 6af24293d1 | |
Matt Baer | 9d7783f80d | |
Matt Baer | 2eaf7493d7 | |
Matt Baer | ab6d4bfb9d | |
dependabot[bot] | 2c45307107 | |
dependabot[bot] | ad2e46cb40 | |
Matt Baer | e796331de8 | |
Matt Baer | 9ff54f9944 | |
dependabot[bot] | aa170d0c5a | |
Matt Baer | 42c6f3ca03 | |
Matt Baer | 191eac77ab | |
dependabot[bot] | 00c47fa62f | |
Matt Baer | 29dc53aacd | |
Matt Baer | 2f06b0b487 | |
dependabot[bot] | 5897ef7cab | |
dependabot[bot] | 99b2f41aa1 | |
dependabot[bot] | 94094ed16d | |
dependabot[bot] | f278eccd14 | |
dependabot[bot] | 267c9df1c4 | |
dependabot[bot] | b569144624 | |
dependabot[bot] | 3d80b46bdc | |
dependabot[bot] | cfaaffdc6c | |
Matt Baer | 3294087abd | |
Shlee | 48e85b7c63 | |
Matt Baer | 4c5f45f462 | |
Matt Baer | 6dbc753ecb | |
Matt Baer | 1451fc1369 | |
Matt Baer | dbe36861c3 | |
Matt Baer | 24fa3d6863 | |
Matt Baer | 94fee2e19e | |
Matt Baer | ede68d86a7 | |
Matt Baer | 504a2a42aa | |
Shlee | b98903cff8 | |
Shlee | beef2b15a7 | |
Neil Moore | 94bcb91220 | |
Matt Baer | a25664bb97 | |
CJ Eller | 591bb0866c | |
prichier | f6aa99e591 | |
Matt Baer | 9624c4db00 | |
Matt Baer | 507acc7e1c | |
Matt Baer | cceea03076 | |
prichier | 724ab34006 | |
prichier | fe7ff38bd8 | |
Keturah Dola-Borg | cd01a4459d | |
Keturah Dola-Borg | 405a2602ce | |
Keturah Dola-Borg | 92d822b5c6 | |
Keturah Dola-Borg | 211d441090 | |
Keturah Dola-Borg | 7b71d455a8 | |
Keturah Dola-Borg | 630ac1f7c0 | |
Keturah Dola-Borg | badaffcd5c | |
Keturah Dola-Borg | cfd2165442 | |
Keturah Dola-Borg | 75ca5cd417 | |
Keturah Dola-Borg | ee1ca48800 | |
Keturah Dola-Borg | 89f7946cb0 | |
Keturah Dola-Borg | 6174987c6a | |
Matt Baer | 5c94d23466 | |
Matt Baer | 2aa154d85c | |
RJ722 | 53cb5c3837 | |
Matt Baer | 9d854c17c1 | |
Matt Baer | 037fc40fb3 | |
Matt Baer | 5fe1dd1731 | |
Matt Baer | b9c467558c | |
Matt Baer | a0e517c224 | |
Matt Baer | dc7b5df90e | |
gytisrepecka | 8675eb0f95 | |
Matt Baer | 99d86a7489 | |
Matt Baer | 8e16bac12c | |
Matt Baer | 7420039770 | |
Matt Baer | f15acf3880 | |
Matt Baer | 308b1a7282 | |
Matt Baer | fd97539f85 | |
Matt Baer | cf3d5588c2 | |
Matt Baer | 6fc166174b | |
Matt Baer | 0c6d3e45e4 | |
Matt Baer | b97038e696 | |
Matt Baer | 37ccf69d81 | |
Matt Baer | 0127e38ed0 | |
Matt Baer | 7b7df5535e | |
Matt Baer | 5400f416c0 | |
Matt Baer | ca4a576c31 | |
Matt Baer | 93c2773412 | |
gytisrepecka | 0e1459c6b2 | |
gytisrepecka | 658310bc24 | |
gytisrepecka | ddd519f6b7 | |
Joice M. Joseph | 671c7e99a5 | |
Matt Baer | 5e4ed5d9bc | |
Matt Baer | 1c5a0099b6 | |
Matt Baer | 5de4d2086b | |
Matt Baer | e51e58386e | |
Matt Baer | 9f1dd7a138 | |
gytisrepecka | c798a44f69 | |
Matt Baer | d6cb178eb6 | |
Matt Baer | f76bfebfde | |
Matt Baer | 4b0833435f | |
Matt Baer | 9780f0bbb9 | |
Matt Baer | d277e283d5 | |
Rob Loranger | 7bccb3d7f1 | |
Rob Loranger | b3a541ab09 | |
Rob Loranger | ee712bbfaa | |
Rob Loranger | cb1553d67e | |
Rob Loranger | 58f27717be | |
Rob Loranger | f1f5dbb128 | |
Matt Baer | 6aa8de3a4b | |
Matt Baer | f406f894c5 | |
Matt Baer | d6c0026644 | |
Matt Baer | 5259c4fcdf | |
Rob Loranger | fc553d277f | |
Rob Loranger | 482e632ca9 | |
Michael Demetriou | 638059a26b | |
Michael Demetriou | 8404f0896c | |
Michael Demetriou | dfa98bcfc8 | |
Rob Loranger | 4d150fe831 | |
Rob Loranger | 25145296b3 | |
Rob Loranger | 84d7ac35d3 | |
Rob Loranger | aad4768aed | |
Rob Loranger | 38c1bf9cab |
|
@ -1,2 +1 @@
|
|||
Dockerfile
|
||||
.git
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
charset = utf-8
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
|
@ -0,0 +1,2 @@
|
|||
github: writefreely
|
||||
open_collective: writefreely
|
|
@ -0,0 +1,15 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
open-pull-requests-limit: 50
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/Dockerfile"
|
||||
schedule:
|
||||
interval: "daily"
|
|
@ -0,0 +1,61 @@
|
|||
name: Build container image, publish as GitHub-package
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
# Publish semver tags as releases.
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4.6.0
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
flavor: latest=true
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5.0.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
|
@ -1,7 +1,9 @@
|
|||
node_modules
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
static/local/custom.css
|
||||
build
|
||||
tmp
|
||||
*.ini
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
[submodule "static/js/mathjax"]
|
||||
path = static/js/mathjax
|
||||
url = https://github.com/mathjax/MathJax.git
|
|
@ -4,7 +4,7 @@ Welcome! We're glad you're interested in contributing to WriteFreely.
|
|||
|
||||
For **questions**, **help**, **feature requests**, and **general discussion**, please use [our forum](https://discuss.write.as).
|
||||
|
||||
For **bug reports**, please [open a GitHub issue](https://github.com/writeas/writefreely/issues/new). See our guide on [submitting bug reports](https://writefreely.org/contribute#bugs).
|
||||
For **bug reports**, please [open a GitHub issue](https://github.com/writefreely/writefreely/issues/new). See our guide on [submitting bug reports](https://writefreely.org/contribute#bugs).
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
@ -80,9 +80,9 @@ We highly value commit messages that follow established form within the project.
|
|||
|
||||
When in doubt, look to our existing git history for examples of good commit messages. Here are a few:
|
||||
|
||||
* [Rename Suspend status to Silence](https://github.com/writeas/writefreely/commit/7e014ca65958750ab703e317b1ce8cfc4aad2d6e)
|
||||
* [Show 404 when remote user not found](https://github.com/writeas/writefreely/commit/867eb53b3596bd7b3f2be3c53a3faf857f4cd36d)
|
||||
* [Fix post deletion on Pleroma](https://github.com/writeas/writefreely/commit/fe82cbb96e3d5c57cfde0db76c28c4ea6dabfe50)
|
||||
* [Rename Suspend status to Silence](https://github.com/writefreely/writefreely/commit/7e014ca65958750ab703e317b1ce8cfc4aad2d6e)
|
||||
* [Show 404 when remote user not found](https://github.com/writefreely/writefreely/commit/867eb53b3596bd7b3f2be3c53a3faf857f4cd36d)
|
||||
* [Fix post deletion on Pleroma](https://github.com/writefreely/writefreely/commit/fe82cbb96e3d5c57cfde0db76c28c4ea6dabfe50)
|
||||
|
||||
### Submitting pull requests
|
||||
|
||||
|
|
47
Dockerfile
47
Dockerfile
|
@ -1,30 +1,42 @@
|
|||
# Build image
|
||||
FROM golang:1.13-alpine as build
|
||||
# SHA256 of golang:1.21-alpine3.18 linux/amd64
|
||||
FROM golang@sha256:f475434ea2047a83e9ba02a1da8efc250fa6b2ed0e9e8e4eb8c5322ea6997795 as build
|
||||
|
||||
RUN apk add --update nodejs nodejs-npm make g++ git sqlite-dev
|
||||
RUN npm install -g less less-plugin-clean-css
|
||||
RUN go get -u github.com/jteeuwen/go-bindata/...
|
||||
LABEL org.opencontainers.image.source="https://github.com/writefreely/writefreely"
|
||||
LABEL org.opencontainers.image.description="WriteFreely is a clean, minimalist publishing platform made for writers. Start a blog, share knowledge within your organization, or build a community around the shared act of writing."
|
||||
|
||||
RUN apk -U upgrade \
|
||||
&& apk add --no-cache nodejs npm make g++ git \
|
||||
&& npm install -g less less-plugin-clean-css \
|
||||
&& mkdir -p /go/src/github.com/writefreely/writefreely
|
||||
|
||||
WORKDIR /go/src/github.com/writefreely/writefreely
|
||||
|
||||
RUN mkdir -p /go/src/github.com/writeas/writefreely
|
||||
WORKDIR /go/src/github.com/writeas/writefreely
|
||||
COPY . .
|
||||
|
||||
RUN cat ossl_legacy.cnf > /etc/ssl/openssl.cnf
|
||||
|
||||
ENV GO111MODULE=on
|
||||
ENV NODE_OPTIONS=--openssl-legacy-provider
|
||||
|
||||
RUN make build \
|
||||
&& make ui
|
||||
RUN mkdir /stage && \
|
||||
cp -R /go/bin \
|
||||
/go/src/github.com/writeas/writefreely/templates \
|
||||
/go/src/github.com/writeas/writefreely/static \
|
||||
/go/src/github.com/writeas/writefreely/pages \
|
||||
/go/src/github.com/writeas/writefreely/keys \
|
||||
/go/src/github.com/writeas/writefreely/cmd \
|
||||
&& make ui \
|
||||
&& mkdir /stage \
|
||||
&& cp -R /go/bin \
|
||||
/go/src/github.com/writefreely/writefreely/templates \
|
||||
/go/src/github.com/writefreely/writefreely/static \
|
||||
/go/src/github.com/writefreely/writefreely/pages \
|
||||
/go/src/github.com/writefreely/writefreely/keys \
|
||||
/go/src/github.com/writefreely/writefreely/cmd \
|
||||
/stage
|
||||
|
||||
# Final image
|
||||
FROM alpine:3.11
|
||||
# SHA256 of alpine:3.18.4 linux/amd64
|
||||
FROM alpine@sha256:48d9183eb12a05c99bcc0bf44a003607b8e941e1d4f41f9ad12bdcc4b5672f86
|
||||
|
||||
RUN apk -U upgrade \
|
||||
&& apk add --no-cache openssl ca-certificates
|
||||
|
||||
RUN apk add --no-cache openssl ca-certificates
|
||||
COPY --from=build --chown=daemon:daemon /stage /go
|
||||
|
||||
WORKDIR /go
|
||||
|
@ -33,3 +45,6 @@ EXPOSE 8080
|
|||
USER daemon
|
||||
|
||||
ENTRYPOINT ["cmd/writefreely/writefreely"]
|
||||
|
||||
HEALTHCHECK --start-period=5s --interval=15s --timeout=5s \
|
||||
CMD curl -fSs http://localhost:8080/ || exit 1
|
71
Makefile
71
Makefile
|
@ -1,5 +1,5 @@
|
|||
GITREV=`git describe | cut -c 2-`
|
||||
LDFLAGS=-ldflags="-X 'github.com/writeas/writefreely.softwareVer=$(GITREV)'"
|
||||
LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)'"
|
||||
|
||||
GOCMD=go
|
||||
GOINSTALL=$(GOCMD) install $(LDFLAGS)
|
||||
|
@ -14,50 +14,50 @@ TMPBIN=./tmp
|
|||
|
||||
all : build
|
||||
|
||||
ci: ci-assets deps
|
||||
ci: deps
|
||||
cd cmd/writefreely; $(GOBUILD) -v
|
||||
|
||||
build: assets deps
|
||||
cd cmd/writefreely; $(GOBUILD) -v -tags='sqlite'
|
||||
build: deps
|
||||
cd cmd/writefreely; $(GOBUILD) -v -tags='netgo sqlite'
|
||||
|
||||
build-no-sqlite: assets-no-sqlite deps-no-sqlite
|
||||
cd cmd/writefreely; $(GOBUILD) -v -o $(BINARY_NAME)
|
||||
build-no-sqlite: deps-no-sqlite
|
||||
cd cmd/writefreely; $(GOBUILD) -v -tags='netgo' -o $(BINARY_NAME)
|
||||
|
||||
build-linux: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-windows: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-darwin: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-arm6: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-arm7: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-arm64: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-docker :
|
||||
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
|
||||
|
@ -65,8 +65,8 @@ build-docker :
|
|||
test:
|
||||
$(GOTEST) -v ./...
|
||||
|
||||
run: dev-assets
|
||||
$(GOINSTALL) -tags='sqlite' ./...
|
||||
run:
|
||||
$(GOINSTALL) -tags='netgo sqlite' ./...
|
||||
$(BINARY_NAME) --debug
|
||||
|
||||
deps :
|
||||
|
@ -81,11 +81,12 @@ install : build
|
|||
cmd/writefreely/$(BINARY_NAME) --init-db
|
||||
cd less/; $(MAKE) install $(MFLAGS)
|
||||
|
||||
release : clean ui assets
|
||||
release : clean ui
|
||||
mkdir -p $(BUILDPATH)
|
||||
cp -r templates $(BUILDPATH)
|
||||
cp -r pages $(BUILDPATH)
|
||||
cp -r static $(BUILDPATH)
|
||||
rm -r $(BUILDPATH)/static/local
|
||||
scripts/invalidate-css.sh $(BUILDPATH)
|
||||
mkdir $(BUILDPATH)/keys
|
||||
$(MAKE) build-linux
|
||||
|
@ -105,13 +106,13 @@ release : clean ui assets
|
|||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-darwin
|
||||
mv build/$(BINARY_NAME)-darwin-10.6-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
mv build/$(BINARY_NAME)-darwin-10.12-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-windows
|
||||
mv build/$(BINARY_NAME)-windows-4.0-amd64.exe $(BUILDPATH)/$(BINARY_NAME).exe
|
||||
cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./$(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME).exe
|
||||
$(MAKE) build-docker
|
||||
$(MAKE) release-docker
|
||||
|
||||
|
@ -131,36 +132,14 @@ release-docker :
|
|||
|
||||
ui : force_look
|
||||
cd less/; $(MAKE) $(MFLAGS)
|
||||
|
||||
assets : generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
|
||||
|
||||
assets-no-sqlite: generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql
|
||||
|
||||
dev-assets : generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -debug -tags="!wflib" schema.sql sqlite.sql
|
||||
|
||||
lib-assets : generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -o bindata-lib.go -tags="wflib" schema.sql
|
||||
|
||||
generate :
|
||||
@hash go-bindata > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u github.com/jteeuwen/go-bindata/go-bindata; \
|
||||
fi
|
||||
cd prose/; $(MAKE) $(MFLAGS)
|
||||
|
||||
$(TMPBIN):
|
||||
mkdir -p $(TMPBIN)
|
||||
|
||||
$(TMPBIN)/go-bindata: deps $(TMPBIN)
|
||||
$(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata
|
||||
|
||||
$(TMPBIN)/xgo: deps $(TMPBIN)
|
||||
$(GOBUILD) -o $(TMPBIN)/xgo src.techknowlogick.com/xgo
|
||||
|
||||
ci-assets : $(TMPBIN)/go-bindata
|
||||
$(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
|
||||
|
||||
clean :
|
||||
-rm -rf build
|
||||
-rm -rf tmp
|
||||
|
|
83
README.md
83
README.md
|
@ -4,17 +4,17 @@
|
|||
</p>
|
||||
<hr />
|
||||
<p align="center">
|
||||
<a href="https://github.com/writeas/writefreely/releases/">
|
||||
<img src="https://img.shields.io/github/release/writeas/writefreely.svg" alt="Latest release" />
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/github.com/writeas/writefreely">
|
||||
<img src="https://goreportcard.com/badge/github.com/writeas/writefreely" alt="Go Report Card" />
|
||||
<a href="https://github.com/writefreely/writefreely/releases/">
|
||||
<img src="https://img.shields.io/github/release/writefreely/writefreely.svg" alt="Latest release" />
|
||||
</a>
|
||||
<a href="https://travis-ci.org/writeas/writefreely">
|
||||
<img src="https://travis-ci.org/writeas/writefreely.svg" alt="Build status" />
|
||||
<img src="https://travis-ci.org/writefreely/writefreely.svg" alt="Build status" />
|
||||
</a>
|
||||
<a href="https://github.com/writeas/writefreely/releases/latest">
|
||||
<img src="https://img.shields.io/github/downloads/writeas/writefreely/total.svg" />
|
||||
<a href="https://github.com/writefreely/writefreely/releases/latest">
|
||||
<img src="https://img.shields.io/github/downloads/writefreely/writefreely/total.svg" />
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/github.com/writefreely/writefreely">
|
||||
<img src="https://goreportcard.com/badge/github.com/writefreely/writefreely" alt="Go Report Card" />
|
||||
</a>
|
||||
<a href="https://hub.docker.com/r/writeas/writefreely/">
|
||||
<img src="https://img.shields.io/docker/pulls/writeas/writefreely.svg" />
|
||||
|
@ -22,73 +22,68 @@
|
|||
</p>
|
||||
|
||||
|
||||
WriteFreely is a beautifully pared-down blogging platform that's simple on the surface, yet powerful underneath.
|
||||
WriteFreely is a clean, minimalist publishing platform made for writers. Start a blog, share knowledge within your organization, or build a community around the shared act of writing.
|
||||
|
||||
It's designed to be flexible and share your writing widely, so it's built around plain text and can publish to the _fediverse_ via ActivityPub. It's easy to install and light enough to run on a Raspberry Pi.
|
||||
![](https://writefreely.org/img/screens/pencil-reader.png)
|
||||
|
||||
[Try the editor](https://write.as/new)
|
||||
[Try the writing experience](https://write.as/new)
|
||||
|
||||
[Find an instance](https://writefreely.org/instances)
|
||||
|
||||
## Features
|
||||
|
||||
* Start a blog for yourself, or host a community of writers
|
||||
* Form larger federated networks, and interact over modern protocols like ActivityPub
|
||||
* Write on a fast, dead-simple, and distraction-free editor
|
||||
* [Format text](https://howto.write.as/getting-started) with Markdown
|
||||
* [Organize posts](https://howto.write.as/organization) with hashtags
|
||||
* Create [static pages](https://howto.write.as/creating-a-static-page)
|
||||
* Publish drafts and let others proofread them by sharing a private link
|
||||
* Create multiple lightweight blogs under a single account
|
||||
* Export all data in plain text files
|
||||
* Read a stream of other posts in your writing community
|
||||
* Build more advanced apps and extensions with the [well-documented API](https://developers.write.as/docs/api/)
|
||||
* Designed around user privacy and consent
|
||||
### Made for writing
|
||||
|
||||
## Hosting
|
||||
Built on a plain, auto-saving editor, WriteFreely gives you a distraction-free writing environment. Once published, your words are front and center, and easy to read.
|
||||
|
||||
We offer two kinds of hosting services that make WriteFreely deployment painless: [Write.as Pro](https://write.as/pro) for individuals, and [Write.as for Teams](https://write.as/for/teams) for businesses. Besides saving you time and effort, both services directly fund WriteFreely development and ensure the long-term sustainability of our open source work.
|
||||
### A connected community
|
||||
|
||||
### [![Write.as Pro](https://writefreely.org/img/writeas-pro-readme.png)](https://write.as/pro)
|
||||
Start writing together, publicly or privately. Connect with other communities, whether running WriteFreely, [Plume](https://joinplu.me/), or other ActivityPub-powered software. And bring members on board from your existing platforms, thanks to our OAuth 2.0 support.
|
||||
|
||||
Start a personal blog on [Write.as](https://write.as), our flagship instance. Built to eliminate setup friction and preserve your privacy, Write.as helps you start a blog in seconds. It supports custom domains (with SSL) and multiple blogs / pen names per account. [Read more here](https://write.as/pro).
|
||||
### Intuitive organization
|
||||
|
||||
### [![Write.as for Teams](https://writefreely.org/img/writeas-for-teams-readme.png)](https://write.as/for/teams)
|
||||
Categorize articles [with hashtags](https://writefreely.org/docs/latest/writer/hashtags), and create static pages from normal posts by [_pinning_ them](https://writefreely.org/docs/latest/writer/static) to your blog. Create draft posts and publish to multiple blogs from one account.
|
||||
|
||||
[Write.as for Teams](https://write.as/for/teams) gives your organization, business, or [open source project](https://write.as/for/open-source) a clutter-free space to share updates or proposals and build your collective knowledge. We take care of hosting, upgrades, backups, and maintenance so your team can focus on writing.
|
||||
### International
|
||||
|
||||
Blog elements are localized in 20+ languages, and WriteFreely includes first-class support for non-Latin and right-to-left (RTL) script languages.
|
||||
|
||||
### Private by default
|
||||
|
||||
WriteFreely collects minimal data, and never publicizes more than a writer consents to. Writers can seamlessly create multiple blogs from a single account for different pen names or purposes without publicly revealing their association.
|
||||
|
||||
<h2><a href="https://write.as/writefreely"><img src="https://writefreely.org/img/writeas-readme.png" height="32px" alt="Write.as" /></a></h2>
|
||||
|
||||
The quickest way to deploy WriteFreely is with [Write.as](https://write.as/writefreely), a hosted service from the team behind WriteFreely. You'll get fully-managed installation, backup, upgrades, and maintenance — and directly fund our free software work ❤️
|
||||
|
||||
[**Learn more on Write.as**](https://write.as/writefreely).
|
||||
|
||||
## Quick start
|
||||
|
||||
WriteFreely has minimal requirements to get up and running — you only need to be able to run an executable.
|
||||
WriteFreely deploys as a static binary on any platform and architecture that Go supports. Just use our built-in SQLite support, or add a MySQL database, and you'll be up and running!
|
||||
|
||||
> **Note** this is currently alpha software. We're quickly moving out of this v0.x stage, but while we're in it, there are no guarantees that this is ready for production use.
|
||||
For common platforms, start with our [pre-built binaries](https://github.com/writefreely/writefreely/releases/) and head over to our [installation guide](https://writefreely.org/start) to get started.
|
||||
|
||||
To get started, head over to our [Getting Started guide](https://writefreely.org/start). For production use, jump to the [Running in Production](https://writefreely.org/start#production) section.
|
||||
### Packages
|
||||
|
||||
## Packages
|
||||
|
||||
WriteFreely is available in these package repositories:
|
||||
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
|
||||
|
||||
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
|
||||
|
||||
## Documentation
|
||||
|
||||
Read our full [documentation on WriteFreely.org](https://writefreely.org/docs). Help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo.
|
||||
Read our full [documentation on WriteFreely.org](https://writefreely.org/docs) —️ and help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo.
|
||||
|
||||
## Development
|
||||
|
||||
Ready to hack on your site? Get started with our [developer guide](https://writefreely.org/docs/latest/developer/setup).
|
||||
|
||||
## Docker
|
||||
|
||||
Read about using Docker in the [documentation](https://writefreely.org/docs/latest/admin/docker).
|
||||
Start hacking on WriteFreely with our [developer setup guide](https://writefreely.org/docs/latest/developer/setup). For Docker support, see our [Docker guide](https://writefreely.org/docs/latest/admin/docker).
|
||||
|
||||
## Contributing
|
||||
|
||||
We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writeas/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or [documentation](https://github.com/writefreely/documentation) improvements.
|
||||
We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writefreely/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writefreely/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or [documentation](https://github.com/writefreely/documentation) improvements.
|
||||
|
||||
Before contributing anything, please read our [Contributing Guide](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely). It describes the correct channels for submitting contributions and any potential requirements.
|
||||
Before contributing anything, please read our [Contributing Guide](https://github.com/writefreely/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely). It describes the correct channels for submitting contributions and any potential requirements.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the AGPL.
|
||||
Copyright © 2018-2022 [Musing Studio LLC](https://musing.studio) and contributing authors. Licensed under the [AGPL](https://github.com/writefreely/writefreely/blob/develop/LICENSE).
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability, send an email to security@writefreely.org.
|
526
account.go
526
account.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -13,13 +13,17 @@ package writefreely
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/mailgun/mailgun-go"
|
||||
"github.com/writefreely/writefreely/spam"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/guregu/null/zero"
|
||||
|
@ -27,10 +31,9 @@ import (
|
|||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/data"
|
||||
"github.com/writeas/web-core/log"
|
||||
|
||||
"github.com/writeas/writefreely/author"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"github.com/writefreely/writefreely/author"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
)
|
||||
|
||||
type (
|
||||
|
@ -49,6 +52,7 @@ type (
|
|||
Separator template.HTML
|
||||
IsAdmin bool
|
||||
CanInvite bool
|
||||
CollAlias string
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -86,6 +90,11 @@ func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
|
||||
func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
|
||||
if app.cfg.App.DisablePasswordAuth {
|
||||
err := ErrDisabledPasswordAuth
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqJSON := IsJSON(r)
|
||||
|
||||
// Get params
|
||||
|
@ -145,8 +154,6 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
}
|
||||
|
||||
// Handle empty optional params
|
||||
// TODO: remove this var
|
||||
createdWithPass := true
|
||||
hashedPass, err := auth.HashPass([]byte(signup.Pass))
|
||||
if err != nil {
|
||||
return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
|
||||
|
@ -156,23 +163,19 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
u := &User{
|
||||
Username: signup.Alias,
|
||||
HashedPass: hashedPass,
|
||||
HasPass: createdWithPass,
|
||||
HasPass: true,
|
||||
Email: prepareUserEmail(signup.Email, app.keys.EmailKey),
|
||||
Created: time.Now().Truncate(time.Second).UTC(),
|
||||
}
|
||||
|
||||
// Create actual user
|
||||
if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
|
||||
if err := app.db.CreateUser(app.cfg, u, desiredUsername, signup.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log invite if needed
|
||||
if signup.InviteCode != "" {
|
||||
cu, err := app.db.GetUserForAuth(signup.Alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID)
|
||||
err = app.db.CreateInvitedUser(signup.InviteCode, u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -186,20 +189,35 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
resUser := &AuthUser{
|
||||
User: u,
|
||||
}
|
||||
if !createdWithPass {
|
||||
resUser.Password = signup.Pass
|
||||
}
|
||||
title := signup.Alias
|
||||
if signup.Normalize {
|
||||
title = desiredUsername
|
||||
}
|
||||
resUser.Collections = &[]Collection{
|
||||
{
|
||||
Alias: signup.Alias,
|
||||
Title: title,
|
||||
Alias: signup.Alias,
|
||||
Title: title,
|
||||
Description: signup.Description,
|
||||
},
|
||||
}
|
||||
|
||||
var coll *Collection
|
||||
if signup.Monetization != "" {
|
||||
if coll == nil {
|
||||
coll, err = app.db.GetCollection(signup.Alias)
|
||||
if err != nil {
|
||||
log.Error("Unable to get new collection '%s' for monetization on signup: %v", signup.Alias, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
err = app.db.SetCollectionAttribute(coll.ID, "monetization_pointer", signup.Monetization)
|
||||
if err != nil {
|
||||
log.Error("Unable to add monetization on signup: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
coll.Monetization = signup.Monetization
|
||||
}
|
||||
|
||||
var token string
|
||||
if reqJSON && !signup.Web {
|
||||
token, err = app.db.GetAccessToken(u.ID)
|
||||
|
@ -303,24 +321,20 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := &struct {
|
||||
page.StaticPage
|
||||
To string
|
||||
Message template.HTML
|
||||
Flashes []template.HTML
|
||||
LoginUsername string
|
||||
OauthSlack bool
|
||||
OauthWriteAs bool
|
||||
OauthGitlab bool
|
||||
GitlabDisplayName string
|
||||
*OAuthButtons
|
||||
To string
|
||||
Message template.HTML
|
||||
Flashes []template.HTML
|
||||
EmailEnabled bool
|
||||
LoginUsername string
|
||||
}{
|
||||
pageForReq(app, r),
|
||||
r.FormValue("to"),
|
||||
template.HTML(""),
|
||||
[]template.HTML{},
|
||||
getTempInfo(app, "login-user", r, w),
|
||||
app.Config().SlackOauth.ClientID != "",
|
||||
app.Config().WriteAsOauth.ClientID != "",
|
||||
app.Config().GitlabOauth.ClientID != "",
|
||||
config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName),
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.Config()),
|
||||
To: r.FormValue("to"),
|
||||
Message: template.HTML(""),
|
||||
Flashes: []template.HTML{},
|
||||
EmailEnabled: app.cfg.Email.Enabled(),
|
||||
LoginUsername: getTempInfo(app, "login-user", r, w),
|
||||
}
|
||||
|
||||
if earlyError != "" {
|
||||
|
@ -395,6 +409,11 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
var err error
|
||||
var signin userCredentials
|
||||
|
||||
if app.cfg.App.DisablePasswordAuth {
|
||||
err := ErrDisabledPasswordAuth
|
||||
return err
|
||||
}
|
||||
|
||||
// Log in with one-time token if one is given
|
||||
if oneTimeToken != "" {
|
||||
log.Info("Login: Logging user in via token.")
|
||||
|
@ -489,10 +508,13 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
// User has no email set, so check if they haven't added a password, either,
|
||||
// so we can return a more helpful error message.
|
||||
if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass {
|
||||
log.Info("Tried logging in to %s, but no password or email.", signin.Alias)
|
||||
log.Info("Tried logging into %s, but no password or email.", signin.Alias)
|
||||
return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
|
||||
}
|
||||
}
|
||||
if len(u.HashedPass) == 0 {
|
||||
return impart.HTTPError{http.StatusUnauthorized, "This user never set a password. Perhaps try logging in via OAuth?"}
|
||||
}
|
||||
if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) {
|
||||
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
|
||||
}
|
||||
|
@ -559,7 +581,7 @@ func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser
|
|||
}
|
||||
passIsSet, err := app.db.IsUserPassSet(u.ID)
|
||||
if err != nil {
|
||||
// TODO: correct error meesage
|
||||
// TODO: correct error message
|
||||
log.Error("Login: Unable to get user collections: %v", err)
|
||||
}
|
||||
|
||||
|
@ -692,6 +714,22 @@ func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) e
|
|||
return ErrBadRequestedType
|
||||
}
|
||||
|
||||
isAnonPosts := r.FormValue("anonymous") == "1"
|
||||
if isAnonPosts {
|
||||
pageStr := r.FormValue("page")
|
||||
pg, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
log.Error("Error parsing page parameter '%s': %s", pageStr, err)
|
||||
pg = 1
|
||||
}
|
||||
|
||||
p, err := app.db.GetAnonymousPosts(u, pg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return impart.WriteSuccess(w, p, http.StatusOK)
|
||||
}
|
||||
|
||||
var err error
|
||||
p := GetPostsCache(u.ID)
|
||||
if p == nil {
|
||||
|
@ -732,7 +770,7 @@ func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
|
||||
func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p, err := app.db.GetAnonymousPosts(u)
|
||||
p, err := app.db.GetAnonymousPosts(u, 1)
|
||||
if err != nil {
|
||||
log.Error("unable to fetch anon posts: %v", err)
|
||||
}
|
||||
|
@ -753,6 +791,9 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view articles: %v", err)
|
||||
}
|
||||
d := struct {
|
||||
|
@ -788,7 +829,10 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request)
|
|||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
log.Error("view collections %v", err)
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view collections: %v", err)
|
||||
return fmt.Errorf("view collections: %v", err)
|
||||
}
|
||||
d := struct {
|
||||
|
@ -824,6 +868,9 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
|||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view edit collection %v", err)
|
||||
return fmt.Errorf("view edit collection: %v", err)
|
||||
}
|
||||
|
@ -832,10 +879,18 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
|||
*UserPage
|
||||
*Collection
|
||||
Silenced bool
|
||||
|
||||
config.EmailCfg
|
||||
LetterReplyTo string
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
||||
Collection: c,
|
||||
Silenced: silenced,
|
||||
EmailCfg: app.cfg.Email,
|
||||
}
|
||||
obj.UserPage.CollAlias = c.Alias
|
||||
if obj.EmailCfg.Enabled() {
|
||||
obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo)
|
||||
}
|
||||
|
||||
showUserPage(w, "collection", obj)
|
||||
|
@ -983,9 +1038,10 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
if c.OwnerID != u.ID {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
}
|
||||
|
||||
topPosts, err := app.db.GetTopPosts(u, alias)
|
||||
topPosts, err := app.db.GetTopPosts(u, alias, c.hostName)
|
||||
if err != nil {
|
||||
log.Error("Unable to get top posts: %v", err)
|
||||
return err
|
||||
|
@ -999,23 +1055,30 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view stats: %v", err)
|
||||
return err
|
||||
}
|
||||
obj := struct {
|
||||
*UserPage
|
||||
VisitsBlog string
|
||||
Collection *Collection
|
||||
TopPosts *[]PublicPost
|
||||
APFollowers int
|
||||
Silenced bool
|
||||
VisitsBlog string
|
||||
Collection *Collection
|
||||
TopPosts *[]PublicPost
|
||||
APFollowers int
|
||||
EmailEnabled bool
|
||||
EmailSubscribers int
|
||||
Silenced bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
||||
VisitsBlog: alias,
|
||||
Collection: c,
|
||||
TopPosts: topPosts,
|
||||
Silenced: silenced,
|
||||
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
||||
VisitsBlog: alias,
|
||||
Collection: c,
|
||||
TopPosts: topPosts,
|
||||
EmailEnabled: app.cfg.Email.Enabled(),
|
||||
Silenced: silenced,
|
||||
}
|
||||
obj.UserPage.CollAlias = c.Alias
|
||||
if app.cfg.App.Federation {
|
||||
folls, err := app.db.GetAPFollowers(c)
|
||||
if err != nil {
|
||||
|
@ -1023,14 +1086,79 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
}
|
||||
obj.APFollowers = len(*folls)
|
||||
}
|
||||
if obj.EmailEnabled {
|
||||
subs, err := app.db.GetEmailSubscribers(c.ID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj.EmailSubscribers = len(subs)
|
||||
}
|
||||
|
||||
showUserPage(w, "stats", obj)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewSubscribers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
c, err := app.db.GetCollection(vars["collection"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filter := r.FormValue("filter")
|
||||
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
obj := struct {
|
||||
*UserPage
|
||||
Collection CollectionNav
|
||||
EmailSubs []*EmailSubscriber
|
||||
Followers *[]RemoteUser
|
||||
Silenced bool
|
||||
|
||||
Filter string
|
||||
FederationEnabled bool
|
||||
CanEmailSub bool
|
||||
CanAddSubs bool
|
||||
EmailSubsEnabled bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, c.DisplayTitle()+" Subscribers", flashes),
|
||||
Collection: CollectionNav{
|
||||
Collection: c,
|
||||
Path: r.URL.Path,
|
||||
SingleUser: app.cfg.App.SingleUser,
|
||||
},
|
||||
Silenced: u.IsSilenced(),
|
||||
Filter: filter,
|
||||
FederationEnabled: app.cfg.App.Federation,
|
||||
CanEmailSub: app.cfg.Email.Enabled(),
|
||||
EmailSubsEnabled: c.EmailSubsEnabled(),
|
||||
}
|
||||
|
||||
obj.Followers, err = app.db.GetAPFollowers(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
obj.EmailSubs, err = app.db.GetEmailSubscribers(c.ID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if obj.Filter == "" {
|
||||
// Set permission to add email subscribers
|
||||
//obj.CanAddSubs = app.db.GetUserAttribute(c.OwnerID, userAttrCanAddEmailSubs) == "1"
|
||||
}
|
||||
|
||||
showUserPage(w, "subscribers", obj)
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
fullUser, err := app.db.GetUserByID(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("Unable to get user for settings: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
|
||||
}
|
||||
|
@ -1046,13 +1174,15 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
enableOauthSlack := app.Config().SlackOauth.ClientID != ""
|
||||
enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != ""
|
||||
enableOauthGitLab := app.Config().GitlabOauth.ClientID != ""
|
||||
enableOauthGeneric := app.Config().GenericOauth.ClientID != ""
|
||||
enableOauthGitea := app.Config().GiteaOauth.ClientID != ""
|
||||
|
||||
oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
log.Error("Unable to get oauth accounts for settings: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
|
||||
}
|
||||
for _, oauthAccount := range oauthAccounts {
|
||||
for idx, oauthAccount := range oauthAccounts {
|
||||
switch oauthAccount.Provider {
|
||||
case "slack":
|
||||
enableOauthSlack = false
|
||||
|
@ -1060,41 +1190,262 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
enableOauthWriteAs = false
|
||||
case "gitlab":
|
||||
enableOauthGitLab = false
|
||||
case "generic":
|
||||
oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName
|
||||
oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect
|
||||
enableOauthGeneric = false
|
||||
case "gitea":
|
||||
enableOauthGitea = false
|
||||
}
|
||||
}
|
||||
|
||||
displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || len(oauthAccounts) > 0
|
||||
displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0
|
||||
|
||||
obj := struct {
|
||||
*UserPage
|
||||
Email string
|
||||
HasPass bool
|
||||
IsLogOut bool
|
||||
Silenced bool
|
||||
OauthSection bool
|
||||
OauthAccounts []oauthAccountInfo
|
||||
OauthSlack bool
|
||||
OauthWriteAs bool
|
||||
OauthGitLab bool
|
||||
GitLabDisplayName string
|
||||
Email string
|
||||
HasPass bool
|
||||
IsLogOut bool
|
||||
Silenced bool
|
||||
CSRFField template.HTML
|
||||
OauthSection bool
|
||||
OauthAccounts []oauthAccountInfo
|
||||
OauthSlack bool
|
||||
OauthWriteAs bool
|
||||
OauthGitLab bool
|
||||
GitLabDisplayName string
|
||||
OauthGeneric bool
|
||||
OauthGenericDisplayName string
|
||||
OauthGitea bool
|
||||
GiteaDisplayName string
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
|
||||
Email: fullUser.EmailClear(app.keys),
|
||||
HasPass: passIsSet,
|
||||
IsLogOut: r.FormValue("logout") == "1",
|
||||
Silenced: fullUser.IsSilenced(),
|
||||
OauthSection: displayOauthSection,
|
||||
OauthAccounts: oauthAccounts,
|
||||
OauthSlack: enableOauthSlack,
|
||||
OauthWriteAs: enableOauthWriteAs,
|
||||
OauthGitLab: enableOauthGitLab,
|
||||
GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName),
|
||||
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
|
||||
Email: fullUser.EmailClear(app.keys),
|
||||
HasPass: passIsSet,
|
||||
IsLogOut: r.FormValue("logout") == "1",
|
||||
Silenced: fullUser.IsSilenced(),
|
||||
CSRFField: csrf.TemplateField(r),
|
||||
OauthSection: displayOauthSection,
|
||||
OauthAccounts: oauthAccounts,
|
||||
OauthSlack: enableOauthSlack,
|
||||
OauthWriteAs: enableOauthWriteAs,
|
||||
OauthGitLab: enableOauthGitLab,
|
||||
GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName),
|
||||
OauthGeneric: enableOauthGeneric,
|
||||
OauthGenericDisplayName: config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName),
|
||||
OauthGitea: enableOauthGitea,
|
||||
GiteaDisplayName: config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName),
|
||||
}
|
||||
|
||||
showUserPage(w, "settings", obj)
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewResetPassword(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
token := r.FormValue("t")
|
||||
resetting := false
|
||||
var userID int64 = 0
|
||||
if token != "" {
|
||||
// Show new password page
|
||||
userID = app.db.GetUserFromPasswordReset(token)
|
||||
if userID == 0 {
|
||||
return impart.HTTPError{http.StatusNotFound, ""}
|
||||
}
|
||||
resetting = true
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
newPass := r.FormValue("new-pass")
|
||||
if newPass == "" {
|
||||
// Send password reset email
|
||||
return handleResetPasswordInit(app, w, r)
|
||||
}
|
||||
|
||||
// Do actual password reset
|
||||
// Assumes token has been validated above
|
||||
err := doAutomatedPasswordChange(app, userID, newPass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = app.db.ConsumePasswordResetToken(token)
|
||||
if err != nil {
|
||||
log.Error("Couldn't consume token %s for user %d!!! %s", token, userID, err)
|
||||
}
|
||||
addSessionFlash(app, w, r, "Your password was reset. Now you can log in below.", nil)
|
||||
return impart.HTTPError{http.StatusFound, "/login"}
|
||||
}
|
||||
|
||||
f, _ := getSessionFlashes(app, w, r, nil)
|
||||
|
||||
// Show reset password page
|
||||
d := struct {
|
||||
page.StaticPage
|
||||
Flashes []string
|
||||
EmailEnabled bool
|
||||
CSRFField template.HTML
|
||||
Token string
|
||||
IsResetting bool
|
||||
IsSent bool
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
Flashes: f,
|
||||
EmailEnabled: app.cfg.Email.Enabled(),
|
||||
CSRFField: csrf.TemplateField(r),
|
||||
Token: token,
|
||||
IsResetting: resetting,
|
||||
IsSent: r.FormValue("sent") == "1",
|
||||
}
|
||||
err := pages["reset.tmpl"].ExecuteTemplate(w, "base", d)
|
||||
if err != nil {
|
||||
log.Error("Unable to render password reset page: %v", err)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func doAutomatedPasswordChange(app *App, userID int64, newPass string) error {
|
||||
// Do password reset
|
||||
hashedPass, err := auth.HashPass([]byte(newPass))
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
|
||||
}
|
||||
|
||||
// Do update
|
||||
err = app.db.ChangePassphrase(userID, true, "", hashedPass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
returnLoc := impart.HTTPError{http.StatusFound, "/reset"}
|
||||
|
||||
if !app.cfg.Email.Enabled() {
|
||||
// Email isn't configured, so there's nothing to do; send back to the reset form, where they'll get an explanation
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
ip := spam.GetIP(r)
|
||||
alias := r.FormValue("alias")
|
||||
|
||||
u, err := app.db.GetUserForAuth(alias)
|
||||
if err != nil {
|
||||
if strings.IndexAny(alias, "@") > 0 {
|
||||
addSessionFlash(app, w, r, ErrUserNotFoundEmail.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
addSessionFlash(app, w, r, ErrUserNotFound.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
if u.IsAdmin() {
|
||||
// Prevent any reset emails on admin accounts
|
||||
log.Error("Admin reset attempt", `Someone just tried to reset the password for an admin (ID %d - %s). IP address: %s`, u.ID, u.Username, ip)
|
||||
return returnLoc
|
||||
}
|
||||
if u.Email.String == "" {
|
||||
err := impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Please contact us (" + app.cfg.App.Host + "/contact) to reset your password."}
|
||||
addSessionFlash(app, w, r, err.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
if isSet, _ := app.db.IsUserPassSet(u.ID); !isSet {
|
||||
err = loginViaEmail(app, u.Username, "/me/settings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addSessionFlash(app, w, r, "We've emailed you a link to log in with.", nil)
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
token, err := app.db.CreatePasswordResetToken(u.ID)
|
||||
if err != nil {
|
||||
log.Error("Error resetting password: %s", err)
|
||||
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
err = emailPasswordReset(app, u.EmailClear(app.keys), token)
|
||||
if err != nil {
|
||||
log.Error("Error emailing password reset: %s", err)
|
||||
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
addSessionFlash(app, w, r, "We sent an email to the address associated with this account.", nil)
|
||||
returnLoc.Message += "?sent=1"
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
func emailPasswordReset(app *App, toEmail, token string) error {
|
||||
// Send email
|
||||
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
|
||||
footerPara := "Didn't request this password reset? Your account is still safe, and you can safely ignore this email."
|
||||
|
||||
plainMsg := fmt.Sprintf("We received a request to reset your password on %s. Please click the following link to continue (or copy and paste it into your browser): %s/reset?t=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara)
|
||||
m := mailgun.NewMessage(app.cfg.App.SiteName+" <noreply-password@"+app.cfg.Email.Domain+">", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||
m.AddTag("Password Reset")
|
||||
m.SetHtml(fmt.Sprintf(`<html>
|
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
|
||||
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
|
||||
<p>We received a request to reset your password on %s. Please click the following link to continue:</p>
|
||||
<p style="font-size:1.2em;margin-bottom:1.5em;"><a href="%s/reset?t=%s">Reset your password</a></p>
|
||||
<p style="font-size: 0.86em;margin:1em auto">%s</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara))
|
||||
_, _, err := gun.Send(m)
|
||||
return err
|
||||
}
|
||||
|
||||
func loginViaEmail(app *App, alias, redirectTo string) error {
|
||||
if !app.cfg.Email.Enabled() {
|
||||
return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server")
|
||||
}
|
||||
|
||||
// Make sure user has added an email
|
||||
// TODO: create a new func to just get user's email; "ForAuth" doesn't match here
|
||||
u, _ := app.db.GetUserForAuth(alias)
|
||||
if u == nil {
|
||||
if strings.IndexAny(alias, "@") > 0 {
|
||||
return ErrUserNotFoundEmail
|
||||
}
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if u.Email.String == "" {
|
||||
return impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Log in with password, instead."}
|
||||
}
|
||||
|
||||
// Generate one-time login token
|
||||
t, err := app.db.GetTemporaryOneTimeAccessToken(u.ID, 60*15, true)
|
||||
if err != nil {
|
||||
log.Error("Unable to generate token for email login: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to generate token."}
|
||||
}
|
||||
|
||||
// Send email
|
||||
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
|
||||
toEmail := u.EmailClear(app.keys)
|
||||
footerPara := "This link will only work once and expires in 15 minutes. Didn't ask us to log in? You can safely ignore this email."
|
||||
|
||||
plainMsg := fmt.Sprintf("Log in to %s here: %s/login?to=%s&with=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, footerPara)
|
||||
m := mailgun.NewMessage(app.cfg.App.SiteName+" <noreply-login@"+app.cfg.Email.Domain+">", "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||
m.AddTag("Email Login")
|
||||
|
||||
m.SetHtml(fmt.Sprintf(`<html>
|
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
|
||||
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
|
||||
<p style="font-size:1.2em;margin-bottom:1.5em;text-align:center"><a href="%s/login?to=%s&with=%s">Log in to %s here</a>.</p>
|
||||
<p style="font-size: 0.86em;color:#666;text-align:center;max-width:35em;margin:1em auto">%s</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, app.cfg.App.SiteName, footerPara))
|
||||
_, _, err = gun.Send(m)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error {
|
||||
session, err := app.sessionStore.Get(r, "t")
|
||||
if err != nil {
|
||||
|
@ -1133,6 +1484,32 @@ func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) s
|
|||
return s
|
||||
}
|
||||
|
||||
func handleUserDelete(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
if !app.cfg.App.OpenDeletion {
|
||||
return impart.HTTPError{http.StatusForbidden, "Open account deletion is disabled on this instance."}
|
||||
}
|
||||
|
||||
confirmUsername := r.PostFormValue("confirm-username")
|
||||
if u.Username != confirmUsername {
|
||||
return impart.HTTPError{http.StatusBadRequest, "Confirmation username must match your username exactly."}
|
||||
}
|
||||
|
||||
// Check for account deletion safeguards in place
|
||||
if u.IsAdmin() {
|
||||
return impart.HTTPError{http.StatusForbidden, "Cannot delete admin."}
|
||||
}
|
||||
|
||||
err := app.db.DeleteAccount(u.ID)
|
||||
if err != nil {
|
||||
log.Error("user delete account: %v", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete account: %v", err)}
|
||||
}
|
||||
|
||||
// FIXME: This doesn't ever appear to the user, as (I believe) the value is erased when the session cookie is reset
|
||||
_ = addSessionFlash(app, w, r, "Thanks for writing with us! You account was deleted successfully.", nil)
|
||||
return impart.HTTPError{http.StatusFound, "/me/logout"}
|
||||
}
|
||||
|
||||
func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
provider := r.FormValue("provider")
|
||||
clientID := r.FormValue("client_id")
|
||||
|
@ -1154,6 +1531,7 @@ func prepareUserEmail(input string, emailKey []byte) zero.String {
|
|||
log.Error("Unable to encrypt email: %s\n", err)
|
||||
} else {
|
||||
email.String = string(encEmail)
|
||||
|
||||
}
|
||||
}
|
||||
return email
|
||||
|
|
|
@ -5,7 +5,6 @@ import (
|
|||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
@ -100,7 +99,7 @@ func handleImport(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
}
|
||||
defer file.Close()
|
||||
|
||||
tempFile, err := ioutil.TempFile("", "post-upload-*.txt")
|
||||
tempFile, err := os.CreateTemp("", "post-upload-*.txt")
|
||||
if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
|
||||
log.Error("import file: create temp file %s: %v", formFile.Filename, err)
|
||||
|
|
194
activitypub.go
194
activitypub.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -17,21 +17,25 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/activity/streams"
|
||||
"github.com/writeas/activityserve"
|
||||
"github.com/writeas/httpsig"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/web-core/activitypub"
|
||||
"github.com/writeas/web-core/activitystreams"
|
||||
"github.com/writeas/web-core/id"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/web-core/silobridge"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -41,12 +45,40 @@ const (
|
|||
apCacheTime = time.Minute
|
||||
)
|
||||
|
||||
var instanceColl *Collection
|
||||
|
||||
func initActivityPub(app *App) {
|
||||
ur, _ := url.Parse(app.cfg.App.Host)
|
||||
instanceColl = &Collection{
|
||||
ID: 0,
|
||||
Alias: ur.Host,
|
||||
Title: ur.Host,
|
||||
db: app.db,
|
||||
hostName: app.cfg.App.Host,
|
||||
}
|
||||
}
|
||||
|
||||
type RemoteUser struct {
|
||||
ID int64
|
||||
ActorID string
|
||||
Inbox string
|
||||
SharedInbox string
|
||||
URL string
|
||||
Handle string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
func (ru *RemoteUser) CreatedFriendly() string {
|
||||
return ru.Created.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
func (ru *RemoteUser) EstimatedHandle() string {
|
||||
if ru.Handle != "" {
|
||||
return ru.Handle
|
||||
}
|
||||
username := filepath.Base(ru.ActorID)
|
||||
host, _ := url.Parse(ru.ActorID)
|
||||
return username + "@" + host.Host
|
||||
}
|
||||
|
||||
func (ru *RemoteUser) AsPerson() *activitystreams.Person {
|
||||
|
@ -76,12 +108,17 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re
|
|||
|
||||
vars := mux.Vars(r)
|
||||
alias := vars["alias"]
|
||||
if alias == "" {
|
||||
alias = filepath.Base(r.RequestURI)
|
||||
}
|
||||
|
||||
// TODO: enforce visibility
|
||||
// Get base Collection data
|
||||
var c *Collection
|
||||
var err error
|
||||
if app.cfg.App.SingleUser {
|
||||
if alias == r.Host {
|
||||
c = instanceColl
|
||||
} else if app.cfg.App.SingleUser {
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
} else {
|
||||
c, err = app.db.GetCollection(alias)
|
||||
|
@ -89,16 +126,19 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection activities: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
if !c.IsInstanceColl() {
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection activities: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
}
|
||||
|
||||
p := c.PersonObject()
|
||||
|
||||
setCacheControl(w, apCacheTime)
|
||||
|
@ -160,6 +200,7 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
|
|||
pp.Collection = res
|
||||
o := pp.ActivityObject(app)
|
||||
a := activitystreams.NewCreateActivity(o)
|
||||
a.Context = nil
|
||||
ocp.OrderedItems = append(ocp.OrderedItems, *a)
|
||||
}
|
||||
|
||||
|
@ -330,7 +371,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
if followID == nil {
|
||||
log.Error("Didn't resolve follow ID")
|
||||
} else {
|
||||
aID := c.FederatedAccount() + "#accept-" + store.GenerateFriendlyRandomString(20)
|
||||
aID := c.FederatedAccount() + "#accept-" + id.GenerateFriendlyRandomString(20)
|
||||
acceptID, err := url.Parse(aID)
|
||||
if err != nil {
|
||||
log.Error("Couldn't parse generated Accept URL '%s': %v", aID, err)
|
||||
|
@ -396,7 +437,9 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
|
||||
go func() {
|
||||
if to == nil {
|
||||
log.Error("No to! %v", err)
|
||||
if debugging {
|
||||
log.Error("No `to` value!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -427,7 +470,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
followerID = remoteUser.ID
|
||||
} else {
|
||||
// Add follower locally, since it wasn't found before
|
||||
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox)
|
||||
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL)
|
||||
if err != nil {
|
||||
// if duplicate key, res will be nil and panic on
|
||||
// res.LastInsertId below
|
||||
|
@ -491,7 +534,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
|
|||
|
||||
r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b))
|
||||
r.Header.Add("Content-Type", "application/activity+json")
|
||||
r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")")
|
||||
r.Header.Set("User-Agent", ServerUserAgent(hostName))
|
||||
h := sha256.New()
|
||||
h.Write(b)
|
||||
r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil)))
|
||||
|
@ -524,7 +567,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
|
|||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -541,7 +584,23 @@ func resolveIRI(hostName, url string) ([]byte, error) {
|
|||
|
||||
r, _ := http.NewRequest("GET", url, nil)
|
||||
r.Header.Add("Accept", "application/activity+json")
|
||||
r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")")
|
||||
r.Header.Set("User-Agent", ServerUserAgent(hostName))
|
||||
|
||||
p := instanceColl.PersonObject()
|
||||
h := sha256.New()
|
||||
h.Write([]byte{})
|
||||
r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil)))
|
||||
|
||||
// Sign using the 'Signature' header
|
||||
privKey, err := activitypub.DecodePrivateKey(p.GetPrivKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signer := httpsig.NewSigner(p.PublicKey.ID, privKey, httpsig.RSASHA256, []string{"(request-target)", "date", "host", "digest"})
|
||||
err = signer.SignSigHeader(r)
|
||||
if err != nil {
|
||||
log.Error("Can't sign: %v", err)
|
||||
}
|
||||
|
||||
if debugging {
|
||||
dump, err := httputil.DumpRequestOut(r, true)
|
||||
|
@ -560,7 +619,7 @@ func resolveIRI(hostName, url string) ([]byte, error) {
|
|||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -603,10 +662,7 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
|||
|
||||
for si, instFolls := range inboxes {
|
||||
na.CC = []string{}
|
||||
for _, f := range instFolls {
|
||||
na.CC = append(na.CC, f)
|
||||
}
|
||||
|
||||
na.CC = append(na.CC, instFolls...)
|
||||
da := activitystreams.NewDeleteActivity(na)
|
||||
// Make the ID unique to ensure it works in Pleroma
|
||||
// See: https://git.pleroma.social/pleroma/pleroma/issues/1481
|
||||
|
@ -621,6 +677,16 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
|||
}
|
||||
|
||||
func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
||||
// If app is private, do not federate
|
||||
if app.cfg.App.Private {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not federate posts from private or protected blogs
|
||||
if p.Collection.Visibility == CollPrivate || p.Collection.Visibility == CollProtected {
|
||||
return nil
|
||||
}
|
||||
|
||||
if debugging {
|
||||
if isUpdate {
|
||||
log.Info("Federating updated post!")
|
||||
|
@ -628,6 +694,7 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
log.Info("Federating new post!")
|
||||
}
|
||||
}
|
||||
|
||||
actor := p.Collection.PersonObject(collID)
|
||||
na := p.ActivityObject(app)
|
||||
|
||||
|
@ -661,12 +728,11 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
// add all followers from that instance
|
||||
// to the CC field
|
||||
na.CC = []string{}
|
||||
for _, f := range instFolls {
|
||||
na.CC = append(na.CC, f)
|
||||
}
|
||||
na.CC = append(na.CC, instFolls...)
|
||||
// create a new "Create" activity
|
||||
// with our article as object
|
||||
if isUpdate {
|
||||
na.Updated = &p.Updated
|
||||
activity = activitystreams.NewUpdateActivity(na)
|
||||
} else {
|
||||
activity = activitystreams.NewCreateActivity(na)
|
||||
|
@ -696,6 +762,10 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
// I don't believe we'd ever have too many mentions in a single post that this
|
||||
// could become a burden.
|
||||
remoteUser, err := getRemoteUser(app, tag.HRef)
|
||||
if err != nil {
|
||||
log.Error("Unable to find remote user %s. Skipping: %v", tag.HRef, err)
|
||||
continue
|
||||
}
|
||||
err = makeActivityPost(app.cfg.App.Host, actor, remoteUser.Inbox, activity)
|
||||
if err != nil {
|
||||
log.Error("Couldn't post! %v", err)
|
||||
|
@ -708,8 +778,8 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
|
||||
func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
||||
u := RemoteUser{ActorID: actorID}
|
||||
var handle sql.NullString
|
||||
err := app.db.QueryRow("SELECT id, inbox, shared_inbox, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &handle)
|
||||
var urlVal, handle sql.NullString
|
||||
err := app.db.QueryRow("SELECT id, inbox, shared_inbox, url, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &urlVal, &handle)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."}
|
||||
|
@ -718,6 +788,7 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
u.URL = urlVal.String
|
||||
u.Handle = handle.String
|
||||
|
||||
return &u, nil
|
||||
|
@ -727,7 +798,8 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
|||
// from the @user@server.tld handle
|
||||
func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) {
|
||||
u := RemoteUser{Handle: handle}
|
||||
err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox)
|
||||
var urlVal sql.NullString
|
||||
err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox, url FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox, &urlVal)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil, ErrRemoteUserNotFound
|
||||
|
@ -735,6 +807,7 @@ func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) {
|
|||
log.Error("Couldn't get remote user %s: %v", handle, err)
|
||||
return nil, err
|
||||
}
|
||||
u.URL = urlVal.String
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
|
@ -768,6 +841,69 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser,
|
|||
return actor, remoteUser, nil
|
||||
}
|
||||
|
||||
func GetProfileURLFromHandle(app *App, handle string) (string, error) {
|
||||
handle = strings.TrimLeft(handle, "@")
|
||||
actorIRI := ""
|
||||
parts := strings.Split(handle, "@")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid handle format")
|
||||
}
|
||||
domain := parts[1]
|
||||
|
||||
// Check non-AP instances
|
||||
if siloProfileURL := silobridge.Profile(parts[0], domain); siloProfileURL != "" {
|
||||
return siloProfileURL, nil
|
||||
}
|
||||
|
||||
remoteUser, err := getRemoteUserFromHandle(app, handle)
|
||||
if err != nil {
|
||||
// can't find using handle in the table but the table may already have this user without
|
||||
// handle from a previous version
|
||||
// TODO: Make this determination. We should know whether a user exists without a handle, or doesn't exist at all
|
||||
actorIRI = RemoteLookup(handle)
|
||||
_, errRemoteUser := getRemoteUser(app, actorIRI)
|
||||
// if it exists then we need to update the handle
|
||||
if errRemoteUser == nil {
|
||||
_, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI)
|
||||
if err != nil {
|
||||
log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI)
|
||||
}
|
||||
} else {
|
||||
// this probably means we don't have the user in the table so let's try to insert it
|
||||
// here we need to ask the server for the inboxes
|
||||
remoteActor, err := activityserve.NewRemoteActor(actorIRI)
|
||||
if err != nil {
|
||||
log.Error("Couldn't fetch remote actor: %v", err)
|
||||
}
|
||||
if debugging {
|
||||
log.Info("Got remote actor: %s %s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle)
|
||||
}
|
||||
_, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url, handle) VALUES(?, ?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle)
|
||||
if err != nil {
|
||||
log.Error("Couldn't insert remote user: %v", err)
|
||||
return "", err
|
||||
}
|
||||
actorIRI = remoteActor.URL()
|
||||
}
|
||||
} else if remoteUser.URL == "" {
|
||||
log.Info("Remote user %s URL empty, fetching", remoteUser.ActorID)
|
||||
newRemoteActor, err := activityserve.NewRemoteActor(remoteUser.ActorID)
|
||||
if err != nil {
|
||||
log.Error("Couldn't fetch remote actor: %v", err)
|
||||
} else {
|
||||
_, err := app.db.Exec("UPDATE remoteusers SET url = ? WHERE actor_id = ?", newRemoteActor.URL(), remoteUser.ActorID)
|
||||
if err != nil {
|
||||
log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI)
|
||||
} else {
|
||||
actorIRI = newRemoteActor.URL()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
actorIRI = remoteUser.URL
|
||||
}
|
||||
return actorIRI, nil
|
||||
}
|
||||
|
||||
// unmarshal actor normalizes the actor response to conform to
|
||||
// the type Person from github.com/writeas/web-core/activitysteams
|
||||
//
|
||||
|
|
72
admin.go
72
admin.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -13,6 +13,7 @@ package writefreely
|
|||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
@ -24,8 +25,8 @@ import (
|
|||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/web-core/passgen"
|
||||
"github.com/writeas/writefreely/appstats"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writefreely/writefreely/appstats"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -102,13 +103,16 @@ func NewAdminPage(app *App) *AdminPage {
|
|||
return ap
|
||||
}
|
||||
|
||||
func (c instanceContent) UpdatedFriendly() string {
|
||||
func (c instanceContent) UpdatedFriendly() template.HTML {
|
||||
/*
|
||||
// TODO: accept a locale in this method and use that for the format
|
||||
var loc monday.Locale = monday.LocaleEnUS
|
||||
return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
|
||||
*/
|
||||
return c.Updated.Format("January 2, 2006, 3:04 PM")
|
||||
if c.Updated.IsZero() {
|
||||
return "<em>Never</em>"
|
||||
}
|
||||
return template.HTML(c.Updated.Format("January 2, 2006, 3:04 PM"))
|
||||
}
|
||||
|
||||
func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
|
@ -189,6 +193,7 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
Flashes []string
|
||||
|
||||
Users *[]User
|
||||
CurPage int
|
||||
|
@ -201,6 +206,7 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
p.Flashes, _ = getSessionFlashes(app, w, r, nil)
|
||||
p.TotalUsers = app.db.GetAllUsersCount()
|
||||
ttlPages := p.TotalUsers / adminUsersPerPage
|
||||
p.TotalPages = []int{}
|
||||
|
@ -312,6 +318,37 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
return nil
|
||||
}
|
||||
|
||||
func handleAdminDeleteUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
if !u.IsAdmin() {
|
||||
return impart.HTTPError{http.StatusForbidden, "Administrator privileges required for this action"}
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
username := vars["username"]
|
||||
confirmUsername := r.PostFormValue("confirm-username")
|
||||
|
||||
if confirmUsername != username {
|
||||
return impart.HTTPError{http.StatusBadRequest, "Username was not confirmed"}
|
||||
}
|
||||
|
||||
user, err := app.db.GetUserForAuth(username)
|
||||
if err == ErrUserNotFound {
|
||||
return impart.HTTPError{http.StatusNotFound, fmt.Sprintf("User '%s' was not found", username)}
|
||||
} else if err != nil {
|
||||
log.Error("get user for deletion: %v", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user with username '%s': %v", username, err)}
|
||||
}
|
||||
|
||||
err = app.db.DeleteAccount(user.ID)
|
||||
if err != nil {
|
||||
log.Error("delete user %s: %v", user.Username, err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete user account for '%s': %v", username, err)}
|
||||
}
|
||||
|
||||
_ = addSessionFlash(app, w, r, fmt.Sprintf("User \"%s\" was deleted successfully.", username), nil)
|
||||
return impart.HTTPError{http.StatusFound, "/admin/users"}
|
||||
}
|
||||
|
||||
func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
username := vars["username"]
|
||||
|
@ -328,6 +365,9 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht
|
|||
err = app.db.SetUserStatus(user.ID, UserActive)
|
||||
} else {
|
||||
err = app.db.SetUserStatus(user.ID, UserSilenced)
|
||||
|
||||
// reset the cache to removed silence user posts
|
||||
updateTimelineCache(app.timeline, true)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("toggle user silenced: %v", err)
|
||||
|
@ -390,9 +430,9 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
|
||||
// Add in default pages
|
||||
var hasAbout, hasPrivacy bool
|
||||
var hasAbout, hasContact, hasPrivacy bool
|
||||
for i, c := range p.Pages {
|
||||
if hasAbout && hasPrivacy {
|
||||
if hasAbout && hasContact && hasPrivacy {
|
||||
break
|
||||
}
|
||||
if c.ID == "about" {
|
||||
|
@ -400,6 +440,11 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
if !c.Title.Valid {
|
||||
p.Pages[i].Title = defaultAboutTitle(app.cfg)
|
||||
}
|
||||
} else if c.ID == "contact" {
|
||||
hasContact = true
|
||||
if !c.Title.Valid {
|
||||
p.Pages[i].Title = defaultContactTitle()
|
||||
}
|
||||
} else if c.ID == "privacy" {
|
||||
hasPrivacy = true
|
||||
if !c.Title.Valid {
|
||||
|
@ -415,6 +460,13 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
Updated: defaultPageUpdatedTime,
|
||||
})
|
||||
}
|
||||
if !hasContact {
|
||||
p.Pages = append(p.Pages, &instanceContent{
|
||||
ID: "contact",
|
||||
Title: defaultContactTitle(),
|
||||
Content: defaultContactPage(app),
|
||||
})
|
||||
}
|
||||
if !hasPrivacy {
|
||||
p.Pages = append(p.Pages, &instanceContent{
|
||||
ID: "privacy",
|
||||
|
@ -453,6 +505,8 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
// Get pre-defined pages, or select slug
|
||||
if slug == "about" {
|
||||
p.Content, err = getAboutPage(app)
|
||||
} else if slug == "contact" {
|
||||
p.Content, err = getContactPage(app)
|
||||
} else if slug == "privacy" {
|
||||
p.Content, err = getPrivacyPage(app)
|
||||
} else if slug == "landing" {
|
||||
|
@ -487,7 +541,7 @@ func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Req
|
|||
id := vars["page"]
|
||||
|
||||
// Validate
|
||||
if id != "about" && id != "privacy" && id != "landing" && id != "reader" {
|
||||
if id != "about" && id != "contact" && id != "privacy" && id != "landing" && id != "reader" {
|
||||
return impart.HTTPError{http.StatusNotFound, "No such page."}
|
||||
}
|
||||
|
||||
|
@ -519,6 +573,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
|
|||
apper.App().cfg.App.SiteDesc = r.FormValue("site_desc")
|
||||
apper.App().cfg.App.Landing = r.FormValue("landing")
|
||||
apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on"
|
||||
apper.App().cfg.App.OpenDeletion = r.FormValue("open_deletion") == "on"
|
||||
mul, err := strconv.Atoi(r.FormValue("min_username_len"))
|
||||
if err == nil {
|
||||
apper.App().cfg.App.MinUsernameLen = mul
|
||||
|
@ -529,6 +584,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
|
|||
}
|
||||
apper.App().cfg.App.Federation = r.FormValue("federation") == "on"
|
||||
apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on"
|
||||
apper.App().cfg.App.Monetization = r.FormValue("monetization") == "on"
|
||||
apper.App().cfg.App.Private = r.FormValue("private") == "on"
|
||||
apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on"
|
||||
if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil {
|
||||
|
|
178
app.go
178
app.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -13,9 +13,10 @@ package writefreely
|
|||
import (
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
@ -30,17 +31,18 @@ import (
|
|||
"github.com/gorilla/schema"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/manifoldco/promptui"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/converter"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/author"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/key"
|
||||
"github.com/writeas/writefreely/migrations"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
|
||||
"github.com/writefreely/writefreely/author"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"github.com/writefreely/writefreely/key"
|
||||
"github.com/writefreely/writefreely/migrations"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -56,7 +58,7 @@ var (
|
|||
debugging bool
|
||||
|
||||
// Software version can be set from git env using -ldflags
|
||||
softwareVer = "0.12.0"
|
||||
softwareVer = "0.15.0"
|
||||
|
||||
// DEPRECATED VARS
|
||||
isSingleUser bool
|
||||
|
@ -166,7 +168,15 @@ func (app *App) LoadKeys() error {
|
|||
if debugging {
|
||||
log.Info(" %s", emailKeyPath)
|
||||
}
|
||||
app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath)
|
||||
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
executable = "writefreely"
|
||||
} else {
|
||||
executable = filepath.Base(executable)
|
||||
}
|
||||
|
||||
app.keys.EmailKey, err = os.ReadFile(emailKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -174,7 +184,7 @@ func (app *App) LoadKeys() error {
|
|||
if debugging {
|
||||
log.Info(" %s", cookieAuthKeyPath)
|
||||
}
|
||||
app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
|
||||
app.keys.CookieAuthKey, err = os.ReadFile(cookieAuthKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -182,11 +192,27 @@ func (app *App) LoadKeys() error {
|
|||
if debugging {
|
||||
log.Info(" %s", cookieKeyPath)
|
||||
}
|
||||
app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath)
|
||||
app.keys.CookieKey, err = os.ReadFile(cookieKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if debugging {
|
||||
log.Info(" %s", csrfKeyPath)
|
||||
}
|
||||
app.keys.CSRFKey, err = os.ReadFile(csrfKeyPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Error(`Missing key: %s.
|
||||
|
||||
Run this command to generate missing keys:
|
||||
%s keys generate
|
||||
|
||||
`, csrfKeyPath, executable)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -238,6 +264,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := struct {
|
||||
page.StaticPage
|
||||
*OAuthButtons
|
||||
Flashes []template.HTML
|
||||
Banner template.HTML
|
||||
Content template.HTML
|
||||
|
@ -245,6 +272,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
ForcedLanding bool
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.Config()),
|
||||
ForcedLanding: forceLanding,
|
||||
}
|
||||
|
||||
|
@ -289,7 +317,7 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te
|
|||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
}
|
||||
if r.URL.Path == "/about" || r.URL.Path == "/privacy" {
|
||||
if r.URL.Path == "/about" || r.URL.Path == "/contact" || r.URL.Path == "/privacy" {
|
||||
var c *instanceContent
|
||||
var err error
|
||||
|
||||
|
@ -300,6 +328,12 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te
|
|||
p.AboutStats = &InstanceStats{}
|
||||
p.AboutStats.NumPosts, _ = app.db.GetTotalPosts()
|
||||
p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections()
|
||||
} else if r.URL.Path == "/contact" {
|
||||
c, err = getContactPage(app)
|
||||
if c.Updated.IsZero() {
|
||||
// Page was never set up, so return 404
|
||||
return ErrPostNotFound
|
||||
}
|
||||
} else {
|
||||
c, err = getPrivacyPage(app)
|
||||
}
|
||||
|
@ -330,6 +364,11 @@ func pageForReq(app *App, r *http.Request) page.StaticPage {
|
|||
Version: "v" + softwareVer,
|
||||
}
|
||||
|
||||
// Use custom style, if file exists
|
||||
if _, err := os.Stat(filepath.Join(app.cfg.Server.StaticParentDir, staticDir, "local", "custom.css")); err == nil {
|
||||
p.CustomCSS = true
|
||||
}
|
||||
|
||||
// Add user information, if given
|
||||
var u *User
|
||||
accessToken := r.FormValue("t")
|
||||
|
@ -387,6 +426,19 @@ func Initialize(apper Apper, debug bool) (*App, error) {
|
|||
return nil, fmt.Errorf("connect to DB: %s", err)
|
||||
}
|
||||
|
||||
initActivityPub(apper.App())
|
||||
|
||||
if apper.App().cfg.Email.Domain != "" || apper.App().cfg.Email.MailgunPrivate != "" {
|
||||
if apper.App().cfg.Email.Domain == "" {
|
||||
log.Error("[FAILED] Starting publish jobs queue: no [letters]domain config value set.")
|
||||
} else if apper.App().cfg.Email.MailgunPrivate == "" {
|
||||
log.Error("[FAILED] Starting publish jobs queue: no [letters]mailgun_private config value set.")
|
||||
} else {
|
||||
log.Info("Starting publish jobs queue...")
|
||||
go startPublishJobsQueue(apper.App())
|
||||
}
|
||||
}
|
||||
|
||||
// Handle local timeline, if enabled
|
||||
if apper.App().cfg.App.LocalTimeline {
|
||||
log.Info("Initializing local timeline...")
|
||||
|
@ -413,6 +465,11 @@ func Serve(app *App, r *mux.Router) {
|
|||
os.Exit(0)
|
||||
}()
|
||||
|
||||
// Start gopher server
|
||||
if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private {
|
||||
go initGopher(app)
|
||||
}
|
||||
|
||||
// Start web application server
|
||||
var bindAddress = app.cfg.Server.Bind
|
||||
if bindAddress == "" {
|
||||
|
@ -470,9 +527,41 @@ requests. We recommend supplying a valid host name.`)
|
|||
err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r)
|
||||
}
|
||||
} else {
|
||||
log.Info("Serving on http://%s:%d\n", bindAddress, app.cfg.Server.Port)
|
||||
network := "tcp"
|
||||
protocol := "http"
|
||||
if strings.HasPrefix(bindAddress, "/") {
|
||||
network = "unix"
|
||||
protocol = "http+unix"
|
||||
|
||||
// old sockets will remain after server closes;
|
||||
// we need to delete them in order to open new ones
|
||||
err = os.Remove(bindAddress)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Error("%s already exists but could not be removed: %v", bindAddress, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
bindAddress = fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port)
|
||||
}
|
||||
|
||||
log.Info("Serving on %s://%s", protocol, bindAddress)
|
||||
log.Info("---")
|
||||
err = http.ListenAndServe(fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port), r)
|
||||
listener, err := net.Listen(network, bindAddress)
|
||||
if err != nil {
|
||||
log.Error("Could not bind to address: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if network == "unix" {
|
||||
err = os.Chmod(bindAddress, 0o666)
|
||||
if err != nil {
|
||||
log.Error("Could not update socket permissions: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
defer listener.Close()
|
||||
err = http.Serve(listener, r)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Unable to start: %v", err)
|
||||
|
@ -496,8 +585,8 @@ func (app *App) InitDecoder() {
|
|||
// tests the connection.
|
||||
func ConnectToDatabase(app *App) error {
|
||||
// Check database configuration
|
||||
if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") {
|
||||
return fmt.Errorf("Database user or password not set.")
|
||||
if app.cfg.Database.Type == driverMySQL && app.cfg.Database.User == "" {
|
||||
return fmt.Errorf("Database user not set.")
|
||||
}
|
||||
if app.cfg.Database.Host == "" {
|
||||
app.cfg.Database.Host = "localhost"
|
||||
|
@ -588,7 +677,7 @@ func DoConfig(app *App, configSections string) {
|
|||
|
||||
// Create blog
|
||||
log.Info("Creating user %s...\n", u.Username)
|
||||
err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName)
|
||||
err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName, "")
|
||||
if err != nil {
|
||||
log.Error("Unable to create user: %s", err)
|
||||
os.Exit(1)
|
||||
|
@ -628,6 +717,10 @@ func GenerateKeyFiles(app *App) error {
|
|||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
err = generateKey(csrfKeyPath)
|
||||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
|
||||
return keyErrs
|
||||
}
|
||||
|
@ -748,7 +841,7 @@ func connectToDatabase(app *App) {
|
|||
var db *sql.DB
|
||||
var err error
|
||||
if app.cfg.Database.Type == driverMySQL {
|
||||
db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String())))
|
||||
db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&tls=%t", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()), app.cfg.Database.TLS))
|
||||
db.SetMaxOpenConns(50)
|
||||
} else if app.cfg.Database.Type == driverSQLite {
|
||||
if !SQLiteEnabled {
|
||||
|
@ -760,7 +853,7 @@ func connectToDatabase(app *App) {
|
|||
os.Exit(1)
|
||||
}
|
||||
db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared")
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxOpenConns(2)
|
||||
} else {
|
||||
log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type)
|
||||
os.Exit(1)
|
||||
|
@ -775,6 +868,16 @@ func connectToDatabase(app *App) {
|
|||
func shutdown(app *App) {
|
||||
log.Info("Closing database connection...")
|
||||
app.db.Close()
|
||||
if strings.HasPrefix(app.cfg.Server.Bind, "/") {
|
||||
// Clean up socket
|
||||
log.Info("Removing socket file...")
|
||||
err := os.Remove(app.cfg.Server.Bind)
|
||||
if err != nil {
|
||||
log.Error("Unable to remove socket: %s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Info("Success.")
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser creates a new admin or normal user from the given credentials.
|
||||
|
@ -829,7 +932,7 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
|||
userType = "admin"
|
||||
}
|
||||
log.Info("Creating %s %s...", userType, usernameDesc)
|
||||
err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername)
|
||||
err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to create user: %s", err)
|
||||
}
|
||||
|
@ -837,15 +940,18 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func adminInitDatabase(app *App) error {
|
||||
schemaFileName := "schema.sql"
|
||||
if app.cfg.Database.Type == driverSQLite {
|
||||
schemaFileName = "sqlite.sql"
|
||||
}
|
||||
//go:embed schema.sql
|
||||
var schemaSql string
|
||||
|
||||
schema, err := Asset(schemaFileName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to load schema file: %v", err)
|
||||
//go:embed sqlite.sql
|
||||
var sqliteSql string
|
||||
|
||||
func adminInitDatabase(app *App) error {
|
||||
var schema string
|
||||
if app.cfg.Database.Type == driverSQLite {
|
||||
schema = sqliteSql
|
||||
} else {
|
||||
schema = schemaSql
|
||||
}
|
||||
|
||||
tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`")
|
||||
|
@ -861,7 +967,7 @@ func adminInitDatabase(app *App) error {
|
|||
} else {
|
||||
log.Info("Creating table ??? (Weird query) No match in: %v", parts)
|
||||
}
|
||||
_, err = app.db.Exec(q)
|
||||
_, err := app.db.Exec(q)
|
||||
if err != nil {
|
||||
log.Error("%s", err)
|
||||
} else {
|
||||
|
@ -871,7 +977,7 @@ func adminInitDatabase(app *App) error {
|
|||
|
||||
// Set up migrations table
|
||||
log.Info("Initializing appmigrations table...")
|
||||
err = migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
|
||||
err := migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to set initial migrations: %v", err)
|
||||
}
|
||||
|
@ -885,3 +991,13 @@ func adminInitDatabase(app *App) error {
|
|||
log.Info("Done.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServerUserAgent returns a User-Agent string to use in external requests. The
|
||||
// hostName parameter may be left empty.
|
||||
func ServerUserAgent(hostName string) string {
|
||||
hostUAStr := ""
|
||||
if hostName != "" {
|
||||
hostUAStr = "; +" + hostName
|
||||
}
|
||||
return "Go (" + serverSoftware + "/" + softwareVer + hostUAStr + ")"
|
||||
}
|
||||
|
|
2
auth.go
2
auth.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,7 +11,8 @@
|
|||
package author
|
||||
|
||||
import (
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
@ -113,10 +114,17 @@ func IsValidUsername(cfg *config.Config, username string) bool {
|
|||
// Username is invalid if page with the same name exists. So traverse
|
||||
// available pages, adding them to reservedUsernames map that'll be checked
|
||||
// later.
|
||||
filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error {
|
||||
err := filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reservedUsernames[i.Name()] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("[IMPORTANT WARNING]: Could not determine IsValidUsername! %s", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Username is invalid if it is reserved!
|
||||
if _, reserved := reservedUsernames[username]; reserved {
|
||||
|
|
105
bindata-lib.go
105
bindata-lib.go
|
@ -1,105 +0,0 @@
|
|||
// +build wflib
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func bindata_read(data []byte, name string) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, gz)
|
||||
gz.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
var _schema_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xd4\x59\x5f\x6f\xa3\x38\x10\x7f\xef\xa7\xf0\xdb\xa6\x52\x23\x6d\x7a\xdd\xaa\xba\xd3\x3e\x64\x53\x76\x2f\xba\x94\xee\x25\x44\xba\x7d\x02\x03\x93\xd4\xaa\xb1\x91\x6d\x92\xe6\xdb\x9f\x8c\x49\x08\x86\x24\xd0\xdb\x3b\x71\x7d\x2a\xcc\x6f\x8c\xfd\x9b\x3f\x9e\x99\x0c\x87\x57\xc3\x21\x7a\xc4\x0a\x87\x58\xc2\xaf\x28\xd8\x0a\xa2\x60\x25\x00\xe8\x2e\xb8\x1a\x0e\xaf\xb4\x78\xf8\xce\x3f\xad\xac\xf5\x3d\x1c\x52\x40\x52\x89\x2c\x52\x99\x00\xb4\xe2\x02\xa9\xfc\x5d\x80\xa3\x08\xa4\x54\xfc\x15\x98\x34\xdf\x9b\xcc\x9d\xb1\xe7\x20\x6f\xfc\x65\xe6\xa0\xe9\x57\xe4\x3e\x7b\xc8\xf9\x6b\xba\xf0\x16\x16\x1a\x0d\xae\x10\x0a\xf2\x87\x00\x85\x84\x61\xb1\x1b\x8c\xee\xaf\x73\x05\x77\x39\x9b\xdd\x68\x71\x26\x41\xf8\x24\x0e\x10\x61\x6a\x60\x0b\x65\x16\xf3\x00\x29\xc2\x76\x5a\x3a\x2a\xa5\xe8\xd1\xf9\x3a\x5e\xce\x3c\xf4\xe1\xe3\x87\x1c\xc9\x19\xf8\x8a\x24\xd0\x0e\x1d\x09\xc0\x0a\xe2\x00\xc5\x58\x81\x56\xab\x43\x27\xcb\xf9\xdc\x71\x3d\xdf\x9b\x3e\x39\x0b\x6f\xfc\xf4\x3d\x57\x84\xb7\x94\x08\x90\x47\x8a\x7b\x7c\xf5\x40\x78\x0d\x4c\x05\x68\x83\x45\xf4\x82\xc5\xe0\xf6\xd3\xa7\xeb\x1a\xf2\xfb\x7c\xfa\x34\x9e\xff\x40\x7f\x38\x3f\xd0\xa0\xa0\xe9\xfa\xea\x1a\x39\xee\xb7\xa9\xeb\x7c\x9e\x32\xc6\x1f\xbf\x94\xfb\xf9\x7d\x3c\x5f\x38\xde\x67\x8a\x15\x61\xa3\xdf\xfe\x75\xb3\xa7\x69\xc4\x99\xd2\xa7\xb8\x6c\xf4\x12\x6b\x4c\xae\xcd\xb9\x3f\xfa\x2f\xb6\x4d\x0f\xd0\x04\x62\x92\x25\x0a\xde\x54\x7e\xb8\xf1\xc4\x73\xe6\x68\xe1\x78\x28\x53\xab\x07\x34\x79\x9e\xcd\xf4\x17\xf5\x83\x1f\x12\x66\x79\x4d\x1a\xbf\xcb\x80\x55\xce\x49\xdc\x2b\xc2\x13\xb2\x16\x58\x11\xde\x18\x68\x16\xc0\x10\xbd\x01\x21\x09\x67\x26\x78\x46\x23\x8b\x69\x03\x6f\x64\x29\x97\x0b\x90\x19\x55\x01\xca\x4d\xb0\x97\xf4\x85\x8f\x88\x53\x0a\x91\x3e\x2c\x56\x4a\x90\x30\x53\xd0\x22\xff\x34\x6a\x19\xae\x4a\xd1\xc9\x74\x73\xd0\x29\xdd\x77\x74\xfb\x60\x81\x36\x98\x66\x60\x85\x76\xdd\x7f\x93\xf0\xae\xe2\xc2\x49\x78\x57\xf3\xe2\xaa\x33\x56\xf7\x77\x73\xb4\x99\xde\xf8\x68\xb9\xc5\x57\xd8\x75\xb2\x46\x8e\x6f\x6d\x87\x34\x0b\x29\x89\xfc\x57\xd8\x05\x28\xa4\x3c\xb4\xa4\x82\x6c\xb0\x82\x13\xe2\x73\xa4\xf6\x90\xc8\x14\x4b\xb9\xe5\x22\xee\xc4\x66\xa9\xd4\x9e\xd2\x42\x25\x40\xb9\xd7\xde\x7f\xbc\xfe\x3f\xb3\x26\x20\x26\x02\x22\xd5\x89\xb5\x52\xc9\xb0\x96\x0a\xd8\xf8\x98\x12\x2c\x8f\xc2\xfd\xa3\x45\x4c\xc0\x60\x7b\x11\x54\x65\xef\x68\xdd\x1e\x52\xd7\x89\x32\x79\x74\xa1\x5b\x5e\x85\xc6\x4b\xef\xd9\x9f\xba\x93\xb9\xf3\xe4\xb8\x9e\xc9\x9f\x0d\x3c\xb5\x4f\x8d\xb5\x4a\x4a\x11\x45\x7f\x4e\xa6\x0d\x62\x90\x91\x20\xa9\xca\x2f\xcb\xc3\xfe\xee\x3b\xed\xaf\x5a\x99\xaa\x1d\x05\x5f\xbe\x00\x14\x17\xa8\x79\x9b\x7f\xa4\xb8\x51\x5b\xaf\x9c\xab\xae\xb8\x48\xf0\x51\xc9\xf8\x50\x2f\x18\x4d\xe6\x8b\x76\x8d\x35\xae\xa9\x82\xb7\xec\x4c\x35\xbd\x21\xb0\xf5\x23\x9e\xe9\xe2\xab\x41\x5e\xaf\x8d\xf4\xdb\xa5\x3b\xfd\x73\xe9\xe4\x2f\xf7\xf6\x1d\x04\x3d\xf3\xee\x94\xcb\x36\xa9\xc0\xc0\x4a\x8f\x2e\x9c\xc0\xee\x39\x68\xb6\xb6\x7c\xb8\x66\x88\x84\xc7\x64\xb5\xf3\x8b\xd6\xc6\xd4\xb9\xb7\x0d\x38\xed\x07\x3e\x4e\x53\xc0\x02\xb3\x08\x0a\xe8\x5d\x53\x67\xc2\xb8\x48\x4c\x73\x42\x31\x5b\x67\x78\xbd\x47\x37\xad\x2b\x14\xad\x38\xc1\x4f\xf0\x94\xda\x12\xcd\x97\x4a\xfd\x4b\x84\x31\x88\xfd\x94\x4b\x62\xa2\xeb\xe8\x8b\x4b\x77\x31\xfd\xe6\x3a\x8f\x0d\x8b\xef\x1b\x30\x5d\x95\x4a\x85\x93\xb4\x6d\x07\x76\xa8\xfc\x3b\x6b\x5e\x70\x7f\x3b\xdd\xfc\x93\xec\x70\xe8\x71\xba\x25\x82\x8e\xe1\x48\x62\xdf\x38\x6b\xbd\x78\xcc\xdf\xd7\x14\x4a\xa3\x0f\xca\xff\x6f\x0e\x6b\xe7\x98\xc2\x73\x0a\xd4\xde\x8f\x6e\x7a\xd5\x2b\x09\x48\xb8\x82\x15\xa7\x94\x6f\x5b\xc4\x7d\x15\x7e\xb2\x64\xaa\xf5\x4f\x46\xcf\xaf\x4c\x28\x6a\xa0\xd3\xa3\x84\xcb\x25\xbe\xf5\x81\x9e\xf1\xab\xb7\xd5\xae\xce\xb7\xf0\xf5\x21\x40\x7e\x75\x77\xe7\xf6\x6c\x1f\x70\x39\x3e\x8c\xc5\x0f\x1e\xdf\x7f\xb6\x3b\x51\x6d\xd7\x66\xc7\xec\x35\x16\x67\x91\xe2\x86\x8a\xd3\x56\x21\x2c\xe4\x6f\xe7\x00\xf2\x05\x0b\x88\xfd\x4b\xb8\xcb\xb6\xb1\xe2\x6f\x50\x6e\xaf\x37\x76\xd1\x24\x77\x99\x3d\x58\x78\x63\x9d\xb3\xe3\xcd\x86\x79\xc3\xfd\xdd\x7f\x34\x6e\xd8\x6f\xac\x97\x83\x06\xbd\x39\xc2\x36\xa4\x99\xf7\x8a\xd8\x2a\xe7\x6c\x8a\xab\x75\x4e\x7d\x44\x86\xdf\x74\x42\x90\x01\x92\x09\xa6\xf4\x64\x2d\x74\x36\xc9\xb7\x99\x0a\x13\x86\x23\x45\x36\xcd\xf3\xe9\x3e\xd1\xde\xd2\xd1\x3b\x76\x86\x5a\x85\xe1\x04\xde\xdd\x1c\x5e\x1a\x66\x54\x57\x32\x7c\x1d\x16\x32\x8f\xf5\x75\x20\xc1\x84\xe6\x5b\x2a\x7e\x9d\x68\x9c\xd3\xbf\xfb\xd7\x82\xcb\x59\xb0\xa4\x65\x50\xfe\xdf\xab\x28\x94\x26\xce\xe2\x53\x61\x78\x90\x17\xee\x90\x3f\xf9\x27\xc3\xf1\xe4\x7d\xdf\xfa\xcc\x7f\x07\x00\x00\xff\xff\xbe\x79\x68\xa8\x10\x1b\x00\x00")
|
||||
|
||||
func schema_sql() ([]byte, error) {
|
||||
return bindata_read(
|
||||
_schema_sql,
|
||||
"schema.sql",
|
||||
)
|
||||
}
|
||||
|
||||
// Asset loads and returns the asset for the given name.
|
||||
// It returns an error if the asset could not be found or
|
||||
// could not be loaded.
|
||||
func Asset(name string) ([]byte, error) {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
if f, ok := _bindata[cannonicalName]; ok {
|
||||
return f()
|
||||
}
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
|
||||
// AssetNames returns the names of the assets.
|
||||
func AssetNames() []string {
|
||||
names := make([]string, 0, len(_bindata))
|
||||
for name := range _bindata {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// _bindata is a table, holding each asset generator, mapped to its name.
|
||||
var _bindata = map[string]func() ([]byte, error){
|
||||
"schema.sql": schema_sql,
|
||||
}
|
||||
// AssetDir returns the file names below a certain
|
||||
// directory embedded in the file by go-bindata.
|
||||
// For example if you run go-bindata on data/... and data contains the
|
||||
// following hierarchy:
|
||||
// data/
|
||||
// foo.txt
|
||||
// img/
|
||||
// a.png
|
||||
// b.png
|
||||
// then AssetDir("data") would return []string{"foo.txt", "img"}
|
||||
// AssetDir("data/img") would return []string{"a.png", "b.png"}
|
||||
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
|
||||
// AssetDir("") will return []string{"data"}.
|
||||
func AssetDir(name string) ([]string, error) {
|
||||
node := _bintree
|
||||
if len(name) != 0 {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
pathList := strings.Split(cannonicalName, "/")
|
||||
for _, p := range pathList {
|
||||
node = node.Children[p]
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.Func != nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
rv := make([]string, 0, len(node.Children))
|
||||
for name := range node.Children {
|
||||
rv = append(rv, name)
|
||||
}
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
type _bintree_t struct {
|
||||
Func func() ([]byte, error)
|
||||
Children map[string]*_bintree_t
|
||||
}
|
||||
var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
|
||||
"schema.sql": &_bintree_t{schema_sql, map[string]*_bintree_t{
|
||||
}},
|
||||
}}
|
2
cache.go
2
cache.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2020 A Bunch Tell LLC.
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,9 +11,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/writeas/writefreely"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2020 A Bunch Tell LLC.
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,9 +11,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/writeas/writefreely"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2020 A Bunch Tell LLC.
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,9 +11,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/writeas/writefreely"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -15,11 +15,10 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2020 A Bunch Tell LLC.
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -13,9 +13,8 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/writeas/writefreely"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2020 A Bunch Tell LLC.
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,11 +11,10 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
368
collections.go
368
collections.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2022 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -24,15 +24,26 @@ import (
|
|||
"unicode"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/activitystreams"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/bots"
|
||||
"github.com/writeas/web-core/i18n"
|
||||
"github.com/writeas/web-core/log"
|
||||
waposts "github.com/writeas/web-core/posts"
|
||||
"github.com/writeas/writefreely/author"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"github.com/writeas/web-core/posts"
|
||||
"github.com/writefreely/writefreely/author"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
"github.com/writefreely/writefreely/spam"
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
collAttrLetterReplyTo = "letter_reply_to"
|
||||
|
||||
collMaxLengthTitle = 255
|
||||
collMaxLengthDescription = 160
|
||||
)
|
||||
|
||||
type (
|
||||
|
@ -47,6 +58,7 @@ type (
|
|||
Language string `schema:"lang" json:"lang,omitempty"`
|
||||
StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
|
||||
Script string `datastore:"script" schema:"script" json:"script,omitempty"`
|
||||
Signature string `datastore:"post_signature" schema:"signature" json:"-"`
|
||||
Public bool `datastore:"public" json:"public"`
|
||||
Visibility collVisibility `datastore:"private" json:"-"`
|
||||
Format string `datastore:"format" json:"format,omitempty"`
|
||||
|
@ -55,6 +67,9 @@ type (
|
|||
PublicOwner bool `datastore:"public_owner" json:"-"`
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
Monetization string `json:"monetization_pointer,omitempty"`
|
||||
Verification string `json:"verification_link"`
|
||||
|
||||
db *datastore
|
||||
hostName string
|
||||
}
|
||||
|
@ -68,11 +83,20 @@ type (
|
|||
DisplayCollection struct {
|
||||
*CollectionObj
|
||||
Prefix string
|
||||
NavSuffix string
|
||||
IsTopLevel bool
|
||||
CurrentPage int
|
||||
TotalPages int
|
||||
Silenced bool
|
||||
}
|
||||
|
||||
CollectionNav struct {
|
||||
*Collection
|
||||
Path string
|
||||
SingleUser bool
|
||||
CanPost bool
|
||||
}
|
||||
|
||||
SubmittedCollection struct {
|
||||
// Data used for updating a given collection
|
||||
ID int64
|
||||
|
@ -83,16 +107,21 @@ type (
|
|||
Privacy int `schema:"privacy" json:"privacy"`
|
||||
Pass string `schema:"password" json:"password"`
|
||||
MathJax bool `schema:"mathjax" json:"mathjax"`
|
||||
EmailSubs bool `schema:"email_subs" json:"email_subs"`
|
||||
Handle string `schema:"handle" json:"handle"`
|
||||
|
||||
// Actual collection values updated in the DB
|
||||
Alias *string `schema:"alias" json:"alias"`
|
||||
Title *string `schema:"title" json:"title"`
|
||||
Description *string `schema:"description" json:"description"`
|
||||
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
|
||||
Script *sql.NullString `schema:"script" json:"script"`
|
||||
Visibility *int `schema:"visibility" json:"public"`
|
||||
Format *sql.NullString `schema:"format" json:"format"`
|
||||
Alias *string `schema:"alias" json:"alias"`
|
||||
Title *string `schema:"title" json:"title"`
|
||||
Description *string `schema:"description" json:"description"`
|
||||
StyleSheet *string `schema:"style_sheet" json:"style_sheet"`
|
||||
Script *string `schema:"script" json:"script"`
|
||||
Signature *string `schema:"signature" json:"signature"`
|
||||
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
|
||||
Verification *string `schema:"verification_link" json:"verification_link"`
|
||||
LetterReply *string `schema:"letter_reply" json:"letter_reply"`
|
||||
Visibility *int `schema:"visibility" json:"public"`
|
||||
Format *sql.NullString `schema:"format" json:"format"`
|
||||
}
|
||||
CollectionFormat struct {
|
||||
Format string
|
||||
|
@ -105,6 +134,8 @@ type (
|
|||
|
||||
// User-related fields
|
||||
isCollOwner bool
|
||||
|
||||
isAuthorized bool
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -175,6 +206,11 @@ func (c *Collection) NewFormat() *CollectionFormat {
|
|||
return cf
|
||||
}
|
||||
|
||||
func (c *Collection) IsInstanceColl() bool {
|
||||
ur, _ := url.Parse(c.hostName)
|
||||
return c.Alias == ur.Host
|
||||
}
|
||||
|
||||
func (c *Collection) IsUnlisted() bool {
|
||||
return c.Visibility == 0
|
||||
}
|
||||
|
@ -224,13 +260,17 @@ func (c *Collection) DisplayCanonicalURL() string {
|
|||
if p == "/" {
|
||||
p = ""
|
||||
}
|
||||
return u.Hostname() + p
|
||||
d := u.Hostname()
|
||||
d, _ = idna.ToUnicode(d)
|
||||
return d + p
|
||||
}
|
||||
|
||||
// RedirectingCanonicalURL returns the fully-qualified canonical URL for the Collection, with a trailing slash. The
|
||||
// hostName field needs to be populated for this to work correctly.
|
||||
func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
|
||||
if c.hostName == "" {
|
||||
// If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail
|
||||
log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writeas/writefreely/issues/new?template=bug_report.md")
|
||||
log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writefreely/writefreely/issues/new?template=bug_report.md")
|
||||
}
|
||||
if isSingleUser {
|
||||
return c.hostName + "/"
|
||||
|
@ -241,16 +281,16 @@ func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
|
|||
|
||||
// PrevPageURL provides a full URL for the previous page of collection posts,
|
||||
// returning a /page/N result for pages >1
|
||||
func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string {
|
||||
func (c *Collection) PrevPageURL(prefix, navSuffix string, n int, tl bool) string {
|
||||
u := ""
|
||||
if n == 2 {
|
||||
// Previous page is 1; no need for /page/ prefix
|
||||
if prefix == "" {
|
||||
u = "/"
|
||||
u = navSuffix + "/"
|
||||
}
|
||||
// Else leave off trailing slash
|
||||
} else {
|
||||
u = fmt.Sprintf("/page/%d", n-1)
|
||||
u = fmt.Sprintf("%s/page/%d", navSuffix, n-1)
|
||||
}
|
||||
|
||||
if tl {
|
||||
|
@ -260,11 +300,12 @@ func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string {
|
|||
}
|
||||
|
||||
// NextPageURL provides a full URL for the next page of collection posts
|
||||
func (c *Collection) NextPageURL(prefix string, n int, tl bool) string {
|
||||
func (c *Collection) NextPageURL(prefix, navSuffix string, n int, tl bool) string {
|
||||
|
||||
if tl {
|
||||
return fmt.Sprintf("/page/%d", n+1)
|
||||
return fmt.Sprintf("%s/page/%d", navSuffix, n+1)
|
||||
}
|
||||
return fmt.Sprintf("/%s%s/page/%d", prefix, c.Alias, n+1)
|
||||
return fmt.Sprintf("/%s%s%s/page/%d", prefix, c.Alias, navSuffix, n+1)
|
||||
}
|
||||
|
||||
func (c *Collection) DisplayTitle() string {
|
||||
|
@ -338,6 +379,51 @@ func (c *Collection) RenderMathJax() bool {
|
|||
return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
|
||||
}
|
||||
|
||||
func (c *Collection) EmailSubsEnabled() bool {
|
||||
return c.db.CollectionHasAttribute(c.ID, "email_subs")
|
||||
}
|
||||
|
||||
func (c *Collection) MonetizationURL() string {
|
||||
if c.Monetization == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Replace(c.Monetization, "$", "https://", 1)
|
||||
}
|
||||
|
||||
// DisplayDescription returns the description with rendered Markdown and HTML.
|
||||
func (c *Collection) DisplayDescription() *template.HTML {
|
||||
if c.Description == "" {
|
||||
s := template.HTML("")
|
||||
return &s
|
||||
}
|
||||
t := template.HTML(posts.ApplyBasicAccessibleMarkdown([]byte(c.Description)))
|
||||
return &t
|
||||
}
|
||||
|
||||
// PlainDescription returns the description with all Markdown and HTML removed.
|
||||
func (c *Collection) PlainDescription() string {
|
||||
if c.Description == "" {
|
||||
return ""
|
||||
}
|
||||
desc := stripHTMLWithoutEscaping(c.Description)
|
||||
desc = stripmd.Strip(desc)
|
||||
return desc
|
||||
}
|
||||
|
||||
func (c CollectionPage) DisplayMonetization() string {
|
||||
return displayMonetization(c.Monetization, c.Alias)
|
||||
}
|
||||
|
||||
func (c *DisplayCollection) Direction() string {
|
||||
if c.Language == "" {
|
||||
return "auto"
|
||||
}
|
||||
if i18n.LangIsRTL(c.Language) {
|
||||
return "rtl"
|
||||
}
|
||||
return "ltr"
|
||||
}
|
||||
|
||||
func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
reqJSON := IsJSON(r)
|
||||
alias := r.FormValue("alias")
|
||||
|
@ -447,8 +533,7 @@ func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (in
|
|||
|
||||
// fetchCollection handles the API endpoint for retrieving collection data.
|
||||
func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
accept := r.Header.Get("Accept")
|
||||
if strings.Contains(accept, "application/activity+json") {
|
||||
if IsActivityPubRequest(r) {
|
||||
return handleFetchCollectionActivities(app, w, r)
|
||||
}
|
||||
|
||||
|
@ -523,11 +608,11 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
}
|
||||
}
|
||||
|
||||
posts, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false)
|
||||
ps, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
coll := &CollectionObj{Collection: *c, Posts: posts}
|
||||
coll := &CollectionObj{Collection: *c, Posts: ps}
|
||||
app.db.GetPostsCount(coll, isCollOwner)
|
||||
// Strip non-public information
|
||||
coll.Collection.ForPublic()
|
||||
|
@ -535,7 +620,7 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
// Transform post bodies if needed
|
||||
if r.FormValue("body") == "html" {
|
||||
for _, p := range *coll.Posts {
|
||||
p.Content = waposts.ApplyMarkdown([]byte(p.Content))
|
||||
p.Content = posts.ApplyMarkdown([]byte(p.Content))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -548,12 +633,45 @@ type CollectionPage struct {
|
|||
IsCustomDomain bool
|
||||
IsWelcome bool
|
||||
IsOwner bool
|
||||
IsCollLoggedIn bool
|
||||
Honeypot string
|
||||
IsSubscriber bool
|
||||
CanPin bool
|
||||
Username string
|
||||
Monetization string
|
||||
Flash template.HTML
|
||||
Collections *[]Collection
|
||||
PinnedPosts *[]PublicPost
|
||||
IsAdmin bool
|
||||
CanInvite bool
|
||||
|
||||
IsAdmin bool
|
||||
CanInvite bool
|
||||
|
||||
// Helper field for Chorus mode
|
||||
CollAlias string
|
||||
}
|
||||
|
||||
type TagCollectionPage struct {
|
||||
CollectionPage
|
||||
Tag string
|
||||
}
|
||||
|
||||
func (tcp TagCollectionPage) PrevPageURL(prefix string, n int, tl bool) string {
|
||||
u := fmt.Sprintf("/tag:%s", tcp.Tag)
|
||||
if n > 2 {
|
||||
u += fmt.Sprintf("/page/%d", n-1)
|
||||
}
|
||||
if tl {
|
||||
return u
|
||||
}
|
||||
return "/" + prefix + tcp.Alias + u
|
||||
|
||||
}
|
||||
|
||||
func (tcp TagCollectionPage) NextPageURL(prefix string, n int, tl bool) string {
|
||||
if tl {
|
||||
return fmt.Sprintf("/tag:%s/page/%d", tcp.Tag, n+1)
|
||||
}
|
||||
return fmt.Sprintf("/%s%s/tag:%s/page/%d", prefix, tcp.Alias, tcp.Tag, n+1)
|
||||
}
|
||||
|
||||
func NewCollectionObj(c *Collection) *CollectionObj {
|
||||
|
@ -666,9 +784,9 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R
|
|||
}
|
||||
|
||||
// See if we've authorized this collection
|
||||
authd := isAuthorizedForCollection(app, c.Alias, r)
|
||||
cr.isAuthorized = isAuthorizedForCollection(app, c.Alias, r)
|
||||
|
||||
if !authd {
|
||||
if !cr.isAuthorized {
|
||||
p := struct {
|
||||
page.StaticPage
|
||||
*CollectionObj
|
||||
|
@ -721,14 +839,14 @@ func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCo
|
|||
return coll
|
||||
}
|
||||
|
||||
// getCollectionPage returns the collection page as an int. If the parsed page value is not
|
||||
// greater than 0 then the default value of 1 is returned.
|
||||
func getCollectionPage(vars map[string]string) int {
|
||||
page := 1
|
||||
var p int
|
||||
p, _ = strconv.Atoi(vars["page"])
|
||||
if p > 0 {
|
||||
page = p
|
||||
if p, _ := strconv.Atoi(vars["page"]); p > 0 {
|
||||
return p
|
||||
}
|
||||
return page
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// handleViewCollection displays the requested Collection
|
||||
|
@ -761,7 +879,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
}
|
||||
|
||||
// Serve ActivityStreams data now, if requested
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
||||
if IsActivityPubRequest(r) {
|
||||
ac := c.PersonObject()
|
||||
ac.Context = []interface{}{activitystreams.Namespace}
|
||||
setCacheControl(w, apCacheTime)
|
||||
|
@ -786,9 +904,16 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
// Serve collection
|
||||
displayPage := CollectionPage{
|
||||
DisplayCollection: coll,
|
||||
IsCollLoggedIn: cr.isAuthorized,
|
||||
StaticPage: pageForReq(app, r),
|
||||
IsCustomDomain: cr.isCustomDomain,
|
||||
IsWelcome: r.FormValue("greeting") != "",
|
||||
Honeypot: spam.HoneypotFieldName(),
|
||||
CollAlias: c.Alias,
|
||||
}
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
for _, f := range flashes {
|
||||
displayPage.Flash = template.HTML(f)
|
||||
}
|
||||
displayPage.IsAdmin = u != nil && u.IsAdmin()
|
||||
displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
|
||||
|
@ -796,6 +921,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
if u != nil {
|
||||
displayPage.Username = u.Username
|
||||
displayPage.IsOwner = u.ID == coll.OwnerID
|
||||
displayPage.IsSubscriber = u.IsEmailSubscriber(app, coll.ID)
|
||||
if displayPage.IsOwner {
|
||||
// Add in needed information for users viewing their own collection
|
||||
owner = u
|
||||
|
@ -827,6 +953,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
// Add more data
|
||||
// TODO: fix this mess of collections inside collections
|
||||
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
|
||||
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
|
||||
|
||||
collTmpl := "collection"
|
||||
if app.cfg.App.Chorus {
|
||||
|
@ -894,16 +1021,29 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
|
||||
coll := newDisplayCollection(c, cr, page)
|
||||
|
||||
taggedPostIDs, err := app.db.GetAllPostsTaggedIDs(c, tag, cr.isCollOwner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ttlPosts := len(taggedPostIDs)
|
||||
pagePosts := coll.Format.PostsPerPage()
|
||||
coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts)))
|
||||
if coll.TotalPages > 0 && page > coll.TotalPages {
|
||||
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
|
||||
if !app.cfg.App.SingleUser {
|
||||
redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, redirURL}
|
||||
}
|
||||
|
||||
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner)
|
||||
if coll.Posts != nil && len(*coll.Posts) == 0 {
|
||||
return ErrCollectionPageNotFound
|
||||
}
|
||||
|
||||
// Serve collection
|
||||
displayPage := struct {
|
||||
CollectionPage
|
||||
Tag string
|
||||
}{
|
||||
displayPage := TagCollectionPage{
|
||||
CollectionPage: CollectionPage{
|
||||
DisplayCollection: coll,
|
||||
StaticPage: pageForReq(app, r),
|
||||
|
@ -945,6 +1085,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
// Add more data
|
||||
// TODO: fix this mess of collections inside collections
|
||||
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
|
||||
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
|
||||
|
||||
err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage)
|
||||
if err != nil {
|
||||
|
@ -954,6 +1095,111 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
return nil
|
||||
}
|
||||
|
||||
func handleViewCollectionLang(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
lang := vars["lang"]
|
||||
|
||||
cr := &collectionReq{}
|
||||
err := processCollectionRequest(cr, vars, w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u, err := checkUserForCollection(app, cr, r, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
page := getCollectionPage(vars)
|
||||
|
||||
c, err := processCollectionPermissions(app, cr, u, w, r)
|
||||
if c == nil || err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
coll := newDisplayCollection(c, cr, page)
|
||||
coll.Language = lang
|
||||
coll.NavSuffix = fmt.Sprintf("/lang:%s", lang)
|
||||
|
||||
ttlPosts, err := app.db.GetCollLangTotalPosts(coll.ID, lang)
|
||||
if err != nil {
|
||||
log.Error("Unable to getCollLangTotalPosts: %s", err)
|
||||
}
|
||||
pagePosts := coll.Format.PostsPerPage()
|
||||
coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts)))
|
||||
if coll.TotalPages > 0 && page > coll.TotalPages {
|
||||
redirURL := fmt.Sprintf("/lang:%s/page/%d", lang, coll.TotalPages)
|
||||
if !app.cfg.App.SingleUser {
|
||||
redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, redirURL}
|
||||
}
|
||||
|
||||
coll.Posts, _ = app.db.GetLangPosts(app.cfg, c, lang, page, cr.isCollOwner)
|
||||
if err != nil {
|
||||
return ErrCollectionPageNotFound
|
||||
}
|
||||
|
||||
// Serve collection
|
||||
displayPage := struct {
|
||||
CollectionPage
|
||||
Tag string
|
||||
}{
|
||||
CollectionPage: CollectionPage{
|
||||
DisplayCollection: coll,
|
||||
StaticPage: pageForReq(app, r),
|
||||
IsCustomDomain: cr.isCustomDomain,
|
||||
},
|
||||
Tag: lang,
|
||||
}
|
||||
var owner *User
|
||||
if u != nil {
|
||||
displayPage.Username = u.Username
|
||||
displayPage.IsOwner = u.ID == coll.OwnerID
|
||||
if displayPage.IsOwner {
|
||||
// Add in needed information for users viewing their own collection
|
||||
owner = u
|
||||
displayPage.CanPin = true
|
||||
|
||||
pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
|
||||
if err != nil {
|
||||
log.Error("unable to fetch collections: %v", err)
|
||||
}
|
||||
displayPage.Collections = pubColls
|
||||
}
|
||||
}
|
||||
isOwner := owner != nil
|
||||
if !isOwner {
|
||||
// Current user doesn't own collection; retrieve owner information
|
||||
owner, err = app.db.GetUserByID(coll.OwnerID)
|
||||
if err != nil {
|
||||
// Log the error and just continue
|
||||
log.Error("Error getting user for collection: %v", err)
|
||||
}
|
||||
if owner.IsSilenced() {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
}
|
||||
displayPage.Silenced = owner != nil && owner.IsSilenced()
|
||||
displayPage.Owner = owner
|
||||
coll.Owner = displayPage.Owner
|
||||
// Add more data
|
||||
// TODO: fix this mess of collections inside collections
|
||||
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
|
||||
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
|
||||
|
||||
collTmpl := "collection"
|
||||
if app.cfg.App.Chorus {
|
||||
collTmpl = "chorus-collection"
|
||||
}
|
||||
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
|
||||
if err != nil {
|
||||
log.Error("Unable to render collection lang page: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
slug := vars["slug"]
|
||||
|
@ -1038,7 +1284,7 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error
|
|||
}
|
||||
}
|
||||
|
||||
err = app.db.UpdateCollection(&c, collAlias)
|
||||
err = app.db.UpdateCollection(app, &c, collAlias)
|
||||
if err != nil {
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
if reqJSON {
|
||||
|
@ -1150,3 +1396,43 @@ func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool {
|
|||
}
|
||||
return authd
|
||||
}
|
||||
|
||||
func logOutCollection(app *App, alias string, w http.ResponseWriter, r *http.Request) error {
|
||||
session, err := app.sessionStore.Get(r, blogPassCookieName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove this from map of blogs logged into
|
||||
delete(session.Values, alias)
|
||||
|
||||
// If not auth'd with any blog, delete entire cookie
|
||||
if len(session.Values) == 0 {
|
||||
session.Options.MaxAge = -1
|
||||
}
|
||||
return session.Save(r, w)
|
||||
}
|
||||
|
||||
func handleLogOutCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
alias := collectionAliasFromReq(r)
|
||||
var c *Collection
|
||||
var err error
|
||||
if app.cfg.App.SingleUser {
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
} else {
|
||||
c, err = app.db.GetCollection(alias)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.IsProtected() {
|
||||
// Invalid to log out of this collection
|
||||
return ErrCollectionPageNotFound
|
||||
}
|
||||
|
||||
err = logOutCollection(app, c.Alias, w, r)
|
||||
if err != nil {
|
||||
addSessionFlash(app, w, r, "Logging out failed. Try clearing cookies for this site, instead.", nil)
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
|
||||
}
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
[server]
|
||||
hidden_host =
|
||||
port = 8080
|
||||
|
||||
[database]
|
||||
type = mysql
|
||||
username = root
|
||||
password = changeme
|
||||
database = writefreely
|
||||
host = db
|
||||
port = 3306
|
||||
|
||||
[app]
|
||||
site_name = WriteFreely Example Blog!
|
||||
host = http://localhost:8080
|
||||
theme = write
|
||||
disable_js = false
|
||||
webfonts = true
|
||||
single_user = true
|
||||
open_registration = false
|
||||
min_username_len = 3
|
||||
max_blogs = 1
|
||||
federation = true
|
||||
public_stats = true
|
||||
private = false
|
||||
update_checks = true
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,9 +12,12 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
"github.com/go-ini/ini"
|
||||
"github.com/writeas/web-core/log"
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -45,6 +48,8 @@ type (
|
|||
|
||||
HashSeed string `ini:"hash_seed"`
|
||||
|
||||
GopherPort int `ini:"gopher_port"`
|
||||
|
||||
Dev bool `ini:"-"`
|
||||
}
|
||||
|
||||
|
@ -57,6 +62,7 @@ type (
|
|||
Database string `ini:"database"`
|
||||
Host string `ini:"host"`
|
||||
Port int `ini:"port"`
|
||||
TLS bool `ini:"tls"`
|
||||
}
|
||||
|
||||
WriteAsOauthCfg struct {
|
||||
|
@ -78,6 +84,15 @@ type (
|
|||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
GiteaOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
Host string `ini:"host"`
|
||||
DisplayName string `ini:"display_name"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
SlackOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
|
@ -86,6 +101,24 @@ type (
|
|||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
GenericOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
Host string `ini:"host"`
|
||||
DisplayName string `ini:"display_name"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
TokenEndpoint string `ini:"token_endpoint"`
|
||||
InspectEndpoint string `ini:"inspect_endpoint"`
|
||||
AuthEndpoint string `ini:"auth_endpoint"`
|
||||
Scope string `ini:"scope"`
|
||||
AllowDisconnect bool `ini:"allow_disconnect"`
|
||||
MapUserID string `ini:"map_user_id"`
|
||||
MapUsername string `ini:"map_username"`
|
||||
MapDisplayName string `ini:"map_display_name"`
|
||||
MapEmail string `ini:"map_email"`
|
||||
}
|
||||
|
||||
// AppCfg holds values that affect how the application functions
|
||||
AppCfg struct {
|
||||
SiteName string `ini:"site_name"`
|
||||
|
@ -109,12 +142,16 @@ type (
|
|||
// Users
|
||||
SingleUser bool `ini:"single_user"`
|
||||
OpenRegistration bool `ini:"open_registration"`
|
||||
OpenDeletion bool `ini:"open_deletion"`
|
||||
MinUsernameLen int `ini:"min_username_len"`
|
||||
MaxBlogs int `ini:"max_blogs"`
|
||||
|
||||
// Options for public instances
|
||||
// Federation
|
||||
Federation bool `ini:"federation"`
|
||||
PublicStats bool `ini:"public_stats"`
|
||||
Federation bool `ini:"federation"`
|
||||
PublicStats bool `ini:"public_stats"`
|
||||
Monetization bool `ini:"monetization"`
|
||||
NotesOnly bool `ini:"notes_only"`
|
||||
|
||||
// Access
|
||||
Private bool `ini:"private"`
|
||||
|
@ -128,6 +165,14 @@ type (
|
|||
|
||||
// Check for Updates
|
||||
UpdateChecks bool `ini:"update_checks"`
|
||||
|
||||
// Disable password authentication if use only Oauth
|
||||
DisablePasswordAuth bool `ini:"disable_password_auth"`
|
||||
}
|
||||
|
||||
EmailCfg struct {
|
||||
Domain string `ini:"domain"`
|
||||
MailgunPrivate string `ini:"mailgun_private"`
|
||||
}
|
||||
|
||||
// Config holds the complete configuration for running a writefreely instance
|
||||
|
@ -135,9 +180,12 @@ type (
|
|||
Server ServerCfg `ini:"server"`
|
||||
Database DatabaseCfg `ini:"database"`
|
||||
App AppCfg `ini:"app"`
|
||||
Email EmailCfg `ini:"email"`
|
||||
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
|
||||
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
|
||||
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
|
||||
GiteaOauth GiteaOauthCfg `ini:"oauth.gitea"`
|
||||
GenericOauth GenericOauthCfg `ini:"oauth.generic"`
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -193,6 +241,10 @@ func (ac *AppCfg) LandingPath() string {
|
|||
return ac.Landing
|
||||
}
|
||||
|
||||
func (lc EmailCfg) Enabled() bool {
|
||||
return lc.Domain != "" && lc.MailgunPrivate != ""
|
||||
}
|
||||
|
||||
func (ac AppCfg) SignupPath() string {
|
||||
if !ac.OpenRegistration {
|
||||
return ""
|
||||
|
@ -219,6 +271,22 @@ func Load(fname string) (*Config, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Do any transformations
|
||||
u, err := url.Parse(uc.App.Host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := idna.ToASCII(u.Hostname())
|
||||
if err != nil {
|
||||
log.Error("idna.ToASCII for %s: %s", u.Hostname(), err)
|
||||
return nil, err
|
||||
}
|
||||
uc.App.Host = u.Scheme + "://" + d
|
||||
if u.Port() != "" {
|
||||
uc.App.Host += ":" + u.Port()
|
||||
}
|
||||
|
||||
return uc, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018, 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,14 +11,34 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"github.com/writeas/web-core/log"
|
||||
"golang.org/x/net/idna"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FriendlyHost returns the app's Host sans any schema
|
||||
func (ac AppCfg) FriendlyHost() string {
|
||||
return ac.Host[strings.Index(ac.Host, "://")+len("://"):]
|
||||
rawHost := ac.Host[strings.Index(ac.Host, "://")+len("://"):]
|
||||
|
||||
u, err := url.Parse(ac.Host)
|
||||
if err != nil {
|
||||
log.Error("url.Parse failed on %s: %s", ac.Host, err)
|
||||
return rawHost
|
||||
}
|
||||
d, err := idna.ToUnicode(u.Hostname())
|
||||
if err != nil {
|
||||
log.Error("idna.ToUnicode failed on %s: %s", ac.Host, err)
|
||||
return rawHost
|
||||
}
|
||||
|
||||
res := d
|
||||
if u.Port() != "" {
|
||||
res += ":" + u.Port()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (ac AppCfg) CanCreateBlogs(currentlyUsed uint64) bool {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -57,7 +57,7 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
Success: "{{ . | bold | faint }}: ",
|
||||
}
|
||||
selTmpls := &promptui.SelectTemplates{
|
||||
Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`),
|
||||
Selected: `{{.Label}} {{ . | faint }}`,
|
||||
}
|
||||
|
||||
var selPrompt promptui.Select
|
||||
|
@ -356,7 +356,7 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
if data.Config.App.Federation {
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Federation usage stats",
|
||||
Label: "Usage stats (active users, posts)",
|
||||
Items: []string{"Public", "Private"},
|
||||
}
|
||||
_, fedStatsType, err := selPrompt.Run()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
//go:build wflib
|
||||
// +build wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
//go:build !sqlite && !wflib
|
||||
// +build !sqlite,!wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
//go:build sqlite && !wflib
|
||||
// +build sqlite,!wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
724
database.go
724
database.go
File diff suppressed because it is too large
Load Diff
|
@ -18,13 +18,13 @@ func TestOAuthDatastore(t *testing.T) {
|
|||
driverName: "",
|
||||
}
|
||||
|
||||
state, err := ds.GenerateOAuthState(ctx, "test", "development", 0)
|
||||
state, err := ds.GenerateOAuthState(ctx, "test", "development", 0, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, state, 24)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = false", state)
|
||||
|
||||
_, _, _, err = ds.ValidateOAuthState(ctx, state)
|
||||
_, _, _, _, err = ds.ValidateOAuthState(ctx, state)
|
||||
assert.NoError(t, err)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = true", state)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -247,10 +247,7 @@ func (b *CreateTableSqlBuilder) ToSQL() (string, error) {
|
|||
}
|
||||
things = append(things, columnStr)
|
||||
}
|
||||
for _, constraint := range b.Constraints {
|
||||
things = append(things, constraint)
|
||||
}
|
||||
|
||||
things = append(things, b.Constraints...)
|
||||
if thingLen := len(things); thingLen > 0 {
|
||||
str.WriteString(" ( ")
|
||||
for i, thing := range things {
|
||||
|
|
1
db/tx.go
1
db/tx.go
|
@ -23,4 +23,3 @@ func RunTransactionWithOptions(ctx context.Context, db *sql.DB, txOpts *sql.TxOp
|
|||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,32 +1,47 @@
|
|||
version: "3"
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
volumes:
|
||||
- "web-data:/go/src/app"
|
||||
- "./config.ini.example:/go/src/app/config.ini"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
networks:
|
||||
- writefreely
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
db:
|
||||
image: "mariadb:latest"
|
||||
volumes:
|
||||
- "./schema.sql:/tmp/schema.sql"
|
||||
- db-data:/var/lib/mysql/data
|
||||
networks:
|
||||
- writefreely
|
||||
environment:
|
||||
- MYSQL_DATABASE=writefreely
|
||||
- MYSQL_ROOT_PASSWORD=changeme
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
web-data:
|
||||
web-keys:
|
||||
db-data:
|
||||
|
||||
networks:
|
||||
writefreely:
|
||||
external_writefreely:
|
||||
internal_writefreely:
|
||||
internal: true
|
||||
|
||||
services:
|
||||
writefreely-web:
|
||||
container_name: "writefreely-web"
|
||||
image: "writeas/writefreely:latest"
|
||||
|
||||
volumes:
|
||||
- "web-keys:/go/keys"
|
||||
- "./config.ini:/go/config.ini"
|
||||
|
||||
networks:
|
||||
- "internal_writefreely"
|
||||
- "external_writefreely"
|
||||
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
depends_on:
|
||||
- "writefreely-db"
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
writefreely-db:
|
||||
container_name: "writefreely-db"
|
||||
image: "mariadb:latest"
|
||||
|
||||
volumes:
|
||||
- "db-data:/var/lib/mysql/data"
|
||||
|
||||
networks:
|
||||
- "internal_writefreely"
|
||||
|
||||
environment:
|
||||
- MYSQL_DATABASE=writefreely
|
||||
- MYSQL_ROOT_PASSWORD=changeme
|
||||
|
||||
restart: unless-stopped
|
||||
|
|
|
@ -0,0 +1,462 @@
|
|||
/*
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aymerick/douceur/inliner"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/mailgun/mailgun-go"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/data"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/key"
|
||||
"github.com/writefreely/writefreely/spam"
|
||||
)
|
||||
|
||||
const (
|
||||
emailSendDelay = 15
|
||||
)
|
||||
|
||||
type (
|
||||
SubmittedSubscription struct {
|
||||
CollAlias string
|
||||
UserID int64
|
||||
|
||||
Email string `schema:"email" json:"email"`
|
||||
Web bool `schema:"web" json:"web"`
|
||||
Slug string `schema:"slug" json:"slug"`
|
||||
From string `schema:"from" json:"from"`
|
||||
}
|
||||
|
||||
EmailSubscriber struct {
|
||||
ID string
|
||||
CollID int64
|
||||
UserID sql.NullInt64
|
||||
Email sql.NullString
|
||||
Subscribed time.Time
|
||||
Token string
|
||||
Confirmed bool
|
||||
AllowExport bool
|
||||
acctEmail sql.NullString
|
||||
}
|
||||
)
|
||||
|
||||
func (es *EmailSubscriber) FinalEmail(keys *key.Keychain) string {
|
||||
if !es.UserID.Valid || es.Email.Valid {
|
||||
return es.Email.String
|
||||
}
|
||||
|
||||
decEmail, err := data.Decrypt(keys.EmailKey, []byte(es.acctEmail.String))
|
||||
if err != nil {
|
||||
log.Error("Error decrypting user email: %v", err)
|
||||
return ""
|
||||
}
|
||||
return string(decEmail)
|
||||
}
|
||||
|
||||
func (es *EmailSubscriber) SubscribedFriendly() string {
|
||||
return es.Subscribed.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
func handleCreateEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
reqJSON := IsJSON(r)
|
||||
vars := mux.Vars(r)
|
||||
var err error
|
||||
|
||||
ss := SubmittedSubscription{
|
||||
CollAlias: vars["alias"],
|
||||
}
|
||||
u := getUserSession(app, r)
|
||||
if u != nil {
|
||||
ss.UserID = u.ID
|
||||
}
|
||||
if reqJSON {
|
||||
// Decode JSON request
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
err = decoder.Decode(&ss)
|
||||
if err != nil {
|
||||
log.Error("Couldn't parse new subscription JSON request: %v\n", err)
|
||||
return ErrBadJSON
|
||||
}
|
||||
} else {
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
log.Error("Couldn't parse new subscription form request: %v\n", err)
|
||||
return ErrBadFormData
|
||||
}
|
||||
|
||||
err = app.formDecoder.Decode(&ss, r.PostForm)
|
||||
if err != nil {
|
||||
log.Error("Continuing, but error decoding new subscription form request: %v\n", err)
|
||||
//return ErrBadFormData
|
||||
}
|
||||
}
|
||||
|
||||
c, err := app.db.GetCollection(ss.CollAlias)
|
||||
if err != nil {
|
||||
log.Error("getCollection: %s", err)
|
||||
return err
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
from := c.CanonicalURL()
|
||||
isAuthorBanned, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if isAuthorBanned {
|
||||
log.Info("Author is silenced, so subscription is blocked.")
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
|
||||
if ss.Web {
|
||||
if u != nil && u.ID == c.OwnerID {
|
||||
from = "/" + c.Alias + "/"
|
||||
}
|
||||
from += ss.Slug
|
||||
}
|
||||
|
||||
if r.FormValue(spam.HoneypotFieldName()) != "" || r.FormValue("fake_password") != "" {
|
||||
log.Info("Honeypot field was filled out! Not subscribing.")
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
|
||||
if ss.Email == "" && ss.UserID < 1 {
|
||||
log.Info("No subscriber data. Not subscribing.")
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
|
||||
confirmed := app.db.IsSubscriberConfirmed(ss.Email)
|
||||
es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed)
|
||||
if err != nil {
|
||||
log.Error("addEmailSubscription: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Send confirmation email if needed
|
||||
if !confirmed {
|
||||
err = sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token)
|
||||
if err != nil {
|
||||
log.Error("Failed to send subscription confirmation email: %s", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if ss.Web {
|
||||
session, err := app.sessionStore.Get(r, userEmailCookieName)
|
||||
if err != nil {
|
||||
// The cookie should still save, even if there's an error.
|
||||
// Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
|
||||
log.Error("Getting user email cookie: %v; ignoring", err)
|
||||
}
|
||||
if confirmed {
|
||||
addSessionFlash(app, w, r, "<strong>Subscribed</strong>. You'll now receive future blog posts via email.", nil)
|
||||
} else {
|
||||
addSessionFlash(app, w, r, "Please check your email and <strong>click the confirmation link</strong> to subscribe.", nil)
|
||||
}
|
||||
session.Values[userEmailCookieVal] = ss.Email
|
||||
err = session.Save(r, w)
|
||||
if err != nil {
|
||||
log.Error("save email cookie: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
return impart.WriteSuccess(w, "", http.StatusAccepted)
|
||||
}
|
||||
|
||||
func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
alias := collectionAliasFromReq(r)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
subID := vars["subscriber"]
|
||||
email := r.FormValue("email")
|
||||
token := r.FormValue("t")
|
||||
slug := r.FormValue("slug")
|
||||
isWeb := r.Method == "GET"
|
||||
|
||||
// Display collection if this is a collection
|
||||
var c *Collection
|
||||
var err error
|
||||
if app.cfg.App.SingleUser {
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
} else {
|
||||
c, err = app.db.GetCollection(alias)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Get collection: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
from := c.CanonicalURL()
|
||||
|
||||
if subID != "" {
|
||||
// User unsubscribing via email, so assume action is taken by either current
|
||||
// user or not current user, and only use the request's information to
|
||||
// satisfy this unsubscribe, i.e. subscriberID and token.
|
||||
err = app.db.DeleteEmailSubscriber(subID, token)
|
||||
} else {
|
||||
// User unsubscribing through the web app, so assume action is taken by
|
||||
// currently-auth'd user.
|
||||
var userID int64
|
||||
u := getUserSession(app, r)
|
||||
if u != nil {
|
||||
// User is logged in
|
||||
userID = u.ID
|
||||
if userID == c.OwnerID {
|
||||
from = "/" + c.Alias + "/"
|
||||
}
|
||||
}
|
||||
if email == "" && userID <= 0 {
|
||||
// Get email address from saved cookie
|
||||
session, err := app.sessionStore.Get(r, userEmailCookieName)
|
||||
if err != nil {
|
||||
log.Error("Unable to get email cookie: %s", err)
|
||||
} else {
|
||||
email = session.Values[userEmailCookieVal].(string)
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" && userID <= 0 {
|
||||
err = fmt.Errorf("No subscriber given.")
|
||||
log.Error("Not deleting subscription: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Unable to delete subscriber: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if isWeb {
|
||||
from += slug
|
||||
addSessionFlash(app, w, r, "<strong>Unsubscribed</strong>. You will no longer receive these blog posts via email.", nil)
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
return impart.WriteSuccess(w, "", http.StatusAccepted)
|
||||
}
|
||||
|
||||
func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
alias := collectionAliasFromReq(r)
|
||||
subID := mux.Vars(r)["subscriber"]
|
||||
token := r.FormValue("t")
|
||||
|
||||
var c *Collection
|
||||
var err error
|
||||
if app.cfg.App.SingleUser {
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
} else {
|
||||
c, err = app.db.GetCollection(alias)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Get collection: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
from := c.CanonicalURL()
|
||||
|
||||
err = app.db.UpdateSubscriberConfirmed(subID, token)
|
||||
if err != nil {
|
||||
addSessionFlash(app, w, r, err.Error(), nil)
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
|
||||
addSessionFlash(app, w, r, "<strong>Confirmed</strong>! Thanks. Now you'll receive future blog posts via email.", nil)
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
|
||||
func emailPost(app *App, p *PublicPost, collID int64) error {
|
||||
p.augmentContent()
|
||||
|
||||
// Do some shortcode replacement.
|
||||
// Since the user is receiving this email, we can assume they're subscribed via email.
|
||||
p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates.</p>`, -1)
|
||||
|
||||
if p.HTMLContent == template.HTML("") {
|
||||
p.formatContent(app.cfg, false, false)
|
||||
}
|
||||
p.augmentReadingDestination()
|
||||
|
||||
title := p.Title.String
|
||||
if title != "" {
|
||||
title = p.Title.String + "\n\n"
|
||||
}
|
||||
plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content)
|
||||
plainMsg += `
|
||||
|
||||
---------------------------------------------------------------------------------
|
||||
|
||||
Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to.
|
||||
|
||||
Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%`
|
||||
|
||||
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
|
||||
m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg)
|
||||
replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo)
|
||||
if replyTo != "" {
|
||||
m.SetReplyTo(replyTo)
|
||||
}
|
||||
|
||||
subs, err := app.db.GetEmailSubscribers(collID, true)
|
||||
if err != nil {
|
||||
log.Error("Unable to get email subscribers: %v", err)
|
||||
return err
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if title != "" {
|
||||
title = string(`<h2 id="title">` + p.FormattedDisplayTitle() + `</h2>`)
|
||||
}
|
||||
m.AddTag("New post")
|
||||
|
||||
fontFam := "Lora, Palatino, Baskerville, serif"
|
||||
if p.IsSans() {
|
||||
fontFam = `"Open Sans", Tahoma, Arial, sans-serif`
|
||||
} else if p.IsMonospace() {
|
||||
fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace`
|
||||
}
|
||||
|
||||
// TODO: move this to a templated file and LESS-generated stylesheet
|
||||
fullHTML := `<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-size: 120%;
|
||||
font-family: ` + fontFam + `;
|
||||
margin: 1em 2em;
|
||||
}
|
||||
#article {
|
||||
line-height: 1.5;
|
||||
margin: 1.5em 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6, p, code {
|
||||
display: inline
|
||||
}
|
||||
img, iframe, video {
|
||||
max-width: 100%
|
||||
}
|
||||
#title {
|
||||
margin-bottom: 1em;
|
||||
display: block;
|
||||
}
|
||||
.intro {
|
||||
font-style: italic;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
div#footer {
|
||||
text-align: center;
|
||||
max-width: 35em;
|
||||
margin: 2em auto;
|
||||
}
|
||||
div#footer p {
|
||||
display: block;
|
||||
font-size: 0.86em;
|
||||
color: #666;
|
||||
}
|
||||
hr {
|
||||
border: 1px solid #ccc;
|
||||
margin: 2em 1em;
|
||||
}
|
||||
p#emailsub {
|
||||
text-align: center;
|
||||
display: inline-block !important;
|
||||
width: 100%;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="article">` + title + `<p class="intro">From <a href="` + p.CanonicalURL(app.cfg.App.Host) + `">` + p.DisplayCanonicalURL() + `</a></p>
|
||||
|
||||
` + string(p.HTMLContent) + `</div>
|
||||
<hr />
|
||||
<div id="footer">
|
||||
<p>Originally published on <a href="` + p.Collection.CanonicalURL() + `">` + p.Collection.DisplayTitle() + `</a>, a blog you subscribe to.</p>
|
||||
<p>Sent to %recipient.to%. <a href="` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%">Unsubscribe</a>.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// inline CSS
|
||||
html, err := inliner.Inline(fullHTML)
|
||||
if err != nil {
|
||||
log.Error("Unable to inline email HTML: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
m.SetHtml(html)
|
||||
|
||||
log.Info("[email] Adding %d recipient(s)", len(subs))
|
||||
for _, s := range subs {
|
||||
e := s.FinalEmail(app.keys)
|
||||
log.Info("[email] Adding %s", e)
|
||||
err = m.AddRecipientAndVariables(e, map[string]interface{}{
|
||||
"id": s.ID,
|
||||
"to": e,
|
||||
"token": s.Token,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Unable to add receipient %s: %s", e, err)
|
||||
}
|
||||
}
|
||||
|
||||
res, _, err := gun.Send(m)
|
||||
log.Info("[email] Send result: %s", res)
|
||||
if err != nil {
|
||||
log.Error("Unable to send post email: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error {
|
||||
if email == "" {
|
||||
return fmt.Errorf("You must supply an email to verify.")
|
||||
}
|
||||
|
||||
// Send email
|
||||
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
|
||||
|
||||
plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser):
|
||||
|
||||
` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + `
|
||||
|
||||
If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.`
|
||||
m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email))
|
||||
m.AddTag("Email Verification")
|
||||
|
||||
m.SetHtml(`<html>
|
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||
<div style="font-size: 1.2em;">
|
||||
<p>Confirm your subscription to <a href="` + c.CanonicalURL() + `">` + c.DisplayTitle() + `</a> to start receiving future posts:</p>
|
||||
<p><a href="` + c.CanonicalURL() + `email/confirm/` + subID + `?t=` + token + `">Subscribe to ` + c.DisplayTitle() + `</a></p>
|
||||
<p>If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`)
|
||||
gun.Send(m)
|
||||
|
||||
return nil
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -52,6 +52,8 @@ var (
|
|||
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
|
||||
|
||||
ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."}
|
||||
|
||||
ErrDisabledPasswordAuth = impart.HTTPError{http.StatusForbidden, "Password authentication is disabled."}
|
||||
)
|
||||
|
||||
// Post operation errors
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -110,7 +110,7 @@ func compileFullExport(app *App, u *User) *ExportUser {
|
|||
log.Error("unable to fetch collections: %v", err)
|
||||
}
|
||||
|
||||
posts, err := app.db.GetAnonymousPosts(u)
|
||||
posts, err := app.db.GetAnonymousPosts(u, 0)
|
||||
if err != nil {
|
||||
log.Error("unable to fetch anon posts: %v", err)
|
||||
}
|
||||
|
|
24
feed.go
24
feed.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -15,9 +15,9 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
. "github.com/gorilla/feeds"
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/gorilla/mux"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/web-core/log"
|
||||
)
|
||||
|
||||
|
@ -87,25 +87,29 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
|
|||
siteURL += "tag:" + tag
|
||||
}
|
||||
|
||||
feed := &Feed{
|
||||
feed := &feeds.Feed{
|
||||
Title: collectionTitle,
|
||||
Link: &Link{Href: siteURL},
|
||||
Link: &feeds.Link{Href: siteURL},
|
||||
Description: coll.Description,
|
||||
Author: &Author{author, ""},
|
||||
Author: &feeds.Author{author, ""},
|
||||
Created: time.Now(),
|
||||
}
|
||||
|
||||
var title, permalink string
|
||||
for _, p := range *coll.Posts {
|
||||
// Add necessary path back to the web browser for Web Monetization if needed
|
||||
p.Collection = coll.CollectionObj // augmentReadingDestination requires a populated Collection field
|
||||
p.augmentReadingDestination()
|
||||
// Create the item for the feed
|
||||
title = p.PlainDisplayTitle()
|
||||
permalink = fmt.Sprintf("%s%s", baseUrl, p.Slug.String)
|
||||
feed.Items = append(feed.Items, &Item{
|
||||
feed.Items = append(feed.Items, &feeds.Item{
|
||||
Id: fmt.Sprintf("%s%s", basePermalinkUrl, p.Slug.String),
|
||||
Title: title,
|
||||
Link: &Link{Href: permalink},
|
||||
Link: &feeds.Link{Href: permalink},
|
||||
Description: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>",
|
||||
Content: applyMarkdown([]byte(p.Content), "", app.cfg),
|
||||
Author: &Author{author, ""},
|
||||
Content: string(p.HTMLContent),
|
||||
Author: &feeds.Author{author, ""},
|
||||
Created: p.Created,
|
||||
Updated: p.Updated,
|
||||
})
|
||||
|
|
123
go.mod
123
go.mod
|
@ -1,65 +1,92 @@
|
|||
module github.com/writeas/writefreely
|
||||
module github.com/writefreely/writefreely
|
||||
|
||||
require (
|
||||
github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
|
||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.8.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/fatih/color v1.7.0
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d // indirect
|
||||
github.com/go-sql-driver/mysql v1.4.1
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
||||
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
|
||||
github.com/fatih/color v1.16.0
|
||||
github.com/go-ini/ini v1.67.0
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/go-test/deep v1.0.1 // indirect
|
||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
|
||||
github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8 // indirect
|
||||
github.com/gobuffalo/envy v1.9.0 // indirect
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
||||
github.com/gorilla/feeds v1.1.0
|
||||
github.com/gorilla/mux v1.7.0
|
||||
github.com/gorilla/schema v1.0.2
|
||||
github.com/gorilla/sessions v1.2.0
|
||||
github.com/guregu/null v3.4.0+incompatible
|
||||
github.com/hashicorp/go-multierror v1.0.0
|
||||
github.com/gorilla/csrf v1.7.2
|
||||
github.com/gorilla/feeds v1.1.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/schema v1.2.1
|
||||
github.com/gorilla/sessions v1.2.2
|
||||
github.com/guregu/null v4.0.0+incompatible
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
|
||||
github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect
|
||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
||||
github.com/lunixbochs/vtclean v1.0.0 // indirect
|
||||
github.com/manifoldco/promptui v0.3.2
|
||||
github.com/mattn/go-colorable v0.1.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.10.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.2
|
||||
github.com/mitchellh/go-wordwrap v1.0.0
|
||||
github.com/nicksnyder/go-i18n v1.10.0 // indirect
|
||||
github.com/mailgun/mailgun-go v2.0.0+incompatible
|
||||
github.com/manifoldco/promptui v0.9.0
|
||||
github.com/mattn/go-sqlite3 v1.14.21
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/mitchellh/go-wordwrap v1.0.1
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
|
||||
github.com/pelletier/go-toml v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.8.1 // indirect
|
||||
github.com/onsi/ginkgo v1.16.4 // indirect
|
||||
github.com/onsi/gomega v1.13.0 // indirect
|
||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
|
||||
github.com/stretchr/testify v1.3.0
|
||||
github.com/urfave/cli/v2 v2.1.1
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/urfave/cli/v2 v2.27.1
|
||||
github.com/writeas/activity v0.1.2
|
||||
github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2
|
||||
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835
|
||||
github.com/writeas/go-strip-markdown/v2 v2.1.1
|
||||
github.com/writeas/go-webfinger v1.1.0
|
||||
github.com/writeas/httpsig v1.0.0
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d
|
||||
github.com/writeas/import v0.2.0
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
|
||||
github.com/writeas/nerds v1.0.0
|
||||
github.com/writeas/saturday v1.7.1
|
||||
github.com/writeas/impart v1.1.1
|
||||
github.com/writeas/import v0.2.1
|
||||
github.com/writeas/monday v1.3.0
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
|
||||
github.com/writeas/slug v1.2.0
|
||||
github.com/writeas/web-core v1.2.0
|
||||
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431
|
||||
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b
|
||||
github.com/writefreely/go-nodeinfo v1.2.0
|
||||
golang.org/dl v0.0.0-20200319204010-bf12898a6070 // indirect
|
||||
golang.org/x/crypto v0.0.0-20200109152110-61a87790db17
|
||||
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
|
||||
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect
|
||||
google.golang.org/appengine v1.4.0 // indirect
|
||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
|
||||
gopkg.in/ini.v1 v1.41.0
|
||||
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b // indirect
|
||||
golang.org/x/crypto v0.21.0
|
||||
golang.org/x/net v0.22.0
|
||||
)
|
||||
|
||||
go 1.13
|
||||
require (
|
||||
code.as/core/socks v1.0.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/beevik/etree v1.1.0 // indirect
|
||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect
|
||||
github.com/fatih/structs v1.1.0 // indirect
|
||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe // indirect
|
||||
github.com/gofrs/uuid v3.3.0+incompatible // indirect
|
||||
github.com/gologme/log v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/joho/godotenv v1.3.0 // indirect
|
||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.9.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sasha-s/go-deadlock v0.3.1 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/writeas/go-writeas/v2 v2.0.2 // indirect
|
||||
github.com/writeas/openssl-go v1.0.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
gopkg.in/ini.v1 v1.62.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
go 1.19
|
||||
|
|
373
go.sum
373
go.sum
|
@ -1,16 +1,16 @@
|
|||
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
|
||||
code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/alecthomas/gometalinter v2.0.11+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
|
||||
github.com/alecthomas/gometalinter v3.0.0+incompatible h1:e9Zfvfytsw/e6Kd/PYd75wggK+/kX5Xn8IYDUKyc5fU=
|
||||
github.com/alecthomas/gometalinter v3.0.0+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 h1:jWNY1NDg6a/c8RSXkai7IX6UOhir0LD39I4Dukg+4Ks=
|
||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8=
|
||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
||||
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
||||
|
@ -20,104 +20,142 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
|
|||
github.com/clbanning/mxj v1.8.3/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfDSMuaPjBr4cf6k7pwQQANm/yLKU=
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0=
|
||||
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
|
||||
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk=
|
||||
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/go-fed/httpsig v0.1.0 h1:6F2OxRVnNTN4OPN+Mc2jxs2WEay9/qiHT/jphlvAwIY=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d h1:+uoOvOnNDgsYbWtAij4xP6Rgir3eJGjocFPxBJETU/U=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
|
||||
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo=
|
||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
||||
github.com/gobuffalo/envy v1.9.0 h1:eZR0DuEgVLfeIb1zIKt3bT4YovIMf9O9LXQeCZLXpqE=
|
||||
github.com/gobuffalo/envy v1.9.0/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
|
||||
github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
|
||||
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8 h1:WD8iJ37bRNwvETMfVTusVSAi0WdXTpfNVGY2aHycNKY=
|
||||
github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
|
||||
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg=
|
||||
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c=
|
||||
github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY=
|
||||
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
|
||||
github.com/gorilla/feeds v1.1.0 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk=
|
||||
github.com/gorilla/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||
github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U=
|
||||
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/schema v1.0.2 h1:sAgNfOcNYvdDSrzGHVy9nzCQahG+qmsg+nE8dK85QRA=
|
||||
github.com/gorilla/schema v1.0.2/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
|
||||
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/guregu/null v3.4.0+incompatible h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM=
|
||||
github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
|
||||
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/feeds v1.1.2 h1:pxzZ5PD3RJdhFH2FsJJ4x6PqMqbgFk1+Vez4XWBW8Iw=
|
||||
github.com/gorilla/feeds v1.1.2/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
|
||||
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/schema v1.2.1 h1:tjDxcmdb+siIqkTNoV+qRH2mjYdr2hHe5MKXbp61ziM=
|
||||
github.com/gorilla/schema v1.2.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
|
||||
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||
github.com/guregu/null v4.0.0+incompatible h1:4zw0ckM7ECd6FNNddc3Fu4aty9nTlpkkzH7dPn4/4Gw=
|
||||
github.com/guregu/null v4.0.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
|
||||
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
|
||||
github.com/jteeuwen/go-bindata v3.0.7+incompatible h1:91Uy4d9SYVr1kyTJ15wJsog+esAZZl7JmEfTkwmhJts=
|
||||
github.com/jteeuwen/go-bindata v3.0.7+incompatible/go.mod h1:JVvhzYOiGBnFSYRyV00iY8q7/0PThjIYav1p9h5dmKs=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
|
||||
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI=
|
||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g=
|
||||
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw=
|
||||
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
||||
github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
|
||||
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
||||
github.com/manifoldco/promptui v0.3.2 h1:rir7oByTERac6jhpHUPErHuopoRDvO3jxS+FdadEns8=
|
||||
github.com/manifoldco/promptui v0.3.2/go.mod h1:8JU+igZ+eeiiRku4T5BjtKh2ms8sziGpSYl1gN8Bazw=
|
||||
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.0 h1:v2XXALHHh6zHfYTJ+cSkwtyffnaOyR1MXaA91mTrb8o=
|
||||
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4=
|
||||
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
|
||||
github.com/nicksnyder/go-i18n v1.10.0 h1:5AzlPKvXBH4qBzmZ09Ua9Gipyruv6uApMcrNZdo96+Q=
|
||||
github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q=
|
||||
github.com/mailgun/mailgun-go v2.0.0+incompatible h1:0FoRHWwMUctnd8KIR3vtZbqdfjpIMxOZgcSa51s8F8o=
|
||||
github.com/mailgun/mailgun-go v2.0.0+incompatible/go.mod h1:NWTyU+O4aczg/nsGhQnvHL6v2n5Gy6Sv5tNDVvC6FbU=
|
||||
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
||||
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.21 h1:IXocQLOykluc3xPE0Lvy8FtggMz1G+U3mEjg+0zGizc=
|
||||
github.com/mattn/go-sqlite3 v1.14.21/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
|
||||
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
|
||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.13.0 h1:7lLHu94wT9Ij0o6EWWclhu0aOh32VxhkwEJvzuWPeak=
|
||||
github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
|
||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
|
||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
|
||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
|
||||
github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY=
|
||||
|
@ -125,86 +163,157 @@ github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1
|
|||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9 h1:vY5WqiEon0ZSTGM3ayVVi+twaHKHDFUVloaQ/wug9/c=
|
||||
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk=
|
||||
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
|
||||
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
||||
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||
github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
|
||||
github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0=
|
||||
github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89 h1:NJhzq9aTccL3SSSZMrcnYhkD6sObdY9otNZ1X6/ZKNE=
|
||||
github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A=
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 h1:DUsp4OhdfI+e6iUqcPQlwx8QYXuUDsToTz/x82D3Zuo=
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
||||
github.com/writeas/go-writeas v1.1.0 h1:WHGm6wriBkxYAOGbvriXH8DlMUGOi6jhSZLUZKQ+4mQ=
|
||||
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835 h1:bm/7gYo6y3GxtTa1qyUFyCk29CTnBAKt7z4D2MASYrw=
|
||||
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o=
|
||||
github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28=
|
||||
github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA=
|
||||
github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q=
|
||||
github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
||||
github.com/writeas/go-writeas v1.1.0/go.mod h1:oh9U1rWaiE0p3kzdKwwvOpNXgp0P0IELI7OLOwV4fkA=
|
||||
github.com/writeas/go-writeas/v2 v2.0.2 h1:akvdMg89U5oBJiCkBwOXljVLTqP354uN6qnG2oOMrbk=
|
||||
github.com/writeas/go-writeas/v2 v2.0.2/go.mod h1:9sjczQJKmru925fLzg0usrU1R1tE4vBmQtGnItUMR0M=
|
||||
github.com/writeas/httpsig v1.0.0 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A=
|
||||
github.com/writeas/httpsig v1.0.0/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY=
|
||||
github.com/writeas/impart v1.1.0 h1:nPnoO211VscNkp/gnzir5UwCDEvdHThL5uELU60NFSE=
|
||||
github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d h1:PK7DOj3JE6MGf647esPrKzXEHFjGWX2hl22uX79ixaE=
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/import v0.2.0 h1:Ov23JW9Rnjxk06rki1Spar45bNX647HhwhAZj3flJiY=
|
||||
github.com/writeas/import v0.2.0/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c=
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ=
|
||||
github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo=
|
||||
github.com/writeas/nerds v1.0.0/go.mod h1:Gn2bHy1EwRcpXeB7ZhVmuUwiweK0e+JllNf66gvNLdU=
|
||||
github.com/writeas/impart v1.1.1 h1:RyA9+CqbdbDuz53k+nXCWUY+NlEkdyw6+nWanxSBl5o=
|
||||
github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/import v0.2.1 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg=
|
||||
github.com/writeas/import v0.2.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
|
||||
github.com/writeas/monday v1.3.0 h1:h51wJ0DULXIDZ1w11zutLL7YCBRO5LznXISSzqVLZeA=
|
||||
github.com/writeas/monday v1.3.0/go.mod h1:9/CdGLDdIeAvzvf4oeihX++PE/qXUT2+tUlPQKCfRWY=
|
||||
github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o=
|
||||
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA=
|
||||
github.com/writeas/saturday v1.6.0/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE=
|
||||
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 h1:PozPZ29CQ/xt6ym/+FvIz+KvKEObSSc5ye+95zbTjVU=
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
|
||||
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
|
||||
github.com/writeas/web-core v1.2.0 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0=
|
||||
github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI=
|
||||
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431 h1:ruqL2u87k504PXkR/fC4DcfZyyHmCindlpjOQKmyOsY=
|
||||
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431/go.mod h1:7+idL4Y4woF7MnUfNX2mvkaQ8nLIJXths2y5iYPtA3k=
|
||||
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b h1:h3NzB8OZ50NNi5k9yrFeyFszt3LyqyVK4+xUHFYY8B0=
|
||||
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b/go.mod h1:T2UVVzt+R5KSSZe2xRSytnwc2M9AoDegi7foeIsik+M=
|
||||
github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
|
||||
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
|
||||
golang.org/dl v0.0.0-20200319204010-bf12898a6070 h1:m3RoSUFYtel4F/gCw0tosY5Exe7hm2NbeNv/737FbSo=
|
||||
golang.org/dl v0.0.0-20200319204010-bf12898a6070/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ=
|
||||
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200109152110-61a87790db17 h1:nVJ3guKA9qdkEQ3TUdXI9QSINo2CUPM/cySEvw2w8I0=
|
||||
golang.org/x/crypto v0.0.0-20200109152110-61a87790db17/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 h1:rJm0LuqUjoDhSk2zO9ISMSToQxGz7Os2jRiOL8AWu4c=
|
||||
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/tools v0.0.0-20181122213734-04b5d21e00f1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 h1:bPP/rGuN1LUM0eaEwo6vnP6OfIWJzJBulzGUiKLjjSY=
|
||||
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c h1:vTxShRUnK60yd8DZU+f95p1zSLj814+5CuEh7NjF2/Y=
|
||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c/go.mod h1:3HH7i1SgMqlzxCcBmUHW657sD4Kvv9sC3HpL3YukzwA=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.41.0 h1:Ka3ViY6gNYSKiVy71zXBEqKplnV35ImDLVG+8uoIklE=
|
||||
gopkg.in/ini.v1 v1.41.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
|
||||
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b h1:rPAdjgXks4ToezTjygsnKZroxKVnA1L35DSpsJXPtfc=
|
||||
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* Copyright © 2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/go-gopher"
|
||||
)
|
||||
|
||||
func initGopher(apper Apper) {
|
||||
handler := NewWFHandler(apper)
|
||||
|
||||
gopher.HandleFunc("/", handler.Gopher(handleGopher))
|
||||
log.Info("Serving on gopher://localhost:%d", apper.App().Config().Server.GopherPort)
|
||||
gopher.ListenAndServe(fmt.Sprintf(":%d", apper.App().Config().Server.GopherPort), nil)
|
||||
}
|
||||
|
||||
// Utility function to strip the URL from the hostname provided by app.cfg.App.Host
|
||||
func stripHostProtocol(app *App) string {
|
||||
u, err := url.Parse(app.cfg.App.Host)
|
||||
if err != nil {
|
||||
// Fall back to host, with scheme stripped
|
||||
return string(regexp.MustCompile("^.*://").ReplaceAll([]byte(app.cfg.App.Host), []byte("")))
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
func handleGopher(app *App, w gopher.ResponseWriter, r *gopher.Request) error {
|
||||
parts := strings.Split(r.Selector, "/")
|
||||
if app.cfg.App.SingleUser {
|
||||
if parts[1] != "" {
|
||||
return handleGopherCollectionPost(app, w, r)
|
||||
}
|
||||
return handleGopherCollection(app, w, r)
|
||||
}
|
||||
|
||||
// Show all public collections (a gopher Reader view, essentially)
|
||||
if len(parts) == 3 {
|
||||
return handleGopherCollection(app, w, r)
|
||||
}
|
||||
|
||||
w.WriteInfo(fmt.Sprintf("Welcome to %s", app.cfg.App.SiteName))
|
||||
|
||||
colls, err := app.db.GetPublicCollections(app.cfg.App.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range *colls {
|
||||
w.WriteItem(&gopher.Item{
|
||||
Host: stripHostProtocol(app),
|
||||
Port: app.cfg.Server.GopherPort,
|
||||
Type: gopher.DIRECTORY,
|
||||
Description: c.DisplayTitle(),
|
||||
Selector: "/" + c.Alias + "/",
|
||||
})
|
||||
}
|
||||
return w.End()
|
||||
}
|
||||
|
||||
func handleGopherCollection(app *App, w gopher.ResponseWriter, r *gopher.Request) error {
|
||||
var collAlias, slug string
|
||||
var c *Collection
|
||||
var err error
|
||||
var baseSel = "/"
|
||||
|
||||
parts := strings.Split(r.Selector, "/")
|
||||
if app.cfg.App.SingleUser {
|
||||
// sanity check
|
||||
slug = parts[1]
|
||||
if slug != "" {
|
||||
return handleGopherCollectionPost(app, w, r)
|
||||
}
|
||||
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
collAlias = parts[1]
|
||||
slug = parts[2]
|
||||
if slug != "" {
|
||||
return handleGopherCollectionPost(app, w, r)
|
||||
}
|
||||
|
||||
c, err = app.db.GetCollection(collAlias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseSel = "/" + c.Alias + "/"
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
w.WriteInfo(c.DisplayTitle())
|
||||
if c.Description != "" {
|
||||
w.WriteInfo(c.Description)
|
||||
}
|
||||
|
||||
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range *posts {
|
||||
w.WriteItem(&gopher.Item{
|
||||
Port: app.cfg.Server.GopherPort,
|
||||
Host: stripHostProtocol(app),
|
||||
Type: gopher.FILE,
|
||||
Description: p.CreatedDate() + " - " + p.DisplayTitle(),
|
||||
Selector: baseSel + p.Slug.String,
|
||||
})
|
||||
}
|
||||
return w.End()
|
||||
}
|
||||
|
||||
func handleGopherCollectionPost(app *App, w gopher.ResponseWriter, r *gopher.Request) error {
|
||||
var collAlias, slug string
|
||||
var c *Collection
|
||||
var err error
|
||||
|
||||
parts := strings.Split(r.Selector, "/")
|
||||
if app.cfg.App.SingleUser {
|
||||
slug = parts[1]
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
collAlias = parts[1]
|
||||
slug = parts[2]
|
||||
c, err = app.db.GetCollection(collAlias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
p, err := app.db.GetPost(slug, c.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b := bytes.Buffer{}
|
||||
if p.Title.String != "" {
|
||||
b.WriteString(p.Title.String + "\n")
|
||||
}
|
||||
b.WriteString(p.DisplayDate + "\n\n")
|
||||
b.WriteString(p.Content)
|
||||
io.Copy(w, &b)
|
||||
|
||||
return w.End()
|
||||
}
|
122
handle.go
122
handle.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -23,8 +23,9 @@ import (
|
|||
"github.com/gorilla/sessions"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"github.com/writefreely/go-gopher"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
)
|
||||
|
||||
// UserLevel represents the required user level for accessing an endpoint
|
||||
|
@ -64,6 +65,7 @@ func UserLevelReader(cfg *config.Config) UserLevel {
|
|||
|
||||
type (
|
||||
handlerFunc func(app *App, w http.ResponseWriter, r *http.Request) error
|
||||
gopherFunc func(app *App, w gopher.ResponseWriter, r *gopher.Request) error
|
||||
userHandlerFunc func(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
||||
userApperHandlerFunc func(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error
|
||||
dataHandlerFunc func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
|
||||
|
@ -153,8 +155,14 @@ func (h *Handler) User(f userHandlerFunc) http.HandlerFunc {
|
|||
err := f(h.app.App(), u, w, r)
|
||||
if err == nil {
|
||||
status = http.StatusOK
|
||||
} else if err, ok := err.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
} else if impErr, ok := err.(impart.HTTPError); ok {
|
||||
status = impErr.Status
|
||||
if impErr == ErrUserNotFound {
|
||||
log.Info("Logged-in user not found. Logging out.")
|
||||
sendRedirect(w, http.StatusFound, "/me/logout?to="+h.app.App().cfg.App.LandingPath())
|
||||
// Reset err so handleHTTPError does nothing
|
||||
err = nil
|
||||
}
|
||||
} else {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
|
@ -254,7 +262,7 @@ func apiAuth(app *App, r *http.Request) (*User, error) {
|
|||
return u, nil
|
||||
}
|
||||
|
||||
// optionaAPIAuth is used for endpoints that accept authenticated requests via
|
||||
// optionalAPIAuth is used for endpoints that accept authenticated requests via
|
||||
// Authorization header or cookie, unlike apiAuth. It returns a different err
|
||||
// in the case where no Authorization header is present.
|
||||
func optionalAPIAuth(app *App, r *http.Request) (*User, error) {
|
||||
|
@ -285,6 +293,26 @@ func (h *Handler) UserAPI(f userHandlerFunc) http.HandlerFunc {
|
|||
return h.UserAll(false, f, apiAuth)
|
||||
}
|
||||
|
||||
// UserWebAPI handles endpoints that accept a user authorized either via the web (cookies) or an Authorization header.
|
||||
func (h *Handler) UserWebAPI(f userHandlerFunc) http.HandlerFunc {
|
||||
return h.UserAll(false, f, func(app *App, r *http.Request) (*User, error) {
|
||||
// Authorize user via cookies
|
||||
u := getUserSession(app, r)
|
||||
if u != nil {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Fall back to access token, since user isn't logged in via web
|
||||
var err error
|
||||
u, err = apiAuth(app, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
handleFunc := func() error {
|
||||
|
@ -552,6 +580,38 @@ func (h *Handler) All(f handlerFunc) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *Handler) PlainTextAPI(f handlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleTextError(w, r, func() error {
|
||||
// TODO: return correct "success" status
|
||||
status := 200
|
||||
start := time.Now()
|
||||
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
log.Error("%s:\n%s", e, debug.Stack())
|
||||
status = http.StatusInternalServerError
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, "Something didn't work quite right. The robots have alerted the humans.")
|
||||
}
|
||||
|
||||
log.Info(fmt.Sprintf("\"%s %s\" %d %s \"%s\" \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent(), r.Host))
|
||||
}()
|
||||
|
||||
err := f(h.app.App(), w, r)
|
||||
if err != nil {
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
} else {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}())
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) OAuth(f handlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleOAuthError(w, r, func() error {
|
||||
|
@ -599,6 +659,9 @@ func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
|
|||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
// Allow any origin, as public endpoints are handled in here
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
if h.app.App().cfg.App.Private {
|
||||
// This instance is private, so ensure it's being accessed by a valid user
|
||||
// Check if authenticated with an access token
|
||||
|
@ -755,7 +818,7 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
|||
return
|
||||
} else if err.Status == http.StatusNotFound {
|
||||
w.WriteHeader(err.Status)
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
||||
if IsActivityPubRequest(r) {
|
||||
// This is a fediverse request; simply return the header
|
||||
return
|
||||
}
|
||||
|
@ -817,6 +880,26 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error)
|
|||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
}
|
||||
|
||||
func (h *Handler) handleTextError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
if err.Status >= 300 && err.Status < 400 {
|
||||
sendRedirect(w, err.Status, err.Message)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(err.Status)
|
||||
fmt.Fprintf(w, http.StatusText(err.Status))
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "This is an unhelpful error message for a miscellaneous internal error.")
|
||||
}
|
||||
|
||||
func (h *Handler) handleOAuthError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
|
@ -898,8 +981,33 @@ func (h *Handler) LogHandlerFunc(f http.HandlerFunc) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Gopher(f gopherFunc) gopher.HandlerFunc {
|
||||
return func(w gopher.ResponseWriter, r *gopher.Request) {
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
log.Error("%s: %s", e, debug.Stack())
|
||||
w.WriteError("An internal error occurred")
|
||||
}
|
||||
log.Info("gopher: %s", r.Selector)
|
||||
}()
|
||||
|
||||
err := f(h.app.App(), w, r)
|
||||
if err != nil {
|
||||
log.Error("failed: %s", err)
|
||||
w.WriteError("the page failed for some reason (see logs)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendRedirect(w http.ResponseWriter, code int, location string) int {
|
||||
w.Header().Set("Location", location)
|
||||
w.WriteHeader(code)
|
||||
return code
|
||||
}
|
||||
|
||||
func cacheControl(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
29
invites.go
29
invites.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -19,9 +19,9 @@ import (
|
|||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/web-core/id"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
)
|
||||
|
||||
type Invite struct {
|
||||
|
@ -42,6 +42,18 @@ func (i Invite) Expired() bool {
|
|||
return i.Expires != nil && i.Expires.Before(time.Now())
|
||||
}
|
||||
|
||||
func (i Invite) Active(db *datastore) bool {
|
||||
if i.Expired() {
|
||||
return false
|
||||
}
|
||||
if i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
|
||||
if c := db.GetUsersInvitedCount(i.ID); c >= i.MaxUses.Int64 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (i Invite) ExpiresFriendly() string {
|
||||
return i.Expires.Format("January 2, 2006, 3:04 PM")
|
||||
}
|
||||
|
@ -66,6 +78,9 @@ func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Req
|
|||
|
||||
p.Silenced, err = app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view invites: %v", err)
|
||||
}
|
||||
|
||||
|
@ -109,7 +124,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re
|
|||
expDate = &ed
|
||||
}
|
||||
|
||||
inviteID := store.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6)
|
||||
inviteID := id.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6)
|
||||
err = app.db.CreateUserInvite(inviteID, u.ID, maxUses, expDate)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -158,12 +173,14 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := struct {
|
||||
page.StaticPage
|
||||
*OAuthButtons
|
||||
Error string
|
||||
Flashes []template.HTML
|
||||
Invite string
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
Invite: inviteCode,
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.cfg),
|
||||
Invite: inviteCode,
|
||||
}
|
||||
|
||||
if expired {
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"github.com/writeas/web-core/log"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PostJob struct {
|
||||
ID int64
|
||||
PostID string
|
||||
Action string
|
||||
Delay int64
|
||||
}
|
||||
|
||||
func addJob(app *App, p *PublicPost, action string, delay int64) error {
|
||||
j := &PostJob{
|
||||
PostID: p.ID,
|
||||
Action: action,
|
||||
Delay: delay,
|
||||
}
|
||||
return app.db.InsertJob(j)
|
||||
}
|
||||
|
||||
func startPublishJobsQueue(app *App) {
|
||||
t := time.NewTicker(62 * time.Second)
|
||||
for {
|
||||
log.Info("[jobs] Done.")
|
||||
<-t.C
|
||||
log.Info("[jobs] Fetching email publish jobs...")
|
||||
jobs, err := app.db.GetJobsToRun("email")
|
||||
if err != nil {
|
||||
log.Error("[jobs] %s - Skipping.", err)
|
||||
continue
|
||||
}
|
||||
log.Info("[jobs] Running %d email publish jobs...", len(jobs))
|
||||
err = runJobs(app, jobs, true)
|
||||
if err != nil {
|
||||
log.Error("[jobs] Failed: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runJobs(app *App, jobs []*PostJob, reqColl bool) error {
|
||||
for _, j := range jobs {
|
||||
p, err := app.db.GetPost(j.PostID, 0)
|
||||
if err != nil {
|
||||
log.Info("[job #%d] Unable to get post: %s", j.ID, err)
|
||||
continue
|
||||
}
|
||||
if !p.CollectionID.Valid && reqColl {
|
||||
log.Info("[job #%d] Post %s not part of a collection", j.ID, p.ID)
|
||||
app.db.DeleteJob(j.ID)
|
||||
continue
|
||||
}
|
||||
coll, err := app.db.GetCollectionByID(p.CollectionID.Int64)
|
||||
if err != nil {
|
||||
log.Info("[job #%d] Unable to get collection: %s", j.ID, err)
|
||||
continue
|
||||
}
|
||||
coll.hostName = app.cfg.App.Host
|
||||
coll.ForPublic()
|
||||
p.Collection = &CollectionObj{Collection: *coll}
|
||||
err = emailPost(app, p, p.Collection.ID)
|
||||
if err != nil {
|
||||
log.Error("[job #%d] Failed to email post %s", j.ID, p.ID)
|
||||
continue
|
||||
}
|
||||
log.Info("[job #%d] Success for post %s.", j.ID, p.ID)
|
||||
app.db.DeleteJob(j.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
10
key/key.go
10
key/key.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019, 2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -20,7 +20,7 @@ const (
|
|||
)
|
||||
|
||||
type Keychain struct {
|
||||
EmailKey, CookieAuthKey, CookieKey []byte
|
||||
EmailKey, CookieAuthKey, CookieKey, CSRFKey []byte
|
||||
}
|
||||
|
||||
// GenerateKeys generates necessary keys for the app on the given Keychain,
|
||||
|
@ -47,6 +47,12 @@ func (keys *Keychain) GenerateKeys() error {
|
|||
keyErrs = err
|
||||
}
|
||||
}
|
||||
if len(keys.CSRFKey) == 0 {
|
||||
keys.CSRFKey, err = GenerateBytes(EncKeysBytes)
|
||||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
}
|
||||
|
||||
return keyErrs
|
||||
}
|
||||
|
|
11
keys.go
11
keys.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2019, 2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,8 +12,7 @@ package writefreely
|
|||
|
||||
import (
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/key"
|
||||
"io/ioutil"
|
||||
"github.com/writefreely/writefreely/key"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
@ -26,6 +25,7 @@ var (
|
|||
emailKeyPath = filepath.Join(keysDir, "email.aes256")
|
||||
cookieAuthKeyPath = filepath.Join(keysDir, "cookies_auth.aes256")
|
||||
cookieKeyPath = filepath.Join(keysDir, "cookies_enc.aes256")
|
||||
csrfKeyPath = filepath.Join(keysDir, "csrf.aes256")
|
||||
)
|
||||
|
||||
// InitKeys loads encryption keys into memory via the given Apper interface
|
||||
|
@ -42,6 +42,7 @@ func initKeyPaths(app *App) {
|
|||
emailKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, emailKeyPath)
|
||||
cookieAuthKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieAuthKeyPath)
|
||||
cookieKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieKeyPath)
|
||||
csrfKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, csrfKeyPath)
|
||||
}
|
||||
|
||||
// generateKey generates a key at the given path used for the encryption of
|
||||
|
@ -50,7 +51,7 @@ func initKeyPaths(app *App) {
|
|||
func generateKey(path string) error {
|
||||
// Check if key file exists
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
log.Info("%s already exists. rm the file if you understand the consquences.", path)
|
||||
log.Info("%s already exists. rm the file if you understand the consequences.", path)
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
log.Error("%s", err)
|
||||
|
@ -63,7 +64,7 @@ func generateKey(path string) error {
|
|||
log.Error("FAILED. %s. Run writefreely --gen-keys again.", err)
|
||||
return err
|
||||
}
|
||||
err = ioutil.WriteFile(path, b, 0600)
|
||||
err = os.WriteFile(path, b, 0600)
|
||||
if err != nil {
|
||||
log.Error("FAILED writing file: %s", err)
|
||||
return err
|
||||
|
|
|
@ -5,6 +5,7 @@ all :
|
|||
lessc app.less --clean-css="--s1 --advanced" $(CSSDIR)write.css
|
||||
lessc fonts.less --clean-css="--s1 --advanced" $(CSSDIR)fonts.css
|
||||
lessc icons.less --clean-css="--s1 --advanced" $(CSSDIR)icons.css
|
||||
lessc prose.less --clean-css="--s1 --advanced" $(CSSDIR)prose.css
|
||||
|
||||
install :
|
||||
./install-less.sh
|
||||
|
|
|
@ -32,6 +32,19 @@ nav#admin {
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
&:not(.pages) {
|
||||
display: block;
|
||||
margin: 0.5em 0;
|
||||
a {
|
||||
margin-left: 0;
|
||||
.rounded(.25em);
|
||||
|
||||
&+a {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: #333;
|
||||
font-family: @sansFont;
|
||||
|
@ -47,6 +60,35 @@ nav#admin {
|
|||
background: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
&.sub {
|
||||
margin: 1em 0 2em;
|
||||
a:not(.toggle) {
|
||||
border: 0;
|
||||
border-bottom: 2px transparent solid;
|
||||
.rounded(0);
|
||||
padding: 0.5em;
|
||||
margin-left: 0.5em;
|
||||
margin-right: 0.5em;
|
||||
|
||||
&:hover {
|
||||
color: @primary;
|
||||
background: transparent;
|
||||
}
|
||||
&.selected {
|
||||
color: @primary;
|
||||
background: transparent;
|
||||
border-bottom-color: @primary;
|
||||
}
|
||||
&+a {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
a.toggle {
|
||||
margin-top: -0.5em;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
@import "post-temp";
|
||||
@import "effects";
|
||||
@import "admin";
|
||||
@import "login";
|
||||
@import "pages/error";
|
||||
@import "resources";
|
||||
@import "lib/elements";
|
||||
@import "lib/material";
|
||||
|
|
125
less/core.less
125
less/core.less
|
@ -1,15 +1,3 @@
|
|||
@primary: rgb(114, 120, 191);
|
||||
@secondary: rgb(114, 191, 133);
|
||||
@subheaders: #444;
|
||||
@headerTextColor: black;
|
||||
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif;
|
||||
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif;
|
||||
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace;
|
||||
@dangerCol: #e21d27;
|
||||
@errUrgentCol: #ecc63c;
|
||||
@proSelectedCol: #71D571;
|
||||
@textLinkColor: rgb(0, 0, 238);
|
||||
|
||||
body {
|
||||
font-family: @serifFont;
|
||||
font-size-adjust: 0.5;
|
||||
|
@ -81,7 +69,7 @@ body {
|
|||
font-size: 1.5em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.17em;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -222,6 +210,10 @@ body {
|
|||
pre {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.flash {
|
||||
text-align: center;
|
||||
margin-bottom: 4em;
|
||||
}
|
||||
}
|
||||
&#subpage {
|
||||
#wrapper {
|
||||
|
@ -405,6 +397,14 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
img {
|
||||
&.paid {
|
||||
height: 0.86em;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
}
|
||||
|
||||
nav#full-nav {
|
||||
margin: 0;
|
||||
|
||||
|
@ -527,7 +527,7 @@ pre, body#post article, #post .alert, #subpage .alert, body#collection article,
|
|||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
textarea, pre, body#post article, body#collection article p {
|
||||
textarea, input#title, pre, body#post article, body#collection article p {
|
||||
&.norm, &.sans, &.wrap {
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap; /* CSS 3 */
|
||||
|
@ -537,7 +537,7 @@ textarea, pre, body#post article, body#collection article p {
|
|||
word-wrap: break-word; /* Internet Explorer 5.5+ */
|
||||
}
|
||||
}
|
||||
textarea, pre, body#post article, body#collection article, body#subpage article, span, .font {
|
||||
textarea, input#title, pre, body#post article, body#collection article, body#subpage article, span, .font {
|
||||
&.norm {
|
||||
font-family: @serifFont;
|
||||
}
|
||||
|
@ -699,6 +699,7 @@ table.downloads {
|
|||
|
||||
select.inputform, textarea.inputform {
|
||||
border: 1px solid #999;
|
||||
background: white;
|
||||
}
|
||||
|
||||
input, button, select.inputform, textarea.inputform, a.btn {
|
||||
|
@ -743,6 +744,31 @@ input, button, select.inputform, textarea.inputform, a.btn {
|
|||
}
|
||||
}
|
||||
|
||||
.btn.pager {
|
||||
border: 1px solid @lightNavBorder;
|
||||
font-size: .86em;
|
||||
padding: .5em 1em;
|
||||
white-space: nowrap;
|
||||
font-family: @sansFont;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: @lightNavBorder;
|
||||
}
|
||||
}
|
||||
|
||||
.btn.cta.secondary, input[type=submit].secondary {
|
||||
background: transparent;
|
||||
color: @primary;
|
||||
&:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
.btn.cta.disabled {
|
||||
background-color: desaturate(@primary, 100%) !important;
|
||||
border-color: desaturate(@primary, 100%) !important;
|
||||
}
|
||||
|
||||
div.flat-select {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
@ -805,6 +831,9 @@ input {
|
|||
margin: 0 auto 3em;
|
||||
font-size: 1.2em;
|
||||
|
||||
&.toosmall {
|
||||
max-width: 25em;
|
||||
}
|
||||
&.tight {
|
||||
max-width: 30em;
|
||||
}
|
||||
|
@ -965,7 +994,12 @@ footer.contain-me {
|
|||
}
|
||||
ul {
|
||||
&.collections {
|
||||
padding-left: 0;
|
||||
margin-left: 0;
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
li {
|
||||
&.collection {
|
||||
a.title {
|
||||
|
@ -1039,6 +1073,19 @@ li {
|
|||
background-color: #dff0d8;
|
||||
border-color: #d6e9c6;
|
||||
}
|
||||
&.danger {
|
||||
border-color: #856404;
|
||||
background-color: white;
|
||||
h3 {
|
||||
margin: 0 0 0.5em 0;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
color: black !important;
|
||||
}
|
||||
h3 + p, button {
|
||||
font-size: 0.86em;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
|
@ -1095,7 +1142,8 @@ body#pad-sub #posts, .atoms {
|
|||
}
|
||||
.electron {
|
||||
font-weight: normal;
|
||||
margin-left: 0.5em;
|
||||
font-size: 0.86em;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
}
|
||||
h3, h4 {
|
||||
|
@ -1245,7 +1293,7 @@ header {
|
|||
}
|
||||
}
|
||||
&.singleuser {
|
||||
margin: 0.5em 0.25em;
|
||||
margin: 0.5em 1em 0.5em 0.25em;
|
||||
nav#user-nav {
|
||||
nav > ul > li:first-child {
|
||||
img {
|
||||
|
@ -1253,6 +1301,9 @@ header {
|
|||
}
|
||||
}
|
||||
}
|
||||
.right-side {
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
}
|
||||
.dash-nav {
|
||||
font-weight: bold;
|
||||
|
@ -1506,6 +1557,11 @@ div.row {
|
|||
margin-left: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
article {
|
||||
.hidden {
|
||||
.opacity(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
@ -1547,3 +1603,38 @@ div.row {
|
|||
pre.code-block {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
#emailsub {
|
||||
text-align: center;
|
||||
}
|
||||
p#emailsub {
|
||||
display: inline-block !important;
|
||||
width: 100%;
|
||||
font-style: italic;
|
||||
}
|
||||
#subscribe-btn {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
#org-nav {
|
||||
font-family: @sansFont;
|
||||
font-size: 1.1em;
|
||||
color: #888;
|
||||
|
||||
em, strong {
|
||||
color: #000;
|
||||
}
|
||||
&+h1 {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
a:link, a:visited, a:hover {
|
||||
color: @accent;
|
||||
}
|
||||
a:first-child {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
a.coll-name {
|
||||
font-weight: bold;
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
}
|
|
@ -3,7 +3,6 @@
|
|||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: optional;
|
||||
src: url('/fonts/open-sans-v13-latin-regular.eot'); /* IE9 Compat Modes */
|
||||
src: local('Open Sans'), local('OpenSans'),
|
||||
url('/fonts/open-sans-v13-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -17,7 +16,6 @@
|
|||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: optional;
|
||||
src: url('/fonts/open-sans-v13-latin-700.eot'); /* IE9 Compat Modes */
|
||||
src: local('Open Sans Bold'), local('OpenSans-Bold'),
|
||||
url('/fonts/open-sans-v13-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -31,7 +29,6 @@
|
|||
font-family: 'Lora';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: optional;
|
||||
src: url('/fonts/Lora-Regular.eot'); /* IE9 Compat Modes */
|
||||
src: local('Lora'), local('Lora-Regular'),
|
||||
url('/fonts/Lora-Regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -44,7 +41,6 @@
|
|||
font-family: 'Lora';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: optional;
|
||||
src: url('/fonts/Lora-Bold.eot'); /* IE9 Compat Modes */
|
||||
src: local('Lora Bold'), local('Lora-Bold'),
|
||||
url('/fonts/Lora-Bold.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -56,7 +52,6 @@
|
|||
font-family: 'Lora';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: optional;
|
||||
src: url('/fonts/Lora-Italic.eot'); /* IE9 Compat Modes */
|
||||
src: local('Lora Italic'), local('Lora-Italic'),
|
||||
url('/fonts/Lora-Italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# Install Less via npm
|
||||
if [ ! -e "$(which lessc)" ]; then
|
||||
sudo npm install -g less
|
||||
sudo npm install -g less@3.5.3
|
||||
sudo npm install -g less-plugin-clean-css
|
||||
else
|
||||
echo LESS $(npm view less version 2>&1 | grep -v WARN) is installed
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* Copyright © 2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
.row.signinbtns {
|
||||
justify-content: center;
|
||||
font-size: 1em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.loginbtn {
|
||||
height: 40px;
|
||||
margin: 0.5em;
|
||||
|
||||
&.btn {
|
||||
box-sizing: border-box;
|
||||
font-size: 17px;
|
||||
white-space: nowrap;
|
||||
|
||||
img {
|
||||
height: 1.5em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
&#writeas-login, &#slack-login {
|
||||
img {
|
||||
margin-top: -0.2em;
|
||||
}
|
||||
}
|
||||
|
||||
&#gitlab-login {
|
||||
background-color: #fc6d26;
|
||||
border-color: #fc6d26;
|
||||
&:hover {
|
||||
background-color: darken(#fc6d26, 5%);
|
||||
border-color: darken(#fc6d26, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&#gitea-login {
|
||||
background-color: #2ecc71;
|
||||
border-color: #2ecc71;
|
||||
&:hover {
|
||||
background-color: #2cc26b;
|
||||
border-color: #2cc26b;
|
||||
}
|
||||
}
|
||||
|
||||
&#slack-login, &#gitlab-login, &#gitea-login, &#generic-oauth-login {
|
||||
font-size: 0.86em;
|
||||
font-family: @sansFont;
|
||||
}
|
||||
|
||||
&#slack-login, &#generic-oauth-login {
|
||||
color: @lightTextColor;
|
||||
background-color: @lightNavBG;
|
||||
border-color: @lightNavBorder;
|
||||
&:hover {
|
||||
background-color: @lightNavHoverBG;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.or {
|
||||
text-align: center;
|
||||
margin-bottom: 3.5em;
|
||||
|
||||
p {
|
||||
display: inline-block;
|
||||
background-color: white;
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: -1.6em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
hr.short {
|
||||
max-width: 30rem;
|
||||
}
|
||||
}
|
|
@ -127,7 +127,6 @@ textarea {
|
|||
&.collection {
|
||||
a.title {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -188,18 +188,18 @@ body#pad, body#pad-sub {
|
|||
body#pad {
|
||||
.pad-theme-transition;
|
||||
|
||||
textarea {
|
||||
textarea, #title {
|
||||
.pad-theme-transition;
|
||||
}
|
||||
|
||||
&.dark {
|
||||
textarea {
|
||||
textarea, #title, #editor {
|
||||
background-color: @darkBG;
|
||||
color: @darkTextColor;
|
||||
}
|
||||
}
|
||||
&.light {
|
||||
textarea {
|
||||
textarea, #title, #editor {
|
||||
background-color: @lightBG;
|
||||
color: @lightTextColor;
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
&:hover {
|
||||
background: @lightNavHoverBG;
|
||||
}
|
||||
&:hover > ul {
|
||||
&:hover > ul, &.open > ul {
|
||||
display: block;
|
||||
}
|
||||
&.selected {
|
||||
|
@ -256,7 +256,7 @@ body#pad {
|
|||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
textarea {
|
||||
textarea, #title {
|
||||
position: fixed !important;
|
||||
top: 3em;
|
||||
right: 0;
|
||||
|
@ -340,6 +340,15 @@ body#pad {
|
|||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
line-height: 1.5;
|
||||
|
||||
input[type=text].confirm {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.short {
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -361,12 +370,38 @@ body#pad {
|
|||
z-index: 10;
|
||||
}
|
||||
|
||||
body#pad .alert {
|
||||
position: fixed;
|
||||
bottom: 0.25em;
|
||||
left: 2em;
|
||||
right: 2em;
|
||||
font-size: 1.1em;
|
||||
|
||||
&#edited-elsewhere {
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-height: 500px) {
|
||||
body#pad {
|
||||
textarea {
|
||||
top: 2.25em;
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
&.classic {
|
||||
#editor {
|
||||
top: 5.25em;
|
||||
}
|
||||
#title {
|
||||
top: 3.5rem;
|
||||
}
|
||||
}
|
||||
#tools {
|
||||
padding-top: 0.5em;
|
||||
padding-bottom: 0.5em;
|
||||
|
@ -420,43 +455,63 @@ body#pad {
|
|||
}
|
||||
|
||||
@media all and (min-width: 50em) {
|
||||
body#pad {
|
||||
textarea {
|
||||
body#pad, body#pad.classic {
|
||||
textarea, #title {
|
||||
padding-left: 10%;
|
||||
padding-right: 10%;
|
||||
}
|
||||
.alert {
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 60em) {
|
||||
body#pad {
|
||||
textarea {
|
||||
body#pad, body#pad.classic {
|
||||
textarea, #title {
|
||||
padding-left: 15%;
|
||||
padding-right: 15%;
|
||||
}
|
||||
.alert {
|
||||
left: 15%;
|
||||
right: 15%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 70em) {
|
||||
body#pad {
|
||||
textarea {
|
||||
body#pad, body#pad.classic {
|
||||
textarea, #title {
|
||||
padding-left: 20%;
|
||||
padding-right: 20%;
|
||||
}
|
||||
.alert {
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 85em) {
|
||||
body#pad {
|
||||
textarea {
|
||||
body#pad, body#pad.classic {
|
||||
textarea, #title {
|
||||
padding-left: 25%;
|
||||
padding-right: 25%;
|
||||
}
|
||||
.alert {
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 105em) {
|
||||
body#pad {
|
||||
textarea {
|
||||
body#pad, body#pad.classic {
|
||||
textarea, #title {
|
||||
padding-left: 30%;
|
||||
padding-right: 30%;
|
||||
}
|
||||
.alert {
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
|
|
|
@ -37,6 +37,25 @@ body#post article, pre, .hljs {
|
|||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
p.split {
|
||||
color: #6161FF;
|
||||
font-style: italic;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
|
||||
#readmore-sell {
|
||||
padding: 1em 1em 2em;
|
||||
background-color: #fafafa;
|
||||
p.split {
|
||||
color: black;
|
||||
font-style: normal;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
.cta + .cta {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Post mixins */
|
||||
.article-code() {
|
||||
background-color: #f8f8f8;
|
||||
|
|
|
@ -0,0 +1,490 @@
|
|||
@classicHorizMargin: 2rem;
|
||||
|
||||
body#pad.classic {
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
#editor {
|
||||
top: 4em;
|
||||
bottom: 1em;
|
||||
}
|
||||
#title {
|
||||
top: 4.25rem;
|
||||
bottom: unset;
|
||||
height: auto;
|
||||
font-weight: bold;
|
||||
font-size: 2em;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
#tools {
|
||||
#belt {
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
#target {
|
||||
ul {
|
||||
a {
|
||||
padding: 0 0.5em !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#title {
|
||||
margin-left: @classicHorizMargin;
|
||||
margin-right: @classicHorizMargin;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
position: relative;
|
||||
height: calc(~"100% - 1.6em");
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
font-size: 1.2em;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
padding: 0.5em @classicHorizMargin;
|
||||
line-height: 1.5;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ProseMirror li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::-moz-selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-selectednode {
|
||||
outline: 2px solid #8cf;
|
||||
}
|
||||
|
||||
/* Make sure li selections wrap around markers */
|
||||
|
||||
li.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
li.ProseMirror-selectednode:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -32px;
|
||||
right: -2px;
|
||||
top: -2px;
|
||||
bottom: -2px;
|
||||
border: 2px solid #8cf;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ProseMirror-textblock-dropdown {
|
||||
min-width: 3em;
|
||||
}
|
||||
|
||||
.ProseMirror-menu {
|
||||
margin: 0 -4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ProseMirror-tooltip .ProseMirror-menu {
|
||||
width: -webkit-fit-content;
|
||||
width: fit-content;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
margin-right: 3px;
|
||||
display: inline-block;
|
||||
div {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menuseparator {
|
||||
border-right: 1px solid #ddd;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu {
|
||||
font-size: 90%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown {
|
||||
vertical-align: 1px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-wrap {
|
||||
padding: 1px 0 1px 4px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown:after {
|
||||
content: "";
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid currentColor;
|
||||
opacity: .6;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: calc(50% - 2px);
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu {
|
||||
position: absolute;
|
||||
background: white;
|
||||
color: #666;
|
||||
border: 1px solid #aaa;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-menu {
|
||||
z-index: 15;
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-item {
|
||||
cursor: pointer;
|
||||
padding: 2px 8px 2px 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-item:hover {
|
||||
background: #f2f2f2;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-wrap {
|
||||
position: relative;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-label:after {
|
||||
content: "";
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 4px solid currentColor;
|
||||
opacity: .6;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: calc(50% - 4px);
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu {
|
||||
display: none;
|
||||
min-width: 4em;
|
||||
left: 100%;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
background: #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
background: #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-disabled {
|
||||
opacity: .3;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
font-family: @sansFont;
|
||||
position: relative;
|
||||
min-height: 1em;
|
||||
color: #666;
|
||||
padding: 0.5em;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
z-index: 10;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
margin-left: @classicHorizMargin;
|
||||
margin-right: @classicHorizMargin;
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
display: inline-block;
|
||||
line-height: .8;
|
||||
vertical-align: -2px; /* Compensate for padding */
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-disabled.ProseMirror-icon {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ProseMirror-icon svg {
|
||||
fill: currentColor;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.ProseMirror-icon span {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 20px;
|
||||
border-top: 1px solid black;
|
||||
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes ProseMirror-cursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Add space around the hr to make clicking it easier */
|
||||
|
||||
.ProseMirror-example-setup-style hr {
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
margin: 1em 0;
|
||||
background: initial;
|
||||
}
|
||||
|
||||
.ProseMirror-example-setup-style hr:after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
background-color: #ccc;
|
||||
line-height: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror ul, .ProseMirror ol {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.ProseMirror blockquote {
|
||||
padding-left: 1em;
|
||||
border-left: 4px solid #ddd;
|
||||
color: #767676;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-example-setup-style img {
|
||||
cursor: default;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
background: white;
|
||||
padding: 1em;
|
||||
border: 1px solid silver;
|
||||
position: fixed;
|
||||
border-radius: 0.25em;
|
||||
z-index: 11;
|
||||
box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
.ProseMirror-prompt h5 {
|
||||
margin: 0 0 0.75em;
|
||||
font-family: @sansFont;
|
||||
font-size: 100%;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt input[type="text"],
|
||||
.ProseMirror-prompt textarea {
|
||||
background: #eee;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt input[type="text"] {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-close {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 1px;
|
||||
color: #666;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-close:after {
|
||||
content: "✕";
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ProseMirror-invalid {
|
||||
background: #ffc;
|
||||
border: 1px solid #cc7;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
min-width: 10em;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-buttons {
|
||||
margin-top: 5px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#editor, .editor {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
color: black;
|
||||
background-clip: padding-box;
|
||||
padding: 5px 0;
|
||||
margin: 4em auto 23px auto;
|
||||
}
|
||||
|
||||
.dark #editor {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ProseMirror p:first-child,
|
||||
.ProseMirror h1:first-child,
|
||||
.ProseMirror h2:first-child,
|
||||
.ProseMirror h3:first-child,
|
||||
.ProseMirror h4:first-child,
|
||||
.ProseMirror h5:first-child,
|
||||
.ProseMirror h6:first-child {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 123px;
|
||||
border: 1px solid silver;
|
||||
box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
padding: 3px 10px;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper, #markdown textarea {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.editorreadmore {
|
||||
color: @textLinkColor;
|
||||
text-decoration: underline;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media all and (min-width: 50em) {
|
||||
#photo-upload label {
|
||||
display: inline;
|
||||
}
|
||||
.ProseMirror-menubar, #title, #photo-upload {
|
||||
margin-left: 10%;
|
||||
margin-right: 10%;
|
||||
}
|
||||
.ProseMirror {
|
||||
padding-left: 10%;
|
||||
padding-right: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 60em) {
|
||||
.ProseMirror-menubar, #title, #photo-upload {
|
||||
margin-left: 15%;
|
||||
margin-right: 15%;
|
||||
}
|
||||
.ProseMirror {
|
||||
padding-left: 15%;
|
||||
padding-right: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 70em) {
|
||||
.ProseMirror-menubar, #title, #photo-upload {
|
||||
margin-left: 20%;
|
||||
margin-right: 20%;
|
||||
}
|
||||
.ProseMirror {
|
||||
padding-left: 20%;
|
||||
padding-right: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 85em) {
|
||||
.ProseMirror-menubar, #title, #photo-upload {
|
||||
margin-left: 25%;
|
||||
margin-right: 25%;
|
||||
}
|
||||
.ProseMirror {
|
||||
padding-left: 25%;
|
||||
padding-right: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 105em) {
|
||||
.ProseMirror-menubar, #title, #photo-upload {
|
||||
margin-left: 30%;
|
||||
margin-right: 30%;
|
||||
}
|
||||
.ProseMirror {
|
||||
padding-left: 30%;
|
||||
padding-right: 30%;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
@import "prose-editor";
|
||||
@import "pad-theme";
|
||||
@import "resources";
|
||||
@import "lib/elements";
|
|
@ -0,0 +1,13 @@
|
|||
@primary: rgb(114, 120, 191);
|
||||
@secondary: rgb(114, 191, 133);
|
||||
@subheaders: #444;
|
||||
@headerTextColor: black;
|
||||
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif;
|
||||
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif;
|
||||
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace;
|
||||
@dangerCol: #e21d27;
|
||||
@errUrgentCol: #ecc63c;
|
||||
@proSelectedCol: #71D571;
|
||||
@textLinkColor: rgb(0, 0, 238);
|
||||
|
||||
@accent: #767676;
|
|
@ -150,4 +150,4 @@ func countRows(t *testing.T, ctx context.Context, db *sql.DB, count int, query s
|
|||
err := db.QueryRowContext(ctx, query, args...).Scan(&returned)
|
||||
assert.NoError(t, err, "error executing query %s and args %s", query, args)
|
||||
assert.Equal(t, count, returned, "unexpected return count %d, expected %d from %s and args %s", returned, count, query, args)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -36,6 +36,13 @@ func (db *datastore) typeSmallInt() string {
|
|||
return "SMALLINT"
|
||||
}
|
||||
|
||||
func (db *datastore) typeTinyInt() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "INTEGER"
|
||||
}
|
||||
return "TINYINT"
|
||||
}
|
||||
|
||||
func (db *datastore) typeText() string {
|
||||
return "TEXT"
|
||||
}
|
||||
|
@ -54,6 +61,13 @@ func (db *datastore) typeVarChar(l int) string {
|
|||
return fmt.Sprintf("VARCHAR(%d)", l)
|
||||
}
|
||||
|
||||
func (db *datastore) typeVarBinary(l int) string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "BLOB"
|
||||
}
|
||||
return fmt.Sprintf("VARBINARY(%d)", l)
|
||||
}
|
||||
|
||||
func (db *datastore) typeBool() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "INTEGER"
|
||||
|
@ -65,6 +79,15 @@ func (db *datastore) typeDateTime() string {
|
|||
return "DATETIME"
|
||||
}
|
||||
|
||||
func (db *datastore) typeIntPrimaryKey() string {
|
||||
if db.driverName == driverSQLite {
|
||||
// From docs: "In SQLite, a column with type INTEGER PRIMARY KEY is an alias for the ROWID (except in WITHOUT
|
||||
// ROWID tables) which is always a 64-bit signed integer."
|
||||
return "INTEGER PRIMARY KEY"
|
||||
}
|
||||
return "INT AUTO_INCREMENT PRIMARY KEY"
|
||||
}
|
||||
|
||||
func (db *datastore) collateMultiByte() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return ""
|
||||
|
@ -78,3 +101,10 @@ func (db *datastore) engine() string {
|
|||
}
|
||||
return " ENGINE = InnoDB"
|
||||
}
|
||||
|
||||
func (db *datastore) after(colName string) string {
|
||||
if db.driverName == driverSQLite {
|
||||
return ""
|
||||
}
|
||||
return " AFTER " + colName
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -61,8 +61,16 @@ var migrations = []Migration{
|
|||
New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0)
|
||||
New("support oauth", oauth), // V3 -> V4
|
||||
New("support slack oauth", oauthSlack), // V4 -> v5
|
||||
New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6 (v0.12.0)
|
||||
New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6
|
||||
New("support oauth attach", oauthAttach), // V6 -> V7
|
||||
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
|
||||
New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9
|
||||
New("support post signatures", supportPostSignatures), // V9 -> V10 (v0.13.0)
|
||||
New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11
|
||||
New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0)
|
||||
New("support newsletters", supportLetters), // V12 -> V13
|
||||
New("support password resetting", supportPassReset), // V13 -> V14
|
||||
New("speed up blog post retrieval", addPostRetrievalIndex), // V14 -> V15
|
||||
}
|
||||
|
||||
// CurrentVer returns the current migration version the application is on
|
||||
|
@ -84,6 +92,9 @@ func Migrate(db *datastore) error {
|
|||
var err error
|
||||
if db.tableExists("appmigrations") {
|
||||
err = db.QueryRow("SELECT MAX(version) FROM appmigrations").Scan(&version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Info("Initializing appmigrations table...")
|
||||
version = 0
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,6 +12,9 @@ package migrations
|
|||
|
||||
func supportUserInvites(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = t.Exec(`CREATE TABLE userinvites (
|
||||
id ` + db.typeChar(6) + ` NOT NULL ,
|
||||
owner_id ` + db.typeInt() + ` NOT NULL ,
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright © 2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func supportPostSignatures(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE collections ADD COLUMN post_signature ` + db.typeText() + db.collateMultiByte() + ` NULL` + db.after("script"))
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright © 2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
/**
|
||||
* Widen `oauth_users.access_token`, necessary only for mysql
|
||||
*/
|
||||
func widenOauthAcceesToken(db *datastore) error {
|
||||
if db.driverName == driverMySQL {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE oauth_users MODIFY COLUMN access_token ` + db.typeText() + db.collateMultiByte() + ` NULL`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright © 2023 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func fediverseVerifyProfile(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN url ` + db.typeVarChar(255) + ` NULL` + db.after("shared_inbox"))
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright © 2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func supportLetters(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`CREATE TABLE publishjobs (
|
||||
id ` + db.typeIntPrimaryKey() + `,
|
||||
post_id ` + db.typeVarChar(16) + ` not null,
|
||||
action ` + db.typeVarChar(16) + ` not null,
|
||||
delay ` + db.typeTinyInt() + ` not null
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`CREATE TABLE emailsubscribers (
|
||||
id ` + db.typeChar(8) + ` not null,
|
||||
collection_id ` + db.typeInt() + ` not null,
|
||||
user_id ` + db.typeInt() + ` null,
|
||||
email ` + db.typeVarChar(255) + ` null,
|
||||
subscribed ` + db.typeDateTime() + ` not null,
|
||||
token ` + db.typeChar(16) + ` not null,
|
||||
confirmed ` + db.typeBool() + ` default 0 not null,
|
||||
allow_export ` + db.typeBool() + ` default 0 not null,
|
||||
constraint eu_coll_email
|
||||
unique (collection_id, email),
|
||||
constraint eu_coll_user
|
||||
unique (collection_id, user_id),
|
||||
PRIMARY KEY (id)
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright © 2023 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func supportPassReset(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`CREATE TABLE password_resets (
|
||||
user_id ` + db.typeInt() + ` not null,
|
||||
token ` + db.typeChar(32) + ` not null primary key,
|
||||
used ` + db.typeBool() + ` default 0 not null,
|
||||
created ` + db.typeDateTime() + ` not null
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright © 2023 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func addPostRetrievalIndex(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec("CREATE INDEX posts_get_collection_index ON posts (`collection_id`, `pinned_position`, `created`)")
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,6 +12,9 @@ package migrations
|
|||
|
||||
func supportInstancePages(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE appcontent ADD COLUMN title ` + db.typeVarChar(255) + db.collateMultiByte() + ` NULL`)
|
||||
if err != nil {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,6 +12,9 @@ package migrations
|
|||
|
||||
func supportUserStatus(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE users ADD COLUMN status ` + db.typeInt() + ` DEFAULT '0' NOT NULL`)
|
||||
if err != nil {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -14,7 +14,7 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writeas/writefreely/db"
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauth(db *datastore) error {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -14,7 +14,7 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writeas/writefreely/db"
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthSlack(db *datastore) error {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,6 +12,9 @@ package migrations
|
|||
|
||||
func supportActivityPubMentions(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` NULL`)
|
||||
if err != nil {
|
||||
|
|
|
@ -1,10 +1,20 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writeas/writefreely/db"
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthAttach(db *datastore) error {
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthInvites(db *datastore) error {
|
||||
dialect := wf_db.DialectMySQL
|
||||
if db.driverName == driverSQLite {
|
||||
dialect = wf_db.DialectSQLite
|
||||
}
|
||||
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
builders := []wf_db.SQLBuilder{
|
||||
dialect.
|
||||
AlterTable("oauth_client_states").
|
||||
AddColumn(dialect.Column("invite_code", wf_db.ColumnTypeChar, wf_db.OptionalInt{
|
||||
Set: true,
|
||||
Value: 6,
|
||||
}).SetNullable(true)),
|
||||
}
|
||||
for _, builder := range builders {
|
||||
query, err := builder.ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright © 2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func optimizeDrafts(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if db.driverName == driverSQLite {
|
||||
_, err = t.Exec(`CREATE INDEX key_owner_post_id ON posts (owner_id, id)`)
|
||||
} else {
|
||||
_, err = t.Exec(`ALTER TABLE posts ADD INDEX(owner_id, id)`)
|
||||
}
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func displayMonetization(monetization, alias string) string {
|
||||
if monetization == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ptrURL, err := url.Parse(strings.Replace(monetization, "$", "https://", 1))
|
||||
if err == nil {
|
||||
if strings.HasSuffix(ptrURL.Host, ".xrptipbot.com") {
|
||||
// xrp tip bot doesn't support stream receipts, so return plain pointer
|
||||
return monetization
|
||||
}
|
||||
}
|
||||
|
||||
u := os.Getenv("PAYMENT_HOST")
|
||||
if u == "" {
|
||||
return "$webmonetization.org/api/receipts/" + url.PathEscape(monetization)
|
||||
}
|
||||
u += "/" + alias
|
||||
return u
|
||||
}
|
||||
|
||||
func handleSPSPEndpoint(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
idStr := r.FormValue("id")
|
||||
id, err := url.QueryUnescape(idStr)
|
||||
if err != nil {
|
||||
log.Error("Unable to unescape: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var c *Collection
|
||||
if strings.IndexRune(id, '.') > 0 && app.cfg.App.SingleUser {
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
} else {
|
||||
c, err = app.db.GetCollection(id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pointer := c.Monetization
|
||||
if pointer == "" {
|
||||
err := impart.HTTPError{http.StatusNotFound, "No monetization pointer."}
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, pointer)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGetSplitContent(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
var collID int64
|
||||
var collLookupID string
|
||||
var coll *Collection
|
||||
var err error
|
||||
vars := mux.Vars(r)
|
||||
if collAlias := vars["alias"]; collAlias != "" {
|
||||
// Fetch collection information, since an alias is provided
|
||||
coll, err = app.db.GetCollection(collAlias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collID = coll.ID
|
||||
collLookupID = coll.Alias
|
||||
}
|
||||
|
||||
p, err := app.db.GetPost(vars["post"], collID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
receipt := r.FormValue("receipt")
|
||||
if receipt == "" {
|
||||
return impart.HTTPError{http.StatusBadRequest, "No `receipt` given."}
|
||||
}
|
||||
err = verifyReceipt(receipt, collLookupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d := struct {
|
||||
Content string `json:"body"`
|
||||
HTMLContent string `json:"html_body"`
|
||||
}{}
|
||||
|
||||
if exc := strings.Index(p.Content, shortCodePaid); exc > -1 {
|
||||
baseURL := ""
|
||||
if coll != nil {
|
||||
baseURL = coll.CanonicalURL()
|
||||
}
|
||||
|
||||
d.Content = p.Content[exc+len(shortCodePaid):]
|
||||
d.HTMLContent = applyMarkdown([]byte(d.Content), baseURL, app.cfg)
|
||||
}
|
||||
|
||||
return impart.WriteSuccess(w, d, http.StatusOK)
|
||||
}
|
||||
|
||||
func verifyReceipt(receipt, id string) error {
|
||||
receiptsHost := os.Getenv("RECEIPTS_HOST")
|
||||
if receiptsHost == "" {
|
||||
receiptsHost = "https://webmonetization.org/api/receipts/verify?id=" + id
|
||||
} else {
|
||||
receiptsHost = fmt.Sprintf("%s/receipts?id=%s", receiptsHost, id)
|
||||
}
|
||||
|
||||
log.Info("Verifying receipt %s at %s", receipt, receiptsHost)
|
||||
r, err := http.NewRequest("POST", receiptsHost, bytes.NewBufferString(receipt))
|
||||
if err != nil {
|
||||
log.Error("Unable to create new request to %s: %s", receiptsHost, err)
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
log.Error("Unable to Do() request to %s: %s", receiptsHost, err)
|
||||
return err
|
||||
}
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Error("Unable to read %s response body: %s", receiptsHost, err)
|
||||
return err
|
||||
}
|
||||
log.Info("Status : %s", resp.Status)
|
||||
log.Info("Response: %s", body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Error("Bad response from %s:\nStatus: %d\n%s", receiptsHost, resp.StatusCode, string(body))
|
||||
return impart.HTTPError{resp.StatusCode, string(body)}
|
||||
}
|
||||
return nil
|
||||
}
|
14
nodeinfo.go
14
nodeinfo.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2019, 2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,8 +12,8 @@ package writefreely
|
|||
|
||||
import (
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writefreely/go-nodeinfo"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
@ -45,7 +45,7 @@ func nodeInfoConfig(db *datastore, cfg *config.Config) *nodeinfo.Config {
|
|||
Private: cfg.App.Private,
|
||||
Software: nodeinfo.SoftwareMeta{
|
||||
HomePage: softwareURL,
|
||||
GitHub: "https://github.com/writeas/writefreely",
|
||||
GitHub: "https://github.com/writefreely/writefreely",
|
||||
Follow: "https://writing.exchange/@write_as",
|
||||
},
|
||||
MaxBlogs: cfg.App.MaxBlogs,
|
||||
|
@ -94,14 +94,20 @@ INNER JOIN collections c
|
|||
ON collection_id = c.id
|
||||
WHERE collection_id IS NOT NULL
|
||||
AND updated > DATE_SUB(NOW(), INTERVAL 6 MONTH)) co`).Scan(&activeHalfYear)
|
||||
if err != nil {
|
||||
log.Error("Failed getting 6-month active user stats: %s", err)
|
||||
}
|
||||
|
||||
err = r.db.QueryRow(`SELECT COUNT(*) FROM (
|
||||
SELECT DISTINCT collection_id
|
||||
FROM posts
|
||||
INNER JOIN FROM collections c
|
||||
INNER JOIN collections c
|
||||
ON collection_id = c.id
|
||||
WHERE collection_id IS NOT NULL
|
||||
AND updated > DATE_SUB(NOW(), INTERVAL 1 MONTH)) co`).Scan(&activeMonth)
|
||||
if err != nil {
|
||||
log.Error("Failed getting 1-month active user stats: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nodeinfo.Usage{
|
||||
|
|
147
oauth.go
147
oauth.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
|
@ -5,7 +15,6 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
@ -15,10 +24,35 @@ import (
|
|||
"github.com/gorilla/sessions"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
)
|
||||
|
||||
// OAuthButtons holds display information for different OAuth providers we support.
|
||||
type OAuthButtons struct {
|
||||
SlackEnabled bool
|
||||
WriteAsEnabled bool
|
||||
GitLabEnabled bool
|
||||
GitLabDisplayName string
|
||||
GiteaEnabled bool
|
||||
GiteaDisplayName string
|
||||
GenericEnabled bool
|
||||
GenericDisplayName string
|
||||
}
|
||||
|
||||
// NewOAuthButtons creates a new OAuthButtons struct based on our app configuration.
|
||||
func NewOAuthButtons(cfg *config.Config) *OAuthButtons {
|
||||
return &OAuthButtons{
|
||||
SlackEnabled: cfg.SlackOauth.ClientID != "",
|
||||
WriteAsEnabled: cfg.WriteAsOauth.ClientID != "",
|
||||
GitLabEnabled: cfg.GitlabOauth.ClientID != "",
|
||||
GitLabDisplayName: config.OrDefaultString(cfg.GitlabOauth.DisplayName, gitlabDisplayName),
|
||||
GiteaEnabled: cfg.GiteaOauth.ClientID != "",
|
||||
GiteaDisplayName: config.OrDefaultString(cfg.GiteaOauth.DisplayName, giteaDisplayName),
|
||||
GenericEnabled: cfg.GenericOauth.ClientID != "",
|
||||
GenericDisplayName: config.OrDefaultString(cfg.GenericOauth.DisplayName, genericOauthDisplayName),
|
||||
}
|
||||
}
|
||||
|
||||
// TokenResponse contains data returned when a token is created either
|
||||
// through a code exchange or using a refresh token.
|
||||
type TokenResponse struct {
|
||||
|
@ -61,10 +95,10 @@ type OAuthDatastoreProvider interface {
|
|||
type OAuthDatastore interface {
|
||||
GetIDForRemoteUser(context.Context, string, string, string) (int64, error)
|
||||
RecordRemoteUserID(context.Context, int64, string, string, string, string) error
|
||||
ValidateOAuthState(context.Context, string) (string, string, int64, error)
|
||||
GenerateOAuthState(context.Context, string, string, int64) (string, error)
|
||||
ValidateOAuthState(context.Context, string) (string, string, int64, string, error)
|
||||
GenerateOAuthState(context.Context, string, string, int64, string) (string, error)
|
||||
|
||||
CreateUser(*config.Config, *User, string) error
|
||||
CreateUser(*config.Config, *User, string, string) error
|
||||
GetUserByID(int64) (*User, error)
|
||||
}
|
||||
|
||||
|
@ -108,7 +142,7 @@ func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Req
|
|||
attachUser = user.ID
|
||||
}
|
||||
|
||||
state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID(), attachUser)
|
||||
state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID(), attachUser, r.FormValue("invite_code"))
|
||||
if err != nil {
|
||||
log.Error("viewOauthInit error: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
||||
|
@ -208,6 +242,70 @@ func configureGitlabOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
|||
}
|
||||
}
|
||||
|
||||
func configureGenericOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||
if app.Config().GenericOauth.ClientID != "" {
|
||||
callbackLocation := app.Config().App.Host + "/oauth/callback/generic"
|
||||
|
||||
var callbackProxy *callbackProxyClient = nil
|
||||
if app.Config().GenericOauth.CallbackProxy != "" {
|
||||
callbackProxy = &callbackProxyClient{
|
||||
server: app.Config().GenericOauth.CallbackProxyAPI,
|
||||
callbackLocation: app.Config().App.Host + "/oauth/callback/generic",
|
||||
httpClient: config.DefaultHTTPClient(),
|
||||
}
|
||||
callbackLocation = app.Config().GenericOauth.CallbackProxy
|
||||
}
|
||||
|
||||
oauthClient := genericOauthClient{
|
||||
ClientID: app.Config().GenericOauth.ClientID,
|
||||
ClientSecret: app.Config().GenericOauth.ClientSecret,
|
||||
ExchangeLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.TokenEndpoint,
|
||||
InspectLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.InspectEndpoint,
|
||||
AuthLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.AuthEndpoint,
|
||||
HttpClient: config.DefaultHTTPClient(),
|
||||
CallbackLocation: callbackLocation,
|
||||
Scope: config.OrDefaultString(app.Config().GenericOauth.Scope, "read_user"),
|
||||
MapUserID: config.OrDefaultString(app.Config().GenericOauth.MapUserID, "user_id"),
|
||||
MapUsername: config.OrDefaultString(app.Config().GenericOauth.MapUsername, "username"),
|
||||
MapDisplayName: config.OrDefaultString(app.Config().GenericOauth.MapDisplayName, "-"),
|
||||
MapEmail: config.OrDefaultString(app.Config().GenericOauth.MapEmail, "email"),
|
||||
}
|
||||
configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy)
|
||||
}
|
||||
}
|
||||
|
||||
func configureGiteaOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||
if app.Config().GiteaOauth.ClientID != "" {
|
||||
callbackLocation := app.Config().App.Host + "/oauth/callback/gitea"
|
||||
|
||||
var callbackProxy *callbackProxyClient = nil
|
||||
if app.Config().GiteaOauth.CallbackProxy != "" {
|
||||
callbackProxy = &callbackProxyClient{
|
||||
server: app.Config().GiteaOauth.CallbackProxyAPI,
|
||||
callbackLocation: app.Config().App.Host + "/oauth/callback/gitea",
|
||||
httpClient: config.DefaultHTTPClient(),
|
||||
}
|
||||
callbackLocation = app.Config().GiteaOauth.CallbackProxy
|
||||
}
|
||||
|
||||
oauthClient := giteaOauthClient{
|
||||
ClientID: app.Config().GiteaOauth.ClientID,
|
||||
ClientSecret: app.Config().GiteaOauth.ClientSecret,
|
||||
ExchangeLocation: app.Config().GiteaOauth.Host + "/login/oauth/access_token",
|
||||
InspectLocation: app.Config().GiteaOauth.Host + "/login/oauth/userinfo",
|
||||
AuthLocation: app.Config().GiteaOauth.Host + "/login/oauth/authorize",
|
||||
HttpClient: config.DefaultHTTPClient(),
|
||||
CallbackLocation: callbackLocation,
|
||||
Scope: "openid profile email",
|
||||
MapUserID: "sub",
|
||||
MapUsername: "login",
|
||||
MapDisplayName: "full_name",
|
||||
MapEmail: "email",
|
||||
}
|
||||
configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy)
|
||||
}
|
||||
}
|
||||
|
||||
func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, callbackProxy *callbackProxyClient) {
|
||||
handler := &oauthHandler{
|
||||
Config: app.Config(),
|
||||
|
@ -228,7 +326,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
code := r.FormValue("code")
|
||||
state := r.FormValue("state")
|
||||
|
||||
provider, clientID, attachUserID, err := h.DB.ValidateOAuthState(ctx, state)
|
||||
provider, clientID, attachUserID, inviteCode, err := h.DB.ValidateOAuthState(ctx, state)
|
||||
if err != nil {
|
||||
log.Error("Unable to ValidateOAuthState: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
|
@ -237,10 +335,16 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
tokenResponse, err := h.oauthClient.exchangeOauthCode(ctx, code)
|
||||
if err != nil {
|
||||
log.Error("Unable to exchangeOauthCode: %s", err)
|
||||
// TODO: show user friendly message if needed
|
||||
// TODO: show NO message for cases like user pressing "Cancel" on authorize step
|
||||
addSessionFlash(app, w, r, err.Error(), nil)
|
||||
if attachUserID > 0 {
|
||||
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||
}
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
|
||||
// Now that we have the access token, let's use it real quick to make sur
|
||||
// Now that we have the access token, let's use it real quick to make sure
|
||||
// it really really works.
|
||||
tokenInfo, err := h.oauthClient.inspectOauthAccessToken(ctx, tokenResponse.AccessToken)
|
||||
if err != nil {
|
||||
|
@ -255,13 +359,14 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
}
|
||||
|
||||
if localUserID != -1 && attachUserID > 0 {
|
||||
if err = addSessionFlash(app, w, r, "This Slack account is already attached to another user.", nil); err != nil {
|
||||
if err = addSessionFlash(app, w, r, "This OAuth account is already attached to another user.", nil); err != nil {
|
||||
return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()}
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||
}
|
||||
|
||||
if localUserID != -1 {
|
||||
// Existing user, so log in now
|
||||
user, err := h.DB.GetUserByID(localUserID)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetUserByID %d: %s", localUserID, err)
|
||||
|
@ -275,6 +380,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
}
|
||||
if attachUserID > 0 {
|
||||
log.Info("attaching to user %d", attachUserID)
|
||||
log.Info("OAuth userid: %s", tokenInfo.UserID)
|
||||
err = h.DB.RecordRemoteUserID(r.Context(), attachUserID, tokenInfo.UserID, provider, clientID, tokenResponse.AccessToken)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
|
@ -282,6 +388,22 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||
}
|
||||
|
||||
// New user registration below.
|
||||
// First, verify that user is allowed to register
|
||||
if inviteCode != "" {
|
||||
// Verify invite code is valid
|
||||
i, err := app.db.GetUserInvite(inviteCode)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
if !i.Active(app.db) {
|
||||
return impart.HTTPError{http.StatusNotFound, "Invite link has expired."}
|
||||
}
|
||||
} else if !app.cfg.App.OpenRegistration {
|
||||
addSessionFlash(app, w, r, ErrUserNotFound.Error(), nil)
|
||||
return impart.HTTPError{http.StatusFound, "/login"}
|
||||
}
|
||||
|
||||
displayName := tokenInfo.DisplayName
|
||||
if len(displayName) == 0 {
|
||||
displayName = tokenInfo.Username
|
||||
|
@ -295,6 +417,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
TokenRemoteUser: tokenInfo.UserID,
|
||||
Provider: provider,
|
||||
ClientID: clientID,
|
||||
InviteCode: inviteCode,
|
||||
}
|
||||
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed)
|
||||
|
||||
|
@ -309,7 +432,7 @@ func (r *callbackProxyClient) register(ctx context.Context, state string) error
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "writefreely")
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
|
@ -326,7 +449,7 @@ func (r *callbackProxyClient) register(ctx context.Context, state string) error
|
|||
|
||||
func limitedJsonUnmarshal(body io.ReadCloser, n int, thing interface{}) error {
|
||||
lr := io.LimitReader(body, int64(n+1))
|
||||
data, err := ioutil.ReadAll(lr)
|
||||
data, err := io.ReadAll(lr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -7,4 +7,3 @@ type ClientStateStore interface {
|
|||
Generate(ctx context.Context) (string, error)
|
||||
Validate(ctx context.Context, state string) error
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC and respective authors.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/writeas/web-core/log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type genericOauthClient struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
AuthLocation string
|
||||
ExchangeLocation string
|
||||
InspectLocation string
|
||||
CallbackLocation string
|
||||
Scope string
|
||||
MapUserID string
|
||||
MapUsername string
|
||||
MapDisplayName string
|
||||
MapEmail string
|
||||
HttpClient HttpClient
|
||||
}
|
||||
|
||||
var _ oauthClient = genericOauthClient{}
|
||||
|
||||
const (
|
||||
genericOauthDisplayName = "OAuth"
|
||||
)
|
||||
|
||||
func (c genericOauthClient) GetProvider() string {
|
||||
return "generic"
|
||||
}
|
||||
|
||||
func (c genericOauthClient) GetClientID() string {
|
||||
return c.ClientID
|
||||
}
|
||||
|
||||
func (c genericOauthClient) GetCallbackLocation() string {
|
||||
return c.CallbackLocation
|
||||
}
|
||||
|
||||
func (c genericOauthClient) buildLoginURL(state string) (string, error) {
|
||||
u, err := url.Parse(c.AuthLocation)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("client_id", c.ClientID)
|
||||
q.Set("redirect_uri", c.CallbackLocation)
|
||||
q.Set("response_type", "code")
|
||||
q.Set("state", state)
|
||||
q.Set("scope", c.Scope)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c genericOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||
form := url.Values{}
|
||||
form.Add("grant_type", "authorization_code")
|
||||
form.Add("redirect_uri", c.CallbackLocation)
|
||||
form.Add("scope", c.Scope)
|
||||
form.Add("code", code)
|
||||
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("unable to exchange code for access token")
|
||||
}
|
||||
|
||||
var tokenResponse TokenResponse
|
||||
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tokenResponse.Error != "" {
|
||||
return nil, errors.New(tokenResponse.Error)
|
||||
}
|
||||
return &tokenResponse, nil
|
||||
}
|
||||
|
||||
func (c genericOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
|
||||
req, err := http.NewRequest("GET", c.InspectLocation, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("unable to inspect access token")
|
||||
}
|
||||
|
||||
// since we don't know what the JSON from the server will look like, we create a
|
||||
// generic interface and then map manually to values set in the config
|
||||
var genericInterface map[string]interface{}
|
||||
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &genericInterface); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// map each relevant field in inspectResponse to the mapped field from the config
|
||||
var inspectResponse InspectResponse
|
||||
inspectResponse.UserID, _ = genericInterface[c.MapUserID].(string)
|
||||
if inspectResponse.UserID == "" {
|
||||
log.Error("[CONFIGURATION ERROR] Generic OAuth provider returned empty UserID value (`%s`).\n Do you need to configure a different `map_user_id` value for this provider?", c.MapUserID)
|
||||
return nil, fmt.Errorf("no UserID (`%s`) value returned", c.MapUserID)
|
||||
}
|
||||
inspectResponse.Username, _ = genericInterface[c.MapUsername].(string)
|
||||
inspectResponse.DisplayName, _ = genericInterface[c.MapDisplayName].(string)
|
||||
inspectResponse.Email, _ = genericInterface[c.MapEmail].(string)
|
||||
|
||||
return &inspectResponse, nil
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/writeas/web-core/log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type giteaOauthClient struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
AuthLocation string
|
||||
ExchangeLocation string
|
||||
InspectLocation string
|
||||
CallbackLocation string
|
||||
Scope string
|
||||
MapUserID string
|
||||
MapUsername string
|
||||
MapDisplayName string
|
||||
MapEmail string
|
||||
HttpClient HttpClient
|
||||
}
|
||||
|
||||
var _ oauthClient = giteaOauthClient{}
|
||||
|
||||
const (
|
||||
giteaDisplayName = "Gitea"
|
||||
)
|
||||
|
||||
func (c giteaOauthClient) GetProvider() string {
|
||||
return "gitea"
|
||||
}
|
||||
|
||||
func (c giteaOauthClient) GetClientID() string {
|
||||
return c.ClientID
|
||||
}
|
||||
|
||||
func (c giteaOauthClient) GetCallbackLocation() string {
|
||||
return c.CallbackLocation
|
||||
}
|
||||
|
||||
func (c giteaOauthClient) buildLoginURL(state string) (string, error) {
|
||||
u, err := url.Parse(c.AuthLocation)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("client_id", c.ClientID)
|
||||
q.Set("redirect_uri", c.CallbackLocation)
|
||||
q.Set("response_type", "code")
|
||||
q.Set("state", state)
|
||||
q.Set("scope", c.Scope)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c giteaOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||
form := url.Values{}
|
||||
form.Add("grant_type", "authorization_code")
|
||||
form.Add("redirect_uri", c.CallbackLocation)
|
||||
form.Add("scope", c.Scope)
|
||||
form.Add("code", code)
|
||||
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("unable to exchange code for access token")
|
||||
}
|
||||
|
||||
var tokenResponse TokenResponse
|
||||
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tokenResponse.Error != "" {
|
||||
return nil, errors.New(tokenResponse.Error)
|
||||
}
|
||||
return &tokenResponse, nil
|
||||
}
|
||||
|
||||
func (c giteaOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
|
||||
req, err := http.NewRequest("GET", c.InspectLocation, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("unable to inspect access token")
|
||||
}
|
||||
|
||||
// since we don't know what the JSON from the server will look like, we create a
|
||||
// generic interface and then map manually to values set in the config
|
||||
var genericInterface map[string]interface{}
|
||||
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &genericInterface); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// map each relevant field in inspectResponse to the mapped field from the config
|
||||
var inspectResponse InspectResponse
|
||||
inspectResponse.UserID, _ = genericInterface[c.MapUserID].(string)
|
||||
// log.Info("Userid from Gitea: %s", inspectResponse.UserID)
|
||||
if inspectResponse.UserID == "" {
|
||||
log.Error("[CONFIGURATION ERROR] Gitea OAuth provider returned empty UserID value (`%s`).\n Do you need to configure a different `map_user_id` value for this provider?", c.MapUserID)
|
||||
return nil, fmt.Errorf("no UserID (`%s`) value returned", c.MapUserID)
|
||||
}
|
||||
inspectResponse.Username, _ = genericInterface[c.MapUsername].(string)
|
||||
inspectResponse.DisplayName, _ = genericInterface[c.MapDisplayName].(string)
|
||||
inspectResponse.Email, _ = genericInterface[c.MapEmail].(string)
|
||||
|
||||
return &inspectResponse, nil
|
||||
}
|
|
@ -63,7 +63,7 @@ func (c gitlabOauthClient) exchangeOauthCode(ctx context.Context, code string) (
|
|||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", "writefreely")
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||
|
@ -92,7 +92,7 @@ func (c gitlabOauthClient) inspectOauthAccessToken(ctx context.Context, accessTo
|
|||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", "writefreely")
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2020 A Bunch Tell LLC.
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -17,7 +17,7 @@ import (
|
|||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
@ -38,6 +38,7 @@ type viewOauthSignupVars struct {
|
|||
Provider string
|
||||
ClientID string
|
||||
TokenHash string
|
||||
InviteCode string
|
||||
|
||||
LoginUsername string
|
||||
Alias string // TODO: rename this to match the data it represents: the collection title
|
||||
|
@ -57,6 +58,7 @@ const (
|
|||
oauthParamAlias = "alias"
|
||||
oauthParamEmail = "email"
|
||||
oauthParamPassword = "password"
|
||||
oauthParamInviteCode = "invite_code"
|
||||
)
|
||||
|
||||
type oauthSignupPageParams struct {
|
||||
|
@ -68,6 +70,7 @@ type oauthSignupPageParams struct {
|
|||
ClientID string
|
||||
Provider string
|
||||
TokenHash string
|
||||
InviteCode string
|
||||
}
|
||||
|
||||
func (p oauthSignupPageParams) HashTokenParams(key string) string {
|
||||
|
@ -92,6 +95,7 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R
|
|||
TokenRemoteUser: r.FormValue(oauthParamTokenRemoteUserID),
|
||||
ClientID: r.FormValue(oauthParamClientID),
|
||||
Provider: r.FormValue(oauthParamProvider),
|
||||
InviteCode: r.FormValue(oauthParamInviteCode),
|
||||
}
|
||||
if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) {
|
||||
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Request has been tampered with."}
|
||||
|
@ -123,11 +127,19 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R
|
|||
displayName = r.FormValue(oauthParamUsername)
|
||||
}
|
||||
|
||||
err = h.DB.CreateUser(h.Config, newUser, displayName)
|
||||
err = h.DB.CreateUser(h.Config, newUser, displayName, "")
|
||||
if err != nil {
|
||||
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||
}
|
||||
|
||||
// Log invite if needed
|
||||
if tp.InviteCode != "" {
|
||||
err = app.db.CreateInvitedUser(tp.InviteCode, newUser.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = h.DB.RecordRemoteUserID(r.Context(), newUser.ID, r.FormValue(oauthParamTokenRemoteUserID), r.FormValue(oauthParamProvider), r.FormValue(oauthParamClientID), r.FormValue(oauthParamAccessToken))
|
||||
if err != nil {
|
||||
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||
|
@ -195,6 +207,7 @@ func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *ht
|
|||
Provider: tp.Provider,
|
||||
ClientID: tp.ClientID,
|
||||
TokenHash: tp.TokenHash,
|
||||
InviteCode: tp.InviteCode,
|
||||
|
||||
LoginUsername: username,
|
||||
Alias: collTitle,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -13,8 +13,6 @@ package writefreely
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/slug"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
@ -113,7 +111,7 @@ func (c slackOauthClient) exchangeOauthCode(ctx context.Context, code string) (*
|
|||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", "writefreely")
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||
|
@ -142,7 +140,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok
|
|||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", "writefreely")
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
|
@ -167,7 +165,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok
|
|||
func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse {
|
||||
return &InspectResponse{
|
||||
UserID: resp.User.ID,
|
||||
Username: fmt.Sprintf("%s-%s", slug.Make(resp.User.Name), store.GenerateRandomString("0123456789bcdfghjklmnpqrstvwxyz", 5)),
|
||||
Username: slug.Make(resp.User.Name),
|
||||
DisplayName: resp.User.Name,
|
||||
Email: resp.User.Email,
|
||||
}
|
||||
|
|
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
|
@ -6,8 +16,8 @@ import (
|
|||
"github.com/gorilla/sessions"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/web-core/id"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
|
@ -22,8 +32,8 @@ type MockOAuthDatastoreProvider struct {
|
|||
}
|
||||
|
||||
type MockOAuthDatastore struct {
|
||||
DoGenerateOAuthState func(context.Context, string, string, int64) (string, error)
|
||||
DoValidateOAuthState func(context.Context, string) (string, string, int64, error)
|
||||
DoGenerateOAuthState func(context.Context, string, string, int64, string) (string, error)
|
||||
DoValidateOAuthState func(context.Context, string) (string, string, int64, string, error)
|
||||
DoGetIDForRemoteUser func(context.Context, string, string, string) (int64, error)
|
||||
DoCreateUser func(*config.Config, *User, string) error
|
||||
DoRecordRemoteUserID func(context.Context, int64, string, string, string, string) error
|
||||
|
@ -86,11 +96,11 @@ func (m *MockOAuthDatastoreProvider) Config() *config.Config {
|
|||
return cfg
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, error) {
|
||||
func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, string, error) {
|
||||
if m.DoValidateOAuthState != nil {
|
||||
return m.DoValidateOAuthState(ctx, state)
|
||||
}
|
||||
return "", "", 0, nil
|
||||
return "", "", 0, "", nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provider, clientID string) (int64, error) {
|
||||
|
@ -100,7 +110,7 @@ func (m *MockOAuthDatastore) GetIDForRemoteUser(ctx context.Context, remoteUserI
|
|||
return -1, nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) CreateUser(cfg *config.Config, u *User, username string) error {
|
||||
func (m *MockOAuthDatastore) CreateUser(cfg *config.Config, u *User, username, description string) error {
|
||||
if m.DoCreateUser != nil {
|
||||
return m.DoCreateUser(cfg, u, username)
|
||||
}
|
||||
|
@ -119,17 +129,15 @@ func (m *MockOAuthDatastore) GetUserByID(userID int64) (*User, error) {
|
|||
if m.DoGetUserByID != nil {
|
||||
return m.DoGetUserByID(userID)
|
||||
}
|
||||
user := &User{
|
||||
|
||||
}
|
||||
user := &User{}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUserID int64) (string, error) {
|
||||
func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUserID int64, inviteCode string) (string, error) {
|
||||
if m.DoGenerateOAuthState != nil {
|
||||
return m.DoGenerateOAuthState(ctx, provider, clientID, attachUserID)
|
||||
return m.DoGenerateOAuthState(ctx, provider, clientID, attachUserID, inviteCode)
|
||||
}
|
||||
return store.Generate62RandomString(14), nil
|
||||
return id.Generate62RandomString(14), nil
|
||||
}
|
||||
|
||||
func TestViewOauthInit(t *testing.T) {
|
||||
|
@ -173,7 +181,7 @@ func TestViewOauthInit(t *testing.T) {
|
|||
app := &MockOAuthDatastoreProvider{
|
||||
DoDB: func() OAuthDatastore {
|
||||
return &MockOAuthDatastore{
|
||||
DoGenerateOAuthState: func(ctx context.Context, provider, clientID string, attachUserID int64) (string, error) {
|
||||
DoGenerateOAuthState: func(ctx context.Context, provider, clientID string, attachUserID int64, inviteCode string) (string, error) {
|
||||
return "", fmt.Errorf("pretend unable to write state error")
|
||||
},
|
||||
}
|
||||
|
@ -246,7 +254,7 @@ func TestViewOauthCallback(t *testing.T) {
|
|||
req, err := http.NewRequest("GET", "/oauth/callback", nil)
|
||||
assert.NoError(t, err)
|
||||
rr := httptest.NewRecorder()
|
||||
err = h.viewOauthCallback(nil, rr, req)
|
||||
err = h.viewOauthCallback(&App{cfg: app.Config(), sessionStore: app.SessionStore()}, rr, req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, rr.Code)
|
||||
})
|
||||
|
|
|
@ -62,7 +62,7 @@ func (c writeAsOauthClient) exchangeOauthCode(ctx context.Context, code string)
|
|||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", "writefreely")
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||
|
@ -91,7 +91,7 @@ func (c writeAsOauthClient) inspectOauthAccessToken(ctx context.Context, accessT
|
|||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", "writefreely")
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
|
||||
[provider_sect]
|
||||
default = default_sect
|
||||
legacy = legacy_sect
|
||||
|
||||
[default_sect]
|
||||
activate = 1
|
||||
|
||||
[legacy_sect]
|
||||
activate = 1
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue