Compare commits
1227 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15cb3cf156 | |||
| 613ccbcd94 | |||
| b9a09a2511 | |||
| 213628e400 | |||
| d6bb2bfb72 | |||
| 33bd290679 | |||
| 85ab5393fd | |||
| 98c73bb6df | |||
| 3e9d9a459f | |||
| a394c3f210 | |||
| a1a19e091a | |||
| ec030d500d | |||
| cfa74f84e7 | |||
| 97cbe7f446 | |||
| 7e4d081027 | |||
| 1e5ae92135 | |||
| 036ee6fac4 | |||
| 6028295748 | |||
| aa4cfd374d | |||
| 3303a4af84 | |||
| 37a136787b | |||
| 300ff37694 | |||
| c1d9d39dc1 | |||
| 93d6ee04d4 | |||
| ae7bf2e132 | |||
| 0020242571 | |||
| 4f796e788c | |||
| 3d192c1179 | |||
| 2f8d188d59 | |||
| 19766662f1 | |||
| 301aad3f73 | |||
| 92a8f8aa4c | |||
| 0c856a5252 | |||
| 2df6138ff1 | |||
| b2942f3f47 | |||
| 94f20c8fc2 | |||
| 5d48b93c8b | |||
| bbf2e00920 | |||
| c97361f5f4 | |||
| 7c799e8abf | |||
| d196e1dbd0 | |||
| 1679315322 | |||
| dd3a5f4a5b | |||
| 3580fb04fa | |||
| 699fdc30d9 | |||
| 704e9aa47f | |||
| d741238ccb | |||
| 9776374d17 | |||
| 2d10ddb9fa | |||
| e746a0b03f | |||
| 85cacf4239 | |||
| f138ae6ed9 | |||
| 399af4004a | |||
| d36f13e984 | |||
| 9a3699160d | |||
| 4103e7513d | |||
| ed9970b102 | |||
| afa875366e | |||
| 9696f04c64 | |||
| 40e1a1fc2c | |||
| ee1e553460 | |||
| d20ce6dd0b | |||
| 72f7909a42 | |||
| 4e1fb64868 | |||
| 85c1bfa300 | |||
| 172c78c41d | |||
| 3b08d5b485 | |||
| 832479a706 | |||
| 3b3148fa6b | |||
| b38d55f486 | |||
| 2804a490ed | |||
| 8c098def61 | |||
| e10ddb50c0 | |||
| 4df2c3e6f6 | |||
| 2f53fc78b6 | |||
| fded87654d | |||
| 08cd777f81 | |||
| 96b88353c5 | |||
| 302026feb9 | |||
| ba6d322da7 | |||
| 488563e9c1 | |||
| 130bb4c102 | |||
| 9368aebe70 | |||
| ca2843822e | |||
| bd91b4a346 | |||
| 35b951967d | |||
| 63d2cf91e9 | |||
| 263cf9e04f | |||
| 22ebecba67 | |||
| 903b48ed12 | |||
| a550291c85 | |||
| 47394fc620 | |||
| b180089b1b | |||
| a275aa5965 | |||
| 87edb2486c | |||
| 10617f3144 | |||
| 6654ad28b7 | |||
| 771d4325c2 | |||
| 1536a6d3f3 | |||
| 620726cc25 | |||
| 0eef7c0b89 | |||
| 321e40ea3f | |||
| a218b4ea4f | |||
| 9613ccd0c3 | |||
| 9493c1ad06 | |||
| e92ac1a13f | |||
| 1517b4d91e | |||
| 38cc4c043d | |||
| 05c1d727dc | |||
| 84645c7ed9 | |||
| 7c505bde7f | |||
| 9f543f1b6b | |||
| 0f7b882749 | |||
| f9f4375a40 | |||
| 12c2848cc7 | |||
| 4cfb3e2494 | |||
| 090b0a6f0d | |||
| 4502b77094 | |||
| 8f5a86206a | |||
| 340157f80d | |||
| 16b10695df | |||
| b8eb631aa3 | |||
| 5c9094fede | |||
| 4e2ca515ce | |||
| eccfbd3fbc | |||
| 8408342b5d | |||
| c47921bb25 | |||
| 03f470f04c | |||
| 5770c3b85b | |||
| c92f46b2c9 | |||
| 69eba69528 | |||
| c302d842e0 | |||
| f660220495 | |||
| 155df7bdf0 | |||
| 29055d1957 | |||
| d6ee49b880 | |||
| 248ed265c4 | |||
| a1f958ee7a | |||
| abf352b957 | |||
| 393f8e5e0c | |||
| 4dfe300ee3 | |||
| 65829094c9 | |||
| d5c3e6d6f0 | |||
| d702dd2fae | |||
| 0d855823c9 | |||
| 2dd33769d4 | |||
| 4ea9f6ecf1 | |||
| 3b0b6c4b0b | |||
| 4328fad5a3 | |||
| b26822c045 | |||
| f372282b04 | |||
| 145253ccbf | |||
| 485223a3dd | |||
| 7f75fa74e7 | |||
| 821fce1903 | |||
| ce484de61e | |||
| 35fb57718d | |||
| 35d12d7cae | |||
| 846154efe1 | |||
| 1ec7acbdfe | |||
| e384fdfcff | |||
| ed58e44d2e | |||
| f151dee339 | |||
| 61f25941e8 | |||
| 0628a14be6 | |||
| b46ae83377 | |||
| 70bc7f8edf | |||
| aff481b947 | |||
| 29ef73d307 | |||
| db205d0d9d | |||
| 57ab7edf23 | |||
| e4bd9d65cf | |||
| 17e4ddb32d | |||
| 4fd85b30f1 | |||
| 6148f29c66 | |||
| 1a3fad2d6a | |||
| 0945d3bc53 | |||
| 9a824f06c3 | |||
| eec09d79fe | |||
| 7f63d2a129 | |||
| 79b639c3e6 | |||
| efef208f53 | |||
| 27e0f755f6 | |||
| a9d7aae5d6 | |||
| 42e584a363 | |||
| 8c37ea3ec3 | |||
| 3c14fa0058 | |||
| ab94cca210 | |||
| ea62388985 | |||
| a9219efee4 | |||
| 776ed058c7 | |||
| aa3e196b8f | |||
| 52cb7270a9 | |||
| 66376afb36 | |||
| 96860be1be | |||
| 3bf61efc34 | |||
| d95549f58b | |||
| bf24e4878a | |||
| 9ae231fcef | |||
| c32acb2fcf | |||
| 770c77ee81 | |||
| aa3e4d7cf8 | |||
| 156a875f02 | |||
| cfed02bbcf | |||
| 4d3db9af73 | |||
| c1c606bc86 | |||
| f401949037 | |||
| e8dc0942e5 | |||
| 9fbafd8e79 | |||
| b9ea83a602 | |||
| 2ada5a83af | |||
| ec25599d1f | |||
| 57551610e2 | |||
| 8948b7acc1 | |||
| ccf7ff2bc9 | |||
| 39de967141 | |||
| 118cfd7166 | |||
| c2fd4ab3a5 | |||
| 70b5bee00f | |||
| de605deb1e | |||
| 116974f811 | |||
| c57f36ccca | |||
| 9def0355aa | |||
| 4e833c2061 | |||
| 5871ed7301 | |||
| 97632fdbfe | |||
| 1f8da7e63d | |||
| 76ca7c1462 | |||
| f06f444a13 | |||
| 10dfecf45c | |||
| a7b899817a | |||
| e0258003b9 | |||
| 2326eb77cd | |||
| 504d41d887 | |||
| 5a7d5e8099 | |||
| 74a1daac8c | |||
| 1f855601ea | |||
| f22c4d5c78 | |||
| ce4b216722 | |||
| 9016995d92 | |||
| 853a1db028 | |||
| 712ee30a1f | |||
| 9e5f9255d1 | |||
| 2e35441483 | |||
| 5c74f598d8 | |||
| 5d711dc47c | |||
| 9ae3057106 | |||
| b7ea154e51 | |||
| 692e6b1c82 | |||
| 528f1bac48 | |||
| 35aa2374c4 | |||
| 3eb7662aef | |||
| de4fcaee93 | |||
| 812fd3d956 | |||
| 5d3b480790 | |||
| 2f1801acae | |||
| 0404528908 | |||
| 4529b929d8 | |||
| 889decc720 | |||
| db0f1a3c46 | |||
| ef57ef91f0 | |||
| 073b72c9ed | |||
| 45a6744d4d | |||
| 4d19861a25 | |||
| f5906cacf3 | |||
| 03ba77a577 | |||
| 0fc7372781 | |||
| 6c2846980a | |||
| 0685c59bf3 | |||
| 5f629195f8 | |||
| 13eeedb620 | |||
| a076c132ca | |||
| a76e0dfe5b | |||
| 8faac20977 | |||
| b04edfa05e | |||
| 1f62bf27f8 | |||
| 1e0d1fb97a | |||
| 19d30c12d1 | |||
| 384474930c | |||
| 4cb64e0a8c | |||
| cd2a2df48d | |||
| cbf960500b | |||
| 3d434f1923 | |||
| 52022fb597 | |||
| d96940c848 | |||
| b17884681d | |||
| 78a001ac89 | |||
| 9b04fb96e6 | |||
| d23002b817 | |||
| 6282b98b03 | |||
| d75600ba14 | |||
| e6ea302319 | |||
| df005a28f8 | |||
| 15134eed60 | |||
| 7dd56a71e3 | |||
| 06d2f68ecd | |||
| 9a640b3438 | |||
| 9791607793 | |||
| ccd3c8a3f2 | |||
| 6bbadc78b0 | |||
| e1673787b4 | |||
| 1dd176dd80 | |||
| 5c59687cb8 | |||
| a24e3c46e6 | |||
| 267fecba66 | |||
| fc99d2b7a0 | |||
| 1b32fa1e34 | |||
| ce42524273 | |||
| 595fa05660 | |||
| f8a0dff526 | |||
| 06d216c7ed | |||
| f44bca30f4 | |||
| e4180b3b38 | |||
| 992a482b96 | |||
| 0ad845e0f7 | |||
| ee97213c90 | |||
| 6d919da049 | |||
| 6c615d01ad | |||
| f8870af9fe | |||
| 2fe2505a01 | |||
| e41fa353e4 | |||
| effdc44943 | |||
| fd341bdb22 | |||
| 68c794c54b | |||
| 7b3b00be23 | |||
| 41ccacc5d3 | |||
| 4ef9350ce7 | |||
| 5d08ff6c3b | |||
| 01dca62ce5 | |||
| 6ab1ecd57b | |||
| b13444895f | |||
| 771c157fe5 | |||
| b5e1076b0e | |||
| 6cc43c2420 | |||
| f365041a45 | |||
| ae9c9262f7 | |||
| 40ce515e6c | |||
| bc96af7f5f | |||
| 811c20c8fb | |||
| 4b4c22cf8a | |||
| 0524b0b153 | |||
| cd6c57b9c5 | |||
| f608f7a4d6 | |||
| 803680186b | |||
| 68a01d5f9b | |||
| 5b3a472b66 | |||
| 2a85f775e9 | |||
| a958300a58 | |||
| a8be31b177 | |||
| 6cd68ab8b0 | |||
| a589435f4f | |||
| 39b49c707e | |||
| c4bb1f771b | |||
| 28440271bb | |||
| 0ab7774e29 | |||
| 33afe9111e | |||
| d8a2e1925f | |||
| 2804f44a06 | |||
| 2165c286ae | |||
| 8cbf410faf | |||
| c521a81373 | |||
| 7ade0550c9 | |||
| 41bc2d6949 | |||
| de6e9c0e2e | |||
| 38ebc9ea41 | |||
| 8f976be998 | |||
| e5a2850105 | |||
| 85727c6d4c | |||
| 87247a23b3 | |||
| 61785364e3 | |||
| 76f688c967 | |||
| 05df3b89a1 | |||
| 4e42a34337 | |||
| 62372201e0 | |||
| 036913a828 | |||
| b2a889b9e4 | |||
| fae8338772 | |||
| 79b5d9a690 | |||
| 3e54d10981 | |||
| a1c3bfb646 | |||
| b2528c21ff | |||
| fcc9e1d81b | |||
| 3093f713ef | |||
| 4ea29d29a0 | |||
| 6b8d90d8b6 | |||
| bd3e6a5a91 | |||
| 46f4676efb | |||
| c814ac5681 | |||
| 0887399048 | |||
| f2a2bf2b23 | |||
| e2702a187b | |||
| d78a57ce47 | |||
| 10acbdd41f | |||
| 73009818f2 | |||
| fb5027becd | |||
| 86609b51fa | |||
| 44799e94fd | |||
| f14c307786 | |||
| 174624f5c1 | |||
| 5f91345d69 | |||
| 9ca975113c | |||
| 38a55857c6 | |||
| 9343d3a120 | |||
| c5656971c9 | |||
| ed55b66253 | |||
| 713ffb9506 | |||
| 9969e844ca | |||
| 0c61dca9ca | |||
| 957725fbf8 | |||
| 1f6361a9a2 | |||
| cf870971d1 | |||
| 08ac7227b5 | |||
| 88eb61c320 | |||
| 1c1dbd481a | |||
| 86b4f622ea | |||
| f854bc5838 | |||
| 489156f4a3 | |||
| 01e8b0bce8 | |||
| 9183d04e66 | |||
| 5e463e2cc9 | |||
| 6e2bff10f7 | |||
| 3e9d9a81b7 | |||
| 98e0754976 | |||
| da7870eeba | |||
| c1562f3868 | |||
| e0390cb105 | |||
| 32cd91cfb9 | |||
| 991dfccf3b | |||
| 16e012ba00 | |||
| 871618f45d | |||
| 680d321a2e | |||
| c37ff54857 | |||
| d4018d61d4 | |||
| 21a0059755 | |||
| 05f4c186f4 | |||
| 53512a6167 | |||
| 7cf7700ef7 | |||
| 216855d3a7 | |||
| 23f273e5e8 | |||
| 70949fad02 | |||
| 1f5ce8e504 | |||
| 2316d36e03 | |||
| 2b1ddc71ac | |||
| b9dac1a21a | |||
| 95fb5a3c71 | |||
| 75b43a738f | |||
| 5bd467c4c1 | |||
| 74d6dc5089 | |||
| 994a4dbb2d | |||
| 67996cc938 | |||
| f5e776c4d7 | |||
| e27fc47287 | |||
| 0ed91b89ff | |||
| 00862790a1 | |||
| ab6f39c192 | |||
| 4edc201c14 | |||
| a1a7acfe94 | |||
| 6b5a1d2130 | |||
| 85e35fdb5d | |||
| da9e13622c | |||
| 8f4dd8a57b | |||
| 78b0535063 | |||
| 6323c7aef8 | |||
| 7f0ad56d07 | |||
| e7eea3901f | |||
| 0979471e54 | |||
| 8d69051a61 | |||
| 55ca1345e1 | |||
| ad951ca842 | |||
| cb8e2e9294 | |||
| 038d65acaa | |||
| e392a89526 | |||
| d62f51665b | |||
| e42aa6fe8e | |||
| ab126563f3 | |||
| c1b9ebdae6 | |||
| d3e11c78d7 | |||
| 4ccfec8019 | |||
| bb5157637d | |||
| 456df3e535 | |||
| f0112850fa | |||
| a6a21d5dfa | |||
| 249fbbe891 | |||
| e925865767 | |||
| 28643fc2c2 | |||
| 3db10a09bb | |||
| a80a95d471 | |||
| e407d58ee9 | |||
| a6d839a766 | |||
| f3b67ab6c9 | |||
| 66f5628a27 | |||
| 4b3b5c1f40 | |||
| 3e687f3af0 | |||
| 119d3e4f6a | |||
| a21d66178e | |||
| 52967f3e47 | |||
| 29439f9d02 | |||
| bc72a4c2d1 | |||
| 3ded0e2166 | |||
| 27c10e5e5c | |||
| 816aefe72a | |||
| bff50f8e4c | |||
| be1c22815b | |||
| 08ab7ffd08 | |||
| 8709f6cf9f | |||
| 04cae95635 | |||
| 5d37b2534a | |||
| eafafdaadf | |||
| 6897b8fa58 | |||
| 16d3279d72 | |||
| 36c76c534d | |||
| 26f460be89 | |||
| b9fb13104a | |||
| 5cc411158f | |||
| ca69c93531 | |||
| 95cb7cc904 | |||
| 0a62fa46aa | |||
| 2e60410969 | |||
| a12d3a591b | |||
| 3cf7c67b6d | |||
| 2cf79f31b7 | |||
| 24cf941303 | |||
| 38cf9b5496 | |||
| b9607b32ac | |||
| 3393da2560 | |||
| 7566f94690 | |||
| ee8312fb57 | |||
| 76219704f3 | |||
| 2a43d4e88a | |||
| f0ce073a37 | |||
| 8047196394 | |||
| 3cf52b3985 | |||
| ac378e448b | |||
| daae2038f8 | |||
| a2356c6e59 | |||
| 44b91c6f07 | |||
| 144565d13e | |||
| 07fd66863d | |||
| 385a5f7c33 | |||
| 8c8c2edc66 | |||
| f7e393bded | |||
| 1df25e34b0 | |||
| ed491bad21 | |||
| 306f2d5738 | |||
| 2196cb95c0 | |||
| b4d494a5c7 | |||
| e8432f575e | |||
| eb48723c08 | |||
| 65168202b4 | |||
| dced3cf881 | |||
| 170fd6026c | |||
| 1ccaa817b3 | |||
| 65ba083720 | |||
| dba902d262 | |||
| d52c7a3afa | |||
| c63f88fb7f | |||
| 4412e0598f | |||
| eb22c1168e | |||
| 917eda356d | |||
| bc6580bbdc | |||
| 920cf622c5 | |||
| 13dcb193dc | |||
| 3afb724fed | |||
| 9662936b44 | |||
| 4780472d48 | |||
| 6f68c4504b | |||
| 28e0cdfe63 | |||
| a5003526c8 | |||
| ec3d78b509 | |||
| 4205e38605 | |||
| 8438d48c71 | |||
| 52faf5996b | |||
| 69eccc50a3 | |||
| 54cbdb236f | |||
| 34c374de1a | |||
| 113722e4ba | |||
| 3b429909f1 | |||
| f1cdf4552f | |||
| 7d320e57da | |||
| e1a598a459 | |||
| 6107842303 | |||
| 65372d2018 | |||
| 4842385ca6 | |||
| 05f55fc1ca | |||
| e8153d4b42 | |||
| 2087a659f9 | |||
| 1770336c11 | |||
| 9c177f6286 | |||
| 93a2c6d99f | |||
| 5ef76873b7 | |||
| b97c3fdb87 | |||
| 64838ad864 | |||
| 4df2ce5744 | |||
| c1f42836d9 | |||
| 0cbc9438d4 | |||
| ca6cd534d8 | |||
| f529e803ef | |||
| e5bc84badf | |||
| e2077bed59 | |||
| 9ab9d29efb | |||
| 5373a674e1 | |||
| bfaa2fafaf | |||
| d4a13a13d4 | |||
| 92c0368dd8 | |||
| 80c0426768 | |||
| 71b21289ab | |||
| fa861ff314 | |||
| 400d2dee32 | |||
| 3993dda17d | |||
| b1255efdcd | |||
| 22036c6a94 | |||
| f2df4b7d7d | |||
| 7c57bf78a1 | |||
| 8d898ff477 | |||
| a1045dbce9 | |||
| 23a07f3f7b | |||
| 458d87fef1 | |||
| 82df86d09e | |||
| 858cad2995 | |||
| c0483cf12e | |||
| 57a54cf016 | |||
| 325d8cde08 | |||
| 9e2c76c3bc | |||
| 996b161c1e | |||
| 831ef88431 | |||
| 89517e5988 | |||
| 48dbcf75a9 | |||
| a56a9bc9c5 | |||
| 918103fa29 | |||
| c9b8f5a739 | |||
| d58ff36d80 | |||
| 00d647c0ad | |||
| a27f196578 | |||
| abe82b79ce | |||
| 95230c3a23 | |||
| eade69a12c | |||
| 4f89e214ef | |||
| 2936679326 | |||
| 18a67fe1b5 | |||
| ba29c8ef6f | |||
| d253f1a020 | |||
| 03060d6ee2 | |||
| ac8ad3aae2 | |||
| 14e294efed | |||
| ec3205b372 | |||
| 45119d9a8c | |||
| 1065078f75 | |||
| 0ce904a985 | |||
| 254eec8e6a | |||
| 0e4cb4f6e1 | |||
| 9b05ac90df | |||
| f28a7fa508 | |||
| 65e95d8998 | |||
| 808b8f8e98 | |||
| 43b46a8be4 | |||
| 9bbfc71fc8 | |||
| 5d58b31f1c | |||
| e31a2238fb | |||
| 7de37bc9b7 | |||
| 13f7734751 | |||
| b4395bce99 | |||
| 7c82b08615 | |||
| 6498dbfbb7 | |||
| 74254aed4a | |||
| 8c48abf48e | |||
| 8958226604 | |||
| 005a6db230 | |||
| 4397abd8ab | |||
| e53882f555 | |||
| 5d5e61dfa1 | |||
| c5c6b70a89 | |||
| 6778a0e943 | |||
| 677e238c6d | |||
| b0bc2372fa | |||
| 6a808c7cc5 | |||
| d53543ccb1 | |||
| 88d7d54601 | |||
| 0f0c896887 | |||
| 65233c0a9a | |||
| 32e1e4788f | |||
| 181a78876b | |||
| 61d5446113 | |||
| c786569171 | |||
| d83a75e3f4 | |||
| a6f06559ea | |||
| 2084145dd3 | |||
| dd54058516 | |||
| 4056a54d44 | |||
| 191cd11741 | |||
| 800e74da67 | |||
| 237da47950 | |||
| ec12539fd0 | |||
| a537db559b | |||
| 2ba158df67 | |||
| c0c066547f | |||
| c3f59b14b9 | |||
| 1d06a8f1ad | |||
| efaf1295e9 | |||
| 4bc9cf3ad1 | |||
| 1e3851ea69 | |||
| b6d38536e3 | |||
| 9b4c678aa9 | |||
| a65775d85b | |||
| 192c7677c3 | |||
| 2a31a7b601 | |||
| 355fd7cb1d | |||
| 40efd73dfc | |||
| 31b144c76d | |||
| 31a46514cb | |||
| c8d906eb99 | |||
| 2895a1c819 | |||
| a4a5d08662 | |||
| b97c9d2165 | |||
| 5b7e8a69a5 | |||
| 9601e99e33 | |||
| d9a59f1b07 | |||
| b6a6af906a | |||
| 2c4799ce27 | |||
| b33b19849c | |||
| e398f36c57 | |||
| ee6064eee8 | |||
| 9d012c8f3c | |||
| 8888dbba0a | |||
| 6f8d5c1eb4 | |||
| 62da4a3d5c | |||
| 5cfc8e71a5 | |||
| a599760891 | |||
| 00324f668f | |||
| d4549704b9 | |||
| 0836e3d693 | |||
| 0058c3053d | |||
| 2a1a0a23a5 | |||
| 5614e3bd59 | |||
| acbda3cde1 | |||
| 0755436458 | |||
| 3daf405ae2 | |||
| 53dc3b0c03 | |||
| 371dcc5091 | |||
| 905fe54fa3 | |||
| 62c0827ff5 | |||
| d6c65ce81a | |||
| 5532b4a4d7 | |||
| 637bd3347b | |||
| bac373a818 | |||
| f0e7ea5640 | |||
| 4b981e0fad | |||
| 8fb9d861de | |||
| 199269ba3c | |||
| 0b9ec4c52c | |||
| 0e51565cc8 | |||
| 0418d35b67 | |||
| bf9d25363b | |||
| 84f00c57d1 | |||
| 7c1a5421fa | |||
| 3815bfe980 | |||
| de448c3192 | |||
| 967e2dfde6 | |||
| dd3c1eac5f | |||
| abc0a794c1 | |||
| ef628aa498 | |||
| 011bd9602d | |||
| b0745cfd82 | |||
| d1b1d9f507 | |||
| 742d545f1c | |||
| 67dec4df22 | |||
| 33f3bbb774 | |||
| 7c1df80695 | |||
| 2adbb6f74c | |||
| 79c05e8381 | |||
| 31a1591043 | |||
| 0ede2ab3ab | |||
| 43656d8e46 | |||
| b7cc2369a7 | |||
| 05a98175bf | |||
| 346a67fe1c | |||
| a22f5f2336 | |||
| 55439990c6 | |||
| 3b0f28d061 | |||
| ac3acfb4ac | |||
| d5256e9ffc | |||
| 13be46445c | |||
| c67b702425 | |||
| e01539ef16 | |||
| de4380fd34 | |||
| 5651e11ba1 | |||
| 5815602309 | |||
| a0a69dfb22 | |||
| d98132db80 | |||
| 25fe2ad802 | |||
| 48fab8ad2c | |||
| a7d8d49faf | |||
| 388acd6738 | |||
| d3c035aa39 | |||
| 12a8d00f8e | |||
| b9ea06a01a | |||
| 1e67b3c13c | |||
| 5a5c8bdac8 | |||
| 44f9d36df1 | |||
| 7d349c2de6 | |||
| 79715ec7c7 | |||
| f4d7dfb261 | |||
| c525410062 | |||
| af5b0b961b | |||
| 2f7a5cbf56 | |||
| 1506802c20 | |||
| 897ea8e11e | |||
| 858806149a | |||
| 0da9572627 | |||
| 94cc260803 | |||
| 7198b06a33 | |||
| 81006e1db8 | |||
| 0f7094a70e | |||
| a0b661fffe | |||
| 4bef91f08b | |||
| 20c17be124 | |||
| ae3344f318 | |||
| 9187e4dde9 | |||
| 89c185f819 | |||
| 09b9a37720 | |||
| 12a8bfcf2d | |||
| b2be00b125 | |||
| 914d394bd0 | |||
| 811db1be0e | |||
| cd81f042b9 | |||
| c6111fcd28 | |||
| 82ebdc023c | |||
| 7b110179a9 | |||
| afd66ce7cb | |||
| 9dac97045b | |||
| 188d4ac063 | |||
| 0fbefe2cdc | |||
| eedd5fe4e9 | |||
| 27a1a56223 | |||
| 8e3322776a | |||
| 3d4336c548 | |||
| 0c8c607aa4 | |||
| 60d6734fe2 | |||
| 8c372aa6fc | |||
| d1a74ca8e6 | |||
| c374e0af4c | |||
| 58e8569048 | |||
| 83dbf2a945 | |||
| d7d6d5f644 | |||
| 9aa9885a89 | |||
| 7fe1e083e0 | |||
| a946823554 | |||
| 3a1872c03e | |||
| 5424f9110f | |||
| a597816617 | |||
| fa7a44f6bb | |||
| dfcdcc1833 | |||
| 6345a57498 | |||
| 87457c0ed1 | |||
| 859a1fd528 | |||
| 5069aab584 | |||
| 033fde38fe | |||
| 7b2bab0f9d | |||
| a419ef5319 | |||
| f77dce9f12 | |||
| 4c2cd92f0d | |||
| dd932e1f15 | |||
| 4376810d96 | |||
| c961f4751b | |||
| 9e5ec0c9df | |||
| 112c034e27 | |||
| 664a3ddeea | |||
| 2ffd357d95 | |||
| 2bc4a13964 | |||
| 58324945cc | |||
| 16953ea907 | |||
| d3c2dc8286 | |||
| ebb0b45299 | |||
| 702aa11ecf | |||
| 33221d386e | |||
| 589c159eb9 | |||
| ea1f4d48d5 | |||
| 462c5a1d42 | |||
| f90d7ddee3 | |||
| 175055cf9d | |||
| fe92d95f6c | |||
| 8e50d95a7a | |||
| 9ed36b2aa3 | |||
| 722165a734 | |||
| 74f99e2588 | |||
| 77c08845b5 | |||
| 2bfc26faf2 | |||
| c26f2623a8 | |||
| 451c3b5c88 | |||
| af5674074d | |||
| 28380021a7 | |||
| 37cbf537bb | |||
| 9345a1b7a9 | |||
| d18f1d6d1d | |||
| 621258481d | |||
| ce285bac17 | |||
| a7e45bee11 | |||
| 73e075e216 | |||
| b7c7b6da9f | |||
| 703328601c | |||
| b2e7664339 | |||
| ed22c6b103 | |||
| 94ccbd6190 | |||
| 77edc4849f | |||
| 07da97f434 | |||
| 89dd6be7dd | |||
| 60c1a4264e | |||
| c4c177c530 | |||
| 4a5bffbd57 | |||
| 49b2296015 | |||
| 0ef4a06565 | |||
| 16b1337d67 | |||
| 6599179174 | |||
| f2411f4607 | |||
| 2c3d429b8e | |||
| 7562cf1e75 | |||
| de1c3f3245 | |||
| 5d38c3ad70 | |||
| b55c2ff2f0 | |||
| 056d68804a | |||
| 347bc653d8 | |||
| 6fcece3bc0 | |||
| e119c5bde9 | |||
| 1bc2749e1b | |||
| 58bb07d11d | |||
| affe24b9c6 | |||
| 0bcf063b05 | |||
| 24ecb15119 | |||
| 208c515d70 | |||
| d0ada7fc53 | |||
| d86bd898cb | |||
| db581a955b | |||
| 0b8b1bf25f | |||
| faab5f6b3b | |||
| 6387a1334b | |||
| 10be055381 | |||
| 24c9c7b5a1 | |||
| ac48744aca | |||
| f2d2ecb247 | |||
| 1d46bf8d50 | |||
| 4353f2a92d | |||
| 1b9aeae53c | |||
| 540b9af873 | |||
| d08d2c6958 | |||
| 348259a14b | |||
| e68ca1151d | |||
| 3c15f5a82f | |||
| 5ba823990a | |||
| c82c38fe52 | |||
| e7ab80d5c8 | |||
| 61b68c3a42 | |||
| 6401cb6fb7 | |||
| 9d37408535 | |||
| ded64e1cf0 | |||
| 3b766fc427 | |||
| ad285898f6 | |||
| 78be49d57d | |||
| 25e52788c5 | |||
| 5d25a64f13 | |||
| 0dc89fbf23 | |||
| 94ed0eec2a | |||
| d70e585b64 | |||
| 8f55e50c6e | |||
| c113c99e1d | |||
| 191239584b | |||
| bd1caaf5da | |||
| 76f7b5e7ac | |||
| afe449b951 | |||
| 29f0712ea1 | |||
| d091c24f79 | |||
| f9e7d2bb23 | |||
| dbf36ddf05 | |||
| 8b00853e88 | |||
| 84a3d53075 | |||
| 7967dd91c2 | |||
| b5eb2628f9 | |||
| 4ef6561cc6 | |||
| 7f54f418a8 | |||
| 8f90ea18bf | |||
| e12528aae4 | |||
| 042a1fd2b0 | |||
| be99fe5f99 | |||
| 4a212e5f9e | |||
| c253bdee54 | |||
| 46fc030df4 | |||
| 02277b1ace | |||
| ac507d1d42 | |||
| 5148349098 | |||
| e817941cc5 | |||
| 06962514fe | |||
| c3c8f6804b | |||
| 6dd730030d | |||
| 6c1dd6608d | |||
| 42fb0cf825 | |||
| 2483303138 | |||
| 47e67d9b64 | |||
| 1d042e1bb2 | |||
| 31418b1079 | |||
| 68d79bfa9c | |||
| a46b718937 | |||
| 5570494a37 | |||
| a0cd9dd6bd | |||
| cd5895d560 | |||
| fc8ee1c3bc | |||
| 2a8cc5f3ba | |||
| 82524c9dca | |||
| 864379fb81 | |||
| aae2073146 | |||
| 69963689f9 | |||
| 6ad3359609 | |||
| 6a2ba2260d | |||
| ccbf2124cb | |||
| a733ece26e | |||
| 16624fd742 | |||
| e18be6ec95 | |||
| 1bfed0294f | |||
| d4d32bcc11 | |||
| 0a148721de | |||
| cfe097edf4 | |||
| 578768d7df | |||
| 996dc309f7 | |||
| 09d9164a1c | |||
| 43f32d30cb | |||
| b1d7b3969d | |||
| 89fa208bec | |||
| a11205324b | |||
| 7d17b12000 | |||
| 885dcf91fe | |||
| 192c0f8efd | |||
| 35e71b0f8a | |||
| 1a24ba44b2 | |||
| 7c00d4a88a | |||
| 476310a217 | |||
| 26ffde726f | |||
| 3fd89e6b48 | |||
| be939cf169 | |||
| 4a15bef34f | |||
| 6eee4b0b65 | |||
| f720dcbe9a | |||
| 33fceddc08 | |||
| dfe83aafc5 | |||
| 0775c8f3f9 | |||
| 4cdc1a3655 | |||
| bbd415c392 | |||
| a236f33269 | |||
| 9b593c76e4 | |||
| 012c8d2026 | |||
| cb05571339 | |||
| 64e4550dea | |||
| ec8c17482d | |||
| f167aad0fb | |||
| 24aac22b89 | |||
| 615a5fe346 | |||
| a6699a7319 | |||
| 0f408bc738 | |||
| 44a7953c20 | |||
| 21069ea18e | |||
| 1e1fd49c8c | |||
| 84621d1d27 | |||
| ba4476bf5a | |||
| f87bc455b7 | |||
| 76ec0d2b13 | |||
| 4d547082e0 | |||
| b9b9af6c44 | |||
| 4a4534d6f3 | |||
| 894c4fee2c | |||
| 27a33c833d | |||
| b52d8e4e26 | |||
| b059fb48fc | |||
| f33b057961 | |||
| 663ca4f530 | |||
| 5892c0b088 | |||
| 044a24eac8 | |||
| b93ebedaf3 | |||
| 8539bdaf72 | |||
| c1abd3d6f9 | |||
| becb40544c | |||
| ee96d2b754 | |||
| 30093d3a9f | |||
| 29f2239e3f | |||
| 7f1b33a567 | |||
| d0dd23a1af | |||
| f8774223d4 | |||
| bca870f1f8 | |||
| 64d38c88e8 | |||
| 3ac94b3e43 | |||
| 254eef6a2a | |||
| f1255f477f | |||
| 6c7b95c1b9 | |||
| e7d17caf9d | |||
| 71dd80f125 | |||
| ed4761e841 | |||
| fe05f9a58e | |||
| 51e5a401a5 | |||
| f1990b782d | |||
| 4e3ed0f4c1 | |||
| fdb58215c0 | |||
| 47c8711933 | |||
| 20a099c16f | |||
| 3824e4ba25 | |||
| b163fc8423 | |||
| b50f723a4d | |||
| b778933c64 | |||
| 12b97ce713 | |||
| 4025a45d52 | |||
| 8a6d832513 | |||
| 0b0b3a158f | |||
| 6b745ca8f0 | |||
| 1f0cb7e05c | |||
| c59821945d | |||
| 1a723b78b7 | |||
| e7ff7e3263 | |||
| 3a6c1aeeef | |||
| 567e011a66 | |||
| eaf63e0b17 | |||
| 63b31a9154 | |||
| 5885cb5b09 | |||
| f5ca464b16 | |||
| 1c40d5d3bc | |||
| 7e1abc527f | |||
| 39b2ef72fd | |||
| 17b081a21f | |||
| c482b1b552 | |||
| 8b16b9aaa4 | |||
| 11acc4172c | |||
| 6187c62cb3 | |||
| d6b5b9a721 | |||
| 80675f85f3 | |||
| 07a6be424f | |||
| 8cc56d8d81 | |||
| f8cabcc904 | |||
| d6a946f5b9 | |||
| 2a5a2ec4d2 | |||
| 106e9417d3 | |||
| 2660efec16 | |||
| 1eaa5a6e31 | |||
| b2c26f03e8 | |||
| a75cdaa870 | |||
| 2047355d46 | |||
| 17bbcd1a4b | |||
| 5c8170a97d | |||
| beaeaf743a | |||
| fe641620a6 | |||
| 3e1a6e61ad | |||
| 45b46838ae | |||
| 46cdde8687 | |||
| 6b0dfb729c | |||
| 75a4d1abf1 | |||
| 984c0cda6b | |||
| 902612a470 | |||
| e311d4d423 | |||
| aa33c8e7d3 | |||
| 60e6d49319 | |||
| 6dbd9b76fb | |||
| e3877d3022 | |||
| 4c11df65dc | |||
| f8cfb8cacf | |||
| 9a7e10b560 | |||
| e131330175 | |||
| b9066cb7d0 | |||
| 4c4d9fafd5 | |||
| bbdf2a8293 | |||
| 0d3e1a233f | |||
| 712bc2afbe | |||
| 2eadb80435 | |||
| de05b9e176 | |||
| 5bf1e81f0b | |||
| 55abbff9b8 | |||
| 5f89401a7c | |||
| 7bd3c1b01e | |||
| 961806247c | |||
| ac7ffae121 | |||
| 5321f3cf18 | |||
| 5099a00c6b | |||
| d44c034f6a | |||
| 963e742dcf | |||
| 50462204e8 | |||
| 174ed738bd | |||
| f8d098f353 | |||
| f5b18dffaf | |||
| 002d1f1720 | |||
| 23a5634035 | |||
| 5cd8ae9106 | |||
| 350697f89a | |||
| 569979374f | |||
| 3c666080a9 | |||
| 4e6d7b70cf | |||
| a59f167771 | |||
| f28ab19dc0 | |||
| 6dd4080d65 | |||
| eac76e6806 | |||
| 0cd26dfbf4 | |||
| 3de009713d | |||
| 69bcb01715 | |||
| 9ec2d93f50 | |||
| 4da9b24cb1 | |||
| f40634aa97 | |||
| 83282e0736 | |||
| 43fde90387 | |||
| ccba8163c9 | |||
| 8975b0f9e9 | |||
| d626f3366d | |||
| b24f195e10 | |||
| 003dcf861a | |||
| 5d8efa77a3 | |||
| fb6d49cc6e | |||
| 48776939f4 | |||
| 104b6c1d0c | |||
| 3881927550 | |||
| 0bec13edc0 | |||
| 191d6da486 | |||
| 9aaff1a484 | |||
| 36fd55a7e4 | |||
| fc474bf8d1 | |||
| a050deb557 | |||
| 6de9a1f1c8 | |||
| 5e30bede40 | |||
| 5cca66b346 | |||
| 484659fde2 | |||
| 5fc827c1c9 | |||
| 50753b3a65 | |||
| c5d03d300b | |||
| 010eac6c4a | |||
| a107d35492 | |||
| f9beb2383b | |||
| 7e78cffcaa | |||
| 719fec0828 | |||
| 90fdade566 |
@@ -0,0 +1,10 @@
|
||||
[target.wasm32-unknown-unknown]
|
||||
# required for clippy
|
||||
rustflags = [
|
||||
"--cfg", "web_sys_unstable_apis",
|
||||
]
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
rustflags = [
|
||||
"--cfg", "web_sys_unstable_apis",
|
||||
]
|
||||
+18
-12
@@ -10,8 +10,8 @@ executors:
|
||||
type: boolean
|
||||
default: false
|
||||
docker:
|
||||
- image: plumeorg/plume-buildenv:v0.0.9
|
||||
- image: <<#parameters.postgres>>circleci/postgres:9.6-alpine<</parameters.postgres>><<^parameters.postgres>>alpine:latest<</parameters.postgres>>
|
||||
- image: plumeorg/plume-buildenv:v0.8.0
|
||||
- image: <<#parameters.postgres>>cimg/postgres:14.2<</parameters.postgres>><<^parameters.postgres>>alpine:latest<</parameters.postgres>>
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: plume
|
||||
@@ -21,6 +21,7 @@ executors:
|
||||
RUST_TEST_THREADS: 1
|
||||
FEATURES: <<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
|
||||
DATABASE_URL: <<#parameters.postgres>>postgres://postgres@localhost/plume<</parameters.postgres>><<^parameters.postgres>>plume.sqlite<</parameters.postgres>>
|
||||
ROCKET_SECRET_KEY: VN5xV1DN7XdpATadOCYcuGeR/dV0hHfgx9mx9TarLdM=
|
||||
|
||||
|
||||
commands:
|
||||
@@ -37,7 +38,7 @@ commands:
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v0-<< parameters.cache >>-{{ checksum "Cargo.lock" }}-{{ .Branch }}
|
||||
- v0-<< parameters.cache >>-{{ checksum "Cargo.lock" }}-master
|
||||
- v0-<< parameters.cache >>-{{ checksum "Cargo.lock" }}-main
|
||||
|
||||
cache:
|
||||
description: push cache
|
||||
@@ -62,6 +63,7 @@ commands:
|
||||
type: boolean
|
||||
default: false
|
||||
steps:
|
||||
- run: rustup component add clippy --toolchain nightly-2022-07-19-x86_64-unknown-linux-gnu
|
||||
- run: cargo clippy <<^parameters.no_feature>>--no-default-features --features="${FEATURES}"<</parameters.no_feature>> --release -p <<parameters.package>> -- -D warnings
|
||||
|
||||
run_with_coverage:
|
||||
@@ -71,7 +73,7 @@ commands:
|
||||
type: string
|
||||
steps:
|
||||
- run: |
|
||||
export RUSTFLAGS="-Zprofile -Zfewer-names -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Zno-landing-pads -Clink-arg=-Xlinker -Clink-arg=--no-keep-memory -Clink-arg=-Xlinker -Clink-arg=--reduce-memory-overheads"
|
||||
export RUSTFLAGS="-Zprofile -Zfewer-names -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Clink-arg=-Xlinker -Clink-arg=--no-keep-memory -Clink-arg=-Xlinker -Clink-arg=--reduce-memory-overheads"
|
||||
export CARGO_INCREMENTAL=0
|
||||
<< parameters.cmd >>
|
||||
|
||||
@@ -99,7 +101,7 @@ commands:
|
||||
steps:
|
||||
- run: |
|
||||
cmd="cargo build <<#parameters.release>>--release<</parameters.release>> --no-default-features --features="${FEATURES}" -p <<parameters.package>> -j"
|
||||
for i in 36 4 2 1 1; do
|
||||
for i in 16 4 2 1 1; do
|
||||
$cmd $i && exit 0
|
||||
done
|
||||
exit 1
|
||||
@@ -110,6 +112,7 @@ jobs:
|
||||
name: default
|
||||
steps:
|
||||
- restore_env
|
||||
- run: rustup component add rustfmt --toolchain nightly-2022-07-19-x86_64-unknown-linux-gnu
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
@@ -143,11 +146,14 @@ jobs:
|
||||
cache: <<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
|
||||
- run_with_coverage:
|
||||
cmd: |
|
||||
cargo build -p plume-cli --no-default-features --features=${FEATURES} -j 4
|
||||
./target/debug/plm migration run
|
||||
./target/debug/plm search init
|
||||
cmd="cargo test --all --exclude plume-front --exclude plume-macro --no-run --no-default-features --features=${FEATURES} -j"
|
||||
for i in 36 4 2 1 1; do
|
||||
for i in 16 4 2 1 1; do
|
||||
$cmd $i && break
|
||||
done
|
||||
cargo test --all --exclude plume-front --exclude plume-macro --no-default-features --features="${FEATURES}" -j1 -- --test-threads=1
|
||||
cargo test --all --exclude plume-front --exclude plume-macro --no-default-features --features="${FEATURES}" -j1
|
||||
- upload_coverage:
|
||||
type: unit
|
||||
- cache:
|
||||
@@ -164,18 +170,18 @@ jobs:
|
||||
steps:
|
||||
- restore_env:
|
||||
cache: <<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
|
||||
- run: cargo web deploy -p plume-front
|
||||
- run: wasm-pack build --target web --release plume-front
|
||||
- run_with_coverage:
|
||||
cmd: |
|
||||
cmd="cargo install --debug --no-default-features --features="${FEATURES}",test --force --path . -j"
|
||||
for i in 36 4 2 1 1; do
|
||||
for i in 16 4 2 1 1; do
|
||||
$cmd $i && exit 0
|
||||
done
|
||||
exit 1
|
||||
- run_with_coverage:
|
||||
cmd: |
|
||||
cmd="cargo install --debug --no-default-features --features="${FEATURES}" --force --path plume-cli -j"
|
||||
for i in 36 4 2 1 1; do
|
||||
for i in 16 4 2 1 1; do
|
||||
$cmd $i && exit 0
|
||||
done
|
||||
exit 1
|
||||
@@ -199,7 +205,7 @@ jobs:
|
||||
steps:
|
||||
- restore_env:
|
||||
cache: release-<<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
|
||||
- run: cargo web deploy -p plume-front --release
|
||||
- run: wasm-pack build --target web --release plume-front
|
||||
- build:
|
||||
package: plume
|
||||
release: true
|
||||
@@ -254,4 +260,4 @@ workflows:
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /^master/
|
||||
- /^main/
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
localhost:443 {
|
||||
proxy / localhost:7878 {
|
||||
transparent
|
||||
}
|
||||
tls self_signed
|
||||
localhost {
|
||||
reverse_proxy localhost:7878
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
FROM debian:stretch-20190326
|
||||
FROM rust:1
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
#install native/circleci/build dependancies
|
||||
RUN apt update &&\
|
||||
apt install -y --no-install-recommends git ssh tar gzip ca-certificates default-jre&&\
|
||||
apt install -y --no-install-recommends binutils-dev build-essential cmake curl gcc gettext git libcurl4-openssl-dev libdw-dev libelf-dev libiberty-dev libpq-dev libsqlite3-dev libssl-dev make openssl pkg-config postgresql postgresql-contrib python zlib1g-dev python3-pip zip unzip libclang-dev&&\
|
||||
echo "deb [trusted=yes] https://apt.fury.io/caddy/ /" \
|
||||
| tee -a /etc/apt/sources.list.d/caddy-fury.list &&\
|
||||
wget -qO - https://artifacts.crowdin.com/repo/GPG-KEY-crowdin | apt-key add - &&\
|
||||
echo "deb https://artifacts.crowdin.com/repo/deb/ /" > /etc/apt/sources.list.d/crowdin.list &&\
|
||||
apt update &&\
|
||||
apt install -y --no-install-recommends binutils-dev build-essential cmake curl gcc gettext git libcurl4-openssl-dev libdw-dev libelf-dev libiberty-dev libpq-dev libsqlite3-dev libssl-dev make openssl pkg-config postgresql postgresql-contrib python zlib1g-dev python3-dev python3-pip python3-setuptools zip unzip libclang-dev clang caddy crowdin3 &&\
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
#install and configure rust
|
||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- --default-toolchain nightly-2020-01-15 -y &&\
|
||||
rustup component add rustfmt clippy &&\
|
||||
rustup component add rust-std --target wasm32-unknown-unknown
|
||||
#stick rust environment
|
||||
COPY rust-toolchain ./
|
||||
RUN rustup component add rustfmt clippy
|
||||
|
||||
#compile some deps
|
||||
RUN cargo install cargo-web &&\
|
||||
RUN cargo install wasm-pack &&\
|
||||
cargo install grcov &&\
|
||||
strip /root/.cargo/bin/* &&\
|
||||
rm -fr ~/.cargo/registry
|
||||
|
||||
#set some compilation parametters
|
||||
@@ -24,14 +27,5 @@ COPY cargo_config /root/.cargo/config
|
||||
#install selenium for front end tests
|
||||
RUN pip3 install selenium
|
||||
|
||||
#install and configure caddy
|
||||
RUN curl https://getcaddy.com | bash -s personal
|
||||
#configure caddy
|
||||
COPY Caddyfile /Caddyfile
|
||||
|
||||
#install crowdin
|
||||
RUN mkdir /crowdin && cd /crowdin &&\
|
||||
curl -O https://downloads.crowdin.com/cli/v2/crowdin-cli.zip &&\
|
||||
unzip crowdin-cli.zip && rm crowdin-cli.zip &&\
|
||||
cd * && mv crowdin-cli.jar /usr/local/bin && cd && rm -rf /crowdin &&\
|
||||
/bin/echo -e '#!/bin/sh\njava -jar /usr/local/bin/crowdin-cli.jar $@' > /usr/local/bin/crowdin &&\
|
||||
chmod +x /usr/local/bin/crowdin
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
nightly-2022-07-19
|
||||
@@ -3,3 +3,5 @@ data
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.env
|
||||
target
|
||||
data
|
||||
|
||||
+12
@@ -15,6 +15,9 @@ DATABASE_URL=postgres://plume:plume@localhost/plume
|
||||
# The domain of your instance
|
||||
BASE_URL=plu.me
|
||||
|
||||
# Log level for each crate
|
||||
RUST_LOG=info
|
||||
|
||||
# The secret key for private cookies and CSRF protection
|
||||
# You can generate one with `openssl rand -base64 32`
|
||||
ROCKET_SECRET_KEY=
|
||||
@@ -45,3 +48,12 @@ ROCKET_ADDRESS=127.0.0.1
|
||||
#PLUME_LOGO_192=icons/trwnh/paragraphs/plumeParagraphs192.png
|
||||
#PLUME_LOGO_256=icons/trwnh/paragraphs/plumeParagraphs256.png
|
||||
#PLUME_LOGO_512=icons/trwnh/paragraphs/plumeParagraphs512.png
|
||||
|
||||
## LDAP CONFIG ##
|
||||
# the object that will be bound is "${USER_NAME_ATTR}=${username},${BASE_DN}"
|
||||
#LDAP_ADDR=ldap://127.0.0.1:1389
|
||||
#LDAP_BASE_DN="ou=users,dc=your-org,dc=eu"
|
||||
#LDAP_USER_NAME_ATTR=cn
|
||||
#LDAP_USER_MAIL_ATTR=mail
|
||||
#LDAP_TLS=false
|
||||
|
||||
|
||||
@@ -7,6 +7,16 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
We would appreciated if you report a bug at our Gitea instance's issue page:
|
||||
https://git.joinplu.me/Plume/Plume/issues
|
||||
You can login to the Gitea with your GitHub account.
|
||||
|
||||
We welcome to receive bug reports here, GitHub, too.
|
||||
-->
|
||||
|
||||
|
||||
|
||||
<!-- Describe your bug, explaining how to reproduce it, and what was expected -->
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,15 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
We would appreciated if you request a feature at our Gitea instance's issue page:
|
||||
https://git.joinplu.me/Plume/Plume/issues
|
||||
You can login to the Gitea with your GitHub account.
|
||||
|
||||
We welcome to receive feature requests here, GitHub, too.
|
||||
-->
|
||||
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
@@ -0,0 +1,7 @@
|
||||
<!--
|
||||
We would appreciated if you report a bug at our Gitea instance's pull request page:
|
||||
https://git.joinplu.me/Plume/Plume/pulls
|
||||
You can login to the Gitea with your GitHub account.
|
||||
|
||||
We welcome to receive pull requests here, GitHub, too.
|
||||
-->
|
||||
@@ -0,0 +1,30 @@
|
||||
name: cd
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
tags: plumeorg/plume:latest
|
||||
@@ -0,0 +1,36 @@
|
||||
name: cd
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: plumeorg/plume
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -18,3 +18,5 @@ tags.*
|
||||
search_index
|
||||
.buildconfig
|
||||
__pycache__
|
||||
.vscode/
|
||||
*-journal
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
# Changelog
|
||||
|
||||
<!-- next-header -->
|
||||
|
||||
## [Unreleased] - ReleaseDate
|
||||
|
||||
### Added
|
||||
|
||||
- Add 'My feed' to i18n timeline name (#1084)
|
||||
- Bidirectional support for user page header (#1092)
|
||||
|
||||
### Changed
|
||||
|
||||
- Use blog title as slug (#1094, #1126, #1127)
|
||||
- Bump Rust to nightly 2022-07-19 (#1119)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Malfunction while creating a blog post in Persian (#1116)
|
||||
- Email block list is ignored when email sign-up (#1122)
|
||||
- Bug that some Activity Sytreams properties are not parsed properly (#1129)
|
||||
- Allow empty avatar for remote users (#1129)
|
||||
- Percent encode blog FQN for federation interoperability (#1129)
|
||||
- The same to `preferredUsername` (#1129)
|
||||
|
||||
## [[0.7.2]] - 2022-05-11
|
||||
|
||||
### Added
|
||||
|
||||
- Basque language (#1013)
|
||||
- Unit tests for ActivityPub (#1021)
|
||||
- Move to action area after liking/boosting/commenting (#1074)
|
||||
|
||||
### Changed
|
||||
|
||||
- Bump Rust to nightly 2022-01-26 (#1015)
|
||||
- Remove "Latest articles" timeline (#1069)
|
||||
- Change order of timeline tabs (#1069, #1070, #1072)
|
||||
- Migrate ActivityPub-related crates from activitypub 0.1 to activitystreams 0.7 (#1022)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Add explanation of sign-up step at sign-up page when email sign-up mode (#1012)
|
||||
- Add NOT NULL constraint to email_blocklist table fields (#1016)
|
||||
- Don't fill empty content when switching rich editor (#1017)
|
||||
- Fix accept header (#1058)
|
||||
- Render 404 page instead of 500 when data is not found (#1062)
|
||||
- Reuse reqwest client on broadcasting (#1059)
|
||||
- Reduce broadcasting HTTP request at once to prevent them being timed out (#1068, #1071)
|
||||
- Some ActivityPub data (#1021)
|
||||
|
||||
## [[0.7.1]] - 2022-01-12
|
||||
|
||||
### Added
|
||||
|
||||
- Introduce environment variable `MAIL_PORT` (#980)
|
||||
- Introduce email sign-up feature (#636, #1002)
|
||||
|
||||
### Changed
|
||||
|
||||
- Some styling improvements (#976, #977, #978)
|
||||
- Respond with error status code when error (#1002)
|
||||
|
||||
### Fiexed
|
||||
|
||||
- Fix comment link (#974)
|
||||
- Fix a bug that prevents posting articles (#975)
|
||||
- Fix a bug that notification page doesn't show (#981)
|
||||
|
||||
## [[0.7.0]] - 2022-01-02
|
||||
|
||||
### Added
|
||||
|
||||
- Allow `dir` attributes for LtoR text in RtoL document (#860)
|
||||
- More translation languages (#862)
|
||||
- Proxy support (#829)
|
||||
- Riker a actor system library (#870)
|
||||
- (request-target) and Host header in HTTP Signature (#872)
|
||||
- Default log levels for RUST_LOG (#885, #886, #919)
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade some dependent crates (#858)
|
||||
- Use tracing crate (#868)
|
||||
- Update Rust version to nightly-2021-11-27 (#961)
|
||||
- Upgrade Tantivy to 0.13.3 and lindera-tantivy to 0.7.1 (#878)
|
||||
- Run searcher on actor system (#870)
|
||||
- Extract a function to calculate posts' ap_url and share it with some places (#918)
|
||||
- Use article title as its slug instead of capitalizing and inserting hyphens (#920)
|
||||
- Sign GET requests to other instances (#957)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Percent-encode URI for remote_interact (#866, #857)
|
||||
- Menu animation not opening on iOS (#876, #897)
|
||||
- Make actors subscribe to channel once (#913)
|
||||
- Upsert posts and media instead of trying to insert and fail (#912)
|
||||
- Update post's ActivityPub id when published by update (#915)
|
||||
- Calculate media URI properly even when MEDIA_UPLOAD_DIRECTORY configured (#916)
|
||||
- Prevent duplicated posts in 'all' timeline (#917)
|
||||
- Draw side line for blockquote on start (#933)
|
||||
- Fix URIs of posts on Mastodon (#947)
|
||||
- Place edit link proper position (#956, #963, #964)
|
||||
|
||||
## [[0.6.0]] - 2020-12-29
|
||||
|
||||
### Added
|
||||
|
||||
- Vazir font for better support of languages written in Arabic script (#787)
|
||||
- Login via LDAP (#826)
|
||||
- cargo-release (#835)
|
||||
- Care about weak ETag header for better caching (#840)
|
||||
- Support for right to left languages in post content (#853)
|
||||
|
||||
### Changed
|
||||
|
||||
- Bump Docker base images to Buster flavor (#797)
|
||||
- Upgrade Rocket to 0.4.5 (#800)
|
||||
- Keep tags as-is (#832)
|
||||
- Update Docker image for testing (#838)
|
||||
- Update Dockerfile.dev (#841)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Recreate search index if its format is outdated (#802)
|
||||
- Make it possible to switch to rich text editor (#808)
|
||||
- Fix margins for the mobile devices (#817)
|
||||
- GPU acceleration for the mobile menu (#818)
|
||||
- Natural title position for RtoL languages (#825)
|
||||
- Remove link to unimplemented page (#827)
|
||||
- Fix displaying not found page when submitting a duplicated blocklist email (#831)
|
||||
|
||||
### Security
|
||||
|
||||
- Validate spoofing of activity
|
||||
|
||||
## [0.5.0] - 2020-06-21
|
||||
|
||||
### Added
|
||||
|
||||
- Email blocklisting (#718)
|
||||
- Syntax highlighting (#691)
|
||||
- Persian localization (#782)
|
||||
- Switchable tokenizer - enables Japanese full-text search (#776)
|
||||
- Make database connections configurable by environment variables (#768)
|
||||
|
||||
### Changed
|
||||
|
||||
- Display likes and boost on post cards (#744)
|
||||
- Rust 2018 (#726)
|
||||
- Bump to LLVM to 9.0.0 to fix ARM builds (#737)
|
||||
- Remove dependency on runtime-fmt (#773)
|
||||
- Drop the -alpha suffix in release names, it is implied that Plume is not stable yet because of the 0 major version (Plume 1.0.0 will be the first stable release).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix parsing of mentions inside a Markdown code block (be430c6)
|
||||
- Fix RSS issues (#720)
|
||||
- Fix Atom feed (#764)
|
||||
- Fix default theme (#746)
|
||||
- Fix shown password on remote interact pages (#741)
|
||||
- Allow unicode hashtags (#757)
|
||||
- Fix French grammar for for 0 (#760)
|
||||
- Don't show boosts and likes for "all" and "local" in timelines (#781)
|
||||
- Fix liking and boosting posts on remote instances (#762)
|
||||
|
||||
## [0.4.0] - 2019-12-23
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for generic timeline (#525)
|
||||
- Federate user deletion (#551)
|
||||
- import migrations and don't require diesel_cli for admins (#555)
|
||||
- Cache local instance (#572)
|
||||
- Initial RTL support #575 (#577)
|
||||
- Confirm deletion of blog (#602)
|
||||
- Make a distinction between moderators and admins (#619)
|
||||
- Theming (#624)
|
||||
- Add clap to plume in order to print help and version (#631)
|
||||
- Add Snapcraft metadata and install/maintenance hooks (#666)
|
||||
- Add environmental variable to control path of media (#683)
|
||||
- Add autosaving to the editor (#688)
|
||||
- CI: Upload artifacts to pull request deploy environment (#539)
|
||||
- CI: Upload artifact of wasm binary (#571)
|
||||
|
||||
### Changed
|
||||
|
||||
- Update follow_remote.rs.html grammar (#548)
|
||||
- Add some feedback when performing some actions (#552)
|
||||
- Theme update (#553)
|
||||
- Remove the new index lock tantivy uses (#556)
|
||||
- Reduce reqwest timeout to 5s (#557)
|
||||
- Improve notification management (#561)
|
||||
- Fix occurrences of 'have been' to 'has been' (#578) + Direct follow-up to #578 (#603)
|
||||
- Store password reset requests in database (#610)
|
||||
- Use futures and tokio to send activities (#620)
|
||||
- Don't ignore dotenv errors (#630)
|
||||
- Replace the input! macro with an Input builder (#646)
|
||||
- Update default license (#659)
|
||||
- Paginate the outbox responses. Fixes #669 (#681)
|
||||
- Use the "classic" editor by default (#697)
|
||||
- Fix issue #705 (#708)
|
||||
- Make comments in styleshhets a bit clearer (#545)
|
||||
- Rewrite circleci config (#558)
|
||||
- Use openssl instead of sha256sum for build.rs (#568)
|
||||
- Update dependencies (#574)
|
||||
- Refactor code to use Shrinkwraprs and diesel-derive-newtype (#598)
|
||||
- Add enum containing all successful route returns (#614)
|
||||
- Update dependencies which depended on nix -- fixes arm32 builds (#615)
|
||||
- Update some documents (#616)
|
||||
- Update dependencies (#643)
|
||||
- Make the comment syntax consistent across all CSS (#487)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Remove r (#535)
|
||||
- Fix certain improper rendering of forms (#560)
|
||||
- make hashtags work in profile summary (#562)
|
||||
- Fix some federation issue (#573)
|
||||
- Prevent comment form submit button distortion on iOS (#592)
|
||||
- Update textarea overflow to scroll (#609)
|
||||
- Fix arm builds (#612)
|
||||
- Fix theme caching (#647)
|
||||
- Fix issue #642, frontend not in English if the user language does not exist (#648)
|
||||
- Don't index drafts (#656)
|
||||
- Fill entirely user on creation (#657)
|
||||
- Delete notification on user deletion (#658)
|
||||
- Order media so that latest added are top (#660)
|
||||
- Fix logo URL (#664)
|
||||
- Snap: Ensure cargo-web doesn't erroneously adopt our workspace. (#667)
|
||||
- Snap: Another fix for building (#668)
|
||||
- Snap: Fix build for non-Tier-1 Rust platforms (#672)
|
||||
- Don't split sentences for translations (#677)
|
||||
- Escape href quotation marks (#678)
|
||||
- Re-add empty strings in translation (#682)
|
||||
- Make the search index creation during migration respect SEARCH_INDEX (#689)
|
||||
- Fix the navigation menu not opening on touch (#690)
|
||||
- Make search items optional (#693)
|
||||
- Various snap fixes (#698)
|
||||
- Fix #637 : Markdown footnotes (#700)
|
||||
- Fix lettre (#706)
|
||||
- CI: Fix Crowdin upload (#576)
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove the Canapi dependency (#540)
|
||||
- Remove use of Rust in migrations (#704)
|
||||
|
||||
## [0.3.0] - 2019-04-19
|
||||
|
||||
### Added
|
||||
|
||||
- Cover for articles (#299, #387)
|
||||
- Password reset (#448)
|
||||
- New editor (#293, #458, #482, #483, #486, #530)
|
||||
- Search (#324, #375, #445)
|
||||
- Edit blogs (#460, #494, #497)
|
||||
- Hashtags in articles (#283, #295)
|
||||
- API endpoints (#245, #285, #307)
|
||||
- A bunch of new translations! (#479, #501, #506, #510, #512, #514)
|
||||
|
||||
### Changed
|
||||
|
||||
- Federation improvements (#216, #217, #357, #364, #399, #443, #446, #455, #502, #519)
|
||||
- Improved build process (#281, #374, #392, #402, #489, #498, #503, #511, #513, #515, #528)
|
||||
|
||||
### Fixes
|
||||
|
||||
- UI usability fixes (#370, #386, #401, #417, #418, #444, #452, #480, #516, #518, #522, #532)
|
||||
|
||||
## [0.2.0] - 2018-09-12
|
||||
|
||||
### Added
|
||||
|
||||
- Article publishing, or save as a draft
|
||||
- Like, or boost an article
|
||||
- Basic Markdown editor
|
||||
- Federated commenting system
|
||||
- User account creation
|
||||
- Limited federation on other platforms and subscribing to users
|
||||
- Ability to create multiple blogs
|
||||
|
||||
<!-- next-url -->
|
||||
[Unreleased]: https://github.com/Plume-org/Plume/compare/0.7.2...HEAD
|
||||
[[0.7.2]]: https://github.com/Plume-org/Plume/compare/0.7.1...0.7.2
|
||||
[[0.7.1]]: https://github.com/Plume-org/Plume/compare/0.7.0...0.7.1
|
||||
[[0.7.0]]: https://github.com/Plume-org/Plume/compare/0.6.0...0.7.0
|
||||
[[0.6.0]]: https://github.com/Plume-org/Plume/compare/0.5.0...0.6.0
|
||||
[0.5.0]: https://github.com/Plume-org/Plume/compare/0.4.0-alpha-4...0.5.0
|
||||
[0.4.0]: https://github.com/Plume-org/Plume/compare/0.3.0-alpha-2...0.4.0-alpha-4
|
||||
[0.3.0]: https://github.com/Plume-org/Plume/compare/0.2.0-alpha-1...0.3.0-alpha-2
|
||||
[0.2.0]: https://github.com/Plume-org/Plume/releases/tag/0.2.0-alpha-1
|
||||
Generated
+3678
-2291
File diff suppressed because it is too large
Load Diff
+28
-31
@@ -1,38 +1,34 @@
|
||||
[package]
|
||||
authors = ["Plume contributors"]
|
||||
name = "plume"
|
||||
version = "0.4.0"
|
||||
version = "0.7.3-dev"
|
||||
repository = "https://github.com/Plume-org/Plume"
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
activitypub = "0.1.3"
|
||||
askama_escape = "0.1"
|
||||
atom_syndication = "0.6"
|
||||
atom_syndication = "0.12.0"
|
||||
clap = "2.33"
|
||||
colored = "1.8"
|
||||
dotenv = "0.14"
|
||||
gettext = { git = "https://github.com/Plume-org/gettext/", rev = "294c54d74c699fbc66502b480a37cc66c1daa7f3" }
|
||||
gettext-macros = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
|
||||
gettext-utils = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
|
||||
guid-create = "0.1"
|
||||
heck = "0.3.0"
|
||||
lettre = "0.9.2"
|
||||
dotenv = "0.15.0"
|
||||
gettext = "0.4.0"
|
||||
gettext-macros = "0.6.1"
|
||||
gettext-utils = "0.1.0"
|
||||
guid-create = "0.2"
|
||||
lettre_email = "0.9.2"
|
||||
num_cpus = "1.10"
|
||||
rocket = "0.4.2"
|
||||
rocket_contrib = { version = "0.4.2", features = ["json"] }
|
||||
rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" }
|
||||
rpassword = "4.0"
|
||||
scheduled-thread-pool = "0.2.2"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
serde_qs = "0.5"
|
||||
shrinkwraprs = "0.2.1"
|
||||
syntect = "3.3"
|
||||
validator = "0.8"
|
||||
validator_derive = "0.8"
|
||||
rocket = "0.4.11"
|
||||
rocket_contrib = { version = "0.4.11", features = ["json"] }
|
||||
rocket_i18n = "0.4.1"
|
||||
scheduled-thread-pool = "0.2.6"
|
||||
rust-s3 = { version = "0.29.0", no-default-features = true, features = ["blocking"], optional = true}
|
||||
serde = "1.0.137"
|
||||
serde_json = "1.0.81"
|
||||
shrinkwraprs = "0.3.0"
|
||||
validator = { version = "0.15", features = ["derive"] }
|
||||
webfinger = "0.4.1"
|
||||
tracing = "0.1.35"
|
||||
tracing-subscriber = "0.3.10"
|
||||
riker = "0.4.2"
|
||||
activitystreams = "=0.7.0-alpha.20"
|
||||
|
||||
[[bin]]
|
||||
name = "plume"
|
||||
@@ -44,16 +40,16 @@ version = "0.4"
|
||||
|
||||
[dependencies.ctrlc]
|
||||
features = ["termination"]
|
||||
version = "3.1.2"
|
||||
version = "3.2.2"
|
||||
|
||||
[dependencies.diesel]
|
||||
features = ["r2d2", "chrono"]
|
||||
version = "*"
|
||||
version = "1.4.5"
|
||||
|
||||
[dependencies.multipart]
|
||||
default-features = false
|
||||
features = ["server"]
|
||||
version = "0.16"
|
||||
version = "0.18"
|
||||
|
||||
[dependencies.plume-api]
|
||||
path = "plume-api"
|
||||
@@ -65,12 +61,12 @@ path = "plume-common"
|
||||
path = "plume-models"
|
||||
|
||||
[dependencies.rocket_csrf]
|
||||
git = "https://github.com/fdb-hiroshima/rocket_csrf"
|
||||
rev = "29910f2829e7e590a540da3804336577b48c7b31"
|
||||
git = "https://git.joinplu.me/plume/rocket_csrf"
|
||||
rev = "0.1.2"
|
||||
|
||||
[build-dependencies]
|
||||
ructe = "0.9.0"
|
||||
rsass = "0.9"
|
||||
ructe = "0.15.0"
|
||||
rsass = "0.26"
|
||||
|
||||
[features]
|
||||
default = ["postgres"]
|
||||
@@ -79,6 +75,7 @@ sqlite = ["plume-models/sqlite", "diesel/sqlite"]
|
||||
debug-mailer = []
|
||||
test = []
|
||||
search-lindera = ["plume-models/search-lindera"]
|
||||
s3 = ["rust-s3"]
|
||||
|
||||
[workspace]
|
||||
members = ["plume-api", "plume-cli", "plume-models", "plume-common", "plume-front", "plume-macro"]
|
||||
|
||||
+3
-5
@@ -1,4 +1,4 @@
|
||||
FROM rust:1-stretch as builder
|
||||
FROM rust:1 as builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
@@ -18,17 +18,15 @@ COPY script/wasm-deps.sh .
|
||||
RUN chmod a+x ./wasm-deps.sh && sleep 1 && ./wasm-deps.sh
|
||||
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock rust-toolchain ./
|
||||
RUN cargo install cargo-web
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN cargo install wasm-pack
|
||||
RUN chmod a+x ./script/plume-front.sh && sleep 1 && ./script/plume-front.sh
|
||||
RUN cargo install --path ./ --force --no-default-features --features postgres
|
||||
RUN cargo install --path plume-cli --force --no-default-features --features postgres
|
||||
RUN cargo clean
|
||||
|
||||
FROM debian:stretch-slim
|
||||
FROM debian:stable-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
FROM rust:1-stretch
|
||||
FROM rust:1-buster
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
@@ -10,7 +10,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
make \
|
||||
openssl \
|
||||
libssl-dev
|
||||
libssl-dev\
|
||||
clang
|
||||
|
||||
WORKDIR /scratch
|
||||
COPY script/wasm-deps.sh .
|
||||
@@ -19,7 +20,7 @@ RUN chmod a+x ./wasm-deps.sh && sleep 1 && ./wasm-deps.sh
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock rust-toolchain ./
|
||||
RUN cargo install diesel_cli --no-default-features --features postgres --version '=1.3.0'
|
||||
RUN cargo install cargo-web
|
||||
RUN cargo install wasm-pack
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<h1 align="center">
|
||||
<img src="https://raw.githubusercontent.com/Plume-org/Plume/master/assets/icons/trwnh/feather/plumeFeather64.png" alt="Plume's logo">
|
||||
<img src="https://raw.githubusercontent.com/Plume-org/Plume/main/assets/icons/trwnh/feather/plumeFeather64.png" alt="Plume's logo">
|
||||
Plume
|
||||
</h1>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Plume-org/Plume/"><img alt="CircleCI" src="https://img.shields.io/circleci/build/gh/Plume-org/Plume.svg"></a>
|
||||
<a href="https://codecov.io/gh/Plume-org/Plume"><img src="https://codecov.io/gh/Plume-org/Plume/branch/master/graph/badge.svg" alt="Code coverage"></a>
|
||||
<a href="https://codecov.io/gh/Plume-org/Plume"><img src="https://codecov.io/gh/Plume-org/Plume/branch/main/graph/badge.svg" alt="Code coverage"></a>
|
||||
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/plume"><img src="https://d322cqt584bo4o.cloudfront.net/plume/localized.svg"></a>
|
||||
<a href="https://hub.docker.com/r/plumeorg/plume"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/plumeorg/plume.svg"></a>
|
||||
<a href="https://liberapay.com/Plume"><img alt="Liberapay patrons" src="https://img.shields.io/liberapay/patrons/Plume.svg"></a>
|
||||
@@ -30,11 +30,11 @@ A lot of features are still missing, but what is already here should be quite st
|
||||
- **Media management**: you can upload pictures to illustrate your articles, but also audio files if you host a podcast, and manage them all from Plume.
|
||||
- **Federation**: Plume is part of a network of interconnected websites called the Fediverse. Each of these websites (often called *instances*) have their own
|
||||
rules and thematics, but they can all communicate with each other.
|
||||
- **Collaborative writing**: invite other people to your blogs, and write articles together.
|
||||
- **Collaborative writing**: invite other people to your blogs, and write articles together. (Not implemented yet, but will be in 1.0)
|
||||
|
||||
## Get involved
|
||||
|
||||
If you want to have regular news about the project, the best place is probably [our blog](https://fediverse.blog/~/PlumeDev), or our Matrix room: [`#plume:disroot.org`](https://riot.im/app/#/room/#plume:disroot.org).
|
||||
If you want to have regular news about the project, the best place is probably [our blog](https://fediverse.blog/~/PlumeDev), or our Matrix room: [`#plume-blog:matrix.org`](https://matrix.to/#/#plume-blog:matrix.org).
|
||||
|
||||
If you want to contribute more, a good first step is to read [our contribution guides](https://docs.joinplu.me/contribute). We accept all kind of contribution:
|
||||
|
||||
@@ -53,3 +53,4 @@ As we want the various spaces related to the project (GitHub, Matrix, Loomio, et
|
||||
|
||||
We provide various way to install Plume: from source, with pre-built binaries, with Docker or with YunoHost.
|
||||
For detailed explanations, please refer to [the documentation](https://docs.joinplu.me/installation/).
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
* {
|
||||
font-family: monospace;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ main header.article {
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
justify-content: flex-end;
|
||||
|
||||
h1, .article-info {
|
||||
text-align: center;
|
||||
@@ -64,41 +64,41 @@ main header.article {
|
||||
}
|
||||
|
||||
main .article-info {
|
||||
margin: 0 auto 3em;
|
||||
font-size: 0.95em;
|
||||
font-weight: 400;
|
||||
margin: 0 auto 3em;
|
||||
font-size: 0.95em;
|
||||
font-weight: 400;
|
||||
|
||||
.author, .author a {
|
||||
font-weight: 600;
|
||||
}
|
||||
.author, .author a {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* The article itself */
|
||||
main article {
|
||||
max-width: $article-width;
|
||||
margin: 2.5em auto;
|
||||
font-family: $lora;
|
||||
font-size: 1.2em;
|
||||
line-height: 1.7;
|
||||
margin: 2.5em auto;
|
||||
font-family: $lora;
|
||||
font-size: 1.2em;
|
||||
line-height: 1.7;
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
margin: 3em auto;
|
||||
max-width: 100%;
|
||||
img {
|
||||
display: block;
|
||||
margin: 3em auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 1em;
|
||||
background: $gray;
|
||||
overflow: auto;
|
||||
padding: 1em;
|
||||
background: $gray;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 5px solid $gray;
|
||||
border-inline-start: 5px solid $gray;
|
||||
margin: 1em auto;
|
||||
padding: 0em 2em;
|
||||
}
|
||||
@@ -126,7 +126,7 @@ main .article-meta {
|
||||
|
||||
> p {
|
||||
margin: 2em $horizontal-margin;
|
||||
font-size: 0.9em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Article Tags */
|
||||
@@ -157,15 +157,15 @@ main .article-meta {
|
||||
/* Likes & Boosts */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.likes, .reshares {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5em 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5em 0;
|
||||
|
||||
p {
|
||||
font-size: 1.5em;
|
||||
@@ -175,34 +175,34 @@ main .article-meta {
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: $text-color;
|
||||
border: none;
|
||||
font-size: 1.1em;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: $text-color;
|
||||
border: none;
|
||||
font-size: 1.1em;
|
||||
cursor: pointer;
|
||||
|
||||
svg.feather {
|
||||
transition: background 0.1s ease-in;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
svg.feather {
|
||||
transition: background 0.1s ease-in;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
margin: 0.5em 0;
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
margin: 0.5em 0;
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
|
||||
border-radius: 50%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.reshared, &.liked {
|
||||
svg.feather {
|
||||
color: $background;
|
||||
font-weight: 900;
|
||||
color: $background;
|
||||
font-weight: 900;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,14 +213,14 @@ main .article-meta {
|
||||
|
||||
.action svg.feather {
|
||||
padding: 0.7em;
|
||||
box-sizing: border-box;
|
||||
color: $red;
|
||||
fill: none;
|
||||
border: solid $red thin;
|
||||
box-sizing: border-box;
|
||||
color: $red;
|
||||
fill: none;
|
||||
border: solid $red thin;
|
||||
}
|
||||
|
||||
.action:hover svg.feather {
|
||||
background: transparentize($red, 0.85);
|
||||
background: transparentize($red, 0.85);
|
||||
}
|
||||
|
||||
.action.liked svg.feather {
|
||||
@@ -238,22 +238,22 @@ main .article-meta {
|
||||
|
||||
.action svg.feather {
|
||||
padding: 0.7em;
|
||||
box-sizing: border-box;
|
||||
color: $primary;
|
||||
border: solid $primary thin;
|
||||
font-weight: 600;
|
||||
box-sizing: border-box;
|
||||
color: $primary;
|
||||
border: solid $primary thin;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action:hover svg.feather {
|
||||
background: transparentize($primary, 0.85);
|
||||
background: transparentize($primary, 0.85);
|
||||
}
|
||||
|
||||
.action.reshared svg.feather {
|
||||
background: $primary;
|
||||
}
|
||||
.action.reshared:hover svg.feather {
|
||||
background: transparentize($primary, 0.75)
|
||||
color: $primary;
|
||||
background: transparentize($primary, 0.75)
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,9 +262,9 @@ main .article-meta {
|
||||
margin: 0 $horizontal-margin;
|
||||
|
||||
h2 {
|
||||
color: $primary;
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
color: $primary;
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
summary {
|
||||
@@ -279,16 +279,16 @@ main .article-meta {
|
||||
|
||||
// Respond & delete comment buttons
|
||||
a.button, form.inline, form.inline input {
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: $text-color;
|
||||
margin-right: 2em;
|
||||
font-family: $route159;
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: $text-color;
|
||||
margin-right: 2em;
|
||||
font-family: $route159;
|
||||
font-weight: normal;
|
||||
|
||||
&::before {
|
||||
color: $primary;
|
||||
padding-right: 0.5em;
|
||||
&::before {
|
||||
color: $primary;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
&:hover { color: $primary; }
|
||||
@@ -296,8 +296,8 @@ main .article-meta {
|
||||
|
||||
.comment {
|
||||
margin: 1em 0;
|
||||
font-size: 1em;
|
||||
border: none;
|
||||
font-size: 1em;
|
||||
border: none;
|
||||
|
||||
.content {
|
||||
background: $gray;
|
||||
@@ -328,36 +328,36 @@ main .article-meta {
|
||||
color: transparentize($text-color, 0.6);
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
.author {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
|
||||
* {
|
||||
transition: all 0.1s ease-in;
|
||||
}
|
||||
* {
|
||||
transition: all 0.1s ease-in;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
color: $text-color;
|
||||
.display-name {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.display-name { color: $primary; }
|
||||
small { opacity: 1; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .comment {
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 1.25em 0;
|
||||
font-family: $lora;
|
||||
font-size: 1.1em;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
.text {
|
||||
padding: 1.25em 0;
|
||||
font-family: $lora;
|
||||
font-size: 1.1em;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,3 +490,37 @@ input:checked ~ .cw-container > .cw-text {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
// Small screens
|
||||
@media screen and (max-width: 600px) {
|
||||
#plume-editor header {
|
||||
flex-direction: column-reverse;
|
||||
|
||||
button {
|
||||
flex: 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.popup {
|
||||
top: 10vh;
|
||||
bottom: 10vh;
|
||||
left: 1vw;
|
||||
right: 1vw;
|
||||
}
|
||||
|
||||
main article {
|
||||
margin: 2.5em .5em;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
main .article-meta > *, main .article-meta .comments, main .article-meta > .banner > * {
|
||||
margin: 0 5%;
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
align-items: center;
|
||||
& > div:nth-child(2) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
label {
|
||||
display: block;
|
||||
margin: 2em auto .5em;
|
||||
font-size: 1.2em;
|
||||
display: block;
|
||||
margin: 2em auto .5em;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
input, textarea, select {
|
||||
transition: all 0.1s ease-in;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
padding: 1em;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.1s ease-in;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
padding: 1em;
|
||||
box-sizing: border-box;
|
||||
-webkit-appearance: textarea;
|
||||
|
||||
background: $form-input-background;
|
||||
color: $text-color;
|
||||
border: solid $form-input-border thin;
|
||||
background: $form-input-background;
|
||||
color: $text-color;
|
||||
border: solid $form-input-border thin;
|
||||
|
||||
font-size: 1.2em;
|
||||
font-weight: 400;
|
||||
font-size: 1.2em;
|
||||
font-weight: 400;
|
||||
|
||||
&:focus {
|
||||
border-color: $primary;
|
||||
}
|
||||
&:focus {
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
form input[type="submit"] {
|
||||
margin: 2em auto;
|
||||
@@ -29,18 +29,18 @@ form input[type="submit"] {
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
resize: vertical;
|
||||
overflow-y: scroll;
|
||||
font-family: $lora;
|
||||
font-size: 1.1em;
|
||||
line-height: 1.5;
|
||||
font-family: $lora;
|
||||
font-size: 1.1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
display: inline;
|
||||
margin: initial;
|
||||
min-width: initial;
|
||||
width: initial;
|
||||
display: inline;
|
||||
margin: initial;
|
||||
min-width: initial;
|
||||
width: initial;
|
||||
-webkit-appearance: checkbox;
|
||||
}
|
||||
|
||||
@@ -71,31 +71,31 @@ form.inline {
|
||||
}
|
||||
|
||||
.button, .button:visited, input[type="submit"], input[type="submit"].button {
|
||||
transition: all 0.1s ease-in;
|
||||
display: inline-block;
|
||||
transition: all 0.1s ease-in;
|
||||
display: inline-block;
|
||||
-webkit-appearance: none;
|
||||
|
||||
margin: 0.5em auto;
|
||||
padding: 0.75em 1em;
|
||||
margin: 0.5em auto;
|
||||
padding: 0.75em 1em;
|
||||
|
||||
background: $primary;
|
||||
color: $primary-text-color;
|
||||
background: $primary;
|
||||
color: $primary-text-color;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: transparentize($primary, 0.1);
|
||||
}
|
||||
&:hover {
|
||||
background: transparentize($primary, 0.1);
|
||||
}
|
||||
|
||||
&.destructive {
|
||||
background: $red;
|
||||
&.destructive {
|
||||
background: $red;
|
||||
|
||||
&:hover {
|
||||
background: transparentize($red, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: $gray;
|
||||
@@ -115,26 +115,27 @@ input[type="submit"] {
|
||||
form.new-post {
|
||||
max-width: 60em;
|
||||
.title {
|
||||
margin: 0 auto;
|
||||
padding: 0.75em 0;
|
||||
margin: 0 auto;
|
||||
padding: 0.75em 0;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
font-family: $playfair;
|
||||
font-size: 2em;
|
||||
text-align: left;
|
||||
font-family: $playfair;
|
||||
font-size: 2em;
|
||||
text-align: left;
|
||||
}
|
||||
textarea {
|
||||
min-height: 20em;
|
||||
overflow-y: scroll;
|
||||
resize: none;
|
||||
min-height: 20em;
|
||||
overflow-y: scroll;
|
||||
resize: none;
|
||||
-webkit-appearance: textarea;
|
||||
}
|
||||
}
|
||||
|
||||
.button + .button {
|
||||
margin-left: 1em;
|
||||
margin-inline-start: 1em;
|
||||
}
|
||||
|
||||
.split {
|
||||
|
||||
@@ -6,43 +6,43 @@ html {
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: $background;
|
||||
color: $text-color;
|
||||
font-family: $route159;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: $background;
|
||||
color: $text-color;
|
||||
font-family: $route159;
|
||||
|
||||
::selection {
|
||||
background: transparentize($primary, 0.7);
|
||||
}
|
||||
::-moz-selection {
|
||||
::-moz-selection {
|
||||
background: transparentize($primary, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a, a:visited {
|
||||
color: $primary;
|
||||
text-decoration: none;
|
||||
color: $primary;
|
||||
text-decoration: none;
|
||||
}
|
||||
a::selection {
|
||||
color: $background;
|
||||
color: $background;
|
||||
}
|
||||
a::-moz-selection {
|
||||
color: $background;
|
||||
color: $background;
|
||||
}
|
||||
small {
|
||||
margin-left: 1em;
|
||||
color: transparentize($text-color, 0.6);
|
||||
font-size: 0.75em;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
margin-left: 1em;
|
||||
color: transparentize($text-color, 0.6);
|
||||
font-size: 0.75em;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
opacity: 0.6;
|
||||
padding: 5em;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
opacity: 0.6;
|
||||
padding: 5em;
|
||||
}
|
||||
|
||||
.right {
|
||||
@@ -53,28 +53,28 @@ small {
|
||||
}
|
||||
|
||||
.spaced {
|
||||
margin: 4rem 0;
|
||||
margin: 4rem 0;
|
||||
}
|
||||
|
||||
.banner {
|
||||
background: $gray;
|
||||
padding-top: 2em;
|
||||
padding-bottom: 1em;
|
||||
margin: 3em 0px;
|
||||
background: $gray;
|
||||
padding-top: 2em;
|
||||
padding-bottom: 1em;
|
||||
margin: 3em 0px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
appearance: none;
|
||||
display: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Main */
|
||||
body > main > *, .h-feed > * {
|
||||
margin: 1em $horizontal-margin;
|
||||
margin: 1em $horizontal-margin;
|
||||
}
|
||||
|
||||
body > main > .h-entry, .h-feed {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body > main {
|
||||
@@ -98,18 +98,18 @@ main {
|
||||
margin-top: 1em;
|
||||
|
||||
&.article {
|
||||
margin: 1em auto 0.5em;
|
||||
font-family: $playfair;
|
||||
font-size: 2.5em;
|
||||
font-weight: normal;
|
||||
margin: 1em auto 0.5em;
|
||||
font-family: $playfair;
|
||||
font-size: 2.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.75em;
|
||||
font-weight: 300;
|
||||
font-size: 1.75em;
|
||||
font-weight: 300;
|
||||
|
||||
&.article {
|
||||
&.article {
|
||||
font-size: 1.25em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
@@ -139,15 +139,15 @@ main {
|
||||
|
||||
/* Errors */
|
||||
p.error {
|
||||
color: $red;
|
||||
font-weight: bold;
|
||||
color: $red;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* User page */
|
||||
.user h1 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
@@ -156,14 +156,14 @@ p.error {
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-right: 1em;
|
||||
padding: 0.35em 1em;
|
||||
margin-right: 1em;
|
||||
padding: 0.35em 1em;
|
||||
|
||||
background: $background;
|
||||
color: $primary;
|
||||
border: 1px solid $primary;
|
||||
background: $background;
|
||||
color: $primary;
|
||||
border: 1px solid $primary;
|
||||
|
||||
font-size: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.user-summary {
|
||||
@@ -172,23 +172,25 @@ p.error {
|
||||
|
||||
/* Cards */
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 5%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 5%;
|
||||
margin: 1rem 0 5rem;
|
||||
}
|
||||
.card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
min-width: 20em;
|
||||
min-height: 20em;
|
||||
margin: 1em;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
|
||||
background: $gray;
|
||||
min-width: 20em;
|
||||
min-height: 20em;
|
||||
margin: 1em;
|
||||
box-sizing: border-box;
|
||||
|
||||
background: $gray;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -213,38 +215,68 @@ p.error {
|
||||
}
|
||||
|
||||
|
||||
> * {
|
||||
margin: 20px;
|
||||
}
|
||||
> * {
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
.cover-link {
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.cover {
|
||||
min-height: 10em;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0.75em 20px;
|
||||
font-family: $playfair;
|
||||
font-size: 1.75em;
|
||||
font-weight: normal;
|
||||
a {
|
||||
transition: color 0.1s ease-in;
|
||||
color: $text-color;
|
||||
header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:hover { color: $primary; }
|
||||
}
|
||||
h3 {
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
font-family: $playfair;
|
||||
font-size: 1.75em;
|
||||
font-weight: normal;
|
||||
line-height: 1.10;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-block-start: 0.5em;
|
||||
transition: color 0.1s ease-in;
|
||||
color: $text-color;
|
||||
|
||||
&:hover { color: $primary; }
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-shrink: 0;
|
||||
text-align: end;
|
||||
|
||||
.button {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
|
||||
font-family: $lora;
|
||||
font-size: 1em;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
font-family: $lora;
|
||||
font-size: 1em;
|
||||
line-height: 1.25;
|
||||
text-align: initial;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,15 +318,15 @@ p.error {
|
||||
|
||||
/* Stats */
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 2em;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 2em;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
p {
|
||||
@@ -447,9 +479,10 @@ figure {
|
||||
|
||||
/// Avatars
|
||||
.avatar {
|
||||
background-position: center;
|
||||
background-position: center !important;
|
||||
background-size: cover;
|
||||
border-radius: 100%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.small {
|
||||
width: 50px;
|
||||
@@ -474,6 +507,7 @@ figure {
|
||||
margin: auto $horizontal-margin 2em;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
@@ -490,6 +524,10 @@ figure {
|
||||
|
||||
/// Small screens
|
||||
@media screen and (max-width: 600px) {
|
||||
body > main > *, .h-feed > * {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
main .article-meta {
|
||||
> *, .comments {
|
||||
margin: 0 5%;
|
||||
@@ -535,15 +573,7 @@ figure {
|
||||
margin: 0;
|
||||
|
||||
& > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
& > div {
|
||||
margin: 0;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ body > header {
|
||||
|
||||
#content {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
justify-content: space-between;
|
||||
align-content: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
nav#menu {
|
||||
@@ -19,44 +19,44 @@ body > header {
|
||||
|
||||
a {
|
||||
transform: skewX(15deg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: $gray;
|
||||
font-size: 1.33em;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: $gray;
|
||||
font-size: 1.33em;
|
||||
}
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
hr {
|
||||
height: 100%;
|
||||
width: 0.2em;
|
||||
background: $primary;
|
||||
border: none;
|
||||
transform: skewX(-15deg);
|
||||
hr {
|
||||
height: 100%;
|
||||
width: 0.2em;
|
||||
background: $primary;
|
||||
border: none;
|
||||
transform: skewX(-15deg);
|
||||
}
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
margin: 0;
|
||||
padding: 0 2em;
|
||||
font-size: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
margin: 0;
|
||||
padding: 0 2em;
|
||||
font-size: 1em;
|
||||
|
||||
i { font-size: 1.2em; }
|
||||
i { font-size: 1.2em; }
|
||||
|
||||
&.title {
|
||||
margin: 0;
|
||||
&.title {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
padding: 0.5em 1em;
|
||||
font-size: 1.75em;
|
||||
@@ -70,7 +70,7 @@ body > header {
|
||||
margin: 0;
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,18 @@ body > header {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes menuOpening {
|
||||
from {
|
||||
-webkit-transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
-webkit-transform: scaleX(1);
|
||||
transform-origin: left;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
body > header {
|
||||
flex-direction: column;
|
||||
@@ -132,7 +144,7 @@ body > header {
|
||||
}
|
||||
}
|
||||
|
||||
body > header:focus-within #content, #content.show {
|
||||
body > header:focus-within #content, .show + #content {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -193,31 +205,133 @@ body > header {
|
||||
|
||||
/* Only enable label animations on large screens */
|
||||
@media screen and (min-width: 600px) {
|
||||
header nav a {
|
||||
i {
|
||||
transition: all 0.2s ease;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-label {
|
||||
transition: all 0.2s ease;
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
opacity: 0;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
img + .mobile-label { display: none; }
|
||||
|
||||
&:hover {
|
||||
i { margin-bottom: 0.75em; }
|
||||
.mobile-label {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 80%);
|
||||
}
|
||||
header nav a {
|
||||
i {
|
||||
transition: all 0.2s ease;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-label {
|
||||
transition: all 0.2s ease;
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateZ(0);
|
||||
opacity: 0;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
img + .mobile-label { display: none; }
|
||||
|
||||
&:hover {
|
||||
i { margin-bottom: 0.75em; }
|
||||
.mobile-label {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 80%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small screens
|
||||
@media screen and (max-width: 600px) {
|
||||
@keyframes menuOpening {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
transform-origin: left;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes menuOpening {
|
||||
from {
|
||||
-webkit-transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
-webkit-transform: scaleX(1);
|
||||
transform-origin: left;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
body > header {
|
||||
flex-direction: column;
|
||||
|
||||
nav#menu {
|
||||
display: inline-flex;
|
||||
z-index: 21;
|
||||
}
|
||||
|
||||
#content {
|
||||
display: none;
|
||||
appearance: none;
|
||||
text-align: center;
|
||||
z-index: 20;
|
||||
}
|
||||
}
|
||||
|
||||
body > header:focus-within #content, .show + #content {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
animation: 0.2s menuOpening;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
transform: skewX(-10deg);
|
||||
top: 0;
|
||||
left: -20%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
z-index: -10;
|
||||
|
||||
background: $primary;
|
||||
}
|
||||
|
||||
> nav {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 1rem 1.5rem;
|
||||
color: $background;
|
||||
font-size: 1.4em;
|
||||
font-weight: 300;
|
||||
|
||||
&.title { font-size: 1.8em; }
|
||||
|
||||
> *:first-child { width: 3rem; }
|
||||
> img:first-child { height: 3rem; }
|
||||
> *:last-child { margin-left: 1rem; }
|
||||
> nav hr {
|
||||
display: block;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
border: solid $background 0.1rem;
|
||||
}
|
||||
.mobile-label { display: initial; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use rsass;
|
||||
|
||||
use ructe::Ructe;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::{ffi::OsStr, fs::*, io::Write, path::*};
|
||||
@@ -43,26 +41,20 @@ fn main() {
|
||||
.expect("compile templates");
|
||||
|
||||
compile_themes().expect("Theme compilation error");
|
||||
recursive_copy(&Path::new("assets").join("icons"), &Path::new("static"))
|
||||
recursive_copy(&Path::new("assets").join("icons"), Path::new("static"))
|
||||
.expect("Couldn't copy icons");
|
||||
recursive_copy(&Path::new("assets").join("images"), &Path::new("static"))
|
||||
recursive_copy(&Path::new("assets").join("images"), Path::new("static"))
|
||||
.expect("Couldn't copy images");
|
||||
create_dir_all(&Path::new("static").join("media")).expect("Couldn't init media directory");
|
||||
|
||||
let cache_id = &compute_static_hash()[..8];
|
||||
println!("cargo:rerun-if-changed=target/deploy/plume-front.wasm");
|
||||
copy("target/deploy/plume-front.wasm", "static/plume-front.wasm")
|
||||
.and_then(|_| read_to_string("target/deploy/plume-front.js"))
|
||||
.and_then(|js| {
|
||||
write(
|
||||
"static/plume-front.js",
|
||||
js.replace(
|
||||
"\"plume-front.wasm\"",
|
||||
&format!("\"/static/cached/{}/plume-front.wasm\"", cache_id),
|
||||
),
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
println!("cargo:rerun-if-changed=plume-front/pkg/plume_front_bg.wasm");
|
||||
copy(
|
||||
"plume-front/pkg/plume_front_bg.wasm",
|
||||
"static/plume_front_bg.wasm",
|
||||
)
|
||||
.and_then(|_| copy("plume-front/pkg/plume_front.js", "static/plume_front.js"))
|
||||
.ok();
|
||||
|
||||
println!("cargo:rustc-env=CACHE_ID={}", cache_id)
|
||||
}
|
||||
@@ -105,12 +97,12 @@ fn compile_theme(path: &Path, out_dir: &Path) -> std::io::Result<()> {
|
||||
.components()
|
||||
.skip_while(|c| *c != Component::Normal(OsStr::new("themes")))
|
||||
.skip(1)
|
||||
.filter_map(|c| {
|
||||
.map(|c| {
|
||||
c.as_os_str()
|
||||
.to_str()
|
||||
.unwrap_or_default()
|
||||
.splitn(2, '.')
|
||||
.next()
|
||||
.split_once('.')
|
||||
.map_or(c.as_os_str().to_str().unwrap_or_default(), |x| x.0)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
@@ -128,8 +120,14 @@ fn compile_theme(path: &Path, out_dir: &Path) -> std::io::Result<()> {
|
||||
// compile the .scss/.sass file
|
||||
let mut out = File::create(out.join("theme.css"))?;
|
||||
out.write_all(
|
||||
&rsass::compile_scss_file(path, rsass::OutputStyle::Compressed)
|
||||
.expect("SCSS compilation error"),
|
||||
&rsass::compile_scss_path(
|
||||
path,
|
||||
rsass::output::Format {
|
||||
style: rsass::output::Style::Compressed,
|
||||
..rsass::output::Format::default()
|
||||
},
|
||||
)
|
||||
.expect("SCSS compilation error"),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
"project_identifier": "plume"
|
||||
"api_key_env": CROWDIN_API_KEY
|
||||
"project_id": 352097
|
||||
"api_token_env": "CROWDIN_API_KEY"
|
||||
preserve_hierarchy: true
|
||||
files:
|
||||
- source: /po/plume/plume.pot
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX medias_index_file_path;
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX medias_index_file_path ON medias (file_path);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE email_signups;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE email_signups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR NOT NULL,
|
||||
token VARCHAR NOT NULL,
|
||||
expiration_date TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX email_signups_token ON email_signups (token);
|
||||
CREATE UNIQUE INDEX email_signups_token_requests_email ON email_signups (email);
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE email_blocklist ALTER COLUMN notification_text DROP NOT NULL;
|
||||
ALTER TABLE email_blocklist ALTER COLUMN notify_user DROP NOT NULL;
|
||||
ALTER TABLE email_blocklist ALTER COLUMN note DROP NOT NULL;
|
||||
ALTER TABLE email_blocklist ALTER COLUMN email_address DROP NOT NULL;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE email_blocklist ALTER COLUMN email_address SET NOT NULL;
|
||||
ALTER TABLE email_blocklist ALTER COLUMN note SET NOT NULL;
|
||||
ALTER TABLE email_blocklist ALTER COLUMN notify_user SET NOT NULL;
|
||||
ALTER TABLE email_blocklist ALTER COLUMN notification_text SET NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX medias_index_file_path;
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX medias_index_file_path ON medias (file_path);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE email_signups;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE email_signups (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
email VARCHAR NOT NULL,
|
||||
token VARCHAR NOT NULL,
|
||||
expiration_date TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX email_signups_token ON email_signups (token);
|
||||
CREATE UNIQUE INDEX email_signups_token_requests_email ON email_signups (email);
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE email_blocklist2(id INTEGER PRIMARY KEY,
|
||||
email_address TEXT UNIQUE,
|
||||
note TEXT,
|
||||
notify_user BOOLEAN DEFAULT FALSE,
|
||||
notification_text TEXT);
|
||||
|
||||
INSERT INTO email_blocklist2 SELECT * FROM email_blocklist;
|
||||
DROP TABLE email_blocklist;
|
||||
ALTER TABLE email_blocklist2 RENAME TO email_blocklist;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE email_blocklist2(id INTEGER PRIMARY KEY,
|
||||
email_address TEXT UNIQUE NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
notify_user BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
notification_text TEXT NOT NULL);
|
||||
|
||||
INSERT INTO email_blocklist2 SELECT * FROM email_blocklist;
|
||||
DROP TABLE email_blocklist;
|
||||
ALTER TABLE email_blocklist2 RENAME TO email_blocklist;
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "plume-api"
|
||||
version = "0.4.0"
|
||||
version = "0.7.2"
|
||||
authors = ["Plume contributors"]
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
serde = "1.0"
|
||||
serde = "1.0.137"
|
||||
serde_derive = "1.0"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pre-release-hook = ["cargo", "fmt"]
|
||||
pre-release-replacements = []
|
||||
release = false
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "plume-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.7.2"
|
||||
authors = ["Plume contributors"]
|
||||
edition = "2018"
|
||||
|
||||
@@ -10,12 +10,12 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = "2.33"
|
||||
dotenv = "0.14"
|
||||
rpassword = "4.0"
|
||||
dotenv = "0.15"
|
||||
rpassword = "6.0.1"
|
||||
|
||||
[dependencies.diesel]
|
||||
features = ["r2d2", "chrono"]
|
||||
version = "*"
|
||||
version = "1.4.5"
|
||||
|
||||
[dependencies.plume-models]
|
||||
path = "../plume-models"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pre-release-hook = ["cargo", "fmt"]
|
||||
pre-release-replacements = []
|
||||
release = false
|
||||
@@ -68,4 +68,6 @@ fn new<'a>(args: &ArgMatches<'a>, conn: &Connection) {
|
||||
},
|
||||
)
|
||||
.expect("Couldn't save instance");
|
||||
Instance::cache_local(conn);
|
||||
Instance::create_local_instance_user(conn).expect("Couldn't save local instance user");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
use clap::{App, Arg, ArgMatches, SubCommand};
|
||||
|
||||
use plume_models::{blogs::Blog, instance::Instance, lists::*, users::User, Connection};
|
||||
|
||||
pub fn command<'a, 'b>() -> App<'a, 'b> {
|
||||
SubCommand::with_name("lists")
|
||||
.about("Manage lists")
|
||||
.subcommand(
|
||||
SubCommand::with_name("new")
|
||||
.arg(
|
||||
Arg::with_name("name")
|
||||
.short("n")
|
||||
.long("name")
|
||||
.takes_value(true)
|
||||
.help("The name of this list"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("type")
|
||||
.short("t")
|
||||
.long("type")
|
||||
.takes_value(true)
|
||||
.help(
|
||||
r#"The type of this list (one of "user", "blog", "word" or "prefix")"#,
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("user")
|
||||
.short("u")
|
||||
.long("user")
|
||||
.takes_value(true)
|
||||
.help("Username of whom this list is for. Empty for an instance list"),
|
||||
)
|
||||
.about("Create a new list"),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("delete")
|
||||
.arg(
|
||||
Arg::with_name("name")
|
||||
.short("n")
|
||||
.long("name")
|
||||
.takes_value(true)
|
||||
.help("The name of the list to delete"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("user")
|
||||
.short("u")
|
||||
.long("user")
|
||||
.takes_value(true)
|
||||
.help("Username of whom this list was for. Empty for instance list"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("yes")
|
||||
.short("y")
|
||||
.long("yes")
|
||||
.help("Confirm the deletion"),
|
||||
)
|
||||
.about("Delete a list"),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("add")
|
||||
.arg(
|
||||
Arg::with_name("name")
|
||||
.short("n")
|
||||
.long("name")
|
||||
.takes_value(true)
|
||||
.help("The name of the list to add an element to"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("user")
|
||||
.short("u")
|
||||
.long("user")
|
||||
.takes_value(true)
|
||||
.help("Username of whom this list is for. Empty for instance list"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("value")
|
||||
.short("v")
|
||||
.long("value")
|
||||
.takes_value(true)
|
||||
.help("The value to add"),
|
||||
)
|
||||
.about("Add element to a list"),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("rm")
|
||||
.arg(
|
||||
Arg::with_name("name")
|
||||
.short("n")
|
||||
.long("name")
|
||||
.takes_value(true)
|
||||
.help("The name of the list to remove an element from"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("user")
|
||||
.short("u")
|
||||
.long("user")
|
||||
.takes_value(true)
|
||||
.help("Username of whom this list is for. Empty for instance list"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("value")
|
||||
.short("v")
|
||||
.long("value")
|
||||
.takes_value(true)
|
||||
.help("The value to remove"),
|
||||
)
|
||||
.about("Remove element from list"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run<'a>(args: &ArgMatches<'a>, conn: &Connection) {
|
||||
let conn = conn;
|
||||
match args.subcommand() {
|
||||
("new", Some(x)) => new(x, conn),
|
||||
("delete", Some(x)) => delete(x, conn),
|
||||
("add", Some(x)) => add(x, conn),
|
||||
("rm", Some(x)) => rm(x, conn),
|
||||
("", None) => command().print_help().unwrap(),
|
||||
_ => println!("Unknown subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_list_identifier(args: &ArgMatches<'_>) -> (String, Option<String>) {
|
||||
let name = args
|
||||
.value_of("name")
|
||||
.map(String::from)
|
||||
.expect("No name provided for the list");
|
||||
let user = args.value_of("user").map(String::from);
|
||||
(name, user)
|
||||
}
|
||||
|
||||
fn get_list_type(args: &ArgMatches<'_>) -> ListType {
|
||||
let typ = args
|
||||
.value_of("type")
|
||||
.map(String::from)
|
||||
.expect("No name type for the list");
|
||||
match typ.as_str() {
|
||||
"user" => ListType::User,
|
||||
"blog" => ListType::Blog,
|
||||
"word" => ListType::Word,
|
||||
"prefix" => ListType::Prefix,
|
||||
_ => panic!("Invalid list type: {}", typ),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_value(args: &ArgMatches<'_>) -> String {
|
||||
args.value_of("value")
|
||||
.map(String::from)
|
||||
.expect("No query provided")
|
||||
}
|
||||
|
||||
fn resolve_user(username: &str, conn: &Connection) -> User {
|
||||
let instance = Instance::get_local_uncached(conn).expect("Failed to load local instance");
|
||||
|
||||
User::find_by_name(conn, username, instance.id).expect("User not found")
|
||||
}
|
||||
|
||||
fn new(args: &ArgMatches<'_>, conn: &Connection) {
|
||||
let (name, user) = get_list_identifier(args);
|
||||
let typ = get_list_type(args);
|
||||
|
||||
let user = user.map(|user| resolve_user(&user, conn));
|
||||
|
||||
List::new(conn, &name, user.as_ref(), typ).expect("failed to create list");
|
||||
}
|
||||
|
||||
fn delete(args: &ArgMatches<'_>, conn: &Connection) {
|
||||
let (name, user) = get_list_identifier(args);
|
||||
|
||||
if !args.is_present("yes") {
|
||||
panic!("Warning, this operation is destructive. Add --yes to confirm you want to do it.")
|
||||
}
|
||||
|
||||
let user = user.map(|user| resolve_user(&user, conn));
|
||||
|
||||
let list =
|
||||
List::find_for_user_by_name(conn, user.map(|u| u.id), &name).expect("list not found");
|
||||
|
||||
list.delete(conn).expect("Failed to update list");
|
||||
}
|
||||
|
||||
fn add(args: &ArgMatches<'_>, conn: &Connection) {
|
||||
let (name, user) = get_list_identifier(args);
|
||||
let value = get_value(args);
|
||||
|
||||
let user = user.map(|user| resolve_user(&user, conn));
|
||||
|
||||
let list =
|
||||
List::find_for_user_by_name(conn, user.map(|u| u.id), &name).expect("list not found");
|
||||
|
||||
match list.kind() {
|
||||
ListType::Blog => {
|
||||
let blog_id = Blog::find_by_fqn(conn, &value).expect("unknown blog").id;
|
||||
if !list.contains_blog(conn, blog_id).unwrap() {
|
||||
list.add_blogs(conn, &[blog_id]).unwrap();
|
||||
}
|
||||
}
|
||||
ListType::User => {
|
||||
let user_id = User::find_by_fqn(conn, &value).expect("unknown user").id;
|
||||
if !list.contains_user(conn, user_id).unwrap() {
|
||||
list.add_users(conn, &[user_id]).unwrap();
|
||||
}
|
||||
}
|
||||
ListType::Word => {
|
||||
if !list.contains_word(conn, &value).unwrap() {
|
||||
list.add_words(conn, &[&value]).unwrap();
|
||||
}
|
||||
}
|
||||
ListType::Prefix => {
|
||||
if !list.contains_prefix(conn, &value).unwrap() {
|
||||
list.add_prefixes(conn, &[&value]).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rm(args: &ArgMatches<'_>, conn: &Connection) {
|
||||
let (name, user) = get_list_identifier(args);
|
||||
let value = get_value(args);
|
||||
|
||||
let user = user.map(|user| resolve_user(&user, conn));
|
||||
|
||||
let list =
|
||||
List::find_for_user_by_name(conn, user.map(|u| u.id), &name).expect("list not found");
|
||||
|
||||
match list.kind() {
|
||||
ListType::Blog => {
|
||||
let blog_id = Blog::find_by_fqn(conn, &value).expect("unknown blog").id;
|
||||
let mut blogs = list.list_blogs(conn).unwrap();
|
||||
if let Some(index) = blogs.iter().position(|b| b.id == blog_id) {
|
||||
blogs.swap_remove(index);
|
||||
let blogs = blogs.iter().map(|b| b.id).collect::<Vec<_>>();
|
||||
list.set_blogs(conn, &blogs).unwrap();
|
||||
}
|
||||
}
|
||||
ListType::User => {
|
||||
let user_id = User::find_by_fqn(conn, &value).expect("unknown user").id;
|
||||
let mut users = list.list_users(conn).unwrap();
|
||||
if let Some(index) = users.iter().position(|u| u.id == user_id) {
|
||||
users.swap_remove(index);
|
||||
let users = users.iter().map(|u| u.id).collect::<Vec<_>>();
|
||||
list.set_users(conn, &users).unwrap();
|
||||
}
|
||||
}
|
||||
ListType::Word => {
|
||||
let mut words = list.list_words(conn).unwrap();
|
||||
if let Some(index) = words.iter().position(|w| *w == value) {
|
||||
words.swap_remove(index);
|
||||
let words = words.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
list.set_words(conn, &words).unwrap();
|
||||
}
|
||||
}
|
||||
ListType::Prefix => {
|
||||
let mut prefixes = list.list_prefixes(conn).unwrap();
|
||||
if let Some(index) = prefixes.iter().position(|p| *p == value) {
|
||||
prefixes.swap_remove(index);
|
||||
let prefixes = prefixes.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
list.set_prefixes(conn, &prefixes).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
use dotenv;
|
||||
|
||||
use clap::App;
|
||||
use diesel::Connection;
|
||||
use plume_models::{instance::Instance, Connection as Conn, CONFIG};
|
||||
use std::io::{self, prelude::*};
|
||||
|
||||
mod instance;
|
||||
mod list;
|
||||
mod migration;
|
||||
mod search;
|
||||
mod timeline;
|
||||
mod users;
|
||||
|
||||
fn main() {
|
||||
@@ -18,6 +18,8 @@ fn main() {
|
||||
.subcommand(instance::command())
|
||||
.subcommand(migration::command())
|
||||
.subcommand(search::command())
|
||||
.subcommand(timeline::command())
|
||||
.subcommand(list::command())
|
||||
.subcommand(users::command());
|
||||
let matches = app.clone().get_matches();
|
||||
|
||||
@@ -27,7 +29,7 @@ fn main() {
|
||||
e => e.map(|_| ()).unwrap(),
|
||||
}
|
||||
let conn = Conn::establish(CONFIG.database_url.as_str());
|
||||
let _ = conn.as_ref().map(|conn| Instance::cache_local(conn));
|
||||
let _ = conn.as_ref().map(Instance::cache_local);
|
||||
|
||||
match matches.subcommand() {
|
||||
("instance", Some(args)) => {
|
||||
@@ -39,6 +41,10 @@ fn main() {
|
||||
("search", Some(args)) => {
|
||||
search::run(args, &conn.expect("Couldn't connect to the database."))
|
||||
}
|
||||
("timeline", Some(args)) => {
|
||||
timeline::run(args, &conn.expect("Couldn't connect to the database."))
|
||||
}
|
||||
("lists", Some(args)) => list::run(args, &conn.expect("Couldn't connect to the database.")),
|
||||
("users", Some(args)) => {
|
||||
users::run(args, &conn.expect("Couldn't connect to the database."))
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ fn refill<'a>(args: &ArgMatches<'a>, conn: &Connection, searcher: Option<Searche
|
||||
searcher.commit();
|
||||
}
|
||||
|
||||
fn unlock<'a>(args: &ArgMatches<'a>) {
|
||||
fn unlock(args: &ArgMatches) {
|
||||
let path = match args.value_of("path") {
|
||||
None => Path::new(&CONFIG.search_index),
|
||||
Some(x) => Path::new(x),
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
use clap::{App, Arg, ArgMatches, SubCommand};
|
||||
|
||||
use plume_models::{instance::Instance, posts::Post, timeline::*, users::*, Connection};
|
||||
|
||||
pub fn command<'a, 'b>() -> App<'a, 'b> {
|
||||
SubCommand::with_name("timeline")
|
||||
.about("Manage public timeline")
|
||||
.subcommand(
|
||||
SubCommand::with_name("new")
|
||||
.arg(
|
||||
Arg::with_name("name")
|
||||
.short("n")
|
||||
.long("name")
|
||||
.takes_value(true)
|
||||
.help("The name of this timeline"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("query")
|
||||
.short("q")
|
||||
.long("query")
|
||||
.takes_value(true)
|
||||
.help("The query posts in this timelines have to match"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("user")
|
||||
.short("u")
|
||||
.long("user")
|
||||
.takes_value(true)
|
||||
.help(
|
||||
"Username of whom this timeline is for. Empty for an instance timeline",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("preload-count")
|
||||
.short("p")
|
||||
.long("preload-count")
|
||||
.takes_value(true)
|
||||
.help("Number of posts to try to preload in this timeline at its creation"),
|
||||
)
|
||||
.about("Create a new timeline"),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("delete")
|
||||
.arg(
|
||||
Arg::with_name("name")
|
||||
.short("n")
|
||||
.long("name")
|
||||
.takes_value(true)
|
||||
.help("The name of the timeline to delete"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("user")
|
||||
.short("u")
|
||||
.long("user")
|
||||
.takes_value(true)
|
||||
.help(
|
||||
"Username of whom this timeline was for. Empty for instance timeline",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("yes")
|
||||
.short("y")
|
||||
.long("yes")
|
||||
.help("Confirm the deletion"),
|
||||
)
|
||||
.about("Delete a timeline"),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("edit")
|
||||
.arg(
|
||||
Arg::with_name("name")
|
||||
.short("n")
|
||||
.long("name")
|
||||
.takes_value(true)
|
||||
.help("The name of the timeline to edit"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("user")
|
||||
.short("u")
|
||||
.long("user")
|
||||
.takes_value(true)
|
||||
.help("Username of whom this timeline is for. Empty for instance timeline"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("query")
|
||||
.short("q")
|
||||
.long("query")
|
||||
.takes_value(true)
|
||||
.help("The query posts in this timelines have to match"),
|
||||
)
|
||||
.about("Edit the query of a timeline"),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("repopulate")
|
||||
.arg(
|
||||
Arg::with_name("name")
|
||||
.short("n")
|
||||
.long("name")
|
||||
.takes_value(true)
|
||||
.help("The name of the timeline to repopulate"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("user")
|
||||
.short("u")
|
||||
.long("user")
|
||||
.takes_value(true)
|
||||
.help(
|
||||
"Username of whom this timeline was for. Empty for instance timeline",
|
||||
),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("preload-count")
|
||||
.short("p")
|
||||
.long("preload-count")
|
||||
.takes_value(true)
|
||||
.help("Number of posts to try to preload in this timeline at its creation"),
|
||||
)
|
||||
.about("Repopulate a timeline. Run this after modifying a list the timeline depends on."),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run<'a>(args: &ArgMatches<'a>, conn: &Connection) {
|
||||
let conn = conn;
|
||||
match args.subcommand() {
|
||||
("new", Some(x)) => new(x, conn),
|
||||
("edit", Some(x)) => edit(x, conn),
|
||||
("delete", Some(x)) => delete(x, conn),
|
||||
("repopulate", Some(x)) => repopulate(x, conn),
|
||||
("", None) => command().print_help().unwrap(),
|
||||
_ => println!("Unknown subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_timeline_identifier(args: &ArgMatches<'_>) -> (String, Option<String>) {
|
||||
let name = args
|
||||
.value_of("name")
|
||||
.map(String::from)
|
||||
.expect("No name provided for the timeline");
|
||||
let user = args.value_of("user").map(String::from);
|
||||
(name, user)
|
||||
}
|
||||
|
||||
fn get_query(args: &ArgMatches<'_>) -> String {
|
||||
let query = args
|
||||
.value_of("query")
|
||||
.map(String::from)
|
||||
.expect("No query provided");
|
||||
|
||||
match TimelineQuery::parse(&query) {
|
||||
Ok(_) => (),
|
||||
Err(QueryError::SyntaxError(start, end, message)) => panic!(
|
||||
"Query parsing error between {} and {}: {}",
|
||||
start, end, message
|
||||
),
|
||||
Err(QueryError::UnexpectedEndOfQuery) => {
|
||||
panic!("Query parsing error: unexpected end of query")
|
||||
}
|
||||
Err(QueryError::RuntimeError(message)) => panic!("Query parsing error: {}", message),
|
||||
}
|
||||
|
||||
query
|
||||
}
|
||||
|
||||
fn get_preload_count(args: &ArgMatches<'_>) -> usize {
|
||||
args.value_of("preload-count")
|
||||
.map(|arg| arg.parse().expect("invalid preload-count"))
|
||||
.unwrap_or(plume_models::ITEMS_PER_PAGE as usize)
|
||||
}
|
||||
|
||||
fn resolve_user(username: &str, conn: &Connection) -> User {
|
||||
let instance = Instance::get_local_uncached(conn).expect("Failed to load local instance");
|
||||
|
||||
User::find_by_name(conn, username, instance.id).expect("User not found")
|
||||
}
|
||||
|
||||
fn preload(timeline: Timeline, count: usize, conn: &Connection) {
|
||||
timeline.remove_all_posts(conn).unwrap();
|
||||
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut posts = Vec::with_capacity(count as usize);
|
||||
for post in Post::list_filtered(conn, None, None, None)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.rev()
|
||||
{
|
||||
if timeline.matches(conn, &post, Kind::Original).unwrap() {
|
||||
posts.push(post);
|
||||
if posts.len() >= count {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for post in posts.iter().rev() {
|
||||
timeline.add_post(conn, post).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn new(args: &ArgMatches<'_>, conn: &Connection) {
|
||||
let (name, user) = get_timeline_identifier(args);
|
||||
let query = get_query(args);
|
||||
let preload_count = get_preload_count(args);
|
||||
|
||||
let user = user.map(|user| resolve_user(&user, conn));
|
||||
|
||||
let timeline = if let Some(user) = user {
|
||||
Timeline::new_for_user(conn, user.id, name, query)
|
||||
} else {
|
||||
Timeline::new_for_instance(conn, name, query)
|
||||
}
|
||||
.expect("Failed to create new timeline");
|
||||
|
||||
preload(timeline, preload_count, conn);
|
||||
}
|
||||
|
||||
fn edit(args: &ArgMatches<'_>, conn: &Connection) {
|
||||
let (name, user) = get_timeline_identifier(args);
|
||||
let query = get_query(args);
|
||||
|
||||
let user = user.map(|user| resolve_user(&user, conn));
|
||||
|
||||
let mut timeline = Timeline::find_for_user_by_name(conn, user.map(|u| u.id), &name)
|
||||
.expect("timeline not found");
|
||||
|
||||
timeline.query = query;
|
||||
|
||||
timeline.update(conn).expect("Failed to update timeline");
|
||||
}
|
||||
|
||||
fn delete(args: &ArgMatches<'_>, conn: &Connection) {
|
||||
let (name, user) = get_timeline_identifier(args);
|
||||
|
||||
if !args.is_present("yes") {
|
||||
panic!("Warning, this operation is destructive. Add --yes to confirm you want to do it.")
|
||||
}
|
||||
|
||||
let user = user.map(|user| resolve_user(&user, conn));
|
||||
|
||||
let timeline = Timeline::find_for_user_by_name(conn, user.map(|u| u.id), &name)
|
||||
.expect("timeline not found");
|
||||
|
||||
timeline.delete(conn).expect("Failed to update timeline");
|
||||
}
|
||||
|
||||
fn repopulate(args: &ArgMatches<'_>, conn: &Connection) {
|
||||
let (name, user) = get_timeline_identifier(args);
|
||||
let preload_count = get_preload_count(args);
|
||||
|
||||
let user = user.map(|user| resolve_user(&user, conn));
|
||||
|
||||
let timeline = Timeline::find_for_user_by_name(conn, user.map(|u| u.id), &name)
|
||||
.expect("timeline not found");
|
||||
preload(timeline, preload_count, conn);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use clap::{App, Arg, ArgMatches, SubCommand};
|
||||
|
||||
use plume_models::{instance::Instance, users::*, Connection};
|
||||
use rpassword;
|
||||
use std::io::{self, Write};
|
||||
|
||||
pub fn command<'a, 'b>() -> App<'a, 'b> {
|
||||
@@ -132,7 +131,7 @@ fn new<'a>(args: &ArgMatches<'a>, conn: &Connection) {
|
||||
role,
|
||||
&bio,
|
||||
email,
|
||||
User::hash_pass(&password).expect("Couldn't hash password"),
|
||||
Some(User::hash_pass(&password).expect("Couldn't hash password")),
|
||||
)
|
||||
.expect("Couldn't save new user");
|
||||
}
|
||||
|
||||
+27
-18
@@ -1,28 +1,30 @@
|
||||
[package]
|
||||
name = "plume-common"
|
||||
version = "0.4.0"
|
||||
version = "0.7.2"
|
||||
authors = ["Plume contributors"]
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
activitypub = "0.1.1"
|
||||
activitystreams-derive = "0.1.1"
|
||||
activitystreams-traits = "0.1.0"
|
||||
array_tool = "1.0"
|
||||
base64 = "0.10"
|
||||
heck = "0.3.0"
|
||||
hex = "0.3"
|
||||
hyper = "0.12.33"
|
||||
openssl = "0.10.22"
|
||||
rocket = "0.4.0"
|
||||
reqwest = "0.9"
|
||||
serde = "1.0"
|
||||
base64 = "0.13"
|
||||
hex = "0.4"
|
||||
openssl = "0.10.40"
|
||||
rocket = "0.4.11"
|
||||
reqwest = { version = "0.11.11", features = ["blocking", "json", "socks"] }
|
||||
serde = "1.0.137"
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
||||
shrinkwraprs = "0.2.1"
|
||||
syntect = "3.3"
|
||||
tokio = "0.1.22"
|
||||
regex-syntax = { version = "0.6.17", default-features = false, features = ["unicode-perl"] }
|
||||
serde_json = "1.0.81"
|
||||
shrinkwraprs = "0.3.0"
|
||||
syntect = "4.5.0"
|
||||
regex-syntax = { version = "0.6.26", default-features = false, features = ["unicode-perl"] }
|
||||
tracing = "0.1.35"
|
||||
askama_escape = "0.10.3"
|
||||
activitystreams = "=0.7.0-alpha.20"
|
||||
activitystreams-ext = "0.1.0-alpha.2"
|
||||
url = "2.2.2"
|
||||
flume = "0.10.13"
|
||||
tokio = { version = "1.19.2", features = ["full"] }
|
||||
futures = "0.3.25"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
@@ -30,4 +32,11 @@ version = "0.4"
|
||||
|
||||
[dependencies.pulldown-cmark]
|
||||
default-features = false
|
||||
version = "0.2.0"
|
||||
git = "https://git.joinplu.me/Plume/pulldown-cmark"
|
||||
branch = "bidi-plume"
|
||||
|
||||
[dev-dependencies]
|
||||
assert-json-diff = "2.0.1"
|
||||
once_cell = "1.12.0"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pre-release-hook = ["cargo", "fmt"]
|
||||
pre-release-replacements = []
|
||||
release = false
|
||||
@@ -1,6 +1,8 @@
|
||||
use reqwest::header::{HeaderValue, ACCEPT};
|
||||
use reqwest;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use super::{request, sign::Signer};
|
||||
|
||||
/// Represents an ActivityPub inbox.
|
||||
///
|
||||
/// It routes an incoming Activity through the registered handlers.
|
||||
@@ -8,9 +10,51 @@ use std::fmt::Debug;
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// # extern crate activitypub;
|
||||
/// # use activitypub::{actor::Person, activity::{Announce, Create}, object::Note};
|
||||
/// # use activitystreams::{prelude::*, base::Base, actor::Person, activity::{Announce, Create}, object::Note, iri_string::types::IriString};
|
||||
/// # use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa};
|
||||
/// # use once_cell::sync::Lazy;
|
||||
/// # use plume_common::activity_pub::inbox::*;
|
||||
/// # use plume_common::activity_pub::sign::{gen_keypair, Error as SignError, Result as SignResult, Signer};
|
||||
/// #
|
||||
/// # static MY_SIGNER: Lazy<MySigner> = Lazy::new(|| MySigner::new());
|
||||
/// #
|
||||
/// # struct MySigner {
|
||||
/// # public_key: String,
|
||||
/// # private_key: String,
|
||||
/// # }
|
||||
/// #
|
||||
/// # impl MySigner {
|
||||
/// # fn new() -> Self {
|
||||
/// # let (pub_key, priv_key) = gen_keypair();
|
||||
/// # Self {
|
||||
/// # public_key: String::from_utf8(pub_key).unwrap(),
|
||||
/// # private_key: String::from_utf8(priv_key).unwrap(),
|
||||
/// # }
|
||||
/// # }
|
||||
/// # }
|
||||
/// #
|
||||
/// # impl Signer for MySigner {
|
||||
/// # fn get_key_id(&self) -> String {
|
||||
/// # "mysigner".into()
|
||||
/// # }
|
||||
/// #
|
||||
/// # fn sign(&self, to_sign: &str) -> SignResult<Vec<u8>> {
|
||||
/// # let key = PKey::from_rsa(Rsa::private_key_from_pem(self.private_key.as_ref()).unwrap())
|
||||
/// # .unwrap();
|
||||
/// # let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap();
|
||||
/// # signer.update(to_sign.as_bytes()).unwrap();
|
||||
/// # signer.sign_to_vec().map_err(|_| SignError())
|
||||
/// # }
|
||||
/// #
|
||||
/// # fn verify(&self, data: &str, signature: &[u8]) -> SignResult<bool> {
|
||||
/// # let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref()).unwrap())
|
||||
/// # .unwrap();
|
||||
/// # let mut verifier = openssl::sign::Verifier::new(MessageDigest::sha256(), &key).unwrap();
|
||||
/// # verifier.update(data.as_bytes()).unwrap();
|
||||
/// # verifier.verify(&signature).map_err(|_| SignError())
|
||||
/// # }
|
||||
/// # }
|
||||
/// #
|
||||
/// # struct User;
|
||||
/// # impl FromId<()> for User {
|
||||
/// # type Error = ();
|
||||
@@ -23,6 +67,10 @@ use std::fmt::Debug;
|
||||
/// # fn from_activity(_: &(), obj: Person) -> Result<Self, Self::Error> {
|
||||
/// # Ok(User)
|
||||
/// # }
|
||||
/// #
|
||||
/// # fn get_sender() -> &'static dyn Signer {
|
||||
/// # &*MY_SIGNER
|
||||
/// # }
|
||||
/// # }
|
||||
/// # impl AsActor<&()> for User {
|
||||
/// # fn get_inbox_url(&self) -> String {
|
||||
@@ -42,6 +90,10 @@ use std::fmt::Debug;
|
||||
/// # fn from_activity(_: &(), obj: Note) -> Result<Self, Self::Error> {
|
||||
/// # Ok(Message)
|
||||
/// # }
|
||||
/// #
|
||||
/// # fn get_sender() -> &'static dyn Signer {
|
||||
/// # &*MY_SIGNER
|
||||
/// # }
|
||||
/// # }
|
||||
/// # impl AsObject<User, Create, &()> for Message {
|
||||
/// # type Error = ();
|
||||
@@ -60,19 +112,20 @@ use std::fmt::Debug;
|
||||
/// # }
|
||||
/// # }
|
||||
/// #
|
||||
/// # let mut act = Create::default();
|
||||
/// # act.object_props.set_id_string(String::from("https://test.ap/activity")).unwrap();
|
||||
/// # let mut person = Person::default();
|
||||
/// # person.object_props.set_id_string(String::from("https://test.ap/actor")).unwrap();
|
||||
/// # act.create_props.set_actor_object(person).unwrap();
|
||||
/// # act.create_props.set_object_object(Note::default()).unwrap();
|
||||
/// # let mut person = Person::new();
|
||||
/// # person.set_id("https://test.ap/actor".parse::<IriString>().unwrap());
|
||||
/// # let mut act = Create::new(
|
||||
/// # Base::retract(person).unwrap().into_generic().unwrap(),
|
||||
/// # Base::retract(Note::new()).unwrap().into_generic().unwrap()
|
||||
/// # );
|
||||
/// # act.set_id("https://test.ap/activity".parse::<IriString>().unwrap());
|
||||
/// # let activity_json = serde_json::to_value(act).unwrap();
|
||||
/// #
|
||||
/// # let conn = ();
|
||||
/// #
|
||||
/// let result: Result<(), ()> = Inbox::handle(&conn, activity_json)
|
||||
/// .with::<User, Announce, Message>()
|
||||
/// .with::<User, Create, Message>()
|
||||
/// .with::<User, Announce, Message>(None)
|
||||
/// .with::<User, Create, Message>(None)
|
||||
/// .done();
|
||||
/// ```
|
||||
pub enum Inbox<'a, C, E, R>
|
||||
@@ -144,31 +197,37 @@ where
|
||||
}
|
||||
|
||||
/// Registers an handler on this Inbox.
|
||||
pub fn with<A, V, M>(self) -> Inbox<'a, C, E, R>
|
||||
pub fn with<A, V, M>(self, proxy: Option<&reqwest::Proxy>) -> Self
|
||||
where
|
||||
A: AsActor<&'a C> + FromId<C, Error = E>,
|
||||
V: activitypub::Activity,
|
||||
V: activitystreams::markers::Activity + serde::de::DeserializeOwned,
|
||||
M: AsObject<A, V, &'a C, Error = E> + FromId<C, Error = E>,
|
||||
M::Output: Into<R>,
|
||||
{
|
||||
if let Inbox::NotHandled(ctx, mut act, e) = self {
|
||||
if let Self::NotHandled(ctx, mut act, e) = self {
|
||||
if serde_json::from_value::<V>(act.clone()).is_ok() {
|
||||
let act_clone = act.clone();
|
||||
let act_id = match act_clone["id"].as_str() {
|
||||
Some(x) => x,
|
||||
None => return Inbox::NotHandled(ctx, act, InboxError::InvalidID),
|
||||
None => return Self::NotHandled(ctx, act, InboxError::InvalidID),
|
||||
};
|
||||
|
||||
// Get the actor ID
|
||||
let actor_id = match get_id(act["actor"].clone()) {
|
||||
Some(x) => x,
|
||||
None => return Inbox::NotHandled(ctx, act, InboxError::InvalidActor(None)),
|
||||
None => return Self::NotHandled(ctx, act, InboxError::InvalidActor(None)),
|
||||
};
|
||||
|
||||
if Self::is_spoofed_activity(&actor_id, &act) {
|
||||
return Self::NotHandled(ctx, act, InboxError::InvalidObject(None));
|
||||
}
|
||||
|
||||
// Transform this actor to a model (see FromId for details about the from_id function)
|
||||
let actor = match A::from_id(
|
||||
ctx,
|
||||
&actor_id,
|
||||
serde_json::from_value(act["actor"].clone()).ok(),
|
||||
proxy,
|
||||
) {
|
||||
Ok(a) => a,
|
||||
// If the actor was not found, go to the next handler
|
||||
@@ -176,38 +235,39 @@ where
|
||||
if let Some(json) = json {
|
||||
act["actor"] = json;
|
||||
}
|
||||
return Inbox::NotHandled(ctx, act, InboxError::InvalidActor(Some(e)));
|
||||
return Self::NotHandled(ctx, act, InboxError::InvalidActor(Some(e)));
|
||||
}
|
||||
};
|
||||
|
||||
// Same logic for "object"
|
||||
let obj_id = match get_id(act["object"].clone()) {
|
||||
Some(x) => x,
|
||||
None => return Inbox::NotHandled(ctx, act, InboxError::InvalidObject(None)),
|
||||
None => return Self::NotHandled(ctx, act, InboxError::InvalidObject(None)),
|
||||
};
|
||||
let obj = match M::from_id(
|
||||
ctx,
|
||||
&obj_id,
|
||||
serde_json::from_value(act["object"].clone()).ok(),
|
||||
proxy,
|
||||
) {
|
||||
Ok(o) => o,
|
||||
Err((json, e)) => {
|
||||
if let Some(json) = json {
|
||||
act["object"] = json;
|
||||
}
|
||||
return Inbox::NotHandled(ctx, act, InboxError::InvalidObject(Some(e)));
|
||||
return Self::NotHandled(ctx, act, InboxError::InvalidObject(Some(e)));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle the activity
|
||||
match obj.activity(ctx, actor, &act_id) {
|
||||
Ok(res) => Inbox::Handled(res.into()),
|
||||
Err(e) => Inbox::Failed(e),
|
||||
match obj.activity(ctx, actor, act_id) {
|
||||
Ok(res) => Self::Handled(res.into()),
|
||||
Err(e) => Self::Failed(e),
|
||||
}
|
||||
} else {
|
||||
// If the Activity type is not matching the expected one for
|
||||
// this handler, try with the next one.
|
||||
Inbox::NotHandled(ctx, act, e)
|
||||
Self::NotHandled(ctx, act, e)
|
||||
}
|
||||
} else {
|
||||
self
|
||||
@@ -222,6 +282,26 @@ where
|
||||
Inbox::Failed(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_spoofed_activity(actor_id: &str, act: &serde_json::Value) -> bool {
|
||||
use serde_json::Value::{Array, Object, String};
|
||||
|
||||
let attributed_to = act["object"].get("attributedTo");
|
||||
if attributed_to.is_none() {
|
||||
return false;
|
||||
}
|
||||
let attributed_to = attributed_to.unwrap();
|
||||
match attributed_to {
|
||||
Array(v) => v.iter().all(|i| match i {
|
||||
String(s) => s != actor_id,
|
||||
Object(obj) => obj.get("id").map_or(true, |s| s != actor_id),
|
||||
_ => false,
|
||||
}),
|
||||
String(s) => s != actor_id,
|
||||
Object(obj) => obj.get("id").map_or(true, |s| s != actor_id),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the ActivityPub ID of a JSON value.
|
||||
@@ -253,7 +333,7 @@ pub trait FromId<C>: Sized {
|
||||
type Error: From<InboxError<Self::Error>> + Debug;
|
||||
|
||||
/// The ActivityPub object type representing Self
|
||||
type Object: activitypub::Object;
|
||||
type Object: activitystreams::markers::Object + serde::de::DeserializeOwned;
|
||||
|
||||
/// Tries to get an instance of `Self` from an ActivityPub ID.
|
||||
///
|
||||
@@ -267,36 +347,26 @@ pub trait FromId<C>: Sized {
|
||||
ctx: &C,
|
||||
id: &str,
|
||||
object: Option<Self::Object>,
|
||||
proxy: Option<&reqwest::Proxy>,
|
||||
) -> Result<Self, (Option<serde_json::Value>, Self::Error)> {
|
||||
match Self::from_db(ctx, id) {
|
||||
Ok(x) => Ok(x),
|
||||
_ => match object {
|
||||
Some(o) => Self::from_activity(ctx, o).map_err(|e| (None, e)),
|
||||
None => Self::from_activity(ctx, Self::deref(id)?).map_err(|e| (None, e)),
|
||||
None => Self::from_activity(ctx, Self::deref(id, proxy.cloned())?)
|
||||
.map_err(|e| (None, e)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Dereferences an ID
|
||||
fn deref(id: &str) -> Result<Self::Object, (Option<serde_json::Value>, Self::Error)> {
|
||||
reqwest::ClientBuilder::new()
|
||||
.connect_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.build()
|
||||
.map_err(|_| (None, InboxError::DerefError.into()))?
|
||||
.get(id)
|
||||
.header(
|
||||
ACCEPT,
|
||||
HeaderValue::from_str(
|
||||
&super::ap_accept_header()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
.map_err(|_| (None, InboxError::DerefError.into()))?,
|
||||
)
|
||||
.send()
|
||||
fn deref(
|
||||
id: &str,
|
||||
proxy: Option<reqwest::Proxy>,
|
||||
) -> Result<Self::Object, (Option<serde_json::Value>, Self::Error)> {
|
||||
request::get(id, Self::get_sender(), proxy)
|
||||
.map_err(|_| (None, InboxError::DerefError))
|
||||
.and_then(|mut r| {
|
||||
.and_then(|r| {
|
||||
let json: serde_json::Value = r
|
||||
.json()
|
||||
.map_err(|_| (None, InboxError::InvalidObject(None)))?;
|
||||
@@ -311,6 +381,8 @@ pub trait FromId<C>: Sized {
|
||||
|
||||
/// Tries to find a `Self` with a given ID (`id`), using `ctx` (a database)
|
||||
fn from_db(ctx: &C, id: &str) -> Result<Self, Self::Error>;
|
||||
|
||||
fn get_sender() -> &'static dyn Signer;
|
||||
}
|
||||
|
||||
/// Should be implemented by anything representing an ActivityPub actor.
|
||||
@@ -346,9 +418,51 @@ pub trait AsActor<C> {
|
||||
/// representing the Note by a Message type, without any specific context.
|
||||
///
|
||||
/// ```rust
|
||||
/// # extern crate activitypub;
|
||||
/// # use activitypub::{activity::Create, actor::Person, object::Note};
|
||||
/// # use activitystreams::{prelude::*, activity::Create, actor::Person, object::Note};
|
||||
/// # use plume_common::activity_pub::inbox::{AsActor, AsObject, FromId};
|
||||
/// # use plume_common::activity_pub::sign::{gen_keypair, Error as SignError, Result as SignResult, Signer};
|
||||
/// # use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa};
|
||||
/// # use once_cell::sync::Lazy;
|
||||
/// #
|
||||
/// # static MY_SIGNER: Lazy<MySigner> = Lazy::new(|| MySigner::new());
|
||||
/// #
|
||||
/// # struct MySigner {
|
||||
/// # public_key: String,
|
||||
/// # private_key: String,
|
||||
/// # }
|
||||
/// #
|
||||
/// # impl MySigner {
|
||||
/// # fn new() -> Self {
|
||||
/// # let (pub_key, priv_key) = gen_keypair();
|
||||
/// # Self {
|
||||
/// # public_key: String::from_utf8(pub_key).unwrap(),
|
||||
/// # private_key: String::from_utf8(priv_key).unwrap(),
|
||||
/// # }
|
||||
/// # }
|
||||
/// # }
|
||||
/// #
|
||||
/// # impl Signer for MySigner {
|
||||
/// # fn get_key_id(&self) -> String {
|
||||
/// # "mysigner".into()
|
||||
/// # }
|
||||
/// #
|
||||
/// # fn sign(&self, to_sign: &str) -> SignResult<Vec<u8>> {
|
||||
/// # let key = PKey::from_rsa(Rsa::private_key_from_pem(self.private_key.as_ref()).unwrap())
|
||||
/// # .unwrap();
|
||||
/// # let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap();
|
||||
/// # signer.update(to_sign.as_bytes()).unwrap();
|
||||
/// # signer.sign_to_vec().map_err(|_| SignError())
|
||||
/// # }
|
||||
/// #
|
||||
/// # fn verify(&self, data: &str, signature: &[u8]) -> SignResult<bool> {
|
||||
/// # let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref()).unwrap())
|
||||
/// # .unwrap();
|
||||
/// # let mut verifier = openssl::sign::Verifier::new(MessageDigest::sha256(), &key).unwrap();
|
||||
/// # verifier.update(data.as_bytes()).unwrap();
|
||||
/// # verifier.verify(&signature).map_err(|_| SignError())
|
||||
/// # }
|
||||
/// # }
|
||||
/// #
|
||||
/// # struct Account;
|
||||
/// # impl FromId<()> for Account {
|
||||
/// # type Error = ();
|
||||
@@ -361,6 +475,10 @@ pub trait AsActor<C> {
|
||||
/// # fn from_activity(_: &(), obj: Person) -> Result<Self, Self::Error> {
|
||||
/// # Ok(Account)
|
||||
/// # }
|
||||
/// #
|
||||
/// # fn get_sender() -> &'static dyn Signer {
|
||||
/// # &*MY_SIGNER
|
||||
/// # }
|
||||
/// # }
|
||||
/// # impl AsActor<()> for Account {
|
||||
/// # fn get_inbox_url(&self) -> String {
|
||||
@@ -382,7 +500,14 @@ pub trait AsActor<C> {
|
||||
/// }
|
||||
///
|
||||
/// fn from_activity(_: &(), obj: Note) -> Result<Self, Self::Error> {
|
||||
/// Ok(Message { text: obj.object_props.content_string().map_err(|_| ())? })
|
||||
/// Ok(Message {
|
||||
/// text: obj.content()
|
||||
/// .and_then(|content| content.to_owned().single_xsd_string()).ok_or(())?
|
||||
/// })
|
||||
/// }
|
||||
///
|
||||
/// fn get_sender() -> &'static dyn Signer {
|
||||
/// &*MY_SIGNER
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
@@ -398,7 +523,7 @@ pub trait AsActor<C> {
|
||||
/// ```
|
||||
pub trait AsObject<A, V, C>
|
||||
where
|
||||
V: activitypub::Activity,
|
||||
V: activitystreams::markers::Activity,
|
||||
{
|
||||
/// What kind of error is returned when something fails
|
||||
type Error;
|
||||
@@ -423,7 +548,57 @@ where
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use activitypub::{activity::*, actor::Person, object::Note};
|
||||
use crate::activity_pub::sign::{
|
||||
gen_keypair, Error as SignError, Result as SignResult, Signer,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{Announce, Create, Delete, Like},
|
||||
actor::Person,
|
||||
base::Base,
|
||||
object::Note,
|
||||
prelude::*,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa};
|
||||
|
||||
static MY_SIGNER: Lazy<MySigner> = Lazy::new(MySigner::new);
|
||||
|
||||
struct MySigner {
|
||||
public_key: String,
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
impl MySigner {
|
||||
fn new() -> Self {
|
||||
let (pub_key, priv_key) = gen_keypair();
|
||||
Self {
|
||||
public_key: String::from_utf8(pub_key).unwrap(),
|
||||
private_key: String::from_utf8(priv_key).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Signer for MySigner {
|
||||
fn get_key_id(&self) -> String {
|
||||
"mysigner".into()
|
||||
}
|
||||
|
||||
fn sign(&self, to_sign: &str) -> SignResult<Vec<u8>> {
|
||||
let key = PKey::from_rsa(Rsa::private_key_from_pem(self.private_key.as_ref()).unwrap())
|
||||
.unwrap();
|
||||
let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap();
|
||||
signer.update(to_sign.as_bytes()).unwrap();
|
||||
signer.sign_to_vec().map_err(|_| SignError())
|
||||
}
|
||||
|
||||
fn verify(&self, data: &str, signature: &[u8]) -> SignResult<bool> {
|
||||
let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref()).unwrap())
|
||||
.unwrap();
|
||||
let mut verifier = openssl::sign::Verifier::new(MessageDigest::sha256(), &key).unwrap();
|
||||
verifier.update(data.as_bytes()).unwrap();
|
||||
verifier.verify(signature).map_err(|_| SignError())
|
||||
}
|
||||
}
|
||||
|
||||
struct MyActor;
|
||||
impl FromId<()> for MyActor {
|
||||
@@ -431,11 +606,15 @@ mod tests {
|
||||
type Object = Person;
|
||||
|
||||
fn from_db(_: &(), _id: &str) -> Result<Self, Self::Error> {
|
||||
Ok(MyActor)
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
fn from_activity(_: &(), _obj: Person) -> Result<Self, Self::Error> {
|
||||
Ok(MyActor)
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
fn get_sender() -> &'static dyn Signer {
|
||||
&*MY_SIGNER
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,11 +634,15 @@ mod tests {
|
||||
type Object = Note;
|
||||
|
||||
fn from_db(_: &(), _id: &str) -> Result<Self, Self::Error> {
|
||||
Ok(MyObject)
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
fn from_activity(_: &(), _obj: Note) -> Result<Self, Self::Error> {
|
||||
Ok(MyObject)
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
fn get_sender() -> &'static dyn Signer {
|
||||
&*MY_SIGNER
|
||||
}
|
||||
}
|
||||
impl AsObject<MyActor, Create, &()> for MyObject {
|
||||
@@ -503,21 +686,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn build_create() -> Create {
|
||||
let mut act = Create::default();
|
||||
act.object_props
|
||||
.set_id_string(String::from("https://test.ap/activity"))
|
||||
.unwrap();
|
||||
let mut person = Person::default();
|
||||
person
|
||||
.object_props
|
||||
.set_id_string(String::from("https://test.ap/actor"))
|
||||
.unwrap();
|
||||
act.create_props.set_actor_object(person).unwrap();
|
||||
let mut note = Note::default();
|
||||
note.object_props
|
||||
.set_id_string(String::from("https://test.ap/note"))
|
||||
.unwrap();
|
||||
act.create_props.set_object_object(note).unwrap();
|
||||
let mut person = Person::new();
|
||||
person.set_id("https://test.ap/actor".parse().unwrap());
|
||||
let mut note = Note::new();
|
||||
note.set_id("https://test.ap/note".parse().unwrap());
|
||||
let mut act = Create::new(
|
||||
Base::retract(person).unwrap().into_generic().unwrap(),
|
||||
Base::retract(note).unwrap().into_generic().unwrap(),
|
||||
);
|
||||
act.set_id("https://test.ap/activity".parse().unwrap());
|
||||
act
|
||||
}
|
||||
|
||||
@@ -525,7 +702,7 @@ mod tests {
|
||||
fn test_inbox_basic() {
|
||||
let act = serde_json::to_value(build_create()).unwrap();
|
||||
let res: Result<(), ()> = Inbox::handle(&(), act)
|
||||
.with::<MyActor, Create, MyObject>()
|
||||
.with::<MyActor, Create, MyObject>(None)
|
||||
.done();
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
@@ -534,10 +711,10 @@ mod tests {
|
||||
fn test_inbox_multi_handlers() {
|
||||
let act = serde_json::to_value(build_create()).unwrap();
|
||||
let res: Result<(), ()> = Inbox::handle(&(), act)
|
||||
.with::<MyActor, Announce, MyObject>()
|
||||
.with::<MyActor, Delete, MyObject>()
|
||||
.with::<MyActor, Create, MyObject>()
|
||||
.with::<MyActor, Like, MyObject>()
|
||||
.with::<MyActor, Announce, MyObject>(None)
|
||||
.with::<MyActor, Delete, MyObject>(None)
|
||||
.with::<MyActor, Create, MyObject>(None)
|
||||
.with::<MyActor, Like, MyObject>(None)
|
||||
.done();
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
@@ -547,13 +724,23 @@ mod tests {
|
||||
let act = serde_json::to_value(build_create()).unwrap();
|
||||
// Create is not handled by this inbox
|
||||
let res: Result<(), ()> = Inbox::handle(&(), act)
|
||||
.with::<MyActor, Announce, MyObject>()
|
||||
.with::<MyActor, Like, MyObject>()
|
||||
.with::<MyActor, Announce, MyObject>(None)
|
||||
.with::<MyActor, Like, MyObject>(None)
|
||||
.done();
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
struct FailingActor;
|
||||
impl AsActor<&()> for FailingActor {
|
||||
fn get_inbox_url(&self) -> String {
|
||||
String::from("https://test.ap/failing-actor/inbox")
|
||||
}
|
||||
|
||||
fn is_local(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl FromId<()> for FailingActor {
|
||||
type Error = ();
|
||||
type Object = Person;
|
||||
@@ -562,17 +749,12 @@ mod tests {
|
||||
Err(())
|
||||
}
|
||||
|
||||
fn from_activity(_: &(), _obj: Person) -> Result<Self, Self::Error> {
|
||||
fn from_activity(_: &(), _obj: Self::Object) -> Result<Self, Self::Error> {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
impl AsActor<&()> for FailingActor {
|
||||
fn get_inbox_url(&self) -> String {
|
||||
String::from("https://test.ap/failing-actor/inbox")
|
||||
}
|
||||
|
||||
fn is_local(&self) -> bool {
|
||||
false
|
||||
fn get_sender() -> &'static dyn Signer {
|
||||
&*MY_SIGNER
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,13 +778,13 @@ mod tests {
|
||||
let act = serde_json::to_value(build_create()).unwrap();
|
||||
|
||||
let res: Result<(), ()> = Inbox::handle(&(), act.clone())
|
||||
.with::<FailingActor, Create, MyObject>()
|
||||
.with::<FailingActor, Create, MyObject>(None)
|
||||
.done();
|
||||
assert!(res.is_err());
|
||||
|
||||
let res: Result<(), ()> = Inbox::handle(&(), act.clone())
|
||||
.with::<FailingActor, Create, MyObject>()
|
||||
.with::<MyActor, Create, MyObject>()
|
||||
let res: Result<(), ()> = Inbox::handle(&(), act)
|
||||
.with::<FailingActor, Create, MyObject>(None)
|
||||
.with::<MyActor, Create, MyObject>(None)
|
||||
.done();
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
use activitypub::{Activity, Link, Object};
|
||||
use activitystreams::{
|
||||
actor::{ApActor, Group, Person},
|
||||
base::{AnyBase, Base, Extends},
|
||||
iri_string::types::IriString,
|
||||
kind,
|
||||
markers::{self, Activity},
|
||||
object::{ApObject, Article, Object},
|
||||
primitives::{AnyString, OneOrMany},
|
||||
unparsed::UnparsedMutExt,
|
||||
};
|
||||
use activitystreams_ext::{Ext1, Ext2, UnparsedExtension};
|
||||
use array_tool::vec::Uniq;
|
||||
use reqwest::r#async::ClientBuilder;
|
||||
use futures::future::join_all;
|
||||
use reqwest::{header::HeaderValue, ClientBuilder, RequestBuilder, Url};
|
||||
use rocket::{
|
||||
http::Status,
|
||||
request::{FromRequest, Request},
|
||||
response::{Responder, Response},
|
||||
Outcome,
|
||||
};
|
||||
use serde_json;
|
||||
use tokio::prelude::*;
|
||||
use tokio::{
|
||||
runtime,
|
||||
time::{sleep, Duration},
|
||||
};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use self::sign::Signable;
|
||||
|
||||
@@ -24,8 +38,8 @@ pub const AP_CONTENT_TYPE: &str =
|
||||
|
||||
pub fn ap_accept_header() -> Vec<&'static str> {
|
||||
vec![
|
||||
"application/ld+json; profile=\"https://w3.org/ns/activitystreams\"",
|
||||
"application/ld+json;profile=\"https://w3.org/ns/activitystreams\"",
|
||||
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
|
||||
"application/ld+json;profile=\"https://www.w3.org/ns/activitystreams\"",
|
||||
"application/activity+json",
|
||||
"application/ld+json",
|
||||
]
|
||||
@@ -63,7 +77,7 @@ impl<T> ActivityStream<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r, O: Object> Responder<'r> for ActivityStream<O> {
|
||||
impl<'r, O: serde::Serialize> Responder<'r> for ActivityStream<O> {
|
||||
fn respond_to(self, request: &Request<'_>) -> Result<Response<'r>, Status> {
|
||||
let mut json = serde_json::to_value(&self.0).map_err(|_| Status::InternalServerError)?;
|
||||
json["@context"] = context();
|
||||
@@ -87,14 +101,16 @@ impl<'a, 'r> FromRequest<'a, 'r> for ApRequest {
|
||||
.map(|header| {
|
||||
header
|
||||
.split(',')
|
||||
.map(|ct| match ct.trim() {
|
||||
.map(|ct| {
|
||||
match ct.trim() {
|
||||
// bool for Forward: true if found a valid Content-Type for Plume first (HTML), false otherwise
|
||||
"application/ld+json; profile=\"https://w3.org/ns/activitystreams\""
|
||||
| "application/ld+json;profile=\"https://w3.org/ns/activitystreams\""
|
||||
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
|
||||
| "application/ld+json;profile=\"https://www.w3.org/ns/activitystreams\""
|
||||
| "application/activity+json"
|
||||
| "application/ld+json" => Outcome::Success(ApRequest),
|
||||
"text/html" => Outcome::Forward(true),
|
||||
_ => Outcome::Forward(false),
|
||||
}
|
||||
})
|
||||
.fold(Outcome::Forward(false), |out, ct| {
|
||||
if out.clone().forwarded().unwrap_or_else(|| out.is_success()) {
|
||||
@@ -108,10 +124,11 @@ impl<'a, 'r> FromRequest<'a, 'r> for ApRequest {
|
||||
.unwrap_or(Outcome::Forward(()))
|
||||
}
|
||||
}
|
||||
pub fn broadcast<S, A, T, C>(sender: &S, act: A, to: Vec<T>)
|
||||
|
||||
pub fn broadcast<S, A, T, C>(sender: &S, act: A, to: Vec<T>, proxy: Option<reqwest::Proxy>)
|
||||
where
|
||||
S: sign::Signer,
|
||||
A: Activity,
|
||||
A: Activity + serde::Serialize,
|
||||
T: inbox::AsActor<C>,
|
||||
{
|
||||
let boxes = to
|
||||
@@ -130,36 +147,79 @@ where
|
||||
.sign(sender)
|
||||
.expect("activity_pub::broadcast: signature error");
|
||||
|
||||
let mut rt = tokio::runtime::current_thread::Runtime::new()
|
||||
.expect("Error while initializing tokio runtime for federation");
|
||||
let client = ClientBuilder::new()
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("Can't build client");
|
||||
for inbox in boxes {
|
||||
let body = signed.to_string();
|
||||
let mut headers = request::headers();
|
||||
headers.insert("Digest", request::Digest::digest(&body));
|
||||
rt.spawn(
|
||||
client
|
||||
.post(&inbox)
|
||||
.headers(headers.clone())
|
||||
.header(
|
||||
"Signature",
|
||||
request::signature(sender, &headers)
|
||||
.expect("activity_pub::broadcast: request signature error"),
|
||||
)
|
||||
.body(body)
|
||||
.send()
|
||||
.and_then(|r| r.into_body().concat2())
|
||||
.map(move |response| {
|
||||
println!("Successfully sent activity to inbox ({})", inbox);
|
||||
println!("Response: \"{:?}\"\n", response)
|
||||
})
|
||||
.map_err(|e| println!("Error while sending to inbox ({:?})", e)),
|
||||
);
|
||||
let client = if let Some(proxy) = proxy {
|
||||
ClientBuilder::new().proxy(proxy)
|
||||
} else {
|
||||
ClientBuilder::new()
|
||||
}
|
||||
rt.run().unwrap();
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("Can't build client");
|
||||
let rt = runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Error while initializing tokio runtime for federation");
|
||||
rt.block_on(async {
|
||||
// TODO: should be determined dependent on database connections because
|
||||
// after broadcasting, target instance sends request to this instance,
|
||||
// and Plume accesses database at that time.
|
||||
let capacity = 6;
|
||||
let (tx, rx) = flume::bounded::<RequestBuilder>(capacity);
|
||||
let mut handles = Vec::with_capacity(capacity);
|
||||
for _ in 0..capacity {
|
||||
let rx = rx.clone();
|
||||
let handle = rt.spawn(async move {
|
||||
while let Ok(request_builder) = rx.recv_async().await {
|
||||
// After broadcasting, target instance sends request to this instance.
|
||||
// Sleep here in order to reduce requests at once
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
let _ = request_builder
|
||||
.send()
|
||||
.await
|
||||
.map(move |r| {
|
||||
if r.status().is_success() {
|
||||
debug!("Successfully sent activity to inbox ({})", &r.url());
|
||||
} else {
|
||||
warn!("Error while sending to inbox ({:?})", &r)
|
||||
}
|
||||
debug!("Response: \"{:?}\"\n", r);
|
||||
})
|
||||
.map_err(|e| warn!("Error while sending to inbox ({:?})", e));
|
||||
}
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
for inbox in boxes {
|
||||
let body = signed.to_string();
|
||||
let mut headers = request::headers();
|
||||
let url = Url::parse(&inbox);
|
||||
if url.is_err() {
|
||||
warn!("Inbox is invalid URL: {:?}", &inbox);
|
||||
continue;
|
||||
}
|
||||
let url = url.unwrap();
|
||||
if !url.has_host() {
|
||||
warn!("Inbox doesn't have host: {:?}", &inbox);
|
||||
continue;
|
||||
};
|
||||
let host_header_value = HeaderValue::from_str(url.host_str().expect("Unreachable"));
|
||||
if host_header_value.is_err() {
|
||||
warn!("Header value is invalid: {:?}", url.host_str());
|
||||
continue;
|
||||
}
|
||||
headers.insert("Host", host_header_value.unwrap());
|
||||
headers.insert("Digest", request::Digest::digest(&body));
|
||||
headers.insert(
|
||||
"Signature",
|
||||
request::signature(sender, &headers, ("post", url.path(), url.query()))
|
||||
.expect("activity_pub::broadcast: request signature error"),
|
||||
);
|
||||
let request_builder = client.post(&inbox).headers(headers.clone()).body(body);
|
||||
let _ = tx.send_async(request_builder).await;
|
||||
}
|
||||
drop(tx);
|
||||
join_all(handles).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Shrinkwrap, Clone, Serialize, Deserialize)]
|
||||
@@ -181,46 +241,193 @@ pub trait IntoId {
|
||||
fn into_id(self) -> Id;
|
||||
}
|
||||
|
||||
impl Link for Id {}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, Properties)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApSignature {
|
||||
#[activitystreams(concrete(PublicKey), functional)]
|
||||
pub public_key: Option<serde_json::Value>,
|
||||
pub public_key: PublicKey,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, Properties)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicKey {
|
||||
#[activitystreams(concrete(String), functional)]
|
||||
pub id: Option<serde_json::Value>,
|
||||
|
||||
#[activitystreams(concrete(String), functional)]
|
||||
pub owner: Option<serde_json::Value>,
|
||||
|
||||
#[activitystreams(concrete(String), functional)]
|
||||
pub public_key_pem: Option<serde_json::Value>,
|
||||
pub id: IriString,
|
||||
pub owner: IriString,
|
||||
pub public_key_pem: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, UnitString)]
|
||||
#[activitystreams(Hashtag)]
|
||||
pub struct HashtagType;
|
||||
impl<U> UnparsedExtension<U> for ApSignature
|
||||
where
|
||||
U: UnparsedMutExt,
|
||||
{
|
||||
type Error = serde_json::Error;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, Properties)]
|
||||
fn try_from_unparsed(unparsed_mut: &mut U) -> Result<Self, Self::Error> {
|
||||
Ok(ApSignature {
|
||||
public_key: unparsed_mut.remove("publicKey")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_into_unparsed(self, unparsed_mut: &mut U) -> Result<(), Self::Error> {
|
||||
unparsed_mut.insert("publicKey", self.public_key)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Hashtag {
|
||||
#[serde(rename = "type")]
|
||||
kind: HashtagType,
|
||||
|
||||
#[activitystreams(concrete(String), functional)]
|
||||
pub href: Option<serde_json::Value>,
|
||||
|
||||
#[activitystreams(concrete(String), functional)]
|
||||
pub name: Option<serde_json::Value>,
|
||||
pub struct SourceProperty {
|
||||
pub source: Source,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
impl<U> UnparsedExtension<U> for SourceProperty
|
||||
where
|
||||
U: UnparsedMutExt,
|
||||
{
|
||||
type Error = serde_json::Error;
|
||||
|
||||
fn try_from_unparsed(unparsed_mut: &mut U) -> Result<Self, Self::Error> {
|
||||
Ok(SourceProperty {
|
||||
source: unparsed_mut.remove("source")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_into_unparsed(self, unparsed_mut: &mut U) -> Result<(), Self::Error> {
|
||||
unparsed_mut.insert("source", self.source)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type CustomPerson = Ext1<ApActor<Person>, ApSignature>;
|
||||
pub type CustomGroup = Ext2<ApActor<Group>, ApSignature, SourceProperty>;
|
||||
|
||||
kind!(HashtagType, Hashtag);
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Hashtag {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub href: Option<IriString>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<AnyString>,
|
||||
|
||||
#[serde(flatten)]
|
||||
inner: Object<HashtagType>,
|
||||
}
|
||||
|
||||
impl Hashtag {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
href: None,
|
||||
name: None,
|
||||
inner: Object::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extending(mut inner: Object<HashtagType>) -> Result<Self, serde_json::Error> {
|
||||
let href = inner.remove("href")?;
|
||||
let name = inner.remove("name")?;
|
||||
|
||||
Ok(Self { href, name, inner })
|
||||
}
|
||||
|
||||
pub fn retracting(self) -> Result<Object<HashtagType>, serde_json::Error> {
|
||||
let Self {
|
||||
href,
|
||||
name,
|
||||
mut inner,
|
||||
} = self;
|
||||
|
||||
inner.insert("href", href)?;
|
||||
inner.insert("name", name)?;
|
||||
Ok(inner)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AsHashtag: markers::Object {
|
||||
fn hashtag_ref(&self) -> &Hashtag;
|
||||
|
||||
fn hashtag_mut(&mut self) -> &mut Hashtag;
|
||||
}
|
||||
|
||||
pub trait HashtagExt: AsHashtag {
|
||||
fn href(&self) -> Option<&IriString> {
|
||||
self.hashtag_ref().href.as_ref()
|
||||
}
|
||||
|
||||
fn set_href<T>(&mut self, href: T) -> &mut Self
|
||||
where
|
||||
T: Into<IriString>,
|
||||
{
|
||||
self.hashtag_mut().href = Some(href.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn take_href(&mut self) -> Option<IriString> {
|
||||
self.hashtag_mut().href.take()
|
||||
}
|
||||
|
||||
fn delete_href(&mut self) -> &mut Self {
|
||||
self.hashtag_mut().href = None;
|
||||
self
|
||||
}
|
||||
|
||||
fn name(&self) -> Option<&AnyString> {
|
||||
self.hashtag_ref().name.as_ref()
|
||||
}
|
||||
|
||||
fn set_name<T>(&mut self, name: T) -> &mut Self
|
||||
where
|
||||
T: Into<AnyString>,
|
||||
{
|
||||
self.hashtag_mut().name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn take_name(&mut self) -> Option<AnyString> {
|
||||
self.hashtag_mut().name.take()
|
||||
}
|
||||
|
||||
fn delete_name(&mut self) -> &mut Self {
|
||||
self.hashtag_mut().name = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Hashtag {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsHashtag for Hashtag {
|
||||
fn hashtag_ref(&self) -> &Self {
|
||||
self
|
||||
}
|
||||
|
||||
fn hashtag_mut(&mut self) -> &mut Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Extends<HashtagType> for Hashtag {
|
||||
type Error = serde_json::Error;
|
||||
|
||||
fn extends(base: Base<HashtagType>) -> Result<Self, Self::Error> {
|
||||
let inner = Object::extends(base)?;
|
||||
Self::extending(inner)
|
||||
}
|
||||
|
||||
fn retracts(self) -> Result<Base<HashtagType>, Self::Error> {
|
||||
let inner = self.retracting()?;
|
||||
inner.retracts()
|
||||
}
|
||||
}
|
||||
|
||||
impl markers::Base for Hashtag {}
|
||||
impl markers::Object for Hashtag {}
|
||||
impl<T> HashtagExt for T where T: AsHashtag {}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Source {
|
||||
pub media_type: String,
|
||||
@@ -228,13 +435,366 @@ pub struct Source {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl Object for Source {}
|
||||
impl<U> UnparsedExtension<U> for Source
|
||||
where
|
||||
U: UnparsedMutExt,
|
||||
{
|
||||
type Error = serde_json::Error;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, Properties)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Licensed {
|
||||
#[activitystreams(concrete(String), functional)]
|
||||
pub license: Option<serde_json::Value>,
|
||||
fn try_from_unparsed(unparsed_mut: &mut U) -> Result<Self, Self::Error> {
|
||||
Ok(Source {
|
||||
content: unparsed_mut.remove("content")?,
|
||||
media_type: unparsed_mut.remove("mediaType")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_into_unparsed(self, unparsed_mut: &mut U) -> Result<(), Self::Error> {
|
||||
unparsed_mut.insert("content", self.content)?;
|
||||
unparsed_mut.insert("mediaType", self.media_type)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Object for Licensed {}
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Licensed {
|
||||
pub license: Option<String>,
|
||||
}
|
||||
|
||||
impl<U> UnparsedExtension<U> for Licensed
|
||||
where
|
||||
U: UnparsedMutExt,
|
||||
{
|
||||
type Error = serde_json::Error;
|
||||
|
||||
fn try_from_unparsed(unparsed_mut: &mut U) -> Result<Self, Self::Error> {
|
||||
Ok(Licensed {
|
||||
license: unparsed_mut.remove("license")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_into_unparsed(self, unparsed_mut: &mut U) -> Result<(), Self::Error> {
|
||||
unparsed_mut.insert("license", self.license)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type LicensedArticle = Ext1<ApObject<Article>, Licensed>;
|
||||
|
||||
pub trait ToAsString {
|
||||
fn to_as_string(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
impl ToAsString for OneOrMany<&AnyString> {
|
||||
fn to_as_string(&self) -> Option<String> {
|
||||
self.as_as_str().map(|s| s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
trait AsAsStr {
|
||||
fn as_as_str(&self) -> Option<&str>;
|
||||
}
|
||||
|
||||
impl AsAsStr for OneOrMany<&AnyString> {
|
||||
fn as_as_str(&self) -> Option<&str> {
|
||||
self.iter().next().map(|prop| prop.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToAsUri {
|
||||
fn to_as_uri(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
impl ToAsUri for OneOrMany<AnyBase> {
|
||||
fn to_as_uri(&self) -> Option<String> {
|
||||
self.iter()
|
||||
.next()
|
||||
.and_then(|prop| prop.as_xsd_any_uri().map(|uri| uri.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use activitystreams::{
|
||||
activity::{ActorAndObjectRef, Create},
|
||||
object::{kind::ArticleType, Image},
|
||||
prelude::{ApActorExt, BaseExt, ExtendsExt, ObjectExt},
|
||||
};
|
||||
use assert_json_diff::assert_json_eq;
|
||||
use serde_json::{from_str, json, to_value};
|
||||
|
||||
#[test]
|
||||
fn se_ap_signature() {
|
||||
let ap_signature = ApSignature {
|
||||
public_key: PublicKey {
|
||||
id: "https://example.com/pubkey".parse().unwrap(),
|
||||
owner: "https://example.com/owner".parse().unwrap(),
|
||||
public_key_pem: "pubKeyPem".into(),
|
||||
},
|
||||
};
|
||||
let expected = json!({
|
||||
"publicKey": {
|
||||
"id": "https://example.com/pubkey",
|
||||
"owner": "https://example.com/owner",
|
||||
"publicKeyPem": "pubKeyPem"
|
||||
}
|
||||
});
|
||||
assert_json_eq!(to_value(ap_signature).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn de_ap_signature() {
|
||||
let value: ApSignature = from_str(
|
||||
r#"
|
||||
{
|
||||
"publicKey": {
|
||||
"id": "https://example.com/",
|
||||
"owner": "https://example.com/",
|
||||
"publicKeyPem": ""
|
||||
}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let expected = ApSignature {
|
||||
public_key: PublicKey {
|
||||
id: "https://example.com/".parse().unwrap(),
|
||||
owner: "https://example.com/".parse().unwrap(),
|
||||
public_key_pem: "".into(),
|
||||
},
|
||||
};
|
||||
assert_eq!(value, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn se_custom_person() {
|
||||
let actor = ApActor::new("https://example.com/inbox".parse().unwrap(), Person::new());
|
||||
let person = CustomPerson::new(
|
||||
actor,
|
||||
ApSignature {
|
||||
public_key: PublicKey {
|
||||
id: "https://example.com/pubkey".parse().unwrap(),
|
||||
owner: "https://example.com/owner".parse().unwrap(),
|
||||
public_key_pem: "pubKeyPem".into(),
|
||||
},
|
||||
},
|
||||
);
|
||||
let expected = json!({
|
||||
"inbox": "https://example.com/inbox",
|
||||
"type": "Person",
|
||||
"publicKey": {
|
||||
"id": "https://example.com/pubkey",
|
||||
"owner": "https://example.com/owner",
|
||||
"publicKeyPem": "pubKeyPem"
|
||||
}
|
||||
});
|
||||
assert_eq!(to_value(person).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn se_custom_group() {
|
||||
let group = CustomGroup::new(
|
||||
ApActor::new("https://example.com/inbox".parse().unwrap(), Group::new()),
|
||||
ApSignature {
|
||||
public_key: PublicKey {
|
||||
id: "https://example.com/pubkey".parse().unwrap(),
|
||||
owner: "https://example.com/owner".parse().unwrap(),
|
||||
public_key_pem: "pubKeyPem".into(),
|
||||
},
|
||||
},
|
||||
SourceProperty {
|
||||
source: Source {
|
||||
content: String::from("This is a *custom* group."),
|
||||
media_type: String::from("text/markdown"),
|
||||
},
|
||||
},
|
||||
);
|
||||
let expected = json!({
|
||||
"inbox": "https://example.com/inbox",
|
||||
"type": "Group",
|
||||
"publicKey": {
|
||||
"id": "https://example.com/pubkey",
|
||||
"owner": "https://example.com/owner",
|
||||
"publicKeyPem": "pubKeyPem"
|
||||
},
|
||||
"source": {
|
||||
"content": "This is a *custom* group.",
|
||||
"mediaType": "text/markdown"
|
||||
}
|
||||
});
|
||||
assert_eq!(to_value(group).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn de_custom_group() {
|
||||
let value: CustomGroup = from_str(
|
||||
r#"
|
||||
{
|
||||
"icon": {
|
||||
"type": "Image"
|
||||
},
|
||||
"id": "https://plume01.localhost/~/Plume01%20Blog%202/",
|
||||
"image": {
|
||||
"type": "Image"
|
||||
},
|
||||
"inbox": "https://plume01.localhost/~/Plume01%20Blog%202/inbox",
|
||||
"name": "Plume01 Blog 2",
|
||||
"outbox": "https://plume01.localhost/~/Plume01%20Blog%202/outbox",
|
||||
"preferredUsername": "Plume01 Blog 2",
|
||||
"publicKey": {
|
||||
"id": "https://plume01.localhost/~/Plume01%20Blog%202/#main-key",
|
||||
"owner": "https://plume01.localhost/~/Plume01%20Blog%202/",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwPGtKkl/iMsNAyeVaJGz\noEz5PoNkjRnKK7G97MFvb4zw9zs5SpzWW7b/pKHa4dODcGDJXmkCJ1H5JWyguzN8\n2GNoFjtEOJHxEGwBHSYDsTmhuLNB0DKxMU2iu55g8iIiXhZiIW1FBNGs/Geaymvr\nh/TEtzdReN8wzloRR55kOVcU49xBkqx8cfDSk/lrrDLlpveHdqgaFnIvuw2vycK0\nxFzS3xlEUpzJk9kHxoR1uEAfZ+gCv26Sgo/HqOAhqSD5IU3QZC3kdkr/hwVqtr8U\nXGkGG6Mo1rgzhkYiCFkWrV2WoKkcEHD4nEzbgoZZ5MyuSoloxnyF3NiScqmqW+Yx\nkQIDAQAB\n-----END PUBLIC KEY-----\n"
|
||||
},
|
||||
"source": {
|
||||
"content": "",
|
||||
"mediaType": "text/markdown"
|
||||
},
|
||||
"summary": "",
|
||||
"type": "Group"
|
||||
}
|
||||
"#
|
||||
).unwrap();
|
||||
let mut expected = CustomGroup::new(
|
||||
ApActor::new("https://plume01.localhost/~/Plume01%20Blog%202/inbox".parse().unwrap(), Group::new()),
|
||||
ApSignature {
|
||||
public_key: PublicKey {
|
||||
id: "https://plume01.localhost/~/Plume01%20Blog%202/#main-key".parse().unwrap(),
|
||||
owner: "https://plume01.localhost/~/Plume01%20Blog%202/".parse().unwrap(),
|
||||
public_key_pem: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwPGtKkl/iMsNAyeVaJGz\noEz5PoNkjRnKK7G97MFvb4zw9zs5SpzWW7b/pKHa4dODcGDJXmkCJ1H5JWyguzN8\n2GNoFjtEOJHxEGwBHSYDsTmhuLNB0DKxMU2iu55g8iIiXhZiIW1FBNGs/Geaymvr\nh/TEtzdReN8wzloRR55kOVcU49xBkqx8cfDSk/lrrDLlpveHdqgaFnIvuw2vycK0\nxFzS3xlEUpzJk9kHxoR1uEAfZ+gCv26Sgo/HqOAhqSD5IU3QZC3kdkr/hwVqtr8U\nXGkGG6Mo1rgzhkYiCFkWrV2WoKkcEHD4nEzbgoZZ5MyuSoloxnyF3NiScqmqW+Yx\nkQIDAQAB\n-----END PUBLIC KEY-----\n".into(),
|
||||
}
|
||||
},
|
||||
SourceProperty {
|
||||
source: Source {
|
||||
content: String::from(""),
|
||||
media_type: String::from("text/markdown")
|
||||
}
|
||||
}
|
||||
);
|
||||
expected.set_icon(Image::new().into_any_base().unwrap());
|
||||
expected.set_id(
|
||||
"https://plume01.localhost/~/Plume01%20Blog%202/"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
expected.set_image(Image::new().into_any_base().unwrap());
|
||||
expected.set_name("Plume01 Blog 2");
|
||||
expected.set_outbox(
|
||||
"https://plume01.localhost/~/Plume01%20Blog%202/outbox"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
expected.set_preferred_username("Plume01 Blog 2");
|
||||
expected.set_summary("");
|
||||
|
||||
assert_json_eq!(value, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn se_licensed_article() {
|
||||
let object = ApObject::new(Article::new());
|
||||
let licensed_article = LicensedArticle::new(
|
||||
object,
|
||||
Licensed {
|
||||
license: Some("CC-0".into()),
|
||||
},
|
||||
);
|
||||
let expected = json!({
|
||||
"type": "Article",
|
||||
"license": "CC-0",
|
||||
});
|
||||
assert_json_eq!(to_value(licensed_article).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn de_licensed_article() {
|
||||
let value: LicensedArticle = from_str(
|
||||
r#"
|
||||
{
|
||||
"type": "Article",
|
||||
"id": "https://plu.me/~/Blog/my-article",
|
||||
"attributedTo": ["https://plu.me/@/Admin", "https://plu.me/~/Blog"],
|
||||
"content": "Hello.",
|
||||
"name": "My Article",
|
||||
"summary": "Bye.",
|
||||
"source": {
|
||||
"content": "Hello.",
|
||||
"mediaType": "text/markdown"
|
||||
},
|
||||
"published": "2014-12-12T12:12:12Z",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"license": "CC-0"
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let expected = json!({
|
||||
"type": "Article",
|
||||
"id": "https://plu.me/~/Blog/my-article",
|
||||
"attributedTo": ["https://plu.me/@/Admin", "https://plu.me/~/Blog"],
|
||||
"content": "Hello.",
|
||||
"name": "My Article",
|
||||
"summary": "Bye.",
|
||||
"source": {
|
||||
"content": "Hello.",
|
||||
"mediaType": "text/markdown"
|
||||
},
|
||||
"published": "2014-12-12T12:12:12Z",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"license": "CC-0"
|
||||
});
|
||||
|
||||
assert_eq!(to_value(value).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn de_create_with_licensed_article() {
|
||||
let create: Create = from_str(
|
||||
r#"
|
||||
{
|
||||
"id": "https://plu.me/~/Blog/my-article",
|
||||
"type": "Create",
|
||||
"actor": "https://plu.me/@/Admin",
|
||||
"to": "https://www.w3.org/ns/activitystreams#Public",
|
||||
"object": {
|
||||
"type": "Article",
|
||||
"id": "https://plu.me/~/Blog/my-article",
|
||||
"attributedTo": ["https://plu.me/@/Admin", "https://plu.me/~/Blog"],
|
||||
"content": "Hello.",
|
||||
"name": "My Article",
|
||||
"summary": "Bye.",
|
||||
"source": {
|
||||
"content": "Hello.",
|
||||
"mediaType": "text/markdown"
|
||||
},
|
||||
"published": "2014-12-12T12:12:12Z",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"license": "CC-0"
|
||||
}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let base = create.object_field_ref().as_single_base().unwrap();
|
||||
let any_base = AnyBase::from_base(base.clone());
|
||||
let value = any_base.extend::<LicensedArticle, ArticleType>().unwrap();
|
||||
let expected = json!({
|
||||
"type": "Article",
|
||||
"id": "https://plu.me/~/Blog/my-article",
|
||||
"attributedTo": ["https://plu.me/@/Admin", "https://plu.me/~/Blog"],
|
||||
"content": "Hello.",
|
||||
"name": "My Article",
|
||||
"summary": "Bye.",
|
||||
"source": {
|
||||
"content": "Hello.",
|
||||
"mediaType": "text/markdown"
|
||||
},
|
||||
"published": "2014-12-12T12:12:12Z",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"license": "CC-0"
|
||||
});
|
||||
|
||||
assert_eq!(to_value(value).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
use base64;
|
||||
use chrono::{offset::Utc, DateTime};
|
||||
use openssl::hash::{Hasher, MessageDigest};
|
||||
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, CONTENT_TYPE, DATE, USER_AGENT};
|
||||
use reqwest::{
|
||||
blocking::{ClientBuilder, Response},
|
||||
header::{
|
||||
HeaderMap, HeaderValue, InvalidHeaderValue, ACCEPT, CONTENT_TYPE, DATE, HOST, USER_AGENT,
|
||||
},
|
||||
Proxy, Url,
|
||||
};
|
||||
use std::ops::Deref;
|
||||
use std::time::SystemTime;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::activity_pub::sign::Signer;
|
||||
use crate::activity_pub::{ap_accept_header, AP_CONTENT_TYPE};
|
||||
|
||||
const PLUME_USER_AGENT: &str = concat!("Plume/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Error();
|
||||
|
||||
impl From<url::ParseError> for Error {
|
||||
fn from(_err: url::ParseError) -> Self {
|
||||
Error()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InvalidHeaderValue> for Error {
|
||||
fn from(_err: InvalidHeaderValue) -> Self {
|
||||
Error()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
fn from(_err: reqwest::Error) -> Self {
|
||||
Error()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Digest(String);
|
||||
|
||||
impl Digest {
|
||||
@@ -62,16 +89,16 @@ impl Digest {
|
||||
base64::decode(&self.0[pos..]).expect("Digest::value: invalid encoding error")
|
||||
}
|
||||
|
||||
pub fn from_header(dig: &str) -> Result<Self, ()> {
|
||||
pub fn from_header(dig: &str) -> Result<Self, Error> {
|
||||
if let Some(pos) = dig.find('=') {
|
||||
let pos = pos + 1;
|
||||
if base64::decode(&dig[pos..]).is_ok() {
|
||||
Ok(Digest(dig.to_owned()))
|
||||
} else {
|
||||
Err(())
|
||||
Err(Error())
|
||||
}
|
||||
} else {
|
||||
Err(())
|
||||
Err(Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,27 +137,47 @@ pub fn headers() -> HeaderMap {
|
||||
headers
|
||||
}
|
||||
|
||||
pub fn signature<S: Signer>(signer: &S, headers: &HeaderMap) -> Result<HeaderValue, ()> {
|
||||
let signed_string = headers
|
||||
type Method<'a> = &'a str;
|
||||
type Path<'a> = &'a str;
|
||||
type Query<'a> = &'a str;
|
||||
type RequestTarget<'a> = (Method<'a>, Path<'a>, Option<Query<'a>>);
|
||||
|
||||
pub fn signature(
|
||||
signer: &dyn Signer,
|
||||
headers: &HeaderMap,
|
||||
request_target: RequestTarget,
|
||||
) -> Result<HeaderValue, Error> {
|
||||
let (method, path, query) = request_target;
|
||||
let origin_form = if let Some(query) = query {
|
||||
format!("{}?{}", path, query)
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
|
||||
let mut headers_vec = Vec::with_capacity(headers.len());
|
||||
for (h, v) in headers.iter() {
|
||||
let v = v.to_str();
|
||||
if v.is_err() {
|
||||
warn!("invalid header error: {:?}", v.unwrap_err());
|
||||
return Err(Error());
|
||||
}
|
||||
headers_vec.push((h.as_str().to_lowercase(), v.expect("Unreachable")));
|
||||
}
|
||||
let request_target = format!("{} {}", method.to_lowercase(), origin_form);
|
||||
headers_vec.push(("(request-target)".to_string(), &request_target));
|
||||
|
||||
let signed_string = headers_vec
|
||||
.iter()
|
||||
.map(|(h, v)| {
|
||||
format!(
|
||||
"{}: {}",
|
||||
h.as_str().to_lowercase(),
|
||||
v.to_str()
|
||||
.expect("request::signature: invalid header error")
|
||||
)
|
||||
})
|
||||
.map(|(h, v)| format!("{}: {}", h, v))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
let signed_headers = headers
|
||||
let signed_headers = headers_vec
|
||||
.iter()
|
||||
.map(|(h, _)| h.as_str())
|
||||
.map(|(h, _)| h.as_ref())
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ")
|
||||
.to_lowercase();
|
||||
.join(" ");
|
||||
|
||||
let data = signer.sign(&signed_string).map_err(|_| ())?;
|
||||
let data = signer.sign(&signed_string).map_err(|_| Error())?;
|
||||
let sign = base64::encode(&data);
|
||||
|
||||
HeaderValue::from_str(&format!(
|
||||
@@ -138,5 +185,86 @@ pub fn signature<S: Signer>(signer: &S, headers: &HeaderMap) -> Result<HeaderVal
|
||||
key_id = signer.get_key_id(),
|
||||
signed_headers = signed_headers,
|
||||
signature = sign
|
||||
)).map_err(|_| ())
|
||||
)).map_err(|_| Error())
|
||||
}
|
||||
|
||||
pub fn get(url_str: &str, sender: &dyn Signer, proxy: Option<Proxy>) -> Result<Response, Error> {
|
||||
let mut headers = headers();
|
||||
let url = Url::parse(url_str)?;
|
||||
if !url.has_host() {
|
||||
return Err(Error());
|
||||
}
|
||||
let host_header_value = HeaderValue::from_str(url.host_str().expect("Unreachable"))?;
|
||||
headers.insert(HOST, host_header_value);
|
||||
if let Some(proxy) = proxy {
|
||||
ClientBuilder::new().proxy(proxy)
|
||||
} else {
|
||||
ClientBuilder::new()
|
||||
}
|
||||
.connect_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.build()?
|
||||
.get(url_str)
|
||||
.headers(headers.clone())
|
||||
.header(
|
||||
"Signature",
|
||||
signature(sender, &headers, ("get", url.path(), url.query()))?,
|
||||
)
|
||||
.send()
|
||||
.map_err(|_| Error())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::signature;
|
||||
use crate::activity_pub::sign::{gen_keypair, Error, Result, Signer};
|
||||
use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa};
|
||||
use reqwest::header::HeaderMap;
|
||||
|
||||
struct MySigner {
|
||||
public_key: String,
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
impl MySigner {
|
||||
fn new() -> Self {
|
||||
let (pub_key, priv_key) = gen_keypair();
|
||||
Self {
|
||||
public_key: String::from_utf8(pub_key).unwrap(),
|
||||
private_key: String::from_utf8(priv_key).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Signer for MySigner {
|
||||
fn get_key_id(&self) -> String {
|
||||
"mysigner".into()
|
||||
}
|
||||
|
||||
fn sign(&self, to_sign: &str) -> Result<Vec<u8>> {
|
||||
let key = PKey::from_rsa(Rsa::private_key_from_pem(self.private_key.as_ref()).unwrap())
|
||||
.unwrap();
|
||||
let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap();
|
||||
signer.update(to_sign.as_bytes()).unwrap();
|
||||
signer.sign_to_vec().map_err(|_| Error())
|
||||
}
|
||||
|
||||
fn verify(&self, data: &str, signature: &[u8]) -> Result<bool> {
|
||||
let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref()).unwrap())
|
||||
.unwrap();
|
||||
let mut verifier = openssl::sign::Verifier::new(MessageDigest::sha256(), &key).unwrap();
|
||||
verifier.update(data.as_bytes()).unwrap();
|
||||
verifier.verify(signature).map_err(|_| Error())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signature_request_target() {
|
||||
let signer = MySigner::new();
|
||||
let headers = HeaderMap::new();
|
||||
let result = signature(&signer, &headers, ("post", "/inbox", None)).unwrap();
|
||||
let fields: Vec<&str> = result.to_str().unwrap().split(',').collect();
|
||||
assert_eq!(r#"headers="(request-target)""#, fields[2]);
|
||||
let sign = &fields[3][11..(fields[3].len() - 1)];
|
||||
assert!(signer.verify("post /inbox", sign.as_bytes()).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use super::request;
|
||||
use base64;
|
||||
use chrono::{naive::NaiveDateTime, DateTime, Duration, Utc};
|
||||
use hex;
|
||||
use openssl::{pkey::PKey, rsa::Rsa, sha::sha256};
|
||||
use rocket::http::HeaderMap;
|
||||
use serde_json;
|
||||
|
||||
/// Returns (public key, private key)
|
||||
pub fn gen_keypair() -> (Vec<u8>, Vec<u8>) {
|
||||
@@ -20,19 +17,27 @@ pub fn gen_keypair() -> (Vec<u8>, Vec<u8>) {
|
||||
)
|
||||
}
|
||||
|
||||
pub trait Signer {
|
||||
type Error;
|
||||
#[derive(Debug)]
|
||||
pub struct Error();
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
impl From<openssl::error::ErrorStack> for Error {
|
||||
fn from(_: openssl::error::ErrorStack) -> Self {
|
||||
Self()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Signer {
|
||||
fn get_key_id(&self) -> String;
|
||||
|
||||
/// Sign some data with the signer keypair
|
||||
fn sign(&self, to_sign: &str) -> Result<Vec<u8>, Self::Error>;
|
||||
fn sign(&self, to_sign: &str) -> Result<Vec<u8>>;
|
||||
/// Verify if the signature is valid
|
||||
fn verify(&self, data: &str, signature: &[u8]) -> Result<bool, Self::Error>;
|
||||
fn verify(&self, data: &str, signature: &[u8]) -> Result<bool>;
|
||||
}
|
||||
|
||||
pub trait Signable {
|
||||
fn sign<T>(&mut self, creator: &T) -> Result<&mut Self, ()>
|
||||
fn sign<T>(&mut self, creator: &T) -> Result<&mut Self>
|
||||
where
|
||||
T: Signer;
|
||||
fn verify<T>(self, creator: &T) -> bool
|
||||
@@ -46,7 +51,7 @@ pub trait Signable {
|
||||
}
|
||||
|
||||
impl Signable for serde_json::Value {
|
||||
fn sign<T: Signer>(&mut self, creator: &T) -> Result<&mut serde_json::Value, ()> {
|
||||
fn sign<T: Signer>(&mut self, creator: &T) -> Result<&mut serde_json::Value> {
|
||||
let creation_date = Utc::now().to_rfc3339();
|
||||
let mut options = json!({
|
||||
"type": "RsaSignature2017",
|
||||
@@ -64,7 +69,7 @@ impl Signable for serde_json::Value {
|
||||
let document_hash = Self::hash(&self.to_string());
|
||||
let to_be_signed = options_hash + &document_hash;
|
||||
|
||||
let signature = base64::encode(&creator.sign(&to_be_signed).map_err(|_| ())?);
|
||||
let signature = base64::encode(&creator.sign(&to_be_signed).map_err(|_| Error())?);
|
||||
|
||||
options["signatureValue"] = serde_json::Value::String(signature);
|
||||
self["signature"] = options;
|
||||
@@ -114,7 +119,7 @@ impl Signable for serde_json::Value {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum SignatureValidity {
|
||||
Invalid,
|
||||
ValidNoDigest,
|
||||
@@ -182,7 +187,7 @@ pub fn verify_http_headers<S: Signer + ::std::fmt::Debug>(
|
||||
}
|
||||
let digest = all_headers.get_one("digest").unwrap_or("");
|
||||
let digest = request::Digest::from_header(digest);
|
||||
if !digest.map(|d| d.verify_header(&data)).unwrap_or(false) {
|
||||
if !digest.map(|d| d.verify_header(data)).unwrap_or(false) {
|
||||
// signature was valid, but body content does not match its digest
|
||||
return SignatureValidity::Invalid;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
#![feature(associated_type_defaults)]
|
||||
|
||||
#[macro_use]
|
||||
extern crate activitystreams_derive;
|
||||
use activitystreams_traits;
|
||||
|
||||
use serde;
|
||||
#[macro_use]
|
||||
extern crate shrinkwraprs;
|
||||
#[macro_use]
|
||||
|
||||
+131
-66
@@ -1,14 +1,9 @@
|
||||
use heck::CamelCase;
|
||||
use openssl::rand::rand_bytes;
|
||||
use pulldown_cmark::{html, Event, Options, Parser, Tag};
|
||||
use pulldown_cmark::{html, CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag};
|
||||
use regex_syntax::is_word_character;
|
||||
use rocket::{
|
||||
http::uri::Uri,
|
||||
response::{Flash, Redirect},
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
use rocket::http::uri::Uri;
|
||||
use std::collections::HashSet;
|
||||
use syntect::html::ClassedHTMLGenerator;
|
||||
use syntect::html::{ClassStyle, ClassedHTMLGenerator};
|
||||
use syntect::parsing::SyntaxSet;
|
||||
|
||||
/// Generates an hexadecimal representation of 32 bytes of random data
|
||||
@@ -20,25 +15,57 @@ pub fn random_hex() -> String {
|
||||
.fold(String::new(), |res, byte| format!("{}{:x}", res, byte))
|
||||
}
|
||||
|
||||
/// Remove non alphanumeric characters and CamelCase a string
|
||||
pub fn make_actor_id(name: &str) -> String {
|
||||
name.to_camel_case()
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.collect()
|
||||
/**
|
||||
* Percent-encode characters which are not allowed in IRI path segments.
|
||||
*
|
||||
* Intended to be used for generating Post ap_url.
|
||||
*/
|
||||
pub fn iri_percent_encode_seg(segment: &str) -> String {
|
||||
segment.chars().map(iri_percent_encode_seg_char).collect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects to the login page with a given message.
|
||||
*
|
||||
* Note that the message should be translated before passed to this function.
|
||||
*/
|
||||
pub fn requires_login<T: Into<Uri<'static>>>(message: &str, url: T) -> Flash<Redirect> {
|
||||
Flash::new(
|
||||
Redirect::to(format!("/login?m={}", Uri::percent_encode(message))),
|
||||
"callback",
|
||||
url.into().to_string(),
|
||||
)
|
||||
pub fn iri_percent_encode_seg_char(c: char) -> String {
|
||||
if c.is_alphanumeric() {
|
||||
c.to_string()
|
||||
} else {
|
||||
match c {
|
||||
'-'
|
||||
| '.'
|
||||
| '_'
|
||||
| '~'
|
||||
| '\u{A0}'..='\u{D7FF}'
|
||||
| '\u{20000}'..='\u{2FFFD}'
|
||||
| '\u{30000}'..='\u{3FFFD}'
|
||||
| '\u{40000}'..='\u{4FFFD}'
|
||||
| '\u{50000}'..='\u{5FFFD}'
|
||||
| '\u{60000}'..='\u{6FFFD}'
|
||||
| '\u{70000}'..='\u{7FFFD}'
|
||||
| '\u{80000}'..='\u{8FFFD}'
|
||||
| '\u{90000}'..='\u{9FFFD}'
|
||||
| '\u{A0000}'..='\u{AFFFD}'
|
||||
| '\u{B0000}'..='\u{BFFFD}'
|
||||
| '\u{C0000}'..='\u{CFFFD}'
|
||||
| '\u{D0000}'..='\u{DFFFD}'
|
||||
| '\u{E0000}'..='\u{EFFFD}'
|
||||
| '!'
|
||||
| '$'
|
||||
| '&'
|
||||
| '\''
|
||||
| '('
|
||||
| ')'
|
||||
| '*'
|
||||
| '+'
|
||||
| ','
|
||||
| ';'
|
||||
| '='
|
||||
| ':'
|
||||
| '@' => c.to_string(),
|
||||
_ => {
|
||||
let s = c.to_string();
|
||||
Uri::percent_encode(&s).to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -51,46 +78,61 @@ enum State {
|
||||
|
||||
fn to_inline(tag: Tag<'_>) -> Tag<'_> {
|
||||
match tag {
|
||||
Tag::Header(_) | Tag::Table(_) | Tag::TableHead | Tag::TableRow | Tag::TableCell => {
|
||||
Tag::Heading(_) | Tag::Table(_) | Tag::TableHead | Tag::TableRow | Tag::TableCell => {
|
||||
Tag::Paragraph
|
||||
}
|
||||
Tag::Image(url, title) => Tag::Link(url, title),
|
||||
Tag::Image(typ, url, title) => Tag::Link(typ, url, title),
|
||||
t => t,
|
||||
}
|
||||
}
|
||||
struct HighlighterContext {
|
||||
content: Vec<String>,
|
||||
}
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn highlight_code<'a>(
|
||||
context: &mut Option<HighlighterContext>,
|
||||
evt: Event<'a>,
|
||||
) -> Option<Vec<Event<'a>>> {
|
||||
match evt {
|
||||
Event::Start(Tag::CodeBlock(lang)) => {
|
||||
if lang.is_empty() {
|
||||
Some(vec![Event::Start(Tag::CodeBlock(lang))])
|
||||
} else {
|
||||
*context = Some(HighlighterContext { content: vec![] });
|
||||
Some(vec![Event::Start(Tag::CodeBlock(lang))])
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
match &kind {
|
||||
CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
|
||||
*context = Some(HighlighterContext { content: vec![] });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(vec![Event::Start(Tag::CodeBlock(kind))])
|
||||
}
|
||||
Event::End(Tag::CodeBlock(x)) => {
|
||||
Event::End(Tag::CodeBlock(kind)) => {
|
||||
let mut result = vec![];
|
||||
if let Some(ctx) = context.take() {
|
||||
let lang = if let CodeBlockKind::Fenced(lang) = &kind {
|
||||
if lang.is_empty() {
|
||||
unreachable!();
|
||||
} else {
|
||||
lang
|
||||
}
|
||||
} else {
|
||||
unreachable!();
|
||||
};
|
||||
let syntax_set = SyntaxSet::load_defaults_newlines();
|
||||
let syntax = syntax_set.find_syntax_by_token(&x).unwrap_or_else(|| {
|
||||
let syntax = syntax_set.find_syntax_by_token(lang).unwrap_or_else(|| {
|
||||
syntax_set
|
||||
.find_syntax_by_name(&x)
|
||||
.find_syntax_by_name(lang)
|
||||
.unwrap_or_else(|| syntax_set.find_syntax_plain_text())
|
||||
});
|
||||
let mut html = ClassedHTMLGenerator::new(&syntax, &syntax_set);
|
||||
let mut html = ClassedHTMLGenerator::new_with_class_style(
|
||||
syntax,
|
||||
&syntax_set,
|
||||
ClassStyle::Spaced,
|
||||
);
|
||||
for line in ctx.content {
|
||||
html.parse_html_for_line(&line);
|
||||
html.parse_html_for_line_which_includes_newline(&line);
|
||||
}
|
||||
let q = html.finalize();
|
||||
result.push(Event::Html(q.into()));
|
||||
}
|
||||
result.push(Event::End(Tag::CodeBlock(x)));
|
||||
result.push(Event::End(Tag::CodeBlock(kind)));
|
||||
*context = None;
|
||||
Some(result)
|
||||
}
|
||||
@@ -106,6 +148,7 @@ fn highlight_code<'a>(
|
||||
_ => Some(vec![evt]),
|
||||
}
|
||||
}
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn flatten_text<'a>(state: &mut Option<String>, evt: Event<'a>) -> Option<Vec<Event<'a>>> {
|
||||
let (s, res) = match evt {
|
||||
Event::Text(txt) => match state.take() {
|
||||
@@ -113,10 +156,10 @@ fn flatten_text<'a>(state: &mut Option<String>, evt: Event<'a>) -> Option<Vec<Ev
|
||||
prev_txt.push_str(&txt);
|
||||
(Some(prev_txt), vec![])
|
||||
}
|
||||
None => (Some(txt.into_owned()), vec![]),
|
||||
None => (Some(txt.into_string()), vec![]),
|
||||
},
|
||||
e => match state.take() {
|
||||
Some(prev) => (None, vec![Event::Text(Cow::Owned(prev)), e]),
|
||||
Some(prev) => (None, vec![Event::Text(CowStr::Boxed(prev.into())), e]),
|
||||
None => (None, vec![e]),
|
||||
},
|
||||
};
|
||||
@@ -124,6 +167,7 @@ fn flatten_text<'a>(state: &mut Option<String>, evt: Event<'a>) -> Option<Vec<Ev
|
||||
Some(res)
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn inline_tags<'a>(
|
||||
(state, inline): &mut (Vec<Tag<'a>>, bool),
|
||||
evt: Event<'a>,
|
||||
@@ -156,42 +200,45 @@ fn process_image<'a, 'b>(
|
||||
) -> Event<'a> {
|
||||
if let Some(ref processor) = *processor {
|
||||
match evt {
|
||||
Event::Start(Tag::Image(id, title)) => {
|
||||
Event::Start(Tag::Image(typ, id, title)) => {
|
||||
if let Some((url, cw)) = id.parse::<i32>().ok().and_then(processor.as_ref()) {
|
||||
if let (Some(cw), false) = (cw, inline) {
|
||||
// there is a cw, and where are not inline
|
||||
Event::Html(Cow::Owned(format!(
|
||||
r#"<label for="postcontent-cw-{id}">
|
||||
Event::Html(CowStr::Boxed(
|
||||
format!(
|
||||
r#"<label for="postcontent-cw-{id}">
|
||||
<input type="checkbox" id="postcontent-cw-{id}" checked="checked" class="cw-checkbox">
|
||||
<span class="cw-container">
|
||||
<span class="cw-text">
|
||||
{cw}
|
||||
</span>
|
||||
<img src="{url}" alt=""#,
|
||||
id = random_hex(),
|
||||
cw = cw,
|
||||
url = url
|
||||
)))
|
||||
id = random_hex(),
|
||||
cw = cw,
|
||||
url = url
|
||||
)
|
||||
.into(),
|
||||
))
|
||||
} else {
|
||||
Event::Start(Tag::Image(Cow::Owned(url), title))
|
||||
Event::Start(Tag::Image(typ, CowStr::Boxed(url.into()), title))
|
||||
}
|
||||
} else {
|
||||
Event::Start(Tag::Image(id, title))
|
||||
Event::Start(Tag::Image(typ, id, title))
|
||||
}
|
||||
}
|
||||
Event::End(Tag::Image(id, title)) => {
|
||||
Event::End(Tag::Image(typ, id, title)) => {
|
||||
if let Some((url, cw)) = id.parse::<i32>().ok().and_then(processor.as_ref()) {
|
||||
if inline || cw.is_none() {
|
||||
Event::End(Tag::Image(Cow::Owned(url), title))
|
||||
Event::End(Tag::Image(typ, CowStr::Boxed(url.into()), title))
|
||||
} else {
|
||||
Event::Html(Cow::Borrowed(
|
||||
Event::Html(CowStr::Borrowed(
|
||||
r#""/>
|
||||
</span>
|
||||
</label>"#,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Event::End(Tag::Image(id, title))
|
||||
Event::End(Tag::Image(typ, id, title))
|
||||
}
|
||||
}
|
||||
e => e,
|
||||
@@ -215,7 +262,7 @@ pub fn md_to_html<'a>(
|
||||
media_processor: Option<MediaProcessor<'a>>,
|
||||
) -> (String, HashSet<String>, HashSet<String>) {
|
||||
let base_url = if let Some(base_url) = base_url {
|
||||
format!("//{}/", base_url)
|
||||
format!("https://{}/", base_url)
|
||||
} else {
|
||||
"/".to_owned()
|
||||
};
|
||||
@@ -231,19 +278,19 @@ pub fn md_to_html<'a>(
|
||||
// Ignore headings, images, and tables if inline = true
|
||||
.scan((vec![], inline), inline_tags)
|
||||
.scan(&mut DocumentContext::default(), |ctx, evt| match evt {
|
||||
Event::Start(Tag::CodeBlock(_)) | Event::Start(Tag::Code) => {
|
||||
Event::Start(Tag::CodeBlock(_)) => {
|
||||
ctx.in_code = true;
|
||||
Some((vec![evt], vec![], vec![]))
|
||||
}
|
||||
Event::End(Tag::CodeBlock(_)) | Event::End(Tag::Code) => {
|
||||
Event::End(Tag::CodeBlock(_)) => {
|
||||
ctx.in_code = false;
|
||||
Some((vec![evt], vec![], vec![]))
|
||||
}
|
||||
Event::Start(Tag::Link(_, _)) => {
|
||||
Event::Start(Tag::Link(_, _, _)) => {
|
||||
ctx.in_link = true;
|
||||
Some((vec![evt], vec![], vec![]))
|
||||
}
|
||||
Event::End(Tag::Link(_, _)) => {
|
||||
Event::End(Tag::Link(_, _, _)) => {
|
||||
ctx.in_link = false;
|
||||
Some((vec![evt], vec![], vec![]))
|
||||
}
|
||||
@@ -262,15 +309,15 @@ pub fn md_to_html<'a>(
|
||||
text_acc.push(c)
|
||||
}
|
||||
let mention = text_acc;
|
||||
let short_mention = mention.splitn(1, '@').next().unwrap_or("");
|
||||
let link = Tag::Link(
|
||||
LinkType::Inline,
|
||||
format!("{}@/{}/", base_url, &mention).into(),
|
||||
short_mention.to_owned().into(),
|
||||
mention.clone().into(),
|
||||
);
|
||||
|
||||
mentions.push(mention.clone());
|
||||
events.push(Event::Start(link.clone()));
|
||||
events.push(Event::Text(format!("@{}", &short_mention).into()));
|
||||
events.push(Event::Text(format!("@{}", &mention).into()));
|
||||
events.push(Event::End(link));
|
||||
|
||||
(
|
||||
@@ -294,8 +341,8 @@ pub fn md_to_html<'a>(
|
||||
}
|
||||
let hashtag = text_acc;
|
||||
let link = Tag::Link(
|
||||
format!("{}tag/{}", base_url, &hashtag.to_camel_case())
|
||||
.into(),
|
||||
LinkType::Inline,
|
||||
format!("{}tag/{}", base_url, &hashtag).into(),
|
||||
hashtag.to_owned().into(),
|
||||
);
|
||||
|
||||
@@ -394,6 +441,10 @@ pub fn md_to_html<'a>(
|
||||
(buf, mentions.collect(), hashtags.collect())
|
||||
}
|
||||
|
||||
pub fn escape(string: &str) -> askama_escape::Escaped<askama_escape::Html> {
|
||||
askama_escape::escape(string, askama_escape::Html)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -456,15 +507,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iri_percent_encode_seg() {
|
||||
assert_eq!(
|
||||
&iri_percent_encode_seg("including whitespace"),
|
||||
"including%20whitespace"
|
||||
);
|
||||
assert_eq!(&iri_percent_encode_seg("%20"), "%2520");
|
||||
assert_eq!(&iri_percent_encode_seg("é"), "é");
|
||||
assert_eq!(
|
||||
&iri_percent_encode_seg("空白入り 日本語"),
|
||||
"空白入り%20日本語"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inline() {
|
||||
assert_eq!(
|
||||
md_to_html("# Hello", None, false, None).0,
|
||||
String::from("<h1>Hello</h1>\n")
|
||||
String::from("<h1 dir=\"auto\">Hello</h1>\n")
|
||||
);
|
||||
assert_eq!(
|
||||
md_to_html("# Hello", None, true, None).0,
|
||||
String::from("<p>Hello</p>\n")
|
||||
String::from("<p dir=\"auto\">Hello</p>\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+47
-7
@@ -1,15 +1,55 @@
|
||||
[package]
|
||||
name = "plume-front"
|
||||
version = "0.4.0"
|
||||
version = "0.7.2"
|
||||
authors = ["Plume contributors"]
|
||||
edition = "2018"
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
stdweb = "=0.4.18"
|
||||
stdweb-internal-runtime = "=0.1.4"
|
||||
gettext = { git = "https://github.com/Plume-org/gettext/", rev = "294c54d74c699fbc66502b480a37cc66c1daa7f3" }
|
||||
gettext-macros = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
|
||||
gettext-utils = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
|
||||
gettext = "0.4.0"
|
||||
gettext-macros = "0.6.1"
|
||||
gettext-utils = "0.1.0"
|
||||
lazy_static = "1.3"
|
||||
serde = "1.0"
|
||||
serde = "1.0.137"
|
||||
serde_json = "1.0"
|
||||
wasm-bindgen = "0.2.81"
|
||||
js-sys = "0.3.58"
|
||||
serde_derive = "1.0.123"
|
||||
console_error_panic_hook = "0.1.6"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3.58"
|
||||
features = [
|
||||
'console',
|
||||
'ClipboardEvent',
|
||||
'CssStyleDeclaration',
|
||||
'DataTransfer',
|
||||
'Document',
|
||||
'DomStringMap',
|
||||
'DomTokenList',
|
||||
'Element',
|
||||
'EventTarget',
|
||||
'FocusEvent',
|
||||
'History',
|
||||
'HtmlAnchorElement',
|
||||
'HtmlDocument',
|
||||
'HtmlFormElement',
|
||||
'HtmlInputElement',
|
||||
'HtmlSelectElement',
|
||||
'HtmlTextAreaElement',
|
||||
'KeyboardEvent',
|
||||
'Storage',
|
||||
'Location',
|
||||
'MouseEvent',
|
||||
'Navigator',
|
||||
'Node',
|
||||
'NodeList',
|
||||
'Text',
|
||||
'TouchEvent',
|
||||
'Window'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pre-release-hook = ["cargo", "fmt"]
|
||||
pre-release-replacements = []
|
||||
release = false
|
||||
+445
-271
@@ -1,10 +1,12 @@
|
||||
use crate::CATALOG;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use std::sync::Mutex;
|
||||
use stdweb::{
|
||||
unstable::{TryFrom, TryInto},
|
||||
web::{event::*, html_element::*, *},
|
||||
use crate::{document, CATALOG};
|
||||
use js_sys::{encode_uri_component, Date, RegExp};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::{convert::TryInto, sync::Mutex};
|
||||
use wasm_bindgen::{prelude::*, JsCast, JsValue};
|
||||
use web_sys::{
|
||||
console, window, ClipboardEvent, Element, Event, FocusEvent, HtmlAnchorElement, HtmlDocument,
|
||||
HtmlElement, HtmlFormElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
|
||||
KeyboardEvent, MouseEvent, Node,
|
||||
};
|
||||
|
||||
macro_rules! mv {
|
||||
@@ -18,30 +20,29 @@ macro_rules! mv {
|
||||
|
||||
fn get_elt_value(id: &'static str) -> String {
|
||||
let elt = document().get_element_by_id(id).unwrap();
|
||||
let inp: Result<InputElement, _> = elt.clone().try_into();
|
||||
let textarea: Result<TextAreaElement, _> = elt.clone().try_into();
|
||||
let select: Result<SelectElement, _> = elt.try_into();
|
||||
inp.map(|i| i.raw_value()).unwrap_or_else(|_| {
|
||||
let inp: Option<&HtmlInputElement> = elt.dyn_ref();
|
||||
let textarea: Option<&HtmlTextAreaElement> = elt.dyn_ref();
|
||||
let select: Option<&HtmlSelectElement> = elt.dyn_ref();
|
||||
inp.map(|i| i.value()).unwrap_or_else(|| {
|
||||
textarea
|
||||
.map(|t| t.value())
|
||||
.unwrap_or_else(|_| select.unwrap().raw_value())
|
||||
.unwrap_or_else(|| select.unwrap().value())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_value<S: AsRef<str>>(id: &'static str, val: S) {
|
||||
let elt = document().get_element_by_id(id).unwrap();
|
||||
let inp: Result<InputElement, _> = elt.clone().try_into();
|
||||
let textarea: Result<TextAreaElement, _> = elt.clone().try_into();
|
||||
let select: Result<SelectElement, _> = elt.try_into();
|
||||
inp.map(|i| i.set_raw_value(val.as_ref()))
|
||||
.unwrap_or_else(|_| {
|
||||
textarea
|
||||
.map(|t| t.set_value(val.as_ref()))
|
||||
.unwrap_or_else(|_| select.unwrap().set_raw_value(val.as_ref()))
|
||||
})
|
||||
let inp: Option<&HtmlInputElement> = elt.dyn_ref();
|
||||
let textarea: Option<&HtmlTextAreaElement> = elt.dyn_ref();
|
||||
let select: Option<&HtmlSelectElement> = elt.dyn_ref();
|
||||
inp.map(|i| i.set_value(val.as_ref())).unwrap_or_else(|| {
|
||||
textarea
|
||||
.map(|t| t.set_value(val.as_ref()))
|
||||
.unwrap_or_else(|| select.unwrap().set_value(val.as_ref()))
|
||||
})
|
||||
}
|
||||
|
||||
fn no_return(evt: KeyDownEvent) {
|
||||
fn no_return(evt: KeyboardEvent) {
|
||||
if evt.key() == "Enter" {
|
||||
evt.prevent_default();
|
||||
}
|
||||
@@ -51,30 +52,9 @@ fn no_return(evt: KeyDownEvent) {
|
||||
pub enum EditorError {
|
||||
NoneError,
|
||||
DOMError,
|
||||
TypeError,
|
||||
}
|
||||
|
||||
impl From<std::option::NoneError> for EditorError {
|
||||
fn from(_: std::option::NoneError) -> Self {
|
||||
EditorError::NoneError
|
||||
}
|
||||
}
|
||||
impl From<stdweb::web::error::InvalidCharacterError> for EditorError {
|
||||
fn from(_: stdweb::web::error::InvalidCharacterError) -> Self {
|
||||
EditorError::DOMError
|
||||
}
|
||||
}
|
||||
impl From<stdweb::private::TODO> for EditorError {
|
||||
fn from(_: stdweb::private::TODO) -> Self {
|
||||
EditorError::DOMError
|
||||
}
|
||||
}
|
||||
impl From<stdweb::private::ConversionError> for EditorError {
|
||||
fn from(_: stdweb::private::ConversionError) -> Self {
|
||||
EditorError::TypeError
|
||||
}
|
||||
}
|
||||
const AUTOSAVE_DEBOUNCE_TIME: u32 = 5000;
|
||||
const AUTOSAVE_DEBOUNCE_TIME: i32 = 5000;
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct AutosaveInformation {
|
||||
contents: String,
|
||||
@@ -85,10 +65,16 @@ struct AutosaveInformation {
|
||||
tags: String,
|
||||
title: String,
|
||||
}
|
||||
js_serializable!(AutosaveInformation);
|
||||
fn is_basic_editor() -> bool {
|
||||
if let Some(basic_editor) = window().local_storage().get("basic-editor") {
|
||||
basic_editor == "true"
|
||||
if let Some(basic_editor) = window()
|
||||
.unwrap()
|
||||
.local_storage()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.get("basic-editor")
|
||||
.unwrap()
|
||||
{
|
||||
&basic_editor == "true"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -97,65 +83,58 @@ fn get_title() -> String {
|
||||
if is_basic_editor() {
|
||||
get_elt_value("title")
|
||||
} else {
|
||||
let title_field = HtmlElement::try_from(
|
||||
document()
|
||||
.query_selector("#plume-editor > h1")
|
||||
.ok()
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.ok()
|
||||
.unwrap();
|
||||
title_field.inner_text()
|
||||
document()
|
||||
.query_selector("#plume-editor > h1")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.dyn_ref::<HtmlElement>()
|
||||
.unwrap()
|
||||
.inner_text()
|
||||
}
|
||||
}
|
||||
fn get_autosave_id() -> String {
|
||||
format!(
|
||||
"editor_contents={}",
|
||||
window().location().unwrap().pathname().unwrap()
|
||||
window().unwrap().location().pathname().unwrap()
|
||||
)
|
||||
}
|
||||
fn get_editor_contents() -> String {
|
||||
if is_basic_editor() {
|
||||
get_elt_value("editor-content")
|
||||
} else {
|
||||
let editor =
|
||||
HtmlElement::try_from(document().query_selector("article").ok().unwrap().unwrap())
|
||||
.ok()
|
||||
.unwrap();
|
||||
editor.child_nodes().iter().fold(String::new(), |md, ch| {
|
||||
let editor = document().query_selector("article").unwrap().unwrap();
|
||||
let child_nodes = editor.child_nodes();
|
||||
let mut md = String::new();
|
||||
for i in 0..child_nodes.length() {
|
||||
let ch = child_nodes.get(i).unwrap();
|
||||
let to_append = match ch.node_type() {
|
||||
NodeType::Element => {
|
||||
if js! { return @{&ch}.tagName; } == "DIV" {
|
||||
(js! { return @{&ch}.innerHTML; })
|
||||
.try_into()
|
||||
.unwrap_or_default()
|
||||
Node::ELEMENT_NODE => {
|
||||
let elt = ch.dyn_ref::<Element>().unwrap();
|
||||
if elt.tag_name() == "DIV" {
|
||||
elt.inner_html()
|
||||
} else {
|
||||
(js! { return @{&ch}.outerHTML; })
|
||||
.try_into()
|
||||
.unwrap_or_default()
|
||||
elt.outer_html()
|
||||
}
|
||||
}
|
||||
NodeType::Text => ch.node_value().unwrap_or_default(),
|
||||
Node::TEXT_NODE => ch.node_value().unwrap_or_default(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
format!("{}\n\n{}", md, to_append)
|
||||
})
|
||||
md = format!("{}\n\n{}", md, to_append);
|
||||
}
|
||||
md
|
||||
}
|
||||
}
|
||||
fn get_subtitle() -> String {
|
||||
if is_basic_editor() {
|
||||
get_elt_value("subtitle")
|
||||
} else {
|
||||
let subtitle_element = HtmlElement::try_from(
|
||||
document()
|
||||
.query_selector("#plume-editor > h2")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.ok()
|
||||
.unwrap();
|
||||
subtitle_element.inner_text()
|
||||
document()
|
||||
.query_selector("#plume-editor > h2")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.dyn_ref::<HtmlElement>()
|
||||
.unwrap()
|
||||
.inner_text()
|
||||
}
|
||||
}
|
||||
fn autosave() {
|
||||
@@ -170,27 +149,31 @@ fn autosave() {
|
||||
};
|
||||
let id = get_autosave_id();
|
||||
match window()
|
||||
.unwrap()
|
||||
.local_storage()
|
||||
.insert(&id, &serde_json::to_string(&info).unwrap())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.set(&id, &serde_json::to_string(&info).unwrap())
|
||||
{
|
||||
Ok(_) => {}
|
||||
_ => console!(log, "Autosave failed D:"),
|
||||
_ => console::log_1(&"Autosave failed D:".into()),
|
||||
}
|
||||
}
|
||||
//This is only necessary until we go to stdweb 4.20 at least
|
||||
fn confirm(message: &str) -> bool {
|
||||
let result: bool = js! {return confirm(@{message});} == true;
|
||||
result
|
||||
}
|
||||
fn load_autosave() {
|
||||
if let Some(autosave_str) = window().local_storage().get(&get_autosave_id()) {
|
||||
if let Ok(Some(autosave_str)) = window()
|
||||
.unwrap()
|
||||
.local_storage()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.get(&get_autosave_id())
|
||||
{
|
||||
let autosave_info: AutosaveInformation = serde_json::from_str(&autosave_str).ok().unwrap();
|
||||
let message = i18n!(
|
||||
CATALOG,
|
||||
"Do you want to load the local autosave last edited at {}?";
|
||||
Date::from_time(autosave_info.last_saved).to_date_string()
|
||||
Date::new(&JsValue::from_f64(autosave_info.last_saved)).to_date_string().as_string().unwrap()
|
||||
);
|
||||
if confirm(&message) {
|
||||
if let Ok(true) = window().unwrap().confirm_with_message(&message) {
|
||||
set_value("editor-content", &autosave_info.contents);
|
||||
set_value("title", &autosave_info.title);
|
||||
set_value("subtitle", &autosave_info.subtitle);
|
||||
@@ -203,18 +186,33 @@ fn load_autosave() {
|
||||
}
|
||||
}
|
||||
fn clear_autosave() {
|
||||
window().local_storage().remove(&get_autosave_id());
|
||||
console!(log, &format!("Saved to {}", &get_autosave_id()));
|
||||
window()
|
||||
.unwrap()
|
||||
.local_storage()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.remove_item(&get_autosave_id())
|
||||
.unwrap();
|
||||
console::log_1(&format!("Saved to {}", &get_autosave_id()).into());
|
||||
}
|
||||
type TimeoutHandle = i32;
|
||||
lazy_static! {
|
||||
static ref AUTOSAVE_TIMEOUT: Mutex<Option<TimeoutHandle>> = Mutex::new(None);
|
||||
}
|
||||
fn autosave_debounce() {
|
||||
let window = window().unwrap();
|
||||
let timeout = &mut AUTOSAVE_TIMEOUT.lock().unwrap();
|
||||
if let Some(timeout) = timeout.take() {
|
||||
timeout.clear();
|
||||
window.clear_timeout_with_handle(timeout);
|
||||
}
|
||||
**timeout = Some(window().set_clearable_timeout(autosave, AUTOSAVE_DEBOUNCE_TIME));
|
||||
let callback = Closure::once(autosave);
|
||||
**timeout = window
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
callback.as_ref().unchecked_ref(),
|
||||
AUTOSAVE_DEBOUNCE_TIME,
|
||||
)
|
||||
.ok();
|
||||
callback.forget();
|
||||
}
|
||||
fn init_widget(
|
||||
parent: &Element,
|
||||
@@ -223,19 +221,33 @@ fn init_widget(
|
||||
content: String,
|
||||
disable_return: bool,
|
||||
) -> Result<HtmlElement, EditorError> {
|
||||
let widget = placeholder(make_editable(tag).try_into()?, &placeholder_text);
|
||||
let widget = placeholder(
|
||||
make_editable(tag).dyn_into::<HtmlElement>().unwrap(),
|
||||
&placeholder_text,
|
||||
);
|
||||
if !content.is_empty() {
|
||||
widget.dataset().insert("edited", "true")?;
|
||||
widget
|
||||
.dataset()
|
||||
.set("edited", "true")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
}
|
||||
widget.append_child(&document().create_text_node(&content));
|
||||
widget
|
||||
.append_child(&document().create_text_node(&content))
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
if disable_return {
|
||||
widget.add_event_listener(no_return);
|
||||
let callback = Closure::wrap(Box::new(no_return) as Box<dyn FnMut(KeyboardEvent)>);
|
||||
widget
|
||||
.add_event_listener_with_callback("keydown", callback.as_ref().unchecked_ref())
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
callback.forget();
|
||||
}
|
||||
|
||||
parent.append_child(&widget);
|
||||
parent
|
||||
.append_child(&widget)
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
// We need to do that to make sure the placeholder is correctly rendered
|
||||
widget.focus();
|
||||
widget.blur();
|
||||
widget.focus().map_err(|_| EditorError::DOMError)?;
|
||||
widget.blur().map_err(|_| EditorError::DOMError)?;
|
||||
|
||||
filter_paste(&widget);
|
||||
|
||||
@@ -244,42 +256,88 @@ fn init_widget(
|
||||
|
||||
fn filter_paste(elt: &HtmlElement) {
|
||||
// Only insert text when pasting something
|
||||
js! {
|
||||
@{&elt}.addEventListener("paste", function (evt) {
|
||||
evt.preventDefault();
|
||||
document.execCommand("insertText", false, evt.clipboardData.getData("text"));
|
||||
});
|
||||
};
|
||||
let insert_text = Closure::wrap(Box::new(|evt: ClipboardEvent| {
|
||||
evt.prevent_default();
|
||||
if let Some(data) = evt.clipboard_data() {
|
||||
if let Ok(data) = data.get_data("text") {
|
||||
document()
|
||||
.dyn_ref::<HtmlDocument>()
|
||||
.unwrap()
|
||||
.exec_command_with_show_ui_and_value("insertText", false, &data)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}) as Box<dyn FnMut(ClipboardEvent)>);
|
||||
elt.add_event_listener_with_callback("paste", insert_text.as_ref().unchecked_ref())
|
||||
.unwrap();
|
||||
insert_text.forget();
|
||||
}
|
||||
|
||||
pub fn init() -> Result<(), EditorError> {
|
||||
if let Some(ed) = document().get_element_by_id("plume-fallback-editor") {
|
||||
load_autosave();
|
||||
ed.add_event_listener(|_: SubmitEvent| clear_autosave());
|
||||
let callback = Closure::wrap(Box::new(|_| clear_autosave()) as Box<dyn FnMut(Event)>);
|
||||
ed.add_event_listener_with_callback("submit", callback.as_ref().unchecked_ref())
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
callback.forget();
|
||||
}
|
||||
// Check if the user wants to use the basic editor
|
||||
if window()
|
||||
.unwrap()
|
||||
.local_storage()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.get("basic-editor")
|
||||
.map(|x| x == "true")
|
||||
.map(|x| x.is_some() && x.unwrap() == "true")
|
||||
.unwrap_or(true)
|
||||
{
|
||||
if let Some(editor) = document().get_element_by_id("plume-fallback-editor") {
|
||||
if let Ok(Some(title_label)) = document().query_selector("label[for=title]") {
|
||||
let editor_button = document().create_element("a")?;
|
||||
js! { @{&editor_button}.href = "#"; }
|
||||
editor_button.add_event_listener(|_: ClickEvent| {
|
||||
window().local_storage().remove("basic-editor");
|
||||
window().history().go(0).ok(); // refresh
|
||||
});
|
||||
editor_button.append_child(
|
||||
&document().create_text_node(&i18n!(CATALOG, "Open the rich text editor")),
|
||||
let editor_button = document()
|
||||
.create_element("a")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
editor_button
|
||||
.dyn_ref::<HtmlAnchorElement>()
|
||||
.unwrap()
|
||||
.set_href("#");
|
||||
let disable_basic_editor = Closure::wrap(Box::new(|_| {
|
||||
let window = window().unwrap();
|
||||
if window
|
||||
.local_storage()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.set("basic-editor", "false")
|
||||
.is_err()
|
||||
{
|
||||
console::log_1(&"Failed to write into local storage".into());
|
||||
}
|
||||
window.history().unwrap().go_with_delta(0).ok(); // refresh
|
||||
})
|
||||
as Box<dyn FnMut(MouseEvent)>);
|
||||
editor_button
|
||||
.add_event_listener_with_callback(
|
||||
"click",
|
||||
disable_basic_editor.as_ref().unchecked_ref(),
|
||||
)
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
disable_basic_editor.forget();
|
||||
editor_button
|
||||
.append_child(
|
||||
&document().create_text_node(&i18n!(CATALOG, "Open the rich text editor")),
|
||||
)
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
editor
|
||||
.insert_before(&editor_button, Some(&title_label))
|
||||
.ok();
|
||||
let callback = Closure::wrap(
|
||||
Box::new(|_| autosave_debounce()) as Box<dyn FnMut(KeyboardEvent)>
|
||||
);
|
||||
editor.insert_before(&editor_button, &title_label).ok();
|
||||
document()
|
||||
.get_element_by_id("editor-content")
|
||||
.unwrap()
|
||||
.add_event_listener(|_: KeyDownEvent| autosave_debounce());
|
||||
.add_event_listener_with_callback("keydown", callback.as_ref().unchecked_ref())
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
callback.forget();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,14 +350,32 @@ pub fn init() -> Result<(), EditorError> {
|
||||
fn init_editor() -> Result<(), EditorError> {
|
||||
if let Some(ed) = document().get_element_by_id("plume-editor") {
|
||||
// Show the editor
|
||||
js! { @{&ed}.style.display = "block"; };
|
||||
ed.dyn_ref::<HtmlElement>()
|
||||
.unwrap()
|
||||
.style()
|
||||
.set_property("display", "block")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
// And hide the HTML-only fallback
|
||||
let old_ed = document().get_element_by_id("plume-fallback-editor")?;
|
||||
let old_title = document().get_element_by_id("plume-editor-title")?;
|
||||
js! {
|
||||
@{&old_ed}.style.display = "none";
|
||||
@{&old_title}.style.display = "none";
|
||||
};
|
||||
let old_ed = document().get_element_by_id("plume-fallback-editor");
|
||||
if old_ed.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let old_ed = old_ed.unwrap();
|
||||
let old_title = document()
|
||||
.get_element_by_id("plume-editor-title")
|
||||
.ok_or(EditorError::NoneError)?;
|
||||
old_ed
|
||||
.dyn_ref::<HtmlElement>()
|
||||
.unwrap()
|
||||
.style()
|
||||
.set_property("display", "none")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
old_title
|
||||
.dyn_ref::<HtmlElement>()
|
||||
.unwrap()
|
||||
.style()
|
||||
.set_property("display", "none")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
|
||||
// Get content from the old editor (when editing an article for instance)
|
||||
let title_val = get_elt_value("title");
|
||||
@@ -321,35 +397,47 @@ fn init_editor() -> Result<(), EditorError> {
|
||||
content_val.clone(),
|
||||
false,
|
||||
)?;
|
||||
js! { @{&content}.innerHTML = @{content_val}; };
|
||||
if !content_val.is_empty() {
|
||||
content.set_inner_html(&content_val);
|
||||
}
|
||||
|
||||
// character counter
|
||||
content.add_event_listener(mv!(content => move |_: KeyDownEvent| {
|
||||
window().set_timeout(mv!(content => move || {
|
||||
let character_counter = Closure::wrap(Box::new(mv!(content => move |_| {
|
||||
let update_char_count = Closure::wrap(Box::new(mv!(content => move || {
|
||||
if let Some(e) = document().get_element_by_id("char-count") {
|
||||
let count = chars_left("#plume-fallback-editor", &content).unwrap_or_default();
|
||||
let text = i18n!(CATALOG, "Around {} characters left"; count);
|
||||
HtmlElement::try_from(e).map(|e| {
|
||||
js!{@{e}.innerText = @{text}};
|
||||
}).ok();
|
||||
e.dyn_ref::<HtmlElement>().map(|e| {
|
||||
e.set_inner_text(&text);
|
||||
}).unwrap();
|
||||
};
|
||||
}), 0);
|
||||
})) as Box<dyn FnMut()>);
|
||||
window().unwrap().set_timeout_with_callback_and_timeout_and_arguments(update_char_count.as_ref().unchecked_ref(), 0, &js_sys::Array::new()).unwrap();
|
||||
update_char_count.forget();
|
||||
autosave_debounce();
|
||||
}));
|
||||
})) as Box<dyn FnMut(KeyboardEvent)>);
|
||||
content
|
||||
.add_event_listener_with_callback("keydown", character_counter.as_ref().unchecked_ref())
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
character_counter.forget();
|
||||
|
||||
document().get_element_by_id("publish")?.add_event_listener(
|
||||
mv!(title, subtitle, content, old_ed => move |_: ClickEvent| {
|
||||
let popup = document().get_element_by_id("publish-popup").or_else(||
|
||||
init_popup(&title, &subtitle, &content, &old_ed).ok()
|
||||
).unwrap();
|
||||
let bg = document().get_element_by_id("popup-bg").or_else(||
|
||||
init_popup_bg().ok()
|
||||
).unwrap();
|
||||
let show_popup = Closure::wrap(Box::new(mv!(title, subtitle, content, old_ed => move |_| {
|
||||
let popup = document().get_element_by_id("publish-popup").or_else(||
|
||||
init_popup(&title, &subtitle, &content, &old_ed).ok()
|
||||
).unwrap();
|
||||
let bg = document().get_element_by_id("popup-bg").or_else(||
|
||||
init_popup_bg().ok()
|
||||
).unwrap();
|
||||
|
||||
popup.class_list().add("show").unwrap();
|
||||
bg.class_list().add("show").unwrap();
|
||||
}),
|
||||
);
|
||||
popup.class_list().add_1("show").unwrap();
|
||||
bg.class_list().add_1("show").unwrap();
|
||||
})) as Box<dyn FnMut(MouseEvent)>);
|
||||
document()
|
||||
.get_element_by_id("publish")
|
||||
.ok_or(EditorError::NoneError)?
|
||||
.add_event_listener_with_callback("click", show_popup.as_ref().unchecked_ref())
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
show_popup.forget();
|
||||
|
||||
show_errors();
|
||||
setup_close_button();
|
||||
@@ -359,32 +447,47 @@ fn init_editor() -> Result<(), EditorError> {
|
||||
|
||||
fn setup_close_button() {
|
||||
if let Some(button) = document().get_element_by_id("close-editor") {
|
||||
button.add_event_listener(|_: ClickEvent| {
|
||||
let close_editor = Closure::wrap(Box::new(|_| {
|
||||
window()
|
||||
.unwrap()
|
||||
.local_storage()
|
||||
.insert("basic-editor", "true")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.set("basic-editor", "true")
|
||||
.unwrap();
|
||||
window().history().go(0).unwrap(); // Refresh the page
|
||||
});
|
||||
window()
|
||||
.unwrap()
|
||||
.history()
|
||||
.unwrap()
|
||||
.go_with_delta(0)
|
||||
.unwrap(); // Refresh the page
|
||||
}) as Box<dyn FnMut(MouseEvent)>);
|
||||
button
|
||||
.add_event_listener_with_callback("click", close_editor.as_ref().unchecked_ref())
|
||||
.unwrap();
|
||||
close_editor.forget();
|
||||
}
|
||||
}
|
||||
|
||||
fn show_errors() {
|
||||
if let Ok(Some(header)) = document().query_selector("header") {
|
||||
let list = document().create_element("header").unwrap();
|
||||
list.class_list().add("messages").unwrap();
|
||||
for error in document().query_selector_all("p.error").unwrap() {
|
||||
let document = document();
|
||||
if let Ok(Some(header)) = document.query_selector("header") {
|
||||
let list = document.create_element("header").unwrap();
|
||||
list.class_list().add_1("messages").unwrap();
|
||||
let errors = document.query_selector_all("p.error").unwrap();
|
||||
for i in 0..errors.length() {
|
||||
let error = errors.get(i).unwrap();
|
||||
error
|
||||
.parent_element()
|
||||
.unwrap()
|
||||
.remove_child(&error)
|
||||
.unwrap();
|
||||
list.append_child(&error);
|
||||
let _ = list.append_child(&error);
|
||||
}
|
||||
header
|
||||
.parent_element()
|
||||
.unwrap()
|
||||
.insert_before(&list, &header.next_sibling().unwrap())
|
||||
.insert_before(&list, header.next_sibling().as_ref())
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -395,9 +498,17 @@ fn init_popup(
|
||||
content: &HtmlElement,
|
||||
old_ed: &Element,
|
||||
) -> Result<Element, EditorError> {
|
||||
let popup = document().create_element("div")?;
|
||||
popup.class_list().add("popup")?;
|
||||
popup.set_attribute("id", "publish-popup")?;
|
||||
let document = document();
|
||||
let popup = document
|
||||
.create_element("div")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
popup
|
||||
.class_list()
|
||||
.add_1("popup")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
popup
|
||||
.set_attribute("id", "publish-popup")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
|
||||
let tags = get_elt_value("tags")
|
||||
.split(',')
|
||||
@@ -405,112 +516,165 @@ fn init_popup(
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let license = get_elt_value("license");
|
||||
make_input(&i18n!(CATALOG, "Tags"), "popup-tags", &popup).set_raw_value(&tags.join(", "));
|
||||
make_input(&i18n!(CATALOG, "License"), "popup-license", &popup).set_raw_value(&license);
|
||||
make_input(&i18n!(CATALOG, "Tags"), "popup-tags", &popup).set_value(&tags.join(", "));
|
||||
make_input(&i18n!(CATALOG, "License"), "popup-license", &popup).set_value(&license);
|
||||
|
||||
let cover_label = document().create_element("label")?;
|
||||
cover_label.append_child(&document().create_text_node(&i18n!(CATALOG, "Cover")));
|
||||
cover_label.set_attribute("for", "cover")?;
|
||||
let cover = document().get_element_by_id("cover")?;
|
||||
cover.parent_element()?.remove_child(&cover).ok();
|
||||
popup.append_child(&cover_label);
|
||||
popup.append_child(&cover);
|
||||
let cover_label = document
|
||||
.create_element("label")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
cover_label
|
||||
.append_child(&document.create_text_node(&i18n!(CATALOG, "Cover")))
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
cover_label
|
||||
.set_attribute("for", "cover")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
let cover = document
|
||||
.get_element_by_id("cover")
|
||||
.ok_or(EditorError::NoneError)?;
|
||||
cover
|
||||
.parent_element()
|
||||
.ok_or(EditorError::NoneError)?
|
||||
.remove_child(&cover)
|
||||
.ok();
|
||||
popup
|
||||
.append_child(&cover_label)
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
popup
|
||||
.append_child(&cover)
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
|
||||
if let Some(draft_checkbox) = document().get_element_by_id("draft") {
|
||||
let draft_label = document().create_element("label")?;
|
||||
draft_label.set_attribute("for", "popup-draft")?;
|
||||
if let Some(draft_checkbox) = document.get_element_by_id("draft") {
|
||||
let draft_checkbox = draft_checkbox.dyn_ref::<HtmlInputElement>().unwrap();
|
||||
let draft_label = document
|
||||
.create_element("label")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
draft_label
|
||||
.set_attribute("for", "popup-draft")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
|
||||
let draft = document().create_element("input").unwrap();
|
||||
js! {
|
||||
@{&draft}.id = "popup-draft";
|
||||
@{&draft}.name = "popup-draft";
|
||||
@{&draft}.type = "checkbox";
|
||||
@{&draft}.checked = @{&draft_checkbox}.checked;
|
||||
};
|
||||
let draft = document.create_element("input").unwrap();
|
||||
draft.set_id("popup-draft");
|
||||
let draft = draft.dyn_ref::<HtmlInputElement>().unwrap();
|
||||
draft.set_name("popup-draft");
|
||||
draft.set_type("checkbox");
|
||||
draft.set_checked(draft_checkbox.checked());
|
||||
|
||||
draft_label.append_child(&draft);
|
||||
draft_label.append_child(&document().create_text_node(&i18n!(CATALOG, "This is a draft")));
|
||||
popup.append_child(&draft_label);
|
||||
draft_label
|
||||
.append_child(draft)
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
draft_label
|
||||
.append_child(&document.create_text_node(&i18n!(CATALOG, "This is a draft")))
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
popup
|
||||
.append_child(&draft_label)
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
}
|
||||
|
||||
let button = document().create_element("input")?;
|
||||
js! {
|
||||
@{&button}.type = "submit";
|
||||
@{&button}.value = @{i18n!(CATALOG, "Publish")};
|
||||
};
|
||||
button.append_child(&document().create_text_node(&i18n!(CATALOG, "Publish")));
|
||||
button.add_event_listener(
|
||||
mv!(title, subtitle, content, old_ed => move |_: ClickEvent| {
|
||||
title.focus(); // Remove the placeholder before publishing
|
||||
set_value("title", title.inner_text());
|
||||
subtitle.focus();
|
||||
set_value("subtitle", subtitle.inner_text());
|
||||
content.focus();
|
||||
set_value("editor-content", content.child_nodes().iter().fold(String::new(), |md, ch| {
|
||||
let to_append = match ch.node_type() {
|
||||
NodeType::Element => {
|
||||
if js!{ return @{&ch}.tagName; } == "DIV" {
|
||||
(js!{ return @{&ch}.innerHTML; }).try_into().unwrap_or_default()
|
||||
} else {
|
||||
(js!{ return @{&ch}.outerHTML; }).try_into().unwrap_or_default()
|
||||
}
|
||||
},
|
||||
NodeType::Text => ch.node_value().unwrap_or_default(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
format!("{}\n\n{}", md, to_append)
|
||||
}));
|
||||
set_value("tags", get_elt_value("popup-tags"));
|
||||
if let Some(draft) = document().get_element_by_id("popup-draft") {
|
||||
js!{
|
||||
document.getElementById("draft").checked = @{draft}.checked;
|
||||
};
|
||||
}
|
||||
let cover = document().get_element_by_id("cover").unwrap();
|
||||
cover.parent_element().unwrap().remove_child(&cover).ok();
|
||||
old_ed.append_child(&cover);
|
||||
set_value("license", get_elt_value("popup-license"));
|
||||
clear_autosave();
|
||||
js! {
|
||||
@{&old_ed}.submit();
|
||||
let button = document
|
||||
.create_element("input")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
button
|
||||
.append_child(&document.create_text_node(&i18n!(CATALOG, "Publish")))
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
let button = button.dyn_ref::<HtmlInputElement>().unwrap();
|
||||
button.set_type("submit");
|
||||
button.set_value(&i18n!(CATALOG, "Publish"));
|
||||
let callback = Closure::wrap(Box::new(mv!(title, subtitle, content, old_ed => move |_| {
|
||||
let document = self::document();
|
||||
title.focus().unwrap(); // Remove the placeholder before publishing
|
||||
set_value("title", title.inner_text());
|
||||
subtitle.focus().unwrap();
|
||||
set_value("subtitle", subtitle.inner_text());
|
||||
content.focus().unwrap();
|
||||
let mut md = String::new();
|
||||
let child_nodes = content.child_nodes();
|
||||
for i in 0..child_nodes.length() {
|
||||
let ch = child_nodes.get(i).unwrap();
|
||||
let to_append = match ch.node_type() {
|
||||
Node::ELEMENT_NODE => {
|
||||
let ch = ch.dyn_ref::<Element>().unwrap();
|
||||
if ch.tag_name() == "DIV" {
|
||||
ch.inner_html()
|
||||
} else {
|
||||
ch.outer_html()
|
||||
}
|
||||
},
|
||||
Node::TEXT_NODE => ch.node_value().unwrap_or_default(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
popup.append_child(&button);
|
||||
md = format!("{}\n\n{}", md, to_append);
|
||||
}
|
||||
set_value("editor-content", md);
|
||||
set_value("tags", get_elt_value("popup-tags"));
|
||||
if let Some(draft) = document.get_element_by_id("popup-draft") {
|
||||
if let Some(draft_checkbox) = document.get_element_by_id("draft") {
|
||||
let draft_checkbox = draft_checkbox.dyn_ref::<HtmlInputElement>().unwrap();
|
||||
let draft = draft.dyn_ref::<HtmlInputElement>().unwrap();
|
||||
draft_checkbox.set_checked(draft.checked());
|
||||
}
|
||||
}
|
||||
let cover = document.get_element_by_id("cover").unwrap();
|
||||
cover.parent_element().unwrap().remove_child(&cover).ok();
|
||||
old_ed.append_child(&cover).unwrap();
|
||||
set_value("license", get_elt_value("popup-license"));
|
||||
clear_autosave();
|
||||
let old_ed = old_ed.dyn_ref::<HtmlFormElement>().unwrap();
|
||||
old_ed.submit().unwrap();
|
||||
})) as Box<dyn FnMut(MouseEvent)>);
|
||||
button
|
||||
.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref())
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
callback.forget();
|
||||
popup
|
||||
.append_child(button)
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
|
||||
document().body()?.append_child(&popup);
|
||||
document
|
||||
.body()
|
||||
.ok_or(EditorError::NoneError)?
|
||||
.append_child(&popup)
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
Ok(popup)
|
||||
}
|
||||
|
||||
fn init_popup_bg() -> Result<Element, EditorError> {
|
||||
let bg = document().create_element("div")?;
|
||||
bg.class_list().add("popup-bg")?;
|
||||
bg.set_attribute("id", "popup-bg")?;
|
||||
let bg = document()
|
||||
.create_element("div")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
bg.class_list()
|
||||
.add_1("popup-bg")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
bg.set_attribute("id", "popup-bg")
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
|
||||
document().body()?.append_child(&bg);
|
||||
bg.add_event_listener(|_: ClickEvent| close_popup());
|
||||
document()
|
||||
.body()
|
||||
.ok_or(EditorError::NoneError)?
|
||||
.append_child(&bg)
|
||||
.map_err(|_| EditorError::DOMError)?;
|
||||
let callback = Closure::wrap(Box::new(|_| close_popup()) as Box<dyn FnMut(MouseEvent)>);
|
||||
bg.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref())
|
||||
.unwrap();
|
||||
callback.forget();
|
||||
Ok(bg)
|
||||
}
|
||||
|
||||
fn chars_left(selector: &str, content: &HtmlElement) -> Option<i32> {
|
||||
match document().query_selector(selector) {
|
||||
Ok(Some(form)) => HtmlElement::try_from(form).ok().and_then(|form| {
|
||||
Ok(Some(form)) => form.dyn_ref::<HtmlElement>().and_then(|form| {
|
||||
if let Some(len) = form
|
||||
.get_attribute("content-size")
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
{
|
||||
(js! {
|
||||
let x = encodeURIComponent(@{content}.innerHTML)
|
||||
.replace(/%20/g, "+")
|
||||
.replace(/%0A/g, "%0D%0A")
|
||||
.replace(new RegExp("[!'*()]", "g"), "XXX") // replace exceptions of encodeURIComponent with placeholder
|
||||
.length + 2;
|
||||
console.log(x);
|
||||
return x;
|
||||
})
|
||||
.try_into()
|
||||
.map(|c: i32| len - c)
|
||||
.ok()
|
||||
(encode_uri_component(&content.inner_html())
|
||||
.replace("%20", "+")
|
||||
.replace("%0A", "%0D0A")
|
||||
.replace_by_pattern(&RegExp::new("[!'*()]", "g"), "XXX")
|
||||
.length()
|
||||
+ 2_u32)
|
||||
.try_into()
|
||||
.map(|c: i32| len - c)
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -520,26 +684,26 @@ fn chars_left(selector: &str, content: &HtmlElement) -> Option<i32> {
|
||||
}
|
||||
|
||||
fn close_popup() {
|
||||
let hide = |x: Element| x.class_list().remove("show");
|
||||
let hide = |x: Element| x.class_list().remove_1("show");
|
||||
document().get_element_by_id("publish-popup").map(hide);
|
||||
document().get_element_by_id("popup-bg").map(hide);
|
||||
}
|
||||
|
||||
fn make_input(label_text: &str, name: &'static str, form: &Element) -> InputElement {
|
||||
let label = document().create_element("label").unwrap();
|
||||
label.append_child(&document().create_text_node(label_text));
|
||||
fn make_input(label_text: &str, name: &'static str, form: &Element) -> HtmlInputElement {
|
||||
let document = document();
|
||||
let label = document.create_element("label").unwrap();
|
||||
label
|
||||
.append_child(&document.create_text_node(label_text))
|
||||
.unwrap();
|
||||
label.set_attribute("for", name).unwrap();
|
||||
|
||||
let inp: InputElement = document()
|
||||
.create_element("input")
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let inp = document.create_element("input").unwrap();
|
||||
let inp = inp.dyn_into::<HtmlInputElement>().unwrap();
|
||||
inp.set_attribute("name", name).unwrap();
|
||||
inp.set_attribute("id", name).unwrap();
|
||||
|
||||
form.append_child(&label);
|
||||
form.append_child(&inp);
|
||||
form.append_child(&label).unwrap();
|
||||
form.append_child(&inp).unwrap();
|
||||
inp
|
||||
}
|
||||
|
||||
@@ -553,36 +717,46 @@ fn make_editable(tag: &'static str) -> Element {
|
||||
}
|
||||
|
||||
fn placeholder(elt: HtmlElement, text: &str) -> HtmlElement {
|
||||
elt.dataset().insert("placeholder", text).unwrap();
|
||||
elt.dataset().insert("edited", "false").unwrap();
|
||||
elt.dataset().set("placeholder", text).unwrap();
|
||||
elt.dataset().set("edited", "false").unwrap();
|
||||
|
||||
elt.add_event_listener(mv!(elt => move |_: FocusEvent| {
|
||||
let callback = Closure::wrap(Box::new(mv!(elt => move |_: FocusEvent| {
|
||||
if elt.dataset().get("edited").unwrap().as_str() != "true" {
|
||||
clear_children(&elt);
|
||||
}
|
||||
}));
|
||||
elt.add_event_listener(mv!(elt => move |_: BlurEvent| {
|
||||
})) as Box<dyn FnMut(FocusEvent)>);
|
||||
elt.add_event_listener_with_callback("focus", callback.as_ref().unchecked_ref())
|
||||
.unwrap();
|
||||
callback.forget();
|
||||
let callback = Closure::wrap(Box::new(mv!(elt => move |_: Event| {
|
||||
if elt.dataset().get("edited").unwrap().as_str() != "true" {
|
||||
clear_children(&elt);
|
||||
|
||||
let ph = document().create_element("span").expect("Couldn't create placeholder");
|
||||
ph.class_list().add("placeholder").expect("Couldn't add class");
|
||||
ph.append_child(&document().create_text_node(&elt.dataset().get("placeholder").unwrap_or_default()));
|
||||
elt.append_child(&ph);
|
||||
ph.class_list().add_1("placeholder").expect("Couldn't add class");
|
||||
ph.append_child(&document().create_text_node(&elt.dataset().get("placeholder").unwrap_or_default())).unwrap();
|
||||
elt.append_child(&ph).unwrap();
|
||||
}
|
||||
}));
|
||||
elt.add_event_listener(mv!(elt => move |_: KeyUpEvent| {
|
||||
elt.dataset().insert("edited", if elt.inner_text().trim_matches('\n').is_empty() {
|
||||
})) as Box<dyn FnMut(Event)>);
|
||||
elt.add_event_listener_with_callback("blur", callback.as_ref().unchecked_ref())
|
||||
.unwrap();
|
||||
callback.forget();
|
||||
let callback = Closure::wrap(Box::new(mv!(elt => move |_: KeyboardEvent| {
|
||||
elt.dataset().set("edited", if elt.inner_text().trim_matches('\n').is_empty() {
|
||||
"false"
|
||||
} else {
|
||||
"true"
|
||||
}).expect("Couldn't update edition state");
|
||||
}));
|
||||
})) as Box<dyn FnMut(KeyboardEvent)>);
|
||||
elt.add_event_listener_with_callback("keyup", callback.as_ref().unchecked_ref())
|
||||
.unwrap();
|
||||
callback.forget();
|
||||
elt
|
||||
}
|
||||
|
||||
fn clear_children(elt: &HtmlElement) {
|
||||
for child in elt.child_nodes() {
|
||||
elt.remove_child(&child).unwrap();
|
||||
let child_nodes = elt.child_nodes();
|
||||
for _ in 0..child_nodes.length() {
|
||||
elt.remove_child(&child_nodes.get(0).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#![recursion_limit = "128"]
|
||||
#![feature(decl_macro, proc_macro_hygiene)]
|
||||
|
||||
#[macro_use]
|
||||
extern crate gettext_macros;
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
use wasm_bindgen::{prelude::*, JsCast};
|
||||
use web_sys::{console, window, Document, Element, Event, HtmlInputElement, TouchEvent};
|
||||
|
||||
init_i18n!(
|
||||
"plume-front",
|
||||
af,
|
||||
ar,
|
||||
bg,
|
||||
ca,
|
||||
cs,
|
||||
cy,
|
||||
da,
|
||||
de,
|
||||
el,
|
||||
en,
|
||||
eo,
|
||||
es,
|
||||
eu,
|
||||
fa,
|
||||
fi,
|
||||
fr,
|
||||
gl,
|
||||
he,
|
||||
hi,
|
||||
hr,
|
||||
hu,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
nb,
|
||||
nl,
|
||||
no,
|
||||
pl,
|
||||
pt,
|
||||
ro,
|
||||
ru,
|
||||
sat,
|
||||
si,
|
||||
sk,
|
||||
sl,
|
||||
sr,
|
||||
sv,
|
||||
tr,
|
||||
uk,
|
||||
vi,
|
||||
zh
|
||||
);
|
||||
|
||||
mod editor;
|
||||
|
||||
compile_i18n!();
|
||||
|
||||
lazy_static! {
|
||||
static ref CATALOG: gettext::Catalog = {
|
||||
let catalogs = include_i18n!();
|
||||
let lang = window().unwrap().navigator().language().unwrap();
|
||||
let lang = lang.split_once('-').map_or("en", |x| x.0);
|
||||
|
||||
let english_position = catalogs
|
||||
.iter()
|
||||
.position(|(language_code, _)| *language_code == "en")
|
||||
.unwrap();
|
||||
catalogs
|
||||
.iter()
|
||||
.find(|(l, _)| l == &lang)
|
||||
.unwrap_or(&catalogs[english_position])
|
||||
.clone()
|
||||
.1
|
||||
};
|
||||
}
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn main() -> Result<(), JsValue> {
|
||||
extern crate console_error_panic_hook;
|
||||
use std::panic;
|
||||
panic::set_hook(Box::new(console_error_panic_hook::hook));
|
||||
|
||||
menu();
|
||||
search();
|
||||
editor::init()
|
||||
.map_err(|e| console::error_1(&format!("Editor error: {:?}", e).into()))
|
||||
.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Toggle menu on mobile devices
|
||||
///
|
||||
/// It should normally be working fine even without this code
|
||||
/// But :focus-within is not yet supported by Webkit/Blink
|
||||
fn menu() {
|
||||
let document = document();
|
||||
if let Ok(Some(button)) = document.query_selector("#menu a") {
|
||||
if let Some(menu) = document.get_element_by_id("content") {
|
||||
let show_menu = Closure::wrap(Box::new(|_: TouchEvent| {
|
||||
self::document()
|
||||
.get_element_by_id("menu")
|
||||
.map(|menu| {
|
||||
menu.set_attribute("aria-expanded", "true")
|
||||
.map(|_| menu.class_list().add_1("show"))
|
||||
})
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}) as Box<dyn FnMut(TouchEvent)>);
|
||||
button
|
||||
.add_event_listener_with_callback("touchend", show_menu.as_ref().unchecked_ref())
|
||||
.unwrap();
|
||||
show_menu.forget();
|
||||
|
||||
let close_menu = Closure::wrap(Box::new(|evt: TouchEvent| {
|
||||
if evt
|
||||
.target()
|
||||
.unwrap()
|
||||
.dyn_ref::<Element>()
|
||||
.unwrap()
|
||||
.closest("a")
|
||||
.unwrap()
|
||||
.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
self::document()
|
||||
.get_element_by_id("menu")
|
||||
.map(|menu| {
|
||||
menu.set_attribute("aria-expanded", "false")
|
||||
.map(|_| menu.class_list().remove_1("show"))
|
||||
})
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}) as Box<dyn FnMut(TouchEvent)>);
|
||||
menu.add_event_listener_with_callback("touchend", close_menu.as_ref().unchecked_ref())
|
||||
.unwrap();
|
||||
close_menu.forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the URL of the search page before submitting request
|
||||
fn search() {
|
||||
if let Some(form) = document().get_element_by_id("form") {
|
||||
let normalize_query = Closure::wrap(Box::new(|_: Event| {
|
||||
document()
|
||||
.query_selector_all("#form input")
|
||||
.map(|inputs| {
|
||||
for i in 0..inputs.length() {
|
||||
let input = inputs.get(i).unwrap();
|
||||
let input = input.dyn_ref::<HtmlInputElement>().unwrap();
|
||||
if input.name().is_empty() {
|
||||
input.set_name(&input.dyn_ref::<Element>().unwrap().id());
|
||||
}
|
||||
if !input.name().is_empty() && input.value().is_empty() {
|
||||
input.set_name("");
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}) as Box<dyn FnMut(Event)>);
|
||||
form.add_event_listener_with_callback("submit", normalize_query.as_ref().unchecked_ref())
|
||||
.unwrap();
|
||||
normalize_query.forget();
|
||||
}
|
||||
}
|
||||
|
||||
fn document() -> Document {
|
||||
window().unwrap().document().unwrap()
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
#![recursion_limit = "128"]
|
||||
#![feature(decl_macro, proc_macro_hygiene, try_trait)]
|
||||
|
||||
#[macro_use]
|
||||
extern crate gettext_macros;
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate stdweb;
|
||||
use stdweb::web::{event::*, *};
|
||||
|
||||
init_i18n!(
|
||||
"plume-front",
|
||||
ar,
|
||||
bg,
|
||||
ca,
|
||||
cs,
|
||||
de,
|
||||
en,
|
||||
eo,
|
||||
es,
|
||||
fa,
|
||||
fr,
|
||||
gl,
|
||||
hi,
|
||||
hr,
|
||||
it,
|
||||
ja,
|
||||
nb,
|
||||
pl,
|
||||
pt,
|
||||
ro,
|
||||
ru,
|
||||
sr,
|
||||
sk,
|
||||
sv
|
||||
);
|
||||
|
||||
mod editor;
|
||||
|
||||
compile_i18n!();
|
||||
|
||||
lazy_static! {
|
||||
static ref CATALOG: gettext::Catalog = {
|
||||
let catalogs = include_i18n!();
|
||||
let lang = js! { return navigator.language }.into_string().unwrap();
|
||||
let lang = lang.splitn(2, '-').next().unwrap_or("en");
|
||||
|
||||
let english_position = catalogs
|
||||
.iter()
|
||||
.position(|(language_code, _)| *language_code == "en")
|
||||
.unwrap();
|
||||
catalogs
|
||||
.iter()
|
||||
.find(|(l, _)| l == &lang)
|
||||
.unwrap_or(&catalogs[english_position])
|
||||
.clone()
|
||||
.1
|
||||
};
|
||||
}
|
||||
|
||||
fn main() {
|
||||
menu();
|
||||
search();
|
||||
editor::init()
|
||||
.map_err(|e| console!(error, format!("Editor error: {:?}", e)))
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Toggle menu on mobile devices
|
||||
///
|
||||
/// It should normally be working fine even without this code
|
||||
/// But :focus-within is not yet supported by Webkit/Blink
|
||||
fn menu() {
|
||||
if let Some(button) = document().get_element_by_id("menu") {
|
||||
if let Some(menu) = document().get_element_by_id("content") {
|
||||
button.add_event_listener(|_: TouchEnd| {
|
||||
document()
|
||||
.get_element_by_id("menu")
|
||||
.map(|menu| menu.class_list().add("show"));
|
||||
});
|
||||
menu.add_event_listener(|_: TouchEnd| {
|
||||
document()
|
||||
.get_element_by_id("menu")
|
||||
.map(|menu| menu.class_list().remove("show"));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the URL of the search page before submitting request
|
||||
fn search() {
|
||||
if let Some(form) = document().get_element_by_id("form") {
|
||||
form.add_event_listener(|_: SubmitEvent| {
|
||||
document()
|
||||
.query_selector_all("#form input")
|
||||
.map(|inputs| {
|
||||
for input in inputs {
|
||||
js! {
|
||||
if (@{&input}.name === "") {
|
||||
@{&input}.name = @{&input}.id
|
||||
}
|
||||
if (@{&input}.name && !@{&input}.value) {
|
||||
@{&input}.name = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "plume-macro"
|
||||
version = "0.4.0"
|
||||
version = "0.7.2"
|
||||
authors = ["Trinity Pointard <trinity.pointard@insa-rennes.fr>"]
|
||||
edition = "2018"
|
||||
description = "Plume procedural macros"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pre-release-hook = ["cargo", "fmt"]
|
||||
pre-release-replacements = []
|
||||
release = false
|
||||
Regular → Executable
+5
-5
@@ -58,7 +58,7 @@ pub fn import_migrations(input: TokenStream) -> TokenStream {
|
||||
(name, up_sql, down_sql)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let migrations_name = migrations.iter().map(|m| &m.0).collect::<Vec<_>>();
|
||||
let migrations_name = migrations.iter().map(|m| &m.0);
|
||||
let migrations_up = migrations
|
||||
.iter()
|
||||
.map(|m| m.1.as_str())
|
||||
@@ -89,12 +89,12 @@ fn file_to_migration(file: &str) -> TokenStream2 {
|
||||
let mut actions = vec![];
|
||||
for line in file.lines() {
|
||||
if sql {
|
||||
if line.starts_with("--#!") {
|
||||
if let Some(acc_str) = line.strip_prefix("--#!") {
|
||||
if !acc.trim().is_empty() {
|
||||
actions.push(quote!(Action::Sql(#acc)));
|
||||
}
|
||||
sql = false;
|
||||
acc = line[4..].to_string();
|
||||
acc = acc_str.to_string();
|
||||
acc.push('\n');
|
||||
} else if line.starts_with("--") {
|
||||
continue;
|
||||
@@ -102,8 +102,8 @@ fn file_to_migration(file: &str) -> TokenStream2 {
|
||||
acc.push_str(line);
|
||||
acc.push('\n');
|
||||
}
|
||||
} else if line.starts_with("--#!") {
|
||||
acc.push_str(&line[4..]);
|
||||
} else if let Some(acc_str) = line.strip_prefix("--#!") {
|
||||
acc.push_str(acc_str);
|
||||
acc.push('\n');
|
||||
} else if line.starts_with("--") {
|
||||
continue;
|
||||
|
||||
+28
-22
@@ -1,36 +1,41 @@
|
||||
[package]
|
||||
name = "plume-models"
|
||||
version = "0.4.0"
|
||||
version = "0.7.2"
|
||||
authors = ["Plume contributors"]
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
activitypub = "0.1.1"
|
||||
ammonia = "2.1.1"
|
||||
askama_escape = "0.1"
|
||||
bcrypt = "0.5"
|
||||
guid-create = "0.1"
|
||||
heck = "0.3.0"
|
||||
itertools = "0.8.0"
|
||||
ammonia = "3.2.0"
|
||||
bcrypt = "0.12.1"
|
||||
guid-create = "0.2"
|
||||
itertools = "0.10.3"
|
||||
lazy_static = "1.0"
|
||||
ldap3 = "0.11.1"
|
||||
migrations_internals= "1.4.0"
|
||||
openssl = "0.10.22"
|
||||
rocket = "0.4.0"
|
||||
rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" }
|
||||
reqwest = "0.9"
|
||||
scheduled-thread-pool = "0.2.2"
|
||||
serde = "1.0"
|
||||
openssl = "0.10.40"
|
||||
rocket = "0.4.11"
|
||||
rocket_i18n = "0.4.1"
|
||||
reqwest = "0.11.11"
|
||||
scheduled-thread-pool = "0.2.6"
|
||||
serde = "1.0.137"
|
||||
rust-s3 = { version = "0.29.0", no-default-features = true, features = ["blocking"] }
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
||||
tantivy = "0.12.0"
|
||||
serde_json = "1.0.81"
|
||||
tantivy = "0.13.3"
|
||||
url = "2.1"
|
||||
walkdir = "2.2"
|
||||
webfinger = "0.4.1"
|
||||
whatlang = "0.7.1"
|
||||
shrinkwraprs = "0.2.1"
|
||||
diesel-derive-newtype = "0.1.2"
|
||||
glob = "0.3.0"
|
||||
lindera-tantivy = { version = "0.1.2", optional = true }
|
||||
whatlang = "0.16.2"
|
||||
shrinkwraprs = "0.3.0"
|
||||
diesel-derive-newtype = "1.0.0"
|
||||
glob = "0.3.1"
|
||||
lindera-tantivy = { version = "0.7.1", optional = true }
|
||||
tracing = "0.1.35"
|
||||
riker = "0.4.2"
|
||||
once_cell = "1.12.0"
|
||||
lettre = "0.9.6"
|
||||
native-tls = "0.2.10"
|
||||
activitystreams = "=0.7.0-alpha.20"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
@@ -38,7 +43,7 @@ version = "0.4"
|
||||
|
||||
[dependencies.diesel]
|
||||
features = ["r2d2", "chrono"]
|
||||
version = "1.4.2"
|
||||
version = "1.4.5"
|
||||
|
||||
[dependencies.plume-api]
|
||||
path = "../plume-api"
|
||||
@@ -50,6 +55,7 @@ path = "../plume-common"
|
||||
path = "../plume-macro"
|
||||
|
||||
[dev-dependencies]
|
||||
assert-json-diff = "2.0.1"
|
||||
diesel_migrations = "1.3.0"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pre-release-hook = ["cargo", "fmt"]
|
||||
pre-release-replacements = []
|
||||
release = false
|
||||
@@ -86,20 +86,24 @@ impl<'a, 'r> FromRequest<'a, 'r> for ApiToken {
|
||||
}
|
||||
|
||||
let mut parsed_header = headers[0].split(' ');
|
||||
let auth_type = parsed_header.next().map_or_else(
|
||||
|| Outcome::Failure((Status::BadRequest, TokenError::NoType)),
|
||||
Outcome::Success,
|
||||
)?;
|
||||
let val = parsed_header.next().map_or_else(
|
||||
|| Outcome::Failure((Status::BadRequest, TokenError::NoValue)),
|
||||
Outcome::Success,
|
||||
)?;
|
||||
let auth_type = parsed_header
|
||||
.next()
|
||||
.map_or_else::<rocket::Outcome<&str, _, ()>, _, _>(
|
||||
|| Outcome::Failure((Status::BadRequest, TokenError::NoType)),
|
||||
Outcome::Success,
|
||||
)?;
|
||||
let val = parsed_header
|
||||
.next()
|
||||
.map_or_else::<rocket::Outcome<&str, _, ()>, _, _>(
|
||||
|| Outcome::Failure((Status::BadRequest, TokenError::NoValue)),
|
||||
Outcome::Success,
|
||||
)?;
|
||||
|
||||
if auth_type == "Bearer" {
|
||||
let conn = request
|
||||
.guard::<DbConn>()
|
||||
.map_failure(|_| (Status::InternalServerError, TokenError::DbError))?;
|
||||
if let Ok(token) = ApiToken::find_by_value(&*conn, val) {
|
||||
if let Ok(token) = ApiToken::find_by_value(&conn, val) {
|
||||
return Outcome::Success(token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ impl BlocklistedEmail {
|
||||
pub fn delete_entries(conn: &Connection, ids: Vec<i32>) -> Result<bool> {
|
||||
use diesel::delete;
|
||||
for i in ids {
|
||||
let be: BlocklistedEmail = BlocklistedEmail::find_by_id(&conn, i)?;
|
||||
let be: BlocklistedEmail = BlocklistedEmail::find_by_id(conn, i)?;
|
||||
delete(&be).execute(conn)?;
|
||||
}
|
||||
Ok(true)
|
||||
@@ -87,7 +87,7 @@ impl BlocklistedEmail {
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::{instance::tests as instance_tests, tests::rockets, Connection as Conn};
|
||||
use crate::{instance::tests as instance_tests, tests::db, Connection as Conn};
|
||||
use diesel::Connection;
|
||||
|
||||
pub(crate) fn fill_database(conn: &Conn) -> Vec<BlocklistedEmail> {
|
||||
@@ -106,33 +106,29 @@ pub(crate) mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn test_match() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let various = fill_database(conn);
|
||||
let various = fill_database(&conn);
|
||||
let match1 = "user1@bad-actor.com";
|
||||
let match2 = "spammer@lax-administration.com";
|
||||
let no_match = "happy-user@lax-administration.com";
|
||||
assert_eq!(
|
||||
BlocklistedEmail::matches_blocklist(conn, match1)
|
||||
BlocklistedEmail::matches_blocklist(&conn, match1)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id,
|
||||
various[0].id
|
||||
);
|
||||
assert_eq!(
|
||||
BlocklistedEmail::matches_blocklist(conn, match2)
|
||||
BlocklistedEmail::matches_blocklist(&conn, match2)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id,
|
||||
various[1].id
|
||||
);
|
||||
assert_eq!(
|
||||
BlocklistedEmail::matches_blocklist(conn, no_match)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
true
|
||||
);
|
||||
assert!(BlocklistedEmail::matches_blocklist(&conn, no_match)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
+381
-215
@@ -1,12 +1,14 @@
|
||||
use crate::{
|
||||
ap_url, instance::*, medias::Media, posts::Post, safe_string::SafeString, schema::blogs,
|
||||
search::Searcher, users::User, Connection, Error, PlumeRocket, Result, ITEMS_PER_PAGE,
|
||||
instance::*, medias::Media, posts::Post, safe_string::SafeString, schema::blogs, users::User,
|
||||
Connection, Error, PlumeRocket, Result, CONFIG, ITEMS_PER_PAGE,
|
||||
};
|
||||
use activitypub::{
|
||||
actor::Group,
|
||||
use activitystreams::{
|
||||
actor::{ApActor, ApActorExt, AsApActor, Group},
|
||||
base::AnyBase,
|
||||
collection::{OrderedCollection, OrderedCollectionPage},
|
||||
object::Image,
|
||||
CustomObject,
|
||||
iri_string::types::IriString,
|
||||
object::{kind::ImageType, ApObject, Image, ObjectExt},
|
||||
prelude::*,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::{self, ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SaveChangesDsl};
|
||||
@@ -16,17 +18,17 @@ use openssl::{
|
||||
rsa::Rsa,
|
||||
sign::{Signer, Verifier},
|
||||
};
|
||||
use plume_common::activity_pub::{
|
||||
inbox::{AsActor, FromId},
|
||||
sign, ActivityStream, ApSignature, Id, IntoId, PublicKey, Source,
|
||||
use plume_common::{
|
||||
activity_pub::{
|
||||
inbox::{AsActor, FromId},
|
||||
sign, ActivityStream, ApSignature, CustomGroup, Id, IntoId, PublicKey, Source,
|
||||
SourceProperty, ToAsString, ToAsUri,
|
||||
},
|
||||
utils::iri_percent_encode_seg,
|
||||
};
|
||||
use serde_json;
|
||||
use url::Url;
|
||||
use webfinger::*;
|
||||
|
||||
pub type CustomGroup = CustomObject<ApSignature, Group>;
|
||||
|
||||
#[derive(Queryable, Identifiable, Clone, AsChangeset)]
|
||||
#[derive(Queryable, Identifiable, Clone, AsChangeset, Debug)]
|
||||
#[changeset_options(treat_none_as_null = "true")]
|
||||
pub struct Blog {
|
||||
pub id: i32,
|
||||
@@ -84,9 +86,13 @@ impl Blog {
|
||||
|
||||
if inserted.fqn.is_empty() {
|
||||
if instance.local {
|
||||
inserted.fqn = inserted.actor_id.clone();
|
||||
inserted.fqn = iri_percent_encode_seg(&inserted.actor_id);
|
||||
} else {
|
||||
inserted.fqn = format!("{}@{}", inserted.actor_id, instance.public_domain);
|
||||
inserted.fqn = format!(
|
||||
"{}@{}",
|
||||
iri_percent_encode_seg(&inserted.actor_id),
|
||||
instance.public_domain
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +102,10 @@ impl Blog {
|
||||
find_by!(blogs, find_by_ap_url, ap_url as &str);
|
||||
find_by!(blogs, find_by_name, actor_id as &str, instance_id as i32);
|
||||
|
||||
pub fn slug(title: &str) -> &str {
|
||||
title
|
||||
}
|
||||
|
||||
pub fn get_instance(&self, conn: &Connection) -> Result<Instance> {
|
||||
Instance::get(conn, self.instance_id)
|
||||
}
|
||||
@@ -132,141 +142,168 @@ impl Blog {
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result<Blog> {
|
||||
pub fn find_by_fqn(conn: &Connection, fqn: &str) -> Result<Blog> {
|
||||
let from_db = blogs::table
|
||||
.filter(blogs::fqn.eq(fqn))
|
||||
.first(&*c.conn)
|
||||
.first(conn)
|
||||
.optional()?;
|
||||
if let Some(from_db) = from_db {
|
||||
Ok(from_db)
|
||||
} else {
|
||||
Blog::fetch_from_webfinger(c, fqn)
|
||||
Blog::fetch_from_webfinger(conn, fqn)
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result<Blog> {
|
||||
fn fetch_from_webfinger(conn: &Connection, acct: &str) -> Result<Blog> {
|
||||
resolve_with_prefix(Prefix::Group, acct.to_owned(), true)?
|
||||
.links
|
||||
.into_iter()
|
||||
.find(|l| l.mime_type == Some(String::from("application/activity+json")))
|
||||
.ok_or(Error::Webfinger)
|
||||
.and_then(|l| Blog::from_id(c, &l.href?, None).map_err(|(_, e)| e))
|
||||
.and_then(|l| {
|
||||
Blog::from_id(
|
||||
conn,
|
||||
&l.href.ok_or(Error::MissingApProperty)?,
|
||||
None,
|
||||
CONFIG.proxy(),
|
||||
)
|
||||
.map_err(|(_, e)| e)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_activity(&self, conn: &Connection) -> Result<CustomGroup> {
|
||||
let mut blog = Group::default();
|
||||
blog.ap_actor_props
|
||||
.set_preferred_username_string(self.actor_id.clone())?;
|
||||
blog.object_props.set_name_string(self.title.clone())?;
|
||||
blog.ap_actor_props
|
||||
.set_outbox_string(self.outbox_url.clone())?;
|
||||
blog.ap_actor_props
|
||||
.set_inbox_string(self.inbox_url.clone())?;
|
||||
blog.object_props
|
||||
.set_summary_string(self.summary_html.to_string())?;
|
||||
blog.ap_object_props.set_source_object(Source {
|
||||
content: self.summary.clone(),
|
||||
media_type: String::from("text/markdown"),
|
||||
})?;
|
||||
let mut blog = ApActor::new(self.inbox_url.parse()?, Group::new());
|
||||
blog.set_preferred_username(iri_percent_encode_seg(&self.actor_id));
|
||||
blog.set_name(self.title.clone());
|
||||
blog.set_outbox(self.outbox_url.parse()?);
|
||||
blog.set_summary(self.summary_html.to_string());
|
||||
let source = SourceProperty {
|
||||
source: Source {
|
||||
content: self.summary.clone(),
|
||||
media_type: String::from("text/markdown"),
|
||||
},
|
||||
};
|
||||
|
||||
let mut icon = Image::default();
|
||||
icon.object_props.set_url_string(
|
||||
self.icon_id
|
||||
.and_then(|id| Media::get(conn, id).and_then(|m| m.url()).ok())
|
||||
.unwrap_or_default(),
|
||||
)?;
|
||||
icon.object_props.set_attributed_to_link(
|
||||
self.icon_id
|
||||
.and_then(|id| {
|
||||
Media::get(conn, id)
|
||||
.and_then(|m| Ok(User::get(conn, m.owner_id)?.into_id()))
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or_else(|| Id::new(String::new())),
|
||||
)?;
|
||||
blog.object_props.set_icon_object(icon)?;
|
||||
let mut icon = Image::new();
|
||||
let _ = self.icon_id.map(|id| {
|
||||
Media::get(conn, id).and_then(|m| {
|
||||
let _ = m
|
||||
.url()
|
||||
.and_then(|url| url.parse::<IriString>().map_err(|_| Error::Url))
|
||||
.map(|url| icon.set_url(url));
|
||||
icon.set_attributed_to(
|
||||
User::get(conn, m.owner_id)?
|
||||
.into_id()
|
||||
.parse::<IriString>()?,
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
blog.set_icon(icon.into_any_base()?);
|
||||
|
||||
let mut banner = Image::default();
|
||||
banner.object_props.set_url_string(
|
||||
self.banner_id
|
||||
.and_then(|id| Media::get(conn, id).and_then(|m| m.url()).ok())
|
||||
.unwrap_or_default(),
|
||||
)?;
|
||||
banner.object_props.set_attributed_to_link(
|
||||
self.banner_id
|
||||
.and_then(|id| {
|
||||
Media::get(conn, id)
|
||||
.and_then(|m| Ok(User::get(conn, m.owner_id)?.into_id()))
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or_else(|| Id::new(String::new())),
|
||||
)?;
|
||||
blog.object_props.set_image_object(banner)?;
|
||||
let mut banner = Image::new();
|
||||
let _ = self.banner_id.map(|id| {
|
||||
Media::get(conn, id).and_then(|m| {
|
||||
let _ = m
|
||||
.url()
|
||||
.and_then(|url| url.parse::<IriString>().map_err(|_| Error::Url))
|
||||
.map(|url| banner.set_url(url));
|
||||
banner.set_attributed_to(
|
||||
User::get(conn, m.owner_id)?
|
||||
.into_id()
|
||||
.parse::<IriString>()?,
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
blog.set_image(banner.into_any_base()?);
|
||||
|
||||
blog.object_props.set_id_string(self.ap_url.clone())?;
|
||||
blog.set_id(self.ap_url.parse()?);
|
||||
|
||||
let mut public_key = PublicKey::default();
|
||||
public_key.set_id_string(format!("{}#main-key", self.ap_url))?;
|
||||
public_key.set_owner_string(self.ap_url.clone())?;
|
||||
public_key.set_public_key_pem_string(self.public_key.clone())?;
|
||||
let mut ap_signature = ApSignature::default();
|
||||
ap_signature.set_public_key_publickey(public_key)?;
|
||||
let pub_key = PublicKey {
|
||||
id: format!("{}#main-key", self.ap_url).parse()?,
|
||||
owner: self.ap_url.parse()?,
|
||||
public_key_pem: self.public_key.clone(),
|
||||
};
|
||||
let ap_signature = ApSignature {
|
||||
public_key: pub_key,
|
||||
};
|
||||
|
||||
Ok(CustomGroup::new(blog, ap_signature))
|
||||
Ok(CustomGroup::new(blog, ap_signature, source))
|
||||
}
|
||||
|
||||
pub fn outbox(&self, conn: &Connection) -> Result<ActivityStream<OrderedCollection>> {
|
||||
let mut coll = OrderedCollection::default();
|
||||
coll.collection_props.items = serde_json::to_value(self.get_activities(conn)?)?;
|
||||
coll.collection_props
|
||||
.set_total_items_u64(self.get_activities(conn)?.len() as u64)?;
|
||||
coll.collection_props
|
||||
.set_first_link(Id::new(ap_url(&format!("{}?page=1", &self.outbox_url))))?;
|
||||
coll.collection_props
|
||||
.set_last_link(Id::new(ap_url(&format!(
|
||||
self.outbox_collection(conn).map(ActivityStream::new)
|
||||
}
|
||||
pub fn outbox_collection(&self, conn: &Connection) -> Result<OrderedCollection> {
|
||||
let acts = self.get_activities(conn);
|
||||
let acts = acts
|
||||
.iter()
|
||||
.filter_map(|value| AnyBase::from_arbitrary_json(value).ok())
|
||||
.collect::<Vec<AnyBase>>();
|
||||
let n_acts = acts.len();
|
||||
let mut coll = OrderedCollection::new();
|
||||
coll.set_many_items(acts);
|
||||
coll.set_total_items(n_acts as u64);
|
||||
coll.set_first(format!("{}?page=1", &self.outbox_url).parse::<IriString>()?);
|
||||
coll.set_last(
|
||||
format!(
|
||||
"{}?page={}",
|
||||
&self.outbox_url,
|
||||
(self.get_activities(conn)?.len() as u64 + ITEMS_PER_PAGE as u64 - 1) as u64
|
||||
/ ITEMS_PER_PAGE as u64
|
||||
))))?;
|
||||
Ok(ActivityStream::new(coll))
|
||||
(n_acts as u64 + ITEMS_PER_PAGE as u64 - 1) as u64 / ITEMS_PER_PAGE as u64
|
||||
)
|
||||
.parse::<IriString>()?,
|
||||
);
|
||||
Ok(coll)
|
||||
}
|
||||
pub fn outbox_page(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
(min, max): (i32, i32),
|
||||
) -> Result<ActivityStream<OrderedCollectionPage>> {
|
||||
let mut coll = OrderedCollectionPage::default();
|
||||
let acts = self.get_activity_page(&conn, (min, max))?;
|
||||
self.outbox_collection_page(conn, (min, max))
|
||||
.map(ActivityStream::new)
|
||||
}
|
||||
pub fn outbox_collection_page(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
(min, max): (i32, i32),
|
||||
) -> Result<OrderedCollectionPage> {
|
||||
let mut coll = OrderedCollectionPage::new();
|
||||
let acts = self.get_activity_page(conn, (min, max));
|
||||
//This still doesn't do anything because the outbox
|
||||
//doesn't do anything yet
|
||||
coll.collection_page_props.set_next_link(Id::new(&format!(
|
||||
"{}?page={}",
|
||||
&self.outbox_url,
|
||||
min / ITEMS_PER_PAGE + 1
|
||||
)))?;
|
||||
coll.collection_page_props.set_prev_link(Id::new(&format!(
|
||||
"{}?page={}",
|
||||
&self.outbox_url,
|
||||
min / ITEMS_PER_PAGE - 1
|
||||
)))?;
|
||||
coll.collection_props.items = serde_json::to_value(acts)?;
|
||||
Ok(ActivityStream::new(coll))
|
||||
coll.set_next(
|
||||
format!("{}?page={}", &self.outbox_url, min / ITEMS_PER_PAGE + 1)
|
||||
.parse::<IriString>()?,
|
||||
);
|
||||
coll.set_prev(
|
||||
format!("{}?page={}", &self.outbox_url, min / ITEMS_PER_PAGE - 1)
|
||||
.parse::<IriString>()?,
|
||||
);
|
||||
coll.set_many_items(
|
||||
acts.iter()
|
||||
.filter_map(|value| AnyBase::from_arbitrary_json(value).ok()),
|
||||
);
|
||||
Ok(coll)
|
||||
}
|
||||
fn get_activities(&self, _conn: &Connection) -> Result<Vec<serde_json::Value>> {
|
||||
Ok(vec![])
|
||||
fn get_activities(&self, _conn: &Connection) -> Vec<serde_json::Value> {
|
||||
vec![]
|
||||
}
|
||||
fn get_activity_page(
|
||||
&self,
|
||||
_conn: &Connection,
|
||||
(_min, _max): (i32, i32),
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
Ok(vec![])
|
||||
) -> Vec<serde_json::Value> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub fn get_keypair(&self) -> Result<PKey<Private>> {
|
||||
PKey::from_rsa(Rsa::private_key_from_pem(
|
||||
self.private_key.clone()?.as_ref(),
|
||||
self.private_key
|
||||
.clone()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.as_ref(),
|
||||
)?)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
@@ -318,9 +355,9 @@ impl Blog {
|
||||
.and_then(|c| c.url().ok())
|
||||
}
|
||||
|
||||
pub fn delete(&self, conn: &Connection, searcher: &Searcher) -> Result<()> {
|
||||
for post in Post::get_for_blog(conn, &self)? {
|
||||
post.delete(conn, searcher)?;
|
||||
pub fn delete(&self, conn: &Connection) -> Result<()> {
|
||||
for post in Post::get_for_blog(conn, self)? {
|
||||
post.delete(conn)?;
|
||||
}
|
||||
diesel::delete(self)
|
||||
.execute(conn)
|
||||
@@ -335,20 +372,102 @@ impl IntoId for Blog {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromId<PlumeRocket> for Blog {
|
||||
impl FromId<Connection> for Blog {
|
||||
type Error = Error;
|
||||
type Object = CustomGroup;
|
||||
|
||||
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
|
||||
Self::find_by_ap_url(&c.conn, id)
|
||||
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
|
||||
Self::find_by_ap_url(conn, id)
|
||||
}
|
||||
|
||||
fn from_activity(c: &PlumeRocket, acct: CustomGroup) -> Result<Self> {
|
||||
let url = Url::parse(&acct.object.object_props.id_string()?)?;
|
||||
let inst = url.host_str()?;
|
||||
let instance = Instance::find_by_domain(&c.conn, inst).or_else(|_| {
|
||||
fn from_activity(conn: &Connection, acct: CustomGroup) -> Result<Self> {
|
||||
let (name, outbox_url, inbox_url) = {
|
||||
let actor = acct.ap_actor_ref();
|
||||
let name = actor
|
||||
.preferred_username()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.to_string();
|
||||
if name.contains(&['<', '>', '&', '@', '\'', '"', ' ', '\t'][..]) {
|
||||
tracing::error!("preferredUsername includes invalid character(s): {}", &name);
|
||||
return Err(Error::InvalidValue);
|
||||
}
|
||||
(
|
||||
name,
|
||||
actor.outbox()?.ok_or(Error::MissingApProperty)?.to_string(),
|
||||
actor.inbox()?.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
let mut new_blog = NewBlog {
|
||||
actor_id: name.to_string(),
|
||||
outbox_url,
|
||||
inbox_url,
|
||||
public_key: acct.ext_one.public_key.public_key_pem.to_string(),
|
||||
private_key: None,
|
||||
theme: None,
|
||||
..NewBlog::default()
|
||||
};
|
||||
|
||||
let object = ApObject::new(acct.inner);
|
||||
new_blog.title = object
|
||||
.name()
|
||||
.and_then(|name| name.to_as_string())
|
||||
.unwrap_or(name);
|
||||
new_blog.summary_html = SafeString::new(
|
||||
&object
|
||||
.summary()
|
||||
.and_then(|summary| summary.to_as_string())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
let icon_id = object
|
||||
.icon()
|
||||
.and_then(|icons| {
|
||||
icons.iter().next().and_then(|icon| {
|
||||
let icon = icon.to_owned().extend::<Image, ImageType>().ok()??;
|
||||
let owner = icon.attributed_to()?.to_as_uri()?;
|
||||
Media::save_remote(
|
||||
conn,
|
||||
icon.url()?.to_as_uri()?,
|
||||
&User::from_id(conn, &owner, None, CONFIG.proxy()).ok()?,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
})
|
||||
.map(|m| m.id);
|
||||
new_blog.icon_id = icon_id;
|
||||
|
||||
let banner_id = object
|
||||
.image()
|
||||
.and_then(|banners| {
|
||||
banners.iter().next().and_then(|banner| {
|
||||
let banner = banner.to_owned().extend::<Image, ImageType>().ok()??;
|
||||
let owner = banner.attributed_to()?.to_as_uri()?;
|
||||
Media::save_remote(
|
||||
conn,
|
||||
banner.url()?.to_as_uri()?,
|
||||
&User::from_id(conn, &owner, None, CONFIG.proxy()).ok()?,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
})
|
||||
.map(|m| m.id);
|
||||
new_blog.banner_id = banner_id;
|
||||
|
||||
new_blog.summary = acct.ext_two.source.content;
|
||||
|
||||
let any_base = AnyBase::from_extended(object)?;
|
||||
let id = any_base.id().ok_or(Error::MissingApProperty)?;
|
||||
new_blog.ap_url = id.to_string();
|
||||
|
||||
let inst = id
|
||||
.authority_components()
|
||||
.ok_or(Error::Url)?
|
||||
.host()
|
||||
.to_string();
|
||||
let instance = Instance::find_by_domain(conn, &inst).or_else(|_| {
|
||||
Instance::insert(
|
||||
&c.conn,
|
||||
conn,
|
||||
NewInstance {
|
||||
public_domain: inst.to_owned(),
|
||||
name: inst.to_owned(),
|
||||
@@ -363,75 +482,13 @@ impl FromId<PlumeRocket> for Blog {
|
||||
},
|
||||
)
|
||||
})?;
|
||||
let icon_id = acct
|
||||
.object
|
||||
.object_props
|
||||
.icon_image()
|
||||
.ok()
|
||||
.and_then(|icon| {
|
||||
let owner = icon.object_props.attributed_to_link::<Id>().ok()?;
|
||||
Media::save_remote(
|
||||
&c.conn,
|
||||
icon.object_props.url_string().ok()?,
|
||||
&User::from_id(c, &owner, None).ok()?,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.map(|m| m.id);
|
||||
new_blog.instance_id = instance.id;
|
||||
|
||||
let banner_id = acct
|
||||
.object
|
||||
.object_props
|
||||
.image_image()
|
||||
.ok()
|
||||
.and_then(|banner| {
|
||||
let owner = banner.object_props.attributed_to_link::<Id>().ok()?;
|
||||
Media::save_remote(
|
||||
&c.conn,
|
||||
banner.object_props.url_string().ok()?,
|
||||
&User::from_id(c, &owner, None).ok()?,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.map(|m| m.id);
|
||||
Blog::insert(conn, new_blog)
|
||||
}
|
||||
|
||||
let name = acct.object.ap_actor_props.preferred_username_string()?;
|
||||
if name.contains(&['<', '>', '&', '@', '\'', '"', ' ', '\t'][..]) {
|
||||
return Err(Error::InvalidValue);
|
||||
}
|
||||
|
||||
Blog::insert(
|
||||
&c.conn,
|
||||
NewBlog {
|
||||
actor_id: name.clone(),
|
||||
title: acct.object.object_props.name_string().unwrap_or(name),
|
||||
outbox_url: acct.object.ap_actor_props.outbox_string()?,
|
||||
inbox_url: acct.object.ap_actor_props.inbox_string()?,
|
||||
summary: acct
|
||||
.object
|
||||
.ap_object_props
|
||||
.source_object::<Source>()
|
||||
.map(|s| s.content)
|
||||
.unwrap_or_default(),
|
||||
instance_id: instance.id,
|
||||
ap_url: acct.object.object_props.id_string()?,
|
||||
public_key: acct
|
||||
.custom_props
|
||||
.public_key_publickey()?
|
||||
.public_key_pem_string()?,
|
||||
private_key: None,
|
||||
banner_id,
|
||||
icon_id,
|
||||
summary_html: SafeString::new(
|
||||
&acct
|
||||
.object
|
||||
.object_props
|
||||
.summary_string()
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
theme: None,
|
||||
},
|
||||
)
|
||||
fn get_sender() -> &'static dyn sign::Signer {
|
||||
Instance::get_local_instance_user().expect("Failed to local instance user")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,24 +509,22 @@ impl AsActor<&PlumeRocket> for Blog {
|
||||
}
|
||||
|
||||
impl sign::Signer for Blog {
|
||||
type Error = Error;
|
||||
|
||||
fn get_key_id(&self) -> String {
|
||||
format!("{}#main-key", self.ap_url)
|
||||
}
|
||||
|
||||
fn sign(&self, to_sign: &str) -> Result<Vec<u8>> {
|
||||
let key = self.get_keypair()?;
|
||||
fn sign(&self, to_sign: &str) -> sign::Result<Vec<u8>> {
|
||||
let key = self.get_keypair().map_err(|_| sign::Error())?;
|
||||
let mut signer = Signer::new(MessageDigest::sha256(), &key)?;
|
||||
signer.update(to_sign.as_bytes())?;
|
||||
signer.sign_to_vec().map_err(Error::from)
|
||||
signer.sign_to_vec().map_err(sign::Error::from)
|
||||
}
|
||||
|
||||
fn verify(&self, data: &str, signature: &[u8]) -> Result<bool> {
|
||||
fn verify(&self, data: &str, signature: &[u8]) -> sign::Result<bool> {
|
||||
let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref())?)?;
|
||||
let mut verifier = Verifier::new(MessageDigest::sha256(), &key)?;
|
||||
verifier.update(data.as_bytes())?;
|
||||
verifier.verify(&signature).map_err(Error::from)
|
||||
verifier.verify(signature).map_err(sign::Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,21 +552,17 @@ impl NewBlog {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
blog_authors::*,
|
||||
config::CONFIG,
|
||||
instance::tests as instance_tests,
|
||||
medias::NewMedia,
|
||||
search::tests::get_searcher,
|
||||
tests::{db, rockets},
|
||||
users::tests as usersTests,
|
||||
Connection as Conn,
|
||||
blog_authors::*, instance::tests as instance_tests, medias::NewMedia, tests::db,
|
||||
users::tests as usersTests, Connection as Conn,
|
||||
};
|
||||
use assert_json_diff::assert_json_eq;
|
||||
use diesel::Connection;
|
||||
use serde_json::to_value;
|
||||
|
||||
pub(crate) fn fill_database(conn: &Conn) -> (Vec<User>, Vec<Blog>) {
|
||||
instance_tests::fill_database(conn);
|
||||
let users = usersTests::fill_database(conn);
|
||||
let blog1 = Blog::insert(
|
||||
let mut blog1 = Blog::insert(
|
||||
conn,
|
||||
NewBlog::new_local(
|
||||
"BlogName".to_owned(),
|
||||
@@ -584,6 +635,41 @@ pub(crate) mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
blog1.icon_id = Some(
|
||||
Media::insert(
|
||||
conn,
|
||||
NewMedia {
|
||||
file_path: "aaa.png".into(),
|
||||
alt_text: String::new(),
|
||||
is_remote: false,
|
||||
remote_url: None,
|
||||
sensitive: false,
|
||||
content_warning: None,
|
||||
owner_id: users[0].id,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.id,
|
||||
);
|
||||
blog1.banner_id = Some(
|
||||
Media::insert(
|
||||
conn,
|
||||
NewMedia {
|
||||
file_path: "bbb.png".into(),
|
||||
alt_text: String::new(),
|
||||
is_remote: false,
|
||||
remote_url: None,
|
||||
sensitive: false,
|
||||
content_warning: None,
|
||||
owner_id: users[0].id,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.id,
|
||||
);
|
||||
let _: Blog = blog1.save_changes(conn).unwrap();
|
||||
|
||||
(users, vec![blog1, blog2, blog3])
|
||||
}
|
||||
|
||||
@@ -717,8 +803,7 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn find_local() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
fill_database(conn);
|
||||
|
||||
@@ -734,7 +819,7 @@ pub(crate) mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(Blog::find_by_fqn(&r, "SomeName").unwrap().id, blog.id);
|
||||
assert_eq!(Blog::find_by_fqn(conn, "SomeName").unwrap().id, blog.id);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -768,9 +853,7 @@ pub(crate) mod tests {
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (_, blogs) = fill_database(conn);
|
||||
|
||||
blogs[0]
|
||||
.delete(conn, &get_searcher(&CONFIG.search_tokenizers))
|
||||
.unwrap();
|
||||
blogs[0].delete(conn).unwrap();
|
||||
assert!(Blog::get(conn, blogs[0].id).is_err());
|
||||
Ok(())
|
||||
})
|
||||
@@ -780,7 +863,6 @@ pub(crate) mod tests {
|
||||
fn delete_via_user() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let searcher = get_searcher(&CONFIG.search_tokenizers);
|
||||
let (user, _) = fill_database(conn);
|
||||
|
||||
let b1 = Blog::insert(
|
||||
@@ -837,10 +919,10 @@ pub(crate) mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
user[0].delete(conn, &searcher).unwrap();
|
||||
user[0].delete(conn).unwrap();
|
||||
assert!(Blog::get(conn, blog[0].id).is_ok());
|
||||
assert!(Blog::get(conn, blog[1].id).is_err());
|
||||
user[1].delete(conn, &searcher).unwrap();
|
||||
user[1].delete(conn).unwrap();
|
||||
assert!(Blog::get(conn, blog[0].id).is_err());
|
||||
Ok(())
|
||||
})
|
||||
@@ -848,8 +930,7 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn self_federation() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, mut blogs) = fill_database(conn);
|
||||
blogs[0].icon_id = Some(
|
||||
@@ -884,11 +965,10 @@ pub(crate) mod tests {
|
||||
.unwrap()
|
||||
.id,
|
||||
);
|
||||
let _: Blog = blogs[0].save_changes(conn).unwrap();
|
||||
|
||||
let _: Blog = blogs[0].save_changes(&**conn).unwrap();
|
||||
let ap_repr = blogs[0].to_activity(conn).unwrap();
|
||||
blogs[0].delete(conn, &*r.searcher).unwrap();
|
||||
let blog = Blog::from_activity(&r, ap_repr).unwrap();
|
||||
blogs[0].delete(conn).unwrap();
|
||||
let blog = Blog::from_activity(conn, ap_repr).unwrap();
|
||||
|
||||
assert_eq!(blog.actor_id, blogs[0].actor_id);
|
||||
assert_eq!(blog.title, blogs[0].title);
|
||||
@@ -906,4 +986,90 @@ pub(crate) mod tests {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_activity() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (_users, blogs) = fill_database(conn);
|
||||
let blog = &blogs[0];
|
||||
let act = blog.to_activity(conn)?;
|
||||
|
||||
let expected = json!({
|
||||
"icon": {
|
||||
"attributedTo": "https://plu.me/@/admin/",
|
||||
"type": "Image",
|
||||
"url": "https://plu.me/aaa.png"
|
||||
},
|
||||
"id": "https://plu.me/~/BlogName/",
|
||||
"image": {
|
||||
"attributedTo": "https://plu.me/@/admin/",
|
||||
"type": "Image",
|
||||
"url": "https://plu.me/bbb.png"
|
||||
},
|
||||
"inbox": "https://plu.me/~/BlogName/inbox",
|
||||
"name": "Blog name",
|
||||
"outbox": "https://plu.me/~/BlogName/outbox",
|
||||
"preferredUsername": "BlogName",
|
||||
"publicKey": {
|
||||
"id": "https://plu.me/~/BlogName/#main-key",
|
||||
"owner": "https://plu.me/~/BlogName/",
|
||||
"publicKeyPem": blog.public_key
|
||||
},
|
||||
"source": {
|
||||
"content": "This is a small blog",
|
||||
"mediaType": "text/markdown"
|
||||
},
|
||||
"summary": "",
|
||||
"type": "Group"
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbox_collection() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (_users, blogs) = fill_database(conn);
|
||||
let blog = &blogs[0];
|
||||
let act = blog.outbox_collection(conn)?;
|
||||
|
||||
let expected = json!({
|
||||
"items": [],
|
||||
"totalItems": 0,
|
||||
"first": "https://plu.me/~/BlogName/outbox?page=1",
|
||||
"last": "https://plu.me/~/BlogName/outbox?page=0",
|
||||
"type": "OrderedCollection"
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbox_collection_page() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (_users, blogs) = fill_database(conn);
|
||||
let blog = &blogs[0];
|
||||
let act = blog.outbox_collection_page(conn, (33, 36))?;
|
||||
|
||||
let expected = json!({
|
||||
"next": "https://plu.me/~/BlogName/outbox?page=3",
|
||||
"prev": "https://plu.me/~/BlogName/outbox?page=1",
|
||||
"items": [],
|
||||
"type": "OrderedCollectionPage"
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+302
-144
@@ -8,23 +8,28 @@ use crate::{
|
||||
safe_string::SafeString,
|
||||
schema::comments,
|
||||
users::User,
|
||||
Connection, Error, PlumeRocket, Result,
|
||||
Connection, Error, Result, CONFIG,
|
||||
};
|
||||
use activitypub::{
|
||||
use activitystreams::{
|
||||
activity::{Create, Delete},
|
||||
link,
|
||||
base::{AnyBase, Base},
|
||||
iri_string::types::IriString,
|
||||
link::{self, kind::MentionType},
|
||||
object::{Note, Tombstone},
|
||||
prelude::*,
|
||||
primitives::OneOrMany,
|
||||
time::OffsetDateTime,
|
||||
};
|
||||
use chrono::{self, NaiveDateTime};
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
|
||||
use plume_common::{
|
||||
activity_pub::{
|
||||
inbox::{AsActor, AsObject, FromId},
|
||||
Id, IntoId, PUBLIC_VISIBILITY,
|
||||
sign::Signer,
|
||||
IntoId, ToAsString, ToAsUri, PUBLIC_VISIBILITY,
|
||||
},
|
||||
utils,
|
||||
};
|
||||
use serde_json;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Queryable, Identifiable, Clone, AsChangeset)]
|
||||
@@ -58,7 +63,7 @@ impl Comment {
|
||||
insert!(comments, NewComment, |inserted, conn| {
|
||||
if inserted.ap_url.is_none() {
|
||||
inserted.ap_url = Some(format!(
|
||||
"{}comment/{}",
|
||||
"{}/comment/{}",
|
||||
inserted.get_post(conn)?.ap_url,
|
||||
inserted.id
|
||||
));
|
||||
@@ -105,54 +110,68 @@ impl Comment {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn to_activity(&self, c: &PlumeRocket) -> Result<Note> {
|
||||
let author = User::get(&c.conn, self.author_id)?;
|
||||
pub fn to_activity(&self, conn: &Connection) -> Result<Note> {
|
||||
let author = User::get(conn, self.author_id)?;
|
||||
let (html, mentions, _hashtags) = utils::md_to_html(
|
||||
self.content.get().as_ref(),
|
||||
Some(&Instance::get_local()?.public_domain),
|
||||
true,
|
||||
Some(Media::get_media_processor(&c.conn, vec![&author])),
|
||||
Some(Media::get_media_processor(conn, vec![&author])),
|
||||
);
|
||||
|
||||
let mut note = Note::default();
|
||||
let to = vec![Id::new(PUBLIC_VISIBILITY.to_string())];
|
||||
let mut note = Note::new();
|
||||
let to = vec![PUBLIC_VISIBILITY.parse::<IriString>()?];
|
||||
|
||||
note.object_props
|
||||
.set_id_string(self.ap_url.clone().unwrap_or_default())?;
|
||||
note.object_props
|
||||
.set_summary_string(self.spoiler_text.clone())?;
|
||||
note.object_props.set_content_string(html)?;
|
||||
note.object_props
|
||||
.set_in_reply_to_link(Id::new(self.in_response_to_id.map_or_else(
|
||||
|| Ok(Post::get(&c.conn, self.post_id)?.ap_url),
|
||||
|id| Ok(Comment::get(&c.conn, id)?.ap_url.unwrap_or_default()) as Result<String>,
|
||||
)?))?;
|
||||
note.object_props
|
||||
.set_published_string(chrono::Utc::now().to_rfc3339())?;
|
||||
note.object_props.set_attributed_to_link(author.into_id())?;
|
||||
note.object_props.set_to_link_vec(to)?;
|
||||
note.object_props.set_tag_link_vec(
|
||||
mentions
|
||||
.into_iter()
|
||||
.filter_map(|m| Mention::build_activity(c, &m).ok())
|
||||
.collect::<Vec<link::Mention>>(),
|
||||
)?;
|
||||
note.set_id(
|
||||
self.ap_url
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.parse::<IriString>()?,
|
||||
);
|
||||
note.set_summary(self.spoiler_text.clone());
|
||||
note.set_content(html);
|
||||
note.set_in_reply_to(self.in_response_to_id.map_or_else(
|
||||
|| Post::get(conn, self.post_id).map(|post| post.ap_url),
|
||||
|id| Comment::get(conn, id).map(|comment| comment.ap_url.unwrap_or_default()),
|
||||
)?);
|
||||
note.set_published(
|
||||
OffsetDateTime::from_unix_timestamp_nanos(self.creation_date.timestamp_nanos().into())
|
||||
.expect("OffsetDateTime"),
|
||||
);
|
||||
note.set_attributed_to(author.into_id().parse::<IriString>()?);
|
||||
note.set_many_tos(to);
|
||||
note.set_many_tags(mentions.into_iter().filter_map(|m| {
|
||||
Mention::build_activity(conn, &m)
|
||||
.map(|mention| mention.into_any_base().expect("Can convert"))
|
||||
.ok()
|
||||
}));
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
pub fn create_activity(&self, c: &PlumeRocket) -> Result<Create> {
|
||||
let author = User::get(&c.conn, self.author_id)?;
|
||||
pub fn create_activity(&self, conn: &Connection) -> Result<Create> {
|
||||
let author = User::get(conn, self.author_id)?;
|
||||
|
||||
let note = self.to_activity(c)?;
|
||||
let mut act = Create::default();
|
||||
act.create_props.set_actor_link(author.into_id())?;
|
||||
act.create_props.set_object_object(note.clone())?;
|
||||
act.object_props
|
||||
.set_id_string(format!("{}/activity", self.ap_url.clone()?,))?;
|
||||
act.object_props
|
||||
.set_to_link_vec(note.object_props.to_link_vec::<Id>()?)?;
|
||||
act.object_props
|
||||
.set_cc_link_vec(vec![Id::new(self.get_author(&c.conn)?.followers_endpoint)])?;
|
||||
let note = self.to_activity(conn)?;
|
||||
let note_clone = note.clone();
|
||||
|
||||
let mut act = Create::new(
|
||||
author.into_id().parse::<IriString>()?,
|
||||
Base::retract(note)?.into_generic()?,
|
||||
);
|
||||
act.set_id(
|
||||
format!(
|
||||
"{}/activity",
|
||||
self.ap_url.clone().ok_or(Error::MissingApProperty)?,
|
||||
)
|
||||
.parse::<IriString>()?,
|
||||
);
|
||||
act.set_many_tos(
|
||||
note_clone
|
||||
.to()
|
||||
.iter()
|
||||
.flat_map(|tos| tos.iter().map(|to| to.to_owned())),
|
||||
);
|
||||
act.set_many_ccs(vec![self.get_author(conn)?.followers_endpoint]);
|
||||
Ok(act)
|
||||
}
|
||||
|
||||
@@ -177,132 +196,140 @@ impl Comment {
|
||||
}
|
||||
|
||||
pub fn build_delete(&self, conn: &Connection) -> Result<Delete> {
|
||||
let mut act = Delete::default();
|
||||
act.delete_props
|
||||
.set_actor_link(self.get_author(conn)?.into_id())?;
|
||||
let mut tombstone = Tombstone::new();
|
||||
tombstone.set_id(
|
||||
self.ap_url
|
||||
.as_ref()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.parse::<IriString>()?,
|
||||
);
|
||||
|
||||
let mut tombstone = Tombstone::default();
|
||||
tombstone.object_props.set_id_string(self.ap_url.clone()?)?;
|
||||
act.delete_props.set_object_object(tombstone)?;
|
||||
let mut act = Delete::new(
|
||||
self.get_author(conn)?.into_id().parse::<IriString>()?,
|
||||
Base::retract(tombstone)?.into_generic()?,
|
||||
);
|
||||
|
||||
act.object_props
|
||||
.set_id_string(format!("{}#delete", self.ap_url.clone().unwrap()))?;
|
||||
act.object_props
|
||||
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY)])?;
|
||||
act.set_id(format!("{}#delete", self.ap_url.clone().unwrap()).parse::<IriString>()?);
|
||||
act.set_many_tos(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
|
||||
|
||||
Ok(act)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromId<PlumeRocket> for Comment {
|
||||
impl FromId<Connection> for Comment {
|
||||
type Error = Error;
|
||||
type Object = Note;
|
||||
|
||||
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
|
||||
Self::find_by_ap_url(&c.conn, id)
|
||||
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
|
||||
Self::find_by_ap_url(conn, id)
|
||||
}
|
||||
|
||||
fn from_activity(c: &PlumeRocket, note: Note) -> Result<Self> {
|
||||
let conn = &*c.conn;
|
||||
fn from_activity(conn: &Connection, note: Note) -> Result<Self> {
|
||||
let comm = {
|
||||
let previous_url = note.object_props.in_reply_to.as_ref()?.as_str()?;
|
||||
let previous_comment = Comment::find_by_ap_url(conn, previous_url);
|
||||
let previous_url = note
|
||||
.in_reply_to()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.iter()
|
||||
.next()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.id()
|
||||
.ok_or(Error::MissingApProperty)?;
|
||||
let previous_comment = Comment::find_by_ap_url(conn, previous_url.as_str());
|
||||
|
||||
let is_public = |v: &Option<serde_json::Value>| match v
|
||||
.as_ref()
|
||||
.unwrap_or(&serde_json::Value::Null)
|
||||
{
|
||||
serde_json::Value::Array(v) => v
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.any(|s| s == PUBLIC_VISIBILITY),
|
||||
serde_json::Value::String(s) => s == PUBLIC_VISIBILITY,
|
||||
_ => false,
|
||||
let is_public = |v: &Option<&OneOrMany<AnyBase>>| match v {
|
||||
Some(one_or_many) => one_or_many.iter().any(|any_base| {
|
||||
let id = any_base.id();
|
||||
id.is_some() && id.unwrap() == PUBLIC_VISIBILITY
|
||||
}),
|
||||
None => false,
|
||||
};
|
||||
|
||||
let public_visibility = is_public(¬e.object_props.to)
|
||||
|| is_public(¬e.object_props.bto)
|
||||
|| is_public(¬e.object_props.cc)
|
||||
|| is_public(¬e.object_props.bcc);
|
||||
let public_visibility = is_public(¬e.to())
|
||||
|| is_public(¬e.bto())
|
||||
|| is_public(¬e.cc())
|
||||
|| is_public(¬e.bcc());
|
||||
|
||||
let summary = note.summary().and_then(|summary| summary.to_as_string());
|
||||
let sensitive = summary.is_some();
|
||||
let comm = Comment::insert(
|
||||
conn,
|
||||
NewComment {
|
||||
content: SafeString::new(¬e.object_props.content_string()?),
|
||||
spoiler_text: note.object_props.summary_string().unwrap_or_default(),
|
||||
ap_url: note.object_props.id_string().ok(),
|
||||
content: SafeString::new(
|
||||
¬e
|
||||
.content()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.to_as_string()
|
||||
.ok_or(Error::InvalidValue)?,
|
||||
),
|
||||
spoiler_text: summary.unwrap_or_default(),
|
||||
ap_url: Some(
|
||||
note.id_unchecked()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.to_string(),
|
||||
),
|
||||
in_response_to_id: previous_comment.iter().map(|c| c.id).next(),
|
||||
post_id: previous_comment.map(|c| c.post_id).or_else(|_| {
|
||||
Ok(Post::find_by_ap_url(conn, previous_url)?.id) as Result<i32>
|
||||
Ok(Post::find_by_ap_url(conn, previous_url.as_str())?.id) as Result<i32>
|
||||
})?,
|
||||
author_id: User::from_id(
|
||||
c,
|
||||
¬e.object_props.attributed_to_link::<Id>()?,
|
||||
conn,
|
||||
¬e
|
||||
.attributed_to()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.to_as_uri()
|
||||
.ok_or(Error::MissingApProperty)?,
|
||||
None,
|
||||
CONFIG.proxy(),
|
||||
)
|
||||
.map_err(|(_, e)| e)?
|
||||
.id,
|
||||
sensitive: note.object_props.summary_string().is_ok(),
|
||||
sensitive,
|
||||
public_visibility,
|
||||
},
|
||||
)?;
|
||||
|
||||
// save mentions
|
||||
if let Some(serde_json::Value::Array(tags)) = note.object_props.tag.clone() {
|
||||
for tag in tags {
|
||||
serde_json::from_value::<link::Mention>(tag)
|
||||
.map_err(Error::from)
|
||||
.and_then(|m| {
|
||||
let author = &Post::get(conn, comm.post_id)?.get_authors(conn)?[0];
|
||||
let not_author = m.link_props.href_string()? != author.ap_url.clone();
|
||||
Ok(Mention::from_activity(
|
||||
conn, &m, comm.id, false, not_author,
|
||||
)?)
|
||||
})
|
||||
.ok();
|
||||
if let Some(tags) = note.tag() {
|
||||
let author_url = &Post::get(conn, comm.post_id)?.get_authors(conn)?[0].ap_url;
|
||||
for tag in tags.iter() {
|
||||
let m = tag.clone().extend::<link::Mention, MentionType>()?; // FIXME: Don't clone
|
||||
if m.is_none() {
|
||||
continue;
|
||||
}
|
||||
let m = m.unwrap();
|
||||
let not_author = m.href().ok_or(Error::MissingApProperty)? != author_url;
|
||||
let _ = Mention::from_activity(conn, &m, comm.id, false, not_author);
|
||||
}
|
||||
}
|
||||
comm
|
||||
};
|
||||
|
||||
if !comm.public_visibility {
|
||||
let receivers_ap_url = |v: Option<serde_json::Value>| {
|
||||
let filter = |e: serde_json::Value| {
|
||||
if let serde_json::Value::String(s) = e {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
let mut receiver_ids = HashSet::new();
|
||||
let mut receivers_id = |v: Option<&'_ OneOrMany<AnyBase>>| {
|
||||
if let Some(one_or_many) = v {
|
||||
for any_base in one_or_many.iter() {
|
||||
if let Some(id) = any_base.id() {
|
||||
receiver_ids.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
match v.unwrap_or(serde_json::Value::Null) {
|
||||
serde_json::Value::Array(v) => v,
|
||||
v => vec![v],
|
||||
}
|
||||
.into_iter()
|
||||
.filter_map(filter)
|
||||
};
|
||||
|
||||
let mut note = note;
|
||||
receivers_id(note.to());
|
||||
receivers_id(note.cc());
|
||||
receivers_id(note.bto());
|
||||
receivers_id(note.bcc());
|
||||
|
||||
let to = receivers_ap_url(note.object_props.to.take());
|
||||
let cc = receivers_ap_url(note.object_props.cc.take());
|
||||
let bto = receivers_ap_url(note.object_props.bto.take());
|
||||
let bcc = receivers_ap_url(note.object_props.bcc.take());
|
||||
|
||||
let receivers_ap_url = to
|
||||
.chain(cc)
|
||||
.chain(bto)
|
||||
.chain(bcc)
|
||||
.collect::<HashSet<_>>() // remove duplicates (don't do a query more than once)
|
||||
let receivers_ap_url = receiver_ids
|
||||
.into_iter()
|
||||
.map(|v| {
|
||||
if let Ok(user) = User::from_id(c, &v, None) {
|
||||
.flat_map(|v| {
|
||||
if let Ok(user) = User::from_id(conn, v.as_ref(), None, CONFIG.proxy()) {
|
||||
vec![user]
|
||||
} else {
|
||||
vec![] // TODO try to fetch collection
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.filter(|u| u.get_instance(conn).map(|i| i.local).unwrap_or(false))
|
||||
.collect::<HashSet<User>>(); //remove duplicates (prevent db error)
|
||||
|
||||
@@ -320,43 +347,47 @@ impl FromId<PlumeRocket> for Comment {
|
||||
comm.notify(conn)?;
|
||||
Ok(comm)
|
||||
}
|
||||
|
||||
fn get_sender() -> &'static dyn Signer {
|
||||
Instance::get_local_instance_user().expect("Failed to local instance user")
|
||||
}
|
||||
}
|
||||
|
||||
impl AsObject<User, Create, &PlumeRocket> for Comment {
|
||||
impl AsObject<User, Create, &Connection> for Comment {
|
||||
type Error = Error;
|
||||
type Output = Self;
|
||||
|
||||
fn activity(self, _c: &PlumeRocket, _actor: User, _id: &str) -> Result<Self> {
|
||||
fn activity(self, _conn: &Connection, _actor: User, _id: &str) -> Result<Self> {
|
||||
// The actual creation takes place in the FromId impl
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsObject<User, Delete, &PlumeRocket> for Comment {
|
||||
impl AsObject<User, Delete, &Connection> for Comment {
|
||||
type Error = Error;
|
||||
type Output = ();
|
||||
|
||||
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
|
||||
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
|
||||
if self.author_id != actor.id {
|
||||
return Err(Error::Unauthorized);
|
||||
}
|
||||
|
||||
for m in Mention::list_for_comment(&c.conn, self.id)? {
|
||||
for n in Notification::find_for_mention(&c.conn, &m)? {
|
||||
n.delete(&c.conn)?;
|
||||
for m in Mention::list_for_comment(conn, self.id)? {
|
||||
for n in Notification::find_for_mention(conn, &m)? {
|
||||
n.delete(conn)?;
|
||||
}
|
||||
m.delete(&c.conn)?;
|
||||
m.delete(conn)?;
|
||||
}
|
||||
|
||||
for n in Notification::find_for_comment(&c.conn, &self)? {
|
||||
n.delete(&c.conn)?;
|
||||
for n in Notification::find_for_comment(conn, &self)? {
|
||||
n.delete(conn)?;
|
||||
}
|
||||
|
||||
diesel::update(comments::table)
|
||||
.filter(comments::in_response_to_id.eq(self.id))
|
||||
.set(comments::in_response_to_id.eq(self.in_response_to_id))
|
||||
.execute(&*c.conn)?;
|
||||
diesel::delete(&self).execute(&*c.conn)?;
|
||||
.execute(conn)?;
|
||||
diesel::delete(&self).execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -390,42 +421,113 @@ impl CommentTree {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::blogs::Blog;
|
||||
use crate::db_conn::DbConn;
|
||||
use crate::inbox::{inbox, tests::fill_database, InboxResult};
|
||||
use crate::safe_string::SafeString;
|
||||
use crate::tests::rockets;
|
||||
use crate::tests::{db, format_datetime};
|
||||
use assert_json_diff::assert_json_eq;
|
||||
use diesel::Connection;
|
||||
use serde_json::{json, to_value};
|
||||
|
||||
fn prepare_activity(conn: &DbConn) -> (Comment, Vec<Post>, Vec<User>, Vec<Blog>) {
|
||||
let (posts, users, blogs) = fill_database(conn);
|
||||
|
||||
let comment = Comment::insert(
|
||||
conn,
|
||||
NewComment {
|
||||
content: SafeString::new("My comment, mentioning to @user"),
|
||||
in_response_to_id: None,
|
||||
post_id: posts[0].id,
|
||||
author_id: users[0].id,
|
||||
ap_url: None,
|
||||
sensitive: true,
|
||||
spoiler_text: "My CW".into(),
|
||||
public_visibility: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
(comment, posts, users, blogs)
|
||||
}
|
||||
|
||||
// creates a post, get it's Create activity, delete the post,
|
||||
// "send" the Create to the inbox, and check it works
|
||||
#[test]
|
||||
fn self_federation() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (posts, users, _) = fill_database(&r);
|
||||
let (original_comm, posts, users, _blogs) = prepare_activity(conn);
|
||||
let act = original_comm.create_activity(conn).unwrap();
|
||||
|
||||
let original_comm = Comment::insert(
|
||||
assert_json_eq!(to_value(&act).unwrap(), json!({
|
||||
"actor": "https://plu.me/@/admin/",
|
||||
"cc": ["https://plu.me/@/admin/followers"],
|
||||
"id": format!("https://plu.me/~/BlogName/testing/comment/{}/activity", original_comm.id),
|
||||
"object": {
|
||||
"attributedTo": "https://plu.me/@/admin/",
|
||||
"content": r###"<p dir="auto">My comment, mentioning to <a href="https://plu.me/@/user/" title="user">@user</a></p>
|
||||
"###,
|
||||
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", original_comm.id),
|
||||
"inReplyTo": "https://plu.me/~/BlogName/testing",
|
||||
"published": format_datetime(&original_comm.creation_date),
|
||||
"summary": "My CW",
|
||||
"tag": [
|
||||
{
|
||||
"href": "https://plu.me/@/user/",
|
||||
"name": "@user",
|
||||
"type": "Mention"
|
||||
}
|
||||
],
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Note"
|
||||
},
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Create",
|
||||
}));
|
||||
|
||||
let reply = Comment::insert(
|
||||
conn,
|
||||
NewComment {
|
||||
content: SafeString::new("My comment"),
|
||||
in_response_to_id: None,
|
||||
content: SafeString::new(""),
|
||||
in_response_to_id: Some(original_comm.id),
|
||||
post_id: posts[0].id,
|
||||
author_id: users[0].id,
|
||||
author_id: users[1].id,
|
||||
ap_url: None,
|
||||
sensitive: true,
|
||||
spoiler_text: "My CW".into(),
|
||||
sensitive: false,
|
||||
spoiler_text: "".into(),
|
||||
public_visibility: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let act = original_comm.create_activity(&r).unwrap();
|
||||
let reply_act = reply.create_activity(conn).unwrap();
|
||||
|
||||
assert_json_eq!(to_value(&reply_act).unwrap(), json!({
|
||||
"actor": "https://plu.me/@/user/",
|
||||
"cc": ["https://plu.me/@/user/followers"],
|
||||
"id": format!("https://plu.me/~/BlogName/testing/comment/{}/activity", reply.id),
|
||||
"object": {
|
||||
"attributedTo": "https://plu.me/@/user/",
|
||||
"content": "",
|
||||
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", reply.id),
|
||||
"inReplyTo": format!("https://plu.me/~/BlogName/testing/comment/{}", original_comm.id),
|
||||
"published": format_datetime(&reply.creation_date),
|
||||
"summary": "",
|
||||
"tag": [],
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Note"
|
||||
},
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Create"
|
||||
}));
|
||||
|
||||
inbox(
|
||||
&r,
|
||||
conn,
|
||||
serde_json::to_value(original_comm.build_delete(conn).unwrap()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match inbox(&r, serde_json::to_value(act).unwrap()).unwrap() {
|
||||
match inbox(conn, to_value(act).unwrap()).unwrap() {
|
||||
InboxResult::Commented(c) => {
|
||||
// TODO: one is HTML, the other markdown: assert_eq!(c.content, original_comm.content);
|
||||
assert_eq!(c.in_response_to_id, original_comm.in_response_to_id);
|
||||
@@ -440,4 +542,60 @@ mod tests {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_activity() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (comment, _posts, _users, _blogs) = prepare_activity(&conn);
|
||||
let act = comment.to_activity(&conn)?;
|
||||
|
||||
let expected = json!({
|
||||
"attributedTo": "https://plu.me/@/admin/",
|
||||
"content": r###"<p dir="auto">My comment, mentioning to <a href="https://plu.me/@/user/" title="user">@user</a></p>
|
||||
"###,
|
||||
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", comment.id),
|
||||
"inReplyTo": "https://plu.me/~/BlogName/testing",
|
||||
"published": format_datetime(&comment.creation_date),
|
||||
"summary": "My CW",
|
||||
"tag": [
|
||||
{
|
||||
"href": "https://plu.me/@/user/",
|
||||
"name": "@user",
|
||||
"type": "Mention"
|
||||
}
|
||||
],
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Note"
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_delete() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (comment, _posts, _users, _blogs) = prepare_activity(&conn);
|
||||
let act = comment.build_delete(&conn)?;
|
||||
|
||||
let expected = json!({
|
||||
"actor": "https://plu.me/@/admin/",
|
||||
"id": format!("https://plu.me/~/BlogName/testing/comment/{}#delete", comment.id),
|
||||
"object": {
|
||||
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", comment.id),
|
||||
"type": "Tombstone"
|
||||
},
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Delete"
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+227
-16
@@ -1,8 +1,15 @@
|
||||
use crate::search::TokenizerKind as SearchTokenizer;
|
||||
use crate::signups::Strategy as SignupStrategy;
|
||||
use crate::smtp::{SMTP_PORT, SUBMISSIONS_PORT, SUBMISSION_PORT};
|
||||
use rocket::config::Limits;
|
||||
use rocket::Config as RocketConfig;
|
||||
use std::collections::HashSet;
|
||||
use std::env::{self, var};
|
||||
|
||||
use s3::{Bucket, Region};
|
||||
use s3::creds::Credentials;
|
||||
|
||||
|
||||
#[cfg(not(test))]
|
||||
const DB_NAME: &str = "plume";
|
||||
#[cfg(test)]
|
||||
@@ -14,30 +21,49 @@ pub struct Config {
|
||||
pub db_name: &'static str,
|
||||
pub db_max_size: Option<u32>,
|
||||
pub db_min_idle: Option<u32>,
|
||||
pub signup: SignupStrategy,
|
||||
pub search_index: String,
|
||||
pub search_tokenizers: SearchTokenizerConfig,
|
||||
pub rocket: Result<RocketConfig, RocketError>,
|
||||
pub rocket: Result<RocketConfig, InvalidRocketConfig>,
|
||||
pub logo: LogoConfig,
|
||||
pub default_theme: String,
|
||||
pub media_directory: String,
|
||||
pub mail: Option<MailConfig>,
|
||||
pub ldap: Option<LdapConfig>,
|
||||
pub proxy: Option<ProxyConfig>,
|
||||
pub s3: Option<S3Config>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn proxy(&self) -> Option<&reqwest::Proxy> {
|
||||
self.proxy.as_ref().map(|p| &p.proxy)
|
||||
}
|
||||
}
|
||||
|
||||
fn string_to_bool(val: &str, name: &str) -> bool {
|
||||
match val {
|
||||
"1" | "true" | "TRUE" => true,
|
||||
"0" | "false" | "FALSE" => false,
|
||||
_ => panic!("Invalid configuration: {} is not boolean", name),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RocketError {
|
||||
InvalidEnv,
|
||||
InvalidAddress,
|
||||
InvalidSecretKey,
|
||||
pub enum InvalidRocketConfig {
|
||||
Env,
|
||||
Address,
|
||||
SecretKey,
|
||||
}
|
||||
|
||||
fn get_rocket_config() -> Result<RocketConfig, RocketError> {
|
||||
let mut c = RocketConfig::active().map_err(|_| RocketError::InvalidEnv)?;
|
||||
fn get_rocket_config() -> Result<RocketConfig, InvalidRocketConfig> {
|
||||
let mut c = RocketConfig::active().map_err(|_| InvalidRocketConfig::Env)?;
|
||||
|
||||
let address = var("ROCKET_ADDRESS").unwrap_or_else(|_| "localhost".to_owned());
|
||||
let port = var("ROCKET_PORT")
|
||||
.ok()
|
||||
.map(|s| s.parse::<u16>().unwrap())
|
||||
.unwrap_or(7878);
|
||||
let secret_key = var("ROCKET_SECRET_KEY").map_err(|_| RocketError::InvalidSecretKey)?;
|
||||
let secret_key = var("ROCKET_SECRET_KEY").map_err(|_| InvalidRocketConfig::SecretKey)?;
|
||||
let form_size = var("FORM_SIZE")
|
||||
.unwrap_or_else(|_| "128".to_owned())
|
||||
.parse::<u64>()
|
||||
@@ -48,10 +74,10 @@ fn get_rocket_config() -> Result<RocketConfig, RocketError> {
|
||||
.unwrap();
|
||||
|
||||
c.set_address(address)
|
||||
.map_err(|_| RocketError::InvalidAddress)?;
|
||||
.map_err(|_| InvalidRocketConfig::Address)?;
|
||||
c.set_port(port);
|
||||
c.set_secret_key(secret_key)
|
||||
.map_err(|_| RocketError::InvalidSecretKey)?;
|
||||
.map_err(|_| InvalidRocketConfig::SecretKey)?;
|
||||
|
||||
c.set_limits(
|
||||
Limits::new()
|
||||
@@ -147,7 +173,7 @@ impl Default for LogoConfig {
|
||||
.ok()
|
||||
.or_else(|| custom_main.clone());
|
||||
let other = if let Some(main) = custom_main.clone() {
|
||||
let ext = |path: &str| match path.rsplitn(2, '.').next() {
|
||||
let ext = |path: &str| match path.rsplit_once('.').map(|x| x.1) {
|
||||
Some("png") => Some("image/png".to_owned()),
|
||||
Some("jpg") | Some("jpeg") => Some("image/jpeg".to_owned()),
|
||||
Some("svg") => Some("image/svg+xml".to_owned()),
|
||||
@@ -156,11 +182,8 @@ impl Default for LogoConfig {
|
||||
};
|
||||
let mut custom_icons = env::vars()
|
||||
.filter_map(|(var, val)| {
|
||||
if var.starts_with("PLUME_LOGO_") {
|
||||
Some((var[11..].to_owned(), val))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
var.strip_prefix("PLUME_LOGO_")
|
||||
.map(|size| (size.to_owned(), val))
|
||||
})
|
||||
.filter_map(|(var, val)| var.parse::<u64>().ok().map(|var| (var, val)))
|
||||
.map(|(dim, src)| Icon {
|
||||
@@ -240,6 +263,189 @@ impl SearchTokenizerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MailConfig {
|
||||
pub server: String,
|
||||
pub port: u16,
|
||||
pub helo_name: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
fn get_mail_config() -> Option<MailConfig> {
|
||||
Some(MailConfig {
|
||||
server: env::var("MAIL_SERVER").ok()?,
|
||||
port: env::var("MAIL_PORT").map_or(SUBMISSIONS_PORT, |port| match port.as_str() {
|
||||
"smtp" => SMTP_PORT,
|
||||
"submissions" => SUBMISSIONS_PORT,
|
||||
"submission" => SUBMISSION_PORT,
|
||||
number => number
|
||||
.parse()
|
||||
.expect(r#"MAIL_PORT must be "smtp", "submissions", "submission" or an integer."#),
|
||||
}),
|
||||
helo_name: env::var("MAIL_HELO_NAME").unwrap_or_else(|_| "localhost".to_owned()),
|
||||
username: env::var("MAIL_USER").ok()?,
|
||||
password: env::var("MAIL_PASSWORD").ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct LdapConfig {
|
||||
pub addr: String,
|
||||
pub base_dn: String,
|
||||
pub tls: bool,
|
||||
pub user_name_attr: String,
|
||||
pub mail_attr: String,
|
||||
}
|
||||
|
||||
fn get_ldap_config() -> Option<LdapConfig> {
|
||||
let addr = var("LDAP_ADDR").ok();
|
||||
let base_dn = var("LDAP_BASE_DN").ok();
|
||||
match (addr, base_dn) {
|
||||
(Some(addr), Some(base_dn)) => {
|
||||
let tls = var("LDAP_TLS").unwrap_or_else(|_| "false".to_owned());
|
||||
let tls = string_to_bool(&tls, "LDAP_TLS");
|
||||
let user_name_attr = var("LDAP_USER_NAME_ATTR").unwrap_or_else(|_| "cn".to_owned());
|
||||
let mail_attr = var("LDAP_USER_MAIL_ATTR").unwrap_or_else(|_| "mail".to_owned());
|
||||
Some(LdapConfig {
|
||||
addr,
|
||||
base_dn,
|
||||
tls,
|
||||
user_name_attr,
|
||||
mail_attr,
|
||||
})
|
||||
}
|
||||
(None, None) => None,
|
||||
(_, _) => {
|
||||
panic!("Invalid LDAP configuration : both LDAP_ADDR and LDAP_BASE_DN must be set")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProxyConfig {
|
||||
pub url: reqwest::Url,
|
||||
pub only_domains: Option<HashSet<String>>,
|
||||
pub proxy: reqwest::Proxy,
|
||||
}
|
||||
|
||||
fn get_proxy_config() -> Option<ProxyConfig> {
|
||||
let url: reqwest::Url = var("PROXY_URL").ok()?.parse().expect("Invalid PROXY_URL");
|
||||
let proxy_url = url.clone();
|
||||
let only_domains: Option<HashSet<String>> = var("PROXY_DOMAINS")
|
||||
.ok()
|
||||
.map(|ods| ods.split(',').map(str::to_owned).collect());
|
||||
let proxy = if let Some(ref only_domains) = only_domains {
|
||||
let only_domains = only_domains.clone();
|
||||
reqwest::Proxy::custom(move |url| {
|
||||
if let Some(domain) = url.domain() {
|
||||
if only_domains.contains(domain)
|
||||
|| only_domains
|
||||
.iter()
|
||||
.any(|target| domain.ends_with(&format!(".{}", target)))
|
||||
{
|
||||
Some(proxy_url.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
reqwest::Proxy::all(proxy_url).expect("Invalid PROXY_URL")
|
||||
};
|
||||
Some(ProxyConfig {
|
||||
url,
|
||||
only_domains,
|
||||
proxy,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct S3Config {
|
||||
pub bucket: String,
|
||||
pub access_key_id: String,
|
||||
pub access_key_secret: String,
|
||||
|
||||
// region? If not set, default to us-east-1
|
||||
pub region: String,
|
||||
// hostname for s3. If not set, default to $region.amazonaws.com
|
||||
pub hostname: String,
|
||||
// may be useful when using self hosted s3. Won't work with recent AWS buckets
|
||||
pub path_style: bool,
|
||||
pub protocol: String,
|
||||
|
||||
// options below this comment are not used yet
|
||||
// upload directly from user to S3, without going through Plume. Uses PostObject endpoint
|
||||
pub direct_upload: bool,
|
||||
// download directly from s3 to user, wihout going through Plume. Require public read on bucket
|
||||
pub direct_download: bool,
|
||||
// use this hostname for downloads, can be used with caching proxy in front of s3
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
impl S3Config {
|
||||
pub fn get_bucket(&self) -> Bucket {
|
||||
let region = Region::Custom {
|
||||
region: self.region.clone(),
|
||||
endpoint: format!("{}://{}", self.protocol, self.hostname),
|
||||
};
|
||||
let credentials = Credentials {
|
||||
access_key: Some(self.access_key_id.clone()),
|
||||
secret_key: Some(self.access_key_secret.clone()),
|
||||
security_token: None,
|
||||
session_token: None,
|
||||
};
|
||||
|
||||
if self.path_style {
|
||||
Bucket::new_with_path_style(&self.bucket, region, credentials)
|
||||
} else {
|
||||
Bucket::new(&self.bucket, region, credentials)
|
||||
}.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_s3_config() -> Option<S3Config> {
|
||||
let bucket = var("S3_BUCKET").ok();
|
||||
let access_key_id = var("AWS_ACCESS_KEY_ID").ok();
|
||||
let access_key_secret = var("AWS_SECRET_ACCESS_KEY").ok();
|
||||
if bucket.is_none() && access_key_id.is_none() && access_key_secret.is_none() {
|
||||
return None;
|
||||
}
|
||||
if bucket.is_none() || access_key_id.is_none() || access_key_secret.is_none() {
|
||||
panic!("Invalid S3 configuration: some required values are set, but not others");
|
||||
}
|
||||
let bucket = bucket.unwrap();
|
||||
let access_key_id = access_key_id.unwrap();
|
||||
let access_key_secret = access_key_secret.unwrap();
|
||||
|
||||
let region = var("S3_REGION").unwrap_or_else(|_| "us-east-1".to_owned());
|
||||
let hostname = var("S3_HOSTNAME").unwrap_or_else(|_| format!("{}.amazonaws.com", region));
|
||||
|
||||
let protocol = var("S3_PROTOCOL").unwrap_or_else(|_| "https".to_owned());
|
||||
if protocol != "http" && protocol != "https" {
|
||||
panic!("Invalid S3 configuration: invalid protocol {}", protocol);
|
||||
}
|
||||
|
||||
let path_style = var("S3_PATH_STYLE").unwrap_or_else(|_| "false".to_owned());
|
||||
let path_style = string_to_bool(&path_style, "S3_PATH_STYLE");
|
||||
let direct_upload = var("S3_DIRECT_UPLOAD").unwrap_or_else(|_| "false".to_owned());
|
||||
let direct_upload = string_to_bool(&direct_upload, "S3_DIRECT_UPLOAD");
|
||||
let direct_download = var("S3_DIRECT_DOWNLOAD").unwrap_or_else(|_| "false".to_owned());
|
||||
let direct_download = string_to_bool(&direct_download, "S3_DIRECT_DOWNLOAD");
|
||||
|
||||
let alias = var("S3_ALIAS_HOST").ok();
|
||||
Some(S3Config {
|
||||
bucket,
|
||||
access_key_id,
|
||||
access_key_secret,
|
||||
region,
|
||||
hostname,
|
||||
protocol,
|
||||
path_style,
|
||||
direct_upload,
|
||||
direct_download,
|
||||
alias,
|
||||
})
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CONFIG: Config = Config {
|
||||
base_url: var("BASE_URL").unwrap_or_else(|_| format!(
|
||||
@@ -255,6 +461,7 @@ lazy_static! {
|
||||
s.parse::<u32>()
|
||||
.expect("Couldn't parse DB_MIN_IDLE into u32")
|
||||
)),
|
||||
signup: var("SIGNUP").map_or(SignupStrategy::default(), |s| s.parse().unwrap()),
|
||||
#[cfg(feature = "postgres")]
|
||||
database_url: var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| format!("postgres://plume:plume@localhost/{}", DB_NAME)),
|
||||
@@ -267,5 +474,9 @@ lazy_static! {
|
||||
default_theme: var("DEFAULT_THEME").unwrap_or_else(|_| "default-light".to_owned()),
|
||||
media_directory: var("MEDIA_UPLOAD_DIRECTORY")
|
||||
.unwrap_or_else(|_| "static/media".to_owned()),
|
||||
mail: get_mail_config(),
|
||||
ldap: get_ldap_config(),
|
||||
proxy: get_proxy_config(),
|
||||
s3: get_s3_config(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +69,8 @@ pub(crate) mod tests {
|
||||
impl CustomizeConnection<Connection, ConnError> for TestConnectionCustomizer {
|
||||
fn on_acquire(&self, conn: &mut Connection) -> Result<(), ConnError> {
|
||||
PragmaForeignKey.on_acquire(conn)?;
|
||||
Ok(conn.begin_test_transaction().unwrap())
|
||||
conn.begin_test_transaction().unwrap();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use crate::{
|
||||
blocklisted_emails::BlocklistedEmail,
|
||||
db_conn::DbConn,
|
||||
schema::email_signups,
|
||||
users::{NewUser, Role, User},
|
||||
Error, Result,
|
||||
};
|
||||
use chrono::{offset::Utc, Duration, NaiveDateTime};
|
||||
use diesel::{
|
||||
Connection as _, ExpressionMethods, Identifiable, Insertable, QueryDsl, Queryable, RunQueryDsl,
|
||||
};
|
||||
use plume_common::utils::random_hex;
|
||||
use std::ops::Deref;
|
||||
|
||||
const TOKEN_VALIDITY_HOURS: i64 = 2;
|
||||
|
||||
#[repr(transparent)]
|
||||
pub struct Token(String);
|
||||
|
||||
impl From<String> for Token {
|
||||
fn from(string: String) -> Self {
|
||||
Token(string)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Token> for String {
|
||||
fn from(token: Token) -> Self {
|
||||
token.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Token {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Token {
|
||||
fn generate() -> Self {
|
||||
Self(random_hex())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable)]
|
||||
pub struct EmailSignup {
|
||||
pub id: i32,
|
||||
pub email: String,
|
||||
pub token: String,
|
||||
pub expiration_date: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[table_name = "email_signups"]
|
||||
pub struct NewEmailSignup<'a> {
|
||||
pub email: &'a str,
|
||||
pub token: &'a str,
|
||||
pub expiration_date: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl EmailSignup {
|
||||
pub fn start(conn: &DbConn, email: &str) -> Result<Token> {
|
||||
Self::ensure_email_not_blocked(conn, email)?;
|
||||
|
||||
conn.transaction(|| {
|
||||
Self::ensure_user_not_exist_by_email(conn, email)?;
|
||||
let _rows = Self::delete_existings_by_email(conn, email)?;
|
||||
let token = Token::generate();
|
||||
let expiration_date = Utc::now()
|
||||
.naive_utc()
|
||||
.checked_add_signed(Duration::hours(TOKEN_VALIDITY_HOURS))
|
||||
.expect("could not calculate expiration date");
|
||||
let new_signup = NewEmailSignup {
|
||||
email,
|
||||
token: &token,
|
||||
expiration_date,
|
||||
};
|
||||
let _rows = diesel::insert_into(email_signups::table)
|
||||
.values(new_signup)
|
||||
.execute(&**conn)?;
|
||||
|
||||
Ok(token)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_by_token(conn: &DbConn, token: Token) -> Result<Self> {
|
||||
let signup = email_signups::table
|
||||
.filter(email_signups::token.eq(token.as_str()))
|
||||
.first::<Self>(&**conn)
|
||||
.map_err(Error::from)?;
|
||||
Ok(signup)
|
||||
}
|
||||
|
||||
pub fn confirm(&self, conn: &DbConn) -> Result<()> {
|
||||
Self::ensure_email_not_blocked(conn, &self.email)?;
|
||||
|
||||
conn.transaction(|| {
|
||||
Self::ensure_user_not_exist_by_email(conn, &self.email)?;
|
||||
if self.expired() {
|
||||
Self::delete_existings_by_email(conn, &self.email)?;
|
||||
return Err(Error::Expired);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn complete(&self, conn: &DbConn, username: String, password: String) -> Result<User> {
|
||||
Self::ensure_email_not_blocked(conn, &self.email)?;
|
||||
|
||||
conn.transaction(|| {
|
||||
Self::ensure_user_not_exist_by_email(conn, &self.email)?;
|
||||
let user = NewUser::new_local(
|
||||
conn,
|
||||
username,
|
||||
"".to_string(),
|
||||
Role::Normal,
|
||||
"",
|
||||
self.email.clone(),
|
||||
Some(User::hash_pass(&password)?),
|
||||
)?;
|
||||
self.delete(conn)?;
|
||||
Ok(user)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete(&self, conn: &DbConn) -> Result<()> {
|
||||
let _rows = diesel::delete(self).execute(&**conn).map_err(Error::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_email_not_blocked(conn: &DbConn, email: &str) -> Result<()> {
|
||||
if let Some(x) = BlocklistedEmail::matches_blocklist(conn, email)? {
|
||||
Err(Error::Blocklisted(x.notify_user, x.notification_text))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_user_not_exist_by_email(conn: &DbConn, email: &str) -> Result<()> {
|
||||
if User::email_used(conn, email)? {
|
||||
let _rows = Self::delete_existings_by_email(conn, email)?;
|
||||
return Err(Error::UserAlreadyExists);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_existings_by_email(conn: &DbConn, email: &str) -> Result<usize> {
|
||||
let existing_signups = email_signups::table.filter(email_signups::email.eq(email));
|
||||
diesel::delete(existing_signups)
|
||||
.execute(&**conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
fn expired(&self) -> bool {
|
||||
self.expiration_date < Utc::now().naive_utc()
|
||||
}
|
||||
}
|
||||
+190
-62
@@ -1,8 +1,13 @@
|
||||
use crate::{
|
||||
ap_url, notifications::*, schema::follows, users::User, Connection, Error, PlumeRocket, Result,
|
||||
CONFIG,
|
||||
ap_url, instance::Instance, notifications::*, schema::follows, users::User, Connection, Error,
|
||||
Result, CONFIG,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{Accept, ActorAndObjectRef, Follow as FollowAct, Undo},
|
||||
base::AnyBase,
|
||||
iri_string::types::IriString,
|
||||
prelude::*,
|
||||
};
|
||||
use activitypub::activity::{Accept, Follow as FollowAct, Undo};
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
|
||||
use plume_common::activity_pub::{
|
||||
broadcast,
|
||||
@@ -53,15 +58,13 @@ impl Follow {
|
||||
pub fn to_activity(&self, conn: &Connection) -> Result<FollowAct> {
|
||||
let user = User::get(conn, self.follower_id)?;
|
||||
let target = User::get(conn, self.following_id)?;
|
||||
let target_id = target.ap_url.parse::<IriString>()?;
|
||||
|
||||
let mut act = FollowAct::new(user.ap_url.parse::<IriString>()?, target_id.clone());
|
||||
act.set_id(self.ap_url.parse::<IriString>()?);
|
||||
act.set_many_tos(vec![target_id]);
|
||||
act.set_many_ccs(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
|
||||
|
||||
let mut act = FollowAct::default();
|
||||
act.follow_props.set_actor_link::<Id>(user.into_id())?;
|
||||
act.follow_props
|
||||
.set_object_link::<Id>(target.clone().into_id())?;
|
||||
act.object_props.set_id_string(self.ap_url.clone())?;
|
||||
act.object_props.set_to_link_vec(vec![target.into_id()])?;
|
||||
act.object_props
|
||||
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
|
||||
Ok(act)
|
||||
}
|
||||
|
||||
@@ -94,88 +97,117 @@ impl Follow {
|
||||
NewFollow {
|
||||
follower_id: from_id,
|
||||
following_id: target_id,
|
||||
ap_url: follow.object_props.id_string()?,
|
||||
ap_url: follow
|
||||
.object_field_ref()
|
||||
.as_single_id()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.to_string(),
|
||||
},
|
||||
)?;
|
||||
res.notify(conn)?;
|
||||
|
||||
let mut accept = Accept::default();
|
||||
let accept_id = ap_url(&format!(
|
||||
"{}/follow/{}/accept",
|
||||
CONFIG.base_url.as_str(),
|
||||
&res.id
|
||||
));
|
||||
accept.object_props.set_id_string(accept_id)?;
|
||||
accept
|
||||
.object_props
|
||||
.set_to_link_vec(vec![from.clone().into_id()])?;
|
||||
accept
|
||||
.object_props
|
||||
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
|
||||
accept
|
||||
.accept_props
|
||||
.set_actor_link::<Id>(target.clone().into_id())?;
|
||||
accept.accept_props.set_object_object(follow)?;
|
||||
broadcast(&*target, accept, vec![from.clone()]);
|
||||
let accept = res.build_accept(from, target, follow)?;
|
||||
broadcast(target, accept, vec![from.clone()], CONFIG.proxy().cloned());
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn build_accept<A: Signer + IntoId + Clone, B: Clone + AsActor<T> + IntoId, T>(
|
||||
&self,
|
||||
from: &B,
|
||||
target: &A,
|
||||
follow: FollowAct,
|
||||
) -> Result<Accept> {
|
||||
let mut accept = Accept::new(
|
||||
target.clone().into_id().parse::<IriString>()?,
|
||||
AnyBase::from_extended(follow)?,
|
||||
);
|
||||
let accept_id = ap_url(&format!(
|
||||
"{}/follows/{}/accept",
|
||||
CONFIG.base_url.as_str(),
|
||||
self.id
|
||||
));
|
||||
accept.set_id(accept_id.parse::<IriString>()?);
|
||||
accept.set_many_tos(vec![from.clone().into_id().parse::<IriString>()?]);
|
||||
accept.set_many_ccs(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
|
||||
|
||||
Ok(accept)
|
||||
}
|
||||
|
||||
pub fn build_undo(&self, conn: &Connection) -> Result<Undo> {
|
||||
let mut undo = Undo::default();
|
||||
undo.undo_props
|
||||
.set_actor_link(User::get(conn, self.follower_id)?.into_id())?;
|
||||
undo.object_props
|
||||
.set_id_string(format!("{}/undo", self.ap_url))?;
|
||||
undo.undo_props
|
||||
.set_object_link::<Id>(self.clone().into_id())?;
|
||||
undo.object_props
|
||||
.set_to_link_vec(vec![User::get(conn, self.following_id)?.into_id()])?;
|
||||
undo.object_props
|
||||
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
|
||||
let mut undo = Undo::new(
|
||||
User::get(conn, self.follower_id)?
|
||||
.ap_url
|
||||
.parse::<IriString>()?,
|
||||
self.ap_url.parse::<IriString>()?,
|
||||
);
|
||||
undo.set_id(format!("{}/undo", self.ap_url).parse::<IriString>()?);
|
||||
undo.set_many_tos(vec![User::get(conn, self.following_id)?
|
||||
.ap_url
|
||||
.parse::<IriString>()?]);
|
||||
undo.set_many_ccs(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
|
||||
|
||||
Ok(undo)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsObject<User, FollowAct, &PlumeRocket> for User {
|
||||
impl AsObject<User, FollowAct, &Connection> for User {
|
||||
type Error = Error;
|
||||
type Output = Follow;
|
||||
|
||||
fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result<Follow> {
|
||||
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Follow> {
|
||||
// Mastodon (at least) requires the full Follow object when accepting it,
|
||||
// so we rebuilt it here
|
||||
let mut follow = FollowAct::default();
|
||||
follow.object_props.set_id_string(id.to_string())?;
|
||||
follow
|
||||
.follow_props
|
||||
.set_actor_link::<Id>(actor.clone().into_id())?;
|
||||
Follow::accept_follow(&c.conn, &actor, &self, follow, actor.id, self.id)
|
||||
let follow = FollowAct::new(actor.ap_url.parse::<IriString>()?, id.parse::<IriString>()?);
|
||||
Follow::accept_follow(conn, &actor, &self, follow, actor.id, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromId<PlumeRocket> for Follow {
|
||||
impl FromId<Connection> for Follow {
|
||||
type Error = Error;
|
||||
type Object = FollowAct;
|
||||
|
||||
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
|
||||
Follow::find_by_ap_url(&c.conn, id)
|
||||
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
|
||||
Follow::find_by_ap_url(conn, id)
|
||||
}
|
||||
|
||||
fn from_activity(c: &PlumeRocket, follow: FollowAct) -> Result<Self> {
|
||||
let actor =
|
||||
User::from_id(c, &follow.follow_props.actor_link::<Id>()?, None).map_err(|(_, e)| e)?;
|
||||
fn from_activity(conn: &Connection, follow: FollowAct) -> Result<Self> {
|
||||
let actor = User::from_id(
|
||||
conn,
|
||||
follow
|
||||
.actor_field_ref()
|
||||
.as_single_id()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.as_str(),
|
||||
None,
|
||||
CONFIG.proxy(),
|
||||
)
|
||||
.map_err(|(_, e)| e)?;
|
||||
|
||||
let target = User::from_id(c, &follow.follow_props.object_link::<Id>()?, None)
|
||||
.map_err(|(_, e)| e)?;
|
||||
Follow::accept_follow(&c.conn, &actor, &target, follow, actor.id, target.id)
|
||||
let target = User::from_id(
|
||||
conn,
|
||||
follow
|
||||
.object_field_ref()
|
||||
.as_single_id()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.as_str(),
|
||||
None,
|
||||
CONFIG.proxy(),
|
||||
)
|
||||
.map_err(|(_, e)| e)?;
|
||||
Follow::accept_follow(conn, &actor, &target, follow, actor.id, target.id)
|
||||
}
|
||||
|
||||
fn get_sender() -> &'static dyn Signer {
|
||||
Instance::get_local_instance_user().expect("Failed to local instance user")
|
||||
}
|
||||
}
|
||||
|
||||
impl AsObject<User, Undo, &PlumeRocket> for Follow {
|
||||
impl AsObject<User, Undo, &Connection> for Follow {
|
||||
type Error = Error;
|
||||
type Output = ();
|
||||
|
||||
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
|
||||
let conn = &*c.conn;
|
||||
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
|
||||
let conn = conn;
|
||||
if self.follower_id == actor.id {
|
||||
diesel::delete(&self).execute(conn)?;
|
||||
|
||||
@@ -200,8 +232,31 @@ impl IntoId for Follow {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{tests::db, users::tests as user_tests};
|
||||
use crate::{
|
||||
db_conn::DbConn, tests::db, users::tests as user_tests, users::tests::fill_database,
|
||||
};
|
||||
use assert_json_diff::assert_json_eq;
|
||||
use diesel::Connection;
|
||||
use serde_json::{json, to_value};
|
||||
|
||||
fn prepare_activity(conn: &DbConn) -> (Follow, User, User, Vec<User>) {
|
||||
let users = fill_database(conn);
|
||||
let following = &users[1];
|
||||
let follower = &users[2];
|
||||
let mut follow = Follow::insert(
|
||||
conn,
|
||||
NewFollow {
|
||||
follower_id: follower.id,
|
||||
following_id: following.id,
|
||||
ap_url: "".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// following.ap_url = format!("https://plu.me/follows/{}", follow.id);
|
||||
follow.ap_url = format!("https://plu.me/follows/{}", follow.id);
|
||||
|
||||
(follow, following.to_owned(), follower.to_owned(), users)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_id() {
|
||||
@@ -236,4 +291,77 @@ mod tests {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_activity() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (follow, _following, _follower, _users) = prepare_activity(&conn);
|
||||
let act = follow.to_activity(&conn)?;
|
||||
|
||||
let expected = json!({
|
||||
"actor": "https://plu.me/@/other/",
|
||||
"cc": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"id": format!("https://plu.me/follows/{}", follow.id),
|
||||
"object": "https://plu.me/@/user/",
|
||||
"to": ["https://plu.me/@/user/"],
|
||||
"type": "Follow"
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_accept() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (follow, following, follower, _users) = prepare_activity(&conn);
|
||||
let act = follow.build_accept(&follower, &following, follow.to_activity(&conn)?)?;
|
||||
|
||||
let expected = json!({
|
||||
"actor": "https://plu.me/@/user/",
|
||||
"cc": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"id": format!("https://127.0.0.1:7878/follows/{}/accept", follow.id),
|
||||
"object": {
|
||||
"actor": "https://plu.me/@/other/",
|
||||
"cc": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"id": format!("https://plu.me/follows/{}", follow.id),
|
||||
"object": "https://plu.me/@/user/",
|
||||
"to": ["https://plu.me/@/user/"],
|
||||
"type": "Follow"
|
||||
},
|
||||
"to": ["https://plu.me/@/other/"],
|
||||
"type": "Accept"
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_undo() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (follow, _following, _follower, _users) = prepare_activity(&conn);
|
||||
let act = follow.build_undo(&conn)?;
|
||||
|
||||
let expected = json!({
|
||||
"actor": "https://plu.me/@/other/",
|
||||
"cc": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"id": format!("https://plu.me/follows/{}/undo", follow.id),
|
||||
"object": format!("https://plu.me/follows/{}", follow.id),
|
||||
"to": ["https://plu.me/@/user/"],
|
||||
"type": "Undo"
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+275
-92
@@ -1,13 +1,13 @@
|
||||
use activitypub::activity::*;
|
||||
use serde_json;
|
||||
use activitystreams::activity::{Announce, Create, Delete, Follow, Like, Undo, Update};
|
||||
|
||||
use crate::{
|
||||
comments::Comment,
|
||||
db_conn::DbConn,
|
||||
follows, likes,
|
||||
posts::{Post, PostUpdate},
|
||||
reshares::Reshare,
|
||||
users::User,
|
||||
Error, PlumeRocket,
|
||||
Error, CONFIG,
|
||||
};
|
||||
use plume_common::activity_pub::inbox::Inbox;
|
||||
|
||||
@@ -46,20 +46,20 @@ impl_into_inbox_result! {
|
||||
Reshare => Reshared
|
||||
}
|
||||
|
||||
pub fn inbox(ctx: &PlumeRocket, act: serde_json::Value) -> Result<InboxResult, Error> {
|
||||
Inbox::handle(ctx, act)
|
||||
.with::<User, Announce, Post>()
|
||||
.with::<User, Create, Comment>()
|
||||
.with::<User, Create, Post>()
|
||||
.with::<User, Delete, Comment>()
|
||||
.with::<User, Delete, Post>()
|
||||
.with::<User, Delete, User>()
|
||||
.with::<User, Follow, User>()
|
||||
.with::<User, Like, Post>()
|
||||
.with::<User, Undo, Reshare>()
|
||||
.with::<User, Undo, follows::Follow>()
|
||||
.with::<User, Undo, likes::Like>()
|
||||
.with::<User, Update, PostUpdate>()
|
||||
pub fn inbox(conn: &DbConn, act: serde_json::Value) -> Result<InboxResult, Error> {
|
||||
Inbox::handle(&**conn, act)
|
||||
.with::<User, Announce, Post>(CONFIG.proxy())
|
||||
.with::<User, Create, Comment>(CONFIG.proxy())
|
||||
.with::<User, Create, Post>(CONFIG.proxy())
|
||||
.with::<User, Delete, Comment>(CONFIG.proxy())
|
||||
.with::<User, Delete, Post>(CONFIG.proxy())
|
||||
.with::<User, Delete, User>(CONFIG.proxy())
|
||||
.with::<User, Follow, User>(CONFIG.proxy())
|
||||
.with::<User, Like, Post>(CONFIG.proxy())
|
||||
.with::<User, Undo, Reshare>(CONFIG.proxy())
|
||||
.with::<User, Undo, follows::Follow>(CONFIG.proxy())
|
||||
.with::<User, Undo, likes::Like>(CONFIG.proxy())
|
||||
.with::<User, Update, PostUpdate>(CONFIG.proxy())
|
||||
.done()
|
||||
}
|
||||
|
||||
@@ -67,13 +67,13 @@ pub fn inbox(ctx: &PlumeRocket, act: serde_json::Value) -> Result<InboxResult, E
|
||||
pub(crate) mod tests {
|
||||
use super::InboxResult;
|
||||
use crate::blogs::tests::fill_database as blog_fill_db;
|
||||
use crate::db_conn::DbConn;
|
||||
use crate::safe_string::SafeString;
|
||||
use crate::tests::rockets;
|
||||
use crate::PlumeRocket;
|
||||
use crate::tests::db;
|
||||
use diesel::Connection;
|
||||
|
||||
pub fn fill_database(
|
||||
rockets: &PlumeRocket,
|
||||
conn: &DbConn,
|
||||
) -> (
|
||||
Vec<crate::posts::Post>,
|
||||
Vec<crate::users::User>,
|
||||
@@ -82,9 +82,9 @@ pub(crate) mod tests {
|
||||
use crate::post_authors::*;
|
||||
use crate::posts::*;
|
||||
|
||||
let (users, blogs) = blog_fill_db(&rockets.conn);
|
||||
let (users, blogs) = blog_fill_db(conn);
|
||||
let post = Post::insert(
|
||||
&rockets.conn,
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "testing".to_owned(),
|
||||
@@ -94,16 +94,15 @@ pub(crate) mod tests {
|
||||
license: "WTFPL".to_owned(),
|
||||
creation_date: None,
|
||||
ap_url: format!("https://plu.me/~/{}/testing", blogs[0].actor_id),
|
||||
subtitle: String::new(),
|
||||
source: String::new(),
|
||||
subtitle: "Bye".to_string(),
|
||||
source: "Hello".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&rockets.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
PostAuthor::insert(
|
||||
&rockets.conn,
|
||||
conn,
|
||||
NewPostAuthor {
|
||||
post_id: post.id,
|
||||
author_id: users[0].id,
|
||||
@@ -116,10 +115,9 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn announce_post() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (posts, users, _) = fill_database(&r);
|
||||
let (posts, users, _) = fill_database(&conn);
|
||||
let act = json!({
|
||||
"id": "https://plu.me/announce/1",
|
||||
"actor": users[0].ap_url,
|
||||
@@ -127,7 +125,7 @@ pub(crate) mod tests {
|
||||
"type": "Announce",
|
||||
});
|
||||
|
||||
match super::inbox(&r, act).unwrap() {
|
||||
match super::inbox(&conn, act).unwrap() {
|
||||
super::InboxResult::Reshared(r) => {
|
||||
assert_eq!(r.post_id, posts[0].id);
|
||||
assert_eq!(r.user_id, users[0].id);
|
||||
@@ -141,10 +139,9 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn create_comment() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (posts, users, _) = fill_database(&r);
|
||||
let (posts, users, _) = fill_database(&conn);
|
||||
let act = json!({
|
||||
"id": "https://plu.me/comment/1/activity",
|
||||
"actor": users[0].ap_url,
|
||||
@@ -159,7 +156,7 @@ pub(crate) mod tests {
|
||||
"type": "Create",
|
||||
});
|
||||
|
||||
match super::inbox(&r, act).unwrap() {
|
||||
match super::inbox(&conn, act).unwrap() {
|
||||
super::InboxResult::Commented(c) => {
|
||||
assert_eq!(c.author_id, users[0].id);
|
||||
assert_eq!(c.post_id, posts[0].id);
|
||||
@@ -174,17 +171,104 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_post() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
fn spoof_comment() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (_, users, blogs) = fill_database(&r);
|
||||
let (posts, users, _) = fill_database(&conn);
|
||||
let act = json!({
|
||||
"id": "https://plu.me/comment/1/activity",
|
||||
"actor": users[0].ap_url,
|
||||
"object": {
|
||||
"type": "Note",
|
||||
"id": "https://plu.me/comment/1",
|
||||
"attributedTo": users[1].ap_url,
|
||||
"inReplyTo": posts[0].ap_url,
|
||||
"content": "Hello.",
|
||||
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
|
||||
},
|
||||
"type": "Create",
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
super::inbox(&conn, act),
|
||||
Err(super::Error::Inbox(
|
||||
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
|
||||
))
|
||||
));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spoof_comment_by_object_with_id() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (posts, users, _) = fill_database(&conn);
|
||||
let act = json!({
|
||||
"id": "https://plu.me/comment/1/activity",
|
||||
"actor": users[0].ap_url,
|
||||
"object": {
|
||||
"type": "Note",
|
||||
"id": "https://plu.me/comment/1",
|
||||
"attributedTo": {
|
||||
"id": users[1].ap_url
|
||||
},
|
||||
"inReplyTo": posts[0].ap_url,
|
||||
"content": "Hello.",
|
||||
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
|
||||
},
|
||||
"type": "Create",
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
super::inbox(&conn, act),
|
||||
Err(super::Error::Inbox(
|
||||
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
|
||||
))
|
||||
));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn spoof_comment_by_object_without_id() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (posts, users, _) = fill_database(&conn);
|
||||
let act = json!({
|
||||
"id": "https://plu.me/comment/1/activity",
|
||||
"actor": users[0].ap_url,
|
||||
"object": {
|
||||
"type": "Note",
|
||||
"id": "https://plu.me/comment/1",
|
||||
"attributedTo": {},
|
||||
"inReplyTo": posts[0].ap_url,
|
||||
"content": "Hello.",
|
||||
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
|
||||
},
|
||||
"type": "Create",
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
super::inbox(&conn, act),
|
||||
Err(super::Error::Inbox(
|
||||
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
|
||||
))
|
||||
));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_post() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (_, users, blogs) = fill_database(&conn);
|
||||
let act = json!({
|
||||
"id": "https://plu.me/comment/1/activity",
|
||||
"actor": users[0].ap_url,
|
||||
"object": {
|
||||
"type": "Article",
|
||||
"id": "https://plu.me/~/Blog/my-article",
|
||||
"id": "https://plu.me/~/BlogName/testing",
|
||||
"attributedTo": [users[0].ap_url, blogs[0].ap_url],
|
||||
"content": "Hello.",
|
||||
"name": "My Article",
|
||||
@@ -199,14 +283,14 @@ pub(crate) mod tests {
|
||||
"type": "Create",
|
||||
});
|
||||
|
||||
match super::inbox(&r, act).unwrap() {
|
||||
match super::inbox(&conn, act).unwrap() {
|
||||
super::InboxResult::Post(p) => {
|
||||
assert!(p.is_author(conn, users[0].id).unwrap());
|
||||
assert_eq!(p.source, "Hello.".to_owned());
|
||||
assert!(p.is_author(&conn, users[0].id).unwrap());
|
||||
assert_eq!(p.source, "Hello".to_owned());
|
||||
assert_eq!(p.blog_id, blogs[0].id);
|
||||
assert_eq!(p.content, SafeString::new("Hello."));
|
||||
assert_eq!(p.subtitle, "Bye.".to_owned());
|
||||
assert_eq!(p.title, "My Article".to_owned());
|
||||
assert_eq!(p.content, SafeString::new("Hello"));
|
||||
assert_eq!(p.subtitle, "Bye".to_owned());
|
||||
assert_eq!(p.title, "Testing".to_owned());
|
||||
}
|
||||
_ => panic!("Unexpected result"),
|
||||
};
|
||||
@@ -214,16 +298,123 @@ pub(crate) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spoof_post() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (_, users, blogs) = fill_database(&conn);
|
||||
let act = json!({
|
||||
"id": "https://plu.me/comment/1/activity",
|
||||
"actor": users[0].ap_url,
|
||||
"object": {
|
||||
"type": "Article",
|
||||
"id": "https://plu.me/~/Blog/my-article",
|
||||
"attributedTo": [users[1].ap_url, blogs[0].ap_url],
|
||||
"content": "Hello.",
|
||||
"name": "My Article",
|
||||
"summary": "Bye.",
|
||||
"source": {
|
||||
"content": "Hello.",
|
||||
"mediaType": "text/markdown"
|
||||
},
|
||||
"published": "2014-12-12T12:12:12Z",
|
||||
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
|
||||
},
|
||||
"type": "Create",
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
super::inbox(&conn, act),
|
||||
Err(super::Error::Inbox(
|
||||
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
|
||||
))
|
||||
));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spoof_post_by_object_with_id() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (_, users, blogs) = fill_database(&conn);
|
||||
let act = json!({
|
||||
"id": "https://plu.me/comment/1/activity",
|
||||
"actor": users[0].ap_url,
|
||||
"object": {
|
||||
"type": "Article",
|
||||
"id": "https://plu.me/~/Blog/my-article",
|
||||
"attributedTo": [
|
||||
{"id": users[1].ap_url},
|
||||
blogs[0].ap_url
|
||||
],
|
||||
"content": "Hello.",
|
||||
"name": "My Article",
|
||||
"summary": "Bye.",
|
||||
"source": {
|
||||
"content": "Hello.",
|
||||
"mediaType": "text/markdown"
|
||||
},
|
||||
"published": "2014-12-12T12:12:12Z",
|
||||
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
|
||||
},
|
||||
"type": "Create",
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
super::inbox(&conn, act),
|
||||
Err(super::Error::Inbox(
|
||||
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
|
||||
))
|
||||
));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spoof_post_by_object_without_id() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (_, users, blogs) = fill_database(&conn);
|
||||
let act = json!({
|
||||
"id": "https://plu.me/comment/1/activity",
|
||||
"actor": users[0].ap_url,
|
||||
"object": {
|
||||
"type": "Article",
|
||||
"id": "https://plu.me/~/Blog/my-article",
|
||||
"attributedTo": [{}, blogs[0].ap_url],
|
||||
"content": "Hello.",
|
||||
"name": "My Article",
|
||||
"summary": "Bye.",
|
||||
"source": {
|
||||
"content": "Hello.",
|
||||
"mediaType": "text/markdown"
|
||||
},
|
||||
"published": "2014-12-12T12:12:12Z",
|
||||
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
|
||||
},
|
||||
"type": "Create",
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
super::inbox(&conn, act),
|
||||
Err(super::Error::Inbox(
|
||||
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
|
||||
))
|
||||
));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_comment() {
|
||||
use crate::comments::*;
|
||||
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (posts, users, _) = fill_database(&r);
|
||||
let (posts, users, _) = fill_database(&conn);
|
||||
Comment::insert(
|
||||
conn,
|
||||
&conn,
|
||||
NewComment {
|
||||
content: SafeString::new("My comment"),
|
||||
in_response_to_id: None,
|
||||
@@ -243,7 +434,7 @@ pub(crate) mod tests {
|
||||
"object": "https://plu.me/comment/1",
|
||||
"type": "Delete",
|
||||
});
|
||||
assert!(super::inbox(&r, fail_act).is_err());
|
||||
assert!(super::inbox(&conn, fail_act).is_err());
|
||||
|
||||
let ok_act = json!({
|
||||
"id": "https://plu.me/comment/1/delete",
|
||||
@@ -251,17 +442,16 @@ pub(crate) mod tests {
|
||||
"object": "https://plu.me/comment/1",
|
||||
"type": "Delete",
|
||||
});
|
||||
assert!(super::inbox(&r, ok_act).is_ok());
|
||||
assert!(super::inbox(&conn, ok_act).is_ok());
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_post() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (posts, users, _) = fill_database(&r);
|
||||
let (posts, users, _) = fill_database(&conn);
|
||||
|
||||
let fail_act = json!({
|
||||
"id": "https://plu.me/comment/1/delete",
|
||||
@@ -269,7 +459,7 @@ pub(crate) mod tests {
|
||||
"object": posts[0].ap_url,
|
||||
"type": "Delete",
|
||||
});
|
||||
assert!(super::inbox(&r, fail_act).is_err());
|
||||
assert!(super::inbox(&conn, fail_act).is_err());
|
||||
|
||||
let ok_act = json!({
|
||||
"id": "https://plu.me/comment/1/delete",
|
||||
@@ -277,17 +467,16 @@ pub(crate) mod tests {
|
||||
"object": posts[0].ap_url,
|
||||
"type": "Delete",
|
||||
});
|
||||
assert!(super::inbox(&r, ok_act).is_ok());
|
||||
assert!(super::inbox(&conn, ok_act).is_ok());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_user() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (_, users, _) = fill_database(&r);
|
||||
let (_, users, _) = fill_database(&conn);
|
||||
|
||||
let fail_act = json!({
|
||||
"id": "https://plu.me/@/Admin#delete",
|
||||
@@ -295,7 +484,7 @@ pub(crate) mod tests {
|
||||
"object": users[0].ap_url,
|
||||
"type": "Delete",
|
||||
});
|
||||
assert!(super::inbox(&r, fail_act).is_err());
|
||||
assert!(super::inbox(&conn, fail_act).is_err());
|
||||
|
||||
let ok_act = json!({
|
||||
"id": "https://plu.me/@/Admin#delete",
|
||||
@@ -303,8 +492,8 @@ pub(crate) mod tests {
|
||||
"object": users[0].ap_url,
|
||||
"type": "Delete",
|
||||
});
|
||||
assert!(super::inbox(&r, ok_act).is_ok());
|
||||
assert!(crate::users::User::get(conn, users[0].id).is_err());
|
||||
assert!(super::inbox(&conn, ok_act).is_ok());
|
||||
assert!(crate::users::User::get(&conn, users[0].id).is_err());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
@@ -312,10 +501,9 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn follow() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (_, users, _) = fill_database(&r);
|
||||
let (_, users, _) = fill_database(&conn);
|
||||
|
||||
let act = json!({
|
||||
"id": "https://plu.me/follow/1",
|
||||
@@ -323,7 +511,7 @@ pub(crate) mod tests {
|
||||
"object": users[1].ap_url,
|
||||
"type": "Follow",
|
||||
});
|
||||
match super::inbox(&r, act).unwrap() {
|
||||
match super::inbox(&conn, act).unwrap() {
|
||||
InboxResult::Followed(f) => {
|
||||
assert_eq!(f.follower_id, users[0].id);
|
||||
assert_eq!(f.following_id, users[1].id);
|
||||
@@ -337,10 +525,9 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
fn like() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (posts, users, _) = fill_database(&r);
|
||||
let (posts, users, _) = fill_database(&conn);
|
||||
|
||||
let act = json!({
|
||||
"id": "https://plu.me/like/1",
|
||||
@@ -348,7 +535,7 @@ pub(crate) mod tests {
|
||||
"object": posts[0].ap_url,
|
||||
"type": "Like",
|
||||
});
|
||||
match super::inbox(&r, act).unwrap() {
|
||||
match super::inbox(&conn, act).unwrap() {
|
||||
InboxResult::Liked(l) => {
|
||||
assert_eq!(l.user_id, users[1].id);
|
||||
assert_eq!(l.post_id, posts[0].id);
|
||||
@@ -364,13 +551,12 @@ pub(crate) mod tests {
|
||||
fn undo_reshare() {
|
||||
use crate::reshares::*;
|
||||
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (posts, users, _) = fill_database(&r);
|
||||
let (posts, users, _) = fill_database(&conn);
|
||||
|
||||
let announce = Reshare::insert(
|
||||
conn,
|
||||
&conn,
|
||||
NewReshare {
|
||||
post_id: posts[0].id,
|
||||
user_id: users[1].id,
|
||||
@@ -385,7 +571,7 @@ pub(crate) mod tests {
|
||||
"object": announce.ap_url,
|
||||
"type": "Undo",
|
||||
});
|
||||
assert!(super::inbox(&r, fail_act).is_err());
|
||||
assert!(super::inbox(&conn, fail_act).is_err());
|
||||
|
||||
let ok_act = json!({
|
||||
"id": "https://plu.me/undo/1",
|
||||
@@ -393,7 +579,7 @@ pub(crate) mod tests {
|
||||
"object": announce.ap_url,
|
||||
"type": "Undo",
|
||||
});
|
||||
assert!(super::inbox(&r, ok_act).is_ok());
|
||||
assert!(super::inbox(&conn, ok_act).is_ok());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -402,13 +588,12 @@ pub(crate) mod tests {
|
||||
fn undo_follow() {
|
||||
use crate::follows::*;
|
||||
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (_, users, _) = fill_database(&r);
|
||||
let (_, users, _) = fill_database(&conn);
|
||||
|
||||
let follow = Follow::insert(
|
||||
conn,
|
||||
&conn,
|
||||
NewFollow {
|
||||
follower_id: users[0].id,
|
||||
following_id: users[1].id,
|
||||
@@ -423,7 +608,7 @@ pub(crate) mod tests {
|
||||
"object": follow.ap_url,
|
||||
"type": "Undo",
|
||||
});
|
||||
assert!(super::inbox(&r, fail_act).is_err());
|
||||
assert!(super::inbox(&conn, fail_act).is_err());
|
||||
|
||||
let ok_act = json!({
|
||||
"id": "https://plu.me/undo/1",
|
||||
@@ -431,7 +616,7 @@ pub(crate) mod tests {
|
||||
"object": follow.ap_url,
|
||||
"type": "Undo",
|
||||
});
|
||||
assert!(super::inbox(&r, ok_act).is_ok());
|
||||
assert!(super::inbox(&conn, ok_act).is_ok());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -440,13 +625,12 @@ pub(crate) mod tests {
|
||||
fn undo_like() {
|
||||
use crate::likes::*;
|
||||
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (posts, users, _) = fill_database(&r);
|
||||
let (posts, users, _) = fill_database(&conn);
|
||||
|
||||
let like = Like::insert(
|
||||
conn,
|
||||
&conn,
|
||||
NewLike {
|
||||
post_id: posts[0].id,
|
||||
user_id: users[1].id,
|
||||
@@ -461,7 +645,7 @@ pub(crate) mod tests {
|
||||
"object": like.ap_url,
|
||||
"type": "Undo",
|
||||
});
|
||||
assert!(super::inbox(&r, fail_act).is_err());
|
||||
assert!(super::inbox(&conn, fail_act).is_err());
|
||||
|
||||
let ok_act = json!({
|
||||
"id": "https://plu.me/undo/1",
|
||||
@@ -469,17 +653,16 @@ pub(crate) mod tests {
|
||||
"object": like.ap_url,
|
||||
"type": "Undo",
|
||||
});
|
||||
assert!(super::inbox(&r, ok_act).is_ok());
|
||||
assert!(super::inbox(&conn, ok_act).is_ok());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_post() {
|
||||
let r = rockets();
|
||||
let conn = &*r.conn;
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (posts, users, _) = fill_database(&r);
|
||||
let (posts, users, _) = fill_database(&conn);
|
||||
|
||||
let act = json!({
|
||||
"id": "https://plu.me/update/1",
|
||||
@@ -498,7 +681,7 @@ pub(crate) mod tests {
|
||||
"type": "Update",
|
||||
});
|
||||
|
||||
super::inbox(&r, act).unwrap();
|
||||
super::inbox(&conn, act).unwrap();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ use crate::{
|
||||
medias::Media,
|
||||
safe_string::SafeString,
|
||||
schema::{instances, users},
|
||||
users::{Role, User},
|
||||
users::{NewUser, Role, User},
|
||||
Connection, Error, Result,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use plume_common::utils::md_to_html;
|
||||
use diesel::{self, result::Error::NotFound, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use once_cell::sync::OnceCell;
|
||||
use plume_common::utils::{iri_percent_encode_seg, md_to_html};
|
||||
use std::sync::RwLock;
|
||||
|
||||
#[derive(Clone, Identifiable, Queryable)]
|
||||
@@ -45,6 +46,9 @@ lazy_static! {
|
||||
static ref LOCAL_INSTANCE: RwLock<Option<Instance>> = RwLock::new(None);
|
||||
}
|
||||
|
||||
const LOCAL_INSTANCE_USERNAME: &str = "__instance__";
|
||||
static LOCAL_INSTANCE_USER: OnceCell<User> = OnceCell::new();
|
||||
|
||||
impl Instance {
|
||||
pub fn set_local(self) {
|
||||
LOCAL_INSTANCE.write().unwrap().replace(self);
|
||||
@@ -76,6 +80,42 @@ impl Instance {
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn create_local_instance_user(conn: &Connection) -> Result<User> {
|
||||
let instance = Instance::get_local()?;
|
||||
let email = format!("{}@{}", LOCAL_INSTANCE_USERNAME, &instance.public_domain);
|
||||
NewUser::new_local(
|
||||
conn,
|
||||
LOCAL_INSTANCE_USERNAME.into(),
|
||||
instance.public_domain,
|
||||
Role::Instance,
|
||||
"Local instance",
|
||||
email,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_local_instance_user() -> Option<&'static User> {
|
||||
LOCAL_INSTANCE_USER.get()
|
||||
}
|
||||
|
||||
pub fn get_local_instance_user_uncached(conn: &Connection) -> Result<User> {
|
||||
users::table
|
||||
.filter(users::role.eq(3))
|
||||
.first(conn)
|
||||
.or_else(|err| match err {
|
||||
NotFound => Self::create_local_instance_user(conn),
|
||||
_ => Err(Error::Db(err)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cache_local_instance_user(conn: &Connection) {
|
||||
let _ = LOCAL_INSTANCE_USER.get_or_init(|| {
|
||||
Self::get_local_instance_user_uncached(conn)
|
||||
.or_else(|_| Self::create_local_instance_user(conn))
|
||||
.expect("Failed to cache local instance user")
|
||||
});
|
||||
}
|
||||
|
||||
pub fn page(conn: &Connection, (min, max): (i32, i32)) -> Result<Vec<Instance>> {
|
||||
instances::table
|
||||
.order(instances::public_domain.asc())
|
||||
@@ -133,8 +173,8 @@ impl Instance {
|
||||
"{instance}/{prefix}/{name}/{box_name}",
|
||||
instance = self.public_domain,
|
||||
prefix = prefix,
|
||||
name = name,
|
||||
box_name = box_name
|
||||
name = iri_percent_encode_seg(name),
|
||||
box_name = iri_percent_encode_seg(box_name)
|
||||
))
|
||||
}
|
||||
|
||||
@@ -247,6 +287,7 @@ pub(crate) mod tests {
|
||||
use diesel::Connection;
|
||||
|
||||
pub(crate) fn fill_database(conn: &Conn) -> Vec<(NewInstance, Instance)> {
|
||||
diesel::delete(instances::table).execute(conn).unwrap();
|
||||
let res = vec"
|
||||
);
|
||||
assert_eq!(
|
||||
inst.long_description_html,
|
||||
SafeString::new("<p><a href=\"/with_link\">long_description</a></p>\n")
|
||||
SafeString::new(
|
||||
"<p dir=\"auto\"><a href=\"/with_link\">long_description</a></p>\n"
|
||||
)
|
||||
);
|
||||
assert_eq!(inst.short_description.get(), "[short](#link)");
|
||||
assert_eq!(
|
||||
inst.short_description_html,
|
||||
SafeString::new("<p><a href=\"#link\">short</a></p>\n")
|
||||
SafeString::new("<p dir=\"auto\"><a href=\"#link\">short</a></p>\n")
|
||||
);
|
||||
assert_eq!(inst.default_license, "CC-BY-SAO".to_owned());
|
||||
Ok(())
|
||||
|
||||
+113
-30
@@ -1,6 +1,6 @@
|
||||
#![feature(try_trait)]
|
||||
#![feature(never_type)]
|
||||
#![feature(proc_macro_hygiene)]
|
||||
#![feature(box_patterns)]
|
||||
|
||||
#[macro_use]
|
||||
extern crate diesel;
|
||||
@@ -10,14 +10,20 @@ extern crate lazy_static;
|
||||
extern crate plume_macro;
|
||||
#[macro_use]
|
||||
extern crate rocket;
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
#[macro_use]
|
||||
extern crate serde_json;
|
||||
#[macro_use]
|
||||
extern crate tantivy;
|
||||
|
||||
use plume_common::activity_pub::inbox::InboxError;
|
||||
use activitystreams::iri_string;
|
||||
pub use lettre;
|
||||
pub use lettre::smtp;
|
||||
use once_cell::sync::Lazy;
|
||||
use plume_common::activity_pub::{inbox::InboxError, request, sign};
|
||||
use posts::PostEvent;
|
||||
use riker::actors::{channel, ActorSystem, ChannelRef, SystemBuilder};
|
||||
use users::UserEvent;
|
||||
|
||||
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
|
||||
compile_error!("Either feature \"sqlite\" or \"postgres\" must be enabled for this crate.");
|
||||
@@ -30,6 +36,19 @@ pub type Connection = diesel::SqliteConnection;
|
||||
#[cfg(all(not(feature = "sqlite"), feature = "postgres"))]
|
||||
pub type Connection = diesel::PgConnection;
|
||||
|
||||
pub(crate) static ACTOR_SYS: Lazy<ActorSystem> = Lazy::new(|| {
|
||||
SystemBuilder::new()
|
||||
.name("plume")
|
||||
.create()
|
||||
.expect("Failed to create actor system")
|
||||
});
|
||||
|
||||
pub(crate) static USER_CHAN: Lazy<ChannelRef<UserEvent>> =
|
||||
Lazy::new(|| channel("user_events", &*ACTOR_SYS).expect("Failed to create user channel"));
|
||||
|
||||
pub(crate) static POST_CHAN: Lazy<ChannelRef<PostEvent>> =
|
||||
Lazy::new(|| channel("post_events", &*ACTOR_SYS).expect("Failed to create post channel"));
|
||||
|
||||
/// All the possible errors that can be encoutered in this crate
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
@@ -49,6 +68,8 @@ pub enum Error {
|
||||
Url,
|
||||
Webfinger,
|
||||
Expired,
|
||||
UserAlreadyExists,
|
||||
Anyhow(anyhow::Error),
|
||||
}
|
||||
|
||||
impl From<bcrypt::BcryptError> for Error {
|
||||
@@ -63,20 +84,26 @@ impl From<openssl::error::ErrorStack> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sign::Error> for Error {
|
||||
fn from(_: sign::Error) -> Self {
|
||||
Error::Signature
|
||||
}
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for Error {
|
||||
fn from(err: diesel::result::Error) -> Self {
|
||||
Error::Db(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::option::NoneError> for Error {
|
||||
fn from(_: std::option::NoneError) -> Self {
|
||||
Error::NotFound
|
||||
impl From<url::ParseError> for Error {
|
||||
fn from(_: url::ParseError) -> Self {
|
||||
Error::Url
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for Error {
|
||||
fn from(_: url::ParseError) -> Self {
|
||||
impl From<iri_string::validate::Error> for Error {
|
||||
fn from(_: iri_string::validate::Error) -> Self {
|
||||
Error::Url
|
||||
}
|
||||
}
|
||||
@@ -99,12 +126,9 @@ impl From<reqwest::header::InvalidHeaderValue> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<activitypub::Error> for Error {
|
||||
fn from(err: activitypub::Error) -> Self {
|
||||
match err {
|
||||
activitypub::Error::NotFound => Error::MissingApProperty,
|
||||
_ => Error::SerDe,
|
||||
}
|
||||
impl From<activitystreams::checked::CheckError> for Error {
|
||||
fn from(_: activitystreams::checked::CheckError) -> Error {
|
||||
Error::MissingApProperty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +165,18 @@ impl From<InboxError<Error>> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<request::Error> for Error {
|
||||
fn from(_err: request::Error) -> Error {
|
||||
Error::Request
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for Error {
|
||||
fn from(err: anyhow::Error) -> Error {
|
||||
Error::Anyhow(err)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Adds a function to a model, that returns the first
|
||||
@@ -148,7 +184,7 @@ pub type Result<T> = std::result::Result<T, Error>;
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```rust
|
||||
/// ```ignore
|
||||
/// impl Model {
|
||||
/// find_by!(model_table, name_of_the_function, field1 as String, field2 as i32);
|
||||
/// }
|
||||
@@ -172,7 +208,7 @@ macro_rules! find_by {
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```rust
|
||||
/// ```ignore
|
||||
/// impl Model {
|
||||
/// list_by!(model_table, name_of_the_function, field1 as String);
|
||||
/// }
|
||||
@@ -196,7 +232,7 @@ macro_rules! list_by {
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```rust
|
||||
/// ```ignore
|
||||
/// impl Model {
|
||||
/// get!(model_table);
|
||||
/// }
|
||||
@@ -219,7 +255,7 @@ macro_rules! get {
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```rust
|
||||
/// ```ignore
|
||||
/// impl Model {
|
||||
/// insert!(model_table, NewModelType);
|
||||
/// }
|
||||
@@ -251,7 +287,7 @@ macro_rules! insert {
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```rust
|
||||
/// ```ignore
|
||||
/// impl Model {
|
||||
/// last!(model_table);
|
||||
/// }
|
||||
@@ -278,15 +314,41 @@ pub fn ap_url(url: &str) -> String {
|
||||
format!("https://{}", url)
|
||||
}
|
||||
|
||||
pub trait SmtpNewWithAddr {
|
||||
fn new_with_addr(
|
||||
addr: (&str, u16),
|
||||
) -> std::result::Result<smtp::SmtpClient, smtp::error::Error>;
|
||||
}
|
||||
|
||||
impl SmtpNewWithAddr for smtp::SmtpClient {
|
||||
// Stolen from lettre::smtp::SmtpClient::new_simple()
|
||||
fn new_with_addr(addr: (&str, u16)) -> std::result::Result<Self, smtp::error::Error> {
|
||||
use native_tls::TlsConnector;
|
||||
use smtp::{
|
||||
client::net::{ClientTlsParameters, DEFAULT_TLS_PROTOCOLS},
|
||||
ClientSecurity, SmtpClient,
|
||||
};
|
||||
|
||||
let (domain, port) = addr;
|
||||
|
||||
let mut tls_builder = TlsConnector::builder();
|
||||
tls_builder.min_protocol_version(Some(DEFAULT_TLS_PROTOCOLS[0]));
|
||||
|
||||
let tls_parameters =
|
||||
ClientTlsParameters::new(domain.to_string(), tls_builder.build().unwrap());
|
||||
|
||||
SmtpClient::new((domain, port), ClientSecurity::Wrapper(tls_parameters))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
mod tests {
|
||||
use crate::{db_conn, migrations::IMPORTED_MIGRATIONS, search, Connection as Conn, CONFIG};
|
||||
use crate::{db_conn, migrations::IMPORTED_MIGRATIONS, Connection as Conn, CONFIG};
|
||||
use chrono::{naive::NaiveDateTime, Datelike, Timelike};
|
||||
use diesel::r2d2::ConnectionManager;
|
||||
use plume_common::utils::random_hex;
|
||||
use scheduled_thread_pool::ScheduledThreadPool;
|
||||
use std::env::temp_dir;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! part_eq {
|
||||
@@ -299,7 +361,7 @@ mod tests {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn db<'a>() -> db_conn::DbConn {
|
||||
pub fn db() -> db_conn::DbConn {
|
||||
db_conn::DbConn((*DB_POOL).get().unwrap())
|
||||
}
|
||||
|
||||
@@ -317,13 +379,31 @@ mod tests {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn rockets() -> super::PlumeRocket {
|
||||
super::PlumeRocket {
|
||||
conn: db_conn::DbConn((*DB_POOL).get().unwrap()),
|
||||
searcher: Arc::new(search::tests::get_searcher(&CONFIG.search_tokenizers)),
|
||||
worker: Arc::new(ScheduledThreadPool::new(2)),
|
||||
user: None,
|
||||
}
|
||||
#[cfg(feature = "postgres")]
|
||||
pub(crate) fn format_datetime(dt: &NaiveDateTime) -> String {
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
|
||||
dt.year(),
|
||||
dt.month(),
|
||||
dt.day(),
|
||||
dt.hour(),
|
||||
dt.minute(),
|
||||
dt.second(),
|
||||
dt.timestamp_subsec_micros()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
pub(crate) fn format_datetime(dt: &NaiveDateTime) -> String {
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
|
||||
dt.year(),
|
||||
dt.month(),
|
||||
dt.day(),
|
||||
dt.hour(),
|
||||
dt.minute(),
|
||||
dt.second()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +416,7 @@ pub mod blogs;
|
||||
pub mod comment_seers;
|
||||
pub mod comments;
|
||||
pub mod db_conn;
|
||||
pub mod email_signups;
|
||||
pub mod follows;
|
||||
pub mod headers;
|
||||
pub mod inbox;
|
||||
@@ -350,11 +431,13 @@ pub mod password_reset_requests;
|
||||
pub mod plume_rocket;
|
||||
pub mod post_authors;
|
||||
pub mod posts;
|
||||
pub mod remote_fetch_actor;
|
||||
pub mod reshares;
|
||||
pub mod safe_string;
|
||||
#[allow(unused_imports)]
|
||||
pub mod schema;
|
||||
pub mod search;
|
||||
pub mod signups;
|
||||
pub mod tags;
|
||||
pub mod timeline;
|
||||
pub mod users;
|
||||
|
||||
+139
-52
@@ -1,13 +1,19 @@
|
||||
use crate::{
|
||||
notifications::*, posts::Post, schema::likes, timeline::*, users::User, Connection, Error,
|
||||
PlumeRocket, Result,
|
||||
instance::Instance, notifications::*, posts::Post, schema::likes, timeline::*, users::User,
|
||||
Connection, Error, Result, CONFIG,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{ActorAndObjectRef, Like as LikeAct, Undo},
|
||||
base::AnyBase,
|
||||
iri_string::types::IriString,
|
||||
prelude::*,
|
||||
};
|
||||
use activitypub::activity;
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use plume_common::activity_pub::{
|
||||
inbox::{AsActor, AsObject, FromId},
|
||||
Id, IntoId, PUBLIC_VISIBILITY,
|
||||
sign::Signer,
|
||||
PUBLIC_VISIBILITY,
|
||||
};
|
||||
|
||||
#[derive(Clone, Queryable, Identifiable)]
|
||||
@@ -33,18 +39,16 @@ impl Like {
|
||||
find_by!(likes, find_by_ap_url, ap_url as &str);
|
||||
find_by!(likes, find_by_user_on_post, user_id as i32, post_id as i32);
|
||||
|
||||
pub fn to_activity(&self, conn: &Connection) -> Result<activity::Like> {
|
||||
let mut act = activity::Like::default();
|
||||
act.like_props
|
||||
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
|
||||
act.like_props
|
||||
.set_object_link(Post::get(conn, self.post_id)?.into_id())?;
|
||||
act.object_props
|
||||
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
|
||||
act.object_props.set_cc_link_vec(vec![Id::new(
|
||||
User::get(conn, self.user_id)?.followers_endpoint,
|
||||
)])?;
|
||||
act.object_props.set_id_string(self.ap_url.clone())?;
|
||||
pub fn to_activity(&self, conn: &Connection) -> Result<LikeAct> {
|
||||
let mut act = LikeAct::new(
|
||||
User::get(conn, self.user_id)?.ap_url.parse::<IriString>()?,
|
||||
Post::get(conn, self.post_id)?.ap_url.parse::<IriString>()?,
|
||||
);
|
||||
act.set_many_tos(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
|
||||
act.set_many_ccs(vec![User::get(conn, self.user_id)?
|
||||
.followers_endpoint
|
||||
.parse::<IriString>()?]);
|
||||
act.set_id(self.ap_url.parse::<IriString>()?);
|
||||
|
||||
Ok(act)
|
||||
}
|
||||
@@ -66,75 +70,95 @@ impl Like {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_undo(&self, conn: &Connection) -> Result<activity::Undo> {
|
||||
let mut act = activity::Undo::default();
|
||||
act.undo_props
|
||||
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
|
||||
act.undo_props.set_object_object(self.to_activity(conn)?)?;
|
||||
act.object_props
|
||||
.set_id_string(format!("{}#delete", self.ap_url))?;
|
||||
act.object_props
|
||||
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
|
||||
act.object_props.set_cc_link_vec(vec![Id::new(
|
||||
User::get(conn, self.user_id)?.followers_endpoint,
|
||||
)])?;
|
||||
pub fn build_undo(&self, conn: &Connection) -> Result<Undo> {
|
||||
let mut act = Undo::new(
|
||||
User::get(conn, self.user_id)?.ap_url.parse::<IriString>()?,
|
||||
AnyBase::from_extended(self.to_activity(conn)?)?,
|
||||
);
|
||||
act.set_id(format!("{}#delete", self.ap_url).parse::<IriString>()?);
|
||||
act.set_many_tos(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
|
||||
act.set_many_ccs(vec![User::get(conn, self.user_id)?
|
||||
.followers_endpoint
|
||||
.parse::<IriString>()?]);
|
||||
|
||||
Ok(act)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsObject<User, activity::Like, &PlumeRocket> for Post {
|
||||
impl AsObject<User, LikeAct, &Connection> for Post {
|
||||
type Error = Error;
|
||||
type Output = Like;
|
||||
|
||||
fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result<Like> {
|
||||
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Like> {
|
||||
let res = Like::insert(
|
||||
&c.conn,
|
||||
conn,
|
||||
NewLike {
|
||||
post_id: self.id,
|
||||
user_id: actor.id,
|
||||
ap_url: id.to_string(),
|
||||
},
|
||||
)?;
|
||||
res.notify(&c.conn)?;
|
||||
res.notify(conn)?;
|
||||
|
||||
Timeline::add_to_all_timelines(c, &self, Kind::Like(&actor))?;
|
||||
Timeline::add_to_all_timelines(conn, &self, Kind::Like(&actor))?;
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromId<PlumeRocket> for Like {
|
||||
impl FromId<Connection> for Like {
|
||||
type Error = Error;
|
||||
type Object = activity::Like;
|
||||
type Object = LikeAct;
|
||||
|
||||
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
|
||||
Like::find_by_ap_url(&c.conn, id)
|
||||
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
|
||||
Like::find_by_ap_url(conn, id)
|
||||
}
|
||||
|
||||
fn from_activity(c: &PlumeRocket, act: activity::Like) -> Result<Self> {
|
||||
fn from_activity(conn: &Connection, act: LikeAct) -> Result<Self> {
|
||||
let res = Like::insert(
|
||||
&c.conn,
|
||||
conn,
|
||||
NewLike {
|
||||
post_id: Post::from_id(c, &act.like_props.object_link::<Id>()?, None)
|
||||
.map_err(|(_, e)| e)?
|
||||
.id,
|
||||
user_id: User::from_id(c, &act.like_props.actor_link::<Id>()?, None)
|
||||
.map_err(|(_, e)| e)?
|
||||
.id,
|
||||
ap_url: act.object_props.id_string()?,
|
||||
post_id: Post::from_id(
|
||||
conn,
|
||||
act.object_field_ref()
|
||||
.as_single_id()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.as_str(),
|
||||
None,
|
||||
CONFIG.proxy(),
|
||||
)
|
||||
.map_err(|(_, e)| e)?
|
||||
.id,
|
||||
user_id: User::from_id(
|
||||
conn,
|
||||
act.actor_field_ref()
|
||||
.as_single_id()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.as_str(),
|
||||
None,
|
||||
CONFIG.proxy(),
|
||||
)
|
||||
.map_err(|(_, e)| e)?
|
||||
.id,
|
||||
ap_url: act
|
||||
.id_unchecked()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.to_string(),
|
||||
},
|
||||
)?;
|
||||
res.notify(&c.conn)?;
|
||||
res.notify(conn)?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
fn get_sender() -> &'static dyn Signer {
|
||||
Instance::get_local_instance_user().expect("Failed to local instance user")
|
||||
}
|
||||
}
|
||||
|
||||
impl AsObject<User, activity::Undo, &PlumeRocket> for Like {
|
||||
impl AsObject<User, Undo, &Connection> for Like {
|
||||
type Error = Error;
|
||||
type Output = ();
|
||||
|
||||
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
|
||||
let conn = &*c.conn;
|
||||
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
|
||||
if actor.id == self.user_id {
|
||||
diesel::delete(&self).execute(conn)?;
|
||||
|
||||
@@ -151,8 +175,7 @@ impl AsObject<User, activity::Undo, &PlumeRocket> for Like {
|
||||
|
||||
impl NewLike {
|
||||
pub fn new(p: &Post, u: &User) -> Self {
|
||||
// TODO: this URL is not valid
|
||||
let ap_url = format!("{}/like/{}", u.ap_url, p.ap_url);
|
||||
let ap_url = format!("{}like/{}", u.ap_url, p.ap_url);
|
||||
NewLike {
|
||||
post_id: p.id,
|
||||
user_id: u.id,
|
||||
@@ -160,3 +183,67 @@ impl NewLike {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::diesel::Connection;
|
||||
use crate::{inbox::tests::fill_database, tests::db};
|
||||
use assert_json_diff::assert_json_eq;
|
||||
use serde_json::{json, to_value};
|
||||
|
||||
#[test]
|
||||
fn to_activity() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (posts, _users, _blogs) = fill_database(&conn);
|
||||
let post = &posts[0];
|
||||
let user = &post.get_authors(&conn)?[0];
|
||||
let like = Like::insert(&conn, NewLike::new(post, user))?;
|
||||
let act = like.to_activity(&conn).unwrap();
|
||||
|
||||
let expected = json!({
|
||||
"actor": "https://plu.me/@/admin/",
|
||||
"cc": ["https://plu.me/@/admin/followers"],
|
||||
"id": "https://plu.me/@/admin/like/https://plu.me/~/BlogName/testing",
|
||||
"object": "https://plu.me/~/BlogName/testing",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Like",
|
||||
});
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_undo() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (posts, _users, _blogs) = fill_database(&conn);
|
||||
let post = &posts[0];
|
||||
let user = &post.get_authors(&conn)?[0];
|
||||
let like = Like::insert(&conn, NewLike::new(post, user))?;
|
||||
let act = like.build_undo(&conn)?;
|
||||
|
||||
let expected = json!({
|
||||
"actor": "https://plu.me/@/admin/",
|
||||
"cc": ["https://plu.me/@/admin/followers"],
|
||||
"id": "https://plu.me/@/admin/like/https://plu.me/~/BlogName/testing#delete",
|
||||
"object": {
|
||||
"actor": "https://plu.me/@/admin/",
|
||||
"cc": ["https://plu.me/@/admin/followers"],
|
||||
"id": "https://plu.me/@/admin/like/https://plu.me/~/BlogName/testing",
|
||||
"object": "https://plu.me/~/BlogName/testing",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Like",
|
||||
},
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Undo",
|
||||
});
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+35
-11
@@ -30,9 +30,9 @@ impl TryFrom<i32> for ListType {
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<i32> for ListType {
|
||||
fn into(self) -> i32 {
|
||||
match self {
|
||||
impl From<ListType> for i32 {
|
||||
fn from(list_type: ListType) -> Self {
|
||||
match list_type {
|
||||
ListType::User => 0,
|
||||
ListType::Blog => 1,
|
||||
ListType::Word => 2,
|
||||
@@ -143,6 +143,7 @@ macro_rules! func {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Queryable, Identifiable)]
|
||||
struct ListElem {
|
||||
pub id: i32,
|
||||
@@ -246,22 +247,22 @@ impl List {
|
||||
private::ListElem::prefix_in_list(conn, self, word)
|
||||
}
|
||||
|
||||
/// Insert new users in a list
|
||||
// Insert new users in a list
|
||||
func! {add: add_users, User}
|
||||
|
||||
/// Insert new blogs in a list
|
||||
// Insert new blogs in a list
|
||||
func! {add: add_blogs, Blog}
|
||||
|
||||
/// Insert new words in a list
|
||||
// Insert new words in a list
|
||||
func! {add: add_words, Word}
|
||||
|
||||
/// Insert new prefixes in a list
|
||||
// Insert new prefixes in a list
|
||||
func! {add: add_prefixes, Prefix}
|
||||
|
||||
/// Get all users in the list
|
||||
// Get all users in the list
|
||||
func! {list: list_users, User, users}
|
||||
|
||||
/// Get all blogs in the list
|
||||
// Get all blogs in the list
|
||||
func! {list: list_blogs, Blog, blogs}
|
||||
|
||||
/// Get all words in the list
|
||||
@@ -285,7 +286,8 @@ impl List {
|
||||
.select(list_elems::word)
|
||||
.load::<Option<String>>(conn)
|
||||
.map_err(Error::from)
|
||||
.map(|r| r.into_iter().filter_map(|o| o).collect::<Vec<String>>())
|
||||
// .map(|r| r.into_iter().filter_map(|o| o).collect::<Vec<String>>())
|
||||
.map(|r| r.into_iter().flatten().collect::<Vec<String>>())
|
||||
}
|
||||
|
||||
pub fn clear(&self, conn: &Connection) -> Result<()> {
|
||||
@@ -295,6 +297,28 @@ impl List {
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn delete(&self, conn: &Connection) -> Result<()> {
|
||||
if let Some(user_id) = self.user_id {
|
||||
diesel::delete(
|
||||
lists::table
|
||||
.filter(lists::user_id.eq(user_id))
|
||||
.filter(lists::name.eq(&self.name)),
|
||||
)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
diesel::delete(
|
||||
lists::table
|
||||
.filter(lists::user_id.is_null())
|
||||
.filter(lists::name.eq(&self.name)),
|
||||
)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
func! {set: set_users, User, add_users}
|
||||
func! {set: set_blogs, Blog, add_blogs}
|
||||
func! {set: set_words, Word, add_words}
|
||||
@@ -411,7 +435,7 @@ mod tests {
|
||||
&List::find_for_user_by_name(conn, l1.user_id, &l1.name).unwrap(),
|
||||
);
|
||||
l_eq(
|
||||
&&l1u,
|
||||
&l1u,
|
||||
&List::find_for_user_by_name(conn, l1u.user_id, &l1u.name).unwrap(),
|
||||
);
|
||||
Ok(())
|
||||
|
||||
+149
-60
@@ -1,19 +1,24 @@
|
||||
use crate::{
|
||||
ap_url, instance::Instance, safe_string::SafeString, schema::medias, users::User, Connection,
|
||||
Error, PlumeRocket, Result,
|
||||
Error, Result, CONFIG,
|
||||
};
|
||||
use activitypub::object::Image;
|
||||
use askama_escape::escape;
|
||||
use activitystreams::{object::Image, prelude::*};
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use guid_create::GUID;
|
||||
use plume_common::{
|
||||
activity_pub::{inbox::FromId, Id},
|
||||
utils::MediaProcessor,
|
||||
activity_pub::{inbox::FromId, request, ToAsString, ToAsUri},
|
||||
utils::{escape, MediaProcessor},
|
||||
};
|
||||
use reqwest;
|
||||
use std::{fs, path::Path};
|
||||
use std::{
|
||||
fs::{self, DirBuilder},
|
||||
path::{self, Path, PathBuf},
|
||||
};
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone, Identifiable, Queryable)]
|
||||
const REMOTE_MEDIA_DIRECTORY: &str = "remote";
|
||||
|
||||
#[derive(Clone, Identifiable, Queryable, AsChangeset)]
|
||||
pub struct Media {
|
||||
pub id: i32,
|
||||
pub file_path: String,
|
||||
@@ -37,7 +42,7 @@ pub struct NewMedia {
|
||||
pub owner_id: i32,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum MediaCategory {
|
||||
Image,
|
||||
Audio,
|
||||
@@ -59,6 +64,7 @@ impl MediaCategory {
|
||||
impl Media {
|
||||
insert!(medias, NewMedia);
|
||||
get!(medias);
|
||||
find_by!(medias, find_by_file_path, file_path as &str);
|
||||
|
||||
pub fn for_user(conn: &Connection, owner: i32) -> Result<Vec<Media>> {
|
||||
medias::table
|
||||
@@ -97,8 +103,8 @@ impl Media {
|
||||
pub fn category(&self) -> MediaCategory {
|
||||
match &*self
|
||||
.file_path
|
||||
.rsplitn(2, '.')
|
||||
.next()
|
||||
.rsplit_once('.')
|
||||
.map(|x| x.1)
|
||||
.expect("Media::category: extension error")
|
||||
.to_lowercase()
|
||||
{
|
||||
@@ -149,19 +155,27 @@ impl Media {
|
||||
if self.is_remote {
|
||||
Ok(self.remote_url.clone().unwrap_or_default())
|
||||
} else {
|
||||
let p = Path::new(&self.file_path);
|
||||
let filename: String = p.file_name().unwrap().to_str().unwrap().to_owned();
|
||||
let file_path = self.file_path.replace(path::MAIN_SEPARATOR, "/").replacen(
|
||||
&CONFIG.media_directory,
|
||||
"static/media",
|
||||
1,
|
||||
); // "static/media" from plume::routs::plume_media_files()
|
||||
Ok(ap_url(&format!(
|
||||
"{}/static/media/{}",
|
||||
"{}/{}",
|
||||
Instance::get_local()?.public_domain,
|
||||
&filename
|
||||
&file_path
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&self, conn: &Connection) -> Result<()> {
|
||||
if !self.is_remote {
|
||||
fs::remove_file(self.file_path.as_str())?;
|
||||
if let Some(config) = &CONFIG.s3 {
|
||||
config.get_bucket()
|
||||
.delete_object_blocking(&self.file_path)?;
|
||||
} else {
|
||||
fs::remove_file(self.file_path.as_str())?;
|
||||
}
|
||||
}
|
||||
diesel::delete(self)
|
||||
.execute(conn)
|
||||
@@ -197,50 +211,90 @@ impl Media {
|
||||
}
|
||||
|
||||
// TODO: merge with save_remote?
|
||||
pub fn from_activity(c: &PlumeRocket, image: &Image) -> Result<Media> {
|
||||
let conn = &*c.conn;
|
||||
let remote_url = image.object_props.url_string().ok()?;
|
||||
let ext = remote_url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| String::from("png"));
|
||||
let path = Path::new(&super::CONFIG.media_directory).join(format!(
|
||||
"{}.{}",
|
||||
GUID::rand().to_string(),
|
||||
ext
|
||||
));
|
||||
pub fn from_activity(conn: &Connection, image: &Image) -> Result<Media> {
|
||||
let remote_url = image
|
||||
.url()
|
||||
.and_then(|url| url.to_as_uri())
|
||||
.ok_or(Error::MissingApProperty)?;
|
||||
let path = determine_mirror_file_path(&remote_url);
|
||||
let parent = path.parent().ok_or(Error::InvalidValue)?;
|
||||
if !parent.is_dir() {
|
||||
DirBuilder::new().recursive(true).create(parent)?;
|
||||
}
|
||||
|
||||
let mut dest = fs::File::create(path.clone()).ok()?;
|
||||
reqwest::get(remote_url.as_str())
|
||||
.ok()?
|
||||
.copy_to(&mut dest)
|
||||
.ok()?;
|
||||
let mut dest = fs::File::create(path.clone())?;
|
||||
// TODO: conditional GET
|
||||
request::get(
|
||||
remote_url.as_str(),
|
||||
User::get_sender(),
|
||||
CONFIG.proxy().cloned(),
|
||||
)?
|
||||
.copy_to(&mut dest)?;
|
||||
|
||||
Media::insert(
|
||||
conn,
|
||||
NewMedia {
|
||||
file_path: path.to_str()?.to_string(),
|
||||
alt_text: image.object_props.content_string().ok()?,
|
||||
is_remote: false,
|
||||
remote_url: None,
|
||||
sensitive: image.object_props.summary_string().is_ok(),
|
||||
content_warning: image.object_props.summary_string().ok(),
|
||||
owner_id: User::from_id(
|
||||
c,
|
||||
image
|
||||
.object_props
|
||||
.attributed_to_link_vec::<Id>()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.next()?
|
||||
.as_ref(),
|
||||
None,
|
||||
Media::find_by_file_path(conn, path.to_str().ok_or(Error::InvalidValue)?)
|
||||
.and_then(|mut media| {
|
||||
let mut updated = false;
|
||||
|
||||
let alt_text = image
|
||||
.content()
|
||||
.and_then(|content| content.to_as_string())
|
||||
.ok_or(Error::NotFound)?;
|
||||
let summary = image.summary().and_then(|summary| summary.to_as_string());
|
||||
let sensitive = summary.is_some();
|
||||
let content_warning = summary;
|
||||
if media.alt_text != alt_text {
|
||||
media.alt_text = alt_text;
|
||||
updated = true;
|
||||
}
|
||||
if media.is_remote {
|
||||
media.is_remote = false;
|
||||
updated = true;
|
||||
}
|
||||
if media.remote_url.is_some() {
|
||||
media.remote_url = None;
|
||||
updated = true;
|
||||
}
|
||||
if media.sensitive != sensitive {
|
||||
media.sensitive = sensitive;
|
||||
updated = true;
|
||||
}
|
||||
if media.content_warning != content_warning {
|
||||
media.content_warning = content_warning;
|
||||
updated = true;
|
||||
}
|
||||
if updated {
|
||||
diesel::update(&media).set(&media).execute(conn)?;
|
||||
}
|
||||
Ok(media)
|
||||
})
|
||||
.or_else(|_| {
|
||||
let summary = image.summary().and_then(|summary| summary.to_as_string());
|
||||
Media::insert(
|
||||
conn,
|
||||
NewMedia {
|
||||
file_path: path.to_str().ok_or(Error::InvalidValue)?.to_string(),
|
||||
alt_text: image
|
||||
.content()
|
||||
.and_then(|content| content.to_as_string())
|
||||
.ok_or(Error::NotFound)?,
|
||||
is_remote: false,
|
||||
remote_url: None,
|
||||
sensitive: summary.is_some(),
|
||||
content_warning: summary,
|
||||
owner_id: User::from_id(
|
||||
conn,
|
||||
&image
|
||||
.attributed_to()
|
||||
.and_then(|attributed_to| attributed_to.to_as_uri())
|
||||
.ok_or(Error::MissingApProperty)?,
|
||||
None,
|
||||
CONFIG.proxy(),
|
||||
)
|
||||
.map_err(|(_, e)| e)?
|
||||
.id,
|
||||
},
|
||||
)
|
||||
.map_err(|(_, e)| e)?
|
||||
.id,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_media_processor<'a>(conn: &'a Connection, user: Vec<&User>) -> MediaProcessor<'a> {
|
||||
@@ -257,6 +311,33 @@ impl Media {
|
||||
}
|
||||
}
|
||||
|
||||
fn determine_mirror_file_path(url: &str) -> PathBuf {
|
||||
let mut file_path = Path::new(&super::CONFIG.media_directory).join(REMOTE_MEDIA_DIRECTORY);
|
||||
Url::parse(url)
|
||||
.map(|url| {
|
||||
if !url.has_host() {
|
||||
return;
|
||||
}
|
||||
file_path.push(url.host_str().unwrap());
|
||||
for segment in url.path_segments().expect("FIXME") {
|
||||
file_path.push(segment);
|
||||
}
|
||||
// TODO: handle query
|
||||
// HINT: Use characters which must be percent-encoded in path as separator between path and query
|
||||
// HINT: handle extension
|
||||
})
|
||||
.unwrap_or_else(|err| {
|
||||
warn!("Failed to parse url: {} {}", &url, err);
|
||||
let ext = url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| String::from("png"));
|
||||
file_path.push(format!("{}.{}", GUID::rand(), ext));
|
||||
});
|
||||
file_path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
@@ -267,7 +348,7 @@ pub(crate) mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn fill_database(conn: &Conn) -> (Vec<User>, Vec<Media>) {
|
||||
let mut wd = current_dir().unwrap().to_path_buf();
|
||||
let mut wd = current_dir().unwrap();
|
||||
while wd.pop() {
|
||||
if wd.join(".git").exists() {
|
||||
set_current_dir(wd).unwrap();
|
||||
@@ -322,7 +403,15 @@ pub(crate) mod tests {
|
||||
pub(crate) fn clean(conn: &Conn) {
|
||||
//used to remove files generated by tests
|
||||
for media in Media::list_all_medias(conn).unwrap() {
|
||||
media.delete(conn).unwrap();
|
||||
if let Some(err) = media.delete(conn).err() {
|
||||
match &err {
|
||||
Error::Io(e) => match e.kind() {
|
||||
std::io::ErrorKind::NotFound => (),
|
||||
_ => panic!("{:?}", err),
|
||||
},
|
||||
_ => panic!("{:?}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +461,7 @@ pub(crate) mod tests {
|
||||
let media = Media::insert(
|
||||
conn,
|
||||
NewMedia {
|
||||
file_path: path.clone(),
|
||||
file_path: path,
|
||||
alt_text: "alt message".to_owned(),
|
||||
is_remote: false,
|
||||
remote_url: None,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use crate::{
|
||||
comments::Comment, notifications::*, posts::Post, schema::mentions, users::User, Connection,
|
||||
Error, PlumeRocket, Result,
|
||||
Error, Result,
|
||||
};
|
||||
use activitystreams::{
|
||||
base::BaseExt,
|
||||
iri_string::types::IriString,
|
||||
link::{self, LinkExt},
|
||||
};
|
||||
use activitypub::link;
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use plume_common::activity_pub::inbox::AsActor;
|
||||
|
||||
@@ -47,26 +51,28 @@ impl Mention {
|
||||
|
||||
pub fn get_user(&self, conn: &Connection) -> Result<User> {
|
||||
match self.get_post(conn) {
|
||||
Ok(p) => Ok(p.get_authors(conn)?.into_iter().next()?),
|
||||
Ok(p) => Ok(p
|
||||
.get_authors(conn)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(Error::NotFound)?),
|
||||
Err(_) => self.get_comment(conn).and_then(|c| c.get_author(conn)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_activity(c: &PlumeRocket, ment: &str) -> Result<link::Mention> {
|
||||
let user = User::find_by_fqn(c, ment)?;
|
||||
let mut mention = link::Mention::default();
|
||||
mention.link_props.set_href_string(user.ap_url)?;
|
||||
mention.link_props.set_name_string(format!("@{}", ment))?;
|
||||
pub fn build_activity(conn: &Connection, ment: &str) -> Result<link::Mention> {
|
||||
let user = User::find_by_fqn(conn, ment)?;
|
||||
let mut mention = link::Mention::new();
|
||||
mention.set_href(user.ap_url.parse::<IriString>()?);
|
||||
mention.set_name(format!("@{}", ment));
|
||||
Ok(mention)
|
||||
}
|
||||
|
||||
pub fn to_activity(&self, conn: &Connection) -> Result<link::Mention> {
|
||||
let user = self.get_mentioned(conn)?;
|
||||
let mut mention = link::Mention::default();
|
||||
mention.link_props.set_href_string(user.ap_url.clone())?;
|
||||
mention
|
||||
.link_props
|
||||
.set_name_string(format!("@{}", user.fqn))?;
|
||||
let mut mention = link::Mention::new();
|
||||
mention.set_href(user.ap_url.parse::<IriString>()?);
|
||||
mention.set_name(format!("@{}", user.fqn));
|
||||
Ok(mention)
|
||||
}
|
||||
|
||||
@@ -77,8 +83,8 @@ impl Mention {
|
||||
in_post: bool,
|
||||
notify: bool,
|
||||
) -> Result<Self> {
|
||||
let ap_url = ment.link_props.href_string().ok()?;
|
||||
let mentioned = User::find_by_ap_url(conn, &ap_url)?;
|
||||
let ap_url = ment.href().ok_or(Error::NotFound)?.as_str();
|
||||
let mentioned = User::find_by_ap_url(conn, ap_url)?;
|
||||
|
||||
if in_post {
|
||||
Post::get(conn, inside).and_then(|post| {
|
||||
@@ -141,3 +147,62 @@ impl Mention {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{inbox::tests::fill_database, tests::db, Error};
|
||||
use assert_json_diff::assert_json_eq;
|
||||
use diesel::Connection;
|
||||
use serde_json::{json, to_value};
|
||||
|
||||
#[test]
|
||||
fn build_activity() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (_posts, users, _blogs) = fill_database(&conn);
|
||||
let user = &users[0];
|
||||
let name = &user.username;
|
||||
let act = Mention::build_activity(&conn, name)?;
|
||||
|
||||
let expected = json!({
|
||||
"href": "https://plu.me/@/admin/",
|
||||
"name": "@admin",
|
||||
"type": "Mention",
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_activity() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (posts, users, _blogs) = fill_database(&conn);
|
||||
let post = &posts[0];
|
||||
let user = &users[0];
|
||||
let mention = Mention::insert(
|
||||
&conn,
|
||||
NewMention {
|
||||
mentioned_id: user.id,
|
||||
post_id: Some(post.id),
|
||||
comment_id: None,
|
||||
},
|
||||
)?;
|
||||
let act = mention.to_activity(&conn)?;
|
||||
|
||||
let expected = json!({
|
||||
"href": "https://plu.me/@/admin/",
|
||||
"name": "@admin",
|
||||
"type": "Mention",
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::{Connection, Error, Result};
|
||||
use diesel::connection::{Connection as Conn, SimpleConnection};
|
||||
use migrations_internals::{setup_database, MigrationConnection};
|
||||
use std::path::Path;
|
||||
use tracing::info;
|
||||
|
||||
#[allow(dead_code)] //variants might not be constructed if not required by current migrations
|
||||
enum Action {
|
||||
@@ -26,7 +27,7 @@ struct ComplexMigration {
|
||||
|
||||
impl ComplexMigration {
|
||||
fn run(&self, conn: &Connection, path: &Path) -> Result<()> {
|
||||
println!("Running migration {}", self.name);
|
||||
info!("Running migration {}", self.name);
|
||||
for step in self.up {
|
||||
step.run(conn, path)?
|
||||
}
|
||||
@@ -34,7 +35,7 @@ impl ComplexMigration {
|
||||
}
|
||||
|
||||
fn revert(&self, conn: &Connection, path: &Path) -> Result<()> {
|
||||
println!("Reverting migration {}", self.name);
|
||||
info!("Reverting migration {}", self.name);
|
||||
for step in self.down {
|
||||
step.run(conn, path)?
|
||||
}
|
||||
@@ -104,7 +105,8 @@ impl ImportedMigrations {
|
||||
pub fn rerun_last_migration(&self, conn: &Connection, path: &Path) -> Result<()> {
|
||||
let latest_migration = conn.latest_run_migration_version()?;
|
||||
let id = latest_migration
|
||||
.and_then(|m| self.0.binary_search_by_key(&m.as_str(), |m| m.name).ok())?;
|
||||
.and_then(|m| self.0.binary_search_by_key(&m.as_str(), |m| m.name).ok())
|
||||
.ok_or(Error::NotFound)?;
|
||||
let migration = &self.0[id];
|
||||
conn.transaction(|| {
|
||||
migration.revert(conn, path)?;
|
||||
|
||||
@@ -61,7 +61,7 @@ impl PasswordResetRequest {
|
||||
}
|
||||
|
||||
pub fn find_and_delete_by_token(conn: &Connection, token: &str) -> Result<Self> {
|
||||
let request = Self::find_by_token(&conn, &token)?;
|
||||
let request = Self::find_by_token(conn, token)?;
|
||||
|
||||
let filter =
|
||||
password_reset_requests::table.filter(password_reset_requests::id.eq(request.id));
|
||||
@@ -89,7 +89,7 @@ mod tests {
|
||||
let request = PasswordResetRequest::find_by_token(&conn, &token)
|
||||
.expect("couldn't retrieve request");
|
||||
|
||||
assert!(&token.len() > &32);
|
||||
assert!(token.len() > 32);
|
||||
assert_eq!(&request.email, &admin_email);
|
||||
|
||||
Ok(())
|
||||
@@ -103,8 +103,8 @@ mod tests {
|
||||
user_tests::fill_database(&conn);
|
||||
let admin_email = "admin@example.com";
|
||||
|
||||
PasswordResetRequest::insert(&conn, &admin_email).expect("couldn't insert new request");
|
||||
PasswordResetRequest::insert(&conn, &admin_email)
|
||||
PasswordResetRequest::insert(&conn, admin_email).expect("couldn't insert new request");
|
||||
PasswordResetRequest::insert(&conn, admin_email)
|
||||
.expect("couldn't insert second request");
|
||||
|
||||
let count = password_reset_requests::table.count().get_result(&*conn);
|
||||
@@ -132,7 +132,7 @@ mod tests {
|
||||
.execute(&*conn)
|
||||
.expect("could not insert request");
|
||||
|
||||
match PasswordResetRequest::find_by_token(&conn, &token) {
|
||||
match PasswordResetRequest::find_by_token(&conn, token) {
|
||||
Err(Error::Expired) => (),
|
||||
_ => panic!("Received unexpected result finding expired token"),
|
||||
}
|
||||
@@ -148,7 +148,7 @@ mod tests {
|
||||
user_tests::fill_database(&conn);
|
||||
let admin_email = "admin@example.com";
|
||||
|
||||
let token = PasswordResetRequest::insert(&conn, &admin_email)
|
||||
let token = PasswordResetRequest::insert(&conn, admin_email)
|
||||
.expect("couldn't insert new request");
|
||||
PasswordResetRequest::find_and_delete_by_token(&conn, &token)
|
||||
.expect("couldn't find and delete request");
|
||||
|
||||
@@ -2,7 +2,7 @@ pub use self::module::PlumeRocket;
|
||||
|
||||
#[cfg(not(test))]
|
||||
mod module {
|
||||
use crate::{db_conn::DbConn, search, users};
|
||||
use crate::{search, users};
|
||||
use rocket::{
|
||||
request::{self, FlashMessage, FromRequest, Request},
|
||||
Outcome, State,
|
||||
@@ -12,7 +12,6 @@ mod module {
|
||||
|
||||
/// Common context needed by most routes and operations on models
|
||||
pub struct PlumeRocket {
|
||||
pub conn: DbConn,
|
||||
pub intl: rocket_i18n::I18n,
|
||||
pub user: Option<users::User>,
|
||||
pub searcher: Arc<search::Searcher>,
|
||||
@@ -24,14 +23,12 @@ mod module {
|
||||
type Error = ();
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<PlumeRocket, ()> {
|
||||
let conn = request.guard::<DbConn>()?;
|
||||
let intl = request.guard::<rocket_i18n::I18n>()?;
|
||||
let user = request.guard::<users::User>().succeeded();
|
||||
let worker = request.guard::<'_, State<'_, Arc<ScheduledThreadPool>>>()?;
|
||||
let searcher = request.guard::<'_, State<'_, Arc<search::Searcher>>>()?;
|
||||
let flash_msg = request.guard::<FlashMessage<'_, '_>>().succeeded();
|
||||
Outcome::Success(PlumeRocket {
|
||||
conn,
|
||||
intl,
|
||||
user,
|
||||
flash_msg: flash_msg.map(|f| (f.name().into(), f.msg().into())),
|
||||
@@ -44,7 +41,7 @@ mod module {
|
||||
|
||||
#[cfg(test)]
|
||||
mod module {
|
||||
use crate::{db_conn::DbConn, search, users};
|
||||
use crate::{search, users};
|
||||
use rocket::{
|
||||
request::{self, FromRequest, Request},
|
||||
Outcome, State,
|
||||
@@ -54,7 +51,6 @@ mod module {
|
||||
|
||||
/// Common context needed by most routes and operations on models
|
||||
pub struct PlumeRocket {
|
||||
pub conn: DbConn,
|
||||
pub user: Option<users::User>,
|
||||
pub searcher: Arc<search::Searcher>,
|
||||
pub worker: Arc<ScheduledThreadPool>,
|
||||
@@ -64,12 +60,10 @@ mod module {
|
||||
type Error = ();
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<PlumeRocket, ()> {
|
||||
let conn = request.guard::<DbConn>()?;
|
||||
let user = request.guard::<users::User>().succeeded();
|
||||
let worker = request.guard::<'_, State<'_, Arc<ScheduledThreadPool>>>()?;
|
||||
let searcher = request.guard::<'_, State<'_, Arc<search::Searcher>>>()?;
|
||||
Outcome::Success(PlumeRocket {
|
||||
conn,
|
||||
user,
|
||||
worker: worker.clone(),
|
||||
searcher: searcher.clone(),
|
||||
|
||||
+662
-290
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
use crate::{
|
||||
db_conn::{DbConn, DbPool},
|
||||
follows,
|
||||
posts::Post,
|
||||
users::{User, UserEvent},
|
||||
ACTOR_SYS, CONFIG, USER_CHAN,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{ActorAndObjectRef, Create},
|
||||
base::AnyBase,
|
||||
object::kind::ArticleType,
|
||||
};
|
||||
use plume_common::activity_pub::{inbox::FromId, LicensedArticle};
|
||||
use riker::actors::{Actor, ActorFactoryArgs, ActorRefFactory, Context, Sender, Subscribe, Tell};
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub struct RemoteFetchActor {
|
||||
conn: DbPool,
|
||||
}
|
||||
|
||||
impl RemoteFetchActor {
|
||||
pub fn init(conn: DbPool) {
|
||||
let actor = ACTOR_SYS
|
||||
.actor_of_args::<RemoteFetchActor, _>("remote-fetch", conn)
|
||||
.expect("Failed to initialize remote fetch actor");
|
||||
|
||||
USER_CHAN.tell(
|
||||
Subscribe {
|
||||
actor: Box::new(actor),
|
||||
topic: "*".into(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for RemoteFetchActor {
|
||||
type Msg = UserEvent;
|
||||
|
||||
fn recv(&mut self, _ctx: &Context<Self::Msg>, msg: Self::Msg, _sender: Sender) {
|
||||
use UserEvent::*;
|
||||
|
||||
match msg {
|
||||
RemoteUserFound(user) => match self.conn.get() {
|
||||
Ok(conn) => {
|
||||
let conn = DbConn(conn);
|
||||
if user
|
||||
.get_instance(&conn)
|
||||
.map_or(false, |instance| instance.blocked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Don't call these functions in parallel
|
||||
// for the case database connections limit is too small
|
||||
fetch_and_cache_articles(&user, &conn);
|
||||
fetch_and_cache_followers(&user, &conn);
|
||||
if user.needs_update() {
|
||||
fetch_and_cache_user(&user, &conn);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
error!("Failed to get database connection");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorFactoryArgs<DbPool> for RemoteFetchActor {
|
||||
fn create_args(conn: DbPool) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_and_cache_articles(user: &Arc<User>, conn: &DbConn) {
|
||||
let create_acts = user.fetch_outbox::<Create>();
|
||||
match create_acts {
|
||||
Ok(create_acts) => {
|
||||
for create_act in create_acts {
|
||||
match create_act.object_field_ref().as_single_base().map(|base| {
|
||||
let any_base = AnyBase::from_base(base.clone()); // FIXME: Don't clone()
|
||||
any_base.extend::<LicensedArticle, ArticleType>()
|
||||
}) {
|
||||
Some(Ok(Some(article))) => {
|
||||
Post::from_activity(conn, article)
|
||||
.expect("Article from remote user couldn't be saved");
|
||||
info!("Fetched article from remote user");
|
||||
}
|
||||
Some(Err(e)) => warn!("Error while fetching articles in background: {:?}", e),
|
||||
_ => warn!("Error while fetching articles in background"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to fetch outboxes: {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_and_cache_followers(user: &Arc<User>, conn: &DbConn) {
|
||||
let follower_ids = user.fetch_followers_ids();
|
||||
match follower_ids {
|
||||
Ok(user_ids) => {
|
||||
for user_id in user_ids {
|
||||
let follower = User::from_id(conn, &user_id, None, CONFIG.proxy());
|
||||
match follower {
|
||||
Ok(follower) => {
|
||||
let inserted = follows::Follow::insert(
|
||||
conn,
|
||||
follows::NewFollow {
|
||||
follower_id: follower.id,
|
||||
following_id: user.id,
|
||||
ap_url: String::new(),
|
||||
},
|
||||
);
|
||||
if inserted.is_err() {
|
||||
error!("Couldn't save follower for remote user: {:?}", user_id);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Couldn't fetch follower: {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to fetch follower: {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_and_cache_user(user: &Arc<User>, conn: &DbConn) {
|
||||
if user.refetch(conn).is_err() {
|
||||
error!("Couldn't update user info: {:?}", user);
|
||||
}
|
||||
}
|
||||
+138
-46
@@ -1,13 +1,19 @@
|
||||
use crate::{
|
||||
notifications::*, posts::Post, schema::reshares, timeline::*, users::User, Connection, Error,
|
||||
PlumeRocket, Result,
|
||||
instance::Instance, notifications::*, posts::Post, schema::reshares, timeline::*, users::User,
|
||||
Connection, Error, Result, CONFIG,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{ActorAndObjectRef, Announce, Undo},
|
||||
base::AnyBase,
|
||||
iri_string::types::IriString,
|
||||
prelude::*,
|
||||
};
|
||||
use activitypub::activity::{Announce, Undo};
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use plume_common::activity_pub::{
|
||||
inbox::{AsActor, AsObject, FromId},
|
||||
Id, IntoId, PUBLIC_VISIBILITY,
|
||||
sign::Signer,
|
||||
PUBLIC_VISIBILITY,
|
||||
};
|
||||
|
||||
#[derive(Clone, Queryable, Identifiable)]
|
||||
@@ -60,16 +66,16 @@ impl Reshare {
|
||||
}
|
||||
|
||||
pub fn to_activity(&self, conn: &Connection) -> Result<Announce> {
|
||||
let mut act = Announce::default();
|
||||
act.announce_props
|
||||
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
|
||||
act.announce_props
|
||||
.set_object_link(Post::get(conn, self.post_id)?.into_id())?;
|
||||
act.object_props.set_id_string(self.ap_url.clone())?;
|
||||
act.object_props
|
||||
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
|
||||
act.object_props
|
||||
.set_cc_link_vec(vec![Id::new(self.get_user(conn)?.followers_endpoint)])?;
|
||||
let mut act = Announce::new(
|
||||
User::get(conn, self.user_id)?.ap_url.parse::<IriString>()?,
|
||||
Post::get(conn, self.post_id)?.ap_url.parse::<IriString>()?,
|
||||
);
|
||||
act.set_id(self.ap_url.parse::<IriString>()?);
|
||||
act.set_many_tos(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
|
||||
act.set_many_ccs(vec![self
|
||||
.get_user(conn)?
|
||||
.followers_endpoint
|
||||
.parse::<IriString>()?]);
|
||||
|
||||
Ok(act)
|
||||
}
|
||||
@@ -92,27 +98,27 @@ impl Reshare {
|
||||
}
|
||||
|
||||
pub fn build_undo(&self, conn: &Connection) -> Result<Undo> {
|
||||
let mut act = Undo::default();
|
||||
act.undo_props
|
||||
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
|
||||
act.undo_props.set_object_object(self.to_activity(conn)?)?;
|
||||
act.object_props
|
||||
.set_id_string(format!("{}#delete", self.ap_url))?;
|
||||
act.object_props
|
||||
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
|
||||
act.object_props
|
||||
.set_cc_link_vec(vec![Id::new(self.get_user(conn)?.followers_endpoint)])?;
|
||||
let mut act = Undo::new(
|
||||
User::get(conn, self.user_id)?.ap_url.parse::<IriString>()?,
|
||||
AnyBase::from_extended(self.to_activity(conn)?)?,
|
||||
);
|
||||
act.set_id(format!("{}#delete", self.ap_url).parse::<IriString>()?);
|
||||
act.set_many_tos(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
|
||||
act.set_many_ccs(vec![self
|
||||
.get_user(conn)?
|
||||
.followers_endpoint
|
||||
.parse::<IriString>()?]);
|
||||
|
||||
Ok(act)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsObject<User, Announce, &PlumeRocket> for Post {
|
||||
impl AsObject<User, Announce, &Connection> for Post {
|
||||
type Error = Error;
|
||||
type Output = Reshare;
|
||||
|
||||
fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result<Reshare> {
|
||||
let conn = &*c.conn;
|
||||
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Reshare> {
|
||||
let conn = conn;
|
||||
let reshare = Reshare::insert(
|
||||
conn,
|
||||
NewReshare {
|
||||
@@ -123,48 +129,70 @@ impl AsObject<User, Announce, &PlumeRocket> for Post {
|
||||
)?;
|
||||
reshare.notify(conn)?;
|
||||
|
||||
Timeline::add_to_all_timelines(c, &self, Kind::Reshare(&actor))?;
|
||||
Timeline::add_to_all_timelines(conn, &self, Kind::Reshare(&actor))?;
|
||||
Ok(reshare)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromId<PlumeRocket> for Reshare {
|
||||
impl FromId<Connection> for Reshare {
|
||||
type Error = Error;
|
||||
type Object = Announce;
|
||||
|
||||
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
|
||||
Reshare::find_by_ap_url(&c.conn, id)
|
||||
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
|
||||
Reshare::find_by_ap_url(conn, id)
|
||||
}
|
||||
|
||||
fn from_activity(c: &PlumeRocket, act: Announce) -> Result<Self> {
|
||||
fn from_activity(conn: &Connection, act: Announce) -> Result<Self> {
|
||||
let res = Reshare::insert(
|
||||
&c.conn,
|
||||
conn,
|
||||
NewReshare {
|
||||
post_id: Post::from_id(c, &act.announce_props.object_link::<Id>()?, None)
|
||||
.map_err(|(_, e)| e)?
|
||||
.id,
|
||||
user_id: User::from_id(c, &act.announce_props.actor_link::<Id>()?, None)
|
||||
.map_err(|(_, e)| e)?
|
||||
.id,
|
||||
ap_url: act.object_props.id_string()?,
|
||||
post_id: Post::from_id(
|
||||
conn,
|
||||
act.object_field_ref()
|
||||
.as_single_id()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.as_str(),
|
||||
None,
|
||||
CONFIG.proxy(),
|
||||
)
|
||||
.map_err(|(_, e)| e)?
|
||||
.id,
|
||||
user_id: User::from_id(
|
||||
conn,
|
||||
act.actor_field_ref()
|
||||
.as_single_id()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.as_str(),
|
||||
None,
|
||||
CONFIG.proxy(),
|
||||
)
|
||||
.map_err(|(_, e)| e)?
|
||||
.id,
|
||||
ap_url: act
|
||||
.id_unchecked()
|
||||
.ok_or(Error::MissingApProperty)?
|
||||
.to_string(),
|
||||
},
|
||||
)?;
|
||||
res.notify(&c.conn)?;
|
||||
res.notify(conn)?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
fn get_sender() -> &'static dyn Signer {
|
||||
Instance::get_local_instance_user().expect("Failed to local instance user")
|
||||
}
|
||||
}
|
||||
|
||||
impl AsObject<User, Undo, &PlumeRocket> for Reshare {
|
||||
impl AsObject<User, Undo, &Connection> for Reshare {
|
||||
type Error = Error;
|
||||
type Output = ();
|
||||
|
||||
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
|
||||
let conn = &*c.conn;
|
||||
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
|
||||
if actor.id == self.user_id {
|
||||
diesel::delete(&self).execute(conn)?;
|
||||
|
||||
// delete associated notification if any
|
||||
if let Ok(notif) = Notification::find(&conn, notification_kind::RESHARE, self.id) {
|
||||
if let Ok(notif) = Notification::find(conn, notification_kind::RESHARE, self.id) {
|
||||
diesel::delete(¬if).execute(conn)?;
|
||||
}
|
||||
|
||||
@@ -177,7 +205,7 @@ impl AsObject<User, Undo, &PlumeRocket> for Reshare {
|
||||
|
||||
impl NewReshare {
|
||||
pub fn new(p: &Post, u: &User) -> Self {
|
||||
let ap_url = format!("{}/reshare/{}", u.ap_url, p.ap_url);
|
||||
let ap_url = format!("{}reshare/{}", u.ap_url, p.ap_url);
|
||||
NewReshare {
|
||||
post_id: p.id,
|
||||
user_id: u.id,
|
||||
@@ -185,3 +213,67 @@ impl NewReshare {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::diesel::Connection;
|
||||
use crate::{inbox::tests::fill_database, tests::db};
|
||||
use assert_json_diff::assert_json_eq;
|
||||
use serde_json::{json, to_value};
|
||||
|
||||
#[test]
|
||||
fn to_activity() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (posts, _users, _blogs) = fill_database(&conn);
|
||||
let post = &posts[0];
|
||||
let user = &post.get_authors(&conn)?[0];
|
||||
let reshare = Reshare::insert(&conn, NewReshare::new(post, user))?;
|
||||
let act = reshare.to_activity(&conn).unwrap();
|
||||
|
||||
let expected = json!({
|
||||
"actor": "https://plu.me/@/admin/",
|
||||
"cc": ["https://plu.me/@/admin/followers"],
|
||||
"id": "https://plu.me/@/admin/reshare/https://plu.me/~/BlogName/testing",
|
||||
"object": "https://plu.me/~/BlogName/testing",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Announce",
|
||||
});
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_undo() {
|
||||
let conn = db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (posts, _users, _blogs) = fill_database(&conn);
|
||||
let post = &posts[0];
|
||||
let user = &post.get_authors(&conn)?[0];
|
||||
let reshare = Reshare::insert(&conn, NewReshare::new(post, user))?;
|
||||
let act = reshare.build_undo(&conn)?;
|
||||
|
||||
let expected = json!({
|
||||
"actor": "https://plu.me/@/admin/",
|
||||
"cc": ["https://plu.me/@/admin/followers"],
|
||||
"id": "https://plu.me/@/admin/reshare/https://plu.me/~/BlogName/testing#delete",
|
||||
"object": {
|
||||
"actor": "https://plu.me/@/admin/",
|
||||
"cc": ["https://plu.me/@/admin/followers"],
|
||||
"id": "https://plu.me/@/admin/reshare/https://plu.me/~/BlogName/testing",
|
||||
"object": "https://plu.me/~/BlogName/testing",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Announce"
|
||||
},
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"type": "Undo",
|
||||
});
|
||||
assert_json_eq!(to_value(act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,13 @@ use std::{
|
||||
borrow::{Borrow, Cow},
|
||||
fmt::{self, Display},
|
||||
io::Write,
|
||||
iter,
|
||||
ops::Deref,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
static ref CLEAN: Builder<'static> = {
|
||||
let mut b = Builder::new();
|
||||
b.add_generic_attributes(iter::once("id"))
|
||||
b.add_generic_attributes(&["id", "dir"])
|
||||
.add_tags(&["iframe", "video", "audio", "label", "input"])
|
||||
.id_prefix(Some("postcontent-"))
|
||||
.url_relative(UrlRelative::Custom(Box::new(url_add_prefix)))
|
||||
@@ -82,6 +81,7 @@ lazy_static! {
|
||||
};
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn url_add_prefix(url: &str) -> Option<Cow<'_, str>> {
|
||||
if url.starts_with('#') && !url.starts_with("#postcontent-") {
|
||||
//if start with an #
|
||||
@@ -93,7 +93,7 @@ fn url_add_prefix(url: &str) -> Option<Cow<'_, str>> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, AsExpression, FromSqlRow, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow, Default)]
|
||||
#[sql_type = "Text"]
|
||||
pub struct SafeString {
|
||||
value: String,
|
||||
@@ -102,7 +102,7 @@ pub struct SafeString {
|
||||
impl SafeString {
|
||||
pub fn new(value: &str) -> Self {
|
||||
SafeString {
|
||||
value: CLEAN.clean(&value).to_string(),
|
||||
value: CLEAN.clean(value).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ impl<'de> Deserialize<'de> for SafeString {
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Ok(deserializer.deserialize_string(SafeStringVisitor)?)
|
||||
deserializer.deserialize_string(SafeStringVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,16 +73,26 @@ table! {
|
||||
user_id -> Int4,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
email_blocklist(id){
|
||||
email_blocklist (id) {
|
||||
id -> Int4,
|
||||
email_address -> VarChar,
|
||||
email_address -> Text,
|
||||
note -> Text,
|
||||
notify_user -> Bool,
|
||||
notification_text -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
email_signups (id) {
|
||||
id -> Int4,
|
||||
email -> Varchar,
|
||||
token -> Varchar,
|
||||
expiration_date -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
follows (id) {
|
||||
id -> Int4,
|
||||
@@ -306,6 +316,8 @@ allow_tables_to_appear_in_same_query!(
|
||||
blogs,
|
||||
comments,
|
||||
comment_seers,
|
||||
email_blocklist,
|
||||
email_signups,
|
||||
follows,
|
||||
instances,
|
||||
likes,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
use super::Searcher;
|
||||
use crate::{db_conn::DbPool, posts::PostEvent, ACTOR_SYS, POST_CHAN};
|
||||
use riker::actors::{Actor, ActorFactoryArgs, ActorRefFactory, Context, Sender, Subscribe, Tell};
|
||||
use std::sync::Arc;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
use tracing::error;
|
||||
|
||||
pub struct SearchActor {
|
||||
searcher: Arc<Searcher>,
|
||||
conn: DbPool,
|
||||
}
|
||||
|
||||
impl SearchActor {
|
||||
pub fn init(searcher: Arc<Searcher>, conn: DbPool) {
|
||||
let actor = ACTOR_SYS
|
||||
.actor_of_args::<SearchActor, _>("search", (searcher, conn))
|
||||
.expect("Failed to initialize searcher actor");
|
||||
|
||||
POST_CHAN.tell(
|
||||
Subscribe {
|
||||
actor: Box::new(actor),
|
||||
topic: "*".into(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for SearchActor {
|
||||
type Msg = PostEvent;
|
||||
|
||||
fn recv(&mut self, _ctx: &Context<Self::Msg>, msg: Self::Msg, _sender: Sender) {
|
||||
use PostEvent::*;
|
||||
|
||||
// Wait for transaction commited
|
||||
sleep(Duration::from_millis(500));
|
||||
|
||||
match msg {
|
||||
PostPublished(post) => {
|
||||
let conn = self.conn.get();
|
||||
match conn {
|
||||
Ok(conn) => {
|
||||
self.searcher
|
||||
.add_document(&conn, &post)
|
||||
.unwrap_or_else(|e| error!("{:?}", e));
|
||||
}
|
||||
_ => {
|
||||
error!("Failed to get database connection");
|
||||
}
|
||||
}
|
||||
}
|
||||
PostUpdated(post) => {
|
||||
let conn = self.conn.get();
|
||||
match conn {
|
||||
Ok(_) => {
|
||||
self.searcher
|
||||
.update_document(&conn.unwrap(), &post)
|
||||
.unwrap_or_else(|e| error!("{:?}", e));
|
||||
}
|
||||
_ => {
|
||||
error!("Failed to get database connection");
|
||||
}
|
||||
}
|
||||
}
|
||||
PostDeleted(post) => self.searcher.delete_document(&post),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorFactoryArgs<(Arc<Searcher>, DbPool)> for SearchActor {
|
||||
fn create_args((searcher, conn): (Arc<Searcher>, DbPool)) -> Self {
|
||||
Self { searcher, conn }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::diesel::Connection;
|
||||
use crate::{
|
||||
blog_authors::{BlogAuthor, NewBlogAuthor},
|
||||
blogs::{Blog, NewBlog},
|
||||
db_conn::{DbPool, PragmaForeignKey},
|
||||
instance::{Instance, NewInstance},
|
||||
post_authors::{NewPostAuthor, PostAuthor},
|
||||
posts::{NewPost, Post},
|
||||
safe_string::SafeString,
|
||||
search::{actor::SearchActor, tests::get_searcher, Query},
|
||||
users::{NewUser, User},
|
||||
Connection as Conn, CONFIG,
|
||||
};
|
||||
use diesel::r2d2::ConnectionManager;
|
||||
use plume_common::utils::random_hex;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn post_updated() {
|
||||
// Need to commit so that searcher on another thread retrieve records.
|
||||
// So, build DbPool instead of using DB_POOL for testing.
|
||||
let manager = ConnectionManager::<Conn>::new(CONFIG.database_url.as_str());
|
||||
let db_pool = DbPool::builder()
|
||||
.connection_customizer(Box::new(PragmaForeignKey))
|
||||
.build(manager)
|
||||
.unwrap();
|
||||
|
||||
let searcher = Arc::new(get_searcher(&CONFIG.search_tokenizers));
|
||||
SearchActor::init(searcher.clone(), db_pool.clone());
|
||||
let conn = db_pool.get().unwrap();
|
||||
|
||||
let title = random_hex()[..8].to_owned();
|
||||
let (_instance, _user, blog) = fill_database(&conn);
|
||||
let author = &blog.list_authors(&conn).unwrap()[0];
|
||||
|
||||
let post = Post::insert(
|
||||
&conn,
|
||||
NewPost {
|
||||
blog_id: blog.id,
|
||||
slug: title.clone(),
|
||||
title: title.clone(),
|
||||
content: SafeString::new(""),
|
||||
published: true,
|
||||
license: "CC-BY-SA".to_owned(),
|
||||
ap_url: "".to_owned(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_owned(),
|
||||
source: "".to_owned(),
|
||||
cover_id: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
PostAuthor::insert(
|
||||
&conn,
|
||||
NewPostAuthor {
|
||||
post_id: post.id,
|
||||
author_id: author.id,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let post_id = post.id;
|
||||
|
||||
// Wait for searcher on another thread add document asynchronously
|
||||
sleep(Duration::from_millis(700));
|
||||
searcher.commit();
|
||||
assert_eq!(
|
||||
searcher.search_document(&conn, Query::from_str(&title).unwrap(), (0, 1))[0].id,
|
||||
post_id
|
||||
);
|
||||
}
|
||||
|
||||
fn fill_database(conn: &Conn) -> (Instance, User, Blog) {
|
||||
conn.transaction::<(Instance, User, Blog), diesel::result::Error, _>(|| {
|
||||
let instance = Instance::insert(
|
||||
conn,
|
||||
NewInstance {
|
||||
default_license: "CC-0-BY-SA".to_string(),
|
||||
local: false,
|
||||
long_description: SafeString::new("Good morning"),
|
||||
long_description_html: "<p>Good morning</p>".to_string(),
|
||||
short_description: SafeString::new("Hello"),
|
||||
short_description_html: "<p>Hello</p>".to_string(),
|
||||
name: random_hex(),
|
||||
open_registrations: true,
|
||||
public_domain: random_hex(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let user = User::insert(
|
||||
conn,
|
||||
NewUser {
|
||||
username: random_hex(),
|
||||
display_name: random_hex(),
|
||||
outbox_url: random_hex(),
|
||||
inbox_url: random_hex(),
|
||||
summary: "".to_string(),
|
||||
email: None,
|
||||
hashed_password: None,
|
||||
instance_id: instance.id,
|
||||
ap_url: random_hex(),
|
||||
private_key: None,
|
||||
public_key: "".to_string(),
|
||||
shared_inbox_url: None,
|
||||
followers_endpoint: random_hex(),
|
||||
avatar_id: None,
|
||||
summary_html: SafeString::new(""),
|
||||
role: 0,
|
||||
fqn: random_hex(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let blog = NewBlog {
|
||||
instance_id: instance.id,
|
||||
actor_id: random_hex(),
|
||||
ap_url: random_hex(),
|
||||
inbox_url: random_hex(),
|
||||
outbox_url: random_hex(),
|
||||
..Default::default()
|
||||
};
|
||||
let blog = Blog::insert(conn, blog).unwrap();
|
||||
BlogAuthor::insert(
|
||||
conn,
|
||||
NewBlogAuthor {
|
||||
blog_id: blog.id,
|
||||
author_id: user.id,
|
||||
is_owner: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Ok((instance, user, blog))
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod actor;
|
||||
mod query;
|
||||
mod searcher;
|
||||
mod tokenizer;
|
||||
@@ -7,12 +8,7 @@ pub use self::tokenizer::TokenizerKind;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::{Query, Searcher, TokenizerKind};
|
||||
use diesel::Connection;
|
||||
use plume_common::utils::random_hex;
|
||||
use std::env::temp_dir;
|
||||
use std::str::FromStr;
|
||||
|
||||
use super::{Query, Searcher};
|
||||
use crate::{
|
||||
blogs::tests::fill_database,
|
||||
config::SearchTokenizerConfig,
|
||||
@@ -22,6 +18,10 @@ pub(crate) mod tests {
|
||||
tests::db,
|
||||
CONFIG,
|
||||
};
|
||||
use diesel::Connection;
|
||||
use plume_common::utils::random_hex;
|
||||
use std::env::temp_dir;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub(crate) fn get_searcher(tokenizers: &SearchTokenizerConfig) -> Searcher {
|
||||
let dir = temp_dir().join(&format!("plume-test-{}", random_hex()));
|
||||
@@ -144,7 +144,6 @@ pub(crate) mod tests {
|
||||
source: "".to_owned(),
|
||||
cover_id: None,
|
||||
},
|
||||
&searcher,
|
||||
)
|
||||
.unwrap();
|
||||
PostAuthor::insert(
|
||||
@@ -155,7 +154,7 @@ pub(crate) mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
searcher.add_document(conn, &post).unwrap();
|
||||
searcher.commit();
|
||||
assert_eq!(
|
||||
searcher.search_document(conn, Query::from_str(&title).unwrap(), (0, 1))[0].id,
|
||||
@@ -164,7 +163,8 @@ pub(crate) mod tests {
|
||||
|
||||
let newtitle = random_hex()[..8].to_owned();
|
||||
post.title = newtitle.clone();
|
||||
post.update(conn, &searcher).unwrap();
|
||||
post.update(conn).unwrap();
|
||||
searcher.update_document(conn, &post).unwrap();
|
||||
searcher.commit();
|
||||
assert_eq!(
|
||||
searcher.search_document(conn, Query::from_str(&newtitle).unwrap(), (0, 1))[0].id,
|
||||
@@ -174,7 +174,7 @@ pub(crate) mod tests {
|
||||
.search_document(conn, Query::from_str(&title).unwrap(), (0, 1))
|
||||
.is_empty());
|
||||
|
||||
post.delete(conn, &searcher).unwrap();
|
||||
searcher.delete_document(&post);
|
||||
searcher.commit();
|
||||
assert!(searcher
|
||||
.search_document(conn, Query::from_str(&newtitle).unwrap(), (0, 1))
|
||||
@@ -213,7 +213,6 @@ pub(crate) mod tests {
|
||||
source: "".to_owned(),
|
||||
cover_id: None,
|
||||
},
|
||||
&searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ macro_rules! gen_to_string {
|
||||
)*
|
||||
$(
|
||||
for val in &$self.$date {
|
||||
$result.push_str(&format!("{}:{} ", stringify!($date), NaiveDate::from_num_days_from_ce(*val as i32).format("%Y-%m-%d")));
|
||||
$result.push_str(&format!("{}:{} ", stringify!($date), NaiveDate::from_num_days_from_ce_opt(*val as i32).unwrap().format("%Y-%m-%d")));
|
||||
}
|
||||
)*
|
||||
}
|
||||
@@ -148,7 +148,7 @@ impl PlumeQuery {
|
||||
|
||||
/// Parse a query string into this Query
|
||||
pub fn parse_query(&mut self, query: &str) -> &mut Self {
|
||||
self.from_str_req(&query.trim())
|
||||
self.from_str_req(query.trim())
|
||||
}
|
||||
|
||||
/// Convert this Query to a Tantivy Query
|
||||
@@ -180,12 +180,16 @@ impl PlumeQuery {
|
||||
|
||||
if self.before.is_some() || self.after.is_some() {
|
||||
// if at least one range bound is provided
|
||||
let after = self
|
||||
.after
|
||||
.unwrap_or_else(|| i64::from(NaiveDate::from_ymd(2000, 1, 1).num_days_from_ce()));
|
||||
let after = self.after.unwrap_or_else(|| {
|
||||
i64::from(
|
||||
NaiveDate::from_ymd_opt(2000, 1, 1)
|
||||
.unwrap()
|
||||
.num_days_from_ce(),
|
||||
)
|
||||
});
|
||||
let before = self
|
||||
.before
|
||||
.unwrap_or_else(|| i64::from(Utc::today().num_days_from_ce()));
|
||||
.unwrap_or_else(|| i64::from(Utc::now().date_naive().num_days_from_ce()));
|
||||
let field = Searcher::schema().get_field("creation_date").unwrap();
|
||||
let range =
|
||||
RangeQuery::new_i64_bounds(field, Bound::Included(after), Bound::Included(before));
|
||||
@@ -202,16 +206,20 @@ impl PlumeQuery {
|
||||
pub fn before<D: Datelike>(&mut self, date: &D) -> &mut Self {
|
||||
let before = self
|
||||
.before
|
||||
.unwrap_or_else(|| i64::from(Utc::today().num_days_from_ce()));
|
||||
.unwrap_or_else(|| i64::from(Utc::now().date_naive().num_days_from_ce()));
|
||||
self.before = Some(cmp::min(before, i64::from(date.num_days_from_ce())));
|
||||
self
|
||||
}
|
||||
|
||||
// documents older than the provided date will be ignored
|
||||
pub fn after<D: Datelike>(&mut self, date: &D) -> &mut Self {
|
||||
let after = self
|
||||
.after
|
||||
.unwrap_or_else(|| i64::from(NaiveDate::from_ymd(2000, 1, 1).num_days_from_ce()));
|
||||
let after = self.after.unwrap_or_else(|| {
|
||||
i64::from(
|
||||
NaiveDate::from_ymd_opt(2000, 1, 1)
|
||||
.unwrap()
|
||||
.num_days_from_ce(),
|
||||
)
|
||||
});
|
||||
self.after = Some(cmp::max(after, i64::from(date.num_days_from_ce())));
|
||||
self
|
||||
}
|
||||
@@ -360,7 +368,7 @@ impl std::str::FromStr for PlumeQuery {
|
||||
fn from_str(query: &str) -> Result<PlumeQuery, !> {
|
||||
let mut res: PlumeQuery = Default::default();
|
||||
|
||||
res.from_str_req(&query.trim());
|
||||
res.from_str_req(query.trim());
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
use crate::{
|
||||
config::SearchTokenizerConfig, instance::Instance, posts::Post, schema::posts,
|
||||
search::query::PlumeQuery, tags::Tag, Connection, Result,
|
||||
search::query::PlumeQuery, tags::Tag, Connection, Error, Result,
|
||||
};
|
||||
use chrono::Datelike;
|
||||
use chrono::{Datelike, Utc};
|
||||
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use itertools::Itertools;
|
||||
use std::{cmp, fs::create_dir_all, path::Path, sync::Mutex};
|
||||
use std::fs;
|
||||
use std::{cmp, fs::create_dir_all, io, path::Path, sync::Mutex};
|
||||
use tantivy::{
|
||||
collector::TopDocs, directory::MmapDirectory, schema::*, Index, IndexReader, IndexWriter,
|
||||
ReloadPolicy, Term,
|
||||
ReloadPolicy, TantivyError, Term,
|
||||
};
|
||||
use tracing::warn;
|
||||
use whatlang::{detect as detect_lang, Lang};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -18,6 +20,7 @@ pub enum SearcherError {
|
||||
WriteLockAcquisitionError,
|
||||
IndexOpeningError,
|
||||
IndexEditionError,
|
||||
InvalidIndexDataError,
|
||||
}
|
||||
|
||||
pub struct Searcher {
|
||||
@@ -66,6 +69,58 @@ impl Searcher {
|
||||
schema_builder.build()
|
||||
}
|
||||
|
||||
pub fn open_or_recreate(path: &dyn AsRef<Path>, tokenizers: &SearchTokenizerConfig) -> Self {
|
||||
let mut open_searcher = Self::open(path, tokenizers);
|
||||
if let Err(Error::Search(SearcherError::InvalidIndexDataError)) = open_searcher {
|
||||
if Self::create(path, tokenizers).is_err() {
|
||||
let backup_path = format!("{}.{}", path.as_ref().display(), Utc::now().timestamp());
|
||||
let backup_path = Path::new(&backup_path);
|
||||
fs::rename(path, backup_path)
|
||||
.expect("main: error on backing up search index directory for recreating");
|
||||
if Self::create(path, tokenizers).is_ok() {
|
||||
if fs::remove_dir_all(backup_path).is_err() {
|
||||
warn!(
|
||||
"error on removing backup directory: {}. it remains",
|
||||
backup_path.display()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
panic!("main: error on recreating search index in new index format. remove search index and run `plm search init` manually");
|
||||
}
|
||||
}
|
||||
open_searcher = Self::open(path, tokenizers);
|
||||
}
|
||||
match open_searcher {
|
||||
Ok(s) => s,
|
||||
Err(Error::Search(e)) => match e {
|
||||
SearcherError::WriteLockAcquisitionError => panic!(
|
||||
r#"
|
||||
Your search index is locked. Plume can't start. To fix this issue
|
||||
make sure no other Plume instance is started, and run:
|
||||
|
||||
plm search unlock
|
||||
|
||||
Then try to restart Plume.
|
||||
"#
|
||||
),
|
||||
SearcherError::IndexOpeningError => panic!(
|
||||
r#"
|
||||
Plume was unable to open the search index. If you created the index
|
||||
before, make sure to run Plume in the same directory it was created in, or
|
||||
to set SEARCH_INDEX accordingly. If you did not yet create the search
|
||||
index, run this command:
|
||||
|
||||
plm search init
|
||||
|
||||
Then try to restart Plume
|
||||
"#
|
||||
),
|
||||
e => Err(e).unwrap(),
|
||||
},
|
||||
_ => panic!("Unexpected error while opening search index"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create(path: &dyn AsRef<Path>, tokenizers: &SearchTokenizerConfig) -> Result<Self> {
|
||||
let schema = Self::schema();
|
||||
|
||||
@@ -135,7 +190,19 @@ impl Searcher {
|
||||
.reader_builder()
|
||||
.reload_policy(ReloadPolicy::Manual)
|
||||
.try_into()
|
||||
.map_err(|_| SearcherError::IndexCreationError)?,
|
||||
.map_err(|e| {
|
||||
if let TantivyError::IOError(err) = e {
|
||||
let err: io::Error = err.into();
|
||||
if err.kind() == io::ErrorKind::InvalidData {
|
||||
// Search index was created in older Tantivy format.
|
||||
SearcherError::InvalidIndexDataError
|
||||
} else {
|
||||
SearcherError::IndexCreationError
|
||||
}
|
||||
} else {
|
||||
SearcherError::IndexCreationError
|
||||
}
|
||||
})?,
|
||||
index,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ impl<'a> WhitespaceTokenStream<'a> {
|
||||
.filter(|&(_, ref c)| c.is_whitespace())
|
||||
.map(|(offset, _)| offset)
|
||||
.next()
|
||||
.unwrap_or_else(|| self.text.len())
|
||||
.unwrap_or(self.text.len())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
use crate::CONFIG;
|
||||
use rocket::request::{FromRequest, Outcome, Request};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub enum Strategy {
|
||||
Password,
|
||||
Email,
|
||||
}
|
||||
|
||||
impl Default for Strategy {
|
||||
fn default() -> Self {
|
||||
Self::Password
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Strategy {
|
||||
type Err = StrategyError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
use self::Strategy::*;
|
||||
|
||||
match s {
|
||||
"password" => Ok(Password),
|
||||
"email" => Ok(Email),
|
||||
s => Err(StrategyError::Unsupported(s.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StrategyError {
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for StrategyError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
use self::StrategyError::*;
|
||||
|
||||
match self {
|
||||
// FIXME: Calc option strings from enum
|
||||
Unsupported(s) => write!(f, "Unsupported strategy: {}. Choose password or email", s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StrategyError {}
|
||||
|
||||
pub struct Password();
|
||||
pub struct Email();
|
||||
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for Password {
|
||||
type Error = ();
|
||||
|
||||
fn from_request(_request: &'a Request<'r>) -> Outcome<Self, ()> {
|
||||
match matches!(CONFIG.signup, Strategy::Password) {
|
||||
true => Outcome::Success(Self()),
|
||||
false => Outcome::Forward(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for Email {
|
||||
type Error = ();
|
||||
|
||||
fn from_request(_request: &'a Request<'r>) -> Outcome<Self, ()> {
|
||||
match matches!(CONFIG.signup, Strategy::Email) {
|
||||
true => Outcome::Success(Self()),
|
||||
false => Outcome::Forward(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
-16
@@ -1,6 +1,7 @@
|
||||
use crate::{ap_url, instance::Instance, schema::tags, Connection, Error, Result};
|
||||
use activitystreams::iri_string::types::IriString;
|
||||
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use plume_common::activity_pub::Hashtag;
|
||||
use plume_common::activity_pub::{Hashtag, HashtagExt};
|
||||
|
||||
#[derive(Clone, Identifiable, Queryable)]
|
||||
pub struct Tag {
|
||||
@@ -25,13 +26,16 @@ impl Tag {
|
||||
list_by!(tags, for_post, post_id as i32);
|
||||
|
||||
pub fn to_activity(&self) -> Result<Hashtag> {
|
||||
let mut ht = Hashtag::default();
|
||||
ht.set_href_string(ap_url(&format!(
|
||||
"{}/tag/{}",
|
||||
Instance::get_local()?.public_domain,
|
||||
self.tag
|
||||
)))?;
|
||||
ht.set_name_string(self.tag.clone())?;
|
||||
let mut ht = Hashtag::new();
|
||||
ht.set_href(
|
||||
ap_url(&format!(
|
||||
"{}/tag/{}",
|
||||
Instance::get_local()?.public_domain,
|
||||
self.tag
|
||||
))
|
||||
.parse::<IriString>()?,
|
||||
);
|
||||
ht.set_name(self.tag.clone());
|
||||
Ok(ht)
|
||||
}
|
||||
|
||||
@@ -44,7 +48,7 @@ impl Tag {
|
||||
Tag::insert(
|
||||
conn,
|
||||
NewTag {
|
||||
tag: tag.name_string()?,
|
||||
tag: tag.name().ok_or(Error::MissingApProperty)?.as_str().into(),
|
||||
is_hashtag,
|
||||
post_id: post,
|
||||
},
|
||||
@@ -52,13 +56,16 @@ impl Tag {
|
||||
}
|
||||
|
||||
pub fn build_activity(tag: String) -> Result<Hashtag> {
|
||||
let mut ht = Hashtag::default();
|
||||
ht.set_href_string(ap_url(&format!(
|
||||
"{}/tag/{}",
|
||||
Instance::get_local()?.public_domain,
|
||||
tag
|
||||
)))?;
|
||||
ht.set_name_string(tag)?;
|
||||
let mut ht = Hashtag::new();
|
||||
ht.set_href(
|
||||
ap_url(&format!(
|
||||
"{}/tag/{}",
|
||||
Instance::get_local()?.public_domain,
|
||||
tag
|
||||
))
|
||||
.parse::<IriString>()?,
|
||||
);
|
||||
ht.set_name(tag);
|
||||
Ok(ht)
|
||||
}
|
||||
|
||||
@@ -69,3 +76,72 @@ impl Tag {
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tests::db;
|
||||
use crate::{diesel::Connection, inbox::tests::fill_database};
|
||||
use assert_json_diff::assert_json_eq;
|
||||
use serde_json::to_value;
|
||||
|
||||
#[test]
|
||||
fn from_activity() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
let (posts, _users, _blogs) = fill_database(conn);
|
||||
let post_id = posts[0].id;
|
||||
let mut ht = Hashtag::new();
|
||||
ht.set_href(ap_url("https://plu.me/tag/a_tag").parse::<IriString>()?);
|
||||
ht.set_name("a_tag".to_string());
|
||||
let tag = Tag::from_activity(conn, &ht, post_id, true)?;
|
||||
|
||||
assert_eq!(&tag.tag, "a_tag");
|
||||
assert!(tag.is_hashtag);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_activity() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
fill_database(conn);
|
||||
let tag = Tag {
|
||||
id: 0,
|
||||
tag: "a_tag".into(),
|
||||
is_hashtag: false,
|
||||
post_id: 0,
|
||||
};
|
||||
let act = tag.to_activity()?;
|
||||
let expected = json!({
|
||||
"href": "https://plu.me/tag/a_tag",
|
||||
"name": "a_tag",
|
||||
"type": "Hashtag"
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(&act)?, expected);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_activity() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, Error, _>(|| {
|
||||
fill_database(conn);
|
||||
let act = Tag::build_activity("a_tag".into())?;
|
||||
let expected = json!({
|
||||
"href": "https://plu.me/tag/a_tag",
|
||||
"name": "a_tag",
|
||||
"type": "Hashtag"
|
||||
});
|
||||
|
||||
assert_json_eq!(to_value(&act)?, expected);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,18 @@ use crate::{
|
||||
lists::List,
|
||||
posts::Post,
|
||||
schema::{posts, timeline, timeline_definition},
|
||||
Connection, Error, PlumeRocket, Result,
|
||||
Connection, Error, Result,
|
||||
};
|
||||
use diesel::{self, BoolExpressionMethods, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use std::cmp::Ordering;
|
||||
use std::ops::Deref;
|
||||
|
||||
pub(crate) mod query;
|
||||
|
||||
pub use self::query::Kind;
|
||||
use self::query::{QueryError, TimelineQuery};
|
||||
pub use self::query::{QueryError, TimelineQuery};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Queryable, Identifiable, AsChangeset)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Queryable, Identifiable, AsChangeset)]
|
||||
#[table_name = "timeline_definition"]
|
||||
pub struct Timeline {
|
||||
pub id: i32,
|
||||
@@ -91,6 +92,16 @@ impl Timeline {
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
.map(|mut timelines| {
|
||||
timelines.sort_by(|t1, t2| {
|
||||
if t1.user_id.is_some() && t2.user_id.is_none() {
|
||||
Ordering::Less
|
||||
} else {
|
||||
t1.id.cmp(&t2.id)
|
||||
}
|
||||
});
|
||||
timelines
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_for_user(
|
||||
@@ -208,20 +219,23 @@ impl Timeline {
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn add_to_all_timelines(rocket: &PlumeRocket, post: &Post, kind: Kind<'_>) -> Result<()> {
|
||||
pub fn add_to_all_timelines(conn: &Connection, post: &Post, kind: Kind<'_>) -> Result<()> {
|
||||
let timelines = timeline_definition::table
|
||||
.load::<Self>(rocket.conn.deref())
|
||||
.load::<Self>(conn.deref())
|
||||
.map_err(Error::from)?;
|
||||
|
||||
for t in timelines {
|
||||
if t.matches(rocket, post, kind)? {
|
||||
t.add_post(&rocket.conn, post)?;
|
||||
if t.matches(conn, post, kind)? {
|
||||
t.add_post(conn, post)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_post(&self, conn: &Connection, post: &Post) -> Result<()> {
|
||||
if self.includes_post(conn, post)? {
|
||||
return Ok(());
|
||||
}
|
||||
diesel::insert_into(timeline::table)
|
||||
.values(TimelineEntry {
|
||||
post_id: post.id,
|
||||
@@ -231,9 +245,38 @@ impl Timeline {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn matches(&self, rocket: &PlumeRocket, post: &Post, kind: Kind<'_>) -> Result<bool> {
|
||||
pub fn remove_post(&self, conn: &Connection, post: &Post) -> Result<bool> {
|
||||
if self.includes_post(conn, post)? {
|
||||
return Ok(false);
|
||||
}
|
||||
diesel::delete(
|
||||
timeline::table
|
||||
.filter(timeline::timeline_id.eq(self.id))
|
||||
.filter(timeline::post_id.eq(post.id)),
|
||||
)
|
||||
.execute(conn)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn remove_all_posts(&self, conn: &Connection) -> Result<u64> {
|
||||
let count = diesel::delete(timeline::table.filter(timeline::timeline_id.eq(self.id)))
|
||||
.execute(conn)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
pub fn matches(&self, conn: &Connection, post: &Post, kind: Kind<'_>) -> Result<bool> {
|
||||
let query = TimelineQuery::parse(&self.query)?;
|
||||
query.matches(rocket, self, post, kind)
|
||||
query.matches(conn, self, post, kind)
|
||||
}
|
||||
|
||||
fn includes_post(&self, conn: &Connection, post: &Post) -> Result<bool> {
|
||||
diesel::dsl::select(diesel::dsl::exists(
|
||||
timeline::table
|
||||
.filter(timeline::timeline_id.eq(self.id))
|
||||
.filter(timeline::post_id.eq(post.id)),
|
||||
))
|
||||
.get_result(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +291,7 @@ mod tests {
|
||||
posts::NewPost,
|
||||
safe_string::SafeString,
|
||||
tags::Tag,
|
||||
tests::{db, rockets},
|
||||
tests::db,
|
||||
users::tests as userTests,
|
||||
};
|
||||
use diesel::Connection;
|
||||
@@ -301,23 +344,19 @@ mod tests {
|
||||
|
||||
let tl_u1 = Timeline::list_for_user(conn, Some(users[0].id)).unwrap();
|
||||
assert_eq!(3, tl_u1.len()); // it is not 2 because there is a "Your feed" tl created for each user automatically
|
||||
assert!(tl_u1.iter().fold(false, |res, tl| { res || *tl == tl1_u1 }));
|
||||
assert!(tl_u1.iter().fold(false, |res, tl| { res || *tl == tl2_u1 }));
|
||||
assert!(tl_u1.iter().any(|tl| *tl == tl1_u1));
|
||||
assert!(tl_u1.iter().any(|tl| *tl == tl2_u1));
|
||||
|
||||
let tl_instance = Timeline::list_for_user(conn, None).unwrap();
|
||||
assert_eq!(3, tl_instance.len()); // there are also the local and federated feed by default
|
||||
assert!(tl_instance
|
||||
.iter()
|
||||
.fold(false, |res, tl| { res || *tl == tl1_instance }));
|
||||
assert!(tl_instance.iter().any(|tl| *tl == tl1_instance));
|
||||
|
||||
tl1_u1.name = "My Super TL".to_owned();
|
||||
let new_tl1_u2 = tl1_u2.update(conn).unwrap();
|
||||
|
||||
let tl_u2 = Timeline::list_for_user(conn, Some(users[1].id)).unwrap();
|
||||
assert_eq!(2, tl_u2.len()); // same here
|
||||
assert!(tl_u2
|
||||
.iter()
|
||||
.fold(false, |res, tl| { res || *tl == new_tl1_u2 }));
|
||||
assert!(tl_u2.iter().any(|tl| *tl == new_tl1_u2));
|
||||
|
||||
Ok(())
|
||||
});
|
||||
@@ -380,8 +419,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_simple_match() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
@@ -408,10 +446,9 @@ mod tests {
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(gnu_tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(gnu_tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
|
||||
let non_free_post = Post::insert(
|
||||
conn,
|
||||
@@ -428,10 +465,11 @@ mod tests {
|
||||
source: "so is Microsoft".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!gnu_tl.matches(r, &non_free_post, Kind::Original).unwrap());
|
||||
assert!(!gnu_tl
|
||||
.matches(conn, &non_free_post, Kind::Original)
|
||||
.unwrap());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
@@ -439,8 +477,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_complex_match() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
Follow::insert(
|
||||
@@ -481,10 +518,9 @@ mod tests {
|
||||
subtitle: "".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(my_tl.matches(r, &post, Kind::Original).unwrap()); // matches because of "blog in fav_blogs" (and there is no cover)
|
||||
assert!(my_tl.matches(conn, &post, Kind::Original).unwrap()); // matches because of "blog in fav_blogs" (and there is no cover)
|
||||
|
||||
let post = Post::insert(
|
||||
conn,
|
||||
@@ -503,10 +539,9 @@ mod tests {
|
||||
subtitle: "".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!my_tl.matches(r, &post, Kind::Like(&users[1])).unwrap());
|
||||
assert!(!my_tl.matches(conn, &post, Kind::Like(&users[1])).unwrap());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
@@ -514,8 +549,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_add_to_all_timelines() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
@@ -549,7 +583,6 @@ mod tests {
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -568,12 +601,11 @@ mod tests {
|
||||
source: "so is Microsoft".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Timeline::add_to_all_timelines(r, &gnu_post, Kind::Original).unwrap();
|
||||
Timeline::add_to_all_timelines(r, &non_free_post, Kind::Original).unwrap();
|
||||
Timeline::add_to_all_timelines(conn, &gnu_post, Kind::Original).unwrap();
|
||||
Timeline::add_to_all_timelines(conn, &non_free_post, Kind::Original).unwrap();
|
||||
|
||||
let res = gnu_tl.get_latest(conn, 2).unwrap();
|
||||
assert_eq!(res.len(), 1);
|
||||
@@ -588,8 +620,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_matches_lists_direct() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
@@ -608,7 +639,6 @@ mod tests {
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
gnu_post
|
||||
@@ -630,7 +660,7 @@ mod tests {
|
||||
format!("blog in [{}]", blogs[0].fqn),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
@@ -639,7 +669,7 @@ mod tests {
|
||||
"blog in [no_one@nowhere]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(!tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
@@ -652,7 +682,7 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
@@ -661,9 +691,11 @@ mod tests {
|
||||
format!("author in [{}]", users[2].fqn),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Reshare(&users[2])).unwrap());
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Like(&users[2])).unwrap());
|
||||
assert!(!tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(tl
|
||||
.matches(conn, &gnu_post, Kind::Reshare(&users[2]))
|
||||
.unwrap());
|
||||
assert!(!tl.matches(conn, &gnu_post, Kind::Like(&users[2])).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
@@ -675,9 +707,11 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Reshare(&users[2])).unwrap());
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Like(&users[2])).unwrap());
|
||||
assert!(!tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(!tl
|
||||
.matches(conn, &gnu_post, Kind::Reshare(&users[2]))
|
||||
.unwrap());
|
||||
assert!(tl.matches(conn, &gnu_post, Kind::Like(&users[2])).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
@@ -687,7 +721,7 @@ mod tests {
|
||||
"tags in [free]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
@@ -696,7 +730,7 @@ mod tests {
|
||||
"tags in [private]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(!tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
@@ -706,7 +740,7 @@ mod tests {
|
||||
"lang in [en]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
@@ -715,7 +749,7 @@ mod tests {
|
||||
"lang in [fr, it]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(!tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
Ok(())
|
||||
@@ -726,12 +760,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_matches_lists_saved() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
let (users, blogs) = blogTests::fill_database(&conn);
|
||||
|
||||
let gnu_post = Post::insert(
|
||||
conn,
|
||||
&conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug".to_string(),
|
||||
@@ -745,11 +779,10 @@ mod tests {
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
gnu_post.update_tags(conn, vec![Tag::build_activity("free".to_owned()).unwrap()]).unwrap();
|
||||
PostAuthor::insert(conn, NewPostAuthor {post_id: gnu_post.id, author_id: blogs[0].list_authors(conn).unwrap()[0].id}).unwrap();
|
||||
gnu_post.update_tags(&conn, vec![Tag::build_activity("free".to_owned()).unwrap()]).unwrap();
|
||||
PostAuthor::insert(&conn, NewPostAuthor {post_id: gnu_post.id, author_id: blogs[0].list_authors(&conn).unwrap()[0].id}).unwrap();
|
||||
|
||||
unimplemented!();
|
||||
|
||||
@@ -759,8 +792,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_matches_keyword() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
@@ -779,7 +811,6 @@ mod tests {
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -790,7 +821,7 @@ mod tests {
|
||||
"title contains Linux".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
@@ -799,7 +830,7 @@ mod tests {
|
||||
"title contains Microsoft".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(!tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
@@ -809,7 +840,7 @@ mod tests {
|
||||
"subtitle contains Stallman".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
@@ -818,7 +849,7 @@ mod tests {
|
||||
"subtitle contains Nadella".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(!tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
@@ -828,7 +859,7 @@ mod tests {
|
||||
"content contains Linux".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
@@ -837,7 +868,7 @@ mod tests {
|
||||
"subtitle contains Windows".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(!tl.matches(conn, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -5,27 +5,21 @@ use crate::{
|
||||
tags::Tag,
|
||||
timeline::Timeline,
|
||||
users::User,
|
||||
PlumeRocket, Result,
|
||||
Connection, Result,
|
||||
};
|
||||
use plume_common::activity_pub::inbox::AsActor;
|
||||
use whatlang::{self, Lang};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum QueryError {
|
||||
SyntaxError(usize, usize, String),
|
||||
UnexpectedEndOfQuery,
|
||||
RuntimeError(String),
|
||||
}
|
||||
|
||||
impl From<std::option::NoneError> for QueryError {
|
||||
fn from(_: std::option::NoneError) -> Self {
|
||||
QueryError::UnexpectedEndOfQuery
|
||||
}
|
||||
}
|
||||
|
||||
pub type QueryResult<T> = std::result::Result<T, QueryError>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind<'a> {
|
||||
Original,
|
||||
Reshare(&'a User),
|
||||
@@ -160,19 +154,19 @@ enum TQ<'a> {
|
||||
impl<'a> TQ<'a> {
|
||||
fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
conn: &Connection,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind<'_>,
|
||||
) -> Result<bool> {
|
||||
match self {
|
||||
TQ::Or(inner) => inner.iter().try_fold(false, |s, e| {
|
||||
e.matches(rocket, timeline, post, kind).map(|r| s || r)
|
||||
e.matches(conn, timeline, post, kind).map(|r| s || r)
|
||||
}),
|
||||
TQ::And(inner) => inner.iter().try_fold(true, |s, e| {
|
||||
e.matches(rocket, timeline, post, kind).map(|r| s && r)
|
||||
e.matches(conn, timeline, post, kind).map(|r| s && r)
|
||||
}),
|
||||
TQ::Arg(inner, invert) => Ok(inner.matches(rocket, timeline, post, kind)? ^ invert),
|
||||
TQ::Arg(inner, invert) => Ok(inner.matches(conn, timeline, post, kind)? ^ invert),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,15 +199,15 @@ enum Arg<'a> {
|
||||
impl<'a> Arg<'a> {
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
conn: &Connection,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind<'_>,
|
||||
) -> Result<bool> {
|
||||
match self {
|
||||
Arg::In(t, l) => t.matches(rocket, timeline, post, l, kind),
|
||||
Arg::In(t, l) => t.matches(conn, timeline, post, l, kind),
|
||||
Arg::Contains(t, v) => t.matches(post, v),
|
||||
Arg::Boolean(t) => t.matches(rocket, timeline, post, kind),
|
||||
Arg::Boolean(t) => t.matches(conn, timeline, post, kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,7 +224,7 @@ enum WithList {
|
||||
impl WithList {
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
conn: &Connection,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
list: &List<'_>,
|
||||
@@ -238,39 +232,34 @@ impl WithList {
|
||||
) -> Result<bool> {
|
||||
match list {
|
||||
List::List(name) => {
|
||||
let list =
|
||||
lists::List::find_for_user_by_name(&rocket.conn, timeline.user_id, &name)?;
|
||||
let list = lists::List::find_for_user_by_name(conn, timeline.user_id, name)?;
|
||||
match (self, list.kind()) {
|
||||
(WithList::Blog, ListType::Blog) => {
|
||||
list.contains_blog(&rocket.conn, post.blog_id)
|
||||
}
|
||||
(WithList::Blog, ListType::Blog) => list.contains_blog(conn, post.blog_id),
|
||||
(WithList::Author { boosts, likes }, ListType::User) => match kind {
|
||||
Kind::Original => Ok(list
|
||||
.list_users(&rocket.conn)?
|
||||
.list_users(conn)?
|
||||
.iter()
|
||||
.any(|a| post.is_author(&rocket.conn, a.id).unwrap_or(false))),
|
||||
.any(|a| post.is_author(conn, a.id).unwrap_or(false))),
|
||||
Kind::Reshare(u) => {
|
||||
if *boosts {
|
||||
list.contains_user(&rocket.conn, u.id)
|
||||
list.contains_user(conn, u.id)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Kind::Like(u) => {
|
||||
if *likes {
|
||||
list.contains_user(&rocket.conn, u.id)
|
||||
list.contains_user(conn, u.id)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
(WithList::License, ListType::Word) => {
|
||||
list.contains_word(&rocket.conn, &post.license)
|
||||
}
|
||||
(WithList::License, ListType::Word) => list.contains_word(conn, &post.license),
|
||||
(WithList::Tags, ListType::Word) => {
|
||||
let tags = Tag::for_post(&rocket.conn, post.id)?;
|
||||
let tags = Tag::for_post(conn, post.id)?;
|
||||
Ok(list
|
||||
.list_words(&rocket.conn)?
|
||||
.list_words(conn)?
|
||||
.iter()
|
||||
.any(|s| tags.iter().any(|t| s == &t.tag)))
|
||||
}
|
||||
@@ -285,7 +274,7 @@ impl WithList {
|
||||
})
|
||||
.unwrap_or(Lang::Eng)
|
||||
.name();
|
||||
list.contains_prefix(&rocket.conn, lang)
|
||||
list.contains_prefix(conn, lang)
|
||||
}
|
||||
(_, _) => Err(QueryError::RuntimeError(format!(
|
||||
"The list '{}' is of the wrong type for this usage",
|
||||
@@ -297,13 +286,13 @@ impl WithList {
|
||||
List::Array(list) => match self {
|
||||
WithList::Blog => Ok(list
|
||||
.iter()
|
||||
.filter_map(|b| Blog::find_by_fqn(rocket, b).ok())
|
||||
.filter_map(|b| Blog::find_by_fqn(conn, b).ok())
|
||||
.any(|b| b.id == post.blog_id)),
|
||||
WithList::Author { boosts, likes } => match kind {
|
||||
Kind::Original => Ok(list
|
||||
.iter()
|
||||
.filter_map(|a| User::find_by_fqn(rocket, a).ok())
|
||||
.any(|a| post.is_author(&rocket.conn, a.id).unwrap_or(false))),
|
||||
.filter_map(|a| User::find_by_fqn(conn, a).ok())
|
||||
.any(|a| post.is_author(conn, a.id).unwrap_or(false))),
|
||||
Kind::Reshare(u) => {
|
||||
if *boosts {
|
||||
Ok(list.iter().any(|user| &u.fqn == user))
|
||||
@@ -321,7 +310,7 @@ impl WithList {
|
||||
},
|
||||
WithList::License => Ok(list.iter().any(|s| s == &post.license)),
|
||||
WithList::Tags => {
|
||||
let tags = Tag::for_post(&rocket.conn, post.id)?;
|
||||
let tags = Tag::for_post(conn, post.id)?;
|
||||
Ok(list.iter().any(|s| tags.iter().any(|t| s == &t.tag)))
|
||||
}
|
||||
WithList::Lang => {
|
||||
@@ -371,7 +360,7 @@ enum Bool {
|
||||
impl Bool {
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
conn: &Connection,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind<'_>,
|
||||
@@ -384,21 +373,19 @@ impl Bool {
|
||||
let user = timeline.user_id.unwrap();
|
||||
match kind {
|
||||
Kind::Original => post
|
||||
.get_authors(&rocket.conn)?
|
||||
.get_authors(conn)?
|
||||
.iter()
|
||||
.try_fold(false, |s, a| {
|
||||
a.is_followed_by(&rocket.conn, user).map(|r| s || r)
|
||||
}),
|
||||
.try_fold(false, |s, a| a.is_followed_by(conn, user).map(|r| s || r)),
|
||||
Kind::Reshare(u) => {
|
||||
if *boosts {
|
||||
u.is_followed_by(&rocket.conn, user)
|
||||
u.is_followed_by(conn, user)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Kind::Like(u) => {
|
||||
if *likes {
|
||||
u.is_followed_by(&rocket.conn, user)
|
||||
u.is_followed_by(conn, user)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
@@ -406,7 +393,7 @@ impl Bool {
|
||||
}
|
||||
}
|
||||
Bool::HasCover => Ok(post.cover_id.is_some()),
|
||||
Bool::Local => Ok(post.get_blog(&rocket.conn)?.is_local() && kind == Kind::Original),
|
||||
Bool::Local => Ok(post.get_blog(conn)?.is_local() && kind == Kind::Original),
|
||||
Bool::All => Ok(kind == Kind::Original),
|
||||
}
|
||||
}
|
||||
@@ -420,7 +407,7 @@ enum List<'a> {
|
||||
|
||||
fn parse_s<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
|
||||
let mut res = Vec::new();
|
||||
let (left, token) = parse_a(&stream)?;
|
||||
let (left, token) = parse_a(stream)?;
|
||||
res.push(token);
|
||||
stream = left;
|
||||
while !stream.is_empty() {
|
||||
@@ -442,7 +429,7 @@ fn parse_s<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>],
|
||||
|
||||
fn parse_a<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
|
||||
let mut res = Vec::new();
|
||||
let (left, token) = parse_b(&stream)?;
|
||||
let (left, token) = parse_b(stream)?;
|
||||
res.push(token);
|
||||
stream = left;
|
||||
while !stream.is_empty() {
|
||||
@@ -469,7 +456,7 @@ fn parse_b<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<
|
||||
match left.get(0) {
|
||||
Some(Token::RParent(_)) => Ok((&left[1..], token)),
|
||||
Some(t) => t.get_error(Token::RParent(0)),
|
||||
None => None?,
|
||||
None => Err(QueryError::UnexpectedEndOfQuery),
|
||||
}
|
||||
}
|
||||
_ => parse_c(stream),
|
||||
@@ -490,9 +477,13 @@ fn parse_c<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<
|
||||
}
|
||||
|
||||
fn parse_d<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], Arg<'a>)> {
|
||||
match stream.get(0).map(Token::get_text)? {
|
||||
match stream
|
||||
.get(0)
|
||||
.map(Token::get_text)
|
||||
.ok_or(QueryError::UnexpectedEndOfQuery)?
|
||||
{
|
||||
s @ "blog" | s @ "author" | s @ "license" | s @ "tags" | s @ "lang" => {
|
||||
match stream.get(1)? {
|
||||
match stream.get(1).ok_or(QueryError::UnexpectedEndOfQuery)? {
|
||||
Token::Word(_, _, r#in) if r#in == &"in" => {
|
||||
let (mut left, list) = parse_l(&stream[2..])?;
|
||||
let kind = match s {
|
||||
@@ -504,7 +495,12 @@ fn parse_d<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>],
|
||||
if *clude != "include" && *clude != "exclude" {
|
||||
break;
|
||||
}
|
||||
match (*clude, left.get(1).map(Token::get_text)?) {
|
||||
match (
|
||||
*clude,
|
||||
left.get(1)
|
||||
.map(Token::get_text)
|
||||
.ok_or(QueryError::UnexpectedEndOfQuery)?,
|
||||
) {
|
||||
("include", "reshares") | ("include", "reshare") => {
|
||||
boosts = true
|
||||
}
|
||||
@@ -535,7 +531,10 @@ fn parse_d<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>],
|
||||
t => t.get_error(Token::Word(0, 0, "'in'")),
|
||||
}
|
||||
}
|
||||
s @ "title" | s @ "subtitle" | s @ "content" => match (stream.get(1)?, stream.get(2)?) {
|
||||
s @ "title" | s @ "subtitle" | s @ "content" => match (
|
||||
stream.get(1).ok_or(QueryError::UnexpectedEndOfQuery)?,
|
||||
stream.get(2).ok_or(QueryError::UnexpectedEndOfQuery)?,
|
||||
) {
|
||||
(Token::Word(_, _, contains), Token::Word(_, _, w)) if contains == &"contains" => Ok((
|
||||
&stream[3..],
|
||||
Arg::Contains(
|
||||
@@ -561,7 +560,13 @@ fn parse_d<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>],
|
||||
if *clude != "include" && *clude != "exclude" {
|
||||
break;
|
||||
}
|
||||
match (*clude, stream.get(2).map(Token::get_text)?) {
|
||||
match (
|
||||
*clude,
|
||||
stream
|
||||
.get(2)
|
||||
.map(Token::get_text)
|
||||
.ok_or(QueryError::UnexpectedEndOfQuery)?,
|
||||
) {
|
||||
("include", "reshares") | ("include", "reshare") => boosts = true,
|
||||
("exclude", "reshares") | ("exclude", "reshare") => boosts = false,
|
||||
("include", "likes") | ("include", "like") => likes = true,
|
||||
@@ -583,20 +588,23 @@ fn parse_d<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>],
|
||||
"all" => Ok((&stream[1..], Arg::Boolean(Bool::All))),
|
||||
_ => unreachable!(),
|
||||
},
|
||||
_ => stream.get(0)?.get_error(Token::Word(
|
||||
0,
|
||||
0,
|
||||
"one of 'blog', 'author', 'license', 'tags', 'lang', \
|
||||
_ => stream
|
||||
.get(0)
|
||||
.ok_or(QueryError::UnexpectedEndOfQuery)?
|
||||
.get_error(Token::Word(
|
||||
0,
|
||||
0,
|
||||
"one of 'blog', 'author', 'license', 'tags', 'lang', \
|
||||
'title', 'subtitle', 'content', 'followed', 'has_cover', 'local' or 'all'",
|
||||
)),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_l<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], List<'a>)> {
|
||||
match stream.get(0)? {
|
||||
match stream.get(0).ok_or(QueryError::UnexpectedEndOfQuery)? {
|
||||
Token::LBracket(_) => {
|
||||
let (left, list) = parse_m(&stream[1..])?;
|
||||
match left.get(0)? {
|
||||
match left.get(0).ok_or(QueryError::UnexpectedEndOfQuery)? {
|
||||
Token::RBracket(_) => Ok((&left[1..], List::Array(list))),
|
||||
t => t.get_error(Token::Word(0, 0, "one of ']' or ','")),
|
||||
}
|
||||
@@ -607,17 +615,20 @@ fn parse_l<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], Lis
|
||||
}
|
||||
|
||||
fn parse_m<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], Vec<&'a str>)> {
|
||||
let mut res: Vec<&str> = Vec::new();
|
||||
res.push(match stream.get(0)? {
|
||||
Token::Word(_, _, w) => w,
|
||||
t => return t.get_error(Token::Word(0, 0, "any word")),
|
||||
});
|
||||
stream = &stream[1..];
|
||||
while let Token::Comma(_) = stream[0] {
|
||||
res.push(match stream.get(1)? {
|
||||
let mut res: Vec<&str> = vec![
|
||||
match stream.get(0).ok_or(QueryError::UnexpectedEndOfQuery)? {
|
||||
Token::Word(_, _, w) => w,
|
||||
t => return t.get_error(Token::Word(0, 0, "any word")),
|
||||
});
|
||||
},
|
||||
];
|
||||
stream = &stream[1..];
|
||||
while let Token::Comma(_) = stream[0] {
|
||||
res.push(
|
||||
match stream.get(1).ok_or(QueryError::UnexpectedEndOfQuery)? {
|
||||
Token::Word(_, _, w) => w,
|
||||
t => return t.get_error(Token::Word(0, 0, "any word")),
|
||||
},
|
||||
);
|
||||
stream = &stream[2..];
|
||||
}
|
||||
|
||||
@@ -642,12 +653,12 @@ impl<'a> TimelineQuery<'a> {
|
||||
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
conn: &Connection,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind<'_>,
|
||||
) -> Result<bool> {
|
||||
self.0.matches(rocket, timeline, post, kind)
|
||||
self.0.matches(conn, timeline, post, kind)
|
||||
}
|
||||
|
||||
pub fn list_used_lists(&self) -> Vec<(String, ListType)> {
|
||||
|
||||
+669
-317
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user