Compare commits
1456 Commits
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 | c2417399a4 | |
Matt Baer | 8cc793142e | |
Matt Baer | 599e7669d0 | |
Matt Baer | dbd7eff7ea | |
Matt Baer | 07debec8d5 | |
Matt Baer | 8ad04c5187 | |
CJ Eller | f11e6ed843 | |
CJ Eller | 540d716d29 | |
Matt Baer | 1d25b38eb7 | |
Matt Baer | c3400242f0 | |
Matt Baer | 9c93e55e0a | |
Matt Baer | 0acc630af5 | |
Matt Baer | 491a1148ee | |
Matt Baer | 5d01f49ce9 | |
Matt Baer | d7d4cd907e | |
Matt Baer | b25e80bb1b | |
Matt Baer | 9dbba9d8c7 | |
Nick Gerakines | 048e8a5e13 | |
Matt Baer | f9cd87ae3a | |
Matt Baer | cf4f08b264 | |
Matt Baer | 75a9df82ab | |
Matt Baer | 9e25979e37 | |
Matt Baer | 0285a9b0bd | |
Matt Baer | 79a968f425 | |
Matt Baer | ac522ed600 | |
Matt Baer | 97aec9c158 | |
Matt Baer | 471a9e0602 | |
Matt Baer | a9bed9fea9 | |
Matt Baer | f4c106beaf | |
Matt Baer | 3e1019f29d | |
Matt Baer | 06054a2cd7 | |
Matt Baer | da0455198d | |
Matt Baer | 5b6e008118 | |
Kyle Robbertze | 26b6ed5f4f | |
Matt Baer | f126ac624a | |
Kyle Robbertze | c292512b9d | |
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 | bad970c60a | |
Matt Baer | 2aeb994b04 | |
Matt Baer | 172a6dba25 | |
Matt Baer | eda267e30a | |
Matt Baer | 32f3fcb859 | |
Matt Baer | 61ddcff2c0 | |
Matt Baer | 83b2c5a21b | |
Matt Baer | 471ef4d403 | |
Matt Baer | bb5da1d3f5 | |
Matt Baer | f1ffcf96ec | |
Matt Baer | 5b2612af54 | |
Matt Baer | 793380c1d9 | |
Matt Baer | 2db6c33a41 | |
Matt Baer | 151ec71163 | |
Matt Baer | 7aef706977 | |
Matt Baer | c71d020e86 | |
Matti R | 2550804d93 | |
Matti R | b6044120ef | |
Matt Baer | 6aa8de3a4b | |
Matt Baer | fca864c94a | |
Matt Baer | 7283b17400 | |
Matt Baer | 4595d480ae | |
Matt Baer | cd2e725746 | |
CJ Eller | e140fe139f | |
CJ Eller | 6027f7cfdc | |
koehr | b42760abab | |
Matt Baer | f903388a28 | |
Matt Baer | 9fe528bf47 | |
Matt Baer | 303144fd24 | |
Matt Baer | 46dbb10433 | |
Matt Baer | d17e82d34c | |
Matt Baer | 05aad04b21 | |
Matt Baer | 8933076296 | |
Matt Baer | 6f3b502e65 | |
Matt Baer | e6e8cb5944 | |
Matt Baer | 563ea5b25b | |
Matt Baer | 34d196376e | |
Matt Baer | 8e8eb3c563 | |
Matt Baer | 987c74c93a | |
Matt Baer | 37b7755c08 | |
Matt Baer | c2ece926e0 | |
Matt Baer | 389dc8b9db | |
Matt Baer | a06bb457de | |
Matt Baer | 48ca695c46 | |
Matt Baer | 68e992a55e | |
Matt Baer | 8e2eab5b73 | |
Shlee | 7d15b799f0 | |
Matt Baer | 04a76c4120 | |
Matt Baer | 602cd80020 | |
Matt Baer | 0d79057bae | |
Matt Baer | 84ab41697b | |
Matt Baer | f79926031f | |
Matt Baer | 8364dce398 | |
Matt Baer | b58464addb | |
Matt Baer | 92da069ce4 | |
Matt Baer | 71224d68a2 | |
Matt Baer | 8ce7d4c9fc | |
Rob Loranger | 33474cb1f1 | |
Matt Baer | 7fe281df69 | |
Matt Baer | b1d006fcf2 | |
Matt Baer | 5d754176e0 | |
Matti R | b0f0de3dde | |
Matti R | 6173405794 | |
Matt Baer | f846cada4b | |
Matt Baer | 9fb12eea74 | |
Matt Baer | 42467fc9c1 | |
Matt Baer | ab2b8dff7f | |
Matt Baer | f406f894c5 | |
Matt Baer | d6c0026644 | |
Matt Baer | 859702f3e7 | |
Matt Baer | 7023b74d12 | |
Matt Baer | 629d40b549 | |
Matt Baer | f70c1dfaa5 | |
Matt Baer | 468bbf2187 | |
Matt Baer | 252d59d3f7 | |
Matt Baer | b78f64bad3 | |
Matt Baer | 8cfffb5650 | |
Matt Baer | 6d3803bfe8 | |
Matt Baer | f902f65365 | |
Matt Baer | 1a10bb3ed6 | |
Matt Baer | fe82cbb96e | |
Matt Baer | f8a40fac4b | |
Matt Baer | 666bd1b9d1 | |
Matt Baer | af14bcbb78 | |
Matt Baer | c9faff178d | |
Matt Baer | 9d360f0e41 | |
Matt Baer | 9be05ef32e | |
Matt Baer | 9589612d0e | |
Matt Baer | ca4b0acf60 | |
Matt Baer | 457051106d | |
Matt Baer | eac223158a | |
Matt Baer | 867eb53b35 | |
Matt Baer | 81edb739dd | |
Matt Baer | bb63e64883 | |
Matt Baer | 68d63d3fef | |
Matt Baer | 1b8f62d143 | |
Matt Baer | fec0eb2a0b | |
Matt Baer | 6e36868e92 | |
Matt Baer | 1fd4230267 | |
Matti R | 0ed3059bd7 | |
Matt Baer | ff33c59f27 | |
Matt Baer | 5452bf0c0d | |
Matt Baer | 51700cc7da | |
Matt Baer | bc9455db4f | |
Matt Baer | 5de2f633e1 | |
Matt Baer | 50901d2446 | |
Matt Baer | d6b7a5925f | |
Matt Baer | 93dd2341c2 | |
Matt Baer | 4d5f58a7e6 | |
Matt Baer | 3e902461f1 | |
Matt Baer | 5ddd73eff4 | |
Matt Baer | b25cec8381 | |
Matt Baer | be0885698e | |
Matt Baer | 8fce34b70b | |
Matt Baer | ae1a892be0 | |
Matt Baer | bf8dcff01e | |
Matt Baer | 8d3e755c8f | |
Matt Baer | bc9843dfa3 | |
Matt Baer | fe26594e8c | |
Matt Baer | 30032e74a0 | |
Matt Baer | b336e95e12 | |
Rob Loranger | 2c075c0347 | |
Matt Baer | 8e09e72979 | |
Matt Baer | b9914dd65a | |
Matt Baer | c1ec6b2605 | |
Matt Baer | dcdd4dd1ef | |
Matt Baer | 803dd78df5 | |
Matt Baer | f7dabd39c2 | |
Matt Baer | b5a38efd28 | |
Matt Baer | 130c9eb747 | |
Matt Baer | 6842ab2e3b | |
Matt Baer | 4d5c89e7ef | |
Matt Baer | 33a6129d1e | |
Matt Baer | f2f779e4a2 | |
Matt Baer | d297859705 | |
Nick Gerakines | 5d834c1cd2 | |
Nick Gerakines | c0317b4e93 | |
Rob Loranger | 571460f08d | |
Rob Loranger | 0766e6cb36 | |
Matti R | 80cffbb3ec | |
Matt Baer | 75e2b60328 | |
Matt Baer | 3e97625cca | |
Matt Baer | 65e2e5126b | |
Matt Baer | 2b066997d1 | |
Matti R | 98ca449b66 | |
Rob Loranger | aae2f28bb6 | |
Matti R | f4c6ce76dd | |
Matt Baer | c7b797929b | |
Nick Gerakines | f7995bee48 | |
Matt Baer | 659392ac4f | |
Matt Baer | c00daf64b0 | |
Nick Gerakines | a77d403dfb | |
Matt Baer | 9958a1122b | |
Matt Baer | 812136357e | |
Matt Baer | f5d21c8c1a | |
Matt Baer | 18d3456a23 | |
Matt Baer | 03eeca179e | |
Matt Baer | 6860c0a3ff | |
Matt Baer | 5b7f37aed8 | |
Matt Baer | a2a9f60976 | |
Nick Gerakines | 8ddfce4f19 | |
Nick Gerakines | 6d79ed3cfd | |
Nick Gerakines | 5e76565271 | |
Matt Baer | e5671cd1e6 | |
Matt Baer | be76f865a4 | |
Matt Baer | d66091a356 | |
Nick Gerakines | 28cf4dd5f5 | |
Matt Baer | 9be534038b | |
Matt Baer | 9fb8de48d4 | |
Matt Baer | 77e0126808 | |
Matt Baer | 5249456ec6 | |
Nick Gerakines | 6429d495a2 | |
Matt Baer | a4579719cd | |
Matt Baer | 97b25628fb | |
Nick Gerakines | a4e373065c | |
Nick Gerakines | 0b229a5ede | |
Nick Gerakines | 6d8da2bffd | |
Matt Baer | 2486b3c100 | |
Nick Gerakines | 6823f10821 | |
Nick Gerakines | 2aea9560bc | |
Nick Gerakines | 31e2dac118 | |
Nick Gerakines | cd5fea5ff1 | |
Nick Gerakines | ee1473aa56 | |
Nick Gerakines | 37f0c281ab | |
Nick Gerakines | b985292b18 | |
Nick Gerakines | 9170c84617 | |
Matt Baer | f343cebce7 | |
Nick Gerakines | b5f716135b | |
Matt Baer | ad5f72d8a4 | |
Matt Baer | 6bcc4cfa46 | |
Matt Baer | 39d0f1de98 | |
Matt Baer | af23e28d05 | |
Nick Gerakines | cf87ae9096 | |
Nick Gerakines | 462f87919a | |
Nick Gerakines | 13121cb266 | |
Nick Gerakines | 4266154749 | |
Nick Gerakines | bf3b6a5ba0 | |
Nick Gerakines | 7a0863f71b | |
Rob Loranger | dae65b7d1f | |
Matt Baer | dc1af91cf6 | |
Matt Baer | e16ea3b419 | |
Matt Baer | 8dc1ef0fdb | |
Matt Baer | ed40e9dea5 | |
Matt Baer | 6afafa4d67 | |
Matt Baer | cfea887b78 | |
Rob Loranger | 26d906ae92 | |
Rob Loranger | 4c0e4d04c1 | |
Matt Baer | aa405bc57c | |
Matt Baer | 6f6204a849 | |
Matt Baer | 6a5d49eeb7 | |
Matt Baer | 0b701c5f7f | |
Matt Baer | acb8f5fe5d | |
Matt Baer | 5259c4fcdf | |
Matt Baer | d8f77585f5 | |
Matt Baer | a266d8e032 | |
Matt Baer | 5fa164d5cf | |
Matt Baer | 8c1bf2ddd5 | |
Matt Baer | a513c99a1e | |
Matt Baer | ae5bbd273d | |
Matt Baer | 88a3ed7878 | |
Matt Baer | 59d892e486 | |
Matt Baer | 181af8c5c8 | |
Matt Baer | af6e5dea3a | |
Matt Baer | bbb7b28110 | |
Matt Baer | d8df15855c | |
Matt Baer | 342c3cde89 | |
Matt Baer | 44a6703742 | |
Matt Baer | c81927a69f | |
yalh76 | 36df095dac | |
Matt Baer | 8d8e671a07 | |
Matt Baer | bd99044e9c | |
Matt Baer | 2899d98cfd | |
Matt Baer | 278e4f6242 | |
Matt Baer | 3d49baf39a | |
Rob j Loranger | 474a5d908d | |
Rob Loranger | 7e014ca659 | |
Matt Baer | 80362000fe | |
Matt Baer | 79f35a0ccd | |
Rob Loranger | 9b69de166f | |
Matt Baer | bca678aee5 | |
Matt Baer | 53586d9cb8 | |
Matt Baer | 5839c2ac4d | |
Matt Baer | 8f24da94a6 | |
Matt Baer | 5644e8d251 | |
Matt Baer | 7f96e8c384 | |
Matt Baer | c3f76a3ab8 | |
Matt Baer | f7550a0da8 | |
Matt Baer | d4206cd5f8 | |
Matt Baer | a9b5bb2f6b | |
Matt Baer | d5dd007ff7 | |
Matt Baer | 3e8d1014d9 | |
Matt Baer | 422c16f39a | |
Matt Baer | f673f9b562 | |
Matt Baer | 6d4ec0b17d | |
Matt Baer | 6e09fcb9e2 | |
Matt Baer | 38f3eec8e0 | |
Matt Baer | a65917ae2e | |
Matt Baer | 2c2ee0c00c | |
Rob Loranger | f66d5bf1e8 | |
Rob Loranger | c0b75f6b65 | |
Matt Baer | e1149cd1e9 | |
Matt Baer | 619b10c3e5 | |
Matt Baer | 280c32afdc | |
Matt Baer | c9f7219831 | |
Matt Baer | da7dcfee6a | |
Matt Baer | 3167e19b77 | |
Matt Baer | fea62b14ce | |
Matt Baer | fcf074cf40 | |
Rob Loranger | fc553d277f | |
Rob Loranger | 482e632ca9 | |
Rob Loranger | b83af955c3 | |
Rob Loranger | 41166e5c35 | |
Matt Baer | bf4f879383 | |
Rob Loranger | c87ca11a52 | |
Rob Loranger | 5429ca4ab0 | |
Rob Loranger | f85f0751a3 | |
Matt Baer | 9873fc443f | |
Rob Loranger | d2480cb3aa | |
Michael Demetriou | 638059a26b | |
Michael Demetriou | 8404f0896c | |
Michael Demetriou | dfa98bcfc8 | |
Michael Demetriou | 1bda0434de | |
Michael Demetriou | 972ec00c58 | |
Michael Demetriou | b9d2689828 | |
Michael Demetriou | bc2016f00f | |
Michael Demetriou | db14f04b59 | |
Michael Demetriou | 99bb77153e | |
Michael Demetriou | e5bbd45b49 | |
Michael Demetriou | 3eb638b14a | |
Rob Loranger | 25fe5285da | |
Michael Demetriou | dccfae7a61 | |
Rob Loranger | 513765c09f | |
Rob Loranger | aa9efc7b37 | |
Rob Loranger | caca8f0ae2 | |
Rob Loranger | 02dd190945 | |
Matt Baer | 3759f16ed3 | |
Matt Baer | 5a9182f688 | |
Matt Baer | c6564b3d16 | |
Matt Baer | ddce177784 | |
Matt Baer | 26a4f48e8b | |
Matt Baer | f01b439ff5 | |
Matt Baer | 7e9e3cb7eb | |
Matt Baer | 891b15b8a8 | |
Matt Baer | afa3792e8e | |
Matt Baer | a01e280890 | |
Rob Loranger | cb78fd227e | |
Rob Loranger | 43849d95d3 | |
Rob Loranger | 9d0027ec53 | |
Rob Loranger | d129894ba7 | |
Matt Baer | 0066fecc20 | |
Rob Loranger | f87371b594 | |
Matt Baer | 66974dcbff | |
Rob Loranger | a6c1f4ae41 | |
Rob Loranger | d954b7c8e3 | |
Matt Baer | 5310e6d509 | |
Rob Loranger | 0286dcf214 | |
Matt Baer | 66b0945b70 | |
Rob Loranger | 4d150fe831 | |
Rob Loranger | 25145296b3 | |
Rob Loranger | 84d7ac35d3 | |
Rob Loranger | feba200916 | |
Rob Loranger | aad4768aed | |
Rob Loranger | 38c1bf9cab | |
Matt Baer | 6b99d75aa9 | |
Matt Baer | c7a90d2ace | |
Matt Baer | 40ffb3a5f9 | |
Matt Baer | 9256293123 | |
Matt Baer | 151e996387 | |
Matt Baer | b7acd39051 | |
Rob Loranger | 908f009248 | |
Matt Baer | ca388d6536 | |
Matt Baer | 94b8fa7756 | |
Matt Baer | 811a0a3cfb | |
Rob Loranger | 6396749f31 | |
Matt Baer | 4419632f83 | |
Matt Baer | 8ec25f1fb4 | |
Matt Baer | 954e57897b | |
Rob Loranger | 2a7a8298e1 | |
Rob Loranger | eae4097677 | |
Rob Loranger | 77f7b4a522 | |
Rob Loranger | 2fa2086654 | |
Rob Loranger | d9bf8ab6cc | |
Matt Baer | 4d97856ec5 | |
Rob Loranger | 6e9000659c | |
Rob Loranger | 42a2219335 | |
Matt Baer | de7acb5abe | |
Rob Loranger | 7fb3c4cafe | |
Rob Loranger | cbc9c6725a | |
Rob Loranger | 4acd35f8cd | |
Rob Loranger | 9dbf14c05e | |
Rob Loranger | 92f75a8871 | |
Rob Loranger | 6c5d89ac86 | |
Rob Loranger | 0ca198c715 | |
Rob Loranger | ee4fe2f4ad | |
Matt Baer | 55808233fd | |
Matt Baer | 8a29a4dfc9 | |
Rob Loranger | 55dc1917fe | |
Rob Loranger | f241d69425 | |
Rob Loranger | 1d80e47e07 | |
Rob Loranger | ca957c4b6d | |
Rob Loranger | b373aad298 | |
Daniel Watkins | 7a53af355e | |
Rob Loranger | 95a98234eb | |
Matt Baer | 047ad0323b | |
Matt Baer | d8405680b4 | |
Rob Loranger | 3c104cb3aa | |
Rob Loranger | 1301160921 | |
Matt Baer | fda2929aed | |
Matt Baer | df56060f99 | |
Matt Baer | 9dc15f569c | |
Matt Baer | da423fa1bc | |
Matt Baer | 603839fda7 | |
Matt Baer | f821dbaac4 | |
Matt Baer | 006b7a86ea | |
Matt Baer | 7b42efb9d9 | |
Matt Baer | cb28c95689 | |
Matt Baer | deec914ccb | |
Matt Baer | 8557119451 | |
Matt Baer | 10ca7ca00a | |
Matt Baer | 1c9438e305 | |
Matt Baer | adfcc82241 | |
Matt Baer | f8d57d9e75 | |
Matt Baer | afadf6fdf6 | |
Matt Baer | df078c569d | |
Matt Baer | de1a51d70d | |
Matt Baer | f6dc07850b | |
Matt Baer | 3cc397ad76 | |
Matt Baer | ef4a5b20d1 | |
Matt Baer | b06d1c2762 | |
Matt Baer | 582f041748 | |
Matt Baer | 35906118d0 | |
Matt Baer | ff7828c558 | |
Matt Baer | 1a80cd3c02 | |
Matt Baer | 5f28eb55a5 | |
Matt Baer | cd27a37027 | |
Matt Baer | 17f7bc1bec | |
Matt Baer | d752d29b4b | |
Matt Baer | 603a52dc46 | |
Matt Baer | 1d25784d20 | |
Matt Baer | 90ad50c7f5 | |
Matt Baer | 81847fbbcc | |
Matt Baer | f6a7dfacb9 | |
Matt Baer | 740282b7b7 | |
Matt Baer | 3321c750ac | |
Matt Baer | 0bd61da3f6 | |
Matt Baer | 6bfc441680 | |
Rob Loranger | dd2a5840ec | |
Matt Baer | 5953a50f4a | |
Rob Loranger | f02a241213 | |
Matt Baer | 73ec3e3016 | |
Matt Baer | 569bc792d0 | |
Matt Baer | a75b45f060 | |
Matt Baer | b0d70d9bdb | |
Matt Baer | a48b746706 | |
Matt Baer | 3129b837f1 | |
Matt Baer | bd4bb52b9c | |
Matt Baer | 4faf41ae7f | |
Matt Baer | f6f116d672 | |
Matt Baer | f541f72224 | |
Matt Baer | ba3cb4b4ff | |
Matt Baer | 1f7a0f0122 | |
Matt Baer | 3346e735d3 | |
Matt Baer | 42386beabc | |
Matt Baer | 36fb7ecb2b | |
Matt Baer | 41062728f5 | |
Matt Baer | 22c1fabbcb | |
Matt Baer | 909976dd90 | |
Matt Baer | 31b521c11c | |
Matt Baer | 71fb63580a | |
Matt Baer | e0666baa5d | |
Matt Baer | 0b25109a6b | |
Matt Baer | 3b079810bb | |
Matt Baer | 79cf6ce0eb | |
Matt Baer | 3faa2def08 | |
Matt Baer | 5923b6401c | |
Matt Baer | ad6fd5e809 | |
Matt Baer | 554995916e | |
Matt Baer | 7aaff778da | |
Matt Baer | 7240bf0cdc | |
Matt Baer | bd180f56a8 | |
Matt Baer | fdcdfe4d25 | |
Matt Baer | 60a6848361 | |
Matt Baer | 5757407994 | |
Matt Baer | 18bafadc43 | |
Matt Baer | b8b15c8550 | |
Matt Baer | a740c67495 | |
Matt Baer | ebeb7b03e6 | |
Matt Baer | c3f3eb0a65 | |
Matt Baer | a72ce2ef29 | |
Matt Baer | aedb05080c | |
Matt Baer | 6fdc343986 | |
Marcel van der Boom | f6c129ed20 | |
Matt Baer | f26e0ca86e | |
Michael Demetriou | 4feac6dcd2 | |
Matt Baer | 8d9f60aaa9 | |
Michael Demetriou | a102f97c3e | |
Matt Baer | bb0be02b4f | |
Matt Baer | 00a8f8c951 | |
Michael Demetriou | 0842119694 | |
Michael Demetriou | c2d7c2c8b7 | |
Michael Demetriou | 6506709fbc | |
Michael Demetriou | aeab30db8a | |
Michael Demetriou | efbef83362 | |
Matt Baer | 77bf403443 | |
Matt Baer | 86a128483b | |
Michael Demetriou | 07fe366c15 | |
Michael Demetriou | 1d5c396327 | |
Matt Baer | bbd775bcc6 | |
Matt Baer | 2b39b714de | |
Matt Baer | 44a4fd7a79 | |
Matt Baer | 7dc620aff1 | |
Matt Baer | d6a77d6668 | |
Matt Baer | 63b536ec87 | |
Matt Baer | 35718cd239 | |
Matt Baer | bf989eb696 | |
Matt Baer | a2088c1646 | |
Matt Baer | b3a36a3be7 | |
Matt Baer | 161f7a8de2 | |
Matt Baer | 2b8b52285d | |
Rob Loranger | 075f25b829 | |
Matt Baer | 872ec4809b | |
Matt Baer | ac7d727435 | |
Matt Baer | 36b160b706 | |
Matt Baer | f38a135bfa | |
Matt Baer | 4c34b34736 | |
Rob Loranger | f6ba1fc9c8 | |
Rob Loranger | 0c44fe1c2e | |
Matt Baer | 26a0990014 | |
Matt Baer | d5c2fe47da | |
Matt Baer | 830b859421 | |
Matt Baer | a10a4e9a28 | |
Matt Baer | be0547a62c | |
Matt Baer | 034db22f8c | |
Matt Baer | ed4aacd1ac | |
Matt Baer | f8de8f7f21 | |
Matt Baer | eb6349f93a | |
Matt Baer | 23acabaeb3 | |
Matt Baer | 758269e3d8 | |
Matt Baer | 584fe4fb93 | |
Michael Demetriou | 9570388d1d | |
Matt Baer | b2a9429db0 | |
Rob Loranger | d58c142467 | |
Matt Baer | c87b7ab39e | |
Rob Loranger | 08799b220b | |
Rob Loranger | d8fa85432d | |
Rob Loranger | 702db2bf75 | |
Matt Baer | 73f627a6c2 | |
Matt Baer | f82e11b3b3 | |
Matt Baer | 6bf4e1a52e | |
Matt Baer | 4b1ca3e296 | |
Matt Baer | 68bd5ef01a | |
Matt Baer | cba7f1223e | |
Matt Baer | cf51fc4886 | |
Noëlle Anthony | f271e53925 | |
Noëlle Anthony | 95e84a1d0e | |
Rob Loranger | 95215aa39d | |
Matt Baer | cf1d2d30e9 | |
Rob Loranger | ff2d3fc3d5 | |
Rob Loranger | 3986c8eec1 | |
Matt Baer | b6da5d9711 | |
Matt Baer | b9b41b1ef7 | |
Rob Loranger | 6ff136455c | |
Matt Baer | 2fdd58387a | |
Sandrockcstm | cf139ecd72 | |
Matt Baer | 98f5b14899 | |
Matt Baer | effab9b6a1 | |
Matt Baer | c95ee0abe1 | |
Matt Baer | 77c8152786 | |
Matt Baer | 9e43b04f04 | |
Matt Baer | d8937e89a8 | |
Matt Baer | 9023d4eb3f | |
Matt Baer | 788713116f | |
Gytis Repečka | 6da10f28ac | |
Matt Baer | 601fc5d93e | |
Darius Kazemi | 91a181f628 | |
Matt Baer | fdbefa806f | |
kaiyou | 70f754e8af | |
kaiyou | 402e9e822c | |
Matt Baer | 7a07e1009b | |
Matt Baer | 771c7acf5f | |
Matt Baer | c08484aa9c | |
Matt Baer | e80f99a72e | |
Matt Baer | 1dc93c076d | |
Matt Baer | 831209f4b6 | |
Matt Baer | 1f973464bc | |
Matt Baer | 238a913ce3 | |
Matt Baer | 63a2225b7f | |
Matt Baer | af0f6302a2 | |
Matt Baer | 7ab5350a94 | |
Matt Baer | 51ac06a51b | |
Matt Baer | 07ab0cdb9c | |
Matt Baer | 9cb0f80921 | |
Matt Baer | 4cad074b44 | |
Matt Baer | 24c56af6ee | |
Matt Baer | a850fa14cd | |
Matt Baer | 00ed2990eb | |
Matt Baer | c7a4955840 | |
Matt Baer | 09fb73bdd5 | |
Matt Baer | 45b01c041b | |
Matt Baer | 8a9ef513fa | |
Matt Baer | 24b193df96 | |
Matt Baer | 4af9fa66aa | |
Matt Baer | 5e7da6678d | |
Matt Baer | 5a0a3ce451 | |
Matt Baer | be0df11653 | |
Matt Baer | b7d07e2037 | |
Matt Baer | 54edb2562d | |
Matt Baer | 2f683e783e | |
Matt Baer | 3d30a09695 | |
Marcel van der Boom | f40ce14fb2 | |
Matt Baer | 372b4e5dcd | |
Matt Baer | 99489aa920 | |
Marcel van der Boom | c8ea346d16 | |
Matt Baer | 16c856ec27 | |
Matt Baer | e20827ac3b | |
Matt Baer | 2cee0dee2a | |
Matt Baer | 32e99d0041 | |
Matt Baer | c2436a43c5 | |
Matt Baer | a896d475e4 | |
Matt Baer | e5a00e00f5 | |
Matt Baer | ee6046bdbf | |
Matt Baer | 88e7cea28b | |
Matt Baer | 261a6fefd6 | |
Matt Baer | 02054aae18 | |
Matt Baer | ef40c50920 | |
Matt Baer | e1cd11df20 | |
Matt Baer | cd752858b7 | |
Matt Baer | c11ede53d9 | |
Matt Baer | 08667d8978 | |
Matt Baer | 47d18b2cc4 | |
Matt Baer | 9c6e7eda65 | |
Matt Baer | e682824be5 | |
Matt Baer | ed5c4ec8b1 | |
Matt Baer | 8b94418d3f | |
Matt Baer | 73ca34bb21 | |
Matt Baer | 3a6118c207 | |
Matt Baer | 5e686a5b0f | |
Matt Baer | b91c931ebd | |
Matt Baer | 53dfe4d215 | |
Matt Baer | e853a15303 | |
Matt Baer | eb8f56a6e2 | |
Matt Baer | 5de193a64d | |
Matt Baer | 1c40103fbf | |
Matt Baer | cb1bd37f64 | |
Matt Baer | 14f0d446e6 | |
Matt Baer | 5c19d249b6 | |
Matt Baer | 53a51be578 | |
Matt Baer | 6c7ee76768 | |
Matt Baer | 41b2f7628f | |
Matt Baer | 820b0ba6b9 | |
Matt Baer | d8876058a6 | |
Matt Baer | 01f9dc86dc | |
Matt Baer | 70e823d6ab | |
Matt Baer | 47b2155f92 | |
Norman | 7da8b3aef6 | |
Matt Baer | 6c2bd8031a | |
kaiyou | 8ead0a9d09 | |
koehr | 059f0d4c54 | |
Matt Baer | a76144c182 | |
Matt Baer | a9dff35f70 | |
Matt Baer | 062ae0e16a | |
Matt Baer | d0edbd1936 | |
Matt Baer | 21e6c64708 | |
Matt Baer | 6da342b0d1 | |
Matt Baer | 7c9bd10f73 | |
Matt Baer | a686b61902 | |
Matt Baer | 64a8e2d0a5 | |
Matt Baer | fe98bd58fc | |
Matt Baer | caf976a054 | |
Matt Baer | c2a7f19ef0 | |
Matt Baer | 2942a6818e | |
Matt Baer | 8a555567a6 | |
Matt Baer | 1c58c64c7c | |
Matt Baer | f53ced382f | |
Matt Baer | 0a3f5b7d16 | |
Matt Baer | 0cf1d13880 | |
Matt Baer | a58a756746 | |
Aaron Ogle | 3722c6ba79 | |
Matt Baer | bf7d422039 | |
Matt Baer | 3d301c97e9 | |
Matt Baer | 0e722de82c | |
Matt Baer | 2f4c93cccb | |
Matt Baer | a419bd63fc | |
Matt Baer | fca3019e4b | |
Matt Baer | 8513def899 | |
Matt Baer | 19215b0355 | |
Matt Baer | 8a07c0f0a0 | |
Matt Baer | 3ae45bc156 | |
Matt Baer | 256d9e3c02 | |
Matt Baer | 739afd2310 | |
Matt Baer | d4a08723aa | |
Matt Baer | e525bc32a7 | |
Matt Baer | 69eab50f42 | |
Matt Baer | 1274914207 | |
Matt Baer | 7f5551105a | |
Matt Baer | 1439c8c359 | |
Matt Baer | 5e5b283daf | |
Matt Baer | 852ca5eea4 | |
Matt Baer | 88e1c65939 | |
Matt Baer | f99244b93f | |
Matt Baer | 13bf5b6638 | |
kaiyou | 38184f4d13 | |
Matt Baer | 11de25237d | |
Matt Baer | dad79adfd2 | |
Matt Baer | 2178b4abf2 | |
Matt Baer | 3fab9f6439 | |
Matt Baer | 8beccaf6c2 | |
Matt Baer | 4b8d5e3e37 | |
Matt Baer | 25a68d0c0e | |
Matt Baer | 7828bf6ba2 | |
Matt Baer | 2422601e89 | |
Matt Baer | d3b120be75 | |
Matt Baer | a3e287a77a | |
Matt Baer | 9fb7777c33 | |
Matt Baer | 111945bc5d | |
Matt Baer | 20c77989ba | |
Matt Baer | 17c816477b | |
Matt Baer | ba3d6ae64c | |
Matt Baer | c6851fee50 | |
Matt Baer | 4b780361bf | |
Matt Baer | 026604b3dd | |
Matt Baer | daaa4564bb | |
Matt Baer | 6f4c004e8c | |
Matt Baer | bc1b3fdfb7 | |
Matt Baer | 7fab11b3c8 | |
Matt Baer | afede80e87 | |
Matt Baer | 9e466a6d23 | |
Matt Baer | 860e8c0120 | |
Matt Baer | e06b328785 | |
Matt Baer | df4cd9ed00 | |
Brad Koehn | c2a3b4935c | |
kaiyou | d01d2c80b6 | |
Marcel van der Boom | 5856e91f84 | |
Matt Baer | 8ceb165020 | |
Matt Baer | cb2b30b379 | |
Matt Baer | 8cbc02d7cf | |
Matt Baer | cbf6ff54df | |
Matt Baer | 573ce02739 | |
Matt Baer | d2f89c6360 | |
Matt Baer | 9fe4b09de5 | |
Matt Baer | 09a3fe09fe | |
Matt Baer | 5fc41687be | |
Matt Baer | f04469beee | |
Marcel van der Boom | 79a7ca750e | |
Matt Baer | 61de04338e | |
Matt Baer | c6d3ef7596 | |
Matt Baer | 31b802e440 | |
Matt Baer | fc856e36eb | |
Ben Overmyer | 6cb86214d7 | |
Matt Baer | c60c61c5b8 | |
Marcel van der Boom | 3b4d14f194 | |
Marcel van der Boom | b034a08350 | |
Marcel van der Boom | c6e4967728 | |
Matt Baer | fb18b8f6e3 | |
Matt Baer | fe78d6d47f | |
Ben Overmyer | dd5f6870d8 | |
Marcel van der Boom | 875c758ba2 | |
Marcel van der Boom | 543f6c9ae3 |
|
@ -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
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Let us know what went wrong.
|
||||
|
||||
labels: ❓ bug
|
||||
---
|
||||
|
||||
### Describe the bug
|
||||
|
@ -18,6 +18,7 @@ What should've happened?
|
|||
|
||||
### Application configuration
|
||||
- **Single mode or Multi-user mode?**
|
||||
- **Database?** [mysql/sqlite]
|
||||
- **Open registration?** [yes/no]
|
||||
- **Federation enabled?** [yes/no]
|
||||
|
||||
|
|
|
@ -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,5 @@
|
|||
[Describe the pull request here...]
|
||||
|
||||
---
|
||||
|
||||
- [ ] I have signed the [CLA](https://phabricator.write.as/L1)
|
|
@ -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,6 +1,12 @@
|
|||
node_modules
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
static/local/custom.css
|
||||
build
|
||||
config.ini
|
||||
tmp
|
||||
*.ini
|
||||
*.db
|
||||
|
||||
bindata.go
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
language: go
|
||||
|
||||
go:
|
||||
- "1.10.x"
|
||||
- "1.13.x"
|
||||
|
||||
env:
|
||||
- GO111MODULE=on
|
||||
|
||||
script: make ci
|
||||
|
|
|
@ -6,3 +6,6 @@ WriteFreely is built by [Matt Baer](https://github.com/thebaer), with contributi
|
|||
* [Ben Overmyer](https://github.com/BenOvermyer)
|
||||
* [Marcel van der Boom](https://github.com/mrvdb)
|
||||
* [Brad Koehn](https://github.com/koehn)
|
||||
* [kaiyou](https://github.com/kaiyou)
|
||||
* [Aaron Ogle](https://github.com/geekgonecrazy)
|
||||
* [Norman](https://github.com/nkoehring)
|
||||
|
|
101
CONTRIBUTING.md
101
CONTRIBUTING.md
|
@ -1,26 +1,99 @@
|
|||
# Contributing to WriteFreely
|
||||
|
||||
Welcome! We're glad you're interested in contributing to the WriteFreely project.
|
||||
Welcome! We're glad you're interested in contributing to WriteFreely.
|
||||
|
||||
To start, we'd suggest checking out [our Phabricator board](https://phabricator.write.as/tag/write_freely/) to see where the project is at and where it's going. You can also [join the WriteFreely forums](https://discuss.write.as/c/writefreely) to start talking about what you'd like to do or see.
|
||||
For **questions**, **help**, **feature requests**, and **general discussion**, please use [our forum](https://discuss.write.as).
|
||||
|
||||
## Asking Questions
|
||||
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).
|
||||
|
||||
The best place to get answers to your questions is on [our forums](https://discuss.write.as/c/writefreely). You can quickly log in using your GitHub account and ask the community about anything. We're also there to answer your questions and discuss potential changes or features.
|
||||
## Getting Started
|
||||
|
||||
## Submitting Bugs
|
||||
There are many ways to contribute to WriteFreely, from code to documentation, to translations, to help in the community!
|
||||
|
||||
Please use the [GitHub issue tracker](https://github.com/writeas/writefreely/issues/new) to report any bugs you encounter. We're very responsive there and try to keep open issues to a minimum, so you can help by:
|
||||
See our [Contributing Guide](https://writefreely.org/contribute) on WriteFreely.org for ways to contribute without writing code. Otherwise, please read on.
|
||||
|
||||
* **Only reporting bugs in the issue tracker**
|
||||
* Providing as much information as possible to replicate the issue, including server logs around the incident
|
||||
* Including the `[app]` section of your configuration, if related
|
||||
* Breaking issues into smaller pieces if they're larger or have many parts
|
||||
## Working on WriteFreely
|
||||
|
||||
## Contributing code
|
||||
First, you'll want to clone the WriteFreely repo, install development dependencies, and build the application from source. Learn how to do this in our [Development Setup](https://writefreely.org/docs/latest/developer/setup) guide.
|
||||
|
||||
We gladly welcome development help, regardless of coding experience. We can also use help [translating the app](https://poeditor.com/join/project/TIZ6HFRFdE) and documenting it!
|
||||
### Starting development
|
||||
|
||||
**Before writing or submitting any code**, please sign our [contributor's agreement](https://phabricator.write.as/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://phabricator.write.as/w/writefreely/cla/).
|
||||
Next, [join our forum](https://discuss.write.as) so you can discuss development with the team. Then take a look at [our roadmap on Phabricator](https://phabricator.write.as/tag/write_freely/) to see where the project is today and where it's headed.
|
||||
|
||||
Once you've done that, please feel free to [submit a pull request](https://github.com/writeas/writefreely/pulls) for any small improvements. For larger projects, please [join our development discussions](https://discuss.write.as/c/writefreely) or [get in touch](https://write.as/contact) so we can talk about what you'd like to work on.
|
||||
When you find something you want to work on, start a new topic on the forum or jump into an existing discussion, if there is one. The team will respond and continue the conversation there.
|
||||
|
||||
Lastly, **before submitting any code**, please sign our [contributor's agreement](https://phabricator.write.as/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://phabricator.write.as/w/writefreely/cla/).
|
||||
|
||||
### Branching
|
||||
|
||||
All stable work lives on the `master` branch. We merge into it only when creating a release. Releases are tagged using semantic versioning.
|
||||
|
||||
While developing, we primarily work from the `develop` branch, creating _feature branches_ off of it for new features and fixes. When starting a new feature or fix, you should also create a new branch off of `develop`.
|
||||
|
||||
#### Branch naming
|
||||
|
||||
For fixes and modifications to existing behavior, branch names should follow a similar pattern to commit messages (see below), such as `fix-post-rendering` or `update-documentation`. You can optionally append a task number, e.g. `fix-post-rendering-T000`.
|
||||
|
||||
For new features, branches can be named after the new feature, e.g. `activitypub-mentions` or `import-zip`.
|
||||
|
||||
#### Pull request scope
|
||||
|
||||
The scope of work on each branch should be as small as possible -- one complete feature, one complete change, or one complete fix. This makes it easier for us to review and accept.
|
||||
|
||||
### Writing code
|
||||
|
||||
We value reliable, readable, and maintainable code over all else in our work. To help you write that kind of code, we offer a few guiding principles, as well as a few concrete guidelines.
|
||||
|
||||
#### Guiding principles
|
||||
|
||||
* Write code for other humans, not computers.
|
||||
* The less complexity, the better. The more someone can understand code just by looking at it, the better.
|
||||
* Functionality, readability, and maintainability over senseless elegance.
|
||||
* Only abstract when necessary.
|
||||
* Keep an eye to the future, but don't pre-optimize at the expense of today's simplicity.
|
||||
|
||||
#### Code guidelines
|
||||
|
||||
* Format all Go code with `go fmt` before committing (**important!**)
|
||||
* Follow whitespace conventions established within the project (tabs vs. spaces)
|
||||
* Add comments to exported Go functions and variables
|
||||
* Follow Go naming conventions, like using [`mixedCaps`](https://golang.org/doc/effective_go.html#mixed-caps)
|
||||
* Avoid new dependencies unless absolutely necessary
|
||||
|
||||
### Commit messages
|
||||
|
||||
We highly value commit messages that follow established form within the project. Generally speaking, we follow the practices [outlined](https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines) in the Pro Git Book. A good commit message will look like the following:
|
||||
|
||||
* **Line 1**: A short summary written in the present imperative tense. For example:
|
||||
* ✔️ **Good**: "Fix post rendering bug"
|
||||
* ❌ No: ~~"Fixes post rendering bug"~~
|
||||
* ❌ No: ~~"Fixing post rendering bug"~~
|
||||
* ❌ No: ~~"Fixed post rendering bug"~~
|
||||
* ❌ No: ~~"Post rendering bug is fixed now"~~
|
||||
* **Line 2**: _[left blank]_
|
||||
* **Line 3**: An added description of what changed, any rationale, etc. -- if necessary
|
||||
* **Last line**: A mention of any applicable task or issue
|
||||
* For Phabricator tasks: `Ref T000` or `Closes T000`
|
||||
* For GitHub issues: `Ref #000` or `Fixes #000`
|
||||
|
||||
#### Good examples
|
||||
|
||||
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/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
|
||||
|
||||
Like our GitHub issues, we aim to keep our number of open pull requests to a minimum. You can follow a few guidelines to ensure changes are merged quickly.
|
||||
|
||||
First, make sure your changes follow the established practices and good form outlined in this guide. This is crucial to our project, and ignoring our practices can delay otherwise important fixes.
|
||||
|
||||
Beyond that, we prioritize pull requests in this order:
|
||||
|
||||
1. Fixes to open GitHub issues
|
||||
2. Superficial changes and improvements that don't adversely impact users
|
||||
3. New features and changes that have been discussed before with the team
|
||||
|
||||
Any pull requests that haven't previously been discussed with the team may be extensively delayed or closed, especially if they require a wider consideration before integrating into the project. When in doubt, please reach out [on the forum](https://discuss.write.as) before submitting a pull request.
|
52
Dockerfile
52
Dockerfile
|
@ -1,26 +1,41 @@
|
|||
FROM golang:1.11.2-alpine3.8 as build
|
||||
# Build image
|
||||
# SHA256 of golang:1.21-alpine3.18 linux/amd64
|
||||
FROM golang@sha256:f475434ea2047a83e9ba02a1da8efc250fa6b2ed0e9e8e4eb8c5322ea6997795 as build
|
||||
|
||||
RUN apk add --update nodejs nodejs-npm make git
|
||||
RUN npm install -g less
|
||||
RUN npm install -g less-plugin-clean-css
|
||||
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
|
||||
|
||||
WORKDIR /go/src/app
|
||||
COPY . .
|
||||
|
||||
RUN make install
|
||||
RUN make ui
|
||||
RUN make deps
|
||||
RUN cat ossl_legacy.cnf > /etc/ssl/openssl.cnf
|
||||
|
||||
RUN mkdir /stage && \
|
||||
cp -R /go/bin \
|
||||
/go/src/app/templates \
|
||||
/go/src/app/static \
|
||||
/go/src/app/schema.sql \
|
||||
/go/src/app/pages \
|
||||
/go/src/app/keys \
|
||||
ENV GO111MODULE=on
|
||||
ENV NODE_OPTIONS=--openssl-legacy-provider
|
||||
|
||||
RUN make build \
|
||||
&& 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
|
||||
|
||||
FROM alpine:3.8
|
||||
# Final image
|
||||
# SHA256 of alpine:3.18.4 linux/amd64
|
||||
FROM alpine@sha256:48d9183eb12a05c99bcc0bf44a003607b8e941e1d4f41f9ad12bdcc4b5672f86
|
||||
|
||||
RUN apk -U upgrade \
|
||||
&& apk add --no-cache openssl ca-certificates
|
||||
|
||||
COPY --from=build --chown=daemon:daemon /stage /go
|
||||
|
||||
|
@ -29,4 +44,7 @@ VOLUME /go/keys
|
|||
EXPOSE 8080
|
||||
USER daemon
|
||||
|
||||
CMD ["bin/writefreely"]
|
||||
ENTRYPOINT ["cmd/writefreely/writefreely"]
|
||||
|
||||
HEALTHCHECK --start-period=5s --interval=15s --timeout=5s \
|
||||
CMD curl -fSs http://localhost:8080/ || exit 1
|
119
Makefile
119
Makefile
|
@ -1,5 +1,5 @@
|
|||
GITREV=`git describe --tags | cut -c 2-`
|
||||
LDFLAGS=-ldflags="-X 'github.com/writeas/writefreely.softwareVer=$(GITREV)'"
|
||||
GITREV=`git describe | cut -c 2-`
|
||||
LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)'"
|
||||
|
||||
GOCMD=go
|
||||
GOINSTALL=$(GOCMD) install $(LDFLAGS)
|
||||
|
@ -7,22 +7,57 @@ GOBUILD=$(GOCMD) build $(LDFLAGS)
|
|||
GOTEST=$(GOCMD) test $(LDFLAGS)
|
||||
GOGET=$(GOCMD) get
|
||||
BINARY_NAME=writefreely
|
||||
BUILDPATH=build/$(BINARY_NAME)
|
||||
DOCKERCMD=docker
|
||||
IMAGE_NAME=writeas/writefreely
|
||||
TMPBIN=./tmp
|
||||
|
||||
all : build
|
||||
|
||||
build: deps
|
||||
ci: deps
|
||||
cd cmd/writefreely; $(GOBUILD) -v
|
||||
|
||||
build: deps
|
||||
cd cmd/writefreely; $(GOBUILD) -v -tags='netgo sqlite'
|
||||
|
||||
build-no-sqlite: deps-no-sqlite
|
||||
cd cmd/writefreely; $(GOBUILD) -v -tags='netgo' -o $(BINARY_NAME)
|
||||
|
||||
build-linux: deps
|
||||
cd cmd/writefreely; GOOS=linux GOARCH=amd64 $(GOBUILD) -v
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-windows: deps
|
||||
cd cmd/writefreely; GOOS=windows GOARCH=amd64 $(GOBUILD) -v
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-darwin: deps
|
||||
cd cmd/writefreely; GOOS=darwin GOARCH=amd64 $(GOBUILD) -v
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
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 \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
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 \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
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 \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
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) .
|
||||
|
@ -31,45 +66,83 @@ test:
|
|||
$(GOTEST) -v ./...
|
||||
|
||||
run:
|
||||
$(GOINSTALL) ./...
|
||||
$(GOINSTALL) -tags='netgo sqlite' ./...
|
||||
$(BINARY_NAME) --debug
|
||||
|
||||
deps :
|
||||
$(GOGET) -v ./...
|
||||
$(GOGET) -tags='sqlite' -d -v ./...
|
||||
|
||||
deps-no-sqlite:
|
||||
$(GOGET) -d -v ./...
|
||||
|
||||
install : build
|
||||
cmd/writefreely/$(BINARY_NAME) --config
|
||||
cmd/writefreely/$(BINARY_NAME) --gen-keys
|
||||
cmd/writefreely/$(BINARY_NAME) --init-db
|
||||
cd less/; $(MAKE) install $(MFLAGS)
|
||||
|
||||
release : clean ui
|
||||
mkdir build
|
||||
cp -r templates build
|
||||
cp -r pages build
|
||||
cp -r static build
|
||||
mkdir build/keys
|
||||
cp schema.sql build
|
||||
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
|
||||
cp cmd/writefreely/$(BINARY_NAME) build
|
||||
cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz *
|
||||
rm build/$(BINARY_NAME)
|
||||
mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-arm6
|
||||
mv build/$(BINARY_NAME)-linux-arm-6 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm6.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-arm7
|
||||
mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-arm64
|
||||
mv build/$(BINARY_NAME)-linux-arm64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-darwin
|
||||
cp cmd/writefreely/$(BINARY_NAME) build
|
||||
cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_darwin_amd64.tar.gz *
|
||||
rm build/$(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
|
||||
cp cmd/writefreely/$(BINARY_NAME).exe build
|
||||
cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./*
|
||||
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).exe
|
||||
$(MAKE) build-docker
|
||||
$(MAKE) release-docker
|
||||
|
||||
# This assumes you're on linux/amd64
|
||||
release-linux : clean ui
|
||||
mkdir -p $(BUILDPATH)
|
||||
cp -r templates $(BUILDPATH)
|
||||
cp -r pages $(BUILDPATH)
|
||||
cp -r static $(BUILDPATH)
|
||||
mkdir $(BUILDPATH)/keys
|
||||
$(MAKE) build-no-sqlite
|
||||
mv cmd/writefreely/$(BINARY_NAME) $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
|
||||
|
||||
release-docker :
|
||||
$(DOCKERCMD) push $(IMAGE_NAME)
|
||||
|
||||
|
||||
ui : force_look
|
||||
cd less/; $(MAKE) $(MFLAGS)
|
||||
cd prose/; $(MAKE) $(MFLAGS)
|
||||
|
||||
$(TMPBIN):
|
||||
mkdir -p $(TMPBIN)
|
||||
|
||||
$(TMPBIN)/xgo: deps $(TMPBIN)
|
||||
$(GOBUILD) -o $(TMPBIN)/xgo src.techknowlogick.com/xgo
|
||||
|
||||
clean :
|
||||
-rm -rf build
|
||||
-rm -rf tmp
|
||||
cd less/; $(MAKE) clean $(MFLAGS)
|
||||
|
||||
force_look :
|
||||
|
|
149
README.md
149
README.md
|
@ -1,134 +1,89 @@
|
|||
|
||||
<p align="center">
|
||||
<a href="https://writefreely.org"><img src="https://writefreely.org/img/writefreely.svg" width="350px" alt="Write Freely" /></a>
|
||||
<a href="https://writefreely.org"><img src="https://writefreely.org/img/writefreely.svg" width="350px" alt="WriteFreely" /></a>
|
||||
</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="http://webchat.freenode.net/?channels=writefreely">
|
||||
<img alt="#writefreely on freenode" src="https://img.shields.io/badge/freenode-%23writefreely-blue.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" />
|
||||
</a>
|
||||
</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)
|
||||
|
||||
**[Start a blog on our instance](https://write.as/new/blog/federated)**
|
||||
[Try the writing experience](https://write.as/new)
|
||||
|
||||
[Try the editor](https://write.as/new)
|
||||
|
||||
[Find another instance](https://writefreely.org/instances)
|
||||
[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 dead-simple, distraction-free and super fast editor
|
||||
* Publish drafts and let others proofread them by sharing a private link
|
||||
* Build more advanced apps and extensions with the [well-documented API](https://developers.write.as/docs/api/)
|
||||
### Made for writing
|
||||
|
||||
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.
|
||||
|
||||
### A connected community
|
||||
|
||||
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.
|
||||
|
||||
### Intuitive organization
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
> **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.
|
||||
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!
|
||||
|
||||
First, download the [latest release](https://github.com/writeas/writefreely/releases/latest) for your OS. It includes everything you need to start your blog.
|
||||
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.
|
||||
|
||||
Now extract the files from the archive, change into the directory, and do the following steps:
|
||||
### Packages
|
||||
|
||||
```bash
|
||||
# 1) Log into MySQL and run:
|
||||
# CREATE DATABASE writefreely;
|
||||
#
|
||||
# 2) Configure your blog
|
||||
./writefreely --config
|
||||
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
|
||||
|
||||
# 3) Import the schema with:
|
||||
./writefreely --init-db
|
||||
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
|
||||
|
||||
# 4) Generate data encryption keys
|
||||
./writefreely --gen-keys
|
||||
## Documentation
|
||||
|
||||
# 5) Run
|
||||
./writefreely
|
||||
|
||||
# 6) Check out your site at the URL you specified in the setup process
|
||||
# 7) There is no Step 7, you're done!
|
||||
```
|
||||
|
||||
For running in production, [see our guide](https://writefreely.org/start#production).
|
||||
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? Here's a quick overview.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* [Go 1.10+](https://golang.org/dl/)
|
||||
* [Node.js](https://nodejs.org/en/download/)
|
||||
|
||||
### Setting up
|
||||
|
||||
```bash
|
||||
go get github.com/writeas/writefreely/cmd/writefreely
|
||||
```
|
||||
|
||||
Configure your site, create your database, and import the schema [as shown above](#quick-start). Then generate the remaining files you'll need:
|
||||
|
||||
```bash
|
||||
make install # Generates encryption keys; installs LESS compiler
|
||||
make ui # Generates CSS (run this whenever you update your styles)
|
||||
make run # Runs the application
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
### Using Docker for Development
|
||||
|
||||
If you'd like to use Docker as a base for working on a site's styles and such,
|
||||
you can run the following from a Bash shell.
|
||||
|
||||
*Note: This process is intended only for working on site styling. If you'd
|
||||
like to run Write Freely in production as a Docker service, it'll require a
|
||||
little more work.*
|
||||
|
||||
The `docker-setup.sh` script will present you with a few questions to set up
|
||||
your dev instance. You can hit enter for most of them, except for "Admin username"
|
||||
and "Admin password." You'll probably have to wait a few seconds after running
|
||||
`docker-compose up -d` for the Docker services to come up before running the
|
||||
bash script.
|
||||
|
||||
```
|
||||
docker-compose up -d
|
||||
./docker-setup.sh
|
||||
```
|
||||
|
||||
Now you should be able to navigate to http://localhost:8080 and start working!
|
||||
|
||||
When you're completely done working, you can run `docker-compose down` to destroy
|
||||
your virtual environment, including your database data. Otherwise, `docker-compose stop`
|
||||
will shut down your environment without destroying your data.
|
||||
|
||||
### Using Docker for Production
|
||||
|
||||
Write Freely doesn't yet provide an official Docker pathway to production. We're
|
||||
working on it, though!
|
||||
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 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.
|
689
account.go
689
account.go
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,194 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/writeas/impart"
|
||||
wfimport "github.com/writeas/import"
|
||||
"github.com/writeas/web-core/log"
|
||||
)
|
||||
|
||||
func viewImport(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
// Fetch extra user data
|
||||
p := NewUserPage(app, r, u, "Import Posts", nil)
|
||||
|
||||
c, err := app.db.GetCollections(u, app.Config().App.Host)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("unable to fetch collections: %v", err)}
|
||||
}
|
||||
|
||||
d := struct {
|
||||
*UserPage
|
||||
Collections *[]Collection
|
||||
Flashes []template.HTML
|
||||
Message string
|
||||
InfoMsg bool
|
||||
}{
|
||||
UserPage: p,
|
||||
Collections: c,
|
||||
Flashes: []template.HTML{},
|
||||
}
|
||||
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
for _, flash := range flashes {
|
||||
if strings.HasPrefix(flash, "SUCCESS: ") {
|
||||
d.Message = strings.TrimPrefix(flash, "SUCCESS: ")
|
||||
} else if strings.HasPrefix(flash, "INFO: ") {
|
||||
d.Message = strings.TrimPrefix(flash, "INFO: ")
|
||||
d.InfoMsg = true
|
||||
} else {
|
||||
d.Flashes = append(d.Flashes, template.HTML(flash))
|
||||
}
|
||||
}
|
||||
|
||||
showUserPage(w, "import", d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleImport(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
// limit 10MB per submission
|
||||
r.ParseMultipartForm(10 << 20)
|
||||
|
||||
collAlias := r.PostFormValue("collection")
|
||||
coll := &Collection{
|
||||
ID: 0,
|
||||
}
|
||||
var err error
|
||||
if collAlias != "" {
|
||||
coll, err = app.db.GetCollection(collAlias)
|
||||
if err != nil {
|
||||
log.Error("Unable to get collection for import: %s", err)
|
||||
return err
|
||||
}
|
||||
// Only allow uploading to collection if current user is owner
|
||||
if coll.OwnerID != u.ID {
|
||||
err := ErrUnauthorizedGeneral
|
||||
_ = addSessionFlash(app, w, r, err.Message, nil)
|
||||
return err
|
||||
}
|
||||
coll.hostName = app.cfg.App.Host
|
||||
}
|
||||
|
||||
fileDates := make(map[string]int64)
|
||||
err = json.Unmarshal([]byte(r.FormValue("fileDates")), &fileDates)
|
||||
if err != nil {
|
||||
log.Error("invalid form data for file dates: %v", err)
|
||||
return impart.HTTPError{http.StatusBadRequest, "form data for file dates was invalid"}
|
||||
}
|
||||
files := r.MultipartForm.File["files"]
|
||||
var fileErrs []error
|
||||
filesSubmitted := len(files)
|
||||
var filesImported int
|
||||
for _, formFile := range files {
|
||||
fname := ""
|
||||
ok := func() bool {
|
||||
file, err := formFile.Open()
|
||||
if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("Unable to read file %s", formFile.Filename))
|
||||
log.Error("import file: open from form: %v", err)
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
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)
|
||||
return false
|
||||
}
|
||||
defer tempFile.Close()
|
||||
|
||||
_, err = io.Copy(tempFile, file)
|
||||
if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
|
||||
log.Error("import file: copy to temp location %s: %v", formFile.Filename, err)
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := tempFile.Stat()
|
||||
if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
|
||||
log.Error("import file: stat temp file %s: %v", formFile.Filename, err)
|
||||
return false
|
||||
}
|
||||
fname = info.Name()
|
||||
return true
|
||||
}()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
post, err := wfimport.FromFile(filepath.Join(os.TempDir(), fname))
|
||||
if err == wfimport.ErrEmptyFile {
|
||||
// not a real error so don't log
|
||||
_ = addSessionFlash(app, w, r, fmt.Sprintf("%s was empty, import skipped", formFile.Filename), nil)
|
||||
continue
|
||||
} else if err == wfimport.ErrInvalidContentType {
|
||||
// same as above
|
||||
_ = addSessionFlash(app, w, r, fmt.Sprintf("%s is not a supported post file", formFile.Filename), nil)
|
||||
continue
|
||||
} else if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("failed to read copy of %s", formFile.Filename))
|
||||
log.Error("import textfile: file to post: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if collAlias != "" {
|
||||
post.Collection = collAlias
|
||||
}
|
||||
dateTime := time.Unix(fileDates[formFile.Filename], 0)
|
||||
post.Created = &dateTime
|
||||
created := post.Created.Format("2006-01-02T15:04:05Z")
|
||||
submittedPost := SubmittedPost{
|
||||
Title: &post.Title,
|
||||
Content: &post.Content,
|
||||
Font: "norm",
|
||||
Created: &created,
|
||||
}
|
||||
rp, err := app.db.CreatePost(u.ID, coll.ID, &submittedPost)
|
||||
if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("failed to create post from %s", formFile.Filename))
|
||||
log.Error("import textfile: create db post: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Federate post, if necessary
|
||||
if app.cfg.App.Federation && coll.ID > 0 {
|
||||
go federatePost(
|
||||
app,
|
||||
&PublicPost{
|
||||
Post: rp,
|
||||
Collection: &CollectionObj{
|
||||
Collection: *coll,
|
||||
},
|
||||
},
|
||||
coll.ID,
|
||||
false,
|
||||
)
|
||||
}
|
||||
filesImported++
|
||||
}
|
||||
if len(fileErrs) != 0 {
|
||||
_ = addSessionFlash(app, w, r, multierror.ListFormatFunc(fileErrs), nil)
|
||||
}
|
||||
|
||||
if filesImported == filesSubmitted {
|
||||
verb := "posts"
|
||||
if filesSubmitted == 1 {
|
||||
verb = "post"
|
||||
}
|
||||
_ = addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: Import complete, %d %s imported.", filesImported, verb), nil)
|
||||
} else if filesImported > 0 {
|
||||
_ = addSessionFlash(app, w, r, fmt.Sprintf("INFO: %d of %d posts imported, see details below.", filesImported, filesSubmitted), nil)
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, "/me/import"}
|
||||
}
|
467
activitypub.go
467
activitypub.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2018-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 (
|
||||
|
@ -7,33 +17,68 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/activity/streams"
|
||||
"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/log"
|
||||
"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/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 (
|
||||
// TODO: delete. don't use this!
|
||||
apCustomHandleDefault = "blog"
|
||||
|
||||
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 {
|
||||
|
@ -52,17 +97,28 @@ func (ru *RemoteUser) AsPerson() *activitystreams.Person {
|
|||
}
|
||||
}
|
||||
|
||||
func handleFetchCollectionActivities(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
func activityPubClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Server", serverSoftware)
|
||||
|
||||
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)
|
||||
|
@ -70,13 +126,26 @@ func handleFetchCollectionActivities(app *app, w http.ResponseWriter, r *http.Re
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
return impart.RenderActivityJSON(w, p, http.StatusOK)
|
||||
}
|
||||
|
||||
func handleFetchCollectionOutbox(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Server", serverSoftware)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
@ -94,6 +163,15 @@ func handleFetchCollectionOutbox(app *app, w http.ResponseWriter, r *http.Reques
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection outbox: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
if app.cfg.App.SingleUser {
|
||||
if alias != c.Alias {
|
||||
|
@ -117,18 +195,20 @@ func handleFetchCollectionOutbox(app *app, w http.ResponseWriter, r *http.Reques
|
|||
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "outbox", res.TotalPosts, p)
|
||||
ocp.OrderedItems = []interface{}{}
|
||||
|
||||
posts, err := app.db.GetPosts(c, p, false, true)
|
||||
posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false)
|
||||
for _, pp := range *posts {
|
||||
pp.Collection = res
|
||||
o := pp.ActivityObject()
|
||||
o := pp.ActivityObject(app)
|
||||
a := activitystreams.NewCreateActivity(o)
|
||||
a.Context = nil
|
||||
ocp.OrderedItems = append(ocp.OrderedItems, *a)
|
||||
}
|
||||
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||
}
|
||||
|
||||
func handleFetchCollectionFollowers(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Server", serverSoftware)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
@ -146,6 +226,15 @@ func handleFetchCollectionFollowers(app *app, w http.ResponseWriter, r *http.Req
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection followers: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
accountRoot := c.FederatedAccount()
|
||||
|
||||
|
@ -170,10 +259,11 @@ func handleFetchCollectionFollowers(app *app, w http.ResponseWriter, r *http.Req
|
|||
ocp.OrderedItems = append(ocp.OrderedItems, f.ActorID)
|
||||
}
|
||||
*/
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||
}
|
||||
|
||||
func handleFetchCollectionFollowing(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Server", serverSoftware)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
@ -191,6 +281,15 @@ func handleFetchCollectionFollowing(app *app, w http.ResponseWriter, r *http.Req
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection following: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
accountRoot := c.FederatedAccount()
|
||||
|
||||
|
@ -205,10 +304,11 @@ func handleFetchCollectionFollowing(app *app, w http.ResponseWriter, r *http.Req
|
|||
// Return outbox page
|
||||
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "following", 0, p)
|
||||
ocp.OrderedItems = []interface{}{}
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||
}
|
||||
|
||||
func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Server", serverSoftware)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
@ -224,6 +324,15 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
|
|||
// TODO: return Reject?
|
||||
return err
|
||||
}
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection inbox: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
if debugging {
|
||||
dump, err := httputil.DumpRequest(r, true)
|
||||
|
@ -262,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)
|
||||
|
@ -327,6 +436,13 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
|
||||
go func() {
|
||||
if to == nil {
|
||||
if debugging {
|
||||
log.Error("No `to` value!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
am, err := a.Serialize()
|
||||
if err != nil {
|
||||
|
@ -335,11 +451,7 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
am["@context"] = []string{activitystreams.Namespace}
|
||||
|
||||
if to == nil {
|
||||
log.Error("No to! %v", err)
|
||||
return
|
||||
}
|
||||
err = makeActivityPost(p, fullActor.Inbox, am)
|
||||
err = makeActivityPost(app.cfg.App.Host, p, fullActor.Inbox, am)
|
||||
if err != nil {
|
||||
log.Error("Unable to make activity POST: %v", err)
|
||||
return
|
||||
|
@ -358,19 +470,13 @@ 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 mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
if mysqlErr.Number != mySQLErrDuplicateKey {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add new remoteuser in DB: %v\n", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add new remoteuser in DB: %v\n", err)
|
||||
return
|
||||
}
|
||||
// if duplicate key, res will be nil and panic on
|
||||
// res.LastInsertId below
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add new remoteuser in DB: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
followerID, err = res.LastInsertId()
|
||||
|
@ -383,13 +489,7 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
|
|||
// Add in key
|
||||
_, err = t.Exec("INSERT INTO remoteuserkeys (id, remote_user_id, public_key) VALUES (?, ?, ?)", fullActor.PublicKey.ID, followerID, fullActor.PublicKey.PublicKeyPEM)
|
||||
if err != nil {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
if mysqlErr.Number != mySQLErrDuplicateKey {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add follower keys in DB: %v\n", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !app.db.isDuplicateKeyErr(err) {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add follower keys in DB: %v\n", err)
|
||||
return
|
||||
|
@ -398,15 +498,9 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
|
||||
// Add follow
|
||||
_, err = t.Exec("INSERT INTO remotefollows (collection_id, remote_user_id, created) VALUES (?, ?, NOW())", c.ID, followerID)
|
||||
_, err = t.Exec("INSERT INTO remotefollows (collection_id, remote_user_id, created) VALUES (?, ?, "+app.db.now()+")", c.ID, followerID)
|
||||
if err != nil {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
if mysqlErr.Number != mySQLErrDuplicateKey {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add follower in DB: %v\n", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !app.db.isDuplicateKeyErr(err) {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add follower in DB: %v\n", err)
|
||||
return
|
||||
|
@ -431,7 +525,7 @@ func handleFetchCollectionInbox(app *app, w http.ResponseWriter, r *http.Request
|
|||
return nil
|
||||
}
|
||||
|
||||
func makeActivityPost(p *activitystreams.Person, url string, m interface{}) error {
|
||||
func makeActivityPost(hostName string, p *activitystreams.Person, url string, m interface{}) error {
|
||||
log.Info("POST %s", url)
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
|
@ -440,7 +534,7 @@ func makeActivityPost(p *activitystreams.Person, url string, m interface{}) erro
|
|||
|
||||
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)))
|
||||
|
@ -465,7 +559,7 @@ func makeActivityPost(p *activitystreams.Person, url string, m interface{}) erro
|
|||
}
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
resp, err := activityPubClient().Do(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -473,7 +567,7 @@ func makeActivityPost(p *activitystreams.Person, url string, m interface{}) erro
|
|||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -485,12 +579,28 @@ func makeActivityPost(p *activitystreams.Person, url string, m interface{}) erro
|
|||
return nil
|
||||
}
|
||||
|
||||
func resolveIRI(url string) ([]byte, error) {
|
||||
func resolveIRI(hostName, url string) ([]byte, error) {
|
||||
log.Info("GET %s", url)
|
||||
|
||||
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)
|
||||
|
@ -501,7 +611,7 @@ func resolveIRI(url string) ([]byte, error) {
|
|||
}
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
resp, err := activityPubClient().Do(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -509,7 +619,7 @@ func resolveIRI(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
|
||||
}
|
||||
|
@ -521,12 +631,13 @@ func resolveIRI(url string) ([]byte, error) {
|
|||
return body, nil
|
||||
}
|
||||
|
||||
func deleteFederatedPost(app *app, p *PublicPost, collID int64) error {
|
||||
func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
||||
if debugging {
|
||||
log.Info("Deleting federated post!")
|
||||
}
|
||||
p.Collection.hostName = app.cfg.App.Host
|
||||
actor := p.Collection.PersonObject(collID)
|
||||
na := p.ActivityObject()
|
||||
na := p.ActivityObject(app)
|
||||
|
||||
// Add followers
|
||||
p.Collection.ID = collID
|
||||
|
@ -538,20 +649,26 @@ func deleteFederatedPost(app *app, p *PublicPost, collID int64) error {
|
|||
|
||||
inboxes := map[string][]string{}
|
||||
for _, f := range *followers {
|
||||
if _, ok := inboxes[f.SharedInbox]; ok {
|
||||
inboxes[f.SharedInbox] = append(inboxes[f.SharedInbox], f.ActorID)
|
||||
inbox := f.SharedInbox
|
||||
if inbox == "" {
|
||||
inbox = f.Inbox
|
||||
}
|
||||
if _, ok := inboxes[inbox]; ok {
|
||||
inboxes[inbox] = append(inboxes[inbox], f.ActorID)
|
||||
} else {
|
||||
inboxes[f.SharedInbox] = []string{f.ActorID}
|
||||
inboxes[inbox] = []string{f.ActorID}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
da.ID += "#Delete"
|
||||
|
||||
err = makeActivityPost(actor, si, activitystreams.NewDeleteActivity(na))
|
||||
err = makeActivityPost(app.cfg.App.Host, actor, si, da)
|
||||
if err != nil {
|
||||
log.Error("Couldn't delete post! %v", err)
|
||||
}
|
||||
|
@ -559,7 +676,17 @@ func deleteFederatedPost(app *app, p *PublicPost, collID int64) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func federatePost(app *app, p *PublicPost, collID int64, isUpdate bool) 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!")
|
||||
|
@ -567,8 +694,9 @@ func federatePost(app *app, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
log.Info("Federating new post!")
|
||||
}
|
||||
}
|
||||
|
||||
actor := p.Collection.PersonObject(collID)
|
||||
na := p.ActivityObject()
|
||||
na := p.ActivityObject(app)
|
||||
|
||||
// Add followers
|
||||
p.Collection.ID = collID
|
||||
|
@ -581,37 +709,77 @@ func federatePost(app *app, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
|
||||
inboxes := map[string][]string{}
|
||||
for _, f := range *followers {
|
||||
if _, ok := inboxes[f.SharedInbox]; ok {
|
||||
inboxes[f.SharedInbox] = append(inboxes[f.SharedInbox], f.ActorID)
|
||||
inbox := f.SharedInbox
|
||||
if inbox == "" {
|
||||
inbox = f.Inbox
|
||||
}
|
||||
if _, ok := inboxes[inbox]; ok {
|
||||
// check if we're already sending to this shared inbox
|
||||
inboxes[inbox] = append(inboxes[inbox], f.ActorID)
|
||||
} else {
|
||||
inboxes[f.SharedInbox] = []string{f.ActorID}
|
||||
// add the new shared inbox to the list
|
||||
inboxes[inbox] = []string{f.ActorID}
|
||||
}
|
||||
}
|
||||
|
||||
var activity *activitystreams.Activity
|
||||
// for each one of the shared inboxes
|
||||
for si, instFolls := range inboxes {
|
||||
// add all followers from that instance
|
||||
// to the CC field
|
||||
na.CC = []string{}
|
||||
for _, f := range instFolls {
|
||||
na.CC = append(na.CC, f)
|
||||
}
|
||||
var activity *activitystreams.Activity
|
||||
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)
|
||||
activity.To = na.To
|
||||
activity.CC = na.CC
|
||||
}
|
||||
err = makeActivityPost(actor, si, activity)
|
||||
// and post it to that sharedInbox
|
||||
err = makeActivityPost(app.cfg.App.Host, actor, si, activity)
|
||||
if err != nil {
|
||||
log.Error("Couldn't post! %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// re-create the object so that the CC list gets reset and has
|
||||
// the mentioned users. This might seem wasteful but the code is
|
||||
// cleaner than adding the mentioned users to CC here instead of
|
||||
// in p.ActivityObject()
|
||||
na = p.ActivityObject(app)
|
||||
for _, tag := range na.Tag {
|
||||
if tag.Type == "Mention" {
|
||||
activity = activitystreams.NewCreateActivity(na)
|
||||
activity.To = na.To
|
||||
activity.CC = na.CC
|
||||
// This here might be redundant in some cases as we might have already
|
||||
// sent this to the sharedInbox of this instance above, but we need too
|
||||
// much logic to catch this at the expense of the odd extra request.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getRemoteUser(app *app, actorID string) (*RemoteUser, error) {
|
||||
func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
||||
u := RemoteUser{ActorID: actorID}
|
||||
err := app.db.QueryRow("SELECT id, inbox, shared_inbox FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox)
|
||||
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."}
|
||||
|
@ -620,10 +788,30 @@ func getRemoteUser(app *app, actorID string) (*RemoteUser, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
u.URL = urlVal.String
|
||||
u.Handle = handle.String
|
||||
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func getActor(app *app, actorIRI string) (*activitystreams.Person, *RemoteUser, error) {
|
||||
// getRemoteUserFromHandle retrieves the profile page of a remote user
|
||||
// from the @user@server.tld handle
|
||||
func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) {
|
||||
u := RemoteUser{Handle: handle}
|
||||
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
|
||||
case err != nil:
|
||||
log.Error("Couldn't get remote user %s: %v", handle, err)
|
||||
return nil, err
|
||||
}
|
||||
u.URL = urlVal.String
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser, error) {
|
||||
log.Info("Fetching actor %s locally", actorIRI)
|
||||
actor := &activitystreams.Person{}
|
||||
remoteUser, err := getRemoteUser(app, actorIRI)
|
||||
|
@ -632,13 +820,12 @@ func getActor(app *app, actorIRI string) (*activitystreams.Person, *RemoteUser,
|
|||
if iErr.Status == http.StatusNotFound {
|
||||
// Fetch remote actor
|
||||
log.Info("Not found; fetching actor %s remotely", actorIRI)
|
||||
actorResp, err := resolveIRI(actorIRI)
|
||||
actorResp, err := resolveIRI(app.cfg.App.Host, actorIRI)
|
||||
if err != nil {
|
||||
log.Error("Unable to get actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't fetch actor."}
|
||||
}
|
||||
if err := json.Unmarshal(actorResp, &actor); err != nil {
|
||||
// FIXME: Hubzilla has an object for the Actor's url: cannot unmarshal object into Go struct field Person.url of type string
|
||||
if err := unmarshalActor(actorResp, actor); err != nil {
|
||||
log.Error("Unable to unmarshal actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actor."}
|
||||
}
|
||||
|
@ -653,3 +840,115 @@ 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
|
||||
//
|
||||
// some implementations return different context field types
|
||||
// this converts any non-slice contexts into a slice
|
||||
func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error {
|
||||
// FIXME: Hubzilla has an object for the Actor's url: cannot unmarshal object into Go struct field Person.url of type string
|
||||
|
||||
// flexActor overrides the Context field to allow
|
||||
// all valid representations during unmarshal
|
||||
flexActor := struct {
|
||||
activitystreams.Person
|
||||
Context json.RawMessage `json:"@context,omitempty"`
|
||||
}{}
|
||||
if err := json.Unmarshal(actorResp, &flexActor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actor.Endpoints = flexActor.Endpoints
|
||||
actor.Followers = flexActor.Followers
|
||||
actor.Following = flexActor.Following
|
||||
actor.ID = flexActor.ID
|
||||
actor.Icon = flexActor.Icon
|
||||
actor.Inbox = flexActor.Inbox
|
||||
actor.Name = flexActor.Name
|
||||
actor.Outbox = flexActor.Outbox
|
||||
actor.PreferredUsername = flexActor.PreferredUsername
|
||||
actor.PublicKey = flexActor.PublicKey
|
||||
actor.Summary = flexActor.Summary
|
||||
actor.Type = flexActor.Type
|
||||
actor.URL = flexActor.URL
|
||||
|
||||
func(val interface{}) {
|
||||
switch val.(type) {
|
||||
case []interface{}:
|
||||
// already a slice, do nothing
|
||||
actor.Context = val.([]interface{})
|
||||
default:
|
||||
actor.Context = []interface{}{val}
|
||||
}
|
||||
}(flexActor.Context)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setCacheControl(w http.ResponseWriter, ttl time.Duration) {
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/writeas/web-core/activitystreams"
|
||||
)
|
||||
|
||||
var actorTestTable = []struct {
|
||||
Name string
|
||||
Resp []byte
|
||||
}{
|
||||
{
|
||||
"Context as a string",
|
||||
[]byte(`{"@context":"https://www.w3.org/ns/activitystreams"}`),
|
||||
},
|
||||
{
|
||||
"Context as a list",
|
||||
[]byte(`{"@context":["one string", "two strings"]}`),
|
||||
},
|
||||
}
|
||||
|
||||
func TestUnmarshalActor(t *testing.T) {
|
||||
for _, tc := range actorTestTable {
|
||||
actor := activitystreams.Person{}
|
||||
err := unmarshalActor(tc.Resp, &actor)
|
||||
if err != nil {
|
||||
t.Errorf("%s failed with error %s", tc.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
613
admin.go
613
admin.go
|
@ -1,14 +1,32 @@
|
|||
/*
|
||||
* Copyright © 2018-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"
|
||||
"fmt"
|
||||
"github.com/gogits/gogs/pkg/tool"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/web-core/passgen"
|
||||
"github.com/writefreely/writefreely/appstats"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -16,6 +34,8 @@ var (
|
|||
sysStatus systemStatus
|
||||
)
|
||||
|
||||
const adminUsersPerPage = 30
|
||||
|
||||
type systemStatus struct {
|
||||
Uptime string
|
||||
NumGoroutine int
|
||||
|
@ -57,27 +77,65 @@ type systemStatus struct {
|
|||
NumGC uint32
|
||||
}
|
||||
|
||||
func handleViewAdminDash(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
updateAppStats()
|
||||
type inspectedCollection struct {
|
||||
CollectionObj
|
||||
Followers int
|
||||
LastPost string
|
||||
}
|
||||
|
||||
type instanceContent struct {
|
||||
ID string
|
||||
Type string
|
||||
Title sql.NullString
|
||||
Content string
|
||||
Updated time.Time
|
||||
}
|
||||
|
||||
type AdminPage struct {
|
||||
UpdateAvailable bool
|
||||
}
|
||||
|
||||
func NewAdminPage(app *App) *AdminPage {
|
||||
ap := &AdminPage{}
|
||||
if app.updates != nil {
|
||||
ap.UpdateAvailable = app.updates.AreAvailableNoCheck()
|
||||
}
|
||||
return ap
|
||||
}
|
||||
|
||||
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)
|
||||
*/
|
||||
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 {
|
||||
p := struct {
|
||||
*UserPage
|
||||
Message string
|
||||
SysStatus systemStatus
|
||||
*AdminPage
|
||||
Message string
|
||||
|
||||
AboutPage, PrivacyPage string
|
||||
UsersCount, CollectionsCount, PostsCount int64
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Admin", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Message: r.FormValue("m"),
|
||||
SysStatus: sysStatus,
|
||||
}
|
||||
|
||||
// Get user stats
|
||||
p.UsersCount = app.db.GetAllUsersCount()
|
||||
var err error
|
||||
p.AboutPage, err = getAboutPage(app)
|
||||
p.CollectionsCount, err = app.db.GetTotalCollections()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.PrivacyPage, _, err = getPrivacyPage(app)
|
||||
p.PostsCount, err = app.db.GetTotalPosts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -86,63 +144,506 @@ func handleViewAdminDash(app *app, u *User, w http.ResponseWriter, r *http.Reque
|
|||
return nil
|
||||
}
|
||||
|
||||
func handleAdminUpdateSite(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
updateAppStats()
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
SysStatus systemStatus
|
||||
Config config.AppCfg
|
||||
|
||||
Message, ConfigMessage string
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Admin", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
SysStatus: sysStatus,
|
||||
Config: app.cfg.App,
|
||||
|
||||
Message: r.FormValue("m"),
|
||||
ConfigMessage: r.FormValue("cm"),
|
||||
}
|
||||
|
||||
showUserPage(w, "monitor", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewAdminSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
|
||||
Message, ConfigMessage string
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Admin", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
|
||||
Message: r.FormValue("m"),
|
||||
ConfigMessage: r.FormValue("cm"),
|
||||
}
|
||||
|
||||
showUserPage(w, "app-settings", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
Flashes []string
|
||||
|
||||
Users *[]User
|
||||
CurPage int
|
||||
TotalUsers int64
|
||||
TotalPages []int
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Users", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
p.Flashes, _ = getSessionFlashes(app, w, r, nil)
|
||||
p.TotalUsers = app.db.GetAllUsersCount()
|
||||
ttlPages := p.TotalUsers / adminUsersPerPage
|
||||
p.TotalPages = []int{}
|
||||
for i := 1; i <= int(ttlPages); i++ {
|
||||
p.TotalPages = append(p.TotalPages, i)
|
||||
}
|
||||
|
||||
var err error
|
||||
p.CurPage, err = strconv.Atoi(r.FormValue("p"))
|
||||
if err != nil || p.CurPage < 1 {
|
||||
p.CurPage = 1
|
||||
} else if p.CurPage > int(ttlPages) {
|
||||
p.CurPage = int(ttlPages)
|
||||
}
|
||||
|
||||
p.Users, err = app.db.GetAllUsers(uint(p.CurPage))
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get users: %v", err)}
|
||||
}
|
||||
|
||||
showUserPage(w, "users", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
username := vars["username"]
|
||||
if username == "" {
|
||||
return impart.HTTPError{http.StatusFound, "/admin/users"}
|
||||
}
|
||||
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
User *User
|
||||
Colls []inspectedCollection
|
||||
LastPost string
|
||||
NewPassword string
|
||||
TotalPosts int64
|
||||
ClearEmail string
|
||||
}{
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
Colls: []inspectedCollection{},
|
||||
}
|
||||
|
||||
var err error
|
||||
p.User, err = app.db.GetUserForAuth(username)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("Could not get user: %v", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
for _, flash := range flashes {
|
||||
if strings.HasPrefix(flash, "SUCCESS: ") {
|
||||
p.NewPassword = strings.TrimPrefix(flash, "SUCCESS: ")
|
||||
p.ClearEmail = p.User.EmailClear(app.keys)
|
||||
}
|
||||
}
|
||||
p.UserPage = NewUserPage(app, r, u, p.User.Username, nil)
|
||||
p.TotalPosts = app.db.GetUserPostsCount(p.User.ID)
|
||||
lp, err := app.db.GetUserLastPostTime(p.User.ID)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's last post time: %v", err)}
|
||||
}
|
||||
if lp != nil {
|
||||
p.LastPost = lp.Format("January 2, 2006, 3:04 PM")
|
||||
}
|
||||
|
||||
colls, err := app.db.GetCollections(p.User, app.cfg.App.Host)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)}
|
||||
}
|
||||
for _, c := range *colls {
|
||||
ic := inspectedCollection{
|
||||
CollectionObj: CollectionObj{Collection: c},
|
||||
}
|
||||
|
||||
if app.cfg.App.Federation {
|
||||
folls, err := app.db.GetAPFollowers(&c)
|
||||
if err == nil {
|
||||
// TODO: handle error here (at least log it)
|
||||
ic.Followers = len(*folls)
|
||||
}
|
||||
}
|
||||
|
||||
app.db.GetPostsCount(&ic.CollectionObj, true)
|
||||
|
||||
lp, err := app.db.GetCollectionLastPostTime(c.ID)
|
||||
if err != nil {
|
||||
log.Error("Didn't get last post time for collection %d: %v", c.ID, err)
|
||||
}
|
||||
if lp != nil {
|
||||
ic.LastPost = lp.Format("January 2, 2006, 3:04 PM")
|
||||
}
|
||||
|
||||
p.Colls = append(p.Colls, ic)
|
||||
}
|
||||
|
||||
showUserPage(w, "view-user", p)
|
||||
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"]
|
||||
if username == "" {
|
||||
return impart.HTTPError{http.StatusFound, "/admin/users"}
|
||||
}
|
||||
|
||||
user, err := app.db.GetUserForAuth(username)
|
||||
if err != nil {
|
||||
log.Error("failed to get user: %v", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)}
|
||||
}
|
||||
if user.IsSilenced() {
|
||||
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)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)}
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
|
||||
}
|
||||
|
||||
func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
username := vars["username"]
|
||||
if username == "" {
|
||||
return impart.HTTPError{http.StatusFound, "/admin/users"}
|
||||
}
|
||||
|
||||
// Generate new random password since none supplied
|
||||
pass := passgen.NewWordish()
|
||||
hashedPass, err := auth.HashPass([]byte(pass))
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
|
||||
}
|
||||
|
||||
userIDVal := r.FormValue("user")
|
||||
log.Info("ADMIN: Changing user %s password", userIDVal)
|
||||
id, err := strconv.Atoi(userIDVal)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid user ID: %v", err)}
|
||||
}
|
||||
|
||||
err = app.db.ChangePassphrase(int64(id), true, "", hashedPass)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
|
||||
}
|
||||
log.Info("ADMIN: Successfully changed.")
|
||||
|
||||
addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: %s", pass), nil)
|
||||
|
||||
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s", username)}
|
||||
}
|
||||
|
||||
func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
Pages []*instanceContent
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Pages", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
var err error
|
||||
p.Pages, err = app.db.GetInstancePages()
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get pages: %v", err)}
|
||||
}
|
||||
|
||||
// Add in default pages
|
||||
var hasAbout, hasContact, hasPrivacy bool
|
||||
for i, c := range p.Pages {
|
||||
if hasAbout && hasContact && hasPrivacy {
|
||||
break
|
||||
}
|
||||
if c.ID == "about" {
|
||||
hasAbout = true
|
||||
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 {
|
||||
p.Pages[i].Title = defaultPrivacyTitle()
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasAbout {
|
||||
p.Pages = append(p.Pages, &instanceContent{
|
||||
ID: "about",
|
||||
Title: defaultAboutTitle(app.cfg),
|
||||
Content: defaultAboutPage(app.cfg),
|
||||
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",
|
||||
Title: defaultPrivacyTitle(),
|
||||
Content: defaultPrivacyPolicy(app.cfg),
|
||||
Updated: defaultPageUpdatedTime,
|
||||
})
|
||||
}
|
||||
|
||||
showUserPage(w, "pages", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
slug := vars["slug"]
|
||||
if slug == "" {
|
||||
return impart.HTTPError{http.StatusFound, "/admin/pages"}
|
||||
}
|
||||
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
Banner *instanceContent
|
||||
Content *instanceContent
|
||||
}{
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
var err error
|
||||
// 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" {
|
||||
p.Banner, err = getLandingBanner(app)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
|
||||
}
|
||||
p.Content, err = getLandingBody(app)
|
||||
p.Content.ID = "landing"
|
||||
} else if slug == "reader" {
|
||||
p.Content, err = getReaderSection(app)
|
||||
} else {
|
||||
p.Content, err = app.db.GetDynamicContent(slug)
|
||||
}
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get page: %v", err)}
|
||||
}
|
||||
title := "New page"
|
||||
if p.Content != nil {
|
||||
title = "Edit " + p.Content.ID
|
||||
} else {
|
||||
p.Content = &instanceContent{}
|
||||
}
|
||||
p.UserPage = NewUserPage(app, r, u, title, nil)
|
||||
|
||||
showUserPage(w, "view-page", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
id := vars["page"]
|
||||
|
||||
// Validate
|
||||
if id != "about" && id != "privacy" {
|
||||
if id != "about" && id != "contact" && id != "privacy" && id != "landing" && id != "reader" {
|
||||
return impart.HTTPError{http.StatusNotFound, "No such page."}
|
||||
}
|
||||
|
||||
// Update page
|
||||
var err error
|
||||
m := ""
|
||||
err := app.db.UpdateDynamicContent(id, r.FormValue("content"))
|
||||
if id == "landing" {
|
||||
// Handle special landing page
|
||||
err = app.db.UpdateDynamicContent("landing-banner", "", r.FormValue("banner"), "section")
|
||||
if err != nil {
|
||||
m = "?m=" + err.Error()
|
||||
return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
|
||||
}
|
||||
err = app.db.UpdateDynamicContent("landing-body", "", r.FormValue("content"), "section")
|
||||
} else if id == "reader" {
|
||||
// Update sections with titles
|
||||
err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "section")
|
||||
} else {
|
||||
// Update page
|
||||
err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page")
|
||||
}
|
||||
if err != nil {
|
||||
m = "?m=" + err.Error()
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, "/admin" + m + "#page-" + id}
|
||||
return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
|
||||
}
|
||||
|
||||
func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
apper.App().cfg.App.SiteName = r.FormValue("site_name")
|
||||
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
|
||||
}
|
||||
mb, err := strconv.Atoi(r.FormValue("max_blogs"))
|
||||
if err == nil {
|
||||
apper.App().cfg.App.MaxBlogs = mb
|
||||
}
|
||||
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 {
|
||||
log.Info("Initializing local timeline...")
|
||||
initLocalTimeline(apper.App())
|
||||
}
|
||||
apper.App().cfg.App.UserInvites = r.FormValue("user_invites")
|
||||
if apper.App().cfg.App.UserInvites == "none" {
|
||||
apper.App().cfg.App.UserInvites = ""
|
||||
}
|
||||
apper.App().cfg.App.DefaultVisibility = r.FormValue("default_visibility")
|
||||
|
||||
m := "?cm=Configuration+saved."
|
||||
err = apper.SaveConfig(apper.App().cfg)
|
||||
if err != nil {
|
||||
m = "?cm=" + err.Error()
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"}
|
||||
}
|
||||
|
||||
func updateAppStats() {
|
||||
sysStatus.Uptime = tool.TimeSincePro(appStartTime)
|
||||
sysStatus.Uptime = appstats.TimeSincePro(appStartTime)
|
||||
|
||||
m := new(runtime.MemStats)
|
||||
runtime.ReadMemStats(m)
|
||||
sysStatus.NumGoroutine = runtime.NumGoroutine()
|
||||
|
||||
sysStatus.MemAllocated = tool.FileSize(int64(m.Alloc))
|
||||
sysStatus.MemTotal = tool.FileSize(int64(m.TotalAlloc))
|
||||
sysStatus.MemSys = tool.FileSize(int64(m.Sys))
|
||||
sysStatus.MemAllocated = appstats.FileSize(int64(m.Alloc))
|
||||
sysStatus.MemTotal = appstats.FileSize(int64(m.TotalAlloc))
|
||||
sysStatus.MemSys = appstats.FileSize(int64(m.Sys))
|
||||
sysStatus.Lookups = m.Lookups
|
||||
sysStatus.MemMallocs = m.Mallocs
|
||||
sysStatus.MemFrees = m.Frees
|
||||
|
||||
sysStatus.HeapAlloc = tool.FileSize(int64(m.HeapAlloc))
|
||||
sysStatus.HeapSys = tool.FileSize(int64(m.HeapSys))
|
||||
sysStatus.HeapIdle = tool.FileSize(int64(m.HeapIdle))
|
||||
sysStatus.HeapInuse = tool.FileSize(int64(m.HeapInuse))
|
||||
sysStatus.HeapReleased = tool.FileSize(int64(m.HeapReleased))
|
||||
sysStatus.HeapAlloc = appstats.FileSize(int64(m.HeapAlloc))
|
||||
sysStatus.HeapSys = appstats.FileSize(int64(m.HeapSys))
|
||||
sysStatus.HeapIdle = appstats.FileSize(int64(m.HeapIdle))
|
||||
sysStatus.HeapInuse = appstats.FileSize(int64(m.HeapInuse))
|
||||
sysStatus.HeapReleased = appstats.FileSize(int64(m.HeapReleased))
|
||||
sysStatus.HeapObjects = m.HeapObjects
|
||||
|
||||
sysStatus.StackInuse = tool.FileSize(int64(m.StackInuse))
|
||||
sysStatus.StackSys = tool.FileSize(int64(m.StackSys))
|
||||
sysStatus.MSpanInuse = tool.FileSize(int64(m.MSpanInuse))
|
||||
sysStatus.MSpanSys = tool.FileSize(int64(m.MSpanSys))
|
||||
sysStatus.MCacheInuse = tool.FileSize(int64(m.MCacheInuse))
|
||||
sysStatus.MCacheSys = tool.FileSize(int64(m.MCacheSys))
|
||||
sysStatus.BuckHashSys = tool.FileSize(int64(m.BuckHashSys))
|
||||
sysStatus.GCSys = tool.FileSize(int64(m.GCSys))
|
||||
sysStatus.OtherSys = tool.FileSize(int64(m.OtherSys))
|
||||
sysStatus.StackInuse = appstats.FileSize(int64(m.StackInuse))
|
||||
sysStatus.StackSys = appstats.FileSize(int64(m.StackSys))
|
||||
sysStatus.MSpanInuse = appstats.FileSize(int64(m.MSpanInuse))
|
||||
sysStatus.MSpanSys = appstats.FileSize(int64(m.MSpanSys))
|
||||
sysStatus.MCacheInuse = appstats.FileSize(int64(m.MCacheInuse))
|
||||
sysStatus.MCacheSys = appstats.FileSize(int64(m.MCacheSys))
|
||||
sysStatus.BuckHashSys = appstats.FileSize(int64(m.BuckHashSys))
|
||||
sysStatus.GCSys = appstats.FileSize(int64(m.GCSys))
|
||||
sysStatus.OtherSys = appstats.FileSize(int64(m.OtherSys))
|
||||
|
||||
sysStatus.NextGC = tool.FileSize(int64(m.NextGC))
|
||||
sysStatus.NextGC = appstats.FileSize(int64(m.NextGC))
|
||||
sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000)
|
||||
sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000)
|
||||
sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000)
|
||||
sysStatus.NumGC = m.NumGC
|
||||
}
|
||||
|
||||
func adminResetPassword(app *app, u *User, newPass string) error {
|
||||
func adminResetPassword(app *App, u *User, newPass string) error {
|
||||
hashedPass, err := auth.HashPass([]byte(newPass))
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
|
||||
|
@ -154,3 +655,39 @@ func adminResetPassword(app *app, u *User, newPass string) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewAdminUpdates(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
check := r.URL.Query().Get("check")
|
||||
|
||||
if check == "now" && app.cfg.App.UpdateChecks {
|
||||
app.updates.CheckNow()
|
||||
}
|
||||
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
CurReleaseNotesURL string
|
||||
LastChecked string
|
||||
LastChecked8601 string
|
||||
LatestVersion string
|
||||
LatestReleaseURL string
|
||||
LatestReleaseNotesURL string
|
||||
CheckFailed bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Updates", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
}
|
||||
p.CurReleaseNotesURL = wfReleaseNotesURL(p.Version)
|
||||
if app.cfg.App.UpdateChecks {
|
||||
p.LastChecked = app.updates.lastCheck.Format("January 2, 2006, 3:04 PM")
|
||||
p.LastChecked8601 = app.updates.lastCheck.Format("2006-01-02T15:04:05Z")
|
||||
p.LatestVersion = app.updates.LatestVersion()
|
||||
p.LatestReleaseURL = app.updates.ReleaseURL()
|
||||
p.LatestReleaseNotesURL = app.updates.ReleaseNotesURL()
|
||||
p.UpdateAvailable = app.updates.AreAvailable()
|
||||
p.CheckFailed = app.updates.checkError != nil
|
||||
}
|
||||
|
||||
showUserPage(w, "app-updates", p)
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
// Copyright 2014-2018 The Gogs Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style license that can be
|
||||
// found in the LICENSE file of the Gogs project (github.com/gogs/gogs).
|
||||
|
||||
package appstats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Borrowed from github.com/gogs/gogs/pkg/tool
|
||||
|
||||
// Seconds-based time units
|
||||
const (
|
||||
Minute = 60
|
||||
Hour = 60 * Minute
|
||||
Day = 24 * Hour
|
||||
Week = 7 * Day
|
||||
Month = 30 * Day
|
||||
Year = 12 * Month
|
||||
)
|
||||
|
||||
func computeTimeDiff(diff int64) (int64, string) {
|
||||
diffStr := ""
|
||||
switch {
|
||||
case diff <= 0:
|
||||
diff = 0
|
||||
diffStr = "now"
|
||||
case diff < 2:
|
||||
diff = 0
|
||||
diffStr = "1 second"
|
||||
case diff < 1*Minute:
|
||||
diffStr = fmt.Sprintf("%d seconds", diff)
|
||||
diff = 0
|
||||
|
||||
case diff < 2*Minute:
|
||||
diff -= 1 * Minute
|
||||
diffStr = "1 minute"
|
||||
case diff < 1*Hour:
|
||||
diffStr = fmt.Sprintf("%d minutes", diff/Minute)
|
||||
diff -= diff / Minute * Minute
|
||||
|
||||
case diff < 2*Hour:
|
||||
diff -= 1 * Hour
|
||||
diffStr = "1 hour"
|
||||
case diff < 1*Day:
|
||||
diffStr = fmt.Sprintf("%d hours", diff/Hour)
|
||||
diff -= diff / Hour * Hour
|
||||
|
||||
case diff < 2*Day:
|
||||
diff -= 1 * Day
|
||||
diffStr = "1 day"
|
||||
case diff < 1*Week:
|
||||
diffStr = fmt.Sprintf("%d days", diff/Day)
|
||||
diff -= diff / Day * Day
|
||||
|
||||
case diff < 2*Week:
|
||||
diff -= 1 * Week
|
||||
diffStr = "1 week"
|
||||
case diff < 1*Month:
|
||||
diffStr = fmt.Sprintf("%d weeks", diff/Week)
|
||||
diff -= diff / Week * Week
|
||||
|
||||
case diff < 2*Month:
|
||||
diff -= 1 * Month
|
||||
diffStr = "1 month"
|
||||
case diff < 1*Year:
|
||||
diffStr = fmt.Sprintf("%d months", diff/Month)
|
||||
diff -= diff / Month * Month
|
||||
|
||||
case diff < 2*Year:
|
||||
diff -= 1 * Year
|
||||
diffStr = "1 year"
|
||||
default:
|
||||
diffStr = fmt.Sprintf("%d years", diff/Year)
|
||||
diff = 0
|
||||
}
|
||||
return diff, diffStr
|
||||
}
|
||||
|
||||
// TimeSincePro calculates the time interval and generate full user-friendly string.
|
||||
func TimeSincePro(then time.Time) string {
|
||||
now := time.Now()
|
||||
diff := now.Unix() - then.Unix()
|
||||
|
||||
if then.After(now) {
|
||||
return "future"
|
||||
}
|
||||
|
||||
var timeStr, diffStr string
|
||||
for {
|
||||
if diff == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
diff, diffStr = computeTimeDiff(diff)
|
||||
timeStr += ", " + diffStr
|
||||
}
|
||||
return strings.TrimPrefix(timeStr, ", ")
|
||||
}
|
||||
|
||||
func logn(n, b float64) float64 {
|
||||
return math.Log(n) / math.Log(b)
|
||||
}
|
||||
|
||||
func humanateBytes(s uint64, base float64, sizes []string) string {
|
||||
if s < 10 {
|
||||
return fmt.Sprintf("%d B", s)
|
||||
}
|
||||
e := math.Floor(logn(float64(s), base))
|
||||
suffix := sizes[int(e)]
|
||||
val := float64(s) / math.Pow(base, math.Floor(e))
|
||||
f := "%.0f"
|
||||
if val < 10 {
|
||||
f = "%.1f"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(f+" %s", val, suffix)
|
||||
}
|
||||
|
||||
// FileSize calculates the file size and generate user-friendly string.
|
||||
func FileSize(s int64) string {
|
||||
sizes := []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"}
|
||||
return humanateBytes(uint64(s), 1024, sizes)
|
||||
}
|
10
auth.go
10
auth.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2018 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
|
||||
|
||||
// AuthenticateUser ensures a user with the given accessToken is valid. Call
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
/*
|
||||
* Copyright © 2018-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 author
|
||||
|
||||
import (
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
@ -28,6 +39,7 @@ var reservedUsernames = map[string]bool{
|
|||
"categories": true,
|
||||
"category": true,
|
||||
"changes": true,
|
||||
"community": true,
|
||||
"create": true,
|
||||
"css": true,
|
||||
"data": true,
|
||||
|
@ -44,6 +56,7 @@ var reservedUsernames = map[string]bool{
|
|||
"guides": true,
|
||||
"help": true,
|
||||
"index": true,
|
||||
"invite": true,
|
||||
"js": true,
|
||||
"login": true,
|
||||
"logout": true,
|
||||
|
@ -53,6 +66,7 @@ var reservedUsernames = map[string]bool{
|
|||
"metadata": true,
|
||||
"new": true,
|
||||
"news": true,
|
||||
"oauth": true,
|
||||
"post": true,
|
||||
"posts": true,
|
||||
"privacy": true,
|
||||
|
@ -100,11 +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.
|
||||
// TODO: use pagesDir const
|
||||
filepath.Walk("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 {
|
||||
|
|
10
cache.go
10
cache.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2018 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 (
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 main
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
cmdConfig cli.Command = cli.Command{
|
||||
Name: "config",
|
||||
Usage: "config management tools",
|
||||
Subcommands: []*cli.Command{
|
||||
&cmdConfigGenerate,
|
||||
&cmdConfigInteractive,
|
||||
},
|
||||
}
|
||||
|
||||
cmdConfigGenerate cli.Command = cli.Command{
|
||||
Name: "generate",
|
||||
Aliases: []string{"gen"},
|
||||
Usage: "Generate a basic configuration",
|
||||
Action: genConfigAction,
|
||||
}
|
||||
|
||||
cmdConfigInteractive cli.Command = cli.Command{
|
||||
Name: "start",
|
||||
Usage: "Interactive configuration process",
|
||||
Action: interactiveConfigAction,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "sections",
|
||||
Value: "server db app",
|
||||
Usage: "Which sections of the configuration to go through\n" +
|
||||
"valid values of sections flag are any combination of 'server', 'db' and 'app' \n" +
|
||||
"example: writefreely config start --sections \"db app\"",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func genConfigAction(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.CreateConfig(app)
|
||||
}
|
||||
|
||||
func interactiveConfigAction(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
writefreely.DoConfig(app, c.String("sections"))
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 main
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
cmdDB cli.Command = cli.Command{
|
||||
Name: "db",
|
||||
Usage: "db management tools",
|
||||
Subcommands: []*cli.Command{
|
||||
&cmdDBInit,
|
||||
&cmdDBMigrate,
|
||||
},
|
||||
}
|
||||
|
||||
cmdDBInit cli.Command = cli.Command{
|
||||
Name: "init",
|
||||
Usage: "Initialize Database",
|
||||
Action: initDBAction,
|
||||
}
|
||||
|
||||
cmdDBMigrate cli.Command = cli.Command{
|
||||
Name: "migrate",
|
||||
Usage: "Migrate Database",
|
||||
Action: migrateDBAction,
|
||||
}
|
||||
)
|
||||
|
||||
func initDBAction(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.CreateSchema(app)
|
||||
}
|
||||
|
||||
func migrateDBAction(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.Migrate(app)
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 main
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
cmdKeys cli.Command = cli.Command{
|
||||
Name: "keys",
|
||||
Usage: "key management tools",
|
||||
Subcommands: []*cli.Command{
|
||||
&cmdGenerateKeys,
|
||||
},
|
||||
}
|
||||
|
||||
cmdGenerateKeys cli.Command = cli.Command{
|
||||
Name: "generate",
|
||||
Aliases: []string{"gen"},
|
||||
Usage: "Generate encryption and authentication keys",
|
||||
Action: genKeysAction,
|
||||
}
|
||||
)
|
||||
|
||||
func genKeysAction(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.GenerateKeyFiles(app)
|
||||
}
|
|
@ -1,9 +1,183 @@
|
|||
/*
|
||||
* Copyright © 2018-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 main
|
||||
|
||||
import (
|
||||
"github.com/writeas/writefreely"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
func main() {
|
||||
writefreely.Serve()
|
||||
cli.VersionPrinter = func(c *cli.Context) {
|
||||
fmt.Printf("%s\n", c.App.Version)
|
||||
}
|
||||
app := &cli.App{
|
||||
Name: "WriteFreely",
|
||||
Usage: "A beautifully pared-down blogging platform",
|
||||
Version: writefreely.FormatVersion(),
|
||||
Action: legacyActions, // legacy due to use of flags for switching actions
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "create-config",
|
||||
Value: false,
|
||||
Usage: "Generate a basic configuration",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "config",
|
||||
Value: false,
|
||||
Usage: "Interactive configuration process",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "sections",
|
||||
Value: "server db app",
|
||||
Usage: "Which sections of the configuration to go through (requires --config)\n" +
|
||||
"valid values are any combination of 'server', 'db' and 'app' \n" +
|
||||
"example: writefreely --config --sections \"db app\"",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "gen-keys",
|
||||
Value: false,
|
||||
Usage: "Generate encryption and authentication keys",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "init-db",
|
||||
Value: false,
|
||||
Usage: "Initialize app database",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migrate",
|
||||
Value: false,
|
||||
Usage: "Migrate the database",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "create-admin",
|
||||
Usage: "Create an admin with the given username:password",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "create-user",
|
||||
Usage: "Create a regular user with the given username:password",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "delete-user",
|
||||
Usage: "Delete a user with the given username",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "reset-pass",
|
||||
Usage: "Reset the given user's password",
|
||||
Hidden: true,
|
||||
},
|
||||
}, // legacy flags (set to hidden to eventually switch to bash-complete compatible format)
|
||||
}
|
||||
|
||||
defaultFlags := []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "c",
|
||||
Value: "config.ini",
|
||||
Usage: "Load configuration from `FILE`",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Value: false,
|
||||
Usage: "Enables debug logging",
|
||||
},
|
||||
}
|
||||
|
||||
app.Flags = append(app.Flags, defaultFlags...)
|
||||
|
||||
app.Commands = []*cli.Command{
|
||||
&cmdUser,
|
||||
&cmdDB,
|
||||
&cmdConfig,
|
||||
&cmdKeys,
|
||||
&cmdServe,
|
||||
}
|
||||
|
||||
err := app.Run(os.Args)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyActions(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
|
||||
switch true {
|
||||
case c.IsSet("create-config"):
|
||||
return writefreely.CreateConfig(app)
|
||||
case c.IsSet("config"):
|
||||
writefreely.DoConfig(app, c.String("sections"))
|
||||
return nil
|
||||
case c.IsSet("gen-keys"):
|
||||
return writefreely.GenerateKeyFiles(app)
|
||||
case c.IsSet("init-db"):
|
||||
return writefreely.CreateSchema(app)
|
||||
case c.IsSet("migrate"):
|
||||
return writefreely.Migrate(app)
|
||||
case c.IsSet("create-admin"):
|
||||
username, password, err := parseCredentials(c.String("create-admin"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writefreely.CreateUser(app, username, password, true)
|
||||
case c.IsSet("create-user"):
|
||||
username, password, err := parseCredentials(c.String("create-user"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writefreely.CreateUser(app, username, password, false)
|
||||
case c.IsSet("delete-user"):
|
||||
return writefreely.DoDeleteAccount(app, c.String("delete-user"))
|
||||
case c.IsSet("reset-pass"):
|
||||
return writefreely.ResetPassword(app, c.String("reset-pass"))
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
var err error
|
||||
log.Info("Starting %s...", writefreely.FormatVersion())
|
||||
app, err = writefreely.Initialize(app, c.Bool("debug"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set app routes
|
||||
r := mux.NewRouter()
|
||||
writefreely.InitRoutes(app, r)
|
||||
app.InitStaticRoutes(r)
|
||||
|
||||
// Serve the application
|
||||
writefreely.Serve(app, r)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCredentials(credentialString string) (string, string, error) {
|
||||
creds := strings.Split(credentialString, ":")
|
||||
if len(creds) != 2 {
|
||||
return "", "", fmt.Errorf("invalid format for passed credentials, must be username:password")
|
||||
}
|
||||
return creds[0], creds[1], nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* 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 main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
cmdUser cli.Command = cli.Command{
|
||||
Name: "user",
|
||||
Usage: "user management tools",
|
||||
Subcommands: []*cli.Command{
|
||||
&cmdAddUser,
|
||||
&cmdDelUser,
|
||||
&cmdResetPass,
|
||||
// TODO: possibly add a user list command
|
||||
},
|
||||
}
|
||||
|
||||
cmdAddUser cli.Command = cli.Command{
|
||||
Name: "create",
|
||||
Usage: "Add new user",
|
||||
Aliases: []string{"a", "add"},
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "admin",
|
||||
Value: false,
|
||||
Usage: "Create admin user",
|
||||
},
|
||||
},
|
||||
Action: addUserAction,
|
||||
}
|
||||
|
||||
cmdDelUser cli.Command = cli.Command{
|
||||
Name: "delete",
|
||||
Usage: "Delete user",
|
||||
Aliases: []string{"del", "d"},
|
||||
Action: delUserAction,
|
||||
}
|
||||
|
||||
cmdResetPass cli.Command = cli.Command{
|
||||
Name: "reset-pass",
|
||||
Usage: "Reset user's password",
|
||||
Aliases: []string{"resetpass", "reset"},
|
||||
Action: resetPassAction,
|
||||
}
|
||||
)
|
||||
|
||||
func addUserAction(c *cli.Context) error {
|
||||
credentials := ""
|
||||
if c.NArg() > 0 {
|
||||
credentials = c.Args().Get(0)
|
||||
} else {
|
||||
return fmt.Errorf("No user passed. Example: writefreely user add [USER]:[PASSWORD]")
|
||||
}
|
||||
username, password, err := parseCredentials(credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.CreateUser(app, username, password, c.Bool("admin"))
|
||||
}
|
||||
|
||||
func delUserAction(c *cli.Context) error {
|
||||
username := ""
|
||||
if c.NArg() > 0 {
|
||||
username = c.Args().Get(0)
|
||||
} else {
|
||||
return fmt.Errorf("No user passed. Example: writefreely user delete [USER]")
|
||||
}
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.DoDeleteAccount(app, username)
|
||||
}
|
||||
|
||||
func resetPassAction(c *cli.Context) error {
|
||||
username := ""
|
||||
if c.NArg() > 0 {
|
||||
username = c.Args().Get(0)
|
||||
} else {
|
||||
return fmt.Errorf("No user passed. Example: writefreely user reset-pass [USER]")
|
||||
}
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.ResetPassword(app, username)
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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 main
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
cmdServe cli.Command = cli.Command{
|
||||
Name: "serve",
|
||||
Aliases: []string{"web"},
|
||||
Usage: "Run web application",
|
||||
Action: serveAction,
|
||||
}
|
||||
)
|
||||
|
||||
func serveAction(c *cli.Context) error {
|
||||
// Initialize the application
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
var err error
|
||||
log.Info("Starting %s...", writefreely.FormatVersion())
|
||||
app, err = writefreely.Initialize(app, c.Bool("debug"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set app routes
|
||||
r := mux.NewRouter()
|
||||
writefreely.InitRoutes(app, r)
|
||||
app.InitStaticRoutes(r)
|
||||
|
||||
// Serve the application
|
||||
writefreely.Serve(app, r)
|
||||
|
||||
return nil
|
||||
}
|
567
collections.go
567
collections.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2018-2022 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 (
|
||||
|
@ -14,14 +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/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 (
|
||||
|
@ -36,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"`
|
||||
|
@ -44,22 +67,36 @@ type (
|
|||
PublicOwner bool `datastore:"public_owner" json:"-"`
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
db *datastore
|
||||
Monetization string `json:"monetization_pointer,omitempty"`
|
||||
Verification string `json:"verification_link"`
|
||||
|
||||
db *datastore
|
||||
hostName string
|
||||
}
|
||||
CollectionObj struct {
|
||||
Collection
|
||||
TotalPosts int `json:"total_posts"`
|
||||
Owner *User `json:"owner,omitempty"`
|
||||
Posts *[]PublicPost `json:"posts,omitempty"`
|
||||
Format *CollectionFormat
|
||||
}
|
||||
DisplayCollection struct {
|
||||
*CollectionObj
|
||||
Prefix string
|
||||
NavSuffix string
|
||||
IsTopLevel bool
|
||||
CurrentPage int
|
||||
TotalPages int
|
||||
Format *CollectionFormat
|
||||
Silenced bool
|
||||
}
|
||||
|
||||
CollectionNav struct {
|
||||
*Collection
|
||||
Path string
|
||||
SingleUser bool
|
||||
CanPost bool
|
||||
}
|
||||
|
||||
SubmittedCollection struct {
|
||||
// Data used for updating a given collection
|
||||
ID int64
|
||||
|
@ -70,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
|
||||
|
@ -92,6 +134,8 @@ type (
|
|||
|
||||
// User-related fields
|
||||
isCollOwner bool
|
||||
|
||||
isAuthorized bool
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -115,6 +159,21 @@ const (
|
|||
CollProtected
|
||||
)
|
||||
|
||||
var collVisibilityStrings = map[string]collVisibility{
|
||||
"unlisted": CollUnlisted,
|
||||
"public": CollPublic,
|
||||
"private": CollPrivate,
|
||||
"protected": CollProtected,
|
||||
}
|
||||
|
||||
func defaultVisibility(cfg *config.Config) collVisibility {
|
||||
vis, ok := collVisibilityStrings[cfg.App.DefaultVisibility]
|
||||
if !ok {
|
||||
vis = CollUnlisted
|
||||
}
|
||||
return vis
|
||||
}
|
||||
|
||||
func (cf *CollectionFormat) Ascending() bool {
|
||||
return cf.Format == "novel"
|
||||
}
|
||||
|
@ -147,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
|
||||
}
|
||||
|
@ -196,29 +260,37 @@ 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/writefreely/writefreely/issues/new?template=bug_report.md")
|
||||
}
|
||||
if isSingleUser {
|
||||
return hostName + "/"
|
||||
return c.hostName + "/"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s/", hostName, c.Alias)
|
||||
return fmt.Sprintf("%s/%s/", c.hostName, c.Alias)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
@ -228,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 {
|
||||
|
@ -252,7 +325,7 @@ func (c *Collection) ForPublic() {
|
|||
c.URL = c.CanonicalURL()
|
||||
}
|
||||
|
||||
var isLowerLetter = regexp.MustCompile("[a-z]").MatchString
|
||||
var isAvatarChar = regexp.MustCompile("[a-z0-9]").MatchString
|
||||
|
||||
func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person {
|
||||
accountRoot := c.FederatedAccount()
|
||||
|
@ -287,14 +360,14 @@ func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person {
|
|||
|
||||
func (c *Collection) AvatarURL() string {
|
||||
fl := string(unicode.ToLower([]rune(c.DisplayTitle())[0]))
|
||||
if !isLowerLetter(fl) {
|
||||
if !isAvatarChar(fl) {
|
||||
return ""
|
||||
}
|
||||
return hostName + "/img/avatars/" + fl + ".png"
|
||||
return c.hostName + "/img/avatars/" + fl + ".png"
|
||||
}
|
||||
|
||||
func (c *Collection) FederatedAPIBase() string {
|
||||
return hostName + "/"
|
||||
return c.hostName + "/"
|
||||
}
|
||||
|
||||
func (c *Collection) FederatedAccount() string {
|
||||
|
@ -306,8 +379,53 @@ func (c *Collection) RenderMathJax() bool {
|
|||
return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
|
||||
}
|
||||
|
||||
func newCollection(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
reqJSON := IsJSON(r.Header.Get("Content-Type"))
|
||||
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")
|
||||
title := r.FormValue("title")
|
||||
|
||||
|
@ -347,36 +465,41 @@ func newCollection(app *app, w http.ResponseWriter, r *http.Request) error {
|
|||
return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)}
|
||||
}
|
||||
|
||||
var userID int64
|
||||
var err error
|
||||
if reqJSON && !c.Web {
|
||||
accessToken = r.Header.Get("Authorization")
|
||||
if accessToken == "" {
|
||||
return ErrNoAccessToken
|
||||
}
|
||||
userID = app.db.GetUserID(accessToken)
|
||||
if userID == -1 {
|
||||
return ErrBadAccessToken
|
||||
}
|
||||
} else {
|
||||
u = getUserSession(app, r)
|
||||
if u == nil {
|
||||
return ErrNotLoggedIn
|
||||
}
|
||||
userID = u.ID
|
||||
}
|
||||
silenced, err := app.db.IsUserSilenced(userID)
|
||||
if err != nil {
|
||||
log.Error("new collection: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
return ErrUserSilenced
|
||||
}
|
||||
|
||||
if !author.IsValidUsername(app.cfg, c.Alias) {
|
||||
return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."}
|
||||
}
|
||||
|
||||
var coll *Collection
|
||||
var err error
|
||||
if accessToken != "" {
|
||||
coll, err = app.db.CreateCollectionFromToken(c.Alias, c.Title, accessToken)
|
||||
if err != nil {
|
||||
// TODO: handle this
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
coll, err = app.db.CreateCollection(c.Alias, c.Title, u.ID)
|
||||
if err != nil {
|
||||
// TODO: handle this
|
||||
return err
|
||||
}
|
||||
coll, err := app.db.CreateCollection(app.cfg, c.Alias, c.Title, userID)
|
||||
if err != nil {
|
||||
// TODO: handle this
|
||||
return err
|
||||
}
|
||||
|
||||
res := &CollectionObj{Collection: *coll}
|
||||
|
@ -389,7 +512,7 @@ func newCollection(app *app, w http.ResponseWriter, r *http.Request) error {
|
|||
return impart.HTTPError{http.StatusFound, redirectTo}
|
||||
}
|
||||
|
||||
func apiCheckCollectionPermissions(app *app, r *http.Request, c *Collection) (int64, error) {
|
||||
func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (int64, error) {
|
||||
accessToken := r.Header.Get("Authorization")
|
||||
var userID int64 = -1
|
||||
if accessToken != "" {
|
||||
|
@ -409,9 +532,8 @@ 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") {
|
||||
func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
if IsActivityPubRequest(r) {
|
||||
return handleFetchCollectionActivities(app, w, r)
|
||||
}
|
||||
|
||||
|
@ -424,8 +546,10 @@ func fetchCollection(app *app, w http.ResponseWriter, r *http.Request) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
// Redirect users who aren't requesting JSON
|
||||
reqJSON := IsJSON(r.Header.Get("Content-Type"))
|
||||
reqJSON := IsJSON(r)
|
||||
if !reqJSON {
|
||||
return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
|
||||
}
|
||||
|
@ -448,6 +572,7 @@ func fetchCollection(app *app, w http.ResponseWriter, r *http.Request) error {
|
|||
res.Owner = u
|
||||
}
|
||||
}
|
||||
// TODO: check status for silenced
|
||||
app.db.GetPostsCount(res, isCollOwner)
|
||||
// Strip non-public information
|
||||
res.Collection.ForPublic()
|
||||
|
@ -457,7 +582,7 @@ func fetchCollection(app *app, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
// fetchCollectionPosts handles an API endpoint for retrieving a collection's
|
||||
// posts.
|
||||
func fetchCollectionPosts(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
alias := vars["alias"]
|
||||
|
||||
|
@ -465,6 +590,7 @@ func fetchCollectionPosts(app *app, w http.ResponseWriter, r *http.Request) erro
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
// Check permissions
|
||||
userID, err := apiCheckCollectionPermissions(app, r, c)
|
||||
|
@ -482,11 +608,11 @@ func fetchCollectionPosts(app *app, w http.ResponseWriter, r *http.Request) erro
|
|||
}
|
||||
}
|
||||
|
||||
posts, err := app.db.GetPosts(c, page, isCollOwner, 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()
|
||||
|
@ -494,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))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -507,10 +633,52 @@ 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
|
||||
|
||||
// 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 {
|
||||
return &CollectionObj{
|
||||
Collection: *c,
|
||||
Format: c.NewFormat(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CollectionObj) ScriptDisplay() template.JS {
|
||||
|
@ -553,7 +721,7 @@ func processCollectionRequest(cr *collectionReq, vars map[string]string, w http.
|
|||
// domain that doesn't yet have a collection associated, or if a collection
|
||||
// requires a password. In either case, this will return nil, nil -- thus both
|
||||
// values should ALWAYS be checked to determine whether or not to continue.
|
||||
func processCollectionPermissions(app *app, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) {
|
||||
func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) {
|
||||
// Display collection if this is a collection
|
||||
var c *Collection
|
||||
var err error
|
||||
|
@ -590,6 +758,7 @@ func processCollectionPermissions(app *app, cr *collectionReq, u *User, w http.R
|
|||
}
|
||||
return nil, err
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
// Update CollectionRequest to reflect owner status
|
||||
cr.isCollOwner = u != nil && u.ID == c.OwnerID
|
||||
|
@ -604,10 +773,20 @@ func processCollectionPermissions(app *app, cr *collectionReq, u *User, w http.R
|
|||
uname = u.Username
|
||||
}
|
||||
|
||||
// See if we've authorized this collection
|
||||
authd := isAuthorizedForCollection(app, c.Alias, r)
|
||||
// TODO: move this to all permission checks?
|
||||
suspended, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("process protected collection permissions: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if suspended {
|
||||
return nil, ErrCollectionNotFound
|
||||
}
|
||||
|
||||
if !authd {
|
||||
// See if we've authorized this collection
|
||||
cr.isAuthorized = isAuthorizedForCollection(app, c.Alias, r)
|
||||
|
||||
if !cr.isAuthorized {
|
||||
p := struct {
|
||||
page.StaticPage
|
||||
*CollectionObj
|
||||
|
@ -644,35 +823,34 @@ func processCollectionPermissions(app *app, cr *collectionReq, u *User, w http.R
|
|||
return c, nil
|
||||
}
|
||||
|
||||
func checkUserForCollection(app *app, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) {
|
||||
func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) {
|
||||
u := getUserSession(app, r)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
|
||||
coll := &DisplayCollection{
|
||||
CollectionObj: &CollectionObj{Collection: *c},
|
||||
CollectionObj: NewCollectionObj(c),
|
||||
CurrentPage: page,
|
||||
Prefix: cr.prefix,
|
||||
IsTopLevel: isSingleUser,
|
||||
Format: c.NewFormat(),
|
||||
}
|
||||
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
|
||||
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
|
||||
func handleViewCollection(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
cr := &collectionReq{}
|
||||
|
||||
|
@ -692,11 +870,19 @@ func handleViewCollection(app *app, w http.ResponseWriter, r *http.Request) erro
|
|||
if c == nil || err != nil {
|
||||
return err
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("view collection: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
|
||||
// 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)
|
||||
return impart.RenderActivityJSON(w, ac, http.StatusOK)
|
||||
}
|
||||
|
||||
|
@ -713,32 +899,43 @@ func handleViewCollection(app *app, w http.ResponseWriter, r *http.Request) erro
|
|||
return impart.HTTPError{http.StatusFound, redirURL}
|
||||
}
|
||||
|
||||
coll.Posts, _ = app.db.GetPosts(c, page, cr.isCollOwner, false)
|
||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false)
|
||||
|
||||
// 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)
|
||||
var owner *User
|
||||
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
|
||||
displayPage.CanPin = true
|
||||
|
||||
pubColls, err := app.db.GetPublishableCollections(owner)
|
||||
pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
|
||||
if err != nil {
|
||||
log.Error("unable to fetch collections: %v", err)
|
||||
}
|
||||
displayPage.Collections = pubColls
|
||||
}
|
||||
}
|
||||
if owner == nil {
|
||||
isOwner := owner != nil
|
||||
if !isOwner {
|
||||
// Current user doesn't own collection; retrieve owner information
|
||||
owner, err = app.db.GetUserByID(coll.OwnerID)
|
||||
if err != nil {
|
||||
|
@ -746,14 +943,23 @@ func handleViewCollection(app *app, w http.ResponseWriter, r *http.Request) erro
|
|||
log.Error("Error getting user for collection: %v", err)
|
||||
}
|
||||
}
|
||||
if !isOwner && silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
displayPage.Silenced = isOwner && silenced
|
||||
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)
|
||||
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
|
||||
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
|
||||
|
||||
err = templates["collection"].ExecuteTemplate(w, "collection", displayPage)
|
||||
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 index: %v", err)
|
||||
}
|
||||
|
@ -778,7 +984,20 @@ func handleViewCollection(app *app, w http.ResponseWriter, r *http.Request) erro
|
|||
return err
|
||||
}
|
||||
|
||||
func handleViewCollectionTag(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
handle := vars["handle"]
|
||||
|
||||
remoteUser, err := app.db.GetProfilePageFromHandle(app, handle)
|
||||
if err != nil || remoteUser == "" {
|
||||
log.Error("Couldn't find user %s: %v", handle, err)
|
||||
return ErrRemoteUserNotFound
|
||||
}
|
||||
|
||||
return impart.HTTPError{Status: http.StatusFound, Message: remoteUser}
|
||||
}
|
||||
|
||||
func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
tag := vars["tag"]
|
||||
|
||||
|
@ -802,16 +1021,29 @@ func handleViewCollectionTag(app *app, w http.ResponseWriter, r *http.Request) e
|
|||
|
||||
coll := newDisplayCollection(c, cr, page)
|
||||
|
||||
coll.Posts, _ = app.db.GetPostsTagged(c, tag, page, cr.isCollOwner)
|
||||
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),
|
||||
|
@ -828,26 +1060,32 @@ func handleViewCollectionTag(app *app, w http.ResponseWriter, r *http.Request) e
|
|||
owner = u
|
||||
displayPage.CanPin = true
|
||||
|
||||
pubColls, err := app.db.GetPublishableCollections(owner)
|
||||
pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
|
||||
if err != nil {
|
||||
log.Error("unable to fetch collections: %v", err)
|
||||
}
|
||||
displayPage.Collections = pubColls
|
||||
}
|
||||
}
|
||||
if owner == nil {
|
||||
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)
|
||||
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 {
|
||||
|
@ -857,7 +1095,112 @@ func handleViewCollectionTag(app *app, w http.ResponseWriter, r *http.Request) e
|
|||
return nil
|
||||
}
|
||||
|
||||
func handleCollectionPostRedirect(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
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"]
|
||||
|
||||
|
@ -875,17 +1218,16 @@ func handleCollectionPostRedirect(app *app, w http.ResponseWriter, r *http.Reque
|
|||
return impart.HTTPError{http.StatusFound, loc}
|
||||
}
|
||||
|
||||
func existingCollection(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
reqJSON := IsJSON(r.Header.Get("Content-Type"))
|
||||
func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
reqJSON := IsJSON(r)
|
||||
vars := mux.Vars(r)
|
||||
collAlias := vars["alias"]
|
||||
isWeb := r.FormValue("web") == "1"
|
||||
|
||||
var u *User
|
||||
u := &User{}
|
||||
if reqJSON && !isWeb {
|
||||
// Ensure an access token was given
|
||||
accessToken := r.Header.Get("Authorization")
|
||||
u = &User{}
|
||||
u.ID = app.db.GetUserID(accessToken)
|
||||
if u.ID == -1 {
|
||||
return ErrBadAccessToken
|
||||
|
@ -897,6 +1239,16 @@ func existingCollection(app *app, w http.ResponseWriter, r *http.Request) error
|
|||
}
|
||||
}
|
||||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
log.Error("existing collection: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
|
||||
if silenced {
|
||||
return ErrUserSilenced
|
||||
}
|
||||
|
||||
if r.Method == "DELETE" {
|
||||
err := app.db.DeleteCollection(collAlias, u.ID)
|
||||
if err != nil {
|
||||
|
@ -909,7 +1261,6 @@ func existingCollection(app *app, w http.ResponseWriter, r *http.Request) error
|
|||
}
|
||||
|
||||
c := SubmittedCollection{OwnerID: uint64(u.ID)}
|
||||
var err error
|
||||
|
||||
if reqJSON {
|
||||
// Decode JSON request
|
||||
|
@ -933,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 {
|
||||
|
@ -970,7 +1321,7 @@ func collectionAliasFromReq(r *http.Request) string {
|
|||
return alias
|
||||
}
|
||||
|
||||
func handleWebCollectionUnlock(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
func handleWebCollectionUnlock(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
var readReq struct {
|
||||
Alias string `schema:"alias" json:"alias"`
|
||||
Pass string `schema:"password" json:"password"`
|
||||
|
@ -1037,7 +1388,7 @@ func handleWebCollectionUnlock(app *app, w http.ResponseWriter, r *http.Request)
|
|||
return impart.HTTPError{http.StatusFound, next}
|
||||
}
|
||||
|
||||
func isAuthorizedForCollection(app *app, alias string, r *http.Request) bool {
|
||||
func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool {
|
||||
authd := false
|
||||
session, err := app.sessionStore.Get(r, blogPassCookieName)
|
||||
if err == nil {
|
||||
|
@ -1045,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,26 +0,0 @@
|
|||
[server]
|
||||
hidden_host =
|
||||
port = 8080
|
||||
|
||||
[database]
|
||||
type = mysql
|
||||
username = root
|
||||
password = changeme
|
||||
database = writefreely
|
||||
host = db
|
||||
port = 3306
|
||||
|
||||
[app]
|
||||
site_name = Write Freely 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
|
||||
|
230
config/config.go
230
config/config.go
|
@ -1,70 +1,200 @@
|
|||
/*
|
||||
* Copyright © 2018-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 config holds and assists in the configuration of a writefreely instance.
|
||||
package config
|
||||
|
||||
import (
|
||||
"gopkg.in/ini.v1"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ini/ini"
|
||||
"github.com/writeas/web-core/log"
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
// FileName is the default configuration file name
|
||||
FileName = "config.ini"
|
||||
|
||||
UserNormal UserType = "user"
|
||||
UserAdmin = "admin"
|
||||
)
|
||||
|
||||
type (
|
||||
UserType string
|
||||
|
||||
// ServerCfg holds values that affect how the HTTP server runs
|
||||
ServerCfg struct {
|
||||
HiddenHost string `ini:"hidden_host"`
|
||||
Port int `ini:"port"`
|
||||
Bind string `ini:"bind"`
|
||||
|
||||
TLSCertPath string `ini:"tls_cert_path"`
|
||||
TLSKeyPath string `ini:"tls_key_path"`
|
||||
Autocert bool `ini:"autocert"`
|
||||
|
||||
TemplatesParentDir string `ini:"templates_parent_dir"`
|
||||
StaticParentDir string `ini:"static_parent_dir"`
|
||||
PagesParentDir string `ini:"pages_parent_dir"`
|
||||
KeysParentDir string `ini:"keys_parent_dir"`
|
||||
|
||||
HashSeed string `ini:"hash_seed"`
|
||||
|
||||
GopherPort int `ini:"gopher_port"`
|
||||
|
||||
Dev bool `ini:"-"`
|
||||
}
|
||||
|
||||
// DatabaseCfg holds values that determine how the application connects to a datastore
|
||||
DatabaseCfg struct {
|
||||
Type string `ini:"type"`
|
||||
FileName string `ini:"filename"`
|
||||
User string `ini:"username"`
|
||||
Password string `ini:"password"`
|
||||
Database string `ini:"database"`
|
||||
Host string `ini:"host"`
|
||||
Port int `ini:"port"`
|
||||
TLS bool `ini:"tls"`
|
||||
}
|
||||
|
||||
WriteAsOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
AuthLocation string `ini:"auth_location"`
|
||||
TokenLocation string `ini:"token_location"`
|
||||
InspectLocation string `ini:"inspect_location"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
GitlabOauthCfg 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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
TeamID string `ini:"team_id"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
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"`
|
||||
SiteDesc string `ini:"site_description"`
|
||||
Host string `ini:"host"`
|
||||
|
||||
// Site appearance
|
||||
Theme string `ini:"theme"`
|
||||
Editor string `ini:"editor"`
|
||||
JSDisabled bool `ini:"disable_js"`
|
||||
WebFonts bool `ini:"webfonts"`
|
||||
Landing string `ini:"landing"`
|
||||
SimpleNav bool `ini:"simple_nav"`
|
||||
WFModesty bool `ini:"wf_modesty"`
|
||||
|
||||
// Site functionality
|
||||
Chorus bool `ini:"chorus"`
|
||||
Forest bool `ini:"forest"` // The admin cares about the forest, not the trees. Hide unnecessary technical info.
|
||||
DisableDrafts bool `ini:"disable_drafts"`
|
||||
|
||||
// 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"`
|
||||
Private bool `ini:"private"`
|
||||
Federation bool `ini:"federation"`
|
||||
PublicStats bool `ini:"public_stats"`
|
||||
Monetization bool `ini:"monetization"`
|
||||
NotesOnly bool `ini:"notes_only"`
|
||||
|
||||
// Access
|
||||
Private bool `ini:"private"`
|
||||
|
||||
// Additional functions
|
||||
LocalTimeline bool `ini:"local_timeline"`
|
||||
UserInvites string `ini:"user_invites"`
|
||||
|
||||
// Defaults
|
||||
DefaultVisibility string `ini:"default_visibility"`
|
||||
|
||||
// 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
|
||||
Config struct {
|
||||
Server ServerCfg `ini:"server"`
|
||||
Database DatabaseCfg `ini:"database"`
|
||||
App AppCfg `ini:"app"`
|
||||
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"`
|
||||
}
|
||||
)
|
||||
|
||||
// New creates a new Config with sane defaults
|
||||
func New() *Config {
|
||||
return &Config{
|
||||
c := &Config{
|
||||
Server: ServerCfg{
|
||||
Port: 8080,
|
||||
},
|
||||
Database: DatabaseCfg{
|
||||
Type: "mysql",
|
||||
Host: "localhost",
|
||||
Port: 3306,
|
||||
Bind: "localhost", /* IPV6 support when not using localhost? */
|
||||
},
|
||||
App: AppCfg{
|
||||
Host: "http://localhost:8080",
|
||||
|
@ -77,14 +207,60 @@ func New() *Config {
|
|||
PublicStats: true,
|
||||
},
|
||||
}
|
||||
c.UseMySQL(true)
|
||||
return c
|
||||
}
|
||||
|
||||
// UseMySQL resets the Config's Database to use default values for a MySQL setup.
|
||||
func (cfg *Config) UseMySQL(fresh bool) {
|
||||
cfg.Database.Type = "mysql"
|
||||
if fresh {
|
||||
cfg.Database.Host = "localhost"
|
||||
cfg.Database.Port = 3306
|
||||
}
|
||||
}
|
||||
|
||||
// UseSQLite resets the Config's Database to use default values for a SQLite setup.
|
||||
func (cfg *Config) UseSQLite(fresh bool) {
|
||||
cfg.Database.Type = "sqlite3"
|
||||
if fresh {
|
||||
cfg.Database.FileName = "writefreely.db"
|
||||
}
|
||||
}
|
||||
|
||||
// IsSecureStandalone returns whether or not the application is running as a
|
||||
// standalone server with TLS enabled.
|
||||
func (cfg *Config) IsSecureStandalone() bool {
|
||||
return cfg.Server.Port == 443 && cfg.Server.TLSCertPath != "" && cfg.Server.TLSKeyPath != ""
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg, err := ini.Load(FileName)
|
||||
func (ac *AppCfg) LandingPath() string {
|
||||
if !strings.HasPrefix(ac.Landing, "/") {
|
||||
return "/" + ac.Landing
|
||||
}
|
||||
return ac.Landing
|
||||
}
|
||||
|
||||
func (lc EmailCfg) Enabled() bool {
|
||||
return lc.Domain != "" && lc.MailgunPrivate != ""
|
||||
}
|
||||
|
||||
func (ac AppCfg) SignupPath() string {
|
||||
if !ac.OpenRegistration {
|
||||
return ""
|
||||
}
|
||||
if ac.Chorus || ac.Private || (ac.Landing != "" && ac.Landing != "/") {
|
||||
return "/signup"
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// Load reads the given configuration file, then parses and returns it as a Config.
|
||||
func Load(fname string) (*Config, error) {
|
||||
if fname == "" {
|
||||
fname = FileName
|
||||
}
|
||||
cfg, err := ini.Load(fname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -95,15 +271,35 @@ func Load() (*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
|
||||
}
|
||||
|
||||
func Save(uc *Config) error {
|
||||
// Save writes the given Config to the given file.
|
||||
func Save(uc *Config, fname string) error {
|
||||
cfg := ini.Empty()
|
||||
err := ini.ReflectFrom(cfg, uc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return cfg.SaveTo(FileName)
|
||||
if fname == "" {
|
||||
fname = FileName
|
||||
}
|
||||
return cfg.SaveTo(fname)
|
||||
}
|
||||
|
|
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2018 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 config
|
||||
|
||||
type UserCreation struct {
|
||||
|
|
|
@ -1,12 +1,44 @@
|
|||
/*
|
||||
* Copyright © 2018, 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 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 {
|
||||
|
@ -15,3 +47,16 @@ func (ac AppCfg) CanCreateBlogs(currentlyUsed uint64) bool {
|
|||
}
|
||||
return int(currentlyUsed) < ac.MaxBlogs
|
||||
}
|
||||
|
||||
// OrDefaultString returns input or a default value if input is empty.
|
||||
func OrDefaultString(input, defaultValue string) string {
|
||||
if len(input) == 0 {
|
||||
return defaultValue
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// DefaultHTTPClient returns a sane default HTTP client.
|
||||
func DefaultHTTPClient() *http.Client {
|
||||
return &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
|
502
config/setup.go
502
config/setup.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2018 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 config
|
||||
|
||||
import (
|
||||
|
@ -7,6 +17,7 @@ import (
|
|||
"github.com/mitchellh/go-wordwrap"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SetupData struct {
|
||||
|
@ -14,305 +25,358 @@ type SetupData struct {
|
|||
Config *Config
|
||||
}
|
||||
|
||||
func Configure() (*SetupData, error) {
|
||||
func Configure(fname string, configSections string) (*SetupData, error) {
|
||||
data := &SetupData{}
|
||||
var err error
|
||||
if fname == "" {
|
||||
fname = FileName
|
||||
}
|
||||
|
||||
data.Config, err = Load()
|
||||
data.Config, err = Load(fname)
|
||||
var action string
|
||||
isNewCfg := false
|
||||
if err != nil {
|
||||
fmt.Println("No configuration yet. Creating new.")
|
||||
fmt.Printf("No %s configuration yet. Creating new.\n", fname)
|
||||
data.Config = New()
|
||||
action = "generate"
|
||||
isNewCfg = true
|
||||
} else {
|
||||
fmt.Println("Configuration loaded.")
|
||||
fmt.Printf("Loaded configuration %s.\n", fname)
|
||||
action = "update"
|
||||
}
|
||||
title := color.New(color.Bold, color.BgGreen).PrintFunc()
|
||||
|
||||
intro := color.New(color.Bold, color.FgWhite).PrintlnFunc()
|
||||
fmt.Println()
|
||||
intro(" ✍ Write Freely Configuration ✍")
|
||||
intro(" ✍ WriteFreely Configuration ✍")
|
||||
fmt.Println()
|
||||
fmt.Println(wordwrap.WrapString(" This quick configuration process will "+action+" the application's config file, "+FileName+".\n\n It validates your input along the way, so you can be sure any future errors aren't caused by a bad configuration. If you'd rather configure your server manually, instead run: writefreely --create-config and edit that file.", 75))
|
||||
fmt.Println()
|
||||
|
||||
title(" Server setup ")
|
||||
fmt.Println(wordwrap.WrapString(" This quick configuration process will "+action+" the application's config file, "+fname+".\n\n It validates your input along the way, so you can be sure any future errors aren't caused by a bad configuration. If you'd rather configure your server manually, instead run: writefreely --create-config and edit that file.", 75))
|
||||
fmt.Println()
|
||||
|
||||
tmpls := &promptui.PromptTemplates{
|
||||
Success: "{{ . | bold | faint }}: ",
|
||||
}
|
||||
selTmpls := &promptui.SelectTemplates{
|
||||
Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`),
|
||||
Selected: `{{.Label}} {{ . | faint }}`,
|
||||
}
|
||||
|
||||
// Environment selection
|
||||
selPrompt := promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Environment",
|
||||
Items: []string{"Development", "Production, standalone", "Production, behind reverse proxy"},
|
||||
}
|
||||
_, envType, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
isDevEnv := envType == "Development"
|
||||
isStandalone := envType == "Production, standalone"
|
||||
|
||||
data.Config.Server.Dev = isDevEnv
|
||||
|
||||
var selPrompt promptui.Select
|
||||
var prompt promptui.Prompt
|
||||
if isDevEnv || !isStandalone {
|
||||
// Running in dev environment or behind reverse proxy; ask for port
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Local port",
|
||||
Validate: validatePort,
|
||||
Default: fmt.Sprintf("%d", data.Config.Server.Port),
|
||||
|
||||
if strings.Contains(configSections, "server") {
|
||||
title(" Server setup ")
|
||||
fmt.Println()
|
||||
|
||||
// Environment selection
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Environment",
|
||||
Items: []string{"Development", "Production, standalone", "Production, behind reverse proxy"},
|
||||
}
|
||||
port, err := prompt.Run()
|
||||
_, envType, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number
|
||||
isDevEnv := envType == "Development"
|
||||
isStandalone := envType == "Production, standalone"
|
||||
|
||||
data.Config.Server.Dev = isDevEnv
|
||||
|
||||
if isDevEnv || !isStandalone {
|
||||
// Running in dev environment or behind reverse proxy; ask for port
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Local port",
|
||||
Validate: validatePort,
|
||||
Default: fmt.Sprintf("%d", data.Config.Server.Port),
|
||||
}
|
||||
port, err := prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number
|
||||
}
|
||||
|
||||
if isStandalone {
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Web server mode",
|
||||
Items: []string{"Insecure (port 80)", "Secure (port 443), manual certificate", "Secure (port 443), auto certificate"},
|
||||
}
|
||||
sel, _, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if sel == 0 {
|
||||
data.Config.Server.Autocert = false
|
||||
data.Config.Server.Port = 80
|
||||
data.Config.Server.TLSCertPath = ""
|
||||
data.Config.Server.TLSKeyPath = ""
|
||||
} else if sel == 1 || sel == 2 {
|
||||
data.Config.Server.Port = 443
|
||||
data.Config.Server.Autocert = sel == 2
|
||||
|
||||
if sel == 1 {
|
||||
// Manual certificate configuration
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Certificate path",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Server.TLSCertPath,
|
||||
}
|
||||
data.Config.Server.TLSCertPath, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Key path",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Server.TLSKeyPath,
|
||||
}
|
||||
data.Config.Server.TLSKeyPath, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
} else {
|
||||
// Automatic certificate
|
||||
data.Config.Server.TLSCertPath = "certs"
|
||||
data.Config.Server.TLSKeyPath = "certs"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data.Config.Server.TLSCertPath = ""
|
||||
data.Config.Server.TLSKeyPath = ""
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if isStandalone {
|
||||
if strings.Contains(configSections, "db") {
|
||||
title(" Database setup ")
|
||||
fmt.Println()
|
||||
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Web server mode",
|
||||
Items: []string{"Insecure (port 80)", "Secure (port 443)"},
|
||||
Label: "Database driver",
|
||||
Items: []string{"MySQL", "SQLite"},
|
||||
}
|
||||
sel, _, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
if sel == 0 {
|
||||
data.Config.Server.Port = 80
|
||||
data.Config.Server.TLSCertPath = ""
|
||||
data.Config.Server.TLSKeyPath = ""
|
||||
// Configure for MySQL
|
||||
data.Config.UseMySQL(isNewCfg)
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Username",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Database.User,
|
||||
}
|
||||
data.Config.Database.User, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Password",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Database.Password,
|
||||
Mask: '*',
|
||||
}
|
||||
data.Config.Database.Password, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Database name",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Database.Database,
|
||||
}
|
||||
data.Config.Database.Database, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Host",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Database.Host,
|
||||
}
|
||||
data.Config.Database.Host, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Port",
|
||||
Validate: validatePort,
|
||||
Default: fmt.Sprintf("%d", data.Config.Database.Port),
|
||||
}
|
||||
dbPort, err := prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number
|
||||
} else if sel == 1 {
|
||||
data.Config.Server.Port = 443
|
||||
// Configure for SQLite
|
||||
data.Config.UseSQLite(isNewCfg)
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Certificate path",
|
||||
Label: "Filename",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Server.TLSCertPath,
|
||||
Default: data.Config.Database.FileName,
|
||||
}
|
||||
data.Config.Server.TLSCertPath, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Key path",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Server.TLSKeyPath,
|
||||
}
|
||||
data.Config.Server.TLSKeyPath, err = prompt.Run()
|
||||
data.Config.Database.FileName, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data.Config.Server.TLSCertPath = ""
|
||||
data.Config.Server.TLSKeyPath = ""
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
title(" Database setup ")
|
||||
fmt.Println()
|
||||
if strings.Contains(configSections, "app") {
|
||||
title(" App setup ")
|
||||
fmt.Println()
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Username",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Database.User,
|
||||
}
|
||||
data.Config.Database.User, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Site type",
|
||||
Items: []string{"Single user blog", "Multi-user instance"},
|
||||
}
|
||||
_, usersType, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.App.SingleUser = usersType == "Single user blog"
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Password",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Database.Password,
|
||||
Mask: '*',
|
||||
}
|
||||
data.Config.Database.Password, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if data.Config.App.SingleUser {
|
||||
data.User = &UserCreation{}
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Database name",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Database.Database,
|
||||
}
|
||||
data.Config.Database.Database, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
// prompt for username
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Admin username",
|
||||
Validate: validateNonEmpty,
|
||||
}
|
||||
data.User.Username, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Host",
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.Database.Host,
|
||||
}
|
||||
data.Config.Database.Host, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
// prompt for password
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Admin password",
|
||||
Validate: validateNonEmpty,
|
||||
}
|
||||
newUserPass, err := prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Port",
|
||||
Validate: validatePort,
|
||||
Default: fmt.Sprintf("%d", data.Config.Database.Port),
|
||||
}
|
||||
dbPort, err := prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number
|
||||
data.User.HashedPass, err = auth.HashPass([]byte(newUserPass))
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
title(" App setup ")
|
||||
fmt.Println()
|
||||
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Site type",
|
||||
Items: []string{"Single user blog", "Multi-user instance"},
|
||||
}
|
||||
_, usersType, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.App.SingleUser = usersType == "Single user blog"
|
||||
|
||||
if data.Config.App.SingleUser {
|
||||
data.User = &UserCreation{}
|
||||
|
||||
// prompt for username
|
||||
siteNameLabel := "Instance name"
|
||||
if data.Config.App.SingleUser {
|
||||
siteNameLabel = "Blog name"
|
||||
}
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Admin username",
|
||||
Label: siteNameLabel,
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.App.SiteName,
|
||||
}
|
||||
data.User.Username, err = prompt.Run()
|
||||
data.Config.App.SiteName, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
// prompt for password
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Admin password",
|
||||
Validate: validateNonEmpty,
|
||||
}
|
||||
newUserPass, err := prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
data.User.HashedPass, err = auth.HashPass([]byte(newUserPass))
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
siteNameLabel := "Instance name"
|
||||
if data.Config.App.SingleUser {
|
||||
siteNameLabel = "Blog name"
|
||||
}
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: siteNameLabel,
|
||||
Validate: validateNonEmpty,
|
||||
Default: data.Config.App.SiteName,
|
||||
}
|
||||
data.Config.App.SiteName, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Public URL",
|
||||
Validate: validateDomain,
|
||||
Default: data.Config.App.Host,
|
||||
}
|
||||
data.Config.App.Host, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
if !data.Config.App.SingleUser {
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Registration",
|
||||
Items: []string{"Open", "Closed"},
|
||||
}
|
||||
_, regType, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.App.OpenRegistration = regType == "Open"
|
||||
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Max blogs per user",
|
||||
Default: fmt.Sprintf("%d", data.Config.App.MaxBlogs),
|
||||
Label: "Public URL",
|
||||
Validate: validateDomain,
|
||||
Default: data.Config.App.Host,
|
||||
}
|
||||
maxBlogs, err := prompt.Run()
|
||||
data.Config.App.Host, err = prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.App.MaxBlogs, _ = strconv.Atoi(maxBlogs) // Ignore error, as we've already validated number
|
||||
}
|
||||
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Federation",
|
||||
Items: []string{"Enabled", "Disabled"},
|
||||
}
|
||||
_, fedType, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.App.Federation = fedType == "Enabled"
|
||||
if !data.Config.App.SingleUser {
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Registration",
|
||||
Items: []string{"Open", "Closed"},
|
||||
}
|
||||
_, regType, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.App.OpenRegistration = regType == "Open"
|
||||
|
||||
if data.Config.App.Federation {
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Federation usage stats",
|
||||
Items: []string{"Public", "Private"},
|
||||
prompt = promptui.Prompt{
|
||||
Templates: tmpls,
|
||||
Label: "Max blogs per user",
|
||||
Default: fmt.Sprintf("%d", data.Config.App.MaxBlogs),
|
||||
}
|
||||
maxBlogs, err := prompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.App.MaxBlogs, _ = strconv.Atoi(maxBlogs) // Ignore error, as we've already validated number
|
||||
}
|
||||
_, fedStatsType, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.App.PublicStats = fedStatsType == "Public"
|
||||
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Instance metadata privacy",
|
||||
Items: []string{"Public", "Private"},
|
||||
Label: "Federation",
|
||||
Items: []string{"Enabled", "Disabled"},
|
||||
}
|
||||
_, fedStatsType, err = selPrompt.Run()
|
||||
_, fedType, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.App.Private = fedStatsType == "Private"
|
||||
data.Config.App.Federation = fedType == "Enabled"
|
||||
|
||||
if data.Config.App.Federation {
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Usage stats (active users, posts)",
|
||||
Items: []string{"Public", "Private"},
|
||||
}
|
||||
_, fedStatsType, err := selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.App.PublicStats = fedStatsType == "Public"
|
||||
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Instance metadata privacy",
|
||||
Items: []string{"Public", "Private"},
|
||||
}
|
||||
_, fedStatsType, err = selPrompt.Run()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.Config.App.Private = fedStatsType == "Private"
|
||||
}
|
||||
}
|
||||
|
||||
return data, Save(data.Config)
|
||||
return data, Save(data.Config, fname)
|
||||
}
|
||||
|
|
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2018 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 config
|
||||
|
||||
import (
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
//go:build wflib
|
||||
// +build wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019-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.
|
||||
*/
|
||||
|
||||
// This file contains dummy database funcs for when writefreely is used as a
|
||||
// library.
|
||||
|
||||
package writefreely
|
||||
|
||||
func (db *datastore) isDuplicateKeyErr(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isIgnorableError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
//go:build !sqlite && !wflib
|
||||
// +build !sqlite,!wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019-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 (
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/writeas/web-core/log"
|
||||
)
|
||||
|
||||
func (db *datastore) isDuplicateKeyErr(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrDuplicateKey
|
||||
}
|
||||
} else {
|
||||
log.Error("isDuplicateKeyErr: failed check for unrecognized driver '%s'", db.driverName)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isIgnorableError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrCollationMix
|
||||
}
|
||||
} else {
|
||||
log.Error("isIgnorableError: failed check for unrecognized driver '%s'", db.driverName)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
//go:build sqlite && !wflib
|
||||
// +build sqlite,!wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019-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 (
|
||||
"database/sql"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/writeas/web-core/log"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
func init() {
|
||||
SQLiteEnabled = true
|
||||
|
||||
regex := func(re, s string) (bool, error) {
|
||||
return regexp.MatchString(re, s)
|
||||
}
|
||||
sql.Register("sqlite3_with_regex", &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
return conn.RegisterFunc("regexp", regex, true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (db *datastore) isDuplicateKeyErr(err error) bool {
|
||||
if db.driverName == driverSQLite {
|
||||
if err, ok := err.(sqlite3.Error); ok {
|
||||
return err.Code == sqlite3.ErrConstraint
|
||||
}
|
||||
} else if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrDuplicateKey
|
||||
}
|
||||
} else {
|
||||
log.Error("isDuplicateKeyErr: failed check for unrecognized driver '%s'", db.driverName)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isIgnorableError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrCollationMix
|
||||
}
|
||||
} else {
|
||||
log.Error("isIgnorableError: failed check for unrecognized driver '%s'", db.driverName)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
1531
database.go
1531
database.go
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,50 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOAuthDatastore(t *testing.T) {
|
||||
if !runMySQLTests() {
|
||||
t.Skip("skipping mysql tests")
|
||||
}
|
||||
withTestDB(t, func(db *sql.DB) {
|
||||
ctx := context.Background()
|
||||
ds := &datastore{
|
||||
DB: db,
|
||||
driverName: "",
|
||||
}
|
||||
|
||||
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)
|
||||
assert.NoError(t, err)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = true", state)
|
||||
|
||||
var localUserID int64 = 99
|
||||
var remoteUserID = "100"
|
||||
err = ds.RecordRemoteUserID(ctx, localUserID, remoteUserID, "test", "test", "access_token_a")
|
||||
assert.NoError(t, err)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_users` WHERE `user_id` = ? AND `remote_user_id` = ? AND access_token = 'access_token_a'", localUserID, remoteUserID)
|
||||
|
||||
err = ds.RecordRemoteUserID(ctx, localUserID, remoteUserID, "test", "test", "access_token_b")
|
||||
assert.NoError(t, err)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_users` WHERE `user_id` = ? AND `remote_user_id` = ? AND access_token = 'access_token_b'", localUserID, remoteUserID)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_users`")
|
||||
|
||||
foundUserID, err := ds.GetIDForRemoteUser(ctx, remoteUserID, "test", "test")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, localUserID, foundUserID)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AlterTableSqlBuilder struct {
|
||||
Dialect DialectType
|
||||
Name string
|
||||
Changes []string
|
||||
}
|
||||
|
||||
func (b *AlterTableSqlBuilder) AddColumn(col *Column) *AlterTableSqlBuilder {
|
||||
if colVal, err := col.String(); err == nil {
|
||||
b.Changes = append(b.Changes, fmt.Sprintf("ADD COLUMN %s", colVal))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *AlterTableSqlBuilder) ChangeColumn(name string, col *Column) *AlterTableSqlBuilder {
|
||||
if colVal, err := col.String(); err == nil {
|
||||
b.Changes = append(b.Changes, fmt.Sprintf("CHANGE COLUMN %s %s", name, colVal))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *AlterTableSqlBuilder) AddUniqueConstraint(name string, columns ...string) *AlterTableSqlBuilder {
|
||||
b.Changes = append(b.Changes, fmt.Sprintf("ADD CONSTRAINT %s UNIQUE (%s)", name, strings.Join(columns, ", ")))
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *AlterTableSqlBuilder) ToSQL() (string, error) {
|
||||
var str strings.Builder
|
||||
|
||||
str.WriteString("ALTER TABLE ")
|
||||
str.WriteString(b.Name)
|
||||
str.WriteString(" ")
|
||||
|
||||
if len(b.Changes) == 0 {
|
||||
return "", fmt.Errorf("no changes provide for table: %s", b.Name)
|
||||
}
|
||||
changeCount := len(b.Changes)
|
||||
for i, thing := range b.Changes {
|
||||
str.WriteString(thing)
|
||||
if i < changeCount-1 {
|
||||
str.WriteString(", ")
|
||||
}
|
||||
}
|
||||
|
||||
return str.String(), nil
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAlterTableSqlBuilder_ToSQL(t *testing.T) {
|
||||
type fields struct {
|
||||
Dialect DialectType
|
||||
Name string
|
||||
Changes []string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
builder *AlterTableSqlBuilder
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "MySQL add int",
|
||||
builder: DialectMySQL.
|
||||
AlterTable("the_table").
|
||||
AddColumn(DialectMySQL.Column("the_col", ColumnTypeInteger, UnsetSize)),
|
||||
want: "ALTER TABLE the_table ADD COLUMN the_col INT NOT NULL",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "MySQL add string",
|
||||
builder: DialectMySQL.
|
||||
AlterTable("the_table").
|
||||
AddColumn(DialectMySQL.Column("the_col", ColumnTypeVarChar, OptionalInt{true, 128})),
|
||||
want: "ALTER TABLE the_table ADD COLUMN the_col VARCHAR(128) NOT NULL",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "MySQL add int and string",
|
||||
builder: DialectMySQL.
|
||||
AlterTable("the_table").
|
||||
AddColumn(DialectMySQL.Column("first_col", ColumnTypeInteger, UnsetSize)).
|
||||
AddColumn(DialectMySQL.Column("second_col", ColumnTypeVarChar, OptionalInt{true, 128})),
|
||||
want: "ALTER TABLE the_table ADD COLUMN first_col INT NOT NULL, ADD COLUMN second_col VARCHAR(128) NOT NULL",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.builder.ToSQL()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ToSQL() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("ToSQL() got = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,263 @@
|
|||
/*
|
||||
* Copyright © 2019-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 db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ColumnType int
|
||||
|
||||
type OptionalInt struct {
|
||||
Set bool
|
||||
Value int
|
||||
}
|
||||
|
||||
type OptionalString struct {
|
||||
Set bool
|
||||
Value string
|
||||
}
|
||||
|
||||
type SQLBuilder interface {
|
||||
ToSQL() (string, error)
|
||||
}
|
||||
|
||||
type Column struct {
|
||||
Dialect DialectType
|
||||
Name string
|
||||
Nullable bool
|
||||
Default OptionalString
|
||||
Type ColumnType
|
||||
Size OptionalInt
|
||||
PrimaryKey bool
|
||||
}
|
||||
|
||||
type CreateTableSqlBuilder struct {
|
||||
Dialect DialectType
|
||||
Name string
|
||||
IfNotExists bool
|
||||
ColumnOrder []string
|
||||
Columns map[string]*Column
|
||||
Constraints []string
|
||||
}
|
||||
|
||||
const (
|
||||
ColumnTypeBool ColumnType = iota
|
||||
ColumnTypeSmallInt ColumnType = iota
|
||||
ColumnTypeInteger ColumnType = iota
|
||||
ColumnTypeChar ColumnType = iota
|
||||
ColumnTypeVarChar ColumnType = iota
|
||||
ColumnTypeText ColumnType = iota
|
||||
ColumnTypeDateTime ColumnType = iota
|
||||
)
|
||||
|
||||
var _ SQLBuilder = &CreateTableSqlBuilder{}
|
||||
|
||||
var UnsetSize OptionalInt = OptionalInt{Set: false, Value: 0}
|
||||
var UnsetDefault OptionalString = OptionalString{Set: false, Value: ""}
|
||||
|
||||
func (d ColumnType) Format(dialect DialectType, size OptionalInt) (string, error) {
|
||||
if dialect != DialectMySQL && dialect != DialectSQLite {
|
||||
return "", fmt.Errorf("unsupported column type %d for dialect %d and size %v", d, dialect, size)
|
||||
}
|
||||
switch d {
|
||||
case ColumnTypeSmallInt:
|
||||
{
|
||||
if dialect == DialectSQLite {
|
||||
return "INTEGER", nil
|
||||
}
|
||||
mod := ""
|
||||
if size.Set {
|
||||
mod = fmt.Sprintf("(%d)", size.Value)
|
||||
}
|
||||
return "SMALLINT" + mod, nil
|
||||
}
|
||||
case ColumnTypeInteger:
|
||||
{
|
||||
if dialect == DialectSQLite {
|
||||
return "INTEGER", nil
|
||||
}
|
||||
mod := ""
|
||||
if size.Set {
|
||||
mod = fmt.Sprintf("(%d)", size.Value)
|
||||
}
|
||||
return "INT" + mod, nil
|
||||
}
|
||||
case ColumnTypeChar:
|
||||
{
|
||||
if dialect == DialectSQLite {
|
||||
return "TEXT", nil
|
||||
}
|
||||
mod := ""
|
||||
if size.Set {
|
||||
mod = fmt.Sprintf("(%d)", size.Value)
|
||||
}
|
||||
return "CHAR" + mod, nil
|
||||
}
|
||||
case ColumnTypeVarChar:
|
||||
{
|
||||
if dialect == DialectSQLite {
|
||||
return "TEXT", nil
|
||||
}
|
||||
mod := ""
|
||||
if size.Set {
|
||||
mod = fmt.Sprintf("(%d)", size.Value)
|
||||
}
|
||||
return "VARCHAR" + mod, nil
|
||||
}
|
||||
case ColumnTypeBool:
|
||||
{
|
||||
if dialect == DialectSQLite {
|
||||
return "INTEGER", nil
|
||||
}
|
||||
return "TINYINT(1)", nil
|
||||
}
|
||||
case ColumnTypeDateTime:
|
||||
return "DATETIME", nil
|
||||
case ColumnTypeText:
|
||||
return "TEXT", nil
|
||||
}
|
||||
return "", fmt.Errorf("unsupported column type %d for dialect %d and size %v", d, dialect, size)
|
||||
}
|
||||
|
||||
func (c *Column) SetName(name string) *Column {
|
||||
c.Name = name
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Column) SetNullable(nullable bool) *Column {
|
||||
c.Nullable = nullable
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Column) SetPrimaryKey(pk bool) *Column {
|
||||
c.PrimaryKey = pk
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Column) SetDefault(value string) *Column {
|
||||
c.Default = OptionalString{Set: true, Value: value}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Column) SetDefaultCurrentTimestamp() *Column {
|
||||
def := "NOW()"
|
||||
if c.Dialect == DialectSQLite {
|
||||
def = "CURRENT_TIMESTAMP"
|
||||
}
|
||||
c.Default = OptionalString{Set: true, Value: def}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Column) SetType(t ColumnType) *Column {
|
||||
c.Type = t
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Column) SetSize(size int) *Column {
|
||||
c.Size = OptionalInt{Set: true, Value: size}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Column) String() (string, error) {
|
||||
var str strings.Builder
|
||||
|
||||
str.WriteString(c.Name)
|
||||
|
||||
str.WriteString(" ")
|
||||
typeStr, err := c.Type.Format(c.Dialect, c.Size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
str.WriteString(typeStr)
|
||||
|
||||
if !c.Nullable {
|
||||
str.WriteString(" NOT NULL")
|
||||
}
|
||||
|
||||
if c.Default.Set {
|
||||
str.WriteString(" DEFAULT ")
|
||||
val := c.Default.Value
|
||||
if val == "" {
|
||||
val = "''"
|
||||
}
|
||||
str.WriteString(val)
|
||||
}
|
||||
|
||||
if c.PrimaryKey {
|
||||
str.WriteString(" PRIMARY KEY")
|
||||
}
|
||||
|
||||
return str.String(), nil
|
||||
}
|
||||
|
||||
func (b *CreateTableSqlBuilder) Column(column *Column) *CreateTableSqlBuilder {
|
||||
if b.Columns == nil {
|
||||
b.Columns = make(map[string]*Column)
|
||||
}
|
||||
b.Columns[column.Name] = column
|
||||
b.ColumnOrder = append(b.ColumnOrder, column.Name)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *CreateTableSqlBuilder) UniqueConstraint(columns ...string) *CreateTableSqlBuilder {
|
||||
for _, column := range columns {
|
||||
if _, ok := b.Columns[column]; !ok {
|
||||
// This fails silently.
|
||||
return b
|
||||
}
|
||||
}
|
||||
b.Constraints = append(b.Constraints, fmt.Sprintf("UNIQUE(%s)", strings.Join(columns, ",")))
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *CreateTableSqlBuilder) SetIfNotExists(ine bool) *CreateTableSqlBuilder {
|
||||
b.IfNotExists = ine
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *CreateTableSqlBuilder) ToSQL() (string, error) {
|
||||
var str strings.Builder
|
||||
|
||||
str.WriteString("CREATE TABLE ")
|
||||
if b.IfNotExists {
|
||||
str.WriteString("IF NOT EXISTS ")
|
||||
}
|
||||
str.WriteString(b.Name)
|
||||
|
||||
var things []string
|
||||
for _, columnName := range b.ColumnOrder {
|
||||
column, ok := b.Columns[columnName]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("column not found: %s", columnName)
|
||||
}
|
||||
columnStr, err := column.String()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
things = append(things, columnStr)
|
||||
}
|
||||
things = append(things, b.Constraints...)
|
||||
if thingLen := len(things); thingLen > 0 {
|
||||
str.WriteString(" ( ")
|
||||
for i, thing := range things {
|
||||
str.WriteString(thing)
|
||||
if i < thingLen-1 {
|
||||
str.WriteString(", ")
|
||||
}
|
||||
}
|
||||
str.WriteString(" )")
|
||||
}
|
||||
|
||||
return str.String(), nil
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDialect_Column(t *testing.T) {
|
||||
c1 := DialectSQLite.Column("foo", ColumnTypeBool, UnsetSize)
|
||||
assert.Equal(t, DialectSQLite, c1.Dialect)
|
||||
c2 := DialectMySQL.Column("foo", ColumnTypeBool, UnsetSize)
|
||||
assert.Equal(t, DialectMySQL, c2.Dialect)
|
||||
}
|
||||
|
||||
func TestColumnType_Format(t *testing.T) {
|
||||
type args struct {
|
||||
dialect DialectType
|
||||
size OptionalInt
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
d ColumnType
|
||||
args args
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"Sqlite bool", ColumnTypeBool, args{dialect: DialectSQLite}, "INTEGER", false},
|
||||
{"Sqlite small int", ColumnTypeSmallInt, args{dialect: DialectSQLite}, "INTEGER", false},
|
||||
{"Sqlite int", ColumnTypeInteger, args{dialect: DialectSQLite}, "INTEGER", false},
|
||||
{"Sqlite char", ColumnTypeChar, args{dialect: DialectSQLite}, "TEXT", false},
|
||||
{"Sqlite varchar", ColumnTypeVarChar, args{dialect: DialectSQLite}, "TEXT", false},
|
||||
{"Sqlite text", ColumnTypeText, args{dialect: DialectSQLite}, "TEXT", false},
|
||||
{"Sqlite datetime", ColumnTypeDateTime, args{dialect: DialectSQLite}, "DATETIME", false},
|
||||
|
||||
{"MySQL bool", ColumnTypeBool, args{dialect: DialectMySQL}, "TINYINT(1)", false},
|
||||
{"MySQL small int", ColumnTypeSmallInt, args{dialect: DialectMySQL}, "SMALLINT", false},
|
||||
{"MySQL small int with param", ColumnTypeSmallInt, args{dialect: DialectMySQL, size: OptionalInt{true, 3}}, "SMALLINT(3)", false},
|
||||
{"MySQL int", ColumnTypeInteger, args{dialect: DialectMySQL}, "INT", false},
|
||||
{"MySQL int with param", ColumnTypeInteger, args{dialect: DialectMySQL, size: OptionalInt{true, 11}}, "INT(11)", false},
|
||||
{"MySQL char", ColumnTypeChar, args{dialect: DialectMySQL}, "CHAR", false},
|
||||
{"MySQL char with param", ColumnTypeChar, args{dialect: DialectMySQL, size: OptionalInt{true, 4}}, "CHAR(4)", false},
|
||||
{"MySQL varchar", ColumnTypeVarChar, args{dialect: DialectMySQL}, "VARCHAR", false},
|
||||
{"MySQL varchar with param", ColumnTypeVarChar, args{dialect: DialectMySQL, size: OptionalInt{true, 25}}, "VARCHAR(25)", false},
|
||||
{"MySQL text", ColumnTypeText, args{dialect: DialectMySQL}, "TEXT", false},
|
||||
{"MySQL datetime", ColumnTypeDateTime, args{dialect: DialectMySQL}, "DATETIME", false},
|
||||
|
||||
{"invalid column type", 10000, args{dialect: DialectMySQL}, "", true},
|
||||
{"invalid dialect", ColumnTypeBool, args{dialect: 10000}, "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.d.Format(tt.args.dialect, tt.args.size)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Format() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("Format() got = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColumn_Build(t *testing.T) {
|
||||
type fields struct {
|
||||
Dialect DialectType
|
||||
Name string
|
||||
Nullable bool
|
||||
Default OptionalString
|
||||
Type ColumnType
|
||||
Size OptionalInt
|
||||
PrimaryKey bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"Sqlite bool", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo INTEGER NOT NULL", false},
|
||||
{"Sqlite bool nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo INTEGER", false},
|
||||
{"Sqlite small int", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeSmallInt, UnsetSize, true}, "foo INTEGER NOT NULL PRIMARY KEY", false},
|
||||
{"Sqlite small int nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeSmallInt, UnsetSize, false}, "foo INTEGER", false},
|
||||
{"Sqlite int", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INTEGER NOT NULL", false},
|
||||
{"Sqlite int nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INTEGER", false},
|
||||
{"Sqlite char", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo TEXT NOT NULL", false},
|
||||
{"Sqlite char nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo TEXT", false},
|
||||
{"Sqlite varchar", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo TEXT NOT NULL", false},
|
||||
{"Sqlite varchar nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo TEXT", false},
|
||||
{"Sqlite text", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT NOT NULL", false},
|
||||
{"Sqlite text nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT", false},
|
||||
{"Sqlite datetime", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME NOT NULL", false},
|
||||
{"Sqlite datetime nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME", false},
|
||||
|
||||
{"MySQL bool", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo TINYINT(1) NOT NULL", false},
|
||||
{"MySQL bool nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo TINYINT(1)", false},
|
||||
{"MySQL small int", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeSmallInt, UnsetSize, true}, "foo SMALLINT NOT NULL PRIMARY KEY", false},
|
||||
{"MySQL small int nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeSmallInt, UnsetSize, false}, "foo SMALLINT", false},
|
||||
{"MySQL int", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INT NOT NULL", false},
|
||||
{"MySQL int nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INT", false},
|
||||
{"MySQL char", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo CHAR NOT NULL", false},
|
||||
{"MySQL char nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo CHAR", false},
|
||||
{"MySQL varchar", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo VARCHAR NOT NULL", false},
|
||||
{"MySQL varchar nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo VARCHAR", false},
|
||||
{"MySQL text", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT NOT NULL", false},
|
||||
{"MySQL text nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT", false},
|
||||
{"MySQL datetime", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME NOT NULL", false},
|
||||
{"MySQL datetime nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &Column{
|
||||
Dialect: tt.fields.Dialect,
|
||||
Name: tt.fields.Name,
|
||||
Nullable: tt.fields.Nullable,
|
||||
Default: tt.fields.Default,
|
||||
Type: tt.fields.Type,
|
||||
Size: tt.fields.Size,
|
||||
PrimaryKey: tt.fields.PrimaryKey,
|
||||
}
|
||||
if got, err := c.String(); got != tt.want {
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("String() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("String() got = %v, want %v", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTableSqlBuilder_ToSQL(t *testing.T) {
|
||||
sql, err := DialectMySQL.
|
||||
Table("foo").
|
||||
SetIfNotExists(true).
|
||||
Column(DialectMySQL.Column("bar", ColumnTypeInteger, UnsetSize).SetPrimaryKey(true)).
|
||||
Column(DialectMySQL.Column("baz", ColumnTypeText, UnsetSize)).
|
||||
Column(DialectMySQL.Column("qux", ColumnTypeDateTime, UnsetSize).SetDefault("NOW()")).
|
||||
UniqueConstraint("bar").
|
||||
UniqueConstraint("bar", "baz").
|
||||
ToSQL()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "CREATE TABLE IF NOT EXISTS foo ( bar INT NOT NULL PRIMARY KEY, baz TEXT NOT NULL, qux DATETIME NOT NULL DEFAULT NOW(), UNIQUE(bar), UNIQUE(bar,baz) )", sql)
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
package db
|
||||
|
||||
import "fmt"
|
||||
|
||||
type DialectType int
|
||||
|
||||
const (
|
||||
DialectSQLite DialectType = iota
|
||||
DialectMySQL DialectType = iota
|
||||
)
|
||||
|
||||
func (d DialectType) Column(name string, t ColumnType, size OptionalInt) *Column {
|
||||
switch d {
|
||||
case DialectSQLite:
|
||||
return &Column{Dialect: DialectSQLite, Name: name, Type: t, Size: size}
|
||||
case DialectMySQL:
|
||||
return &Column{Dialect: DialectMySQL, Name: name, Type: t, Size: size}
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected dialect: %d", d))
|
||||
}
|
||||
}
|
||||
|
||||
func (d DialectType) Table(name string) *CreateTableSqlBuilder {
|
||||
switch d {
|
||||
case DialectSQLite:
|
||||
return &CreateTableSqlBuilder{Dialect: DialectSQLite, Name: name}
|
||||
case DialectMySQL:
|
||||
return &CreateTableSqlBuilder{Dialect: DialectMySQL, Name: name}
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected dialect: %d", d))
|
||||
}
|
||||
}
|
||||
|
||||
func (d DialectType) AlterTable(name string) *AlterTableSqlBuilder {
|
||||
switch d {
|
||||
case DialectSQLite:
|
||||
return &AlterTableSqlBuilder{Dialect: DialectSQLite, Name: name}
|
||||
case DialectMySQL:
|
||||
return &AlterTableSqlBuilder{Dialect: DialectMySQL, Name: name}
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected dialect: %d", d))
|
||||
}
|
||||
}
|
||||
|
||||
func (d DialectType) CreateUniqueIndex(name, table string, columns ...string) *CreateIndexSqlBuilder {
|
||||
switch d {
|
||||
case DialectSQLite:
|
||||
return &CreateIndexSqlBuilder{Dialect: DialectSQLite, Name: name, Table: table, Unique: true, Columns: columns}
|
||||
case DialectMySQL:
|
||||
return &CreateIndexSqlBuilder{Dialect: DialectMySQL, Name: name, Table: table, Unique: true, Columns: columns}
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected dialect: %d", d))
|
||||
}
|
||||
}
|
||||
|
||||
func (d DialectType) CreateIndex(name, table string, columns ...string) *CreateIndexSqlBuilder {
|
||||
switch d {
|
||||
case DialectSQLite:
|
||||
return &CreateIndexSqlBuilder{Dialect: DialectSQLite, Name: name, Table: table, Unique: false, Columns: columns}
|
||||
case DialectMySQL:
|
||||
return &CreateIndexSqlBuilder{Dialect: DialectMySQL, Name: name, Table: table, Unique: false, Columns: columns}
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected dialect: %d", d))
|
||||
}
|
||||
}
|
||||
|
||||
func (d DialectType) DropIndex(name, table string) *DropIndexSqlBuilder {
|
||||
switch d {
|
||||
case DialectSQLite:
|
||||
return &DropIndexSqlBuilder{Dialect: DialectSQLite, Name: name, Table: table}
|
||||
case DialectMySQL:
|
||||
return &DropIndexSqlBuilder{Dialect: DialectMySQL, Name: name, Table: table}
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected dialect: %d", d))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CreateIndexSqlBuilder struct {
|
||||
Dialect DialectType
|
||||
Name string
|
||||
Table string
|
||||
Unique bool
|
||||
Columns []string
|
||||
}
|
||||
|
||||
type DropIndexSqlBuilder struct {
|
||||
Dialect DialectType
|
||||
Name string
|
||||
Table string
|
||||
}
|
||||
|
||||
func (b *CreateIndexSqlBuilder) ToSQL() (string, error) {
|
||||
var str strings.Builder
|
||||
|
||||
str.WriteString("CREATE ")
|
||||
if b.Unique {
|
||||
str.WriteString("UNIQUE ")
|
||||
}
|
||||
str.WriteString("INDEX ")
|
||||
str.WriteString(b.Name)
|
||||
str.WriteString(" on ")
|
||||
str.WriteString(b.Table)
|
||||
|
||||
if len(b.Columns) == 0 {
|
||||
return "", fmt.Errorf("columns provided for this index: %s", b.Name)
|
||||
}
|
||||
|
||||
str.WriteString(" (")
|
||||
columnCount := len(b.Columns)
|
||||
for i, thing := range b.Columns {
|
||||
str.WriteString(thing)
|
||||
if i < columnCount-1 {
|
||||
str.WriteString(", ")
|
||||
}
|
||||
}
|
||||
str.WriteString(")")
|
||||
|
||||
return str.String(), nil
|
||||
}
|
||||
|
||||
func (b *DropIndexSqlBuilder) ToSQL() (string, error) {
|
||||
return fmt.Sprintf("DROP INDEX %s on %s", b.Name, b.Table), nil
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package db
|
||||
|
||||
type RawSqlBuilder struct {
|
||||
Query string
|
||||
}
|
||||
|
||||
func (b *RawSqlBuilder) ToSQL() (string, error) {
|
||||
return b.Query, nil
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// TransactionScopedWork describes code executed within a database transaction.
|
||||
type TransactionScopedWork func(ctx context.Context, db *sql.Tx) error
|
||||
|
||||
// RunTransactionWithOptions executes a block of code within a database transaction.
|
||||
func RunTransactionWithOptions(ctx context.Context, db *sql.DB, txOpts *sql.TxOptions, txWork TransactionScopedWork) error {
|
||||
tx, err := db.BeginTx(ctx, txOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = txWork(ctx, tx); err != nil {
|
||||
if txErr := tx.Rollback(); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
24
errors.go
24
errors.go
|
@ -1,8 +1,19 @@
|
|||
/*
|
||||
* Copyright © 2018-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 (
|
||||
"github.com/writeas/impart"
|
||||
"net/http"
|
||||
|
||||
"github.com/writeas/impart"
|
||||
)
|
||||
|
||||
// Commonly returned HTTP errors
|
||||
|
@ -26,6 +37,8 @@ var (
|
|||
ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."}
|
||||
ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."}
|
||||
|
||||
ErrUnavailable = impart.HTTPError{http.StatusServiceUnavailable, "Service temporarily unavailable due to high load."}
|
||||
|
||||
ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
|
||||
ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."}
|
||||
ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."}
|
||||
|
@ -34,8 +47,13 @@ var (
|
|||
ErrPostUnpublished = impart.HTTPError{Status: http.StatusGone, Message: "Post unpublished by author."}
|
||||
ErrPostFetchError = impart.HTTPError{Status: http.StatusInternalServerError, Message: "We encountered an error getting the post. The humans have been alerted."}
|
||||
|
||||
ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."}
|
||||
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
|
||||
ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."}
|
||||
ErrRemoteUserNotFound = impart.HTTPError{http.StatusNotFound, "Remote user not found."}
|
||||
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
|
||||
|
|
42
export.go
42
export.go
|
@ -1,15 +1,26 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 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 (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"github.com/writeas/web-core/log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/writeas/web-core/log"
|
||||
)
|
||||
|
||||
func exportPostsCSV(u *User, posts *[]PublicPost) []byte {
|
||||
func exportPostsCSV(hostName string, u *User, posts *[]PublicPost) []byte {
|
||||
var b bytes.Buffer
|
||||
|
||||
r := [][]string{
|
||||
|
@ -19,23 +30,25 @@ func exportPostsCSV(u *User, posts *[]PublicPost) []byte {
|
|||
var blog string
|
||||
if p.Collection != nil {
|
||||
blog = p.Collection.Alias
|
||||
p.Collection.hostName = hostName
|
||||
}
|
||||
f := []string{p.ID, p.Slug.String, blog, p.CanonicalURL(), p.Created8601(), p.Title.String, strings.Replace(p.Content, "\n", "\\n", -1)}
|
||||
f := []string{p.ID, p.Slug.String, blog, p.CanonicalURL(hostName), p.Created8601(), p.Title.String, strings.Replace(p.Content, "\n", "\\n", -1)}
|
||||
r = append(r, f)
|
||||
}
|
||||
|
||||
w := csv.NewWriter(&b)
|
||||
w.WriteAll(r) // calls Flush internally
|
||||
if err := w.Error(); err != nil {
|
||||
log.Info("error writing csv:", err)
|
||||
log.Info("error writing csv: %v", err)
|
||||
}
|
||||
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
type exportedTxt struct {
|
||||
Name, Body string
|
||||
Mod time.Time
|
||||
Name, Title, Body string
|
||||
|
||||
Mod time.Time
|
||||
}
|
||||
|
||||
func exportPostsZip(u *User, posts *[]PublicPost) []byte {
|
||||
|
@ -57,7 +70,7 @@ func exportPostsZip(u *User, posts *[]PublicPost) []byte {
|
|||
filename += p.Slug.String + "_"
|
||||
}
|
||||
filename += p.ID + ".txt"
|
||||
files = append(files, exportedTxt{filename, p.Content, p.Created})
|
||||
files = append(files, exportedTxt{filename, p.Title.String, p.Content, p.Created})
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
|
@ -67,7 +80,12 @@ func exportPostsZip(u *User, posts *[]PublicPost) []byte {
|
|||
if err != nil {
|
||||
log.Error("export zip header: %v", err)
|
||||
}
|
||||
_, err = f.Write([]byte(file.Body))
|
||||
var fullPost string
|
||||
if file.Title != "" {
|
||||
fullPost = "# " + file.Title + "\n\n"
|
||||
}
|
||||
fullPost += file.Body
|
||||
_, err = f.Write([]byte(fullPost))
|
||||
if err != nil {
|
||||
log.Error("export zip write: %v", err)
|
||||
}
|
||||
|
@ -82,17 +100,17 @@ func exportPostsZip(u *User, posts *[]PublicPost) []byte {
|
|||
return b.Bytes()
|
||||
}
|
||||
|
||||
func compileFullExport(app *app, u *User) *ExportUser {
|
||||
func compileFullExport(app *App, u *User) *ExportUser {
|
||||
exportUser := &ExportUser{
|
||||
User: u,
|
||||
}
|
||||
|
||||
colls, err := app.db.GetCollections(u)
|
||||
colls, err := app.db.GetCollections(u, app.cfg.App.Host)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
@ -101,7 +119,7 @@ func compileFullExport(app *app, u *User) *ExportUser {
|
|||
var collObjs []CollectionObj
|
||||
for _, c := range *colls {
|
||||
co := &CollectionObj{Collection: c}
|
||||
co.Posts, err = app.db.GetPosts(&c, 0, true, false)
|
||||
co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true)
|
||||
if err != nil {
|
||||
log.Error("unable to get collection posts: %v", err)
|
||||
}
|
||||
|
|
53
feed.go
53
feed.go
|
@ -1,16 +1,27 @@
|
|||
/*
|
||||
* Copyright © 2018-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 (
|
||||
"fmt"
|
||||
. "github.com/gorilla/feeds"
|
||||
"github.com/gorilla/mux"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
"github.com/writeas/web-core/log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/gorilla/mux"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/web-core/log"
|
||||
)
|
||||
|
||||
func ViewFeed(app *app, w http.ResponseWriter, req *http.Request) error {
|
||||
func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
|
||||
alias := collectionAliasFromReq(req)
|
||||
|
||||
// Display collection if this is a collection
|
||||
|
@ -25,6 +36,16 @@ func ViewFeed(app *app, w http.ResponseWriter, req *http.Request) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("view feed: get user: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
if c.IsPrivate() || c.IsProtected() {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
|
@ -44,9 +65,9 @@ func ViewFeed(app *app, w http.ResponseWriter, req *http.Request) error {
|
|||
|
||||
tag := mux.Vars(req)["tag"]
|
||||
if tag != "" {
|
||||
coll.Posts, _ = app.db.GetPostsTagged(c, tag, 1, false)
|
||||
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, 1, false)
|
||||
} else {
|
||||
coll.Posts, _ = app.db.GetPosts(c, 1, false, true)
|
||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false)
|
||||
}
|
||||
|
||||
author := ""
|
||||
|
@ -66,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)),
|
||||
Author: &Author{author, ""},
|
||||
Content: string(p.HTMLContent),
|
||||
Author: &feeds.Author{author, ""},
|
||||
Created: p.Created,
|
||||
Updated: p.Updated,
|
||||
})
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
module github.com/writefreely/writefreely
|
||||
|
||||
require (
|
||||
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/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/gobuffalo/envy v1.9.0 // indirect
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
||||
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/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
||||
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/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.9.0
|
||||
github.com/urfave/cli/v2 v2.27.1
|
||||
github.com/writeas/activity v0.1.2
|
||||
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
|
||||
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.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/x/crypto v0.21.0
|
||||
golang.org/x/net v0.22.0
|
||||
)
|
||||
|
||||
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
|
|
@ -0,0 +1,319 @@
|
|||
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/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-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=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
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/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-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/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.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/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/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/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/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/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/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/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/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/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/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=
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
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.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-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/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
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.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.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=
|
||||
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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
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/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/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/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/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
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()
|
||||
}
|
551
handle.go
551
handle.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2018-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 (
|
||||
|
@ -13,29 +23,60 @@ import (
|
|||
"github.com/gorilla/sessions"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
"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
|
||||
type UserLevel int
|
||||
|
||||
const (
|
||||
UserLevelNone UserLevel = iota // user or not -- ignored
|
||||
UserLevelOptional // user or not -- object fetched if user
|
||||
UserLevelNoneRequired // non-user (required)
|
||||
UserLevelUser // user (required)
|
||||
UserLevelNoneType UserLevel = iota // user or not -- ignored
|
||||
UserLevelOptionalType // user or not -- object fetched if user
|
||||
UserLevelNoneRequiredType // non-user (required)
|
||||
UserLevelUserType // user (required)
|
||||
)
|
||||
|
||||
func UserLevelNone(cfg *config.Config) UserLevel {
|
||||
return UserLevelNoneType
|
||||
}
|
||||
|
||||
func UserLevelOptional(cfg *config.Config) UserLevel {
|
||||
return UserLevelOptionalType
|
||||
}
|
||||
|
||||
func UserLevelNoneRequired(cfg *config.Config) UserLevel {
|
||||
return UserLevelNoneRequiredType
|
||||
}
|
||||
|
||||
func UserLevelUser(cfg *config.Config) UserLevel {
|
||||
return UserLevelUserType
|
||||
}
|
||||
|
||||
// UserLevelReader returns the permission level required for any route where
|
||||
// users can read published content.
|
||||
func UserLevelReader(cfg *config.Config) UserLevel {
|
||||
if cfg.App.Private {
|
||||
return UserLevelUserType
|
||||
}
|
||||
return UserLevelOptionalType
|
||||
}
|
||||
|
||||
type (
|
||||
handlerFunc func(app *app, w http.ResponseWriter, r *http.Request) error
|
||||
userHandlerFunc func(app *app, u *User, w http.ResponseWriter, r *http.Request) error
|
||||
dataHandlerFunc func(app *app, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
|
||||
authFunc func(app *app, r *http.Request) (*User, error)
|
||||
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)
|
||||
authFunc func(app *App, r *http.Request) (*User, error)
|
||||
UserLevelFunc func(cfg *config.Config) UserLevel
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
errors *ErrorPages
|
||||
sessionStore *sessions.CookieStore
|
||||
app *app
|
||||
sessionStore sessions.Store
|
||||
app Apper
|
||||
}
|
||||
|
||||
// ErrorPages hold template HTML error pages for displaying errors to the user.
|
||||
|
@ -44,26 +85,42 @@ type ErrorPages struct {
|
|||
NotFound *template.Template
|
||||
Gone *template.Template
|
||||
InternalServerError *template.Template
|
||||
UnavailableError *template.Template
|
||||
Blank *template.Template
|
||||
}
|
||||
|
||||
// NewHandler returns a new Handler instance, using the given StaticPage data,
|
||||
// and saving alias to the application's CookieStore.
|
||||
func NewHandler(app *app) *Handler {
|
||||
func NewHandler(apper Apper) *Handler {
|
||||
h := &Handler{
|
||||
errors: &ErrorPages{
|
||||
NotFound: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>404</title></head><body><p>Not found.</p></body></html>{{end}}")),
|
||||
Gone: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>410</title></head><body><p>Gone.</p></body></html>{{end}}")),
|
||||
InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")),
|
||||
UnavailableError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>503</title></head><body><p>Service is temporarily unavailable.</p></body></html>{{end}}")),
|
||||
Blank: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")),
|
||||
},
|
||||
sessionStore: app.sessionStore,
|
||||
app: app,
|
||||
sessionStore: apper.App().SessionStore(),
|
||||
app: apper,
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// NewWFHandler returns a new Handler instance, using WriteFreely template files.
|
||||
// You MUST call writefreely.InitTemplates() before this.
|
||||
func NewWFHandler(apper Apper) *Handler {
|
||||
h := NewHandler(apper)
|
||||
h.SetErrorPages(&ErrorPages{
|
||||
NotFound: pages["404-general.tmpl"],
|
||||
Gone: pages["410.tmpl"],
|
||||
InternalServerError: pages["500.tmpl"],
|
||||
UnavailableError: pages["503.tmpl"],
|
||||
Blank: pages["blank.tmpl"],
|
||||
})
|
||||
return h
|
||||
}
|
||||
|
||||
// SetErrorPages sets the given set of ErrorPages as templates for any errors
|
||||
// that come up.
|
||||
func (h *Handler) SetErrorPages(e *ErrorPages) {
|
||||
|
@ -81,25 +138,31 @@ func (h *Handler) User(f userHandlerFunc) http.HandlerFunc {
|
|||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
log.Error("%s: %s", e, debug.Stack())
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
u := getUserSession(h.app, r)
|
||||
u := getUserSession(h.app.App(), r)
|
||||
if u == nil {
|
||||
err := ErrNotLoggedIn
|
||||
status = err.Status
|
||||
return err
|
||||
}
|
||||
|
||||
err := f(h.app, u, w, r)
|
||||
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
|
||||
}
|
||||
|
@ -119,14 +182,52 @@ func (h *Handler) Admin(f userHandlerFunc) http.HandlerFunc {
|
|||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
log.Error("%s: %s", e, debug.Stack())
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
log.Info(fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent()))
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
u := getUserSession(h.app, r)
|
||||
u := getUserSession(h.app.App(), r)
|
||||
if u == nil || !u.IsAdmin() {
|
||||
err := impart.HTTPError{http.StatusNotFound, ""}
|
||||
status = err.Status
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return err
|
||||
}())
|
||||
}
|
||||
}
|
||||
|
||||
// AdminApper handles requests on /admin routes that require an Apper.
|
||||
func (h *Handler) AdminApper(f userApperHandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleHTTPError(w, r, func() error {
|
||||
var status int
|
||||
start := time.Now()
|
||||
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
log.Error("%s: %s", e, debug.Stack())
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
u := getUserSession(h.app.App(), r)
|
||||
if u == nil || !u.IsAdmin() {
|
||||
err := impart.HTTPError{http.StatusNotFound, ""}
|
||||
status = err.Status
|
||||
|
@ -147,18 +248,65 @@ func (h *Handler) Admin(f userHandlerFunc) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func apiAuth(app *App, r *http.Request) (*User, error) {
|
||||
// Authorize user from Authorization header
|
||||
t := r.Header.Get("Authorization")
|
||||
if t == "" {
|
||||
return nil, ErrNoAccessToken
|
||||
}
|
||||
u := &User{ID: app.db.GetUserID(t)}
|
||||
if u.ID == -1 {
|
||||
return nil, ErrBadAccessToken
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Authorize user from Authorization header
|
||||
t := r.Header.Get("Authorization")
|
||||
if t == "" {
|
||||
return nil, ErrNotLoggedIn
|
||||
}
|
||||
u := &User{ID: app.db.GetUserID(t)}
|
||||
if u.ID == -1 {
|
||||
return nil, ErrBadAccessToken
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func webAuth(app *App, r *http.Request) (*User, error) {
|
||||
u := getUserSession(app, r)
|
||||
if u == nil {
|
||||
return nil, ErrNotLoggedIn
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UserAPI handles requests made in the API by the authenticated user.
|
||||
// This provides user-friendly HTML pages and actions that work in the browser.
|
||||
func (h *Handler) UserAPI(f userHandlerFunc) http.HandlerFunc {
|
||||
return h.UserAll(false, f, func(app *app, r *http.Request) (*User, error) {
|
||||
// Authorize user from Authorization header
|
||||
t := r.Header.Get("Authorization")
|
||||
if t == "" {
|
||||
return nil, ErrNoAccessToken
|
||||
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
|
||||
}
|
||||
u := &User{ID: app.db.GetUserID(t)}
|
||||
if u.ID == -1 {
|
||||
return nil, ErrBadAccessToken
|
||||
|
||||
// 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
|
||||
|
@ -178,10 +326,10 @@ func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerF
|
|||
status = 500
|
||||
}
|
||||
|
||||
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
u, err := a(h.app, r)
|
||||
u, err := a(h.app.App(), r)
|
||||
if err != nil {
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
|
@ -191,7 +339,7 @@ func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerF
|
|||
return err
|
||||
}
|
||||
|
||||
err = f(h.app, u, w, r)
|
||||
err = f(h.app.App(), u, w, r)
|
||||
if err == nil {
|
||||
status = 200
|
||||
} else if err, ok := err.(impart.HTTPError); ok {
|
||||
|
@ -212,7 +360,7 @@ func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerF
|
|||
}
|
||||
|
||||
func (h *Handler) RedirectOnErr(f handlerFunc, loc string) handlerFunc {
|
||||
return func(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
return func(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
err := f(app, w, r)
|
||||
if err != nil {
|
||||
if ie, ok := err.(impart.HTTPError); ok {
|
||||
|
@ -229,7 +377,7 @@ func (h *Handler) RedirectOnErr(f handlerFunc, loc string) handlerFunc {
|
|||
}
|
||||
|
||||
func (h *Handler) Page(n string) http.HandlerFunc {
|
||||
return h.Web(func(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
return h.Web(func(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
t, ok := pages[n]
|
||||
if !ok {
|
||||
return impart.HTTPError{http.StatusNotFound, "Page not found."}
|
||||
|
@ -245,7 +393,7 @@ func (h *Handler) Page(n string) http.HandlerFunc {
|
|||
}, UserLevelOptional)
|
||||
}
|
||||
|
||||
func (h *Handler) WebErrors(f handlerFunc, ul UserLevel) http.HandlerFunc {
|
||||
func (h *Handler) WebErrors(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: factor out this logic shared with Web()
|
||||
h.handleHTTPError(w, r, func() error {
|
||||
|
@ -254,36 +402,36 @@ func (h *Handler) WebErrors(f handlerFunc, ul UserLevel) http.HandlerFunc {
|
|||
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
u := getUserSession(h.app, r)
|
||||
u := getUserSession(h.app.App(), r)
|
||||
username := "None"
|
||||
if u != nil {
|
||||
username = u.Username
|
||||
}
|
||||
log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
status = 500
|
||||
}
|
||||
|
||||
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
var session *sessions.Session
|
||||
var err error
|
||||
if ul != UserLevelNone {
|
||||
if ul(h.app.App().cfg) != UserLevelNoneType {
|
||||
session, err = h.sessionStore.Get(r, cookieName)
|
||||
if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
|
||||
if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
|
||||
// Cookie is required, but we can ignore this error
|
||||
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
|
||||
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
|
||||
}
|
||||
|
||||
_, gotUser := session.Values[cookieUserVal].(*User)
|
||||
if ul == UserLevelNoneRequired && gotUser {
|
||||
if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
|
||||
to := correctPageFromLoginAttempt(r)
|
||||
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
|
||||
err := impart.HTTPError{http.StatusFound, to}
|
||||
status = err.Status
|
||||
return err
|
||||
} else if ul == UserLevelUser && !gotUser {
|
||||
} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
|
||||
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
|
||||
err := ErrNotLoggedIn
|
||||
status = err.Status
|
||||
|
@ -292,13 +440,13 @@ func (h *Handler) WebErrors(f handlerFunc, ul UserLevel) http.HandlerFunc {
|
|||
}
|
||||
|
||||
// TODO: pass User object to function
|
||||
err = f(h.app, w, r)
|
||||
err = f(h.app.App(), w, r)
|
||||
if err == nil {
|
||||
status = 200
|
||||
} else if httpErr, ok := err.(impart.HTTPError); ok {
|
||||
status = httpErr.Status
|
||||
if status < 300 || status > 399 {
|
||||
addSessionFlash(h.app, w, r, httpErr.Message, session)
|
||||
addSessionFlash(h.app.App(), w, r, httpErr.Message, session)
|
||||
return impart.HTTPError{http.StatusFound, r.Referer()}
|
||||
}
|
||||
} else {
|
||||
|
@ -309,7 +457,7 @@ func (h *Handler) WebErrors(f handlerFunc, ul UserLevel) http.HandlerFunc {
|
|||
log.Error(e)
|
||||
}
|
||||
log.Info("Web handler internal error render")
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
status = 500
|
||||
}
|
||||
|
||||
|
@ -318,9 +466,25 @@ func (h *Handler) WebErrors(f handlerFunc, ul UserLevel) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *Handler) CollectionPostOrStatic(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, ".") && !isRaw(r) {
|
||||
start := time.Now()
|
||||
status := 200
|
||||
defer func() {
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
// Serve static file
|
||||
h.app.App().shttp.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
h.Web(viewCollectionPost, UserLevelReader)(w, r)
|
||||
}
|
||||
|
||||
// Web handles requests made in the web application. This provides user-
|
||||
// friendly HTML pages and actions that work in the browser.
|
||||
func (h *Handler) Web(f handlerFunc, ul UserLevel) http.HandlerFunc {
|
||||
func (h *Handler) Web(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleHTTPError(w, r, func() error {
|
||||
var status int
|
||||
|
@ -328,35 +492,35 @@ func (h *Handler) Web(f handlerFunc, ul UserLevel) http.HandlerFunc {
|
|||
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
u := getUserSession(h.app, r)
|
||||
u := getUserSession(h.app.App(), r)
|
||||
username := "None"
|
||||
if u != nil {
|
||||
username = u.Username
|
||||
}
|
||||
log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
|
||||
log.Info("Web deferred internal error render")
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
status = 500
|
||||
}
|
||||
|
||||
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
if ul != UserLevelNone {
|
||||
if ul(h.app.App().cfg) != UserLevelNoneType {
|
||||
session, err := h.sessionStore.Get(r, cookieName)
|
||||
if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
|
||||
if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
|
||||
// Cookie is required, but we can ignore this error
|
||||
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
|
||||
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
|
||||
}
|
||||
|
||||
_, gotUser := session.Values[cookieUserVal].(*User)
|
||||
if ul == UserLevelNoneRequired && gotUser {
|
||||
if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
|
||||
to := correctPageFromLoginAttempt(r)
|
||||
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
|
||||
err := impart.HTTPError{http.StatusFound, to}
|
||||
status = err.Status
|
||||
return err
|
||||
} else if ul == UserLevelUser && !gotUser {
|
||||
} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
|
||||
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
|
||||
err := ErrNotLoggedIn
|
||||
status = err.Status
|
||||
|
@ -365,7 +529,7 @@ func (h *Handler) Web(f handlerFunc, ul UserLevel) http.HandlerFunc {
|
|||
}
|
||||
|
||||
// TODO: pass User object to function
|
||||
err := f(h.app, w, r)
|
||||
err := f(h.app.App(), w, r)
|
||||
if err == nil {
|
||||
status = 200
|
||||
} else if httpErr, ok := err.(impart.HTTPError); ok {
|
||||
|
@ -374,7 +538,7 @@ func (h *Handler) Web(f handlerFunc, ul UserLevel) http.HandlerFunc {
|
|||
e := fmt.Sprintf("[Web handler] 500: %v", err)
|
||||
log.Error(e)
|
||||
log.Info("Web internal error render")
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
status = 500
|
||||
}
|
||||
|
||||
|
@ -397,12 +561,12 @@ func (h *Handler) All(f handlerFunc) http.HandlerFunc {
|
|||
status = 500
|
||||
}
|
||||
|
||||
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
// TODO: do any needed authentication
|
||||
|
||||
err := f(h.app, w, r)
|
||||
err := f(h.app.App(), w, r)
|
||||
if err != nil {
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
|
@ -416,7 +580,131 @@ func (h *Handler) All(f handlerFunc) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Download(f dataHandlerFunc, ul UserLevel) 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 {
|
||||
// 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())
|
||||
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
|
||||
status = 500
|
||||
}
|
||||
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
err := f(h.app.App(), w, r)
|
||||
if err != nil {
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
} else {
|
||||
status = 500
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}())
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleError(w, r, func() error {
|
||||
status := 200
|
||||
start := time.Now()
|
||||
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
log.Error("%s:\n%s", e, debug.Stack())
|
||||
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
|
||||
status = 500
|
||||
}
|
||||
|
||||
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
|
||||
_, apiErr := optionalAPIAuth(h.app.App(), r)
|
||||
if apiErr != nil {
|
||||
if err, ok := apiErr.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
} else {
|
||||
status = 500
|
||||
}
|
||||
|
||||
if apiErr == ErrNotLoggedIn {
|
||||
// Fall back to web auth since there was no access token given
|
||||
_, err := webAuth(h.app.App(), r)
|
||||
if err != nil {
|
||||
if err, ok := apiErr.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
} else {
|
||||
status = 500
|
||||
}
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return apiErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := f(h.app.App(), w, r)
|
||||
if err != nil {
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
} else {
|
||||
status = 500
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}())
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Download(f dataHandlerFunc, ul UserLevelFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleHTTPError(w, r, func() error {
|
||||
var status int
|
||||
|
@ -424,14 +712,14 @@ func (h *Handler) Download(f dataHandlerFunc, ul UserLevel) http.HandlerFunc {
|
|||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
log.Error("%s: %s", e, debug.Stack())
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
status = 500
|
||||
}
|
||||
|
||||
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
data, filename, err := f(h.app, w, r)
|
||||
data, filename, err := f(h.app.App(), w, r)
|
||||
if err != nil {
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
|
@ -461,27 +749,27 @@ func (h *Handler) Download(f dataHandlerFunc, ul UserLevel) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Redirect(url string, ul UserLevel) http.HandlerFunc {
|
||||
func (h *Handler) Redirect(url string, ul UserLevelFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleHTTPError(w, r, func() error {
|
||||
start := time.Now()
|
||||
|
||||
var status int
|
||||
if ul != UserLevelNone {
|
||||
if ul(h.app.App().cfg) != UserLevelNoneType {
|
||||
session, err := h.sessionStore.Get(r, cookieName)
|
||||
if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
|
||||
if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
|
||||
// Cookie is required, but we can ignore this error
|
||||
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
|
||||
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
|
||||
}
|
||||
|
||||
_, gotUser := session.Values[cookieUserVal].(*User)
|
||||
if ul == UserLevelNoneRequired && gotUser {
|
||||
if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
|
||||
to := correctPageFromLoginAttempt(r)
|
||||
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
|
||||
err := impart.HTTPError{http.StatusFound, to}
|
||||
status = err.Status
|
||||
return err
|
||||
} else if ul == UserLevelUser && !gotUser {
|
||||
} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
|
||||
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
|
||||
err := ErrNotLoggedIn
|
||||
status = err.Status
|
||||
|
@ -491,7 +779,7 @@ func (h *Handler) Redirect(url string, ul UserLevel) http.HandlerFunc {
|
|||
|
||||
status = sendRedirect(w, http.StatusFound, url)
|
||||
|
||||
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
|
||||
return nil
|
||||
}())
|
||||
|
@ -515,11 +803,12 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
|||
sendRedirect(w, http.StatusFound, "/login?to="+r.URL.Path+q)
|
||||
return
|
||||
} else if err.Status == http.StatusGone {
|
||||
w.WriteHeader(err.Status)
|
||||
p := &struct {
|
||||
page.StaticPage
|
||||
Content *template.HTML
|
||||
}{
|
||||
StaticPage: pageForReq(h.app, r),
|
||||
StaticPage: pageForReq(h.app.App(), r),
|
||||
}
|
||||
if err.Message != "" {
|
||||
co := template.HTML(err.Message)
|
||||
|
@ -528,11 +817,21 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
|||
h.errors.Gone.ExecuteTemplate(w, "base", p)
|
||||
return
|
||||
} else if err.Status == http.StatusNotFound {
|
||||
h.errors.NotFound.ExecuteTemplate(w, "base", pageForReq(h.app, r))
|
||||
w.WriteHeader(err.Status)
|
||||
if IsActivityPubRequest(r) {
|
||||
// This is a fediverse request; simply return the header
|
||||
return
|
||||
}
|
||||
h.errors.NotFound.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
return
|
||||
} else if err.Status == http.StatusInternalServerError {
|
||||
w.WriteHeader(err.Status)
|
||||
log.Info("handleHTTPErorr internal error render")
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
return
|
||||
} else if err.Status == http.StatusServiceUnavailable {
|
||||
w.WriteHeader(err.Status)
|
||||
h.errors.UnavailableError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
return
|
||||
} else if err.Status == http.StatusAccepted {
|
||||
impart.WriteSuccess(w, "", err.Status)
|
||||
|
@ -543,7 +842,7 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
|||
Title string
|
||||
Content template.HTML
|
||||
}{
|
||||
pageForReq(h.app, r),
|
||||
pageForReq(h.app.App(), r),
|
||||
fmt.Sprintf("Uh oh (%d)", err.Status),
|
||||
template.HTML(fmt.Sprintf("<p style=\"text-align: center\" class=\"introduction\">%s</p>", err.Message)),
|
||||
}
|
||||
|
@ -574,11 +873,50 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error)
|
|||
return
|
||||
}
|
||||
|
||||
if IsJSON(r.Header.Get("Content-Type")) {
|
||||
if IsJSON(r) {
|
||||
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
|
||||
return
|
||||
}
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
|
||||
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
|
||||
}
|
||||
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
if err.Status >= 300 && err.Status < 400 {
|
||||
sendRedirect(w, err.Status, err.Message)
|
||||
return
|
||||
}
|
||||
|
||||
impart.WriteOAuthError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
impart.WriteOAuthError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
|
||||
return
|
||||
}
|
||||
|
||||
func correctPageFromLoginAttempt(r *http.Request) string {
|
||||
|
@ -600,14 +938,42 @@ func (h *Handler) LogHandlerFunc(f http.HandlerFunc) http.HandlerFunc {
|
|||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
log.Error("Handler.LogHandlerFunc\n\n%s: %s", e, debug.Stack())
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
status = 500
|
||||
}
|
||||
|
||||
// TODO: log actual status code returned
|
||||
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
|
||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
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
|
||||
_, apiErr := optionalAPIAuth(h.app.App(), r)
|
||||
if apiErr != nil {
|
||||
if err, ok := apiErr.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
} else {
|
||||
status = 500
|
||||
}
|
||||
|
||||
if apiErr == ErrNotLoggedIn {
|
||||
// Fall back to web auth since there was no access token given
|
||||
_, err := webAuth(h.app.App(), r)
|
||||
if err != nil {
|
||||
if err, ok := apiErr.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
} else {
|
||||
status = 500
|
||||
}
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return apiErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f(w, r)
|
||||
|
||||
return nil
|
||||
|
@ -615,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)
|
||||
})
|
||||
}
|
||||
|
|
12
hostmeta.go
12
hostmeta.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 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,7 @@ import (
|
|||
"net/http"
|
||||
)
|
||||
|
||||
func handleViewHostMeta(app *app, w http.ResponseWriter, r *http.Request) error {
|
||||
func handleViewHostMeta(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Server", serverSoftware)
|
||||
w.Header().Set("Content-Type", "application/xrd+xml; charset=utf-8")
|
||||
|
||||
|
|
10
instance.go
10
instance.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2018 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
|
||||
|
||||
type InstanceStats struct {
|
||||
|
|
|
@ -0,0 +1,206 @@
|
|||
/*
|
||||
* 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"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/id"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
)
|
||||
|
||||
type Invite struct {
|
||||
ID string
|
||||
MaxUses sql.NullInt64
|
||||
Created time.Time
|
||||
Expires *time.Time
|
||||
Inactive bool
|
||||
|
||||
uses int64
|
||||
}
|
||||
|
||||
func (i Invite) Uses() int64 {
|
||||
return i.uses
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
// Don't show page if instance doesn't allow it
|
||||
if !(app.cfg.App.UserInvites != "" && (u.IsAdmin() || app.cfg.App.UserInvites != "admin")) {
|
||||
return impart.HTTPError{http.StatusNotFound, ""}
|
||||
}
|
||||
|
||||
f, _ := getSessionFlashes(app, w, r, nil)
|
||||
|
||||
p := struct {
|
||||
*UserPage
|
||||
Invites *[]Invite
|
||||
Silenced bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Invite People", f),
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
p.Silenced, err = app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view invites: %v", err)
|
||||
}
|
||||
|
||||
p.Invites, err = app.db.GetUserInvites(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range *p.Invites {
|
||||
(*p.Invites)[i].uses = app.db.GetUsersInvitedCount((*p.Invites)[i].ID)
|
||||
}
|
||||
|
||||
showUserPage(w, "invite", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
muVal := r.FormValue("uses")
|
||||
expVal := r.FormValue("expires")
|
||||
|
||||
if u.IsSilenced() {
|
||||
return ErrUserSilenced
|
||||
}
|
||||
|
||||
var err error
|
||||
var maxUses int
|
||||
if muVal != "0" {
|
||||
maxUses, err = strconv.Atoi(muVal)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusBadRequest, "Invalid value for 'max_uses'"}
|
||||
}
|
||||
}
|
||||
|
||||
var expDate *time.Time
|
||||
var expires int
|
||||
if expVal != "0" {
|
||||
expires, err = strconv.Atoi(expVal)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusBadRequest, "Invalid value for 'expires'"}
|
||||
}
|
||||
ed := time.Now().Add(time.Duration(expires) * time.Minute)
|
||||
expDate = &ed
|
||||
}
|
||||
|
||||
inviteID := id.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6)
|
||||
err = app.db.CreateUserInvite(inviteID, u.ID, maxUses, expDate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return impart.HTTPError{http.StatusFound, "/me/invites"}
|
||||
}
|
||||
|
||||
func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
inviteCode := mux.Vars(r)["code"]
|
||||
|
||||
i, err := app.db.GetUserInvite(inviteCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expired := i.Expired()
|
||||
if !expired && i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
|
||||
// Invite has a max-use number, so check if we're past that limit
|
||||
i.uses = app.db.GetUsersInvitedCount(inviteCode)
|
||||
expired = i.uses >= i.MaxUses.Int64
|
||||
}
|
||||
|
||||
if u := getUserSession(app, r); u != nil {
|
||||
// check if invite belongs to another user
|
||||
// error can be ignored as not important in this case
|
||||
if ownInvite, _ := app.db.IsUsersInvite(inviteCode, u.ID); !ownInvite {
|
||||
addSessionFlash(app, w, r, "You're already registered and logged in.", nil)
|
||||
// show homepage
|
||||
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||
}
|
||||
|
||||
// show invite instructions
|
||||
p := struct {
|
||||
*UserPage
|
||||
Invite *Invite
|
||||
Expired bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Invite to "+app.cfg.App.SiteName, nil),
|
||||
Invite: i,
|
||||
Expired: expired,
|
||||
}
|
||||
showUserPage(w, "invite-help", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
p := struct {
|
||||
page.StaticPage
|
||||
*OAuthButtons
|
||||
Error string
|
||||
Flashes []template.HTML
|
||||
Invite string
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.cfg),
|
||||
Invite: inviteCode,
|
||||
}
|
||||
|
||||
if expired {
|
||||
p.Error = "This invite link has expired."
|
||||
}
|
||||
|
||||
// Tell search engines not to index invite links
|
||||
w.Header().Set("X-Robots-Tag", "noindex")
|
||||
|
||||
// Get error messages
|
||||
session, err := app.sessionStore.Get(r, cookieName)
|
||||
if err != nil {
|
||||
// Ignore this
|
||||
log.Error("Unable to get session in handleViewInvite; ignoring: %v", err)
|
||||
}
|
||||
flashes, _ := getSessionFlashes(app, w, r, session)
|
||||
for _, flash := range flashes {
|
||||
p.Flashes = append(p.Flashes, template.HTML(flash))
|
||||
}
|
||||
|
||||
// Show landing page
|
||||
return renderPage(w, "signup.tmpl", p)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* 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 key holds application keys and utilities around generating them.
|
||||
package key
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
)
|
||||
|
||||
const (
|
||||
EncKeysBytes = 32
|
||||
)
|
||||
|
||||
type Keychain struct {
|
||||
EmailKey, CookieAuthKey, CookieKey, CSRFKey []byte
|
||||
}
|
||||
|
||||
// GenerateKeys generates necessary keys for the app on the given Keychain,
|
||||
// skipping any that already exist.
|
||||
func (keys *Keychain) GenerateKeys() error {
|
||||
// Generate keys only if they don't already exist
|
||||
// TODO: use something like https://github.com/hashicorp/go-multierror to return errors
|
||||
var err, keyErrs error
|
||||
if len(keys.EmailKey) == 0 {
|
||||
keys.EmailKey, err = GenerateBytes(EncKeysBytes)
|
||||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
}
|
||||
if len(keys.CookieAuthKey) == 0 {
|
||||
keys.CookieAuthKey, err = GenerateBytes(EncKeysBytes)
|
||||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
}
|
||||
if len(keys.CookieKey) == 0 {
|
||||
keys.CookieKey, err = GenerateBytes(EncKeysBytes)
|
||||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
}
|
||||
if len(keys.CSRFKey) == 0 {
|
||||
keys.CSRFKey, err = GenerateBytes(EncKeysBytes)
|
||||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
}
|
||||
|
||||
return keyErrs
|
||||
}
|
||||
|
||||
// GenerateBytes returns securely generated random bytes.
|
||||
func GenerateBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
73
keys.go
73
keys.go
|
@ -1,49 +1,48 @@
|
|||
/*
|
||||
* Copyright © 2018-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 (
|
||||
"crypto/rand"
|
||||
"github.com/writeas/web-core/log"
|
||||
"io/ioutil"
|
||||
"github.com/writefreely/writefreely/key"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
keysDir = "keys"
|
||||
|
||||
encKeysBytes = 32
|
||||
)
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
type keychain struct {
|
||||
emailKey, cookieAuthKey, cookieKey []byte
|
||||
// InitKeys loads encryption keys into memory via the given Apper interface
|
||||
func InitKeys(apper Apper) error {
|
||||
log.Info("Loading encryption keys...")
|
||||
err := apper.LoadKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initKeys(app *app) error {
|
||||
var err error
|
||||
app.keys = &keychain{}
|
||||
|
||||
app.keys.emailKey, err = ioutil.ReadFile(emailKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.keys.cookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.keys.cookieKey, err = ioutil.ReadFile(cookieKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
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
|
||||
|
@ -51,18 +50,21 @@ func initKeys(app *app) error {
|
|||
// keys, this won't overwrite any existing key, and instead outputs a message.
|
||||
func generateKey(path string) error {
|
||||
// Check if key file exists
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
log.Info("%s already exists. rm the file if you understand the consquences.", path)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info("Generating %s.", path)
|
||||
b, err := generateBytes(encKeysBytes)
|
||||
b, err := key.GenerateBytes(key.EncKeysBytes)
|
||||
if err != nil {
|
||||
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
|
||||
|
@ -70,14 +72,3 @@ func generateKey(path string) error {
|
|||
log.Info("Success.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateBytes returns securely generated random bytes.
|
||||
func generateBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
|
|
@ -1,18 +1,13 @@
|
|||
ifeq ($(shell which lessc),/usr/bin/lessc)
|
||||
LESSC=/usr/bin/lessc
|
||||
else
|
||||
LESSC=node_modules/.bin/lessc
|
||||
endif
|
||||
export LESSC
|
||||
|
||||
CSSDIR=../static/css/
|
||||
|
||||
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
|
||||
@command -v lessc >/dev/null 2>&1 || { echo >&2 "lessc is not installed, please run: make install or: less/install-less.sh"; exit 1; }
|
||||
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 :
|
||||
./install-less.sh
|
||||
$(MAKE) all
|
||||
|
||||
|
|
124
less/admin.less
124
less/admin.less
|
@ -2,3 +2,127 @@
|
|||
font-size: 1em;
|
||||
min-height: 12em;
|
||||
}
|
||||
header.admin {
|
||||
margin: 0;
|
||||
|
||||
h1 + a {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
nav#admin {
|
||||
display: block;
|
||||
margin: 0.5em 0;
|
||||
a {
|
||||
margin-left: 0;
|
||||
.rounded(.25em);
|
||||
border: 0;
|
||||
&.selected {
|
||||
background: #dedede;
|
||||
font-weight: bold;
|
||||
.blip {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
.blip {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.pager {
|
||||
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;
|
||||
font-size: 0.86em;
|
||||
padding: 0.5em 1em;
|
||||
border: 1px solid #ccc;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: #efefef;
|
||||
}
|
||||
&.selected {
|
||||
cursor: default;
|
||||
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 {
|
||||
.btn {
|
||||
font-family: @sansFont;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
}
|
||||
|
||||
.features {
|
||||
margin: 1em 0;
|
||||
|
||||
div {
|
||||
&:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
&+div {
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-weight: normal;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.86em;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
div.row.features {
|
||||
align-items: start;
|
||||
}
|
||||
.features div + div {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
|
@ -5,6 +5,8 @@
|
|||
@import "post-temp";
|
||||
@import "effects";
|
||||
@import "admin";
|
||||
@import "login";
|
||||
@import "pages/error";
|
||||
@import "resources";
|
||||
@import "lib/elements";
|
||||
@import "lib/material";
|
||||
|
|
267
less/core.less
267
less/core.less
|
@ -1,20 +1,9 @@
|
|||
@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;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: white;
|
||||
color: #111;
|
||||
|
||||
h1, header h2 {
|
||||
|
@ -46,6 +35,11 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
p + p {
|
||||
margin: -2em 0 0.5em;
|
||||
}
|
||||
}
|
||||
article {
|
||||
margin-bottom: 2em !important;
|
||||
|
||||
|
@ -55,18 +49,18 @@ body {
|
|||
}
|
||||
hr + p, ol, ul {
|
||||
display: block;
|
||||
margin-top: -1em;
|
||||
margin-bottom: -1em;
|
||||
margin-top: -1rem;
|
||||
margin-bottom: -1rem;
|
||||
}
|
||||
ol, ul {
|
||||
margin: 0.75em 0 -1em;
|
||||
}
|
||||
ul {
|
||||
padding: 0 0 0 2em;
|
||||
margin: 2rem 0 -1rem;
|
||||
ol, ul {
|
||||
margin: 1.25rem 0 -0.5rem;
|
||||
}
|
||||
}
|
||||
li {
|
||||
margin-top: -0.5em;
|
||||
margin-bottom: -0.5em;
|
||||
margin-top: -0.5rem;
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
h2#title {
|
||||
.article-title;
|
||||
|
@ -75,7 +69,7 @@ body {
|
|||
font-size: 1.5em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.17em;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -102,9 +96,13 @@ body {
|
|||
code {
|
||||
.article-code;
|
||||
}
|
||||
img, video {
|
||||
img, video, audio {
|
||||
max-width: 100%;
|
||||
}
|
||||
audio {
|
||||
width: 100%;
|
||||
white-space: initial;
|
||||
}
|
||||
pre {
|
||||
.code-block;
|
||||
|
||||
|
@ -212,6 +210,10 @@ body {
|
|||
pre {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.flash {
|
||||
text-align: center;
|
||||
margin-bottom: 4em;
|
||||
}
|
||||
}
|
||||
&#subpage {
|
||||
#wrapper {
|
||||
|
@ -247,6 +249,8 @@ body {
|
|||
margin-bottom: 0.25em;
|
||||
&+time {
|
||||
display: block;
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
}
|
||||
time {
|
||||
|
@ -393,6 +397,39 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
img {
|
||||
&.paid {
|
||||
height: 0.86em;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
}
|
||||
|
||||
nav#full-nav {
|
||||
margin: 0;
|
||||
|
||||
.left-side {
|
||||
display: inline-block;
|
||||
|
||||
a:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.right-side {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
nav#full-nav a.simple-btn, .tool button {
|
||||
font-family: @sansFont;
|
||||
border: 1px solid #ccc !important;
|
||||
padding: .5rem 1rem;
|
||||
margin: 0;
|
||||
.rounded(.25em);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
a {
|
||||
&:link {
|
||||
|
@ -479,13 +516,20 @@ abbr {
|
|||
body#collection article p, body#subpage article p {
|
||||
.article-p;
|
||||
}
|
||||
pre, body#post article, body#collection article, body#subpage article, body#subpage #wrapper h1 {
|
||||
pre, body#post article, #post .alert, #subpage .alert, body#collection article, body#subpage article, body#subpage #wrapper h1 {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
textarea, pre, body#post article, body#collection article p {
|
||||
#collection header .alert, #post .alert, #subpage .alert {
|
||||
margin-bottom: 1em;
|
||||
p {
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
textarea, input#title, pre, body#post article, body#collection article p {
|
||||
&.norm, &.sans, &.wrap {
|
||||
line-height: 1.4em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap; /* CSS 3 */
|
||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||
white-space: -pre-wrap; /* Opera 4-6 */
|
||||
|
@ -493,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;
|
||||
}
|
||||
|
@ -595,10 +639,30 @@ table.classy {
|
|||
}
|
||||
}
|
||||
|
||||
article table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
th {
|
||||
border-width: 1px 1px 2px 1px;
|
||||
border-style: solid;
|
||||
border-color: #ccc;
|
||||
}
|
||||
td {
|
||||
border-width: 0 1px 1px 1px;
|
||||
border-style: solid;
|
||||
border-color: #ccc;
|
||||
padding: .25rem .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
body#collection article, body#subpage article {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
.book {
|
||||
h2 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
a.hidden.action {
|
||||
color: #666;
|
||||
float: right;
|
||||
|
@ -635,20 +699,22 @@ table.downloads {
|
|||
|
||||
select.inputform, textarea.inputform {
|
||||
border: 1px solid #999;
|
||||
background: white;
|
||||
}
|
||||
|
||||
input, button, select.inputform, textarea.inputform {
|
||||
input, button, select.inputform, textarea.inputform, a.btn {
|
||||
padding: 0.5em;
|
||||
font-family: @serifFont;
|
||||
font-size: 100%;
|
||||
.rounded(.25em);
|
||||
&[type=submit], &.submit {
|
||||
&[type=submit], &.submit, &.cta {
|
||||
border: 1px solid @primary;
|
||||
background: @primary;
|
||||
color: white;
|
||||
.transition(0.2s);
|
||||
&:hover {
|
||||
background-color: lighten(@primary, 3%);
|
||||
text-decoration: none;
|
||||
}
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
|
@ -678,6 +744,31 @@ input, button, select.inputform, textarea.inputform {
|
|||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
@ -740,15 +831,15 @@ input {
|
|||
margin: 0 auto 3em;
|
||||
font-size: 1.2em;
|
||||
|
||||
&.toosmall {
|
||||
max-width: 25em;
|
||||
}
|
||||
&.tight {
|
||||
max-width: 30em;
|
||||
}
|
||||
&.snug {
|
||||
max-width: 40em;
|
||||
}
|
||||
&.regular {
|
||||
font-size: 1em;
|
||||
}
|
||||
.app {
|
||||
+ .app {
|
||||
margin-top: 1.5em;
|
||||
|
@ -765,7 +856,7 @@ input {
|
|||
font-weight: normal;
|
||||
}
|
||||
p {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
li {
|
||||
margin: 0.3em 0;
|
||||
|
@ -820,20 +911,6 @@ input {
|
|||
text-align: center;
|
||||
}
|
||||
}
|
||||
div.features {
|
||||
margin-top: 1.5em;
|
||||
text-align: center;
|
||||
font-size: 0.86em;
|
||||
ul {
|
||||
text-align: left;
|
||||
max-width: 26em;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
li.soon, span.soon {
|
||||
color: lighten(#111, 40%);
|
||||
}
|
||||
}
|
||||
}
|
||||
div.blurbs {
|
||||
>h2 {
|
||||
text-align: center;
|
||||
|
@ -917,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 {
|
||||
|
@ -959,7 +1041,7 @@ footer.contain-me {
|
|||
}
|
||||
|
||||
li {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
|
||||
.item-desc, .prog-lang {
|
||||
font-size: 0.6em;
|
||||
|
@ -991,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;
|
||||
|
@ -1047,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 {
|
||||
|
@ -1197,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 {
|
||||
|
@ -1205,6 +1301,9 @@ header {
|
|||
}
|
||||
}
|
||||
}
|
||||
.right-side {
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
}
|
||||
.dash-nav {
|
||||
font-weight: bold;
|
||||
|
@ -1270,6 +1369,24 @@ form {
|
|||
font-size: 0.86em;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
&.prominent {
|
||||
margin: 1em 0;
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
}
|
||||
select {
|
||||
font-size: 1em;
|
||||
padding: 0.5rem;
|
||||
display: block;
|
||||
border-radius: 0.25rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
div.row {
|
||||
display: flex;
|
||||
|
@ -1279,6 +1396,16 @@ div.row {
|
|||
}
|
||||
}
|
||||
|
||||
.check, .blip {
|
||||
font-size: 1.125em;
|
||||
color: #71D571;
|
||||
}
|
||||
|
||||
.ex.failure {
|
||||
font-weight: bold;
|
||||
color: @dangerCol;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px) {
|
||||
body#post {
|
||||
header {
|
||||
|
@ -1345,7 +1472,7 @@ div.row {
|
|||
}
|
||||
|
||||
@media all and (max-width: 600px) {
|
||||
div.row {
|
||||
div.row:not(.admin-actions) {
|
||||
flex-direction: column;
|
||||
}
|
||||
.half {
|
||||
|
@ -1430,6 +1557,11 @@ div.row {
|
|||
margin-left: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
article {
|
||||
.hidden {
|
||||
.opacity(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
@ -1471,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;
|
||||
}
|
||||
}
|
|
@ -49,13 +49,13 @@
|
|||
url('/fonts/Lora-Bold.ttf') format('truetype'); /* Safari, Android, iOS */
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Lora';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
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 */
|
||||
url('/fonts/Lora-Italic.woff2') format('woff2'), /* Super Modern Browsers */
|
||||
url('/fonts/Lora-Italic.woff') format('woff'), /* Modern Browsers */
|
||||
url('/fonts/Lora-Italic.ttf') format('truetype'); /* Safari, Android, iOS */
|
||||
font-family: 'Lora';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
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 */
|
||||
url('/fonts/Lora-Italic.woff2') format('woff2'), /* Super Modern Browsers */
|
||||
url('/fonts/Lora-Italic.woff') format('woff'), /* Modern Browsers */
|
||||
url('/fonts/Lora-Italic.ttf') format('truetype'); /* Safari, Android, iOS */
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
@actionNavColor: #999;
|
||||
@actionNavColor: #767676;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
|
@ -58,7 +58,7 @@ header {
|
|||
}
|
||||
p {
|
||||
&.description {
|
||||
color: #666;
|
||||
color: #444;
|
||||
font-size: 1.1em;
|
||||
margin-top: 0.5em;
|
||||
line-height: 1.5;
|
||||
|
@ -113,7 +113,7 @@ textarea {
|
|||
ul {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1em;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
|
||||
&.collections, &.posts, &.integrations {
|
||||
list-style: none;
|
||||
|
@ -127,7 +127,6 @@ textarea {
|
|||
&.collection {
|
||||
a.title {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -206,7 +205,7 @@ code, textarea#embed {
|
|||
font-weight: normal;
|
||||
}
|
||||
p {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
li {
|
||||
margin: 0.3em 0;
|
||||
|
|
|
@ -63,7 +63,7 @@ body#pad, body#pad-sub {
|
|||
}
|
||||
}
|
||||
#belt {
|
||||
a {
|
||||
a, button {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
@ -100,7 +100,7 @@ body#pad, body#pad-sub {
|
|||
}
|
||||
}
|
||||
#belt {
|
||||
a {
|
||||
a, button {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -222,6 +222,13 @@ body#pad, body#pad-sub {
|
|||
font-style: italic;
|
||||
}
|
||||
}
|
||||
button {
|
||||
font-family: @sansFont;
|
||||
background-color: transparent;
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -249,7 +256,7 @@ body#pad {
|
|||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
textarea {
|
||||
textarea, #title {
|
||||
position: fixed !important;
|
||||
top: 3em;
|
||||
right: 0;
|
||||
|
@ -333,6 +340,15 @@ body#pad {
|
|||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
line-height: 1.5;
|
||||
|
||||
input[type=text].confirm {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.short {
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -354,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;
|
||||
|
@ -413,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) {
|
||||
|
|
|
@ -17,6 +17,16 @@ body {
|
|||
font-size: 1.6em;
|
||||
}
|
||||
}
|
||||
article {
|
||||
h2#title.dated {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
time.dt-published {
|
||||
display: block;
|
||||
color: #666;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -27,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;
|
||||
|
@ -39,7 +68,7 @@ body#post article, pre, .hljs {
|
|||
border-left: 4px solid #ddd;
|
||||
padding: 0 1em;
|
||||
margin: 0.5em;
|
||||
color: #777;
|
||||
color: #767676;
|
||||
display: inline-block;
|
||||
|
||||
p {
|
||||
|
@ -48,7 +77,7 @@ body#post article, pre, .hljs {
|
|||
}
|
||||
}
|
||||
.article-p() {
|
||||
line-height: 1.4em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap; /* CSS 3 */
|
||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||
white-space: -pre-wrap; /* Opera 4-6 */
|
||||
|
|
|
@ -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;
|
|
@ -0,0 +1,153 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
uuid "github.com/nu7hatch/gouuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var testDB *sql.DB
|
||||
|
||||
type ScopedTestBody func(*sql.DB)
|
||||
|
||||
// TestMain provides testing infrastructure within this package.
|
||||
func TestMain(m *testing.M) {
|
||||
rand.Seed(time.Now().UTC().UnixNano())
|
||||
gob.Register(&User{})
|
||||
|
||||
if runMySQLTests() {
|
||||
var err error
|
||||
|
||||
testDB, err = initMySQL(os.Getenv("WF_USER"), os.Getenv("WF_PASSWORD"), os.Getenv("WF_DB"), os.Getenv("WF_HOST"))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
if runMySQLTests() {
|
||||
if closeErr := testDB.Close(); closeErr != nil {
|
||||
fmt.Println(closeErr)
|
||||
}
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func runMySQLTests() bool {
|
||||
return len(os.Getenv("TEST_MYSQL")) > 0
|
||||
}
|
||||
|
||||
func initMySQL(dbUser, dbPassword, dbName, dbHost string) (*sql.DB, error) {
|
||||
if dbUser == "" || dbPassword == "" {
|
||||
return nil, errors.New("database user or password not set")
|
||||
}
|
||||
if dbHost == "" {
|
||||
dbHost = "localhost"
|
||||
}
|
||||
if dbName == "" {
|
||||
dbName = "writefreely"
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?charset=utf8mb4&parseTime=true", dbUser, dbPassword, dbHost, dbName)
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureMySQL(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func ensureMySQL(db *sql.DB) error {
|
||||
if err := db.Ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
db.SetMaxOpenConns(250)
|
||||
return nil
|
||||
}
|
||||
|
||||
// withTestDB provides a scoped database connection.
|
||||
func withTestDB(t *testing.T, testBody ScopedTestBody) {
|
||||
db, cleanup, err := newTestDatabase(testDB,
|
||||
os.Getenv("WF_USER"),
|
||||
os.Getenv("WF_PASSWORD"),
|
||||
os.Getenv("WF_DB"),
|
||||
os.Getenv("WF_HOST"),
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
assert.NoError(t, cleanup())
|
||||
}()
|
||||
|
||||
testBody(db)
|
||||
}
|
||||
|
||||
// newTestDatabase creates a new temporary test database. When a test
|
||||
// database connection is returned, it will have created a new database and
|
||||
// initialized it with tables from a reference database.
|
||||
func newTestDatabase(base *sql.DB, dbUser, dbPassword, dbName, dbHost string) (*sql.DB, func() error, error) {
|
||||
var err error
|
||||
var baseName = dbName
|
||||
|
||||
if baseName == "" {
|
||||
row := base.QueryRow("SELECT DATABASE()")
|
||||
err := row.Scan(&baseName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
tUUID, _ := uuid.NewV4()
|
||||
suffix := strings.Replace(tUUID.String(), "-", "_", -1)
|
||||
newDBName := baseName + suffix
|
||||
_, err = base.Exec("CREATE DATABASE " + newDBName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
newDB, err := initMySQL(dbUser, dbPassword, newDBName, dbHost)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rows, err := base.Query("SHOW TABLES IN " + baseName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var tableName string
|
||||
if err := rows.Scan(&tableName); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
query := fmt.Sprintf("CREATE TABLE %s LIKE %s.%s", tableName, baseName, tableName)
|
||||
if _, err := newDB.Exec(query); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cleanup := func() error {
|
||||
if closeErr := newDB.Close(); closeErr != nil {
|
||||
fmt.Println(closeErr)
|
||||
}
|
||||
|
||||
_, err = base.Exec("DROP DATABASE " + newDBName)
|
||||
return err
|
||||
}
|
||||
return newDB, cleanup, nil
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, ctx context.Context, db *sql.DB, count int, query string, args ...interface{}) {
|
||||
var returned int
|
||||
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)
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Copyright © 2019 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 (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// TODO: use now() from writefreely pkg
|
||||
func (db *datastore) now() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "strftime('%Y-%m-%d %H:%M:%S','now')"
|
||||
}
|
||||
return "NOW()"
|
||||
}
|
||||
|
||||
func (db *datastore) typeInt() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "INTEGER"
|
||||
}
|
||||
return "INT"
|
||||
}
|
||||
|
||||
func (db *datastore) typeSmallInt() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "INTEGER"
|
||||
}
|
||||
return "SMALLINT"
|
||||
}
|
||||
|
||||
func (db *datastore) typeTinyInt() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "INTEGER"
|
||||
}
|
||||
return "TINYINT"
|
||||
}
|
||||
|
||||
func (db *datastore) typeText() string {
|
||||
return "TEXT"
|
||||
}
|
||||
|
||||
func (db *datastore) typeChar(l int) string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "TEXT"
|
||||
}
|
||||
return fmt.Sprintf("CHAR(%d)", l)
|
||||
}
|
||||
|
||||
func (db *datastore) typeVarChar(l int) string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "TEXT"
|
||||
}
|
||||
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"
|
||||
}
|
||||
return "TINYINT(1)"
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
return " COLLATE utf8_bin"
|
||||
}
|
||||
|
||||
func (db *datastore) engine() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return ""
|
||||
}
|
||||
return " ENGINE = InnoDB"
|
||||
}
|
||||
|
||||
func (db *datastore) after(colName string) string {
|
||||
if db.driverName == driverSQLite {
|
||||
return ""
|
||||
}
|
||||
return " AFTER " + colName
|
||||
}
|
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* Copyright © 2019 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 contains database migrations for WriteFreely
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/writeas/web-core/log"
|
||||
)
|
||||
|
||||
// TODO: refactor to use the datastore struct from writefreely pkg
|
||||
type datastore struct {
|
||||
*sql.DB
|
||||
driverName string
|
||||
}
|
||||
|
||||
func NewDatastore(db *sql.DB, dn string) *datastore {
|
||||
return &datastore{db, dn}
|
||||
}
|
||||
|
||||
// TODO: use these consts from writefreely pkg
|
||||
const (
|
||||
driverMySQL = "mysql"
|
||||
driverSQLite = "sqlite3"
|
||||
)
|
||||
|
||||
type Migration interface {
|
||||
Description() string
|
||||
Migrate(db *datastore) error
|
||||
}
|
||||
|
||||
type migration struct {
|
||||
description string
|
||||
migrate func(db *datastore) error
|
||||
}
|
||||
|
||||
func New(d string, fn func(db *datastore) error) Migration {
|
||||
return &migration{d, fn}
|
||||
}
|
||||
|
||||
func (m *migration) Description() string {
|
||||
return m.description
|
||||
}
|
||||
|
||||
func (m *migration) Migrate(db *datastore) error {
|
||||
return m.migrate(db)
|
||||
}
|
||||
|
||||
var migrations = []Migration{
|
||||
New("support user invites", supportUserInvites), // -> V1 (v0.8.0)
|
||||
New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0)
|
||||
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
|
||||
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
|
||||
func CurrentVer() int {
|
||||
return len(migrations)
|
||||
}
|
||||
|
||||
func SetInitialMigrations(db *datastore) error {
|
||||
// Included schema files represent changes up to V1, so note that in the database
|
||||
_, err := db.Exec("INSERT INTO appmigrations (version, migrated, result) VALUES (?, "+db.now()+", ?)", 1, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Migrate(db *datastore) error {
|
||||
var version int
|
||||
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
|
||||
_, err = db.Exec(`CREATE TABLE appmigrations (
|
||||
version ` + db.typeInt() + ` NOT NULL,
|
||||
migrated ` + db.typeDateTime() + ` NOT NULL,
|
||||
result ` + db.typeText() + ` NOT NULL
|
||||
) ` + db.engine() + `;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(migrations[version:]) > 0 {
|
||||
for i, m := range migrations[version:] {
|
||||
curVer := version + i + 1
|
||||
log.Info("Migrating to V%d: %s", curVer, m.Description())
|
||||
err = m.Migrate(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update migrations table
|
||||
_, err = db.Exec("INSERT INTO appmigrations (version, migrated, result) VALUES (?, "+db.now()+", ?)", curVer, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Info("Database up-to-date. No migrations to run.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *datastore) tableExists(t string) bool {
|
||||
var dummy string
|
||||
var err error
|
||||
if db.driverName == driverSQLite {
|
||||
err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", t).Scan(&dummy)
|
||||
} else {
|
||||
err = db.QueryRow("SHOW TABLES LIKE '" + t + "'").Scan(&dummy)
|
||||
}
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return false
|
||||
case err != nil:
|
||||
log.Error("Couldn't SHOW TABLES: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright © 2019 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 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 ,
|
||||
max_uses ` + db.typeSmallInt() + ` NULL ,
|
||||
created ` + db.typeDateTime() + ` NOT NULL ,
|
||||
expires ` + db.typeDateTime() + ` NULL ,
|
||||
inactive ` + db.typeBool() + ` NOT NULL ,
|
||||
PRIMARY KEY (id)
|
||||
) ` + db.engine() + `;`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`CREATE TABLE usersinvited (
|
||||
invite_id ` + db.typeChar(6) + ` NOT NULL ,
|
||||
user_id ` + db.typeInt() + ` NOT NULL ,
|
||||
PRIMARY KEY (invite_id, user_id)
|
||||
) ` + db.engine() + `;`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright © 2019 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 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 {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE appcontent ADD COLUMN content_type ` + db.typeVarChar(36) + ` DEFAULT 'page' NOT NULL`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright © 2019 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 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 {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauth(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 {
|
||||
createTableUsersOauth, err := dialect.
|
||||
Table("oauth_users").
|
||||
SetIfNotExists(false).
|
||||
Column(dialect.Column("user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
|
||||
Column(dialect.Column("remote_user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
|
||||
ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createTableOauthClientState, err := dialect.
|
||||
Table("oauth_client_states").
|
||||
SetIfNotExists(false).
|
||||
Column(dialect.Column("state", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 255})).
|
||||
Column(dialect.Column("used", wf_db.ColumnTypeBool, wf_db.UnsetSize)).
|
||||
Column(dialect.Column("created_at", wf_db.ColumnTypeDateTime, wf_db.UnsetSize).SetDefaultCurrentTimestamp()).
|
||||
UniqueConstraint("state").
|
||||
ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, table := range []string{createTableUsersOauth, createTableOauthClientState} {
|
||||
if _, err := tx.ExecContext(ctx, table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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 migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthSlack(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(
|
||||
"provider",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
|
||||
dialect.
|
||||
AlterTable("oauth_client_states").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"client_id",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
|
||||
dialect.
|
||||
AlterTable("oauth_users").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"provider",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
|
||||
dialect.
|
||||
AlterTable("oauth_users").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"client_id",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
|
||||
dialect.
|
||||
AlterTable("oauth_users").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"access_token",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 512}).SetDefault("")),
|
||||
dialect.CreateUniqueIndex("oauth_users_uk", "oauth_users", "user_id", "provider", "client_id"),
|
||||
}
|
||||
|
||||
if dialect != wf_db.DialectSQLite {
|
||||
// This updates the length of the `remote_user_id` column. It isn't needed for SQLite databases.
|
||||
builders = append(builders, dialect.
|
||||
AlterTable("oauth_users").
|
||||
ChangeColumn("remote_user_id",
|
||||
dialect.
|
||||
Column(
|
||||
"remote_user_id",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 128})))
|
||||
}
|
||||
|
||||
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,32 @@
|
|||
/*
|
||||
* Copyright © 2019-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 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 {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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 oauthAttach(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(
|
||||
"attach_user_id",
|
||||
wf_db.ColumnTypeInteger,
|
||||
wf_db.OptionalInt{Set: true, Value: 24}).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,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
|
||||
}
|
32
nodeinfo.go
32
nodeinfo.go
|
@ -1,9 +1,19 @@
|
|||
/*
|
||||
* Copyright © 2018-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 (
|
||||
"github.com/writeas/go-nodeinfo"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writefreely/go-nodeinfo"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
@ -14,7 +24,10 @@ type nodeInfoResolver struct {
|
|||
|
||||
func nodeInfoConfig(db *datastore, cfg *config.Config) *nodeinfo.Config {
|
||||
name := cfg.App.SiteName
|
||||
desc := "Minimal, federated blogging platform."
|
||||
desc := cfg.App.SiteDesc
|
||||
if desc == "" {
|
||||
desc = "Minimal, federated blogging platform."
|
||||
}
|
||||
if cfg.App.SingleUser {
|
||||
// Fetch blog information, instead
|
||||
coll, err := db.GetCollectionByID(1)
|
||||
|
@ -32,9 +45,12 @@ 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,
|
||||
PublicReader: cfg.App.LocalTimeline,
|
||||
Invites: cfg.App.UserInvites != "",
|
||||
},
|
||||
Protocols: []nodeinfo.NodeProtocol{
|
||||
nodeinfo.ProtocolActivityPub,
|
||||
|
@ -78,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{
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue