Compare commits
2864 Commits
Author | SHA1 | Date |
---|---|---|
Adrià Casajús | faae37b6bc | |
Ggcu | 3fd9884c56 | |
ghisch | 4817dfdcaf | |
Adrià Casajús | 1ecc5eb89b | |
Son Nguyen Kim | 209ed65ebc | |
Adrià Casajús | 8a77a8b251 | |
Carlos Quintana | b931518620 | |
Carlos Quintana | 9d2a35b9c2 | |
Carlos Quintana | 5f190d4b46 | |
Carlos Quintana | 6862ed3602 | |
Carlos Quintana | 450322fff1 | |
Carlos Quintana | aad6f59e96 | |
Carlos Quintana | 8eccb05e33 | |
Carlos Quintana | 3e0b7bb369 | |
Son Nguyen Kim | 60ab8c15ec | |
Son Nguyen Kim | b5b167479f | |
Adrià Casajús | 8f12fabd81 | |
Daniel Mühlbachler-Pietrzykowski | b6004f3336 | |
Adrià Casajús | 80c8bc820b | |
Son Nguyen Kim | 037bc9da36 | |
Son Nguyen Kim | ee0be3688f | |
Adrià Casajús | 015036b499 | |
Son Nguyen Kim | d5df91aab6 | |
Adrià Casajús | 2eb5feaa8f | |
Adrià Casajús | 3c364da37d | |
Adrià Casajús | 36cf530ef8 | |
Adrià Casajús | 0da1811311 | |
Adrià Casajús | f2fcaa6c60 | |
Adrià Casajús | aa2c676b5e | |
Adrià Casajús | 30ddd4c807 | |
Son Nguyen Kim | f5babd9c81 | |
Adrià Casajús | 74b811dd35 | |
martadams89 | e6c51bcf20 | |
martadams89 | 4bfc6b9aca | |
Adrià Casajús | e96de79665 | |
Daniel Mühlbachler-Pietrzykowski | a608503df6 | |
Son Nguyen Kim | 0c3c6db2ab | |
Adrià Casajús | 9719a36dab | |
Adrià Casajús | a7d4bd15a7 | |
Adrià Casajús | 565f6dc142 | |
Adrià Casajús | 76423527dd | |
Adrià Casajús | 501b225e40 | |
Adrià Casajús | 1dada1a4b5 | |
Adrià Casajús | 37f227da42 | |
Adrià Casajús | 97e68159c5 | |
Adrià Casajús | 673e19b287 | |
Sukuna | 5959d40a00 | |
Adrià Casajús | 173ae6a221 | |
Adrià Casajús | eb92823ef8 | |
Adrià Casajús | 363b851f61 | |
Adrià Casajús | d0a6b8ed79 | |
Adrià Casajús | 50c130a3a3 | |
Adrià Casajús | b462c256d3 | |
Adrià Casajús | f756b04ead | |
Son Nguyen Kim | 05d18c23cc | |
Adrià Casajús | 4a7c0293f8 | |
Adrià Casajús | 30aaf118e7 | |
D-Bao | 7b0d6dae1b | |
Adrià Casajús | b6f1cecee9 | |
Adrià Casajús | d12e776949 | |
Ed | b8dad2d657 | |
Son Nguyen Kim | 860ce03f2a | |
Son Nguyen Kim | 71bb7bc795 | |
Adrià Casajús | 761420ece9 | |
Adrià Casajús | c3848862c3 | |
Adrià Casajús | da09db3864 | |
Adrià Casajús | 44138e25a5 | |
Adrià Casajús | b541ca4ceb | |
Revi99 | 66c18e2f8e | |
Son Nguyen Kim | 4a046c5f6f | |
Ueri8 | a731bf4435 | |
SecurityGuy | f3127dc857 | |
Kelp8 | d9d28d3c75 | |
Kelp8 | bca6bfa617 | |
Agent-XD | 5d6a4963a0 | |
Kelp8 | 00737f68de | |
Joseph Demcher | 9ae206ec77 | |
Agent-XD | 9452b14e10 | |
Son Nguyen Kim | 7705fa1c9b | |
Adrià Casajús | 1dfb0e3356 | |
Adrià Casajús | 2a9c1c5658 | |
Carlos Quintana | dc39ab2de7 | |
Adrià Casajús | fe1c66268b | |
Adrià Casajús | 72041ee520 | |
Adrià Casajús | f81f8ca032 | |
Adrià Casajús | 31896ff262 | |
Adrià Casajús | 45575261dc | |
Adrià Casajús | 627ad302d2 | |
Son NK | 08862a35c3 | |
Son Nguyen Kim | 75dd3cf925 | |
Son Nguyen Kim | a097e33abe | |
Adrià Casajús | e5cc8b9628 | |
Hulk667i | d149686296 | |
Adrià Casajús | babf4b058a | |
UserBob6 | eb8f8caeb8 | |
UserBob6 | 70fc9c383a | |
Adrià Casajús | b68f074783 | |
Adrià Casajús | 73a0addf27 | |
Adrià Casajús | e6bcf81726 | |
Adrià Casajús | 7600038813 | |
Adrià Casajús | c19b62b878 | |
Jack Wright | 4fe79bdd42 | |
Son Nguyen Kim | fd1744470b | |
Adrià Casajús | 989a577db6 | |
Adrià Casajús | 373c30e53b | |
Son Nguyen Kim | ff3dbdaad2 | |
Adrià Casajús | 7ec7e06c2b | |
Adrià Casajús | ef90423a35 | |
Adrià Casajús | c04f5102d6 | |
Son Nguyen Kim | 5714403976 | |
Carlos Quintana | 40ff4604c8 | |
mlec | 66d26a1193 | |
D-Bao | 9b1e4f73ca | |
Son Nguyen Kim | 0435c745fd | |
Adrià Casajús | 366631ee93 | |
Adrià Casajús | 4bf925fe6f | |
Carlos Quintana | 0e82801512 | |
Adrià Casajús | 9ab3695d36 | |
Son Nguyen Kim | 06b7e05e61 | |
Son Nguyen Kim | 6c7e9e69dc | |
Adrià Casajús | 6e4f6fe540 | |
Adrià Casajús | f2dad4c28c | |
Adrià Casajús | e9e863807c | |
Adrià Casajús | c4003b07ac | |
Adrià Casajús | d8943cf126 | |
Maxime Labelle | 2eec918543 | |
Maxime Labelle | 4d9b8f9a4b | |
Efren | 81d5ef0783 | |
Adrià Casajús | 04d92b7f23 | |
Adrià Casajús | cb900ed057 | |
Adrià Casajús | 516072fd99 | |
Son Nguyen Kim | 2351330732 | |
Adrià Casajús | e2dbf8d48d | |
Adrià Casajús | d62bff8e46 | |
Adrià Casajús | fc205157a8 | |
Adrià Casajús | ac9d550069 | |
Adrià Casajús | daec781ffc | |
Son Nguyen Kim | 501c625ddf | |
Adrià Casajús | d3aae31d45 | |
Adrià Casajús | 8512093bfc | |
Adrià Casajús | 76b05e0d64 | |
Adrià Casajús | 40663358d8 | |
Adrià Casajús | f046b2270c | |
Adrià Casajús | 03c67ead44 | |
Adrià Casajús | 37ffe4d5fe | |
Adrià Casajús | 689ef3a579 | |
Adrià Casajús | 495d544505 | |
Adrià Casajús | a539428607 | |
Adrià Casajús | 8c7e9f7fb3 | |
Adrià Casajús | 9d9e5fcab6 | |
Adrià Casajús | ff33392398 | |
Adrià Casajús | 85964f283e | |
Carlos Quintana | d30183bbda | |
Adrià Casajús | ed66c7306b | |
Adrià Casajús | 07bb658310 | |
Adrià Casajús | e43a2dd34d | |
Adrià Casajús | 3de83f2f05 | |
Adrià Casajús | e4d4317988 | |
Adrià Casajús | da2cedd254 | |
Adrià Casajús | e343b27fa6 | |
Adrià Casajús | 6dfb6bb3e4 | |
Adrià Casajús | a5e7da10dd | |
Adrià Casajús | 5ddbca05b2 | |
Faisal Misle | 6c33e0d986 | |
Adrià Casajús | 7cb7b48845 | |
Son Nguyen Kim | 6276ad4419 | |
Son Nguyen Kim | 66c3a07c92 | |
D-Bao | 23a4e46885 | |
Adrià Casajús | 52e6f5e2d2 | |
Son Nguyen Kim | 59c189957f | |
Adrià Casajús | bec8cb2292 | |
Adrià Casajús | 7f23533c64 | |
Adrià Casajús | 62fecf1190 | |
Adrià Casajús | 9d8116e535 | |
Adrià Casajús | 796c0c5aa1 | |
Adrià Casajús | 5a56b46650 | |
D-Bao | e3ae9bc6d5 | |
Son Nguyen Kim | ec666aee87 | |
D-Bao | 2230e0b925 | |
Adrià Casajús | 71fd5e2241 | |
Adrià Casajús | 97cbff5dc9 | |
Adrià Casajús | b6f79ea3a6 | |
Adrià Casajús | 43b91cd197 | |
Son Nguyen Kim | 03e5083d97 | |
Son Nguyen Kim | 1f9d784382 | |
dependabot[bot] | c09b5bc526 | |
Son Nguyen Kim | eba4ee8c2c | |
D-Bao | 1c65094da8 | |
Carlos Quintana | 2a014f0e4b | |
Son Nguyen Kim | b081b6a16a | |
Son Nguyen Kim | 66039c526b | |
Adrià Casajús | f722cae8d6 | |
Son Nguyen Kim | b6286e3c1b | |
Son Nguyen Kim | 26d5fd400c | |
Son Nguyen Kim | b470ab3396 | |
Adrià Casajús | 66388e72e0 | |
Adrià Casajús | 432fb3fcf7 | |
Adrià Casajús | 44e0dd8635 | |
Adrià Casajús | 2ec1208eb7 | |
Adrià Casajús | 87efe6b059 | |
guzlewski | 6a60a4951e | |
Carlos Quintana | b3ce5c8901 | |
Adrià Casajús | 3fcb37f246 | |
Son Nguyen Kim | 62ba2844f3 | |
Carlos Quintana | 9143a0f6bc | |
Adrià Casajús | 48ae859e1b | |
Adrià Casajús | 0a197313ea | |
Son Nguyen Kim | b487b01442 | |
Son Nguyen Kim | 170082e2c1 | |
rubencm | 51916a8c8a | |
Adrià Casajús | 4f2b624cc7 | |
Adrià Casajús | 81eb56e213 | |
Adrià Casajús | 650a74ac00 | |
Adrian Schnell | e6cdabd46e | |
Adrià Casajús | d874acfe2c | |
Adrià Casajús | 0ab53ad49a | |
Adrià Casajús | 92de307c75 | |
Adrià Casajús | 38c93e7f85 | |
Carlos Quintana | f2a840016b | |
Son Nguyen Kim | 54997a8978 | |
Son Nguyen Kim | be6bc7088e | |
Adrià Casajús | ca0cbd911f | |
Adrià Casajús | 0284719dbb | |
Adrià Casajús | 9378b8a17d | |
Adrià Casajús | 3f84a63e6d | |
Adrià Casajús | 5e48d86efa | |
Adrià Casajús | 9dcf063337 | |
Adrià Casajús | 73c0429cad | |
Adrià Casajús | 21e9fce3ba | |
Adrià Casajús | c8ab1c747e | |
Adrià Casajús | 8636659ca9 | |
Adrià Casajús | 7e360bcbd9 | |
Adrià Casajús | 327b672f24 | |
Adrià Casajús | 12b18dd8b1 | |
Adrià Casajús | 0996378537 | |
Adrià Casajús | 0664e3b80c | |
Adrià Casajús | f728b0175a | |
Adrià Casajús | 53ef99562c | |
Adrià Casajús | 363a9932f1 | |
Adrià Casajús | b6ec4a9ac7 | |
Adrià Casajús | 3c36f37a12 | |
Adrià Casajús | 478b1386cd | |
Spitfireap | b849d1cfa7 | |
Adrià Casajús | 0fbe576c44 | |
Son Nguyen Kim | d2360d1a99 | |
Son Nguyen Kim | 420bc56fc8 | |
Son Nguyen Kim | b3e9232956 | |
Son Nguyen Kim | 989358af34 | |
Son Nguyen Kim | 390b96b991 | |
Adrià Casajús | 4661972f97 | |
Son Nguyen Kim | 25743da161 | |
Adrià Casajús | 5bbf6a2654 | |
Adrià Casajús | dace2b1233 | |
Adrià Casajús | afe2de4167 | |
Adrià Casajús | efc7760ecb | |
Adrià Casajús | 90d60217a4 | |
Adrià Casajús | 3bc976c322 | |
Son Nguyen Kim | 36d1626972 | |
Adrià Casajús | 6d8fba0320 | |
Son Nguyen Kim | 02f42821c5 | |
Adrià Casajús | a5056b3fcc | |
Adrià Casajús | f6463a5adc | |
Adrià Casajús | 7f9ce5641f | |
Adrià Casajús | d324e2fa79 | |
Son Nguyen Kim | 2f769b38ad | |
Son Nguyen Kim | 87047b3250 | |
Adrià Casajús | 300f8c959e | |
Son Nguyen Kim | 8c73ff3c16 | |
Son Nguyen Kim | 9b452641a8 | |
Son Nguyen Kim | 35470613d3 | |
Son Nguyen Kim | c71824c68e | |
Son Nguyen Kim | 1fc75203f2 | |
Son Nguyen Kim | 3a4dac15f0 | |
Son Nguyen Kim | 7b24cdd98a | |
Son Nguyen Kim | 851ba0a99a | |
Son Nguyen Kim | 3be75a1bd9 | |
Adrià Casajús | 72277211bb | |
Adrià Casajús | d5ca316e41 | |
Son Nguyen Kim | f3bfc6e6a1 | |
mfmw123 | 21ce5c8e10 | |
Son Nguyen Kim | 1c5a547cd0 | |
Son Nguyen Kim | 5088604bb8 | |
Son Nguyen Kim | 4ff158950d | |
Son Nguyen Kim | d159a51de4 | |
Son Nguyen Kim | 002897182e | |
Adrià Casajús | faeddc365c | |
Adrià Casajús | faaff7e9b9 | |
Son Nguyen Kim | d415974e3b | |
Carlos Quintana | fa50c23a43 | |
Son Nguyen Kim | 3900742d1f | |
Son Nguyen Kim | 72a130e225 | |
Adrià Casajús | b5aff490ef | |
Son Nguyen Kim | 2760b149ff | |
Adrià Casajús | 9c86e1a820 | |
Son Nguyen Kim | 753a28e886 | |
Carlos Quintana | f47661c3d2 | |
Son Nguyen Kim | 6595d34276 | |
Son Nguyen Kim | 192d03fd68 | |
Son Nguyen Kim | 313a928070 | |
PurpleSn0w | 48127914c2 | |
Son Nguyen Kim | cea139b7d5 | |
Son Nguyen Kim | 25773448c2 | |
Son Nguyen Kim | 96e6753c95 | |
Son Nguyen Kim | 2b389cbe53 | |
Son Nguyen Kim | ae2cbf98e2 | |
Son Nguyen Kim | f69c9583fb | |
Son Nguyen Kim | 72256d935c | |
Son Nguyen Kim | fd00100141 | |
Son Nguyen Kim | 9eacd980ef | |
Son Nguyen Kim | b299a305b5 | |
Carlos Quintana | ba06852dc2 | |
Carlos Quintana | 7eb44a5947 | |
Thanh-Nhon NGUYEN | 7476bdde4b | |
Carlos Quintana | 596dd0b1ee | |
Adrià Casajús | 3a75686898 | |
Carlos Quintana | a9549c11d7 | |
Son Nguyen Kim | a88a8ff2be | |
Son Nguyen Kim | 6c6deedf47 | |
melbv | f340c9c9ea | |
Son Nguyen Kim | 69d5de8d41 | |
Son Nguyen Kim | d72226aa19 | |
Son Nguyen Kim | abe0e0fc46 | |
Carlos Quintana | a04152a37f | |
Adrià Casajús | 54466389c5 | |
Adrià Casajús | 25fde11a86 | |
Adrià Casajús | bd044304f0 | |
Adrià Casajús | f4c5198055 | |
Son Nguyen Kim | 97805173cb | |
Son Nguyen Kim | c3c0b045db | |
Carlos Quintana | 827e3a1acb | |
Son Nguyen Kim | 4f4a098b9b | |
Son Nguyen Kim | 125538748d | |
Son Nguyen Kim | 6322e03996 | |
Carlos Quintana | 7db3ec246e | |
Adrià Casajús | 598d912f2e | |
Adrià Casajús | 3fa9db9bb7 | |
Adrià Casajús | 06c1c7f2f7 | |
Son Nguyen Kim | 8773ed199a | |
Adrià Casajús | f3d47a1eaa | |
Son Nguyen Kim | 750b6f9038 | |
Son Nguyen Kim | c5773af6a8 | |
Adrià Casajús | afb2ab3758 | |
Son Nguyen Kim | 36547bd82d | |
Adrià Casajús | 2837350204 | |
Adrià Casajús | bcd4383e05 | |
Adrià Casajús | 67be5ba050 | |
Son Nguyen Kim | f367acbeaf | |
Son Nguyen Kim | b742f58829 | |
Adrià Casajús | 2bc088cad7 | |
Adrià Casajús | f75bdd006a | |
Son Nguyen Kim | 288f086a55 | |
Adrià Casajús | 82d0f44cab | |
Son Nguyen Kim | 6aeb710ca0 | |
Adrià Casajús | 494005eaa5 | |
Son Nguyen Kim | 8fffe72910 | |
Carlos Quintana | 38d305da23 | |
Adrià Casajús | c2bb6488e4 | |
Adrià Casajús | 046748c443 | |
Carlos Quintana | e2f9ea4ae1 | |
Son Nguyen Kim | 6d86e64d65 | |
Son Nguyen Kim | 2f9301eb97 | |
Adrià Casajús | 38c9138cdb | |
Son Nguyen Kim | 66a2152ea3 | |
Son Nguyen Kim | 02b39f98b7 | |
Son Nguyen Kim | 8799691f99 | |
Adrià Casajús | aabcc8e72a | |
Adrià Casajús | 88dd07e48d | |
Son Nguyen Kim | 93968d00b6 | |
Adrià Casajús | 8b89a428e0 | |
Adrià Casajús | 21feced342 | |
Adrià Casajús | c85ed7d29e | |
Son Nguyen Kim | 44ddd95730 | |
Carlos Quintana | d06470a3c6 | |
Carlos Quintana | 9abb8aa47f | |
Carlos Quintana | cb7868bdca | |
Son Nguyen Kim | f6a7ee981a | |
Son Nguyen Kim | 90b767169b | |
Son Nguyen Kim | 75c710a6ab | |
Adrià Casajús | aac493ad2f | |
Carlos Quintana | 07b7f40371 | |
Son Nguyen Kim | 89062edc06 | |
Carlos Quintana | dd0598a4dd | |
Adrià Casajús | 5fa41d6ccf | |
Carlos Quintana | 686f4f3f68 | |
Carlos Quintana | f58c4a9a50 | |
Adrià Casajús | de31e6d072 | |
Adrià Casajús | 9cc9d38dce | |
Son Nguyen Kim | 09cec0cdec | |
Adrià Casajús | db6ec2dbe6 | |
Adrià Casajús | 99ce10a1bc | |
Adrià Casajús | fbb59a1531 | |
Carlos Quintana | fb1e14e509 | |
Carlos Quintana | 1798d411a4 | |
Carlos Quintana | 5ee5e386e5 | |
Carlos Quintana | ba6c5f93ac | |
Carlos Quintana | 332fcb27d9 | |
Carlos Quintana | 58990ec762 | |
Carlos Quintana | b4e3c39329 | |
Carlos Quintana | 3b47e79fae | |
Carlos Quintana | cf5ff6fa23 | |
Son Nguyen Kim | 39aeb81f9a | |
Son Nguyen Kim | 715ce33b09 | |
Adrià Casajús | 3d3d408a8f | |
Son Nguyen Kim | 83d58c7bca | |
Adrià Casajús | efa534fd3e | |
Son Nguyen Kim | 91b3e05ed6 | |
Carlos Quintana | 56ec95bc93 | |
Son Nguyen Kim | a0a92a7562 | |
Son Nguyen Kim | 0afd414a66 | |
Adrià Casajús | a9a44c378a | |
Carlos Quintana | c0fe10def6 | |
Carlos Quintana | c0a4c44e94 | |
Adrià Casajús | faf67ff338 | |
Son Nguyen Kim | 9cf2f44166 | |
Son Nguyen Kim | 84fcc9ddc4 | |
Adrià Casajús | e688f04d6b | |
Thanh-Nhon NGUYEN | cbd44c01f5 | |
Carlos Quintana | dba56f0dae | |
Adrià Casajús | 7ba9bcb9e2 | |
Son Nguyen Kim | 4a839d9a55 | |
Adrià Casajús | b30d7d2565 | |
Adrià Casajús | 653be79eb2 | |
dependabot[bot] | 62882ecaa8 | |
Son Nguyen Kim | f14e003a38 | |
Adrià Casajús | 2b8f7139b8 | |
Son Nguyen Kim | 6b3ff6f9d9 | |
Adrià Casajús | 687b51be0f | |
Carlos Quintana | 5ab943e12c | |
Carlos Quintana | 8c6c144ba2 | |
Carlos Quintana | 0064729ca7 | |
Carlos Quintana | ed9d2ed816 | |
Adrià Casajús | b26d04e82c | |
Adrià Casajús | 0dfc6c0b0d | |
Son | 631254a1cd | |
Son | 3897d723ea | |
Son Nguyen Kim | 08eb041a28 | |
Adrià Casajús | 5c5bafea18 | |
Son | e5f23e3517 | |
Son | 9e8a419994 | |
Son | 0f9232eeeb | |
Son | 9ba5464bc9 | |
Son | 53a050d4d1 | |
Son | b8e3db3e11 | |
Son | 471003c631 | |
Son | 2a2a72342d | |
Son | 07f5267c5a | |
Son Nguyen Kim | 6afe86b395 | |
Son Nguyen Kim | d879a0c62b | |
Son | 11e0cbfe9c | |
Son Nguyen Kim | cfa46e18fc | |
Son Nguyen Kim | a90e880b24 | |
Son Nguyen Kim | c87e503701 | |
Adrià Casajús | ef7dac6da0 | |
Adrià Casajús | 220f21bb2a | |
Carlos Quintana | afb7f5ef42 | |
Carlos Quintana | 521f6b5822 | |
Son | a7d419eec3 | |
Carlos Quintana | f7e27ce0da | |
Son | 47246d15cf | |
Carlos Quintana | 893520c361 | |
Carlos Quintana | a1f37f0841 | |
Carlos Quintana | e5770de329 | |
Son Nguyen Kim | 0e3be23acc | |
Carlos Quintana | 7ce4aa6e96 | |
Carlos Quintana | 6e905f769d | |
Carlos Quintana | 39b5fa50d8 | |
Adrià Casajús | f17043124e | |
Adrià Casajús | 76e40894e2 | |
Adrià Casajús | 3330625426 | |
Adrià Casajús | 74de29cec8 | |
Carlos Quintana | e4d6f1f117 | |
Carlos Quintana | b4da667a5e | |
Carlos Quintana | a73a15d628 | |
Carlos Quintana | e6acff13e5 | |
snyk-bot | 9595f9d997 | |
Son | 8ca4daf894 | |
Adrià Casajús | e37689dc58 | |
Adrià Casajús | cb5fea033f | |
Adrià Casajús | 88c60f5387 | |
Carlos Quintana | c01db463f7 | |
Son | 11dc28941b | |
Carlos Quintana | d3f4602bb7 | |
Carlos Quintana | 8ac87217d2 | |
Adrià Casajús | a224f4faa6 | |
Son | 41c6e8fd79 | |
Son | e61bf038be | |
Son Nguyen Kim | 4a4d4a5717 | |
Son | 345b3ea4f0 | |
Adrià Casajús | 2adcbf52be | |
Adrià Casajús | 0da2fd94f1 | |
Adrià Casajús | d86a7877a8 | |
Son Nguyen Kim | f0263b812e | |
Carlos Quintana | 5fc8245b8b | |
Son | 54e78786b0 | |
Son | f89967f585 | |
Adrià Casajús | 3578c61366 | |
Adrià Casajús | 64c67f4429 | |
Adrià Casajús | 34ad81c7c0 | |
Adrià Casajús | 8984d11805 | |
Adrià Casajús | 3a48b30f30 | |
Carlos Quintana | a0bcb33bd1 | |
Carlos Quintana | 2bab0e3e7c | |
Adrià Casajús | 3e0cb546a2 | |
Adrià Casajús | 7235de8e73 | |
Carlos Quintana | bc48ec0e9f | |
Carlos Quintana | 2e62a9f00c | |
Carlos Quintana | bef71b7be3 | |
Carlos Quintana | 933237e73b | |
Carlos Quintana | 3872626747 | |
Carlos Quintana | 710f4d0709 | |
Adrià Casajús | 52cd9d2692 | |
Adrià Casajús | 75dd20ebcc | |
Adrià Casajús | 6e948408c6 | |
Adrià Casajús | 0c896100a4 | |
Adrià Casajús | bfb1ae6371 | |
Adrià Casajús | 39b035a123 | |
Adrià Casajús | 9066116b7e | |
Adrià Casajús | 4d07bc9d31 | |
Adrià Casajús | 8b3dc765fa | |
Adrià Casajús | 514f5c8baa | |
Adrià Casajús | 28cc678c5c | |
Adrià Casajús | bf577a6021 | |
Adrià Casajús | e584268219 | |
Adrià Casajús | 6880fe2150 | |
Carlos Quintana | 0ed45f54c6 | |
Adrià Casajús | caff70ea38 | |
Carlos Quintana | d6a50ff864 | |
Carlos Quintana | 975eacc969 | |
Carlos Quintana | 9959848d74 | |
Carlos Quintana | c3792dc333 | |
Carlos Quintana | 6b36651def | |
Adrià Casajús | 19e30eaf0a | |
Adrià Casajús | 5dde39eb37 | |
Adrià Casajús | 2660c96fa7 | |
Carlos Quintana | 79f6b2235e | |
Carlos Quintana | 7b71eab5d4 | |
Carlos Quintana | 69e2f48d13 | |
Adrià Casajús | dde25678cd | |
Adrià Casajús | b17af67614 | |
Carlos Quintana | e01bae6206 | |
Carlos Quintana | cbcd4ea92f | |
Adrià Casajús | 9f43a33c09 | |
Adrià Casajús | bc41fdec35 | |
Son | f3b41279a9 | |
Son | 7d591baea5 | |
Son | d2fad44003 | |
Son | 2573c68e82 | |
Adrià Casajús | b3645b33dd | |
Adrià Casajús | 4f8a590ef9 | |
Adrià Casajús | 42159dce4d | |
Adrià Casajús | 924f286db1 | |
Adrià Casajús | a8c01a1443 | |
Carlos Quintana | e09d7a2b71 | |
Adrià Casajús | dc51ad4f11 | |
Carlos Quintana | 243fc7b7ab | |
Carlos Quintana | 709d5a8866 | |
Adrià Casajús | 3aab1e02b5 | |
Adrià Casajús | 48554369bd | |
Son | e1ca90a28e | |
Son | 998e1d7aef | |
Adrià Casajús | d2111d4768 | |
Adrià Casajús | 6c13f7de05 | |
Adrià Casajús | a2f141d3cc | |
Carlos Quintana | 26da6ea7c5 | |
Son Nguyen Kim | b70170cf0c | |
Son | 21255866b6 | |
Son | b1b3c15a9f | |
Son | 4dbbc4ed5e | |
Son | 44b0aba4f3 | |
Adrià Casajús | 8b80b72665 | |
Son Nguyen Kim | 22e554b785 | |
Carlos Quintana | 5b60ef1e35 | |
Son Nguyen Kim | 7e00dfddc3 | |
Adrià Casajús | 3ccca2e02e | |
Carlos Quintana | c95bfb80a2 | |
Son | 4e2baf0169 | |
Son | b720dfc381 | |
Adrià Casajús | a92981c52d | |
Carlos Quintana | 8d4683e59e | |
Adrià Casajús | f2d761c61b | |
Son Nguyen Kim | 6d49ae62d4 | |
alpha-tango-kilo | cb177a10a2 | |
Adrià Casajús | c48247e852 | |
Adrià Casajús | 67dad33b70 | |
Adrià Casajús | 66c6db773f | |
Adrià Casajús | eea436875a | |
Adrià Casajús | 6936d99779 | |
Son Nguyen Kim | beea14ef14 | |
Adrià Casajús | 56159765d9 | |
Son Nguyen Kim | 65bc6c7fdf | |
Son | 2de5161cd2 | |
Son | e74362dd9f | |
Son | c748ab22e6 | |
Adrià Casajús | ba46ce5208 | |
Adrià Casajús | baddc0fe67 | |
Adrià Casajús | e62022f032 | |
Adrià Casajús | cca709ed48 | |
Adrià Casajús | 884407d6c8 | |
Adrià Casajús | 04399e827e | |
Son Nguyen Kim | 3fa820fc2b | |
Adrià Casajús | 1f040fcebc | |
Son | a2c477a816 | |
Adrià Casajús | 46646f4ee2 | |
Adrià Casajús | bb4207c3a1 | |
Adrià Casajús | 7190df9c4e | |
Adrià Casajús | 89fe4387e5 | |
Carlos Quintana | 8fedceb090 | |
Adrià Casajús | 74b31eac66 | |
Carlos Quintana | 0a34c1547f | |
Adrià Casajús | 7fd9bdc5a7 | |
Adrià Casajús | 8e35a09788 | |
Adrià Casajús | f9a390c1a2 | |
Adrià Casajús | 9a04376894 | |
Adrià Casajús | 25c3626226 | |
Son Nguyen Kim | 93ae82aa46 | |
Son Nguyen Kim | b85f0952a5 | |
Son | 845b53b03f | |
Son | 7b7cb0b571 | |
Son Nguyen Kim | 69d1875be1 | |
Son | eab7606f93 | |
Adrià Casajús | 7d38c41d52 | |
Adrià Casajús | 83a8d439e5 | |
Son Nguyen Kim | 2fc2c85c5e | |
Adrià Casajús | 657cae53a6 | |
Adrià Casajús | ff33380bed | |
Son | d1447e293d | |
Son | e01eff8755 | |
Son | f6320d5321 | |
Son Nguyen Kim | f5a5a06e19 | |
Adrià Casajús | 5208c549fa | |
Son | 58b332b7bc | |
Adrià Casajús | fcd2ab6fed | |
Adrià Casajús | 89d94963d7 | |
Son Nguyen Kim | 5053d9f1f5 | |
Son | 8cf58d7e24 | |
Son | af1c2e5556 | |
Son | 68ec159d91 | |
Son Nguyen Kim | 2bcc22c391 | |
Son Nguyen Kim | e5943dcdc6 | |
Son Nguyen Kim | a886fb70f2 | |
Carlos Quintana | d0dcf1f148 | |
Adrià Casajús | 0f14c3e74e | |
Son | db8359fca6 | |
Adrià Casajús | 112b2c77c3 | |
Son | 0f7ccec51a | |
Adrià Casajús | c573ef655e | |
Son Nguyen Kim | 99d31698e7 | |
Son | b61670fbc0 | |
Son | b3bb0cf250 | |
Son | bddb5e500a | |
Adrià Casajús | af24876c71 | |
Son Nguyen Kim | 0dae9091ab | |
Son Nguyen Kim | a26927f96e | |
Son Nguyen Kim | c14e01839e | |
Son | b545ebaeb1 | |
Son | 01fd880902 | |
Son | 0f1e290461 | |
Son Nguyen Kim | 9b624edf11 | |
Son | e136fc8c92 | |
Son Nguyen Kim | 6eb6283c78 | |
Adrià Casajús | bad9202cf8 | |
Son Nguyen Kim | 259851a04e | |
Son | becde6458b | |
Son | 9a994ec98b | |
Son | aaccfc6f9d | |
Son | 199ec09554 | |
Son | cb8b20fc9a | |
Son | 8dfdac79bf | |
Son Nguyen Kim | 6f7ab01487 | |
Son | 06874ea97c | |
Son | 0565ca4d5e | |
Son | a966665478 | |
Son Nguyen Kim | 72464bd959 | |
Son | 7edbc3a5d5 | |
Son | 217518c00e | |
Adrià Casajús | d28980a810 | |
Adrià Casajús | 4bcc728222 | |
Son | debed67c68 | |
Son | a957cbb3c0 | |
Son | 1709de93ef | |
Son | 95770de4d5 | |
Son Nguyen Kim | 80a45b4b07 | |
Adrià Casajús | fc13171f3d | |
Adrià Casajús | ca93c8e603 | |
Son Nguyen Kim | 2fd3d268e9 | |
Adrià Casajús | 0f91effce9 | |
Adrià Casajús | 9928525cf9 | |
Son Nguyen Kim | 7a0fd34823 | |
Son Nguyen Kim | 0a9c103ad1 | |
Adrià Casajús | 2b149747f5 | |
Adrià Casajús | 8da4293305 | |
Adrià Casajús | edf34656b6 | |
Adrià Casajús | c16fd25b2e | |
Adrià Casajús | dbc55c50a2 | |
Son Nguyen Kim | 9d6ba0a9b3 | |
Adrià Casajús | ae8824a356 | |
Adrià Casajús | 7649f6b822 | |
Adrià Casajús | dc59b61fba | |
Adrià Casajús | f333bb00c5 | |
Adrià Casajús | 60a070731e | |
Adrià Casajús | 7fdd7d7f6a | |
Adrià Casajús | 0dbe504329 | |
Adrià Casajús | 8df6d98522 | |
Adrià Casajús | 68e58c0876 | |
Son | 42f89b71d7 | |
Son | d26fc6ecf0 | |
Adrià Casajús | b128d64563 | |
Son Nguyen Kim | a611b90593 | |
Adrià Casajús | 44c77439c1 | |
Adrià Casajús | 33e83fc153 | |
Adrià Casajús | 0e3c46d944 | |
Adrià Casajús | 61b8bbdfcc | |
Adrià Casajús | 8ca1be0166 | |
Son Nguyen Kim | 936fa17005 | |
Son | 754bd4964c | |
Son | 9aeceb9119 | |
Son | 43a6c87fd6 | |
Son | c83bea6650 | |
Adrià Casajús | 26889283d3 | |
Adrià Casajús | c9a15f4921 | |
Adrià Casajús | 451e69a3c4 | |
Son Nguyen Kim | 358d777b9e | |
Adrià Casajús | dce9e633bf | |
Adrià Casajús | db06ce0ae6 | |
Son | 215561dec1 | |
Son | 1b5521efcf | |
Son | 67c2c6afad | |
Son Nguyen Kim | 110f2f2f2c | |
Son Nguyen Kim | f7a98bc7d2 | |
Adrià Casajús | 87ec7e05de | |
Adrià Casajús | 83fc8964a8 | |
Son Nguyen Kim | d561bae7dd | |
Son Nguyen Kim | 90508c7ee7 | |
Adrià Casajús | 1555bc6346 | |
Adrià Casajús | 19e87a7156 | |
Adrià Casajús | b15facb6e4 | |
Adrià Casajús | 77faff5f7c | |
Adrià Casajús | 97ef5ff765 | |
Adrià Casajús | a9e31cff26 | |
Adrià Casajús | c5b0f5304e | |
Adrià Casajús | d6df5e0ea0 | |
Adrià Casajús | e91fd26964 | |
Adrià Casajús | 8963a92f30 | |
Adrià Casajús | fe9161b101 | |
Adrià Casajús | ac9b88f87d | |
Adrià Casajús | 085c166cb2 | |
Adrià Casajús | 7d36256b7c | |
Son | b0023981af | |
Son Nguyen Kim | af85b3a997 | |
Son | 8820cecdd3 | |
Son | 4dbe22d856 | |
Son | 0d7d56c0ea | |
Son Nguyen Kim | beee438445 | |
Son | 159d30820e | |
Son | 75da6d7027 | |
Son | 334365e853 | |
Son | 17d9190309 | |
Son | 63b1100a8b | |
Son | ce2d2a3b3a | |
Son Nguyen Kim | 2a4d2d723b | |
Adrià Casajús | e5fa90cf04 | |
Son | 32fd65b69b | |
Son | 37de10e54c | |
Son | cb92f1efea | |
Son | e11c257571 | |
Son | 4fc450720f | |
Son Nguyen Kim | 3d30870395 | |
Son Nguyen Kim | 99b05034b0 | |
Adrià Casajús | 517bcb632e | |
Son Nguyen Kim | ed92941bed | |
Adrià Casajús | 51b479c64f | |
Son | 5b3688b6df | |
Adrià Casajús | ce6ee1a105 | |
Adrià Casajús | 93b06fe30c | |
Adrià Casajús | 1b2d504b3b | |
Adrià Casajús | 5f831d593a | |
Son Nguyen Kim | a783b78a7f | |
Adrià Casajús | 45459d65be | |
Adrià Casajús | 16275620ae | |
Son | f554375f23 | |
Son Nguyen Kim | 7464588144 | |
Son | 2baebe7934 | |
Son | 1952f368a8 | |
Son | 9dc7cff87f | |
Son | a662ef4aee | |
Adrià Casajús | 4d13e0c2b8 | |
Adrià Casajús | 35b47f4698 | |
Adrià Casajús | 9930433d21 | |
Adrià Casajús | 06a1363e92 | |
Adrià Casajús | cdea0f5ee2 | |
Adrià Casajús | d53ea381a0 | |
Adrià Casajús | 4a533bb03b | |
Adrià Casajús | 44dd06fabf | |
Adrià Casajús | c9cbaeb460 | |
Adrià Casajús | e8013f8e0c | |
Son | 0931642d11 | |
Son | 7f4357a329 | |
Son | fa2f83dbf4 | |
Son | cd693eda69 | |
Son | 93009158a8 | |
Son | 7e0992b767 | |
Son Nguyen Kim | 79154378f2 | |
Son Nguyen Kim | 6d52daee21 | |
Carlos Quintana | ed58e811d1 | |
Adrià Casajús | 479a7420cb | |
Adrià Casajús | b463ba8f41 | |
Adrià Casajús | bf177ac5ba | |
Adrià Casajús | 9b16143e59 | |
Adrià Casajús | 553d8976be | |
Adrià Casajús | b44904bc15 | |
Adrià Casajús | 549c6ec7d3 | |
Son | 5127534a00 | |
Adrià Casajús | 4368fd323f | |
Adrià Casajús | d0860cd54d | |
Adrià Casajús | 733efc387c | |
Adrià Casajús | 98c942d84a | |
Adrià Casajús | bc82bab1eb | |
Adrià Casajús | 1d15af53b7 | |
Son Nguyen Kim | 9807d32159 | |
Son | ed12e47077 | |
Son | e0b5bd36a6 | |
Son | fb00c18d5a | |
Son | 0e3a5c3d3c | |
Son Nguyen Kim | aa5c86605a | |
Adrià Casajús | b35b13b764 | |
Son | b6b917eba8 | |
Son | 6f80edfd64 | |
Son | b711743d6e | |
Son | 89218fab7f | |
Son | ed089109bb | |
Son | a64a70cbc8 | |
Son | 350f498b94 | |
Son | 99dc45e09a | |
Son | 71136669e9 | |
Son | f7ba3873d0 | |
Son | 52a911f9d3 | |
Son | b2d8f5a017 | |
Son | 627b2e56d9 | |
Son | 8502e1666b | |
Son | 3d1a960702 | |
Son Nguyen Kim | 6a520e110c | |
Son Nguyen Kim | d4867dc524 | |
Son | 205d8d7d3f | |
Son | 4faf0d7636 | |
Son | fa95f4273d | |
Son | 9c67aad34d | |
Son Nguyen Kim | ad54c7ece0 | |
Adrià Casajús | c2ae38ec8f | |
Adrià Casajús | 61d1655529 | |
Son Nguyen Kim | 7df93c2ee5 | |
Son | 6c8d4310e5 | |
Son | 007aa56551 | |
Son | 51598ada02 | |
Carlos Quintana | e9dd73e99b | |
Son Nguyen Kim | 4df32b3b03 | |
Adrià Casajús | 3d498b4eae | |
Adrià Casajús | 0c008edc82 | |
Adrià Casajús | 77cf5d9620 | |
Adrià Casajús | 01cc65bdca | |
Carlos Quintana | 8f339923f8 | |
Son | 7da06ba424 | |
Son Nguyen Kim | e9d134fe8f | |
Son Nguyen Kim | e55c3a155b | |
Adrià Casajús | 4b13d5a28c | |
Son | 8fc5fd6d16 | |
Son Nguyen Kim | 7d008228e3 | |
Son | f8640bfc91 | |
Son Nguyen Kim | bfcd75bdea | |
Carlos Quintana | ee9170bb17 | |
Adrià Casajús | 33163660f7 | |
Adrià Casajús | 3e983e3557 | |
Adrià Casajús | b069f81920 | |
Adrià Casajús | b0ac2f871a | |
Adrià Casajús | 398c1a55f1 | |
Adrià Casajús | 780f5b75aa | |
Son Nguyen Kim | be161d0778 | |
Adrià Casajús | 0dfbe1bca4 | |
Adrià Casajús | 17c6923ddc | |
Adrià Casajús | 1b525a55a5 | |
Adrià Casajús | 15ce7b00d8 | |
Carlos Quintana | 2a751624a8 | |
Carlos Quintana | b4e291d4fd | |
Carlos Quintana | 6be99bc576 | |
Carlos Quintana | a44acf1846 | |
Son Nguyen Kim | 88ed4b8d2b | |
Adrià Casajús | 92ec70c497 | |
Carlos Quintana | 39222cf868 | |
Carlos Quintana | 2f9489fe39 | |
Son | c947e7cbd5 | |
Son | 4d23134372 | |
Son | 728d935d65 | |
Son | 1e7d224f35 | |
Son | bef3b8bd96 | |
Son | c3cd1419f9 | |
Son | a0bb4e9ccc | |
Son | 473d0350ca | |
Son | 5c0bfe2f34 | |
Son | ea00e2ba8f | |
Son | 634ad4ac19 | |
Son Nguyen Kim | 69c8980c18 | |
Adrià Casajús | d24ee42240 | |
Adrià Casajús | 416e7b363a | |
Adrià Casajús | 305ce38379 | |
Son Nguyen Kim | f2d02e6f93 | |
Adrià Casajús | 700856053a | |
dependabot[bot] | c8ca51fc5e | |
Adrià Casajús | 8120128a51 | |
Adrià Casajús | 639d4412e1 | |
Adrià Casajús | c9974d5321 | |
Adrià Casajús | 3fedc84c95 | |
Son Nguyen Kim | c18f9658b0 | |
Adrià Casajús | e844c9a392 | |
Son Nguyen Kim | 5121a3c2d9 | |
Son | fbf3d49717 | |
Adrià Casajús | f59c5499fb | |
Jakob Yanagibashi | 01bf037638 | |
Adrià Casajús | 8aee883aae | |
Adrià Casajús | 95fa95649d | |
Adrià Casajús | e57dcac2d2 | |
Adrià Casajús | 219d5b998f | |
Son | 5b62f5a745 | |
george | bbb2ac64b9 | |
george | 68462f2d8f | |
george | 83434c3212 | |
george | 813e83d673 | |
george | 05e1208d57 | |
george | c415324932 | |
george | 9999f7de1e | |
george | 17e7635dab | |
george | e68363dbbc | |
george | 7f765e83b7 | |
george | 4800274b33 | |
george | 116fc7114a | |
george | f7be992437 | |
george | 87a327912f | |
george | 42b9471a8f | |
Son | cca23b753c | |
Son | 5da31f53b4 | |
george | 936d90a5f5 | |
Son Nguyen Kim | 68acfc986a | |
Son Nguyen Kim | 2d980b8990 | |
george | e6276dc32e | |
george | 172e509f53 | |
george | 5815ee0b2e | |
george | 3a5f077bbf | |
george | 3837a9955e | |
george | 836e599517 | |
george | 010c343641 | |
george | 709ccb176a | |
george | c0712a6b95 | |
Son Nguyen Kim | a436859a55 | |
Son Nguyen Kim | 72a3e118c8 | |
Son Nguyen Kim | ed02438c10 | |
Son Nguyen Kim | eee8a5bf97 | |
Son Nguyen Kim | 377e94b883 | |
Son Nguyen Kim | becb0b50bb | |
Son Nguyen Kim | 4c9ae778e7 | |
george | 17353c306c | |
george | 8f8a8b875b | |
george | 1f04dfad61 | |
dependabot[bot] | 1a74269ff1 | |
dependabot[bot] | 429fc3ae51 | |
dependabot[bot] | 2e896e28e1 | |
dependabot[bot] | 7caffc0a4f | |
dependabot[bot] | d8f246c3e2 | |
dependabot[bot] | d6af7e8362 | |
Son | b490acead8 | |
Son | d570868dcf | |
Son | 4d1c4cfdff | |
Son | 70cb0609d8 | |
Son | 730cc14cca | |
Son Nguyen Kim | 543923b325 | |
Son | 043d62bf20 | |
Son | 049bd746ad | |
Son | 5a712f3877 | |
george | 285c1d10cf | |
george | 74713c2142 | |
Son Nguyen Kim | 89a800eed9 | |
Son | f1c0b94ffd | |
Son Nguyen Kim | 227087a10f | |
Son | 3be4f341a2 | |
Son | fc3f06f4d8 | |
Son | 78c14fa67e | |
Son | 90fa4abf69 | |
Son | bdb97e73e9 | |
Son | 1de6fefc59 | |
Son | 5b7949f346 | |
Son | 3422f038eb | |
george | 65531b5c63 | |
Son | e73288354d | |
george | ab72927a16 | |
george | 50122da0fe | |
george | 42407a0543 | |
george | f7f91afc1e | |
george | 6b4d276ffe | |
george | 6d736aa915 | |
george | 122a402c22 | |
george | 0eb2984b9c | |
Mr. Blue Coat | 99ff4c6f88 | |
Son | b929dc5462 | |
Mr. Blue Coat | 724edee311 | |
Son | efdb0a60d3 | |
Son | 94ecdb0515 | |
Mr. Blue Coat | 11fe7f6f65 | |
Mr. Blue Coat | 1caf8e5dcd | |
Son | 0806f9243e | |
Son | 8ff3b5ef8e | |
Son | 0e496518ba | |
Son | 1c8a0c4f16 | |
Son | a72f1bd414 | |
Son | ca18c9c5e0 | |
Son | e73a46cf36 | |
Son | ca971567c5 | |
Son | 841621dbe2 | |
Son | 4cea47cc27 | |
Son | 6cd8e45d21 | |
Son | db24ed8739 | |
Son | 2a1ef7beec | |
Son | 42a29eba90 | |
Son | 5e7ff7a694 | |
Son | 2f6229cd54 | |
Son | c0067b7657 | |
Son | 94bbade62e | |
Son | 73d781cf6b | |
Son | 9c696bd038 | |
Son | 6cdf5637aa | |
Son | a0727435eb | |
Son | f855d27836 | |
Son | c5e4dd6d16 | |
Son | 4298fe73e6 | |
Son | 862d0e7a11 | |
Son | ed4acebdb1 | |
Son | b23f9fa971 | |
Son | 01ba5e8bf0 | |
Son | ed39d47e7a | |
Son | 20b6ce29fc | |
Son | d8627fea97 | |
Son | d4e31257fa | |
Son | ad185ebc3d | |
Son | 2a1d735800 | |
Son | fb87225d2d | |
Son | 746dfae495 | |
Son | d4e1aec875 | |
Son | 6b31b8926e | |
Son | bf75f8e8ab | |
Son | 40b6fde2c3 | |
Son | 12a7e9b3fa | |
Son | 4fae291251 | |
Son | 4c63b4c0f1 | |
Son | 1bdae7fbe8 | |
Son | 5195c9de8b | |
Son | 035d238c75 | |
Son | db30639380 | |
Son | 84d1f22a7b | |
Son | ad622df071 | |
Son | bb6aec8b80 | |
Son | 723d871550 | |
Son | b306abb689 | |
Son | b2e4578953 | |
Son | 01cc9fe388 | |
Son | d1b9fb8bb5 | |
Son | 17e9798bfd | |
Son | 37bb7655d5 | |
Son | 9ff323c746 | |
Son | b7e8324e5a | |
Son | 55e3203512 | |
Son | 33bd7dbcd6 | |
Son | e7c473c943 | |
Son | de9f994fe2 | |
Son | 3fb6dd4aeb | |
Son | 4976f48944 | |
Son | b505ceebe9 | |
Son | 0c25ed939f | |
Son | 778c90a164 | |
Son | 385dd1e755 | |
Son | 6c42872440 | |
Son | ffc621596a | |
Son | 0abfb82fd1 | |
Son | 6cb55e27f3 | |
Son | de23828df1 | |
Son | 5e2ea81a6c | |
Son | 2ed7c5fcdb | |
Son | 4ac8da1e8f | |
Son | 4d8c89105f | |
Son | d51c32ad51 | |
Son | 72bd998b9b | |
Son | bc2f9ad45f | |
Son | 127f8daad7 | |
Son | 3484f71dac | |
Son | a4b113b7fa | |
Son | 1b5f059899 | |
Son | d38fa95eed | |
Son | 1149fe964b | |
Son | 325207d6ba | |
Son | 4332fd3244 | |
Son | 414f6a2463 | |
Son | f548e74e77 | |
Son | 85fb859dcb | |
Son | 08c7aa8b98 | |
Son | 34118f459a | |
Son | 558200113c | |
Son | b24d58bdf3 | |
Son | 01a8a0343e | |
Son | 61226545c2 | |
Son | e3d06f7a1d | |
Son | 9ee449722a | |
Son | a6f5b755aa | |
Son | e1d82b7e0d | |
Son | 30ba566457 | |
Son | 22cf8cfe38 | |
Son | 2cd50c582a | |
Son | 77f1544a1d | |
Son | c5185eddf3 | |
Son | 378bad6253 | |
Son | a64968f6e5 | |
Son | b3469ba9d4 | |
Son | c2e95f0853 | |
Son | c14a7b4f7a | |
Son | d80ecfb068 | |
Son | f439e39580 | |
Son | 9e019ae98a | |
Son | d8f7323b95 | |
Son | 8530abfb2d | |
Son | 733a9c42b0 | |
Son | 80b0af91e5 | |
Son | 335a89f912 | |
Son | b9e2a79933 | |
Son | e752e466e1 | |
Son | a270c72d60 | |
Son | 229dc7fd44 | |
Son | 83be94b43e | |
Son | bd614278df | |
Son | fc42db43ca | |
Son | 922fa4925e | |
Son | 24a392818b | |
Son | 2cf1c4143a | |
Son | 32fffeaa6e | |
Son | a6569d47dd | |
Son | f0e582c1a6 | |
Son | 584772f798 | |
Son | 879b364a47 | |
Son | 544df7034d | |
Son | 75d6b1dab5 | |
Son | 64c6ef2cbe | |
Son | a142a430d2 | |
Son | eec2880c41 | |
Son | 79ca39a625 | |
Son | 5e7730c35c | |
Son Nguyen Kim | 0d7d451313 | |
Florent Marquez | 3997269670 | |
snyk-bot | 4f84c0d1c9 | |
Son | f3e8fc10a9 | |
Son | a021bba811 | |
Son | 40299cbf34 | |
Son | c6a2af3c3c | |
Son | c878e07c78 | |
Son | 3e2c120a73 | |
Son | 7109dc7120 | |
Son | daca70f2b4 | |
Son | 226ce9333c | |
Son | e1123961cf | |
Son | 57ec92ed7c | |
Son | 4f9bb59b58 | |
Son | 4d388a202c | |
Son | 5dab819ac3 | |
Son | c6f49821c7 | |
Son | 34509cbbb3 | |
Son | 4ffa5c9345 | |
Son | 94a90665ea | |
Son | 47f37fae25 | |
Son | 5dbc42a6a7 | |
Son | 57b390733d | |
Son | 20dfcfb88c | |
Son | 21bd4ed97e | |
Son | c29b5100fb | |
Son | 325a1a9524 | |
Son | d875b2e0e5 | |
Son | 5f47d172e0 | |
Son | 5ea087e7a3 | |
Son | c0c490517a | |
Son | c356c75494 | |
Son | 4d0f6811b2 | |
Son | 06d459ba99 | |
Son | 6d1b6720cf | |
Son | dd6e265aa0 | |
Son | 4c33b63f97 | |
Son | cd553608a5 | |
Son | f049da8c9a | |
Son | eeb24f594a | |
Son | 64d2e7804e | |
Son | 55ae61527d | |
Son | 2d9f8e83e6 | |
Son | b5c2d9ee2a | |
Son | 3add9e6db8 | |
Son | bd8b9526f6 | |
Son | e4f2e1f5a8 | |
Son | 63e1baf46a | |
Son | dc243d6027 | |
Son | 04d6ab519b | |
Son | 77e38e63fe | |
Son | bc936436ef | |
Son | 63f4d15329 | |
Son | a072fdcd96 | |
Son | 30f2734853 | |
Son | 7c7bf15a13 | |
Son | b2c31ef658 | |
Son | d2ed9337f1 | |
Son | fdfa286d3e | |
Son | a17e81a8f1 | |
Son | 6f4c9f6c5a | |
Son | adb376525f | |
Son | 38ecb227b0 | |
Son | 85c6e791bc | |
Son | bccfcee780 | |
Son | ffc04c7fe9 | |
Son | a8c86785d1 | |
Son | 5a81c08e32 | |
Son | 417f7b92b0 | |
Son | 482aa8614c | |
Son | 225a3ae750 | |
Son | 8280acb266 | |
Son | 2b8de82028 | |
Son | 2ce7f3d445 | |
Son | 67377a0f22 | |
Son | 6eb702870c | |
Son | 9c27f94e8e | |
Son | 278a9d19c6 | |
Son | abc074ea9b | |
Son | 6012b6ff54 | |
Son | 96f16b658f | |
Son | d8a23ba9d3 | |
Son | 8c56fde84d | |
Son | eaff8b7ff3 | |
Son | 82e0bcec8e | |
Son | 33e3227b81 | |
Son | 790f0ed23c | |
Son | 324cc8734b | |
Son | 416eafaeb9 | |
Son | bb5259ac3f | |
Son | 611fb8a20c | |
Son | 293cc74c53 | |
Son | 2fad942c95 | |
Son | 4fc6619553 | |
Son | 4c1c8a3dc1 | |
Son | e24b84f6bf | |
Son | 5105c0dbee | |
Son | 4c87e4ce68 | |
Son | e55fae50b8 | |
Son | 72575db8c4 | |
Son | 9f9b470ab8 | |
Son | d7971953ac | |
Son | 89648a83dd | |
Son | 0e24513bcf | |
Son | 827b90432c | |
Son | 429683f444 | |
Son | 38e7a64f4f | |
Son | a1fdbc0caa | |
Son | bcf1fa2510 | |
Son | 383f633e41 | |
Son | bfab753e76 | |
Son | 4ed60ba1d0 | |
Son | 58e92e7462 | |
Son | ef734d7045 | |
Son | 3f1020d5d7 | |
Son | 4214efa497 | |
Son | 516898af59 | |
Son | 4a47e8c9c6 | |
Son | 0de85fdce3 | |
Son | a03d87b62c | |
Son | 914696ef3b | |
Son | f8b6b20dd8 | |
Son | 80bbfb6f4b | |
Son | 5c0cd60659 | |
Son | fd24f6eb1b | |
Son | 014b7d5b1f | |
Son | 0ae40d599a | |
Son | 2aab48a3f9 | |
Son | fa743fc142 | |
Son | 1fd9a344d4 | |
Son | 51a85011b1 | |
Son | ba16234456 | |
Son | 334dc01a1b | |
Son | accbf882c4 | |
Son | 31e39314d5 | |
Son | d81e9fb75f | |
Son | 4369137e25 | |
Son | 368a2f1b47 | |
Son | caa8656748 | |
Son | fd7d9969f8 | |
Son | b50f1d60b2 | |
Son | 52a19818b7 | |
Son | 4a5983993e | |
Son | fbb1451352 | |
Son | d27c19c33a | |
Son | 67a8e0f9cc | |
Son | 48918ba2c1 | |
Son | cb0d992ecc | |
Son Nguyen Kim | 10be304865 | |
Son Nguyen Kim | 7524689e8d | |
Son | f95428a5cc | |
Son | 542310f5ca | |
Viktor Szépe | 271ddb82f2 | |
Viktor Szépe | 103d550347 | |
Viktor Szépe | 597f8cac74 | |
Son | 6b33e66016 | |
Son | 5b5bbcc83c | |
Son | 2546fefa51 | |
Son | 7fef62f67a | |
Son | 603e98d0bf | |
Son | db226c5706 | |
Son | e67969cdcf | |
Son | 2691fff217 | |
Son | e62c5d1591 | |
Son | c0aa45fc6d | |
Son | 74d4aa9f8f | |
Son | ebe727dc53 | |
Son | cf8150b996 | |
Son | 5f6ad21e85 | |
Son | a4dbbb6ac2 | |
Son | 507d10cd89 | |
Son | bc4805b1fa | |
Son | 9620f97449 | |
Son | 02a005d076 | |
Son | 9d3711a98a | |
Son | 35256bcdeb | |
Son | 78ad9fbcc5 | |
Son | 286717dae3 | |
Son | 7c182d20a4 | |
Son | 8aa7b1b773 | |
Son | b41b695228 | |
Son | 04bcc24ad7 | |
Son | 1aff59e112 | |
Son | f19655fc93 | |
Son | a99ac24b72 | |
Son | a0165d6381 | |
Son | 3d071d27a6 | |
Son | 0fbd351bed | |
Son | 83c5eded80 | |
Son | 8cb413d5fd | |
Son | 7e0609c39a | |
Son | d701b84110 | |
Son | 8680c0a739 | |
Son | befec56a86 | |
Son | 8bafdfc879 | |
Son | de0f838950 | |
Son | 9299904fc9 | |
Son | 6468f7c8a5 | |
Son | ef5670b1cf | |
Son | 083b56b9a6 | |
Son | 50b0dc3767 | |
Son | d41ab5f5de | |
Son | ad24f19cd6 | |
Son | b0822519eb | |
Son | ff210394a0 | |
Son | 4a90c79753 | |
Son | ecdce2307f | |
Son | e411e09779 | |
Son | 5843fa94a0 | |
Son | 9fb6e45077 | |
Son | 421c121d59 | |
Son | be7ae3021a | |
Son | c5987bcfbb | |
Son | a2fcfbbb20 | |
Son | 7952ce7ecf | |
Son | c12f3b3e7a | |
Son | 9c653dbacd | |
Son | 1483f2e103 | |
Son | 462164ff16 | |
Son | af221998f3 | |
Son | 7d33f10c05 | |
Son | d6edd59450 | |
Son Nguyen Kim | 9f36f3e2a9 | |
Son | 0b06c46f65 | |
Son | 283a6a530d | |
Job | 742bfd6815 | |
Job | 4e5ca3b30b | |
Job | be82600fe6 | |
Son | 7bfdb821af | |
Son | 34564f6fa4 | |
Son | 4d740a4dc0 | |
Son | d18bb28ca9 | |
Son | 57bfa7e933 | |
Son | 72931aa9b7 | |
Son | fcb94f0331 | |
Son | 7add04accc | |
Son | 3bdeda3e04 | |
Son | e5a7aeb3fb | |
Son | 05cf085511 | |
Son | ced31edda2 | |
Son | cfe88b5df2 | |
Son | fbabe6fb44 | |
Son | 3a0b125323 | |
Son | e13a974e53 | |
Son | 68cf54b2d9 | |
Son | 0ec4a3971c | |
Son | becf789d5e | |
Son | 94a9a1479b | |
Job | 4451e6af33 | |
Job | 961daa91f3 | |
Son | 572f25ff75 | |
Son | b9d26d46f6 | |
Son | c132e3fbbc | |
Son | 2524c8ab98 | |
Son | a8b3955fe6 | |
Son | 4d7cd09847 | |
Son | eb0e327402 | |
Son | 074dd875dc | |
Son | 372466ab06 | |
Son | 653a03ac11 | |
Son | 8394d7340c | |
Son | b602f7e746 | |
Son | ee0ed7d9ec | |
Son | d6fc132df1 | |
Son | 5821294ae9 | |
Son | 9bb83fe3e2 | |
Son | a7f82b2110 | |
Son | 5d7e10f776 | |
Son | fdc23b3107 | |
Son | f525c951c6 | |
Son | ea3ac5697b | |
Son | cef6579946 | |
Son | c7626dd23e | |
Son | 9c528b913c | |
Son | ba6ed3cba7 | |
Son | d622d95c35 | |
Son | 931e924f8c | |
Son | 4638155bbc | |
Son Nguyen Kim | 9acfda0fba | |
prashantkamdar | fbf1ca3395 | |
prashantkamdar | 202fadcfc8 | |
prashantkamdar | 8356a9627d | |
Prashant Kamdar | 71b7c18ae8 | |
Son Nguyen Kim | 9528bdcb2e | |
Son Nguyen Kim | e3f81bc4e4 | |
Son Nguyen Kim | 339d611e63 | |
Son Nguyen Kim | 8301015afd | |
Son Nguyen Kim | 3ad961bfb9 | |
Son Nguyen Kim | 408322217d | |
Son Nguyen Kim | 51a7dbfa52 | |
Son Nguyen Kim | b14534db2c | |
Son Nguyen Kim | 469c2011aa | |
Son Nguyen Kim | 486dd831cf | |
Son Nguyen Kim | 0ed0ac9ea7 | |
Son Nguyen Kim | 7f5201effa | |
Son Nguyen Kim | c5425b0a73 | |
Son Nguyen Kim | d7d301b9c3 | |
Son Nguyen Kim | 2e6b012eff | |
Son Nguyen Kim | 3b16e502b3 | |
Son Nguyen Kim | 3443499ab9 | |
Son Nguyen Kim | 1a32b654d0 | |
Son Nguyen Kim | 7674d8480e | |
Son Nguyen Kim | ff1238a56f | |
Son Nguyen Kim | 16dd35470f | |
Son Nguyen Kim | 8024b35f1d | |
Son Nguyen Kim | d8280af93c | |
Son Nguyen Kim | 5e9fb83150 | |
Son Nguyen Kim | 636879ac1a | |
Son Nguyen Kim | 9279b20975 | |
Son Nguyen Kim | 0075cee1ee | |
Son Nguyen Kim | 3c81f982ca | |
Son Nguyen Kim | 65ce47b6f7 | |
Son Nguyen Kim | 18acfd9a42 | |
Son Nguyen Kim | 19088ba85f | |
Son Nguyen Kim | d9d67df126 | |
Son Nguyen Kim | 8d40392b5c | |
Son Nguyen Kim | bcc5126500 | |
Son Nguyen Kim | 1d09d76cb2 | |
Son Nguyen Kim | 1a6c68e98d | |
Son Nguyen Kim | 2c60414796 | |
Son Nguyen Kim | 84880ae32a | |
Son Nguyen Kim | 1e3afa257c | |
Son Nguyen Kim | f160ebec4e | |
Son Nguyen Kim | 809f547742 | |
Son Nguyen Kim | e5a8ce1492 | |
Son Nguyen Kim | 56c72d5fba | |
Son Nguyen Kim | f36f8b94e2 | |
Son Nguyen Kim | 0055ca976b | |
Son Nguyen Kim | ba3074b94a | |
Son Nguyen Kim | f6fd97ef05 | |
Son Nguyen Kim | 17c13ee37f | |
Son Nguyen Kim | cfb7b7cefc | |
Son Nguyen Kim | 005a760710 | |
Son Nguyen Kim | 0aa3dff38b | |
Son Nguyen Kim | 153831ed1a | |
Son Nguyen Kim | 7bb54e1e8e | |
Son Nguyen Kim | 98b472d925 | |
Son Nguyen Kim | 530bc8591e | |
Son Nguyen Kim | cbc20dd268 | |
Son Nguyen Kim | 838f1dc86d | |
Son | 9b89d7cc5d | |
Son | ec2812bfa4 | |
Son Nguyen Kim | 8b676bc4af | |
Son Nguyen Kim | de3207ac4b | |
Son Nguyen Kim | 344f8e67d2 | |
Son Nguyen Kim | a6c874e914 | |
Son Nguyen Kim | 2b84168d68 | |
Son Nguyen Kim | 0b127216ee | |
Son Nguyen Kim | 58d36e9cd8 | |
Son Nguyen Kim | b990c052ac | |
Son Nguyen Kim | 512ade83b4 | |
Son Nguyen Kim | 785a619385 | |
Son Nguyen Kim | 68d33ea85b | |
Son Nguyen Kim | 981f6ecfb2 | |
Son Nguyen Kim | da0ddd5a34 | |
Son Nguyen Kim | 695a628e68 | |
Son Nguyen Kim | 144418ae47 | |
Son Nguyen Kim | 3441d2ccf1 | |
David Siregar | 1cc8f7f2e3 | |
Son Nguyen Kim | 567bee9a0b | |
Son Nguyen Kim | 8990895dd2 | |
Son Nguyen Kim | e4ed192cce | |
Son Nguyen Kim | 106358da5f | |
Son Nguyen Kim | b3012376c3 | |
Son Nguyen Kim | 6e42e536db | |
Son Nguyen Kim | 62044e6db1 | |
Son Nguyen Kim | f53e8c1af8 | |
Son Nguyen Kim | 4949afc791 | |
Son Nguyen Kim | e5a388dffb | |
Son Nguyen Kim | 61d9f7ee43 | |
Son Nguyen Kim | 41478a5715 | |
Son Nguyen Kim | 638e8137ec | |
Son Nguyen Kim | 3ad4b6b76f | |
Son Nguyen Kim | 500ff00c7c | |
Son Nguyen Kim | 8023afe9be | |
Son Nguyen Kim | 6b65e00dcf | |
Son Nguyen Kim | 6e9dfdd6f1 | |
Son Nguyen Kim | db55f289c1 | |
Son Nguyen Kim | defd7b159d | |
Son Nguyen Kim | 493a5daa45 | |
Son Nguyen Kim | ff2cbeb3af | |
Son Nguyen Kim | a58cf9dd5e | |
Son Nguyen Kim | 4df83f953d | |
Son Nguyen Kim | 5ac78f2694 | |
Son Nguyen Kim | a6e8684afb | |
Son Nguyen Kim | 201eb3b9b9 | |
Son Nguyen Kim | 41f10373d1 | |
Son Nguyen Kim | f3cff1f1bf | |
Son Nguyen Kim | d9f44437da | |
Son Nguyen Kim | 48838eb176 | |
Son Nguyen Kim | b2ac1b537d | |
Son Nguyen Kim | 6dd6b74073 | |
Son Nguyen Kim | b53da25a41 | |
Son Nguyen Kim | ccb526faa1 | |
Son Nguyen Kim | 1df5bec8df | |
Son Nguyen Kim | ffd2ec5e81 | |
Son Nguyen Kim | 3faf5c921d | |
Son Nguyen Kim | 8b86851530 | |
Son Nguyen Kim | ef6388887f | |
Son Nguyen Kim | d6e48ea2e4 | |
Son Nguyen Kim | 40915ad741 | |
Son Nguyen Kim | 4b184998bc | |
Son Nguyen Kim | b5c827c2ea | |
Son Nguyen Kim | 513f5cd4fb | |
boarwell | 8519d06639 | |
Son Nguyen Kim | bae9a6f431 | |
Son Nguyen Kim | 76c1b3d807 | |
Son Nguyen Kim | 51578ce934 | |
Son Nguyen Kim | 00b3d716b7 | |
Son Nguyen Kim | b606d35c11 | |
Son Nguyen Kim | 0a1f545c12 | |
Son | 009e1edced | |
Son | d8cb327b6e | |
Son Nguyen Kim | 59e4dbb6a6 | |
Son Nguyen Kim | 9c6f3989a0 | |
Son Nguyen Kim | aa041708e3 | |
Son Nguyen Kim | f4fead2542 | |
Son Nguyen Kim | 52e2e67081 | |
Son | e03f9d2342 | |
Son | cc86f698ee | |
Son | be418029bd | |
Son | 800e866663 | |
prashantkamdar | 1e2d682351 | |
Son | 1678945d5a | |
prashantkamdar | 173b509706 | |
Son | 0f4ad1a0d4 | |
Son | e5308932a2 | |
prashantkamdar | e22af08e0b | |
prashantkamdar | bf39b924dd | |
prashantkamdar | 5bf8b75a11 | |
Son | 66bafe7439 | |
Son | d9c682a23e | |
Son | 4cbbf260d4 | |
Son | 1384ccc459 | |
Son Nguyen Kim | 888de34a69 | |
boarwell | e0da867b4a | |
Son Nguyen Kim | 2e9b288d7b | |
Son Nguyen Kim | 12150a3656 | |
Son Nguyen Kim | a13953e13f | |
Son Nguyen Kim | 142dcafb99 | |
Son Nguyen Kim | 07c912fd35 | |
Son Nguyen Kim | 006a7b1420 | |
Son Nguyen Kim | 348c2271c6 | |
Son Nguyen Kim | 264bab965a | |
Son Nguyen Kim | 012c6fc3fb | |
Son Nguyen Kim | 2f8f354f28 | |
Son Nguyen Kim | 91d3d11452 | |
Son | 51995954f0 | |
Son | 8d6ff446d8 | |
Son Nguyen Kim | 8640f830f2 | |
Son Nguyen Kim | 9eb3c7cf2c | |
Son Nguyen Kim | 2b048543d3 | |
Son Nguyen Kim | e2fea3aed8 | |
Son Nguyen Kim | 35a9a723aa | |
Son Nguyen Kim | b9d1d10473 | |
Son Nguyen Kim | e9538a62be | |
Son Nguyen Kim | fb29503b81 | |
Son Nguyen Kim | 6dac717c75 | |
Son Nguyen Kim | 383cd49f25 | |
Son Nguyen Kim | 9968cbfa8e | |
Son Nguyen Kim | fcb18e66e8 | |
Son Nguyen Kim | cbcae31288 | |
Son Nguyen Kim | 1cca7d4025 | |
Son Nguyen Kim | 6c12b31060 | |
Son Nguyen Kim | 5821bd6512 | |
Son Nguyen Kim | fb4cb8727c | |
Son Nguyen Kim | 5aef6cceb2 | |
Son Nguyen Kim | dc83c3dd9e | |
Son Nguyen Kim | 77c993b864 | |
Son Nguyen Kim | 91fdf1ade0 | |
Son Nguyen Kim | 52376484a5 | |
Son Nguyen Kim | 39e2750486 | |
Son Nguyen Kim | 67cd7ae3d4 | |
Son Nguyen Kim | 8e72d79837 | |
Son Nguyen Kim | 518c102642 | |
Son Nguyen Kim | d706bbbd4b | |
Son Nguyen Kim | 8ab840933f | |
snyk-bot | 29c5b12680 | |
Son Nguyen Kim | 4f4d487e28 | |
Son Nguyen Kim | 52fb01ed8d | |
Son Nguyen Kim | c0fe72ccd0 | |
Son Nguyen Kim | 8de9931b28 | |
Son Nguyen Kim | a58aaf8399 | |
Son Nguyen Kim | f9b71a4bf4 | |
Son Nguyen Kim | d181cd49dd | |
Son Nguyen Kim | 3141bf1367 | |
Son Nguyen Kim | 06c2114534 | |
Son Nguyen Kim | 55dcd63654 | |
Son Nguyen Kim | ac1f56f206 | |
Son Nguyen Kim | aa799fa339 | |
Son Nguyen Kim | 25f0a71ea5 | |
Son Nguyen Kim | ba6b6e2fdd | |
Son Nguyen Kim | 253be7bad4 | |
Son Nguyen Kim | 189eb8427e | |
Son Nguyen Kim | e26287a4c7 | |
Son Nguyen Kim | b98e913304 | |
Son Nguyen Kim | 45891bed36 | |
Son Nguyen Kim | fb88654d84 | |
Son Nguyen Kim | 7ce83c36b9 | |
Son Nguyen Kim | 80d23b8c4f | |
Son Nguyen Kim | eb8118e89e | |
Son Nguyen Kim | 8583615ba1 | |
Son Nguyen Kim | cbd6c96d01 | |
Son Nguyen Kim | 7ac2a02b27 | |
Son Nguyen Kim | 4b9b3f18a2 | |
Son Nguyen Kim | 7c65d92cc1 | |
Son Nguyen Kim | edab5dfac3 | |
Son Nguyen Kim | 19c067fa17 | |
Son Nguyen Kim | 601385a0c1 | |
Son Nguyen Kim | 7a8b5d80ed | |
Son Nguyen Kim | cc650f9fae | |
Son Nguyen Kim | 99599bb09f | |
Son Nguyen Kim | c011a4b90b | |
Son Nguyen Kim | 183449e38b | |
Son Nguyen Kim | 1e4746dfe5 | |
Son Nguyen Kim | b01fd18951 | |
Son Nguyen Kim | f97b18e60a | |
Son Nguyen Kim | 0b063cb409 | |
Son Nguyen Kim | fe1f8e9eb8 | |
Son Nguyen Kim | 7ae60b9d82 | |
Son NK | f59651045d | |
Son NK | 95d6fa3478 | |
Son | 9a9da53a58 | |
Son NK | 3443b456b5 | |
Son NK | 01815b9153 | |
Son NK | 09d00df363 | |
Son Nguyen Kim | 68c1463707 | |
Raymond Nook | 4469a64de6 | |
Son NK | 6532e0de93 | |
Son NK | 6d67c02311 | |
Son NK | e8cee6de80 | |
Son NK | 70b51b5002 | |
Son NK | 945496f67d | |
Son NK | 6fa267e92b | |
Son NK | 58a1d6e783 | |
snyk-bot | 57655fad8a | |
Son NK | b84eb13ab5 | |
Son NK | 3ed9c3d6fe | |
Son | ec763544f1 | |
Son | 38de6118ee | |
Raymond Nook | 101c6c85ef | |
Son | 6bc093df3f | |
Son | e14e697207 | |
Son | 2bed79095c | |
Son | 93991816c9 | |
Raymond Nook | e40c276a68 | |
devStorm | e79959c330 | |
Son NK | 3e1f098c79 | |
Son NK | 3308919906 | |
Son NK | ef32998e99 | |
Son NK | a2ffc53c62 | |
Son NK | 78df95395b | |
Son NK | 00e0b69c76 | |
Son NK | 79d0ef8906 | |
Son NK | d53796c8d9 | |
Son NK | 10414a6b96 | |
Son NK | da0424666a | |
Son NK | 62683a221a | |
Son Nguyen Kim | b14d79c8f7 | |
Son NK | eb2adc870a | |
Son NK | dd591c7437 | |
Son NK | 54f806fc4d | |
Son NK | 3897f6b633 | |
Son NK | 22096cae66 | |
Son NK | 09abdffda3 | |
Son NK | ed938dd86a | |
Son NK | 809a50f7d1 | |
nicoo | 586654e08e | |
Son NK | 28285f28ac | |
Son NK | c890bfb073 | |
Son NK | 1750ad45d5 | |
Son NK | aa667851e9 | |
nicoo | ecd74b801b | |
nicoo | d216812f14 | |
devStorm | e6192ece01 | |
Raymond Nook | 258d505cbf | |
devStorm | f7bef3941a | |
nicoo | f5f4d46aa4 | |
nicoo | 52d4d2abdb | |
Son NK | 8cfd5e01dc | |
Son NK | 99d26a01cb | |
Son NK | 12f3901330 | |
Son NK | 388a425cac | |
Son NK | b23e3d94fd | |
Son NK | fb97f384e4 | |
Son NK | 60a1f48e6e | |
Son NK | 73555ad524 | |
Son NK | 2f96322977 | |
Son NK | a918cc3670 | |
Son Nguyen Kim | 8262d3559d | |
Son NK | 3c6c3f7dbd | |
Son NK | 159843a923 | |
Son NK | bdec7ff5e4 | |
doanguyen | 4db8a4169e | |
Son NK | ce22e16285 | |
Son NK | ade07f9449 | |
Son NK | 78e3a4bf77 | |
Son Nguyen Kim | e911bdf203 | |
Sylvia van Os | 1ee941647f | |
Son Nguyen Kim | 7a1a1d3a01 | |
Sylvia van Os | 6bcaa6453e | |
Son NK | 14bc4f8872 | |
Son NK | 3422bd9aee | |
Son NK | d4e930c930 | |
Son Nguyen Kim | b3f8fd6789 | |
Sylvia van Os | 40d0dee88f | |
Sylvia van Os | a08b0c05cc | |
Sylvia van Os | 969616d671 | |
Son NK | 33f70914fa | |
Son Nguyen Kim | c7f6e6cedb | |
Peter Dave Hello | a9794325cd | |
Sylvia van Os | a9c897c6c5 | |
Sylvia van Os | 42cfce7ce1 | |
Sylvia van Os | bee468e055 | |
devStorm | 4a0fc8380f | |
Sylvia van Os | b3fa445250 | |
Sylvia van Os | f67f5297f2 | |
Son NK | ef2eb7f959 | |
devStorm | 30183ac8c3 | |
devStorm | 5c74ad2dc0 | |
devStorm | 178ce34399 | |
devStorm | 3fc250018d | |
Son NK | cb3bc8bc36 | |
Son NK | 078f3e8188 | |
Son NK | 6801b1f453 | |
Son NK | edaf293398 | |
Son NK | 93a8873192 | |
Son NK | 229d2c644b | |
Son | ac2ee4f2d0 | |
Son NK | ecdef797f9 | |
Son NK | c003dd0b01 | |
Son NK | 5317b8ab84 | |
Son NK | e5926978c8 | |
Son NK | 722e38deb1 | |
Son NK | ceacf8e3a7 | |
Son NK | e74dbd7e98 | |
Son NK | c5697fbf3c | |
Son NK | 7946879308 | |
Son NK | f3b04b9d81 | |
Son NK | 0039b4c301 | |
Son NK | 45b0acc1c4 | |
Son NK | 15610f1efc | |
Son NK | cfb52a2eba | |
Son NK | 5040e7b74b | |
Son NK | 9bfd9ebf07 | |
Son NK | a1de682ae1 | |
Son NK | 62c36a6e22 | |
Son NK | aad1270e0d | |
Son NK | 95c8f14ea5 | |
Son NK | 06bb3ffe41 | |
Son NK | f45e7b53d0 | |
Son NK | f8540808bc | |
Son NK | e42fb0816d | |
Son NK | ea5281de95 | |
Son NK | 7c1af6a265 | |
Son NK | 45221477a3 | |
Son NK | be5cdc59ba | |
Son NK | c715f87526 | |
Son NK | 9e4ff01b17 | |
Son NK | 17cb4462e3 | |
Son NK | af9597cf5a | |
Son NK | 38730bdecd | |
Son NK | 085dec069b | |
Son NK | 03976ea1c2 | |
Son NK | 9757b12b95 | |
Son NK | efae1710c8 | |
Son NK | 3c4a1413e0 | |
Son NK | e6d8815ac5 | |
Son NK | 3c5706fb16 | |
Son NK | 2f28e51c53 | |
Son NK | da17f51778 | |
Son NK | 313b442af7 | |
Son NK | 36e7cf3fdc | |
Son NK | 46109770fc | |
Son NK | b13c65166f | |
Son NK | a90fa49636 | |
Son NK | c3d57ed6e4 | |
Son NK | 7d4e1048af | |
Son NK | bbfb69d774 | |
Son NK | eab4f5f7ac | |
Son NK | d5de99afe9 | |
Son NK | e31e19047c | |
Son Nguyen Kim | 9df62e0380 | |
Benoit VIGNAL | f9366e2ed4 | |
Son NK | 1b41911598 | |
Son NK | ac216e7a08 | |
Son NK | 1f4637c064 | |
Son NK | bbf895ed42 | |
Son NK | 9d5c2e3f80 | |
Son NK | 26a087619c | |
Son NK | 365c11f926 | |
Son NK | 2270ccf35d | |
Son NK | 0bb8f9a227 | |
Son NK | 5a55121dfc | |
Son NK | 1cac625a90 | |
Son NK | c122b05896 | |
Son NK | 8eed6008f3 | |
Son NK | 92acf352b6 | |
Son NK | a570a426d4 | |
Son NK | 71389b7e09 | |
Son NK | e46e3b1c01 | |
Son NK | acc285abf0 | |
Son NK | 0c62ac4b1f | |
Son NK | 6435d951e1 | |
Son NK | 10cc61b4a0 | |
Son NK | 84b4c11086 | |
Son NK | db04303172 | |
Son NK | d59cee0bcc | |
Son NK | 1492f29a1a | |
Son NK | 58b0c91db5 | |
Son NK | 1600e273dd | |
Son NK | ec1633d0d7 | |
Son NK | e9e97cea61 | |
Son NK | f038a97649 | |
Son NK | 7ab64d9768 | |
Son NK | 2ad1b75e45 | |
Son NK | c0efc78a94 | |
Son NK | d2c99ea00e | |
Son NK | f0fb5108f9 | |
Son NK | 8b234b63a5 | |
Son NK | 8bb324e82b | |
Son NK | 9dede0a281 | |
Son NK | bcfc846af3 | |
Son NK | 89c69ad625 | |
Son NK | 91e805a637 | |
Son NK | 1187b6dc99 | |
Son NK | 2d968a01f8 | |
Son NK | c87fe55898 | |
Son NK | 48d7b66803 | |
Son NK | 66eb93fe53 | |
Son NK | 0848405d0c | |
Son NK | aadf2e1939 | |
Son NK | 5cba2eaa38 | |
Son NK | 826e4455cf | |
Son NK | d0dd64bf7b | |
Son NK | 11789559f1 | |
Son NK | d1d81e6a6d | |
Son Nguyen Kim | f6d3172e3e | |
Son Nguyen Kim | 169d70881a | |
Son Nguyen Kim | 89df6fc61a | |
Son NK | 57b2e2d4ab | |
Son NK | 9a1dc0240b | |
Son NK | 7ec0405709 | |
Son NK | 2d7219c218 | |
Son NK | 82154ec858 | |
Son NK | 7811f06fc1 | |
Son NK | 98264b14bc | |
Son NK | 6eb7ebc338 | |
Son Nguyen Kim | ca4d097f14 | |
Sylvia van Os | e02d95216f | |
Sylvia van Os | cfe889f7b9 | |
Sylvia van Os | 8800c29526 | |
Sylvia van Os | dcfd63eb0f | |
Sylvia van Os | 44ae20816a | |
Son | cf6442cec2 | |
Son | eb22a6302e | |
Son | 6c2daf1bb6 | |
Sylvia van Os | c6646d5971 | |
Son Nguyen Kim | 6b79dbdd5c | |
Sylvia van Os | 1915c8d09d | |
Son NK | 3c8ec8fcf2 | |
Son NK | 819738f55c | |
Son NK | 58df3442d5 | |
Son NK | 48a16a1d2e | |
Son NK | ffb7cdbdec | |
Son Nguyen Kim | 8e81b2ed8b | |
Son NK | cb3c5d7f12 | |
Son NK | 3512cc087e | |
Son NK | 9c4a5fc734 | |
Son Nguyen Kim | 095e2ae0de | |
Son NK | 157b7adbda | |
Son NK | 26613cdeeb | |
Son NK | bc69e11f9b | |
Son NK | 796ad58dca | |
Son NK | 3768429909 | |
Sylvia van Os | 3932ed2eb8 | |
Son NK | 39c92110cb | |
Son | c4c29dfa1d | |
Son | 863d8dcbe7 | |
Son | 178d2c8689 | |
Son | 23a0861790 | |
Son | 29afc1b6b5 | |
Son | 475eaa2bc0 | |
Son NK | 3494f314bc | |
Son NK | 31ff8b962b | |
Son NK | 9492aaccf5 | |
Son | d933bffa2f | |
Son | 2c46097330 | |
Son | 6f37bf858d | |
Son | 80d80657d6 | |
Son | fb24760039 | |
snyk-bot | 1386afe545 | |
Son NK | 193f8d8ccc | |
Son NK | 3af98026e3 | |
Son NK | a65680b5ba | |
Son Nguyen Kim | 48dcd634fa | |
Son NK | 2b811f942d | |
Son NK | a536a34a0b | |
Son NK | 38790fdc84 | |
Son NK | 3a142ca2f8 | |
Son NK | 36a117d790 | |
Son NK | deef432c58 | |
Son NK | 9ccfed28c5 | |
Son NK | 3aeaf6fe29 | |
Son NK | c9b246259d | |
Son NK | 0f9cb13920 | |
Son NK | b00524e74f | |
Son NK | a2dad50d20 | |
Son NK | d4ac2da96a | |
Son NK | 9030d8b543 | |
Son NK | 9e486fc2c0 | |
Son NK | 1f7366c07c | |
Son NK | e19fff3a9a | |
Son NK | cb58adc44b | |
Son NK | 7efce95145 | |
Son NK | 219703cb04 | |
Son NK | 7879b854a3 | |
Son NK | e7063b6514 | |
Son NK | dc29c6f9d9 | |
Son | cd3854561a | |
Son | 3f7d325e6e | |
Son | 51a38be070 | |
Son Nguyen Kim | 33ae42dddf | |
VD | d6504b7e13 | |
Son NK | 25afe4831c | |
Son NK | 5f0930b291 | |
Son NK | 00fde00d53 | |
Son NK | f8dbb50552 | |
Son NK | 2831cd04d8 | |
Son NK | 4f5a2cc8be | |
Son NK | 40e4d8e232 | |
Son NK | bbb6049351 | |
Son NK | b476e207fa | |
Son NK | e651e70d2d | |
Son NK | 66f3585253 | |
Son NK | e2f729206e | |
Son NK | 5bb0ae0234 | |
Son NK | 0354943ff4 | |
Son NK | 01aa733fe8 | |
Son NK | e85cfebf92 | |
Son NK | 6547d9420f | |
Son NK | d3a825c44b | |
Son NK | 3544db8f1c | |
Son NK | 4cd49b66c2 | |
Son NK | 3e1ef3358b | |
Son NK | b3181c054f | |
Son NK | 1013e8dd79 | |
Son NK | e09e6c51b8 | |
Son NK | 4a91db8e11 | |
Son | fcc04ba929 | |
Son | 81840e5ba5 | |
Son NK | 113dbd1c81 | |
Son NK | 2a33f112b9 | |
Son NK | 980942a1f9 | |
Son NK | 0de5b5a9bf | |
Son NK | 604ba285b1 | |
Son NK | b0b74906a7 | |
Son NK | 2bcd238250 | |
Son NK | 8e49fc40d4 | |
Son NK | 202f28722e | |
Son NK | 013a94d1e9 | |
Son NK | da53b7fa00 | |
Son NK | 0d6338b525 | |
Son NK | f94b82c134 | |
Son NK | d1d7a93ca5 | |
Son NK | c927edfeaa | |
Son NK | 7b9136d951 | |
Son NK | 264f41d466 | |
Son | e75ede969a | |
Son | 8a74aee363 | |
Son | f9161dba20 | |
Son NK | fd0ba7030d | |
Son NK | 7986ff0819 | |
Son NK | 4bfe6d1ac9 | |
Son NK | f4218a0693 | |
Son NK | c431abd917 | |
Son NK | 79f22857b5 | |
Son NK | 67de0e3c5b | |
Son NK | f9b0bdc2ed | |
Son NK | d6cc2a4bf3 | |
Son Nguyen Kim | 7cf0bb4ef4 | |
AndreasGassmann | 79e0fcf3d4 | |
Son NK | 361945f3f8 | |
Son NK | 56864ff0df | |
Son NK | 0e94c329d1 | |
Son NK | 8764a050d5 | |
Son NK | a044c47295 | |
Son NK | 13f3deb671 | |
Son NK | f1e9b2b5d7 | |
Son NK | c83b146f14 | |
Son NK | 45ac548e2b | |
Son NK | 2cc7f5ac13 | |
Son NK | 0ee0167b8e | |
Son NK | e9adb3270d | |
Son NK | 01858ac452 | |
Son NK | 2293c6d2e3 | |
Son NK | 70cc920ce8 | |
Son NK | cce08adb87 | |
Son NK | 435ced66bc | |
Son NK | ef7fae32b1 | |
Son NK | c9c2190874 | |
Son NK | 08d8e11a27 | |
Son NK | 4293bba5ab | |
Son NK | 7e53b97f81 | |
Son NK | 271734f5e2 | |
Son NK | 3ac159d073 | |
Son NK | 30593f9c78 | |
Son NK | 8dbaf3cf56 | |
Son NK | b942b44ec8 | |
Son NK | aae63006c6 | |
Son NK | 2b4dc3cdcc | |
Son NK | 3179d70df1 | |
Son NK | a34af98de8 | |
Son NK | ef2624ccea | |
Son NK | 0cf283089d | |
Son NK | c252665e46 | |
Son NK | 33dd6083c7 | |
Son NK | 95f3db6aa5 | |
Son NK | 5a3b79b4cf | |
Son NK | 7915a2abb9 | |
Son NK | abb3ec1f05 | |
Son NK | 4e20ffcc60 | |
Son NK | 75d1b090cd | |
Son NK | e0a414212e | |
Son NK | 1647a7a628 | |
Son NK | b9d8f11f2d | |
Son NK | 66e7aa7242 | |
Son NK | 291b9a7d55 | |
Son NK | 01da9aafcd | |
Son NK | 1c22e14f68 | |
Son NK | 0b83835065 | |
Son NK | 0585ba97ee | |
Son NK | 74a63db835 | |
Son NK | 0df4d1a93d | |
Son NK | 3b850f6228 | |
Son NK | 07febc9715 | |
Son NK | 31774f9ea7 | |
snyk-bot | d924dc1d52 | |
Son NK | e06f3dc209 | |
Son NK | 6441c22bcd | |
Son NK | da5bb6f9b5 | |
Son NK | df3e594a53 | |
Son NK | be57add431 | |
Son NK | 9f9d292754 | |
Son NK | b3d1085e0c | |
Son NK | 10f15f78c8 | |
Son NK | 8111beb6ff | |
Son NK | 3015cd1dc0 | |
Son NK | e48f19afb5 | |
Son NK | 6cec373b6d | |
Son Nguyen Kim | 9886f7c327 | |
Son NK | fb8a43fd5d | |
Son NK | 091ff3ad2c | |
Son NK | efc6b32ce0 | |
Son NK | 866ef1c139 | |
Son NK | 2f803e4714 | |
Son NK | ae9abe8512 | |
Son NK | ef4ed8ca74 | |
Son NK | 30b2182694 | |
Son NK | 3a8cdce650 | |
Son Nguyen Kim | eb07ba8eef | |
Son NK | 20094c9943 | |
snyk-bot | 6982a9bc5e | |
Son NK | f1f6234248 | |
Son Nguyen Kim | d161ca94f6 | |
Son NK | 830331d9b3 | |
Son NK | b9dba9c2c3 | |
Son NK | 6eaeb1fcf6 | |
Son NK | 3dd8ed7840 | |
Son NK | f19a7e1bca | |
Son NK | 369c9dafce | |
Son NK | 530160567b | |
Son NK | 1f4631821b | |
Son NK | 7094a0b694 | |
Son NK | 1348b58672 | |
Son NK | 1961d2f18e | |
Son NK | 43a021dd88 | |
Son NK | b00841f679 | |
Son NK | fbe48b7b3e | |
Son NK | 6c21b83975 | |
Son NK | 436e31229f | |
Son NK | 794e7ca5b9 | |
Son NK | 0542adb761 | |
Son NK | 02c74e6a5a | |
Son NK | 9329cf04ad | |
Son NK | 8527fed69e | |
Son Nguyen Kim | 65e001a33a | |
Son NK | 8d72d66d08 | |
Son NK | b2e1682704 | |
Son NK | c1ad161db7 | |
Son NK | 93503d4cd3 | |
Son NK | f03bde1d8d | |
Son NK | 41389c7444 | |
Son Nguyen Kim | bf139f83b3 | |
Son Nguyen Kim | 982d4e692a | |
Son NK | ce3dae2a07 | |
Son NK | 37a74bc093 | |
Son NK | 4fb7b7bd2c | |
Son NK | 05d4ec1c2f | |
Renaud Boyer | df565bca1c | |
Son NK | a40bbe74fe | |
Son Nguyen Kim | 811b33a56a | |
Renaud Boyer | 7838ff3224 | |
Renaud Boyer | 73a7527b5e | |
Renaud Boyer | c61dd9dec6 | |
Renaud Boyer | 0e70e5cf18 | |
Renaud Boyer | 5d948faf56 | |
Renaud Boyer | f2f13958c7 | |
Renaud Boyer | a2f3aeeece | |
Renaud Boyer | 2b9cb44cdb | |
Renaud Boyer | c09b6ef675 | |
Renaud Boyer | 1c73f07d18 | |
Renaud Boyer | a8d67f94e2 | |
Renaud Boyer | f3303ee6bb | |
Renaud Boyer | 4e93e511ec | |
Renaud Boyer | f1fb0ebe1f | |
Renaud Boyer | b7e6270a18 | |
Renaud Boyer | a45b6df78c | |
Renaud Boyer | 479a9d1a35 | |
Renaud Boyer | 47b1398cad | |
Renaud Boyer | 4bf22771af | |
Renaud Boyer | 43d9dbc1fc | |
Son NK | b0e39949cb | |
Son NK | fec281b84f | |
Son NK | ce2d68a64d | |
Son NK | eab09d8c32 | |
Renaud Boyer | 7bac9e82b9 | |
Renaud Boyer | 7e1a474875 | |
Renaud Boyer | 1ced8f76b7 | |
Renaud Boyer | 4bcc0d107f | |
Renaud Boyer | fad64ff064 | |
Renaud Boyer | fefbaeb143 | |
Renaud Boyer | 1e5185b328 | |
Renaud Boyer | bcdb4c08d9 | |
Renaud Boyer | a46a03be85 | |
Renaud Boyer | 0d535c8765 | |
Renaud Boyer | 6945cb633d | |
Renaud Boyer | 079e548ab7 | |
Renaud Boyer | 7bcc72cc02 | |
Renaud Boyer | f360488eca | |
Renaud Boyer | 0f48121fd5 | |
Renaud Boyer | d88aeeab7f | |
Renaud Boyer | 6b416bcbbe | |
Renaud Boyer | 99b4fc9625 | |
Renaud Boyer | 57ef3ac35c | |
Renaud Boyer | 419051cdd5 | |
Renaud Boyer | f746d17a02 | |
Renaud Boyer | b6c311a02e | |
Renaud Boyer | b16bfaac35 | |
Renaud Boyer | ced9c879d3 | |
Renaud Boyer | 21e928548f | |
Renaud Boyer | 7ffe1c93f1 | |
Renaud Boyer | 1fa64941c9 | |
Renaud Boyer | 20b54ca248 | |
Renaud Boyer | 5a24c7e2ae | |
Son Nguyen Kim | 2d9abe0ea4 | |
Son Nguyen Kim | 82f3751350 | |
Son NK | d150dfacdb | |
Son NK | 8c5f311367 | |
Renaud Boyer | 0023627bf5 | |
Renaud Boyer | db0114bf16 | |
Renaud Boyer | 5fc1606fb5 | |
snyk-bot | 537617ae34 | |
Son NK | 83df119178 | |
Son NK | 59daaa3164 | |
Son NK | 255a7e085a | |
Son NK | 81f9f9f41b | |
Son NK | 450b101e6e | |
Son NK | b7fdbe7721 | |
Son NK | 66abbf2614 | |
Son NK | 8551dade7c | |
Son NK | cd680bcd7f | |
Son NK | 2107bd4b08 | |
Son NK | 652bb6a369 | |
Son NK | 692c81ac2a | |
Son NK | 4d89ac4158 | |
Son NK | cefa68d392 | |
Son NK | 22b082fd55 | |
Son NK | 0b95ca33b8 | |
Son NK | f804332c2d | |
Son NK | 93563178a7 | |
Son NK | 59745b68d0 | |
Son NK | 86636b2eb7 | |
Son Nguyen Kim | 84e64d4c4f | |
Son NK | d61f45ea86 | |
Son NK | 1241838b26 | |
Son NK | e2a7061429 | |
Son NK | da8b0089ff | |
Son NK | 1801fa1a4b | |
Son NK | d35faf7154 | |
Son NK | e7b83fadbc | |
Son NK | 083398522c | |
Son Nguyen Kim | 83e38274e7 | |
Son Nguyen Kim | 56a74c961c | |
Son NK | adfbfe8026 | |
Son NK | 5b9eb8686a | |
Son NK | 3efa96020b | |
Son NK | 25d7709a8b | |
Son NK | 4c1bf68d86 | |
Son NK | e2f0a72ab7 | |
snyk-bot | 2b429b1738 | |
Son NK | dcd116f11a | |
Son NK | 03383eb181 | |
Son NK | ca625e60d5 | |
Son NK | ade21bc0c4 | |
Son NK | ac1a6f5613 | |
Son NK | dfcb74dc87 | |
Son NK | 609d59d23f | |
Son NK | 2f882b81fe | |
Son NK | 5e1a68cdee | |
Son NK | bb8c9451c4 | |
Son NK | 4f211bba61 | |
Son Nguyen Kim | 5fe48e4821 | |
Son NK | 4381314f6f | |
Son NK | 272c5628bb | |
Son NK | 297857a140 | |
Son NK | 5231483026 | |
Son NK | fb465ba03e | |
Son NK | 54942cdf65 | |
Son NK | 6b07be5677 | |
Son NK | bcb2657de3 | |
Son NK | c28872288b | |
Son NK | 3d75ef974a | |
Son NK | cbbb472d06 | |
Son NK | c707342695 | |
Son NK | 5997e5b5b5 | |
Son Nguyen Kim | ab861b3624 | |
snyk-bot | 5cdd3e1969 | |
Son Nguyen Kim | a9bd313d52 | |
Son Nguyen Kim | c5636ece1d | |
Son NK | 33e6342a9c | |
Son NK | 836b602316 | |
Son NK | bcdf522174 | |
Son NK | a1d5b01143 | |
Son NK | 824a610aa6 | |
Son NK | fbf242f6c6 | |
Ian McKenzie | 7dc97efb4b | |
Son NK | f069d2f083 | |
Son NK | 8aed5ced3f | |
Son NK | 78ddf16c87 | |
Son NK | c25a5b50f6 | |
Son Nguyen Kim | d108d7b8b7 | |
Renaud Boyer | d3ef6bc1fd | |
Son NK | 52c1adfd38 | |
Son NK | c53fe90484 | |
Son NK | 24548ff945 | |
Son NK | 30185a2798 | |
Son NK | 75c3fa1c11 | |
Son NK | ed22701cbe | |
Son NK | 78cb49095a | |
Son NK | 9ca129cb97 | |
Son NK | 5b9dc88c67 | |
Son NK | 0224e5f8a6 | |
Son NK | 319078fceb | |
Son NK | e06c872bc0 | |
Son NK | 0963049d1f | |
Son NK | 28d42a7a22 | |
Son NK | be510ea1d7 | |
Son NK | 391318cbaa | |
Son NK | 6ed6218895 | |
Son NK | 145fc9c67c | |
Son NK | aada12f17e | |
Son NK | 9154b4656d | |
Son NK | 3d153f5203 | |
Son NK | 1926408a13 | |
Son NK | 75ba1669e0 | |
Son NK | 5781bebfd0 | |
Son NK | 71c1b7cc45 | |
Son NK | 15a6d9630a | |
Son NK | c794e73abd | |
Son NK | f0f81930bc | |
Son NK | dec7969ead | |
Son NK | d8c9078708 | |
Son NK | aee917a3ef | |
Son NK | de495b9afe | |
Son NK | 9d24b1b88a | |
Son NK | f6568aca6a | |
Son NK | f500a495b7 | |
Son NK | ae05c164c9 | |
Son NK | 6c7018dd33 | |
Son NK | abf50e302b | |
Son NK | 08902bf784 | |
Son NK | 9cfb6d412a | |
Son NK | f452c79aec | |
Son NK | ae64bd26b9 | |
Son NK | fde01af5b5 | |
Son Nguyen Kim | 521ab4f47e | |
Son NK | 56aca5edaf | |
Son NK | 81e211f8b4 | |
Son NK | ea6e6f23d2 | |
Son NK | 496be08639 | |
Son NK | f62c568dd0 | |
Son NK | 3489e41fdb | |
Son NK | 2c46d1db8e | |
Son NK | a072d6c0cd | |
Son NK | f16676e921 | |
Son NK | b4e5e3eecb | |
Son NK | 25de8001e2 | |
Son NK | 1c061ceb59 | |
Son NK | b4d1b3950d | |
Son NK | ec4d879836 | |
Son NK | 5570300699 | |
Son NK | 4e16eb7403 | |
Son NK | 4666d21f63 | |
Son NK | 632a5bbbc8 | |
Son NK | c3f73b25b2 | |
Son NK | b2f9479bce | |
Son NK | 44c3ac1741 | |
Son NK | a35256d161 | |
Son NK | 21839d579c | |
Son NK | 2cc7cb6a37 | |
Son NK | 15466903d1 | |
Son NK | 3f8e5d0a8b | |
Son NK | a9297078d3 | |
Son NK | 76389647bb | |
Son NK | 4d03d2fe04 | |
Son NK | 78f5f27d5d | |
Son Nguyen Kim | 44fd80b2e1 | |
Son NK | 4be182320e | |
Son NK | 6a68141d8d | |
Son NK | 606f9dfbae | |
Son NK | e659680875 | |
Son NK | f57f29a97b | |
Son NK | 7cc57106de | |
Son NK | 8b356eef01 | |
Son NK | 5ef3ab4d74 | |
Son NK | d0ca773376 | |
Son NK | 352cd978bd | |
Son NK | 6585aef443 | |
Son NK | 3dee121bec | |
Son NK | 3a03dec077 | |
Son NK | 009236e623 | |
Son NK | 6c626520d3 | |
Son NK | f4f2db0f04 | |
Son NK | 6e0394d980 | |
Son NK | 5a7df14d58 | |
Son NK | 4b8a2a1851 | |
Son NK | c1b8f717b5 | |
Son NK | 4b6368b378 | |
Son NK | 19ac657c1c | |
Son NK | 5ee74c74b6 | |
Son NK | 273537e7ae | |
Son NK | a989545505 | |
Son NK | 04a418e655 | |
Son NK | 9fafddd603 | |
Son NK | 751cc05534 | |
Son NK | 72a34e28be | |
Son NK | fe6e9fa435 | |
Son NK | a890557c7f | |
snyk-bot | 42237c9539 | |
Son NK | 8d0e243c83 | |
Son NK | 3f150e5944 | |
Son NK | 63788125da | |
Son NK | c41c36acaa | |
Son NK | 38877598cf | |
Son Nguyen Kim | d2f2053738 | |
Son NK | 756e8080ab | |
Son NK | 1d0aa0f900 | |
Son NK | 7337110110 | |
Son NK | 593e81705b | |
Son NK | 53e57eee42 | |
Son NK | 7ca74eaa6f | |
Son NK | ec1b7dd8b8 | |
Son NK | 92ea8de374 | |
Son NK | 38ca2341bc | |
Son NK | e49169b887 | |
Son NK | fef6edf619 | |
snyk-bot | 6df616c4b4 | |
Son NK | 28563b9653 | |
Son NK | c076c7c7f3 | |
Son NK | 12f7485cb1 | |
Son NK | 77bf9537d0 | |
Son NK | 91534d3cf2 | |
Son NK | 3f40e3c1cf | |
Son NK | 0307793666 | |
Son NK | 6c816d51d6 | |
Son NK | 5a190ed840 | |
Son NK | 9210459a72 | |
Son NK | 71be3b27f7 | |
Son NK | a2254cfdf8 | |
Son NK | 1abebe8067 | |
Son NK | aa1cac521b | |
Son NK | 8f6550f992 | |
Son Nguyen Kim | 5681f061b5 | |
snyk-bot | a8d4ef73a2 | |
Son NK | dec956c84d | |
Son NK | 4a7b73a218 | |
Son NK | 6803d4bf42 | |
Son NK | b09bb42b2d | |
Son NK | f0b46c1887 | |
Son NK | 477481c41e | |
Son NK | 83f3309149 | |
Son NK | c140d3f842 | |
Son NK | 9f50ab4cce | |
Son NK | bdec727cd1 | |
Son NK | 5da7953a64 | |
Son NK | bfa59dcdd9 | |
Son NK | da7c07fc42 | |
Son NK | 85a1d67c6f | |
Son NK | bc1eeb4f01 | |
Son NK | 677f150fef | |
Son NK | ea45ac119e | |
Son NK | f624085aa3 | |
Son NK | 4f7b30c204 | |
Son NK | 050c2feaeb | |
Son NK | 459f821036 | |
Son NK | a001132497 | |
Son NK | 14b86749df | |
Son NK | 6921ab05fd | |
Son NK | 635182e1ef | |
Son NK | acce32fcc8 | |
Son NK | c6b6083c46 | |
Son NK | 32e25f5378 | |
Son NK | 8da14ca8ca | |
Son NK | 44b544d768 | |
Son NK | 08e2c1b05a | |
Son NK | 828799010b | |
Son NK | 8482a55df6 | |
Son NK | 03521b5a84 | |
Son NK | 886d3a761c | |
Son NK | 60b1145670 | |
Son Nguyen Kim | ac07c775e4 | |
Son NK | a2a4e50f27 | |
Son NK | 1524bb4e4b | |
Son NK | dbf0404aa9 | |
Son NK | 4a32db5b5d | |
Son NK | 0a4fc76b61 | |
Son NK | adff510359 | |
Son NK | 521d8e51a5 | |
Son NK | 1fcf166c00 | |
Son NK | e79522b638 | |
Son NK | dcbd7baabc | |
Son NK | 90163220cf | |
Son NK | f56b0cddb2 | |
Son NK | cd15c64731 | |
Son NK | 805e78cad1 | |
Son NK | 2d9abe55a4 | |
Son Nguyen Kim | ad90a0c93e | |
Son NK | 6557b7157f | |
Son NK | 8268568f08 | |
Son NK | 987d25263c | |
Son NK | 7c0b3b290b | |
Son NK | c2e03854ef | |
Son Nguyen Kim | 4e45a619cd | |
snyk-bot | 23d7a83f16 | |
Sylvia van Os | 9702037573 | |
Sylvia van Os | 26d0437009 | |
Son NK | 5486f54955 | |
Son NK | 0f349388ca | |
Son NK | 8dbd3c1c68 | |
Son NK | 859bc7976e | |
Sylvia van Os | 6b085960cb | |
Sylvia van Os | 739fb50b04 | |
Sylvia van Os | aeef9ccca9 | |
Son NK | 2d73d52127 | |
Son Nguyen Kim | a6ce047f32 | |
Son Nguyen Kim | a4bcf59bfe | |
Son NK | 6993721ae2 | |
Son NK | 7e425c0338 | |
Son NK | 6c37a91c6d | |
Son NK | e225bffc30 | |
Son Nguyen Kim | 9d0a896e1c | |
Son Nguyen Kim | 73e90e6892 | |
Son NK | 731b8db5cb | |
Sylvia van Os | ff0eaa4bbf | |
pojhm91c7iwk | b90705f12e | |
Sylvia van Os | 7be674c13b | |
Sylvia van Os | d8ed1cbbc3 | |
Son NK | b99085419e | |
Son NK | b690e903fa | |
Son NK | 52b5526261 | |
Son NK | 73f56818fb | |
Son NK | 351adc57f5 | |
Son NK | 9aa460d47f | |
Son NK | 5985c7f655 | |
snyk-bot | 8b63d302e4 | |
Son NK | cbdcab7d24 | |
Son NK | 6253a4eb23 | |
Son NK | 770b15aba3 | |
Son NK | 6a4622fca9 | |
Son NK | 772a2e7355 | |
Son NK | 8ed619687f | |
Son NK | c8e92af4d3 | |
Son NK | 8517e7d356 | |
Son Nguyen Kim | 38cc2e7986 | |
Son Nguyen Kim | 4e7aec7dce | |
Son NK | abc42df0fb | |
Son NK | 91e3cc5dcb | |
Son NK | 078368362c | |
Son NK | f2eedfd3d1 | |
Son NK | 19c61fa656 | |
snyk-bot | 89c91f3843 | |
Sylvia van Os | b95c44e3db | |
Son NK | 61e4455406 | |
Son NK | cc8b3a116b | |
Son NK | d7ca639dc1 | |
Son NK | 65938d2fb7 | |
Son NK | e8ccbced59 | |
Son NK | e661f90ce7 | |
Son Nguyen Kim | 3a765ffc83 | |
Son NK | 17ef292779 | |
Son NK | 571e39bb30 | |
Son NK | 8b344e7dfe | |
Son NK | 0a7643b367 | |
Son NK | 063885ccf7 | |
Son NK | 0830bba218 | |
Son NK | d6d686c4c3 | |
Son NK | bb6a5bf0b3 | |
Son NK | 53f66d0f3c | |
Son NK | f274bac053 | |
Son NK | 740d31871d | |
Son NK | d81ad2fd12 | |
Son NK | 5f8fff5af3 | |
Son NK | 4b697fc897 | |
Son NK | 25118dff9b | |
Son NK | 03dfafe1cf | |
Son NK | 5c8d31111c | |
snyk-bot | b8099bdb2f | |
Son NK | b9b442294b | |
Son NK | 5480f6d35b | |
Son NK | a37f7fe8b8 | |
Son NK | cb2033443c | |
Son NK | 8faae3d0d4 | |
Son NK | 40892f8253 | |
Son NK | 299f7d3fba | |
Son NK | 491f4de120 | |
Son NK | 1ab36bd22b | |
Son NK | ed2e748d1e | |
Son NK | c48b5038f3 | |
Son NK | 18263c2fd5 | |
Son NK | e291a71037 | |
Son NK | 5cfeb4c3f2 | |
Son NK | 85beb774c7 | |
Son NK | 1026f0763d | |
Son Nguyen Kim | 3e450c5ac2 | |
Son Nguyen Kim | 1f55bc73d3 | |
Son NK | fead5efc8b | |
Son NK | c6eba9f125 | |
Son NK | 09380915fb | |
Son NK | 3545ae7d97 | |
Son NK | 2cfaa93a5f | |
Son NK | 8fe508c5d3 | |
Son NK | e519a917d2 | |
Son NK | 2fba4c9a53 | |
snyk-bot | 54ace01d86 | |
Son NK | 7cc90ad194 | |
Son NK | c625a178e8 | |
Son NK | d14f6cf7fb | |
Son NK | 76175dc517 | |
Son NK | 530db2fdd4 | |
snyk-bot | 8f04ae82e6 | |
Son Nguyen Kim | 43babcf2d9 | |
Son NK | b92966b2c6 | |
Son NK | f664243e42 | |
Son NK | 6da48298a6 | |
Son NK | f224d16c56 | |
Son NK | e6dd2f1717 | |
Son NK | a660a05f83 | |
Son NK | bde6f661e4 | |
Son NK | 91c4b68ca3 | |
Son NK | 85332a5fb5 | |
Son NK | ab5cd37f70 | |
Son NK | a46a1dfaea | |
Son NK | e3e9428247 | |
Son NK | 32373b6bd0 | |
Son NK | b9bd167ff6 | |
Son NK | 0c9106717b | |
Son NK | 6a8c0d6f76 | |
Son NK | 290428009a | |
Son NK | b65534a8e7 | |
Son NK | a0b50762ee | |
Son NK | 7b6e58ef95 | |
Son NK | 15d7f6407e | |
Son NK | 10205e3731 | |
Son NK | d1eb1ea799 | |
Son NK | 5cf0a4bcfe | |
Son NK | e36768824f | |
Son NK | b83c513607 | |
Son NK | 0dbc755790 | |
Son Nguyen Kim | f27a448d1b | |
snyk-bot | a0854ae35c | |
Son NK | a25559dace | |
Son NK | d97966a2e8 | |
Son NK | 5ea3d1bd42 | |
Son NK | b7b4c07cd3 | |
Son NK | c03bb70755 | |
Son NK | 916e6a1a7f | |
Son NK | 2d395f99bb | |
Son NK | 6629b8687b | |
Son NK | f069e6f7cb | |
Son NK | 4cb3e54821 | |
Son NK | 85b87bbacb | |
Son NK | e4c4797cdb | |
Son NK | 63e228d9f4 | |
Son NK | 77c67c5314 | |
Son NK | 8079746e47 | |
Son NK | 171100eda7 | |
Son NK | e117726cd9 | |
Son NK | ffc59a6fad | |
Son NK | a3d919db2e | |
Son Nguyen Kim | 42d8b017ba | |
Son Nguyen Kim | 0b2b653a7b | |
Son NK | 62ddaaf7b4 | |
Son NK | a3f3c252e3 | |
Son NK | e4271f725c | |
Son NK | a6df989a8f | |
Son NK | b937e14ee3 | |
Son NK | 4a90ea9aca | |
Son NK | 37a53757eb | |
Son NK | 03b8a6f2e9 | |
Son NK | 830299ce2c | |
Son NK | fdedc24358 | |
Son NK | bb6e2a35ca | |
Son NK | 26ecf38760 | |
snyk-bot | 119622ecb3 | |
Son NK | 828d9e4fe1 | |
Son NK | 9c72f4dec0 | |
Son NK | 4101142253 | |
Son NK | f213469e9f | |
Son Nguyen Kim | a1206d212f | |
Son Nguyen Kim | 76316c7085 | |
snyk-bot | bf7e26d67e | |
Son NK | cd687664d1 | |
Son NK | ab911fd55e | |
Son NK | ce791567f1 | |
Son NK | 61fd81489f | |
Son NK | b53cc94310 | |
Son NK | d410b34b50 | |
Son Nguyen Kim | 637bc569eb | |
Son NK | 2e6c22131f | |
Son NK | 9237f43c19 | |
Son NK | 0bb10d8fc3 | |
Son NK | ffa9304d00 | |
Son NK | 9cf807f7bd | |
Son NK | 29b6b52a62 | |
Son NK | 17c7303fb5 | |
Son NK | eb6647d62e | |
Son NK | 77bfa67402 | |
Fabio Widmer | 6a45010740 | |
Son NK | ef196c5b4a | |
Son NK | 00a08d898a | |
Son NK | d7583f1733 | |
Son NK | 30fe09185f | |
Son NK | 06c48244e4 | |
Son NK | dc8c2f403e | |
Son NK | 01afb7557c | |
Son NK | 2b2512e775 | |
Son NK | 5bb4c20fba | |
Son NK | 90eae05e9e | |
Son NK | 386fcbdc3a | |
Son NK | a3e052cc7b | |
Son NK | db0e197500 | |
Son NK | 1de57119c4 | |
Son NK | c77b0c07b4 | |
Son NK | b8e0ee424c | |
Son NK | 7e345e4db3 | |
Son NK | 7f34dc1a20 | |
Fabio Widmer | aa18b7ecd1 | |
Son NK | 8626c5e232 | |
Son NK | 50683be4f8 | |
Son NK | 38bf117f29 | |
Son NK | d8a415c00a | |
Son NK | 24d8babe46 | |
Son NK | 735c0310fd | |
Son NK | 284aaad52b | |
Son NK | cbdd080587 | |
Son NK | 6835c5b69d | |
Son NK | a3a99ac3f4 | |
Son NK | d99d186bc0 | |
Son NK | 61a8f1e676 | |
Son NK | d4a6269e43 | |
Son NK | 98a9e88ce4 | |
Son NK | 1ab9c926dd | |
Fabio Widmer | 28dbafe1f7 | |
Son NK | 0c6a5f4333 | |
Son NK | d738997c4e | |
Son NK | 118862ead0 | |
Son NK | 79853b7736 | |
Son NK | 3d638f1a97 | |
Son Nguyen Kim | becb3fe720 | |
Son NK | 359eec23c0 | |
Son NK | f9300009e5 | |
Son NK | bf555ed605 | |
Son NK | 673b08712c | |
Son NK | c4dd980cf6 | |
Son NK | 08db23658a | |
Son NK | ec0a2bb6e3 | |
Son NK | 2a38d7c5fa | |
Son NK | 8e6fb9975d | |
Son NK | 4bbb07c3ce | |
Son NK | c0f263ee70 | |
Son NK | d8e4396a70 | |
Fabio Widmer | fbb17b1f57 | |
Fabio Widmer | 9fa9dbe821 | |
Fabio Widmer | 9226492f83 | |
Son Nguyen Kim | edb8144be8 | |
Son NK | 89830e2173 | |
Son NK | 5636b7ba32 | |
Son NK | 6bc0c5ada4 | |
Son NK | b4eb110971 | |
Son NK | 2cae0200a8 | |
Son NK | 4434ad62dd | |
Son NK | b7cbaa6e84 | |
Son Nguyen Kim | cb687c4248 | |
Fabio Widmer | 8e71e8e7f4 | |
Fabio Widmer | d0ed69f8aa | |
Son NK | 0d1e5b1f7d | |
Son NK | b395c2ebd0 | |
Son NK | 7e5deef34f | |
Son NK | cba78b1b5d | |
Son NK | 00411cef61 | |
Son NK | dc206b41c7 | |
Son NK | 184397dc92 | |
Son NK | ef45e28ab3 | |
Son NK | b064341f4e | |
Son NK | 9def7df974 | |
Son NK | 8db2ddcd5b | |
Son NK | 8a11e42da9 | |
Son NK | c74857c7e7 | |
Son NK | 2f00294ba3 | |
Son NK | 0484fdbb83 | |
Son NK | 5dc631a6b5 | |
Son NK | 1f0ef13ff2 | |
Son NK | f17608df50 | |
snyk-bot | 8e3a16841d | |
Son Nguyen Kim | 5d61d5b31b | |
Son NK | 6cb589350b | |
Son Nguyen Kim | 0a50b21450 | |
Son NK | a3051b3d45 | |
Son NK | b3ca7d1d5b | |
Son NK | 03841693ba | |
Son NK | 3d2a325e55 | |
Son NK | f2fb599664 | |
Son Nguyen Kim | a0ed091b35 | |
snyk-bot | 6d142cc926 | |
Son Nguyen Kim | 852c13fb60 | |
Son NK | 4a2a4b9828 | |
Son NK | 918b18870f | |
Son NK | 41e2283d93 | |
Son NK | f5bc166f39 | |
Son NK | ec8f120085 | |
Son NK | e8fc9752b5 | |
Son NK | d98cde440a | |
Son Nguyen Kim | 9d78f9a21b | |
Son Nguyen Kim | c8a4c53870 | |
Son NK | ed22f5116f | |
Son NK | 3e5323c2dd | |
Son NK | 68eeb2e121 | |
Son NK | 701579f18c | |
snyk-bot | 327a26b5d1 | |
Son Nguyen Kim | 0121806301 | |
Son Nguyen Kim | c78e3a6ee2 | |
Son Nguyen Kim | 782844e2b9 | |
Son NK | efe1ab641f | |
Son NK | 307e3c93c6 | |
Son NK | 38a6bcc461 | |
Son NK | 7a22b58f19 | |
Son NK | d89e41d0e5 | |
Son NK | 8e9968a7d9 | |
Son NK | 6faaacc972 | |
Son NK | 582c92bbcd | |
Son NK | 363bfa1bf3 | |
Son NK | 6437ee46e0 | |
Son NK | 7f29756230 | |
Son NK | 515b3510a8 | |
Son NK | 8f17cda794 | |
Son NK | bb343a5cde | |
Son NK | 28c96d0d35 | |
Son NK | 293a5cb396 | |
Son NK | 81bc11bd8c | |
Son Nguyen Kim | 7ab04b030e | |
Son NK | 7a20261aae | |
Son NK | 1bbc416ec1 | |
Son NK | f5e6f541ff | |
Son Nguyen Kim | 5e464a824c | |
Son NK | d7b992aef3 | |
Son NK | 74498146d8 | |
Son NK | f9cb40aa5b | |
Son NK | 4b479ea003 | |
Son NK | 4542a8353e | |
Son NK | 955231199e | |
Son NK | dc9ee048a2 | |
Son NK | ee9b796b7d | |
Son NK | 4cd0227477 | |
Son NK | 5ed39b47ca | |
Son NK | d59e9a6238 | |
Son NK | d0776b770f | |
Son NK | 0d3a3e0c48 | |
Son NK | 5b3ec91300 | |
Son NK | df96773959 | |
Son NK | 96ac15a3e0 | |
Son NK | 40b4273111 | |
Son NK | 3754cee8f8 | |
Son NK | e3569ee7ad | |
Son NK | c22af6d1f0 | |
Son NK | c41bffbbae | |
Son NK | 8e068eea30 | |
Son NK | 59764f8e84 | |
Son NK | 28da78e75f | |
Son NK | b23259cacd | |
Son NK | ec0f5ccd3a | |
Son NK | 204f5f9a0c | |
Son NK | 02bd45bc4a | |
Son Nguyen Kim | 0e3aa42326 | |
Son NK | 1f7779ed7b | |
Son NK | b2f82ba4a8 | |
Son NK | abe9768db4 | |
Son NK | 040c6d1f9e | |
Son NK | c91b44fa97 | |
Son NK | 774ffcae3b | |
Son NK | 85bb30abb0 | |
Son NK | 45a8b360e4 | |
Son NK | edfd3c0719 | |
Son NK | 3f8f306a34 | |
Son NK | 13416bfd31 |
|
@ -6,6 +6,12 @@ db.sqlite
|
|||
.vscode
|
||||
.DS_Store
|
||||
config
|
||||
LICENSE
|
||||
README.md
|
||||
adhoc_*
|
||||
adhoc
|
||||
static/node_modules
|
||||
db.sqlite-journal
|
||||
static/upload
|
||||
venv/
|
||||
.venv
|
||||
.coverage
|
||||
htmlcov
|
||||
.git/
|
|
@ -0,0 +1,26 @@
|
|||
[flake8]
|
||||
max-line-length = 88
|
||||
select = C,E,F,W,B,B902,B903,B904,B950
|
||||
extend-ignore =
|
||||
# For black compatibility
|
||||
E203,
|
||||
E501,
|
||||
# Ignore "f-string is missing placeholders"
|
||||
F541,
|
||||
# allow bare except
|
||||
E722, B001
|
||||
exclude =
|
||||
.git,
|
||||
__pycache__,
|
||||
.pytest_cache,
|
||||
.venv,
|
||||
static,
|
||||
templates,
|
||||
# migrations are generated by alembic
|
||||
migrations,
|
||||
docs,
|
||||
shell.py
|
||||
|
||||
per-file-ignores =
|
||||
# ignore unused imports in __init__
|
||||
__init__.py:F401
|
|
@ -0,0 +1,2 @@
|
|||
## code changes will send PR to following users
|
||||
* @acasajus @cquintana92 @nguyenkims
|
|
@ -1,2 +1 @@
|
|||
patreon: simplelogin
|
||||
custom: ["https://www.paypal.me/RealSimpleLogin"]
|
||||
open_collective: simplelogin
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve SimpleLogin.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Please note that this is only for bug report.
|
||||
|
||||
For help on your account, please reach out to us at hi[at]simplelogin.io. Please make sure to check out [our FAQ](https://simplelogin.io/faq/) that contains frequently asked questions.
|
||||
|
||||
|
||||
For feature request, you can use our [forum](https://github.com/simple-login/app/discussions/categories/feature-request).
|
||||
|
||||
For self-hosted question/issue, please ask in [self-hosted forum](https://github.com/simple-login/app/discussions/categories/self-hosting-question)
|
||||
|
||||
## Prerequisites
|
||||
- [ ] I have searched open and closed issues to make sure that the bug has not yet been reported.
|
||||
|
||||
## Bug report
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (If applicable):**
|
||||
- OS: Linux, Mac, Windows
|
||||
- Browser: Firefox, Chrome, Brave, Safari
|
||||
- Version [e.g. 78]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"template": "${{CHANGELOG}}\n\n<details>\n<summary>Uncategorized</summary>\n\n${{UNCATEGORIZED}}\n</details>",
|
||||
"pr_template": "- ${{TITLE}} #${{NUMBER}}",
|
||||
"empty_template": "- no changes",
|
||||
"categories": [
|
||||
{
|
||||
"title": "## 🚀 Features",
|
||||
"labels": ["feature"]
|
||||
},
|
||||
{
|
||||
"title": "## 🐛 Fixes",
|
||||
"labels": ["fix", "bug"]
|
||||
},
|
||||
{
|
||||
"title": "## 🔧 Enhancements",
|
||||
"labels": ["enhancement"]
|
||||
}
|
||||
],
|
||||
"ignore_labels": ["ignore"],
|
||||
"tag_resolver": {
|
||||
"method": "semver"
|
||||
}
|
||||
}
|
|
@ -1,60 +1,244 @@
|
|||
name: Run tests & Public to Docker Registry
|
||||
name: Test and lint
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'poetry'
|
||||
|
||||
- name: Install OS dependencies
|
||||
if: ${{ matrix.python-version }} == '3.10'
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y libre2-dev libpq-dev
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||
run: poetry install --no-interaction
|
||||
|
||||
- name: Check formatting & linting
|
||||
run: |
|
||||
poetry run pre-commit run --all-files
|
||||
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.7]
|
||||
python-version: ["3.10"]
|
||||
|
||||
# service containers to run with `postgres-job`
|
||||
services:
|
||||
# label used to access the service container
|
||||
postgres:
|
||||
# Docker Hub image
|
||||
image: postgres:13
|
||||
# service environment variables
|
||||
# `POSTGRES_HOST` is `postgres`
|
||||
env:
|
||||
# optional (defaults to `postgres`)
|
||||
POSTGRES_DB: test
|
||||
# required
|
||||
POSTGRES_PASSWORD: test
|
||||
# optional (defaults to `5432`)
|
||||
POSTGRES_PORT: 5432
|
||||
# optional (defaults to `postgres`)
|
||||
POSTGRES_USER: test
|
||||
ports:
|
||||
- 15432:5432
|
||||
# set health checks to wait until postgres has started
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
- name: Install poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'poetry'
|
||||
|
||||
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Test formatting
|
||||
- name: Install OS dependencies
|
||||
if: ${{ matrix.python-version }} == '3.10'
|
||||
run: |
|
||||
pip install black
|
||||
black --check .
|
||||
sudo apt update
|
||||
sudo apt install -y libre2-dev libpq-dev
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||
run: poetry install --no-interaction
|
||||
|
||||
|
||||
- name: Start Redis v6
|
||||
uses: superchargejs/redis-github-action@1.1.0
|
||||
with:
|
||||
redis-version: 6
|
||||
|
||||
- name: Run db migration
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
CONFIG=tests/test.env poetry run alembic upgrade head
|
||||
|
||||
- name: Prepare version file
|
||||
run: |
|
||||
scripts/generate-build-info.sh ${{ github.sha }}
|
||||
cat app/build_info.py
|
||||
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pip install pytest
|
||||
pytest
|
||||
poetry run pytest
|
||||
env:
|
||||
GITHUB_ACTIONS_TEST: true
|
||||
|
||||
- name: Publish to Docker Registry
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v1
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: htmlcov
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: ['test', 'lint']
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))
|
||||
|
||||
steps:
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: simplelogin/app-ci
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
repository: simplelogin/app-ci
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
tag_with_ref: true
|
||||
|
||||
- name: Send Telegram message
|
||||
if: github.event_name == 'push'
|
||||
uses: appleboy/telegram-action@master
|
||||
# We need to checkout the repository in order for the "Create Sentry release" to work
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
to: ${{ secrets.TELEGRAM_TO }}
|
||||
token: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
args: Docker image pushed on ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v1
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
with:
|
||||
ignore_missing: true
|
||||
ignore_empty: true
|
||||
|
||||
- name: Prepare version file
|
||||
run: |
|
||||
scripts/generate-build-info.sh ${{ github.sha }}
|
||||
cat app/build_info.py
|
||||
|
||||
- name: Build image and publish to Docker Registry
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
|
||||
#- name: Send Telegram message
|
||||
# uses: appleboy/telegram-action@master
|
||||
# with:
|
||||
# to: ${{ secrets.TELEGRAM_TO }}
|
||||
# token: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
# args: Docker image pushed on ${{ github.ref }}
|
||||
|
||||
# If we have generated a tag, generate the changelog, send a notification to slack and create the GitHub release
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: mikepenz/release-changelog-builder-action@v3
|
||||
with:
|
||||
configuration: ".github/changelog_configuration.json"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Prepare Slack notification contents
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
changelog=$(cat << EOH
|
||||
${{ steps.build_changelog.outputs.changelog }}
|
||||
EOH
|
||||
)
|
||||
messageWithoutNewlines=$(echo "${changelog}" | awk '{printf "%s\\n", $0}')
|
||||
messageWithoutDoubleQuotes=$(echo "${messageWithoutNewlines}" | sed "s/\"/'/g")
|
||||
echo "${messageWithoutDoubleQuotes}"
|
||||
|
||||
echo "SLACK_CHANGELOG=${messageWithoutDoubleQuotes}" >> $GITHUB_ENV
|
||||
|
||||
- name: Post notification to Slack
|
||||
uses: slackapi/slack-github-action@v1.19.0
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
with:
|
||||
channel-id: ${{ secrets.SLACK_CHANNEL_ID }}
|
||||
payload: |
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "New tag created",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Tag: ${{ github.ref_name }}* (${{ github.sha }})"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Changelog:*\n${{ env.SLACK_CHANGELOG }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/create-release@v1
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
@ -9,6 +9,10 @@ config
|
|||
static/node_modules
|
||||
db.sqlite-journal
|
||||
static/upload
|
||||
adhoc_*
|
||||
adhoc.py
|
||||
env/
|
||||
venv/
|
||||
.venv
|
||||
.python-version
|
||||
.coverage
|
||||
htmlcov
|
||||
adhoc
|
||||
.env.*
|
|
@ -1,6 +1,25 @@
|
|||
exclude: "(migrations|static/node_modules|static/assets|static/vendor)"
|
||||
default_language_version:
|
||||
python: python3
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: stable
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.2.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.7
|
||||
- id: check-yaml
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||
rev: v1.3.0
|
||||
hooks:
|
||||
- id: djlint-jinja
|
||||
files: '.*\.html'
|
||||
entry: djlint --reformat
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.1.5
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
args: [ --fix ]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
|
||||
|
|
|
@ -0,0 +1,227 @@
|
|||
[MASTER]
|
||||
extension-pkg-allow-list=re2
|
||||
|
||||
fail-under=7.0
|
||||
ignore=CVS
|
||||
ignore-paths=migrations
|
||||
ignore-patterns=^\.#
|
||||
jobs=0
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=missing-function-docstring,
|
||||
missing-module-docstring,
|
||||
duplicate-code,
|
||||
#import-error,
|
||||
missing-class-docstring,
|
||||
useless-object-inheritance,
|
||||
use-dict-literal,
|
||||
logging-format-interpolation,
|
||||
consider-using-f-string,
|
||||
unnecessary-comprehension,
|
||||
inconsistent-return-statements,
|
||||
wrong-import-order,
|
||||
line-too-long,
|
||||
invalid-name,
|
||||
global-statement,
|
||||
no-else-return,
|
||||
unspecified-encoding,
|
||||
logging-fstring-interpolation,
|
||||
too-few-public-methods,
|
||||
bare-except,
|
||||
fixme,
|
||||
unnecessary-pass,
|
||||
f-string-without-interpolation,
|
||||
super-init-not-called,
|
||||
unused-argument,
|
||||
ungrouped-imports,
|
||||
too-many-locals,
|
||||
consider-using-with,
|
||||
too-many-statements,
|
||||
consider-using-set-comprehension,
|
||||
unidiomatic-typecheck,
|
||||
useless-else-on-loop,
|
||||
too-many-return-statements,
|
||||
broad-except,
|
||||
protected-access,
|
||||
consider-using-enumerate,
|
||||
too-many-nested-blocks,
|
||||
too-many-branches,
|
||||
simplifiable-if-expression,
|
||||
possibly-unused-variable,
|
||||
pointless-string-statement,
|
||||
wrong-import-position,
|
||||
redefined-outer-name,
|
||||
raise-missing-from,
|
||||
logging-too-few-args,
|
||||
redefined-builtin,
|
||||
too-many-arguments,
|
||||
import-outside-toplevel,
|
||||
redefined-argument-from-local,
|
||||
logging-too-many-args,
|
||||
too-many-instance-attributes,
|
||||
unreachable,
|
||||
no-name-in-module,
|
||||
no-member,
|
||||
consider-using-ternary,
|
||||
too-many-lines,
|
||||
arguments-differ,
|
||||
too-many-public-methods,
|
||||
unused-variable,
|
||||
consider-using-dict-items,
|
||||
consider-using-in,
|
||||
reimported,
|
||||
too-many-boolean-expressions,
|
||||
cyclic-import,
|
||||
not-callable, # (paddle_utils.py) verifier.verify cannot be called (although it can)
|
||||
abstract-method, # (models.py)
|
||||
|
||||
[BASIC]
|
||||
|
||||
# Naming style matching correct argument names.
|
||||
argument-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct argument names. Overrides argument-
|
||||
# naming-style. If left empty, argument names will be checked with the set
|
||||
# naming style.
|
||||
#argument-rgx=
|
||||
|
||||
# Naming style matching correct attribute names.
|
||||
attr-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
||||
# style. If left empty, attribute names will be checked with the set naming
|
||||
# style.
|
||||
#attr-rgx=
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma.
|
||||
bad-names=foo,
|
||||
bar,
|
||||
baz,
|
||||
toto,
|
||||
tutu,
|
||||
tata
|
||||
|
||||
# Bad variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be refused
|
||||
bad-names-rgxs=
|
||||
|
||||
# Naming style matching correct class attribute names.
|
||||
class-attribute-naming-style=any
|
||||
|
||||
# Regular expression matching correct class attribute names. Overrides class-
|
||||
# attribute-naming-style. If left empty, class attribute names will be checked
|
||||
# with the set naming style.
|
||||
#class-attribute-rgx=
|
||||
|
||||
# Naming style matching correct class constant names.
|
||||
class-const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct class constant names. Overrides class-
|
||||
# const-naming-style. If left empty, class constant names will be checked with
|
||||
# the set naming style.
|
||||
#class-const-rgx=
|
||||
|
||||
# Naming style matching correct class names.
|
||||
class-naming-style=PascalCase
|
||||
|
||||
# Regular expression matching correct class names. Overrides class-naming-
|
||||
# style. If left empty, class names will be checked with the set naming style.
|
||||
#class-rgx=
|
||||
|
||||
# Naming style matching correct constant names.
|
||||
const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct constant names. Overrides const-naming-
|
||||
# style. If left empty, constant names will be checked with the set naming
|
||||
# style.
|
||||
#const-rgx=
|
||||
|
||||
# Minimum line length for functions/classes that require docstrings, shorter
|
||||
# ones are exempt.
|
||||
docstring-min-length=-1
|
||||
|
||||
# Naming style matching correct function names.
|
||||
function-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct function names. Overrides function-
|
||||
# naming-style. If left empty, function names will be checked with the set
|
||||
# naming style.
|
||||
#function-rgx=
|
||||
|
||||
# Good variable names which should always be accepted, separated by a comma.
|
||||
good-names=i,
|
||||
j,
|
||||
k,
|
||||
ex,
|
||||
Run,
|
||||
_
|
||||
|
||||
# Good variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be accepted
|
||||
good-names-rgxs=
|
||||
|
||||
# Include a hint for the correct naming format with invalid-name.
|
||||
include-naming-hint=no
|
||||
|
||||
# Naming style matching correct inline iteration names.
|
||||
inlinevar-naming-style=any
|
||||
|
||||
# Regular expression matching correct inline iteration names. Overrides
|
||||
# inlinevar-naming-style. If left empty, inline iteration names will be checked
|
||||
# with the set naming style.
|
||||
#inlinevar-rgx=
|
||||
|
||||
# Naming style matching correct method names.
|
||||
method-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct method names. Overrides method-naming-
|
||||
# style. If left empty, method names will be checked with the set naming style.
|
||||
#method-rgx=
|
||||
|
||||
# Naming style matching correct module names.
|
||||
module-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct module names. Overrides module-naming-
|
||||
# style. If left empty, module names will be checked with the set naming style.
|
||||
#module-rgx=
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
# These decorators are taken in consideration only for invalid-name.
|
||||
property-classes=abc.abstractproperty
|
||||
|
||||
# Regular expression matching correct type variable names. If left empty, type
|
||||
# variable names will be checked with the set naming style.
|
||||
#typevar-rgx=
|
||||
|
||||
# Naming style matching correct variable names.
|
||||
variable-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct variable names. Overrides variable-
|
||||
# naming-style. If left empty, variable names will be checked with the set
|
||||
# naming style.
|
||||
#variable-rgx=
|
||||
|
||||
|
||||
[STRING]
|
||||
|
||||
# This flag controls whether inconsistent-quotes generates a warning when the
|
||||
# character used as a quote delimiter is used inconsistently within a module.
|
||||
check-quote-consistency=no
|
||||
|
||||
# This flag controls whether the implicit-str-concat should generate a warning
|
||||
# on implicit string concatenation in sequences defined over several lines.
|
||||
check-str-concat-over-line-jumps=no
|
||||
|
||||
|
||||
[FORMAT]
|
||||
max-line-length=88
|
||||
single-line-if-stmt=yes
|
44
CHANGELOG
44
CHANGELOG
|
@ -6,7 +6,49 @@ The version corresponds to SimpleLogin Docker `image tag`.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [3.4.0] - 2021-04-06
|
||||
Support ARM arch
|
||||
Remove unused config like DEBUG, CLOUDWATCH, DKIM_PUBLIC_KEY_PATH, DKIM_DNS_VALUE
|
||||
Handle auto responder email
|
||||
Inform user when their alias has been transferred to another user
|
||||
Use alias transfer_token
|
||||
Improve logging
|
||||
Add /api/export/data, /api/export/aliases endpoints
|
||||
Take into account mailbox when importing/exporting aliases
|
||||
Multiple bug fixes
|
||||
Code refactoring
|
||||
Add ENABLE_SPAM_ASSASSIN config
|
||||
|
||||
## [3.3.0] - 2021-03-05
|
||||
Notify user when reply cannot be sent
|
||||
User can choose default domain for random alias
|
||||
enable LOCAL_FILE_UPLOAD by default
|
||||
fix user has to login again after quitting the browser
|
||||
login user in api auth endpoints
|
||||
Create POST /api/api_key
|
||||
Add GET /api/logout
|
||||
Add setup-done page
|
||||
Add PublicDomain
|
||||
User can choose a random alias domain in a list of public domains
|
||||
User can choose mailboxes for a domain
|
||||
Return support_pgp in GET /api/v2/aliases
|
||||
Self hosting improvements
|
||||
Improve Search
|
||||
Use poetry instead of pip
|
||||
Add PATCH /api/user_info
|
||||
Add GET /api/setting
|
||||
Add GET /api/setting/domains
|
||||
Add PATCH /api/setting
|
||||
Add "Generic Subject" option
|
||||
Add /v2/setting/domains
|
||||
Add /api/v5/alias/options
|
||||
Add GET /api/custom_domains
|
||||
Add GET /api/custom_domains/:custom_domain_id/trash
|
||||
Able to disable a directory
|
||||
Use VERP: send email from bounce address
|
||||
Use VERP for transactional email: remove SENDER, SENDER_DIR
|
||||
Use "John Wick - john at wick.com" as default sender format
|
||||
Able to transfer an alias
|
||||
|
||||
## [3.2.2] - 2020-06-15
|
||||
Fix POST /v2/alias/custom/new when DISABLE_ALIAS_SUFFIX is set
|
||||
|
|
|
@ -0,0 +1,228 @@
|
|||
Thanks for taking the time to contribute! 🎉👍
|
||||
|
||||
Before working on a new feature, please get in touch with us at dev[at]simplelogin.io to avoid duplication.
|
||||
We can also discuss the best way to implement it.
|
||||
|
||||
The project uses Flask, Python3.7+ and requires Postgres 12+ as dependency.
|
||||
|
||||
## General Architecture
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/archi.png" height="450px">
|
||||
</p>
|
||||
|
||||
SimpleLogin backend consists of 2 main components:
|
||||
|
||||
- the `webapp` used by several clients: the web app, the browser extensions (Chrome & Firefox for now), OAuth clients (apps that integrate "Sign in with SimpleLogin" button) and mobile apps.
|
||||
|
||||
- the `email handler`: implements the email forwarding (i.e. alias receiving email) and email sending (i.e. alias sending email).
|
||||
|
||||
## Install dependencies
|
||||
|
||||
The project requires:
|
||||
- Python 3.7+ and [poetry](https://python-poetry.org/) to manage dependencies
|
||||
- Node v10 for front-end.
|
||||
- Postgres 12+
|
||||
|
||||
First, install all dependencies by running the following command.
|
||||
Feel free to use `virtualenv` or similar tools to isolate development environment.
|
||||
|
||||
```bash
|
||||
poetry install
|
||||
```
|
||||
|
||||
On Mac, sometimes you might need to install some other packages via `brew`:
|
||||
|
||||
```bash
|
||||
brew install pkg-config libffi openssl postgresql@13
|
||||
```
|
||||
|
||||
You also need to install `gpg` tool, on Mac it can be done with:
|
||||
|
||||
```bash
|
||||
brew install gnupg
|
||||
```
|
||||
|
||||
If you see the `pyre2` package in the error message, you might need to install its dependencies with `brew`.
|
||||
More info on https://github.com/andreasvc/pyre2
|
||||
|
||||
```bash
|
||||
brew install -s re2 pybind11
|
||||
```
|
||||
|
||||
## Linting and static analysis
|
||||
|
||||
We use pre-commit to run all our linting and static analysis checks. Please run
|
||||
|
||||
```bash
|
||||
poetry run pre-commit install
|
||||
```
|
||||
|
||||
To install it in your development environment.
|
||||
|
||||
## Run tests
|
||||
|
||||
For most tests, you will need to have ``redis`` installed and started on your machine (listening on port 6379).
|
||||
|
||||
```bash
|
||||
sh scripts/run-test.sh
|
||||
```
|
||||
|
||||
You can also run tests using a local Postgres DB to speed things up. This can be done by
|
||||
|
||||
- creating an empty test DB and running the database migration by `dropdb test && createdb test && DB_URI=postgresql://localhost:5432/test alembic upgrade head`
|
||||
|
||||
- replacing the `DB_URI` in `test.env` file by `DB_URI=postgresql://localhost:5432/test`
|
||||
|
||||
## Run the code locally
|
||||
|
||||
Install npm packages
|
||||
|
||||
```bash
|
||||
cd static && npm install
|
||||
```
|
||||
|
||||
To run the code locally, please create a local setting file based on `example.env`:
|
||||
|
||||
```
|
||||
cp example.env .env
|
||||
```
|
||||
|
||||
You need to edit your .env to reflect the postgres exposed port, edit the `DB_URI` to:
|
||||
|
||||
```
|
||||
DB_URI=postgresql://myuser:mypassword@localhost:35432/simplelogin
|
||||
```
|
||||
|
||||
Run the postgres database:
|
||||
|
||||
```bash
|
||||
docker run -e POSTGRES_PASSWORD=mypassword -e POSTGRES_USER=myuser -e POSTGRES_DB=simplelogin -p 15432:5432 postgres:13
|
||||
```
|
||||
|
||||
To run the server:
|
||||
|
||||
```
|
||||
alembic upgrade head && flask dummy-data && python3 server.py
|
||||
```
|
||||
|
||||
then open http://localhost:7777, you should be able to login with `john@wick.com / password` account.
|
||||
|
||||
You might need to change the `.env` file for developing certain features. This file is ignored by git.
|
||||
|
||||
## Database migration
|
||||
|
||||
The database migration is handled by `alembic`
|
||||
|
||||
Whenever the model changes, a new migration has to be created.
|
||||
|
||||
If you have Docker installed, you can create the migration by the following script:
|
||||
|
||||
```bash
|
||||
sh scripts/new-migration.sh
|
||||
```
|
||||
|
||||
Make sure to review the migration script before committing it.
|
||||
Sometimes (very rarely though), the automatically generated script can be incorrect.
|
||||
|
||||
We cannot use the local database to generate migration script as the local database doesn't use migration.
|
||||
It is created via `db.create_all()` (cf `fake_data()` method). This is convenient for development and
|
||||
unit tests as we don't have to wait for the migration.
|
||||
|
||||
## Reset database
|
||||
|
||||
There are two scripts to reset your local db to an empty state:
|
||||
|
||||
- `scripts/reset_local_db.sh` will reset your development db to the latest migration version and add the development data needed to run the
|
||||
server.py locally.
|
||||
- `scripts/reset_test_db.sh` will reset your test db to the latest migration without adding the dev server data to prevent interferring with
|
||||
the tests.
|
||||
|
||||
## Code structure
|
||||
|
||||
The repo consists of the three following entry points:
|
||||
|
||||
- wsgi.py and server.py: the webapp.
|
||||
- email_handler.py: the email handler.
|
||||
- cron.py: the cronjob.
|
||||
|
||||
Here are the small sum-ups of the directory structures and their roles:
|
||||
|
||||
- app/: main Flask app. It is structured into different packages representing different features like oauth, api, dashboard, etc.
|
||||
- local_data/: contains files to facilitate the local development. They are replaced during the deployment.
|
||||
- migrations/: generated by flask-migrate. Edit these files will be only edited when you spot (very rare) errors on the database migration files.
|
||||
- static/: files available at `/static` url.
|
||||
- templates/: contains both html and email templates.
|
||||
- tests/: tests. We don't really distinguish unit, functional or integration test. A test is simply here to make sure a feature works correctly.
|
||||
|
||||
## Pull request
|
||||
|
||||
The code is formatted using [ruff](https://github.com/astral-sh/ruff), to format the code, simply run
|
||||
|
||||
```
|
||||
poetry run ruff format .
|
||||
```
|
||||
|
||||
The code is also checked with `flake8`, make sure to run `flake8` before creating the pull request by
|
||||
|
||||
```bash
|
||||
poetry run flake8
|
||||
```
|
||||
|
||||
For HTML templates, we use `djlint`. Before creating a pull request, please run
|
||||
|
||||
```bash
|
||||
poetry run djlint --check templates
|
||||
```
|
||||
|
||||
If some files aren't properly formatted, you can format all files with
|
||||
|
||||
```bash
|
||||
poetry run djlint --reformat .
|
||||
```
|
||||
|
||||
## Test sending email
|
||||
|
||||
[swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`.
|
||||
|
||||
[mailcatcher](https://github.com/sj26/mailcatcher) or [MailHog](https://github.com/mailhog/MailHog) can be used as a MTA to receive emails.
|
||||
|
||||
Here's how set up the email handler:
|
||||
|
||||
1) run mailcatcher or MailHog
|
||||
|
||||
```bash
|
||||
mailcatcher
|
||||
```
|
||||
|
||||
2) Make sure to set the following variables in the `.env` file
|
||||
|
||||
```
|
||||
# comment out this variable
|
||||
# NOT_SEND_EMAIL=true
|
||||
|
||||
# So the emails will be sent to mailcatcher/MailHog
|
||||
POSTFIX_SERVER=localhost
|
||||
POSTFIX_PORT=1025
|
||||
```
|
||||
|
||||
3) Run email_handler
|
||||
|
||||
```bash
|
||||
python email_handler.py
|
||||
```
|
||||
|
||||
4) Send a test email
|
||||
|
||||
```bash
|
||||
swaks --to e1@sl.local --from hey@google.com --server 127.0.0.1:20381
|
||||
```
|
||||
|
||||
Now open http://localhost:1080/ (or http://localhost:1080/ for MailHog), you should see the forwarded email.
|
||||
|
||||
## Job runner
|
||||
|
||||
Some features require a job handler (such as GDPR data export). To test such feature you need to run the job_runner
|
||||
```bash
|
||||
python job_runner.py
|
||||
```
|
33
Dockerfile
33
Dockerfile
|
@ -2,15 +2,38 @@
|
|||
FROM node:10.17.0-alpine AS npm
|
||||
WORKDIR /code
|
||||
COPY ./static/package*.json /code/static/
|
||||
RUN cd /code/static && npm install
|
||||
RUN cd /code/static && npm ci
|
||||
|
||||
# Main image
|
||||
FROM python:3.10
|
||||
|
||||
# Keeps Python from generating .pyc files in the container
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
# Turns off buffering for easier container logging
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Add poetry to PATH
|
||||
ENV PATH="${PATH}:/root/.local/bin"
|
||||
|
||||
FROM python:3.7
|
||||
WORKDIR /code
|
||||
|
||||
# install dependencies
|
||||
COPY ./requirements.txt ./
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
# Copy poetry files
|
||||
COPY poetry.lock pyproject.toml ./
|
||||
|
||||
# Install and setup poetry
|
||||
RUN pip install -U pip \
|
||||
&& apt-get update \
|
||||
&& apt install -y curl netcat-traditional gcc python3-dev gnupg git libre2-dev cmake ninja-build\
|
||||
&& curl -sSL https://install.python-poetry.org | python3 - \
|
||||
# Remove curl and netcat from the image
|
||||
&& apt-get purge -y curl netcat-traditional \
|
||||
# Run poetry
|
||||
&& poetry config virtualenvs.create false \
|
||||
&& poetry install --no-interaction --no-ansi --no-root \
|
||||
# Clear apt cache \
|
||||
&& apt-get purge -y libre2-dev cmake ninja-build\
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# copy npm packages
|
||||
COPY --from=npm /code /code
|
||||
|
|
674
LICENSE
674
LICENSE
|
@ -1,21 +1,661 @@
|
|||
MIT License
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (c) 2020 SimpleLogin
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We only add security updates to the latest MAJOR.MINOR version of the project. No security updates are backported to previous versions.
|
||||
If you want be up to date on security patches, make sure your SimpleLogin image is up to date.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you've found a security vulnerability, you can disclose it responsibly by sending a summary to security@simplelogin.io.
|
||||
We will review the potential threat and fix it as fast as we can.
|
||||
|
||||
We are incredibly thankful for people who disclose vulnerabilities, unfortunately we do not have a bounty program in place yet.
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = migrations
|
||||
|
||||
# template used to generate migration files
|
||||
file_template = %%(year)d_%%(month).2d%%(day).2d%%(hour).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# timezone to use when rendering the date
|
||||
# within the migration file as well as the filename.
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; this defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path
|
||||
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks=black
|
||||
# black.type=console_scripts
|
||||
# black.entrypoint=black
|
||||
# black.options=-l 79
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
|
@ -0,0 +1,316 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from arrow import Arrow
|
||||
from newrelic import agent
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.db import Session
|
||||
from app.email_utils import send_welcome_email
|
||||
from app.utils import sanitize_email, canonicalize_email
|
||||
from app.errors import (
|
||||
AccountAlreadyLinkedToAnotherPartnerException,
|
||||
AccountIsUsingAliasAsEmail,
|
||||
AccountAlreadyLinkedToAnotherUserException,
|
||||
)
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
PartnerSubscription,
|
||||
Partner,
|
||||
PartnerUser,
|
||||
User,
|
||||
Alias,
|
||||
)
|
||||
from app.utils import random_string
|
||||
|
||||
|
||||
class SLPlanType(Enum):
|
||||
Free = 1
|
||||
Premium = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class SLPlan:
|
||||
type: SLPlanType
|
||||
expiration: Optional[Arrow]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PartnerLinkRequest:
|
||||
name: str
|
||||
email: str
|
||||
external_user_id: str
|
||||
plan: SLPlan
|
||||
from_partner: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkResult:
|
||||
user: User
|
||||
strategy: str
|
||||
|
||||
|
||||
def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
|
||||
sub = PartnerSubscription.get_by(partner_user_id=partner_user.id)
|
||||
if plan.type == SLPlanType.Free:
|
||||
if sub is not None:
|
||||
LOG.i(
|
||||
f"Deleting partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
|
||||
)
|
||||
PartnerSubscription.delete(sub.id)
|
||||
agent.record_custom_event("PlanChange", {"plan": "free"})
|
||||
else:
|
||||
if sub is None:
|
||||
LOG.i(
|
||||
f"Creating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
|
||||
)
|
||||
PartnerSubscription.create(
|
||||
partner_user_id=partner_user.id,
|
||||
end_at=plan.expiration,
|
||||
)
|
||||
agent.record_custom_event("PlanChange", {"plan": "premium", "type": "new"})
|
||||
else:
|
||||
if sub.end_at != plan.expiration:
|
||||
LOG.i(
|
||||
f"Updating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
|
||||
)
|
||||
agent.record_custom_event(
|
||||
"PlanChange", {"plan": "premium", "type": "extension"}
|
||||
)
|
||||
sub.end_at = plan.expiration
|
||||
Session.commit()
|
||||
|
||||
|
||||
def set_plan_for_user(user: User, plan: SLPlan, partner: Partner):
|
||||
partner_user = PartnerUser.get_by(partner_id=partner.id, user_id=user.id)
|
||||
if partner_user is None:
|
||||
return
|
||||
return set_plan_for_partner_user(partner_user, plan)
|
||||
|
||||
|
||||
def ensure_partner_user_exists_for_user(
|
||||
link_request: PartnerLinkRequest, sl_user: User, partner: Partner
|
||||
) -> PartnerUser:
|
||||
# Find partner_user by user_id
|
||||
res = PartnerUser.get_by(user_id=sl_user.id)
|
||||
if res and res.partner_id != partner.id:
|
||||
raise AccountAlreadyLinkedToAnotherPartnerException()
|
||||
if not res:
|
||||
res = PartnerUser.create(
|
||||
user_id=sl_user.id,
|
||||
partner_id=partner.id,
|
||||
partner_email=link_request.email,
|
||||
external_user_id=link_request.external_user_id,
|
||||
)
|
||||
Session.commit()
|
||||
LOG.i(
|
||||
f"Created new partner_user for partner:{partner.id} user:{sl_user.id} external_user_id:{link_request.external_user_id}. PartnerUser.id is {res.id}"
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
class ClientMergeStrategy(ABC):
|
||||
def __init__(
|
||||
self,
|
||||
link_request: PartnerLinkRequest,
|
||||
user: Optional[User],
|
||||
partner: Partner,
|
||||
):
|
||||
if self.__class__ == ClientMergeStrategy:
|
||||
raise RuntimeError("Cannot directly instantiate a ClientMergeStrategy")
|
||||
self.link_request = link_request
|
||||
self.user = user
|
||||
self.partner = partner
|
||||
|
||||
@abstractmethod
|
||||
def process(self) -> LinkResult:
|
||||
pass
|
||||
|
||||
|
||||
class NewUserStrategy(ClientMergeStrategy):
|
||||
def process(self) -> LinkResult:
|
||||
# Will create a new SL User with a random password
|
||||
canonical_email = canonicalize_email(self.link_request.email)
|
||||
new_user = User.create(
|
||||
email=canonical_email,
|
||||
name=self.link_request.name,
|
||||
password=random_string(20),
|
||||
activated=True,
|
||||
from_partner=self.link_request.from_partner,
|
||||
)
|
||||
partner_user = PartnerUser.create(
|
||||
user_id=new_user.id,
|
||||
partner_id=self.partner.id,
|
||||
external_user_id=self.link_request.external_user_id,
|
||||
partner_email=self.link_request.email,
|
||||
)
|
||||
LOG.i(
|
||||
f"Created new user for login request for partner:{self.partner.id} external_user_id:{self.link_request.external_user_id}. New user {new_user.id} partner_user:{partner_user.id}"
|
||||
)
|
||||
set_plan_for_partner_user(
|
||||
partner_user,
|
||||
self.link_request.plan,
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
if not new_user.created_by_partner:
|
||||
send_welcome_email(new_user)
|
||||
|
||||
agent.record_custom_event("PartnerUserCreation", {"partner": self.partner.name})
|
||||
|
||||
return LinkResult(
|
||||
user=new_user,
|
||||
strategy=self.__class__.__name__,
|
||||
)
|
||||
|
||||
|
||||
class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
|
||||
def process(self) -> LinkResult:
|
||||
# IF it was scheduled to be deleted. Unschedule it.
|
||||
self.user.delete_on = None
|
||||
partner_user = ensure_partner_user_exists_for_user(
|
||||
self.link_request, self.user, self.partner
|
||||
)
|
||||
set_plan_for_partner_user(partner_user, self.link_request.plan)
|
||||
|
||||
return LinkResult(
|
||||
user=self.user,
|
||||
strategy=self.__class__.__name__,
|
||||
)
|
||||
|
||||
|
||||
class LinkedWithAnotherPartnerUserStrategy(ClientMergeStrategy):
|
||||
def process(self) -> LinkResult:
|
||||
raise AccountAlreadyLinkedToAnotherUserException()
|
||||
|
||||
|
||||
def get_login_strategy(
|
||||
link_request: PartnerLinkRequest, user: Optional[User], partner: Partner
|
||||
) -> ClientMergeStrategy:
|
||||
if user is None:
|
||||
# We couldn't find any SimpleLogin user with the requested e-mail
|
||||
return NewUserStrategy(link_request, user, partner)
|
||||
# Check if user is already linked with another partner_user
|
||||
other_partner_user = PartnerUser.get_by(partner_id=partner.id, user_id=user.id)
|
||||
if other_partner_user is not None:
|
||||
return LinkedWithAnotherPartnerUserStrategy(link_request, user, partner)
|
||||
# There is a SimpleLogin user with the partner_user's e-mail
|
||||
return ExistingUnlinkedUserStrategy(link_request, user, partner)
|
||||
|
||||
|
||||
def check_alias(email: str) -> bool:
|
||||
alias = Alias.get_by(email=email)
|
||||
if alias is not None:
|
||||
raise AccountIsUsingAliasAsEmail()
|
||||
|
||||
|
||||
def process_login_case(
|
||||
link_request: PartnerLinkRequest, partner: Partner
|
||||
) -> LinkResult:
|
||||
# Sanitize email just in case
|
||||
link_request.email = sanitize_email(link_request.email)
|
||||
# Try to find a SimpleLogin user registered with that partner user id
|
||||
partner_user = PartnerUser.get_by(
|
||||
partner_id=partner.id, external_user_id=link_request.external_user_id
|
||||
)
|
||||
if partner_user is None:
|
||||
canonical_email = canonicalize_email(link_request.email)
|
||||
# We didn't find any SimpleLogin user registered with that partner user id
|
||||
# Make sure they aren't using an alias as their link email
|
||||
check_alias(link_request.email)
|
||||
check_alias(canonical_email)
|
||||
# Try to find it using the partner's e-mail address
|
||||
users = User.filter(
|
||||
or_(User.email == link_request.email, User.email == canonical_email)
|
||||
).all()
|
||||
if len(users) > 1:
|
||||
user = [user for user in users if user.email == canonical_email][0]
|
||||
elif len(users) == 1:
|
||||
user = users[0]
|
||||
else:
|
||||
user = None
|
||||
return get_login_strategy(link_request, user, partner).process()
|
||||
else:
|
||||
# We found the SL user registered with that partner user id
|
||||
# We're done
|
||||
set_plan_for_partner_user(partner_user, link_request.plan)
|
||||
# It's the same user. No need to do anything
|
||||
return LinkResult(
|
||||
user=partner_user.user,
|
||||
strategy="Link",
|
||||
)
|
||||
|
||||
|
||||
def link_user(
|
||||
link_request: PartnerLinkRequest, current_user: User, partner: Partner
|
||||
) -> LinkResult:
|
||||
# Sanitize email just in case
|
||||
link_request.email = sanitize_email(link_request.email)
|
||||
# If it was scheduled to be deleted. Unschedule it.
|
||||
current_user.delete_on = None
|
||||
partner_user = ensure_partner_user_exists_for_user(
|
||||
link_request, current_user, partner
|
||||
)
|
||||
set_plan_for_partner_user(partner_user, link_request.plan)
|
||||
|
||||
agent.record_custom_event("AccountLinked", {"partner": partner.name})
|
||||
Session.commit()
|
||||
return LinkResult(
|
||||
user=current_user,
|
||||
strategy="Link",
|
||||
)
|
||||
|
||||
|
||||
def switch_already_linked_user(
|
||||
link_request: PartnerLinkRequest, partner_user: PartnerUser, current_user: User
|
||||
):
|
||||
# Find if the user has another link and unlink it
|
||||
other_partner_user = PartnerUser.get_by(
|
||||
user_id=current_user.id,
|
||||
partner_id=partner_user.partner_id,
|
||||
)
|
||||
if other_partner_user is not None:
|
||||
LOG.i(
|
||||
f"Deleting previous partner_user:{other_partner_user.id} from user:{current_user.id}"
|
||||
)
|
||||
PartnerUser.delete(other_partner_user.id)
|
||||
LOG.i(f"Linking partner_user:{partner_user.id} to user:{current_user.id}")
|
||||
# Link this partner_user to the current user
|
||||
partner_user.user_id = current_user.id
|
||||
# Set plan
|
||||
set_plan_for_partner_user(partner_user, link_request.plan)
|
||||
Session.commit()
|
||||
return LinkResult(
|
||||
user=current_user,
|
||||
strategy="Link",
|
||||
)
|
||||
|
||||
|
||||
def process_link_case(
|
||||
link_request: PartnerLinkRequest,
|
||||
current_user: User,
|
||||
partner: Partner,
|
||||
) -> LinkResult:
|
||||
# Sanitize email just in case
|
||||
link_request.email = sanitize_email(link_request.email)
|
||||
# Try to find a SimpleLogin user linked with this Partner account
|
||||
partner_user = PartnerUser.get_by(
|
||||
partner_id=partner.id, external_user_id=link_request.external_user_id
|
||||
)
|
||||
if partner_user is None:
|
||||
# There is no SL user linked with the partner. Proceed with linking
|
||||
return link_user(link_request, current_user, partner)
|
||||
|
||||
# There is a SL user registered with the partner. Check if is the current one
|
||||
if partner_user.user_id == current_user.id:
|
||||
# Update plan
|
||||
set_plan_for_partner_user(partner_user, link_request.plan)
|
||||
# It's the same user. No need to do anything
|
||||
return LinkResult(
|
||||
user=current_user,
|
||||
strategy="Link",
|
||||
)
|
||||
else:
|
||||
return switch_already_linked_user(link_request, partner_user, current_user)
|
|
@ -1,16 +1,89 @@
|
|||
from flask import redirect, url_for, request
|
||||
from typing import Optional
|
||||
|
||||
import arrow
|
||||
import sqlalchemy
|
||||
from flask_admin.model.template import EndpointLinkRowAction
|
||||
from markupsafe import Markup
|
||||
|
||||
from app import models, s3
|
||||
from flask import redirect, url_for, request, flash, Response
|
||||
from flask_admin import expose, AdminIndexView
|
||||
from flask_admin.actions import action
|
||||
from flask_admin.contrib import sqla
|
||||
from flask_login import current_user
|
||||
|
||||
from app.db import Session
|
||||
from app.models import (
|
||||
User,
|
||||
ManualSubscription,
|
||||
Fido,
|
||||
Subscription,
|
||||
AppleSubscription,
|
||||
AdminAuditLog,
|
||||
AuditLogActionEnum,
|
||||
ProviderComplaintState,
|
||||
Phase,
|
||||
ProviderComplaint,
|
||||
Alias,
|
||||
Newsletter,
|
||||
PADDLE_SUBSCRIPTION_GRACE_DAYS,
|
||||
)
|
||||
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
|
||||
|
||||
|
||||
class SLModelView(sqla.ModelView):
|
||||
column_default_sort = ("id", True)
|
||||
column_display_pk = True
|
||||
page_size = 100
|
||||
|
||||
can_edit = False
|
||||
can_create = False
|
||||
can_delete = False
|
||||
edit_modal = True
|
||||
|
||||
def is_accessible(self):
|
||||
return current_user.is_authenticated and current_user.is_admin
|
||||
|
||||
def inaccessible_callback(self, name, **kwargs):
|
||||
# redirect to login page if user doesn't have access
|
||||
return redirect(url_for("auth.login", next=request.url))
|
||||
flash("You don't have access to the admin page", "error")
|
||||
return redirect(url_for("dashboard.index", next=request.url))
|
||||
|
||||
def on_model_change(self, form, model, is_created):
|
||||
changes = {}
|
||||
for attr in sqlalchemy.inspect(model).attrs:
|
||||
if attr.history.has_changes() and attr.key not in (
|
||||
"created_at",
|
||||
"updated_at",
|
||||
):
|
||||
value = attr.value
|
||||
# If it's a model reference, get the source id
|
||||
if issubclass(type(value), models.Base):
|
||||
value = value.id
|
||||
# otherwise, if its a generic object stringify it
|
||||
if issubclass(type(value), object):
|
||||
value = str(value)
|
||||
changes[attr.key] = value
|
||||
auditAction = (
|
||||
AuditLogActionEnum.create_object
|
||||
if is_created
|
||||
else AuditLogActionEnum.update_object
|
||||
)
|
||||
AdminAuditLog.create(
|
||||
admin_user_id=current_user.id,
|
||||
model=model.__class__.__name__,
|
||||
model_id=model.id,
|
||||
action=auditAction.value,
|
||||
data=changes,
|
||||
)
|
||||
|
||||
def on_model_delete(self, model):
|
||||
AdminAuditLog.create(
|
||||
admin_user_id=current_user.id,
|
||||
model=model.__class__.__name__,
|
||||
model_id=model.id,
|
||||
action=AuditLogActionEnum.delete_object.value,
|
||||
)
|
||||
|
||||
|
||||
class SLAdminIndexView(AdminIndexView):
|
||||
|
@ -19,4 +92,582 @@ class SLAdminIndexView(AdminIndexView):
|
|||
if not current_user.is_authenticated or not current_user.is_admin:
|
||||
return redirect(url_for("auth.login", next=request.url))
|
||||
|
||||
return super(SLAdminIndexView, self).index()
|
||||
return redirect("/admin/user")
|
||||
|
||||
|
||||
def _user_upgrade_channel_formatter(view, context, model, name):
|
||||
return Markup(model.upgrade_channel)
|
||||
|
||||
|
||||
class UserAdmin(SLModelView):
|
||||
column_searchable_list = ["email", "id"]
|
||||
column_exclude_list = [
|
||||
"salt",
|
||||
"password",
|
||||
"otp_secret",
|
||||
"last_otp",
|
||||
"fido_uuid",
|
||||
"profile_picture",
|
||||
]
|
||||
can_edit = False
|
||||
|
||||
def scaffold_list_columns(self):
|
||||
ret = super().scaffold_list_columns()
|
||||
ret.insert(0, "upgrade_channel")
|
||||
return ret
|
||||
|
||||
column_formatters = {
|
||||
"upgrade_channel": _user_upgrade_channel_formatter,
|
||||
}
|
||||
|
||||
@action(
|
||||
"disable_user",
|
||||
"Disable user",
|
||||
"Are you sure you want to disable the selected users?",
|
||||
)
|
||||
def action_disable_user(self, ids):
|
||||
for user in User.filter(User.id.in_(ids)):
|
||||
user.disabled = True
|
||||
|
||||
flash(f"Disabled user {user.id}")
|
||||
AdminAuditLog.disable_user(current_user.id, user.id)
|
||||
|
||||
Session.commit()
|
||||
|
||||
@action(
|
||||
"enable_user",
|
||||
"Enable user",
|
||||
"Are you sure you want to enable the selected users?",
|
||||
)
|
||||
def action_enable_user(self, ids):
|
||||
for user in User.filter(User.id.in_(ids)):
|
||||
user.disabled = False
|
||||
|
||||
flash(f"Enabled user {user.id}")
|
||||
AdminAuditLog.enable_user(current_user.id, user.id)
|
||||
|
||||
Session.commit()
|
||||
|
||||
@action(
|
||||
"education_upgrade",
|
||||
"Education upgrade",
|
||||
"Are you sure you want to edu-upgrade selected users?",
|
||||
)
|
||||
def action_edu_upgrade(self, ids):
|
||||
manual_upgrade("Edu", ids, is_giveaway=True)
|
||||
|
||||
@action(
|
||||
"charity_org_upgrade",
|
||||
"Charity Organization upgrade",
|
||||
"Are you sure you want to upgrade selected users using the Charity organization program?",
|
||||
)
|
||||
def action_charity_org_upgrade(self, ids):
|
||||
manual_upgrade("Charity Organization", ids, is_giveaway=True)
|
||||
|
||||
@action(
|
||||
"journalist_upgrade",
|
||||
"Journalist upgrade",
|
||||
"Are you sure you want to upgrade selected users using the Journalist program?",
|
||||
)
|
||||
def action_journalist_upgrade(self, ids):
|
||||
manual_upgrade("Journalist", ids, is_giveaway=True)
|
||||
|
||||
@action(
|
||||
"cash_upgrade",
|
||||
"Cash upgrade",
|
||||
"Are you sure you want to cash-upgrade selected users?",
|
||||
)
|
||||
def action_cash_upgrade(self, ids):
|
||||
manual_upgrade("Cash", ids, is_giveaway=False)
|
||||
|
||||
@action(
|
||||
"crypto_upgrade",
|
||||
"Crypto upgrade",
|
||||
"Are you sure you want to crypto-upgrade selected users?",
|
||||
)
|
||||
def action_monero_upgrade(self, ids):
|
||||
manual_upgrade("Crypto", ids, is_giveaway=False)
|
||||
|
||||
@action(
|
||||
"adhoc_upgrade",
|
||||
"Adhoc upgrade - for exceptional case",
|
||||
"Are you sure you want to crypto-upgrade selected users?",
|
||||
)
|
||||
def action_adhoc_upgrade(self, ids):
|
||||
manual_upgrade("Adhoc", ids, is_giveaway=False)
|
||||
|
||||
@action(
|
||||
"extend_trial_1w",
|
||||
"Extend trial for 1 week more",
|
||||
"Extend trial for 1 week more?",
|
||||
)
|
||||
def extend_trial_1w(self, ids):
|
||||
for user in User.filter(User.id.in_(ids)):
|
||||
if user.trial_end and user.trial_end > arrow.now():
|
||||
user.trial_end = user.trial_end.shift(weeks=1)
|
||||
else:
|
||||
user.trial_end = arrow.now().shift(weeks=1)
|
||||
|
||||
flash(f"Extend trial for {user} to {user.trial_end}", "success")
|
||||
AdminAuditLog.extend_trial(
|
||||
current_user.id, user.id, user.trial_end, "1 week"
|
||||
)
|
||||
|
||||
Session.commit()
|
||||
|
||||
@action(
|
||||
"remove trial",
|
||||
"Stop trial period",
|
||||
"Remove trial for this user?",
|
||||
)
|
||||
def stop_trial(self, ids):
|
||||
for user in User.filter(User.id.in_(ids)):
|
||||
user.trial_end = None
|
||||
|
||||
flash(f"Stopped trial for {user}", "success")
|
||||
AdminAuditLog.stop_trial(current_user.id, user.id)
|
||||
|
||||
Session.commit()
|
||||
|
||||
@action(
|
||||
"disable_otp_fido",
|
||||
"Disable OTP & FIDO",
|
||||
"Disable OTP & FIDO?",
|
||||
)
|
||||
def disable_otp_fido(self, ids):
|
||||
for user in User.filter(User.id.in_(ids)):
|
||||
user_had_otp = user.enable_otp
|
||||
if user.enable_otp:
|
||||
user.enable_otp = False
|
||||
flash(f"Disable OTP for {user}", "info")
|
||||
|
||||
user_had_fido = user.fido_uuid is not None
|
||||
if user.fido_uuid:
|
||||
Fido.filter_by(uuid=user.fido_uuid).delete()
|
||||
user.fido_uuid = None
|
||||
flash(f"Disable FIDO for {user}", "info")
|
||||
AdminAuditLog.disable_otp_fido(
|
||||
current_user.id, user.id, user_had_otp, user_had_fido
|
||||
)
|
||||
|
||||
Session.commit()
|
||||
|
||||
@action(
|
||||
"stop_paddle_sub",
|
||||
"Stop user Paddle subscription",
|
||||
"This will stop the current user Paddle subscription so if user doesn't have Proton sub, they will lose all SL benefits immediately",
|
||||
)
|
||||
def stop_paddle_sub(self, ids):
|
||||
for user in User.filter(User.id.in_(ids)):
|
||||
sub: Subscription = user.get_paddle_subscription()
|
||||
if not sub:
|
||||
flash(f"No Paddle sub for {user}", "warning")
|
||||
continue
|
||||
|
||||
flash(f"{user} sub will end now, instead of {sub.next_bill_date}", "info")
|
||||
sub.next_bill_date = (
|
||||
arrow.now().shift(days=-PADDLE_SUBSCRIPTION_GRACE_DAYS).date()
|
||||
)
|
||||
|
||||
Session.commit()
|
||||
|
||||
@action(
|
||||
"clear_delete_on",
|
||||
"Remove scheduled deletion of user",
|
||||
"This will remove the scheduled deletion for this users",
|
||||
)
|
||||
def clean_delete_on(self, ids):
|
||||
for user in User.filter(User.id.in_(ids)):
|
||||
user.delete_on = None
|
||||
|
||||
Session.commit()
|
||||
|
||||
# @action(
|
||||
# "login_as",
|
||||
# "Login as this user",
|
||||
# "Login as this user?",
|
||||
# )
|
||||
# def login_as(self, ids):
|
||||
# if len(ids) != 1:
|
||||
# flash("only 1 user can be selected", "error")
|
||||
# return
|
||||
#
|
||||
# for user in User.filter(User.id.in_(ids)):
|
||||
# AdminAuditLog.logged_as_user(current_user.id, user.id)
|
||||
# login_user(user)
|
||||
# flash(f"Login as user {user}", "success")
|
||||
# return redirect("/")
|
||||
|
||||
|
||||
def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
|
||||
for user in User.filter(User.id.in_(ids)).all():
|
||||
if user.lifetime:
|
||||
flash(f"user {user} already has a lifetime license", "warning")
|
||||
continue
|
||||
|
||||
sub: Subscription = user.get_paddle_subscription()
|
||||
if sub and not sub.cancelled:
|
||||
flash(
|
||||
f"user {user} already has a Paddle license, they have to cancel it first",
|
||||
"warning",
|
||||
)
|
||||
continue
|
||||
|
||||
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=user.id)
|
||||
if apple_sub and apple_sub.is_valid():
|
||||
flash(
|
||||
f"user {user} already has a Apple subscription, they have to cancel it first",
|
||||
"warning",
|
||||
)
|
||||
continue
|
||||
|
||||
AdminAuditLog.create_manual_upgrade(current_user.id, way, user.id, is_giveaway)
|
||||
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=user.id)
|
||||
if manual_sub:
|
||||
# renew existing subscription
|
||||
if manual_sub.end_at > arrow.now():
|
||||
manual_sub.end_at = manual_sub.end_at.shift(years=1)
|
||||
else:
|
||||
manual_sub.end_at = arrow.now().shift(years=1, days=1)
|
||||
flash(f"Subscription extended to {manual_sub.end_at.humanize()}", "success")
|
||||
continue
|
||||
|
||||
ManualSubscription.create(
|
||||
user_id=user.id,
|
||||
end_at=arrow.now().shift(years=1, days=1),
|
||||
comment=way,
|
||||
is_giveaway=is_giveaway,
|
||||
)
|
||||
|
||||
flash(f"New {way} manual subscription for {user} is created", "success")
|
||||
Session.commit()
|
||||
|
||||
|
||||
class EmailLogAdmin(SLModelView):
|
||||
column_searchable_list = ["id"]
|
||||
column_filters = ["id", "user.email", "mailbox.email", "contact.website_email"]
|
||||
|
||||
can_edit = False
|
||||
can_create = False
|
||||
|
||||
|
||||
class AliasAdmin(SLModelView):
|
||||
column_searchable_list = ["id", "user.email", "email", "mailbox.email"]
|
||||
column_filters = ["id", "user.email", "email", "mailbox.email"]
|
||||
|
||||
@action(
|
||||
"disable_email_spoofing_check",
|
||||
"Disable email spoofing protection",
|
||||
"Disable email spoofing protection?",
|
||||
)
|
||||
def disable_email_spoofing_check_for(self, ids):
|
||||
for alias in Alias.filter(Alias.id.in_(ids)):
|
||||
if alias.disable_email_spoofing_check:
|
||||
flash(
|
||||
f"Email spoofing protection is already disabled on {alias.email}",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
alias.disable_email_spoofing_check = True
|
||||
flash(
|
||||
f"Email spoofing protection is disabled on {alias.email}", "success"
|
||||
)
|
||||
|
||||
Session.commit()
|
||||
|
||||
|
||||
class MailboxAdmin(SLModelView):
|
||||
column_searchable_list = ["id", "user.email", "email"]
|
||||
column_filters = ["id", "user.email", "email"]
|
||||
|
||||
|
||||
# class LifetimeCouponAdmin(SLModelView):
|
||||
# can_edit = True
|
||||
# can_create = True
|
||||
|
||||
|
||||
class CouponAdmin(SLModelView):
|
||||
can_edit = False
|
||||
can_create = True
|
||||
|
||||
|
||||
class ManualSubscriptionAdmin(SLModelView):
|
||||
can_edit = True
|
||||
column_searchable_list = ["id", "user.email"]
|
||||
|
||||
@action(
|
||||
"extend_1y",
|
||||
"Extend for 1 year",
|
||||
"Extend 1 year more?",
|
||||
)
|
||||
def extend_1y(self, ids):
|
||||
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
|
||||
ms.end_at = ms.end_at.shift(years=1)
|
||||
flash(f"Extend subscription for 1 year for {ms.user}", "success")
|
||||
AdminAuditLog.extend_subscription(
|
||||
current_user.id, ms.user.id, ms.end_at, "1 year"
|
||||
)
|
||||
|
||||
Session.commit()
|
||||
|
||||
@action(
|
||||
"extend_1m",
|
||||
"Extend for 1 month",
|
||||
"Extend 1 month more?",
|
||||
)
|
||||
def extend_1m(self, ids):
|
||||
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
|
||||
ms.end_at = ms.end_at.shift(months=1)
|
||||
flash(f"Extend subscription for 1 month for {ms.user}", "success")
|
||||
AdminAuditLog.extend_subscription(
|
||||
current_user.id, ms.user.id, ms.end_at, "1 month"
|
||||
)
|
||||
|
||||
Session.commit()
|
||||
|
||||
|
||||
# class ClientAdmin(SLModelView):
|
||||
# column_searchable_list = ["name", "description", "user.email"]
|
||||
# column_exclude_list = ["oauth_client_secret", "home_url"]
|
||||
# can_edit = True
|
||||
|
||||
|
||||
class CustomDomainAdmin(SLModelView):
|
||||
column_searchable_list = ["domain", "user.email", "user.id"]
|
||||
column_exclude_list = ["ownership_txt_token"]
|
||||
can_edit = False
|
||||
|
||||
|
||||
class ReferralAdmin(SLModelView):
|
||||
column_searchable_list = ["id", "user.email", "code", "name"]
|
||||
column_filters = ["id", "user.email", "code", "name"]
|
||||
|
||||
def scaffold_list_columns(self):
|
||||
ret = super().scaffold_list_columns()
|
||||
ret.insert(0, "nb_user")
|
||||
ret.insert(0, "nb_paid_user")
|
||||
return ret
|
||||
|
||||
|
||||
# class PayoutAdmin(SLModelView):
|
||||
# column_searchable_list = ["id", "user.email"]
|
||||
# column_filters = ["id", "user.email"]
|
||||
# can_edit = True
|
||||
# can_create = True
|
||||
# can_delete = True
|
||||
|
||||
|
||||
def _admin_action_formatter(view, context, model, name):
|
||||
action_name = AuditLogActionEnum.get_name(model.action)
|
||||
return "{} ({})".format(action_name, model.action)
|
||||
|
||||
|
||||
def _admin_created_at_formatter(view, context, model, name):
|
||||
return model.created_at.format()
|
||||
|
||||
|
||||
class AdminAuditLogAdmin(SLModelView):
|
||||
column_searchable_list = ["admin.id", "admin.email", "model_id", "created_at"]
|
||||
column_filters = ["admin.id", "admin.email", "model_id", "created_at"]
|
||||
column_exclude_list = ["id"]
|
||||
column_hide_backrefs = False
|
||||
can_edit = False
|
||||
can_create = False
|
||||
can_delete = False
|
||||
|
||||
column_formatters = {
|
||||
"action": _admin_action_formatter,
|
||||
"created_at": _admin_created_at_formatter,
|
||||
}
|
||||
|
||||
|
||||
def _transactionalcomplaint_state_formatter(view, context, model, name):
|
||||
return "{} ({})".format(ProviderComplaintState(model.state).name, model.state)
|
||||
|
||||
|
||||
def _transactionalcomplaint_phase_formatter(view, context, model, name):
|
||||
return Phase(model.phase).name
|
||||
|
||||
|
||||
def _transactionalcomplaint_refused_email_id_formatter(view, context, model, name):
|
||||
markupstring = "<a href='{}'>{}</a>".format(
|
||||
url_for(".download_eml", id=model.id), model.refused_email.full_report_path
|
||||
)
|
||||
return Markup(markupstring)
|
||||
|
||||
|
||||
class ProviderComplaintAdmin(SLModelView):
|
||||
column_searchable_list = ["id", "user.id", "created_at"]
|
||||
column_filters = ["user.id", "state"]
|
||||
column_hide_backrefs = False
|
||||
can_edit = False
|
||||
can_create = False
|
||||
can_delete = False
|
||||
|
||||
column_formatters = {
|
||||
"created_at": _admin_created_at_formatter,
|
||||
"updated_at": _admin_created_at_formatter,
|
||||
"state": _transactionalcomplaint_state_formatter,
|
||||
"phase": _transactionalcomplaint_phase_formatter,
|
||||
"refused_email": _transactionalcomplaint_refused_email_id_formatter,
|
||||
}
|
||||
|
||||
column_extra_row_actions = [ # Add a new action button
|
||||
EndpointLinkRowAction("fa fa-check-square", ".mark_ok"),
|
||||
]
|
||||
|
||||
def _get_complaint(self) -> Optional[ProviderComplaint]:
|
||||
complain_id = request.args.get("id")
|
||||
if complain_id is None:
|
||||
flash("Missing id", "error")
|
||||
return None
|
||||
complaint = ProviderComplaint.get_by(id=complain_id)
|
||||
if not complaint:
|
||||
flash("Could not find complaint", "error")
|
||||
return None
|
||||
return complaint
|
||||
|
||||
@expose("/mark_ok", methods=["GET"])
|
||||
def mark_ok(self):
|
||||
complaint = self._get_complaint()
|
||||
if not complaint:
|
||||
return redirect("/admin/transactionalcomplaint/")
|
||||
complaint.state = ProviderComplaintState.reviewed.value
|
||||
Session.commit()
|
||||
return redirect("/admin/transactionalcomplaint/")
|
||||
|
||||
@expose("/download_eml", methods=["GET"])
|
||||
def download_eml(self):
|
||||
complaint = self._get_complaint()
|
||||
if not complaint:
|
||||
return redirect("/admin/transactionalcomplaint/")
|
||||
eml_path = complaint.refused_email.full_report_path
|
||||
eml_data = s3.download_email(eml_path)
|
||||
AdminAuditLog.downloaded_provider_complaint(current_user.id, complaint.id)
|
||||
Session.commit()
|
||||
return Response(
|
||||
eml_data,
|
||||
mimetype="message/rfc822",
|
||||
headers={
|
||||
"Content-Disposition": "attachment;filename={}".format(
|
||||
complaint.refused_email.path
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _newsletter_plain_text_formatter(view, context, model: Newsletter, name):
|
||||
# to display newsletter plain_text with linebreaks in the list view
|
||||
return Markup(model.plain_text.replace("\n", "<br>"))
|
||||
|
||||
|
||||
def _newsletter_html_formatter(view, context, model: Newsletter, name):
|
||||
# to display newsletter html with linebreaks in the list view
|
||||
return Markup(model.html.replace("\n", "<br>"))
|
||||
|
||||
|
||||
class NewsletterAdmin(SLModelView):
|
||||
list_template = "admin/model/newsletter-list.html"
|
||||
edit_template = "admin/model/newsletter-edit.html"
|
||||
edit_modal = False
|
||||
|
||||
can_edit = True
|
||||
can_create = True
|
||||
|
||||
column_formatters = {
|
||||
"plain_text": _newsletter_plain_text_formatter,
|
||||
"html": _newsletter_html_formatter,
|
||||
}
|
||||
|
||||
@action(
|
||||
"send_newsletter_to_user",
|
||||
"Send this newsletter to myself or the specified userID",
|
||||
)
|
||||
def send_newsletter_to_user(self, newsletter_ids):
|
||||
user_id = request.form["user_id"]
|
||||
if user_id:
|
||||
user = User.get(user_id)
|
||||
if not user:
|
||||
flash(f"No such user with ID {user_id}", "error")
|
||||
return
|
||||
else:
|
||||
flash("use the current user", "info")
|
||||
user = current_user
|
||||
|
||||
for newsletter_id in newsletter_ids:
|
||||
newsletter = Newsletter.get(newsletter_id)
|
||||
sent, error_msg = send_newsletter_to_user(newsletter, user)
|
||||
if sent:
|
||||
flash(f"{newsletter} sent to {user}", "success")
|
||||
else:
|
||||
flash(error_msg, "error")
|
||||
|
||||
@action(
|
||||
"send_newsletter_to_address",
|
||||
"Send this newsletter to a specific address",
|
||||
)
|
||||
def send_newsletter_to_address(self, newsletter_ids):
|
||||
to_address = request.form["to_address"]
|
||||
if not to_address:
|
||||
flash("to_address missing", "error")
|
||||
return
|
||||
|
||||
for newsletter_id in newsletter_ids:
|
||||
newsletter = Newsletter.get(newsletter_id)
|
||||
# use the current_user for rendering email
|
||||
sent, error_msg = send_newsletter_to_address(
|
||||
newsletter, current_user, to_address
|
||||
)
|
||||
if sent:
|
||||
flash(
|
||||
f"{newsletter} sent to {to_address} with {current_user} context",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
flash(error_msg, "error")
|
||||
|
||||
@action(
|
||||
"clone_newsletter",
|
||||
"Clone this newsletter",
|
||||
)
|
||||
def clone_newsletter(self, newsletter_ids):
|
||||
if len(newsletter_ids) != 1:
|
||||
flash("you can only select 1 newsletter", "error")
|
||||
return
|
||||
|
||||
newsletter_id = newsletter_ids[0]
|
||||
newsletter: Newsletter = Newsletter.get(newsletter_id)
|
||||
new_newsletter = Newsletter.create(
|
||||
subject=newsletter.subject,
|
||||
html=newsletter.html,
|
||||
plain_text=newsletter.plain_text,
|
||||
commit=True,
|
||||
)
|
||||
|
||||
flash(f"Newsletter {new_newsletter.subject} has been cloned", "success")
|
||||
|
||||
|
||||
class NewsletterUserAdmin(SLModelView):
|
||||
column_searchable_list = ["id"]
|
||||
column_filters = ["id", "user.email", "newsletter.subject"]
|
||||
column_exclude_list = ["created_at", "updated_at", "id"]
|
||||
|
||||
can_edit = False
|
||||
can_create = False
|
||||
|
||||
|
||||
class DailyMetricAdmin(SLModelView):
|
||||
column_exclude_list = ["created_at", "updated_at", "id"]
|
||||
|
||||
can_export = True
|
||||
|
||||
|
||||
class MetricAdmin(SLModelView):
|
||||
column_exclude_list = ["created_at", "updated_at", "id"]
|
||||
|
||||
can_export = True
|
||||
|
||||
|
||||
class InvalidMailboxDomainAdmin(SLModelView):
|
||||
can_create = True
|
||||
can_delete = True
|
||||
|
|
|
@ -0,0 +1,190 @@
|
|||
from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Optional
|
||||
|
||||
import itsdangerous
|
||||
from app import config
|
||||
from app.log import LOG
|
||||
from app.models import User, AliasOptions, SLDomain
|
||||
|
||||
signer = itsdangerous.TimestampSigner(config.CUSTOM_ALIAS_SECRET)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AliasSuffix:
|
||||
# whether this is a custom domain
|
||||
is_custom: bool
|
||||
# Suffix
|
||||
suffix: str
|
||||
# Suffix signature
|
||||
signed_suffix: str
|
||||
# whether this is a premium SL domain. Not apply to custom domain
|
||||
is_premium: bool
|
||||
# can be either Custom or SL domain
|
||||
domain: str
|
||||
# if custom domain, whether the custom domain has MX verified, i.e. can receive emails
|
||||
mx_verified: bool = True
|
||||
|
||||
def serialize(self):
|
||||
return json.dumps(asdict(self))
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: str) -> AliasSuffix:
|
||||
return AliasSuffix(**json.loads(data))
|
||||
|
||||
|
||||
def check_suffix_signature(signed_suffix: str) -> Optional[str]:
|
||||
# hypothesis: user will click on the button in the 600 secs
|
||||
try:
|
||||
return signer.unsign(signed_suffix, max_age=600).decode()
|
||||
except itsdangerous.BadSignature:
|
||||
return None
|
||||
|
||||
|
||||
def verify_prefix_suffix(
|
||||
user: User, alias_prefix, alias_suffix, alias_options: Optional[AliasOptions] = None
|
||||
) -> bool:
|
||||
"""verify if user could create an alias with the given prefix and suffix"""
|
||||
if not alias_prefix or not alias_suffix: # should be caught on frontend
|
||||
return False
|
||||
|
||||
user_custom_domains = [cd.domain for cd in user.verified_custom_domains()]
|
||||
|
||||
# make sure alias_suffix is either .random_word@simplelogin.co or @my-domain.com
|
||||
alias_suffix = alias_suffix.strip()
|
||||
# alias_domain_prefix is either a .random_word or ""
|
||||
alias_domain_prefix, alias_domain = alias_suffix.split("@", 1)
|
||||
|
||||
# alias_domain must be either one of user custom domains or built-in domains
|
||||
if alias_domain not in user.available_alias_domains(alias_options=alias_options):
|
||||
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
|
||||
# SimpleLogin domain case:
|
||||
# 1) alias_suffix must start with "." and
|
||||
# 2) alias_domain_prefix must come from the word list
|
||||
if (
|
||||
alias_domain in user.available_sl_domains(alias_options=alias_options)
|
||||
and alias_domain not in user_custom_domains
|
||||
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
|
||||
and not config.DISABLE_ALIAS_SUFFIX
|
||||
):
|
||||
if not alias_domain_prefix.startswith("."):
|
||||
LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix)
|
||||
return False
|
||||
|
||||
else:
|
||||
if alias_domain not in user_custom_domains:
|
||||
if not config.DISABLE_ALIAS_SUFFIX:
|
||||
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
|
||||
if alias_domain not in user.available_sl_domains(
|
||||
alias_options=alias_options
|
||||
):
|
||||
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_alias_suffixes(
|
||||
user: User, alias_options: Optional[AliasOptions] = None
|
||||
) -> [AliasSuffix]:
|
||||
"""
|
||||
Similar to as get_available_suffixes() but also return custom domain that doesn't have MX set up.
|
||||
"""
|
||||
user_custom_domains = user.verified_custom_domains()
|
||||
|
||||
alias_suffixes: [AliasSuffix] = []
|
||||
|
||||
# put custom domain first
|
||||
# for each user domain, generate both the domain and a random suffix version
|
||||
for custom_domain in user_custom_domains:
|
||||
if custom_domain.random_prefix_generation:
|
||||
suffix = (
|
||||
f".{user.get_random_alias_suffix(custom_domain)}@{custom_domain.domain}"
|
||||
)
|
||||
alias_suffix = AliasSuffix(
|
||||
is_custom=True,
|
||||
suffix=suffix,
|
||||
signed_suffix=signer.sign(suffix).decode(),
|
||||
is_premium=False,
|
||||
domain=custom_domain.domain,
|
||||
mx_verified=custom_domain.verified,
|
||||
)
|
||||
if user.default_alias_custom_domain_id == custom_domain.id:
|
||||
alias_suffixes.insert(0, alias_suffix)
|
||||
else:
|
||||
alias_suffixes.append(alias_suffix)
|
||||
|
||||
suffix = f"@{custom_domain.domain}"
|
||||
alias_suffix = AliasSuffix(
|
||||
is_custom=True,
|
||||
suffix=suffix,
|
||||
signed_suffix=signer.sign(suffix).decode(),
|
||||
is_premium=False,
|
||||
domain=custom_domain.domain,
|
||||
mx_verified=custom_domain.verified,
|
||||
)
|
||||
|
||||
# put the default domain to top
|
||||
# only if random_prefix_generation isn't enabled
|
||||
if (
|
||||
user.default_alias_custom_domain_id == custom_domain.id
|
||||
and not custom_domain.random_prefix_generation
|
||||
):
|
||||
alias_suffixes.insert(0, alias_suffix)
|
||||
else:
|
||||
alias_suffixes.append(alias_suffix)
|
||||
|
||||
# then SimpleLogin domain
|
||||
sl_domains = user.get_sl_domains(alias_options=alias_options)
|
||||
default_domain_found = False
|
||||
for sl_domain in sl_domains:
|
||||
prefix = (
|
||||
"" if config.DISABLE_ALIAS_SUFFIX else f".{user.get_random_alias_suffix()}"
|
||||
)
|
||||
suffix = f"{prefix}@{sl_domain.domain}"
|
||||
alias_suffix = AliasSuffix(
|
||||
is_custom=False,
|
||||
suffix=suffix,
|
||||
signed_suffix=signer.sign(suffix).decode(),
|
||||
is_premium=sl_domain.premium_only,
|
||||
domain=sl_domain.domain,
|
||||
mx_verified=True,
|
||||
)
|
||||
# No default or this is not the default
|
||||
if (
|
||||
user.default_alias_public_domain_id is None
|
||||
or user.default_alias_public_domain_id != sl_domain.id
|
||||
):
|
||||
alias_suffixes.append(alias_suffix)
|
||||
else:
|
||||
default_domain_found = True
|
||||
alias_suffixes.insert(0, alias_suffix)
|
||||
|
||||
if not default_domain_found:
|
||||
domain_conditions = {"id": user.default_alias_public_domain_id, "hidden": False}
|
||||
if not user.is_premium():
|
||||
domain_conditions["premium_only"] = False
|
||||
sl_domain = SLDomain.get_by(**domain_conditions)
|
||||
if sl_domain:
|
||||
prefix = (
|
||||
""
|
||||
if config.DISABLE_ALIAS_SUFFIX
|
||||
else f".{user.get_random_alias_suffix()}"
|
||||
)
|
||||
suffix = f"{prefix}@{sl_domain.domain}"
|
||||
alias_suffix = AliasSuffix(
|
||||
is_custom=False,
|
||||
suffix=suffix,
|
||||
signed_suffix=signer.sign(suffix).decode(),
|
||||
is_premium=sl_domain.premium_only,
|
||||
domain=sl_domain.domain,
|
||||
mx_verified=True,
|
||||
)
|
||||
alias_suffixes.insert(0, alias_suffix)
|
||||
|
||||
return alias_suffixes
|
|
@ -1,15 +1,36 @@
|
|||
from typing import Optional
|
||||
import csv
|
||||
from io import StringIO
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from email_validator import validate_email, EmailNotValidError
|
||||
from sqlalchemy.exc import IntegrityError, DataError
|
||||
from flask import make_response
|
||||
|
||||
from app.config import (
|
||||
BOUNCE_PREFIX_FOR_REPLY_PHASE,
|
||||
BOUNCE_PREFIX,
|
||||
BOUNCE_SUFFIX,
|
||||
VERP_PREFIX,
|
||||
)
|
||||
from app.db import Session
|
||||
from app.email_utils import (
|
||||
get_email_domain_part,
|
||||
send_cannot_create_directory_alias,
|
||||
can_create_directory_for_address,
|
||||
send_cannot_create_directory_alias_disabled,
|
||||
get_email_local_part,
|
||||
send_cannot_create_domain_alias,
|
||||
email_belongs_to_alias_domains,
|
||||
send_email,
|
||||
render,
|
||||
)
|
||||
from app.errors import AliasInTrashError
|
||||
from app.extensions import db
|
||||
from app.events.event_dispatcher import EventDispatcher
|
||||
from app.events.generated.event_pb2 import (
|
||||
AliasDeleted,
|
||||
AliasStatusChanged,
|
||||
EventContent,
|
||||
)
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
Alias,
|
||||
|
@ -19,13 +40,160 @@ from app.models import (
|
|||
DeletedAlias,
|
||||
DomainDeletedAlias,
|
||||
AliasMailbox,
|
||||
Mailbox,
|
||||
EmailLog,
|
||||
Contact,
|
||||
AutoCreateRule,
|
||||
AliasUsedOn,
|
||||
ClientUser,
|
||||
)
|
||||
from app.regex_utils import regex_match
|
||||
|
||||
|
||||
def get_user_if_alias_would_auto_create(
|
||||
address: str, notify_user: bool = False
|
||||
) -> Optional[User]:
|
||||
banned_prefix = f"{VERP_PREFIX}."
|
||||
if address.startswith(banned_prefix):
|
||||
LOG.w("alias %s can't start with %s", address, banned_prefix)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Prevent addresses with unicode characters (🤯) in them for now.
|
||||
validate_email(address, check_deliverability=False, allow_smtputf8=False)
|
||||
except EmailNotValidError:
|
||||
return None
|
||||
|
||||
domain_and_rule = check_if_alias_can_be_auto_created_for_custom_domain(
|
||||
address, notify_user=notify_user
|
||||
)
|
||||
if DomainDeletedAlias.get_by(email=address):
|
||||
return None
|
||||
if domain_and_rule:
|
||||
return domain_and_rule[0].user
|
||||
directory = check_if_alias_can_be_auto_created_for_a_directory(
|
||||
address, notify_user=notify_user
|
||||
)
|
||||
if directory:
|
||||
return directory.user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_if_alias_can_be_auto_created_for_custom_domain(
|
||||
address: str, notify_user: bool = True
|
||||
) -> Optional[Tuple[CustomDomain, Optional[AutoCreateRule]]]:
|
||||
"""
|
||||
Check if this address would generate an auto created alias.
|
||||
If that's the case return the domain that would create it and the rule that triggered it.
|
||||
If there's no rule it's a catchall creation
|
||||
"""
|
||||
alias_domain = get_email_domain_part(address)
|
||||
custom_domain: CustomDomain = CustomDomain.get_by(domain=alias_domain)
|
||||
|
||||
if not custom_domain:
|
||||
return None
|
||||
|
||||
user: User = custom_domain.user
|
||||
if user.disabled:
|
||||
LOG.i("Disabled user %s can't create new alias via custom domain", user)
|
||||
return None
|
||||
|
||||
if not user.can_create_new_alias():
|
||||
LOG.d(f"{user} can't create new custom-domain alias {address}")
|
||||
if notify_user:
|
||||
send_cannot_create_domain_alias(custom_domain.user, address, alias_domain)
|
||||
return None
|
||||
|
||||
if not custom_domain.catch_all:
|
||||
if len(custom_domain.auto_create_rules) == 0:
|
||||
return None
|
||||
local = get_email_local_part(address)
|
||||
|
||||
for rule in custom_domain.auto_create_rules:
|
||||
if regex_match(rule.regex, local):
|
||||
LOG.d(
|
||||
"%s passes %s on %s",
|
||||
address,
|
||||
rule.regex,
|
||||
custom_domain,
|
||||
)
|
||||
return custom_domain, rule
|
||||
else: # no rule passes
|
||||
LOG.d("no rule passed to create %s", local)
|
||||
return None
|
||||
LOG.d("Create alias via catchall")
|
||||
|
||||
return custom_domain, None
|
||||
|
||||
|
||||
def check_if_alias_can_be_auto_created_for_a_directory(
|
||||
address: str, notify_user: bool = True
|
||||
) -> Optional[Directory]:
|
||||
"""
|
||||
Try to create an alias with directory
|
||||
If an alias would be created, return the dictionary that would trigger the creation. Otherwise, return None.
|
||||
"""
|
||||
# check if alias belongs to a directory, ie having directory/anything@EMAIL_DOMAIN format
|
||||
if not can_create_directory_for_address(address):
|
||||
return None
|
||||
|
||||
# alias contains one of the 3 special directory separator: "/", "+" or "#"
|
||||
if "/" in address:
|
||||
sep = "/"
|
||||
elif "+" in address:
|
||||
sep = "+"
|
||||
elif "#" in address:
|
||||
sep = "#"
|
||||
else:
|
||||
# if there's no directory separator in the alias, no way to auto-create it
|
||||
return None
|
||||
|
||||
directory_name = address[: address.find(sep)]
|
||||
LOG.d("directory_name %s", directory_name)
|
||||
|
||||
directory = Directory.get_by(name=directory_name)
|
||||
if not directory:
|
||||
return None
|
||||
|
||||
user: User = directory.user
|
||||
if user.disabled:
|
||||
LOG.i("Disabled %s can't create new alias with directory", user)
|
||||
return None
|
||||
|
||||
if not user.can_create_new_alias():
|
||||
LOG.d(f"{user} can't create new directory alias {address}")
|
||||
if notify_user:
|
||||
send_cannot_create_directory_alias(user, address, directory_name)
|
||||
return None
|
||||
|
||||
if directory.disabled:
|
||||
if notify_user:
|
||||
send_cannot_create_directory_alias_disabled(user, address, directory_name)
|
||||
return None
|
||||
|
||||
return directory
|
||||
|
||||
|
||||
def try_auto_create(address: str) -> Optional[Alias]:
|
||||
"""Try to auto-create the alias using directory or catch-all domain
|
||||
"""
|
||||
alias = try_auto_create_catch_all_domain(address)
|
||||
"""Try to auto-create the alias using directory or catch-all domain"""
|
||||
# VERP for reply phase is {BOUNCE_PREFIX_FOR_REPLY_PHASE}+{email_log.id}+@{alias_domain}
|
||||
if address.startswith(f"{BOUNCE_PREFIX_FOR_REPLY_PHASE}+") and "+@" in address:
|
||||
LOG.e("alias %s can't start with %s", address, BOUNCE_PREFIX_FOR_REPLY_PHASE)
|
||||
return None
|
||||
|
||||
# VERP for forward phase is BOUNCE_PREFIX + email_log.id + BOUNCE_SUFFIX
|
||||
if address.startswith(BOUNCE_PREFIX) and address.endswith(BOUNCE_SUFFIX):
|
||||
LOG.e("alias %s can't start with %s", address, BOUNCE_PREFIX)
|
||||
return None
|
||||
|
||||
try:
|
||||
# NOT allow unicode for now
|
||||
validate_email(address, check_deliverability=False, allow_smtputf8=False)
|
||||
except EmailNotValidError:
|
||||
return None
|
||||
|
||||
alias = try_auto_create_via_domain(address)
|
||||
if not alias:
|
||||
alias = try_auto_create_directory(address)
|
||||
|
||||
|
@ -36,33 +204,12 @@ def try_auto_create_directory(address: str) -> Optional[Alias]:
|
|||
"""
|
||||
Try to create an alias with directory
|
||||
"""
|
||||
# check if alias belongs to a directory, ie having directory/anything@EMAIL_DOMAIN format
|
||||
if email_belongs_to_alias_domains(address):
|
||||
# if there's no directory separator in the alias, no way to auto-create it
|
||||
if "/" not in address and "+" not in address and "#" not in address:
|
||||
return None
|
||||
|
||||
# alias contains one of the 3 special directory separator: "/", "+" or "#"
|
||||
if "/" in address:
|
||||
sep = "/"
|
||||
elif "+" in address:
|
||||
sep = "+"
|
||||
else:
|
||||
sep = "#"
|
||||
|
||||
directory_name = address[: address.find(sep)]
|
||||
LOG.d("directory_name %s", directory_name)
|
||||
|
||||
directory = Directory.get_by(name=directory_name)
|
||||
directory = check_if_alias_can_be_auto_created_for_a_directory(
|
||||
address, notify_user=True
|
||||
)
|
||||
if not directory:
|
||||
return None
|
||||
|
||||
dir_user: User = directory.user
|
||||
|
||||
if not dir_user.can_create_new_alias():
|
||||
send_cannot_create_directory_alias(dir_user, address, directory_name)
|
||||
return None
|
||||
|
||||
try:
|
||||
LOG.d("create alias %s for directory %s", address, directory)
|
||||
|
||||
|
@ -74,45 +221,55 @@ def try_auto_create_directory(address: str) -> Optional[Alias]:
|
|||
directory_id=directory.id,
|
||||
mailbox_id=mailboxes[0].id,
|
||||
)
|
||||
db.session.flush()
|
||||
if not directory.user.disable_automatic_alias_note:
|
||||
alias.note = f"Created by directory {directory.name}"
|
||||
Session.flush()
|
||||
for i in range(1, len(mailboxes)):
|
||||
AliasMailbox.create(
|
||||
alias_id=alias.id, mailbox_id=mailboxes[i].id,
|
||||
alias_id=alias.id,
|
||||
mailbox_id=mailboxes[i].id,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
return alias
|
||||
except AliasInTrashError:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"Alias %s was deleted before, cannot auto-create using directory %s, user %s",
|
||||
address,
|
||||
directory_name,
|
||||
dir_user,
|
||||
directory.name,
|
||||
directory.user,
|
||||
)
|
||||
return None
|
||||
except IntegrityError:
|
||||
LOG.w("Alias %s already exists", address)
|
||||
Session.rollback()
|
||||
alias = Alias.get_by(email=address)
|
||||
return alias
|
||||
|
||||
|
||||
def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
||||
"""Try to create an alias with catch-all domain"""
|
||||
|
||||
# try to create alias on-the-fly with custom-domain catch-all feature
|
||||
# check if alias is custom-domain alias and if the custom-domain has catch-all enabled
|
||||
alias_domain = get_email_domain_part(address)
|
||||
custom_domain = CustomDomain.get_by(domain=alias_domain)
|
||||
|
||||
if not custom_domain:
|
||||
def try_auto_create_via_domain(address: str) -> Optional[Alias]:
|
||||
"""Try to create an alias with catch-all or auto-create rules on custom domain"""
|
||||
can_create = check_if_alias_can_be_auto_created_for_custom_domain(address)
|
||||
if not can_create:
|
||||
return None
|
||||
custom_domain, rule = can_create
|
||||
|
||||
# custom_domain exists
|
||||
if not custom_domain.catch_all:
|
||||
return None
|
||||
if rule:
|
||||
alias_note = f"Created by rule {rule.order} with regex {rule.regex}"
|
||||
mailboxes = rule.mailboxes
|
||||
else:
|
||||
alias_note = "Created by catchall option"
|
||||
mailboxes = custom_domain.mailboxes
|
||||
|
||||
# custom_domain has catch-all enabled
|
||||
domain_user: User = custom_domain.user
|
||||
|
||||
if not domain_user.can_create_new_alias():
|
||||
send_cannot_create_domain_alias(domain_user, address, alias_domain)
|
||||
return None
|
||||
# a rule can have 0 mailboxes. Happened when a mailbox is deleted
|
||||
if not mailboxes:
|
||||
LOG.d(
|
||||
"use %s default mailbox for %s %s",
|
||||
custom_domain.user,
|
||||
address,
|
||||
custom_domain,
|
||||
)
|
||||
mailboxes = [custom_domain.user.default_mailbox]
|
||||
|
||||
try:
|
||||
LOG.d("create alias %s for domain %s", address, custom_domain)
|
||||
|
@ -121,42 +278,207 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
|||
user_id=custom_domain.user_id,
|
||||
custom_domain_id=custom_domain.id,
|
||||
automatic_creation=True,
|
||||
mailbox_id=domain_user.default_mailbox_id,
|
||||
mailbox_id=mailboxes[0].id,
|
||||
)
|
||||
db.session.commit()
|
||||
if not custom_domain.user.disable_automatic_alias_note:
|
||||
alias.note = alias_note
|
||||
Session.flush()
|
||||
for i in range(1, len(mailboxes)):
|
||||
AliasMailbox.create(
|
||||
alias_id=alias.id,
|
||||
mailbox_id=mailboxes[i].id,
|
||||
)
|
||||
Session.commit()
|
||||
return alias
|
||||
except AliasInTrashError:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"Alias %s was deleted before, cannot auto-create using domain catch-all %s, user %s",
|
||||
address,
|
||||
custom_domain,
|
||||
domain_user,
|
||||
custom_domain.user,
|
||||
)
|
||||
return None
|
||||
except IntegrityError:
|
||||
LOG.w("Alias %s already exists", address)
|
||||
Session.rollback()
|
||||
alias = Alias.get_by(email=address)
|
||||
return alias
|
||||
except DataError:
|
||||
LOG.w("Cannot create alias %s", address)
|
||||
Session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
def delete_alias(alias: Alias, user: User):
|
||||
Alias.delete(alias.id)
|
||||
db.session.commit()
|
||||
|
||||
# save deleted alias to either global or domain trash
|
||||
"""
|
||||
Delete an alias and add it to either global or domain trash
|
||||
Should be used instead of Alias.delete, DomainDeletedAlias.create, DeletedAlias.create
|
||||
"""
|
||||
LOG.i(f"User {user} has deleted alias {alias}")
|
||||
# save deleted alias to either global or domain tra
|
||||
if alias.custom_domain_id:
|
||||
try:
|
||||
DomainDeletedAlias.create(
|
||||
user_id=user.id, email=alias.email, domain_id=alias.custom_domain_id
|
||||
if not DomainDeletedAlias.get_by(
|
||||
email=alias.email, domain_id=alias.custom_domain_id
|
||||
):
|
||||
domain_deleted_alias = DomainDeletedAlias(
|
||||
user_id=user.id,
|
||||
email=alias.email,
|
||||
domain_id=alias.custom_domain_id,
|
||||
)
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
LOG.error(
|
||||
"alias %s domain %s has been added before to DeletedAlias",
|
||||
alias.email,
|
||||
alias.custom_domain_id,
|
||||
Session.add(domain_deleted_alias)
|
||||
Session.commit()
|
||||
LOG.i(
|
||||
f"Moving {alias} to domain {alias.custom_domain_id} trash {domain_deleted_alias}"
|
||||
)
|
||||
db.session.rollback()
|
||||
else:
|
||||
try:
|
||||
DeletedAlias.create(email=alias.email)
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
LOG.error("alias %s has been added before to DeletedAlias", alias.email)
|
||||
db.session.rollback()
|
||||
if not DeletedAlias.get_by(email=alias.email):
|
||||
deleted_alias = DeletedAlias(email=alias.email)
|
||||
Session.add(deleted_alias)
|
||||
Session.commit()
|
||||
LOG.i(f"Moving {alias} to global trash {deleted_alias}")
|
||||
|
||||
Alias.filter(Alias.id == alias.id).delete()
|
||||
Session.commit()
|
||||
|
||||
EventDispatcher.send_event(
|
||||
user, EventContent(alias_deleted=AliasDeleted(alias_id=alias.id))
|
||||
)
|
||||
|
||||
|
||||
def aliases_for_mailbox(mailbox: Mailbox) -> [Alias]:
|
||||
"""
|
||||
get list of aliases for a given mailbox
|
||||
"""
|
||||
ret = set(Alias.filter(Alias.mailbox_id == mailbox.id).all())
|
||||
|
||||
for alias in (
|
||||
Session.query(Alias)
|
||||
.join(AliasMailbox, Alias.id == AliasMailbox.alias_id)
|
||||
.filter(AliasMailbox.mailbox_id == mailbox.id)
|
||||
):
|
||||
ret.add(alias)
|
||||
|
||||
return list(ret)
|
||||
|
||||
|
||||
def nb_email_log_for_mailbox(mailbox: Mailbox):
|
||||
aliases = aliases_for_mailbox(mailbox)
|
||||
alias_ids = [alias.id for alias in aliases]
|
||||
return (
|
||||
Session.query(EmailLog)
|
||||
.join(Contact, EmailLog.contact_id == Contact.id)
|
||||
.filter(Contact.alias_id.in_(alias_ids))
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
# Only lowercase letters, numbers, dots (.), dashes (-) and underscores (_) are currently supported
|
||||
_ALIAS_PREFIX_PATTERN = r"[0-9a-z-_.]{1,}"
|
||||
|
||||
|
||||
def check_alias_prefix(alias_prefix) -> bool:
|
||||
if len(alias_prefix) > 40:
|
||||
return False
|
||||
|
||||
if re.fullmatch(_ALIAS_PREFIX_PATTERN, alias_prefix) is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def alias_export_csv(user, csv_direct_export=False):
|
||||
"""
|
||||
Get user aliases as importable CSV file
|
||||
Output:
|
||||
Importable CSV file
|
||||
|
||||
"""
|
||||
data = [["alias", "note", "enabled", "mailboxes"]]
|
||||
for alias in Alias.filter_by(user_id=user.id).all(): # type: Alias
|
||||
# Always put the main mailbox first
|
||||
# It is seen a primary while importing
|
||||
alias_mailboxes = alias.mailboxes
|
||||
alias_mailboxes.insert(
|
||||
0, alias_mailboxes.pop(alias_mailboxes.index(alias.mailbox))
|
||||
)
|
||||
|
||||
mailboxes = " ".join([mailbox.email for mailbox in alias_mailboxes])
|
||||
data.append([alias.email, alias.note, alias.enabled, mailboxes])
|
||||
|
||||
si = StringIO()
|
||||
cw = csv.writer(si)
|
||||
cw.writerows(data)
|
||||
if csv_direct_export:
|
||||
return si.getvalue()
|
||||
output = make_response(si.getvalue())
|
||||
output.headers["Content-Disposition"] = "attachment; filename=aliases.csv"
|
||||
output.headers["Content-type"] = "text/csv"
|
||||
return output
|
||||
|
||||
|
||||
def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
|
||||
# cannot transfer alias which is used for receiving newsletter
|
||||
if User.get_by(newsletter_alias_id=alias.id):
|
||||
raise Exception("Cannot transfer alias that's used to receive newsletter")
|
||||
|
||||
# update user_id
|
||||
Session.query(Contact).filter(Contact.alias_id == alias.id).update(
|
||||
{"user_id": new_user.id}
|
||||
)
|
||||
|
||||
Session.query(AliasUsedOn).filter(AliasUsedOn.alias_id == alias.id).update(
|
||||
{"user_id": new_user.id}
|
||||
)
|
||||
|
||||
Session.query(ClientUser).filter(ClientUser.alias_id == alias.id).update(
|
||||
{"user_id": new_user.id}
|
||||
)
|
||||
|
||||
# remove existing mailboxes from the alias
|
||||
Session.query(AliasMailbox).filter(AliasMailbox.alias_id == alias.id).delete()
|
||||
|
||||
# set mailboxes
|
||||
alias.mailbox_id = new_mailboxes.pop().id
|
||||
for mb in new_mailboxes:
|
||||
AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id)
|
||||
|
||||
# alias has never been transferred before
|
||||
if not alias.original_owner_id:
|
||||
alias.original_owner_id = alias.user_id
|
||||
|
||||
# inform previous owner
|
||||
old_user = alias.user
|
||||
send_email(
|
||||
old_user.email,
|
||||
f"Alias {alias.email} has been received",
|
||||
render(
|
||||
"transactional/alias-transferred.txt",
|
||||
alias=alias,
|
||||
),
|
||||
render(
|
||||
"transactional/alias-transferred.html",
|
||||
alias=alias,
|
||||
),
|
||||
)
|
||||
|
||||
# now the alias belongs to the new user
|
||||
alias.user_id = new_user.id
|
||||
|
||||
# set some fields back to default
|
||||
alias.disable_pgp = False
|
||||
alias.pinned = False
|
||||
|
||||
Session.commit()
|
||||
|
||||
|
||||
def change_alias_status(alias: Alias, enabled: bool, commit: bool = False):
|
||||
LOG.i(f"Changing alias {alias} enabled to {enabled}")
|
||||
alias.enabled = enabled
|
||||
|
||||
event = AliasStatusChanged(
|
||||
alias_id=alias.id, alias_email=alias.email, enabled=enabled
|
||||
)
|
||||
EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event))
|
||||
|
||||
if commit:
|
||||
Session.commit()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from .views import (
|
||||
alias_options,
|
||||
new_custom_alias,
|
||||
custom_domain,
|
||||
new_random_alias,
|
||||
user_info,
|
||||
auth,
|
||||
|
@ -9,4 +10,28 @@ from .views import (
|
|||
apple,
|
||||
mailbox,
|
||||
notification,
|
||||
setting,
|
||||
export,
|
||||
phone,
|
||||
sudo,
|
||||
user,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"alias_options",
|
||||
"new_custom_alias",
|
||||
"custom_domain",
|
||||
"new_random_alias",
|
||||
"user_info",
|
||||
"auth",
|
||||
"auth_mfa",
|
||||
"alias",
|
||||
"apple",
|
||||
"mailbox",
|
||||
"notification",
|
||||
"setting",
|
||||
"export",
|
||||
"phone",
|
||||
"sudo",
|
||||
"user",
|
||||
]
|
||||
|
|
|
@ -1,34 +1,70 @@
|
|||
from functools import wraps
|
||||
from typing import Tuple, Optional
|
||||
|
||||
import arrow
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask_login import current_user
|
||||
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.models import ApiKey
|
||||
|
||||
api_bp = Blueprint(name="api", import_name=__name__, url_prefix="/api")
|
||||
|
||||
SUDO_MODE_MINUTES_VALID = 5
|
||||
|
||||
|
||||
def authorize_request() -> Optional[Tuple[str, int]]:
|
||||
api_code = request.headers.get("Authentication")
|
||||
api_key = ApiKey.get_by(code=api_code)
|
||||
|
||||
if not api_key:
|
||||
if current_user.is_authenticated:
|
||||
g.user = current_user
|
||||
else:
|
||||
return jsonify(error="Wrong api key"), 401
|
||||
else:
|
||||
# Update api key stats
|
||||
api_key.last_used = arrow.now()
|
||||
api_key.times += 1
|
||||
Session.commit()
|
||||
|
||||
g.user = api_key.user
|
||||
|
||||
if g.user.disabled:
|
||||
return jsonify(error="Disabled account"), 403
|
||||
|
||||
if not g.user.is_active():
|
||||
return jsonify(error="Account does not exist"), 401
|
||||
|
||||
g.api_key = api_key
|
||||
return None
|
||||
|
||||
|
||||
def check_sudo_mode_is_active(api_key: ApiKey) -> bool:
|
||||
return api_key.sudo_mode_at and g.api_key.sudo_mode_at >= arrow.now().shift(
|
||||
minutes=-SUDO_MODE_MINUTES_VALID
|
||||
)
|
||||
|
||||
|
||||
def require_api_auth(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if current_user.is_authenticated:
|
||||
g.user = current_user
|
||||
else:
|
||||
api_code = request.headers.get("Authentication")
|
||||
api_key = ApiKey.get_by(code=api_code)
|
||||
|
||||
if not api_key:
|
||||
return jsonify(error="Wrong api key"), 401
|
||||
|
||||
# Update api key stats
|
||||
api_key.last_used = arrow.now()
|
||||
api_key.times += 1
|
||||
db.session.commit()
|
||||
|
||||
g.user = api_key.user
|
||||
|
||||
error_return = authorize_request()
|
||||
if error_return:
|
||||
return error_return
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def require_api_sudo(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
error_return = authorize_request()
|
||||
if error_return:
|
||||
return error_return
|
||||
if not check_sudo_mode_is_active(g.api_key):
|
||||
return jsonify(error="Need sudo"), 440
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
|
|
@ -1,12 +1,21 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from arrow import Arrow
|
||||
from sqlalchemy import or_, func, case
|
||||
from sqlalchemy import or_, func, case, and_
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.config import PAGE_LIMIT
|
||||
from app.extensions import db
|
||||
from app.models import Alias, Contact, EmailLog, Mailbox
|
||||
from app.db import Session
|
||||
from app.models import (
|
||||
Alias,
|
||||
Contact,
|
||||
EmailLog,
|
||||
Mailbox,
|
||||
AliasMailbox,
|
||||
CustomDomain,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -21,6 +30,7 @@ class AliasInfo:
|
|||
|
||||
latest_email_log: EmailLog = None
|
||||
latest_contact: Contact = None
|
||||
custom_domain: Optional[CustomDomain] = None
|
||||
|
||||
def contain_mailbox(self, mailbox_id: int) -> bool:
|
||||
return mailbox_id in [m.id for m in self.mailboxes]
|
||||
|
@ -62,6 +72,10 @@ def serialize_alias_info_v2(alias_info: AliasInfo) -> dict:
|
|||
{"id": mailbox.id, "email": mailbox.email}
|
||||
for mailbox in alias_info.mailboxes
|
||||
],
|
||||
"support_pgp": alias_info.alias.mailbox_support_pgp(),
|
||||
"disable_pgp": alias_info.alias.disable_pgp,
|
||||
"latest_activity": None,
|
||||
"pinned": alias_info.alias.pinned,
|
||||
}
|
||||
if alias_info.latest_email_log:
|
||||
email_log = alias_info.latest_email_log
|
||||
|
@ -79,7 +93,7 @@ def serialize_alias_info_v2(alias_info: AliasInfo) -> dict:
|
|||
return res
|
||||
|
||||
|
||||
def serialize_contact(contact: Contact) -> dict:
|
||||
def serialize_contact(contact: Contact, existed=False) -> dict:
|
||||
res = {
|
||||
"id": contact.id,
|
||||
"creation_date": contact.created_at.format(),
|
||||
|
@ -88,6 +102,9 @@ def serialize_contact(contact: Contact) -> dict:
|
|||
"last_email_sent_timestamp": None,
|
||||
"contact": contact.website_email,
|
||||
"reverse_alias": contact.website_send_to(),
|
||||
"reverse_alias_address": contact.reply_email,
|
||||
"existed": existed,
|
||||
"block_forward": contact.block_forward,
|
||||
}
|
||||
|
||||
email_log: EmailLog = contact.last_reply()
|
||||
|
@ -101,7 +118,7 @@ def serialize_contact(contact: Contact) -> dict:
|
|||
def get_alias_infos_with_pagination(user, page_id=0, query=None) -> [AliasInfo]:
|
||||
ret = []
|
||||
q = (
|
||||
db.session.query(Alias)
|
||||
Session.query(Alias)
|
||||
.options(joinedload(Alias.mailbox))
|
||||
.filter(Alias.user_id == user.id)
|
||||
.order_by(Alias.created_at.desc())
|
||||
|
@ -120,37 +137,49 @@ def get_alias_infos_with_pagination(user, page_id=0, query=None) -> [AliasInfo]:
|
|||
return ret
|
||||
|
||||
|
||||
def get_alias_infos_with_pagination_v2(
|
||||
user, page_id=0, query=None, sort=None, alias_filter=None
|
||||
def get_alias_infos_with_pagination_v3(
|
||||
user,
|
||||
page_id=0,
|
||||
query=None,
|
||||
sort=None,
|
||||
alias_filter=None,
|
||||
mailbox_id=None,
|
||||
directory_id=None,
|
||||
page_limit=PAGE_LIMIT,
|
||||
page_size=PAGE_LIMIT,
|
||||
) -> [AliasInfo]:
|
||||
ret = []
|
||||
latest_activity = func.max(
|
||||
case(
|
||||
[
|
||||
(Alias.created_at > EmailLog.created_at, Alias.created_at),
|
||||
(Alias.created_at < EmailLog.created_at, EmailLog.created_at),
|
||||
],
|
||||
else_=Alias.created_at,
|
||||
)
|
||||
).label("latest")
|
||||
|
||||
q = (
|
||||
db.session.query(Alias, Mailbox, latest_activity)
|
||||
.join(Contact, Alias.id == Contact.alias_id, isouter=True)
|
||||
.join(EmailLog, Contact.id == EmailLog.contact_id, isouter=True)
|
||||
.filter(Alias.user_id == user.id)
|
||||
.filter(Alias.mailbox_id == Mailbox.id)
|
||||
)
|
||||
q = construct_alias_query(user)
|
||||
|
||||
if query:
|
||||
q = q.filter(
|
||||
or_(Alias.email.ilike(f"%{query}%"), Alias.note.ilike(f"%{query}%"))
|
||||
or_(
|
||||
Alias.email.ilike(f"%{query}%"),
|
||||
Alias.note.ilike(f"%{query}%"),
|
||||
# can't use match() here as it uses to_tsquery that expected a tsquery input
|
||||
# Alias.ts_vector.match(query),
|
||||
Alias.ts_vector.op("@@")(func.plainto_tsquery("english", query)),
|
||||
Alias.name.ilike(f"%{query}%"),
|
||||
)
|
||||
)
|
||||
|
||||
if mailbox_id:
|
||||
q = q.join(
|
||||
AliasMailbox, Alias.id == AliasMailbox.alias_id, isouter=True
|
||||
).filter(
|
||||
or_(Alias.mailbox_id == mailbox_id, AliasMailbox.mailbox_id == mailbox_id)
|
||||
)
|
||||
|
||||
if directory_id:
|
||||
q = q.filter(Alias.directory_id == directory_id)
|
||||
|
||||
if alias_filter == "enabled":
|
||||
q = q.filter(Alias.enabled)
|
||||
elif alias_filter == "disabled":
|
||||
q = q.filter(Alias.enabled == False)
|
||||
q = q.filter(Alias.enabled.is_(False))
|
||||
elif alias_filter == "pinned":
|
||||
q = q.filter(Alias.pinned)
|
||||
elif alias_filter == "hibp":
|
||||
q = q.filter(Alias.hibp_breaches.any())
|
||||
|
||||
if sort == "old2new":
|
||||
q = q.order_by(Alias.created_at)
|
||||
|
@ -162,27 +191,40 @@ def get_alias_infos_with_pagination_v2(
|
|||
q = q.order_by(Alias.email.desc())
|
||||
else:
|
||||
# default sorting
|
||||
latest_activity = case(
|
||||
[
|
||||
(Alias.created_at > EmailLog.created_at, Alias.created_at),
|
||||
(Alias.created_at < EmailLog.created_at, EmailLog.created_at),
|
||||
],
|
||||
else_=Alias.created_at,
|
||||
)
|
||||
q = q.order_by(Alias.pinned.desc())
|
||||
q = q.order_by(latest_activity.desc())
|
||||
|
||||
q = q.group_by(Alias.id, Mailbox.id)
|
||||
q = q.limit(page_limit).offset(page_id * page_size)
|
||||
|
||||
q = list(q.limit(PAGE_LIMIT).offset(page_id * PAGE_LIMIT))
|
||||
|
||||
# preload alias.mailboxes to speed up
|
||||
alias_ids = [alias.id for alias, _, _ in q]
|
||||
Alias.query.options(joinedload(Alias._mailboxes)).filter(
|
||||
Alias.id.in_(alias_ids)
|
||||
).all()
|
||||
|
||||
for alias, mailbox, latest_activity in q:
|
||||
ret.append(get_alias_info_v2(alias, mailbox))
|
||||
ret = []
|
||||
for alias, contact, email_log, nb_reply, nb_blocked, nb_forward in list(q):
|
||||
ret.append(
|
||||
AliasInfo(
|
||||
alias=alias,
|
||||
mailbox=alias.mailbox,
|
||||
mailboxes=alias.mailboxes,
|
||||
nb_forward=nb_forward,
|
||||
nb_blocked=nb_blocked,
|
||||
nb_reply=nb_reply,
|
||||
latest_email_log=email_log,
|
||||
latest_contact=contact,
|
||||
custom_domain=alias.custom_domain,
|
||||
)
|
||||
)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def get_alias_info(alias: Alias) -> AliasInfo:
|
||||
q = (
|
||||
db.session.query(Contact, EmailLog)
|
||||
Session.query(Contact, EmailLog)
|
||||
.filter(Contact.alias_id == alias.id)
|
||||
.filter(EmailLog.contact_id == Contact.id)
|
||||
)
|
||||
|
@ -212,7 +254,7 @@ def get_alias_info_v2(alias: Alias, mailbox=None) -> AliasInfo:
|
|||
mailbox = alias.mailbox
|
||||
|
||||
q = (
|
||||
db.session.query(Contact, EmailLog)
|
||||
Session.query(Contact, EmailLog)
|
||||
.filter(Contact.alias_id == alias.id)
|
||||
.filter(EmailLog.contact_id == Contact.id)
|
||||
)
|
||||
|
@ -258,7 +300,7 @@ def get_alias_info_v2(alias: Alias, mailbox=None) -> AliasInfo:
|
|||
|
||||
def get_alias_contacts(alias, page_id: int) -> [dict]:
|
||||
q = (
|
||||
Contact.query.filter_by(alias_id=alias.id)
|
||||
Contact.filter_by(alias_id=alias.id)
|
||||
.order_by(Contact.id.desc())
|
||||
.limit(PAGE_LIMIT)
|
||||
.offset(page_id * PAGE_LIMIT)
|
||||
|
@ -269,3 +311,72 @@ def get_alias_contacts(alias, page_id: int) -> [dict]:
|
|||
res.append(serialize_contact(fe))
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def get_alias_info_v3(user: User, alias_id: int) -> AliasInfo:
|
||||
# use the same query construction in get_alias_infos_with_pagination_v3
|
||||
q = construct_alias_query(user)
|
||||
q = q.filter(Alias.id == alias_id)
|
||||
|
||||
for alias, contact, email_log, nb_reply, nb_blocked, nb_forward in q:
|
||||
return AliasInfo(
|
||||
alias=alias,
|
||||
mailbox=alias.mailbox,
|
||||
mailboxes=alias.mailboxes,
|
||||
nb_forward=nb_forward,
|
||||
nb_blocked=nb_blocked,
|
||||
nb_reply=nb_reply,
|
||||
latest_email_log=email_log,
|
||||
latest_contact=contact,
|
||||
custom_domain=alias.custom_domain,
|
||||
)
|
||||
|
||||
|
||||
def construct_alias_query(user: User):
|
||||
# subquery on alias annotated with nb_reply, nb_blocked, nb_forward, max_created_at, latest_email_log_created_at
|
||||
alias_activity_subquery = (
|
||||
Session.query(
|
||||
Alias.id,
|
||||
func.sum(case([(EmailLog.is_reply, 1)], else_=0)).label("nb_reply"),
|
||||
func.sum(
|
||||
case(
|
||||
[(and_(EmailLog.is_reply.is_(False), EmailLog.blocked), 1)],
|
||||
else_=0,
|
||||
)
|
||||
).label("nb_blocked"),
|
||||
func.sum(
|
||||
case(
|
||||
[
|
||||
(
|
||||
and_(
|
||||
EmailLog.is_reply.is_(False),
|
||||
EmailLog.blocked.is_(False),
|
||||
),
|
||||
1,
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
)
|
||||
).label("nb_forward"),
|
||||
)
|
||||
.join(EmailLog, Alias.id == EmailLog.alias_id, isouter=True)
|
||||
.filter(Alias.user_id == user.id)
|
||||
.group_by(Alias.id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
return (
|
||||
Session.query(
|
||||
Alias,
|
||||
Contact,
|
||||
EmailLog,
|
||||
alias_activity_subquery.c.nb_reply,
|
||||
alias_activity_subquery.c.nb_blocked,
|
||||
alias_activity_subquery.c.nb_forward,
|
||||
)
|
||||
.options(joinedload(Alias.hibp_breaches))
|
||||
.options(joinedload(Alias.custom_domain))
|
||||
.join(EmailLog, Alias.last_email_log_id == EmailLog.id, isouter=True)
|
||||
.join(Contact, EmailLog.contact_id == Contact.id, isouter=True)
|
||||
.filter(Alias.id == alias_activity_subquery.c.id)
|
||||
)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from deprecated import deprecated
|
||||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import request
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from app import alias_utils
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
|
@ -11,22 +11,28 @@ from app.api.serializer import (
|
|||
serialize_contact,
|
||||
get_alias_infos_with_pagination,
|
||||
get_alias_contacts,
|
||||
get_alias_infos_with_pagination_v2,
|
||||
serialize_alias_info_v2,
|
||||
get_alias_info_v2,
|
||||
get_alias_infos_with_pagination_v3,
|
||||
)
|
||||
from app.config import EMAIL_DOMAIN
|
||||
from app.dashboard.views.alias_contact_manager import create_contact
|
||||
from app.dashboard.views.alias_log import get_alias_log
|
||||
from app.email_utils import parseaddr_unicode
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.errors import (
|
||||
CannotCreateContactForReverseAlias,
|
||||
ErrContactErrorUpgradeNeeded,
|
||||
ErrContactAlreadyExists,
|
||||
ErrAddressInvalid,
|
||||
)
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import Alias, Contact, Mailbox, AliasMailbox
|
||||
from app.utils import random_string
|
||||
|
||||
|
||||
@deprecated
|
||||
@api_bp.route("/aliases", methods=["GET", "POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
@limiter.limit("10/minute", key_func=lambda: g.user.id)
|
||||
def get_aliases():
|
||||
"""
|
||||
Get aliases
|
||||
|
@ -68,13 +74,16 @@ def get_aliases():
|
|||
|
||||
|
||||
@api_bp.route("/v2/aliases", methods=["GET", "POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
@limiter.limit("50/minute", key_func=lambda: g.user.id)
|
||||
def get_aliases_v2():
|
||||
"""
|
||||
Get aliases
|
||||
Input:
|
||||
page_id: in query
|
||||
pinned: in query
|
||||
disabled: in query
|
||||
enabled: in query
|
||||
Output:
|
||||
- aliases: list of alias:
|
||||
- id
|
||||
|
@ -87,7 +96,9 @@ def get_aliases_v2():
|
|||
- note
|
||||
- mailbox
|
||||
- mailboxes
|
||||
- (optional) latest_activity:
|
||||
- support_pgp
|
||||
- disable_pgp
|
||||
- latest_activity: null if no activity.
|
||||
- timestamp
|
||||
- action: forward|reply|block|bounced
|
||||
- contact:
|
||||
|
@ -103,13 +114,26 @@ def get_aliases_v2():
|
|||
except (ValueError, TypeError):
|
||||
return jsonify(error="page_id must be provided in request query"), 400
|
||||
|
||||
pinned = "pinned" in request.args
|
||||
disabled = "disabled" in request.args
|
||||
enabled = "enabled" in request.args
|
||||
|
||||
if pinned:
|
||||
alias_filter = "pinned"
|
||||
elif disabled:
|
||||
alias_filter = "disabled"
|
||||
elif enabled:
|
||||
alias_filter = "enabled"
|
||||
else:
|
||||
alias_filter = None
|
||||
|
||||
query = None
|
||||
data = request.get_json(silent=True)
|
||||
if data:
|
||||
query = data.get("query")
|
||||
|
||||
alias_infos: [AliasInfo] = get_alias_infos_with_pagination_v2(
|
||||
user, page_id=page_id, query=query
|
||||
alias_infos: [AliasInfo] = get_alias_infos_with_pagination_v3(
|
||||
user, page_id=page_id, query=query, alias_filter=alias_filter
|
||||
)
|
||||
|
||||
return (
|
||||
|
@ -121,7 +145,6 @@ def get_aliases_v2():
|
|||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>", methods=["DELETE"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def delete_alias(alias_id):
|
||||
"""
|
||||
|
@ -135,7 +158,7 @@ def delete_alias(alias_id):
|
|||
user = g.user
|
||||
alias = Alias.get(alias_id)
|
||||
|
||||
if alias.user_id != user.id:
|
||||
if not alias or alias.user_id != user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
alias_utils.delete_alias(alias, user)
|
||||
|
@ -144,7 +167,6 @@ def delete_alias(alias_id):
|
|||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>/toggle", methods=["POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def toggle_alias(alias_id):
|
||||
"""
|
||||
|
@ -160,17 +182,17 @@ def toggle_alias(alias_id):
|
|||
user = g.user
|
||||
alias: Alias = Alias.get(alias_id)
|
||||
|
||||
if alias.user_id != user.id:
|
||||
if not alias or alias.user_id != user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
alias.enabled = not alias.enabled
|
||||
db.session.commit()
|
||||
alias_utils.change_alias_status(alias, enabled=not alias.enabled)
|
||||
LOG.i(f"User {user} changed alias {alias} enabled status to {alias.enabled}")
|
||||
Session.commit()
|
||||
|
||||
return jsonify(enabled=alias.enabled), 200
|
||||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>/activities")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_alias_activities(alias_id):
|
||||
"""
|
||||
|
@ -204,6 +226,7 @@ def get_alias_activities(alias_id):
|
|||
activity = {
|
||||
"timestamp": alias_log.when.timestamp,
|
||||
"reverse_alias": alias_log.reverse_alias,
|
||||
"reverse_alias_address": alias_log.contact.reply_email,
|
||||
}
|
||||
if alias_log.is_reply:
|
||||
activity["from"] = alias_log.alias
|
||||
|
@ -225,8 +248,7 @@ def get_alias_activities(alias_id):
|
|||
return jsonify(activities=activities), 200
|
||||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>", methods=["PUT"])
|
||||
@cross_origin()
|
||||
@api_bp.route("/aliases/<int:alias_id>", methods=["PUT", "PATCH"])
|
||||
@require_api_auth
|
||||
def update_alias(alias_id):
|
||||
"""
|
||||
|
@ -247,7 +269,7 @@ def update_alias(alias_id):
|
|||
user = g.user
|
||||
alias: Alias = Alias.get(alias_id)
|
||||
|
||||
if alias.user_id != user.id:
|
||||
if not alias or alias.user_id != user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
changed = False
|
||||
|
@ -281,8 +303,8 @@ def update_alias(alias_id):
|
|||
|
||||
# <<< update alias mailboxes >>>
|
||||
# first remove all existing alias-mailboxes links
|
||||
AliasMailbox.query.filter_by(alias_id=alias.id).delete()
|
||||
db.session.flush()
|
||||
AliasMailbox.filter_by(alias_id=alias.id).delete()
|
||||
Session.flush()
|
||||
|
||||
# then add all new mailboxes
|
||||
for i, mailbox in enumerate(mailboxes):
|
||||
|
@ -295,7 +317,13 @@ def update_alias(alias_id):
|
|||
changed = True
|
||||
|
||||
if "name" in data:
|
||||
# to make sure alias name doesn't contain linebreak
|
||||
new_name = data.get("name")
|
||||
if new_name and len(new_name) > 128:
|
||||
return jsonify(error="Name can't be longer than 128 characters"), 400
|
||||
|
||||
if new_name:
|
||||
new_name = new_name.replace("\n", "")
|
||||
alias.name = new_name
|
||||
changed = True
|
||||
|
||||
|
@ -303,14 +331,17 @@ def update_alias(alias_id):
|
|||
alias.disable_pgp = data.get("disable_pgp")
|
||||
changed = True
|
||||
|
||||
if "pinned" in data:
|
||||
alias.pinned = data.get("pinned")
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
return jsonify(ok=True), 200
|
||||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>", methods=["GET"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_alias(alias_id):
|
||||
"""
|
||||
|
@ -324,6 +355,9 @@ def get_alias(alias_id):
|
|||
user = g.user
|
||||
alias: Alias = Alias.get(alias_id)
|
||||
|
||||
if not alias:
|
||||
return jsonify(error="Unknown error"), 400
|
||||
|
||||
if alias.user_id != user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
|
@ -331,7 +365,6 @@ def get_alias(alias_id):
|
|||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>/contacts")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_alias_contacts_route(alias_id):
|
||||
"""
|
||||
|
@ -356,6 +389,9 @@ def get_alias_contacts_route(alias_id):
|
|||
|
||||
alias: Alias = Alias.get(alias_id)
|
||||
|
||||
if not alias:
|
||||
return jsonify(error="No such alias"), 404
|
||||
|
||||
if alias.user_id != user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
|
@ -365,7 +401,6 @@ def get_alias_contacts_route(alias_id):
|
|||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>/contacts", methods=["POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def create_contact_route(alias_id):
|
||||
"""
|
||||
|
@ -376,51 +411,31 @@ def create_contact_route(alias_id):
|
|||
Output:
|
||||
201 if success
|
||||
409 if contact already added
|
||||
|
||||
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify(error="request body cannot be empty"), 400
|
||||
|
||||
user = g.user
|
||||
alias: Alias = Alias.get(alias_id)
|
||||
|
||||
if alias.user_id != user.id:
|
||||
if alias.user_id != g.user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
contact_addr = data.get("contact")
|
||||
contact_address = data.get("contact")
|
||||
|
||||
# generate a reply_email, make sure it is unique
|
||||
# not use while to avoid infinite loop
|
||||
reply_email = f"ra+{random_string(25)}@{EMAIL_DOMAIN}"
|
||||
for _ in range(1000):
|
||||
reply_email = f"ra+{random_string(25)}@{EMAIL_DOMAIN}"
|
||||
if not Contact.get_by(reply_email=reply_email):
|
||||
break
|
||||
|
||||
contact_name, contact_email = parseaddr_unicode(contact_addr)
|
||||
|
||||
# already been added
|
||||
if Contact.get_by(alias_id=alias.id, website_email=contact_email):
|
||||
return jsonify(error="Contact already added"), 409
|
||||
|
||||
contact = Contact.create(
|
||||
user_id=alias.user_id,
|
||||
alias_id=alias.id,
|
||||
website_email=contact_email,
|
||||
name=contact_name,
|
||||
reply_email=reply_email,
|
||||
)
|
||||
|
||||
LOG.d("create reverse-alias for %s %s", contact_addr, alias)
|
||||
db.session.commit()
|
||||
try:
|
||||
contact = create_contact(g.user, alias, contact_address)
|
||||
except ErrContactErrorUpgradeNeeded as err:
|
||||
return jsonify(error=err.error_for_user()), 403
|
||||
except (ErrAddressInvalid, CannotCreateContactForReverseAlias) as err:
|
||||
return jsonify(error=err.error_for_user()), 400
|
||||
except ErrContactAlreadyExists as err:
|
||||
return jsonify(**serialize_contact(err.contact, existed=True)), 200
|
||||
|
||||
return jsonify(**serialize_contact(contact)), 201
|
||||
|
||||
|
||||
@api_bp.route("/contacts/<int:contact_id>", methods=["DELETE"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def delete_contact(contact_id):
|
||||
"""
|
||||
|
@ -437,6 +452,28 @@ def delete_contact(contact_id):
|
|||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
Contact.delete(contact_id)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
return jsonify(deleted=True), 200
|
||||
|
||||
|
||||
@api_bp.route("/contacts/<int:contact_id>/toggle", methods=["POST"])
|
||||
@require_api_auth
|
||||
def toggle_contact(contact_id):
|
||||
"""
|
||||
Block/Unblock contact
|
||||
Input:
|
||||
contact_id: in url
|
||||
Output:
|
||||
200
|
||||
"""
|
||||
user = g.user
|
||||
contact = Contact.get(contact_id)
|
||||
|
||||
if not contact or contact.alias.user_id != user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
contact.block_forward = not contact.block_forward
|
||||
Session.commit()
|
||||
|
||||
return jsonify(block_forward=contact.block_forward), 200
|
||||
|
|
|
@ -1,252 +1,16 @@
|
|||
import tldextract
|
||||
from flask import jsonify, request, g
|
||||
from flask_cors import cross_origin
|
||||
from sqlalchemy import desc
|
||||
|
||||
from app.alias_suffix import get_alias_suffixes
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.config import ALIAS_DOMAINS, DISABLE_ALIAS_SUFFIX
|
||||
from app.dashboard.views.custom_alias import available_suffixes
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import AliasUsedOn, Alias, User
|
||||
from app.utils import convert_to_id, random_word
|
||||
|
||||
|
||||
@api_bp.route("/alias/options")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def options():
|
||||
"""
|
||||
Return what options user has when creating new alias.
|
||||
Input:
|
||||
a valid api-key in "Authentication" header and
|
||||
optional "hostname" in args
|
||||
Output: cf README
|
||||
optional recommendation:
|
||||
optional custom
|
||||
can_create_custom: boolean
|
||||
existing: array of existing aliases
|
||||
|
||||
"""
|
||||
LOG.warning("/alias/options is obsolete")
|
||||
user = g.user
|
||||
hostname = request.args.get("hostname")
|
||||
|
||||
ret = {
|
||||
"existing": [ge.email for ge in Alias.query.filter_by(user_id=user.id)],
|
||||
"can_create_custom": user.can_create_new_alias(),
|
||||
}
|
||||
|
||||
# recommendation alias if exist
|
||||
if hostname:
|
||||
# put the latest used alias first
|
||||
q = (
|
||||
db.session.query(AliasUsedOn, Alias, User)
|
||||
.filter(
|
||||
AliasUsedOn.alias_id == Alias.id,
|
||||
Alias.user_id == user.id,
|
||||
AliasUsedOn.hostname == hostname,
|
||||
)
|
||||
.order_by(desc(AliasUsedOn.created_at))
|
||||
)
|
||||
|
||||
r = q.first()
|
||||
if r:
|
||||
_, alias, _ = r
|
||||
LOG.d("found alias %s %s %s", alias, hostname, user)
|
||||
ret["recommendation"] = {"alias": alias.email, "hostname": hostname}
|
||||
|
||||
# custom alias suggestion and suffix
|
||||
ret["custom"] = {}
|
||||
if hostname:
|
||||
# keep only the domain name of hostname, ignore TLD and subdomain
|
||||
# for ex www.groupon.com -> groupon
|
||||
domain_name = hostname
|
||||
if "." in hostname:
|
||||
parts = hostname.split(".")
|
||||
domain_name = parts[-2]
|
||||
domain_name = convert_to_id(domain_name)
|
||||
ret["custom"]["suggestion"] = domain_name
|
||||
else:
|
||||
ret["custom"]["suggestion"] = ""
|
||||
|
||||
ret["custom"]["suffixes"] = []
|
||||
# maybe better to make sure the suffix is never used before
|
||||
# but this is ok as there's a check when creating a new custom alias
|
||||
for domain in ALIAS_DOMAINS:
|
||||
if DISABLE_ALIAS_SUFFIX:
|
||||
ret["custom"]["suffixes"].append(f"@{domain}")
|
||||
else:
|
||||
ret["custom"]["suffixes"].append(f".{random_word()}@{domain}")
|
||||
|
||||
for custom_domain in user.verified_custom_domains():
|
||||
ret["custom"]["suffixes"].append("@" + custom_domain.domain)
|
||||
|
||||
# custom domain should be put first
|
||||
ret["custom"]["suffixes"] = list(reversed(ret["custom"]["suffixes"]))
|
||||
|
||||
return jsonify(ret)
|
||||
|
||||
|
||||
@api_bp.route("/v2/alias/options")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def options_v2():
|
||||
"""
|
||||
Return what options user has when creating new alias.
|
||||
Input:
|
||||
a valid api-key in "Authentication" header and
|
||||
optional "hostname" in args
|
||||
Output: cf README
|
||||
can_create: bool
|
||||
suffixes: [str]
|
||||
prefix_suggestion: str
|
||||
existing: [str]
|
||||
recommendation: Optional dict
|
||||
alias: str
|
||||
hostname: str
|
||||
|
||||
|
||||
"""
|
||||
LOG.warning("/v2/alias/options is obsolete")
|
||||
|
||||
user = g.user
|
||||
hostname = request.args.get("hostname")
|
||||
|
||||
ret = {
|
||||
"existing": [
|
||||
ge.email for ge in Alias.query.filter_by(user_id=user.id, enabled=True)
|
||||
],
|
||||
"can_create": user.can_create_new_alias(),
|
||||
"suffixes": [],
|
||||
"prefix_suggestion": "",
|
||||
}
|
||||
|
||||
# recommendation alias if exist
|
||||
if hostname:
|
||||
# put the latest used alias first
|
||||
q = (
|
||||
db.session.query(AliasUsedOn, Alias, User)
|
||||
.filter(
|
||||
AliasUsedOn.alias_id == Alias.id,
|
||||
Alias.user_id == user.id,
|
||||
AliasUsedOn.hostname == hostname,
|
||||
)
|
||||
.order_by(desc(AliasUsedOn.created_at))
|
||||
)
|
||||
|
||||
r = q.first()
|
||||
if r:
|
||||
_, alias, _ = r
|
||||
LOG.d("found alias %s %s %s", alias, hostname, user)
|
||||
ret["recommendation"] = {"alias": alias.email, "hostname": hostname}
|
||||
|
||||
# custom alias suggestion and suffix
|
||||
if hostname:
|
||||
# keep only the domain name of hostname, ignore TLD and subdomain
|
||||
# for ex www.groupon.com -> groupon
|
||||
domain_name = hostname
|
||||
if "." in hostname:
|
||||
parts = hostname.split(".")
|
||||
domain_name = parts[-2]
|
||||
domain_name = convert_to_id(domain_name)
|
||||
ret["prefix_suggestion"] = domain_name
|
||||
|
||||
# maybe better to make sure the suffix is never used before
|
||||
# but this is ok as there's a check when creating a new custom alias
|
||||
for domain in ALIAS_DOMAINS:
|
||||
if DISABLE_ALIAS_SUFFIX:
|
||||
ret["suffixes"].append(f"@{domain}")
|
||||
else:
|
||||
ret["suffixes"].append(f".{random_word()}@{domain}")
|
||||
|
||||
for custom_domain in user.verified_custom_domains():
|
||||
ret["suffixes"].append("@" + custom_domain.domain)
|
||||
|
||||
# custom domain should be put first
|
||||
ret["suffixes"] = list(reversed(ret["suffixes"]))
|
||||
|
||||
return jsonify(ret)
|
||||
|
||||
|
||||
@api_bp.route("/v3/alias/options")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def options_v3():
|
||||
"""
|
||||
Return what options user has when creating new alias.
|
||||
Same as v2 but do NOT return existing alias
|
||||
Input:
|
||||
a valid api-key in "Authentication" header and
|
||||
optional "hostname" in args
|
||||
Output: cf README
|
||||
can_create: bool
|
||||
suffixes: [str]
|
||||
prefix_suggestion: str
|
||||
recommendation: Optional dict
|
||||
alias: str
|
||||
hostname: str
|
||||
|
||||
|
||||
"""
|
||||
LOG.warning("/v3/alias/options is obsolete")
|
||||
user = g.user
|
||||
hostname = request.args.get("hostname")
|
||||
|
||||
ret = {
|
||||
"can_create": user.can_create_new_alias(),
|
||||
"suffixes": [],
|
||||
"prefix_suggestion": "",
|
||||
}
|
||||
|
||||
# recommendation alias if exist
|
||||
if hostname:
|
||||
# put the latest used alias first
|
||||
q = (
|
||||
db.session.query(AliasUsedOn, Alias, User)
|
||||
.filter(
|
||||
AliasUsedOn.alias_id == Alias.id,
|
||||
Alias.user_id == user.id,
|
||||
AliasUsedOn.hostname == hostname,
|
||||
)
|
||||
.order_by(desc(AliasUsedOn.created_at))
|
||||
)
|
||||
|
||||
r = q.first()
|
||||
if r:
|
||||
_, alias, _ = r
|
||||
LOG.d("found alias %s %s %s", alias, hostname, user)
|
||||
ret["recommendation"] = {"alias": alias.email, "hostname": hostname}
|
||||
|
||||
# custom alias suggestion and suffix
|
||||
if hostname:
|
||||
# keep only the domain name of hostname, ignore TLD and subdomain
|
||||
# for ex www.groupon.com -> groupon
|
||||
domain_name = hostname
|
||||
if "." in hostname:
|
||||
parts = hostname.split(".")
|
||||
domain_name = parts[-2]
|
||||
domain_name = convert_to_id(domain_name)
|
||||
ret["prefix_suggestion"] = domain_name
|
||||
|
||||
# maybe better to make sure the suffix is never used before
|
||||
# but this is ok as there's a check when creating a new custom alias
|
||||
for domain in ALIAS_DOMAINS:
|
||||
if DISABLE_ALIAS_SUFFIX:
|
||||
ret["suffixes"].append(f"@{domain}")
|
||||
else:
|
||||
ret["suffixes"].append(f".{random_word()}@{domain}")
|
||||
|
||||
for custom_domain in user.verified_custom_domains():
|
||||
ret["suffixes"].append("@" + custom_domain.domain)
|
||||
|
||||
# custom domain should be put first
|
||||
ret["suffixes"] = list(reversed(ret["suffixes"]))
|
||||
|
||||
return jsonify(ret)
|
||||
from app.utils import convert_to_id
|
||||
|
||||
|
||||
@api_bp.route("/v4/alias/options")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def options_v4():
|
||||
"""
|
||||
|
@ -278,7 +42,7 @@ def options_v4():
|
|||
if hostname:
|
||||
# put the latest used alias first
|
||||
q = (
|
||||
db.session.query(AliasUsedOn, Alias, User)
|
||||
Session.query(AliasUsedOn, Alias, User)
|
||||
.filter(
|
||||
AliasUsedOn.alias_id == Alias.id,
|
||||
Alias.user_id == user.id,
|
||||
|
@ -297,17 +61,93 @@ def options_v4():
|
|||
if hostname:
|
||||
# keep only the domain name of hostname, ignore TLD and subdomain
|
||||
# for ex www.groupon.com -> groupon
|
||||
domain_name = hostname
|
||||
if "." in hostname:
|
||||
parts = hostname.split(".")
|
||||
domain_name = parts[-2]
|
||||
domain_name = convert_to_id(domain_name)
|
||||
ret["prefix_suggestion"] = domain_name
|
||||
ext = tldextract.extract(hostname)
|
||||
prefix_suggestion = ext.domain
|
||||
prefix_suggestion = convert_to_id(prefix_suggestion)
|
||||
ret["prefix_suggestion"] = prefix_suggestion
|
||||
|
||||
# List of (is_custom_domain, alias-suffix, time-signed alias-suffix)
|
||||
suffixes = available_suffixes(user)
|
||||
suffixes = get_alias_suffixes(user)
|
||||
|
||||
# custom domain should be put first
|
||||
ret["suffixes"] = list([suffix[1], suffix[2]] for suffix in suffixes)
|
||||
ret["suffixes"] = list([suffix.suffix, suffix.signed_suffix] for suffix in suffixes)
|
||||
|
||||
return jsonify(ret)
|
||||
|
||||
|
||||
@api_bp.route("/v5/alias/options")
|
||||
@require_api_auth
|
||||
def options_v5():
|
||||
"""
|
||||
Return what options user has when creating new alias.
|
||||
Same as v4 but uses a better format. To be used with /v2/alias/custom/new
|
||||
Input:
|
||||
a valid api-key in "Authentication" header and
|
||||
optional "hostname" in args
|
||||
Output: cf README
|
||||
can_create: bool
|
||||
suffixes: [
|
||||
{
|
||||
suffix: "suffix",
|
||||
signed_suffix: "signed_suffix",
|
||||
is_custom: true,
|
||||
is_premium: false
|
||||
}
|
||||
]
|
||||
prefix_suggestion: str
|
||||
recommendation: Optional dict
|
||||
alias: str
|
||||
hostname: str
|
||||
|
||||
|
||||
"""
|
||||
user = g.user
|
||||
hostname = request.args.get("hostname")
|
||||
|
||||
ret = {
|
||||
"can_create": user.can_create_new_alias(),
|
||||
"suffixes": [],
|
||||
"prefix_suggestion": "",
|
||||
}
|
||||
|
||||
# recommendation alias if exist
|
||||
if hostname:
|
||||
# put the latest used alias first
|
||||
q = (
|
||||
Session.query(AliasUsedOn, Alias, User)
|
||||
.filter(
|
||||
AliasUsedOn.alias_id == Alias.id,
|
||||
Alias.user_id == user.id,
|
||||
AliasUsedOn.hostname == hostname,
|
||||
)
|
||||
.order_by(desc(AliasUsedOn.created_at))
|
||||
)
|
||||
|
||||
r = q.first()
|
||||
if r:
|
||||
_, alias, _ = r
|
||||
LOG.d("found alias %s %s %s", alias, hostname, user)
|
||||
ret["recommendation"] = {"alias": alias.email, "hostname": hostname}
|
||||
|
||||
# custom alias suggestion and suffix
|
||||
if hostname:
|
||||
# keep only the domain name of hostname, ignore TLD and subdomain
|
||||
# for ex www.groupon.com -> groupon
|
||||
ext = tldextract.extract(hostname)
|
||||
prefix_suggestion = ext.domain
|
||||
prefix_suggestion = convert_to_id(prefix_suggestion)
|
||||
ret["prefix_suggestion"] = prefix_suggestion
|
||||
|
||||
suffixes = get_alias_suffixes(user)
|
||||
|
||||
# custom domain should be put first
|
||||
ret["suffixes"] = [
|
||||
{
|
||||
"suffix": suffix.suffix,
|
||||
"signed_suffix": suffix.signed_suffix,
|
||||
"is_custom": suffix.is_custom,
|
||||
"is_premium": suffix.is_premium,
|
||||
}
|
||||
for suffix in suffixes
|
||||
]
|
||||
|
||||
return jsonify(ret)
|
||||
|
|
|
@ -5,27 +5,32 @@ import requests
|
|||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import request
|
||||
from flask_cors import cross_origin
|
||||
from requests import RequestException
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.config import APPLE_API_SECRET, MACAPP_APPLE_API_SECRET
|
||||
from app.extensions import db
|
||||
from app.subscription_webhook import execute_subscription_webhook
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import PlanEnum, AppleSubscription
|
||||
|
||||
_MONTHLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.monthly"
|
||||
_YEARLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.yearly"
|
||||
|
||||
# SL Mac app used to be in SL account
|
||||
_MACAPP_MONTHLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.monthly"
|
||||
_MACAPP_YEARLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.yearly"
|
||||
|
||||
# SL Mac app is moved to Proton account
|
||||
_MACAPP_MONTHLY_PRODUCT_ID_NEW = "me.proton.simplelogin.macos.premium.monthly"
|
||||
_MACAPP_YEARLY_PRODUCT_ID_NEW = "me.proton.simplelogin.macos.premium.yearly"
|
||||
|
||||
# Apple API URL
|
||||
_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt"
|
||||
_PROD_URL = "https://buy.itunes.apple.com/verifyReceipt"
|
||||
|
||||
|
||||
@api_bp.route("/apple/process_payment", methods=["POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def apple_process_payment():
|
||||
"""
|
||||
|
@ -37,22 +42,24 @@ def apple_process_payment():
|
|||
200 of the payment is successful, i.e. user is upgraded to premium
|
||||
|
||||
"""
|
||||
LOG.debug("request for /apple/process_payment")
|
||||
user = g.user
|
||||
LOG.d("request for /apple/process_payment from %s", user)
|
||||
data = request.get_json()
|
||||
receipt_data = data.get("receipt_data")
|
||||
is_macapp = "is_macapp" in data
|
||||
is_macapp = "is_macapp" in data and data["is_macapp"] is True
|
||||
|
||||
if is_macapp:
|
||||
LOG.d("Use Macapp secret")
|
||||
password = MACAPP_APPLE_API_SECRET
|
||||
else:
|
||||
password = APPLE_API_SECRET
|
||||
|
||||
apple_sub = verify_receipt(receipt_data, user, password)
|
||||
if apple_sub:
|
||||
execute_subscription_webhook(user)
|
||||
return jsonify(ok=True), 200
|
||||
|
||||
return jsonify(ok=False), 400
|
||||
return jsonify(error="Processing failed"), 400
|
||||
|
||||
|
||||
@api_bp.route("/apple/update_notification", methods=["GET", "POST"])
|
||||
|
@ -230,7 +237,7 @@ def apple_update_notification():
|
|||
# "auto_renew_product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
|
||||
# "notification_type": "DID_CHANGE_RENEWAL_STATUS",
|
||||
# }
|
||||
LOG.debug("request for /api/apple/update_notification")
|
||||
LOG.d("request for /api/apple/update_notification")
|
||||
data = request.get_json()
|
||||
if not (
|
||||
data
|
||||
|
@ -261,7 +268,11 @@ def apple_update_notification():
|
|||
plan = (
|
||||
PlanEnum.monthly
|
||||
if transaction["product_id"]
|
||||
in (_MONTHLY_PRODUCT_ID, _MACAPP_MONTHLY_PRODUCT_ID)
|
||||
in (
|
||||
_MONTHLY_PRODUCT_ID,
|
||||
_MACAPP_MONTHLY_PRODUCT_ID,
|
||||
_MACAPP_MONTHLY_PRODUCT_ID_NEW,
|
||||
)
|
||||
else PlanEnum.yearly
|
||||
)
|
||||
|
||||
|
@ -280,36 +291,48 @@ def apple_update_notification():
|
|||
apple_sub.receipt_data = data["unified_receipt"]["latest_receipt"]
|
||||
apple_sub.expires_date = expires_date
|
||||
apple_sub.plan = plan
|
||||
db.session.commit()
|
||||
apple_sub.product_id = transaction["product_id"]
|
||||
Session.commit()
|
||||
execute_subscription_webhook(user)
|
||||
return jsonify(ok=True), 200
|
||||
else:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"No existing AppleSub for original_transaction_id %s",
|
||||
original_transaction_id,
|
||||
)
|
||||
LOG.d("request data %s", data)
|
||||
return jsonify(ok=False), 400
|
||||
return jsonify(error="Processing failed"), 400
|
||||
|
||||
|
||||
def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
||||
"""Call verifyReceipt endpoint and create/update AppleSubscription table
|
||||
"""
|
||||
Call https://buy.itunes.apple.com/verifyReceipt and create/update AppleSubscription table
|
||||
Call the production URL for verifyReceipt first,
|
||||
and proceed to verify with the sandbox URL if receive a 21007 status code.
|
||||
use sandbox URL if receive a 21007 status code.
|
||||
|
||||
Return AppleSubscription object if success
|
||||
|
||||
https://developer.apple.com/documentation/appstorereceipts/verifyreceipt
|
||||
"""
|
||||
LOG.d("start verify_receipt")
|
||||
try:
|
||||
r = requests.post(
|
||||
_PROD_URL, json={"receipt-data": receipt_data, "password": password}
|
||||
)
|
||||
except RequestException:
|
||||
LOG.w("cannot call Apple server %s", _PROD_URL)
|
||||
return None
|
||||
|
||||
if r.status_code >= 500:
|
||||
LOG.w("Apple server error, response:%s %s", r, r.content)
|
||||
return None
|
||||
|
||||
if r.json() == {"status": 21007}:
|
||||
# try sandbox_url
|
||||
LOG.warning("Use the sandbox url instead")
|
||||
LOG.w("Use the sandbox url instead")
|
||||
r = requests.post(
|
||||
_SANDBOX_URL, json={"receipt-data": receipt_data, "password": password},
|
||||
_SANDBOX_URL,
|
||||
json={"receipt-data": receipt_data, "password": password},
|
||||
)
|
||||
|
||||
data = r.json()
|
||||
|
@ -464,12 +487,16 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||
# }
|
||||
|
||||
if data["status"] != 0:
|
||||
LOG.warning(
|
||||
"verifyReceipt status !=0, probably invalid receipt. User %s", user,
|
||||
LOG.e(
|
||||
"verifyReceipt status !=0, probably invalid receipt. User %s, data %s",
|
||||
user,
|
||||
data,
|
||||
)
|
||||
return None
|
||||
|
||||
# each item in data["receipt"]["in_app"] has the following format
|
||||
# use responseBody.Latest_receipt_info and not responseBody.Receipt.In_app
|
||||
# as recommended on https://developer.apple.com/documentation/appstorereceipts/responsebody/receipt/in_app
|
||||
# each item in data["latest_receipt_info"] has the following format
|
||||
# {
|
||||
# "quantity": "1",
|
||||
# "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
|
||||
|
@ -488,9 +515,9 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||
# "is_trial_period": "false",
|
||||
# "is_in_intro_offer_period": "false",
|
||||
# }
|
||||
transactions = data["receipt"]["in_app"]
|
||||
transactions = data.get("latest_receipt_info")
|
||||
if not transactions:
|
||||
LOG.warning("Empty transactions in data %s", data)
|
||||
LOG.i("Empty transactions in data %s", data)
|
||||
return None
|
||||
|
||||
latest_transaction = max(transactions, key=lambda t: int(t["expires_date_ms"]))
|
||||
|
@ -498,7 +525,12 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||
expires_date = arrow.get(int(latest_transaction["expires_date_ms"]) / 1000)
|
||||
plan = (
|
||||
PlanEnum.monthly
|
||||
if latest_transaction["product_id"] == _MONTHLY_PRODUCT_ID
|
||||
if latest_transaction["product_id"]
|
||||
in (
|
||||
_MONTHLY_PRODUCT_ID,
|
||||
_MACAPP_MONTHLY_PRODUCT_ID,
|
||||
_MACAPP_MONTHLY_PRODUCT_ID_NEW,
|
||||
)
|
||||
else PlanEnum.yearly
|
||||
)
|
||||
|
||||
|
@ -506,19 +538,21 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||
|
||||
if apple_sub:
|
||||
LOG.d(
|
||||
"Update AppleSubscription for user %s, expired at %s, plan %s",
|
||||
"Update AppleSubscription for user %s, expired at %s (%s), plan %s",
|
||||
user,
|
||||
expires_date,
|
||||
expires_date.humanize(),
|
||||
plan,
|
||||
)
|
||||
apple_sub.receipt_data = receipt_data
|
||||
apple_sub.expires_date = expires_date
|
||||
apple_sub.original_transaction_id = original_transaction_id
|
||||
apple_sub.product_id = latest_transaction["product_id"]
|
||||
apple_sub.plan = plan
|
||||
else:
|
||||
# the same original_transaction_id has been used on another account
|
||||
if AppleSubscription.get_by(original_transaction_id=original_transaction_id):
|
||||
LOG.error("Same Apple Sub has been used before, current user %s", user)
|
||||
LOG.e("Same Apple Sub has been used before, current user %s", user)
|
||||
return None
|
||||
|
||||
LOG.d(
|
||||
|
@ -533,8 +567,10 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||
expires_date=expires_date,
|
||||
original_transaction_id=original_transaction_id,
|
||||
plan=plan,
|
||||
product_id=latest_transaction["product_id"],
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
execute_subscription_webhook(user)
|
||||
Session.commit()
|
||||
|
||||
return apple_sub
|
||||
|
|
|
@ -1,31 +1,33 @@
|
|||
import secrets
|
||||
import string
|
||||
|
||||
import facebook
|
||||
import google.oauth2.credentials
|
||||
import googleapiclient.discovery
|
||||
import random
|
||||
from flask import jsonify, request, g
|
||||
from flask_cors import cross_origin
|
||||
from flask import jsonify, request
|
||||
from flask_login import login_user
|
||||
from itsdangerous import Signer
|
||||
|
||||
from app import email_utils
|
||||
from app.api.base import api_bp
|
||||
from app.config import FLASK_SECRET, DISABLE_REGISTRATION
|
||||
from app.dashboard.views.setting import send_reset_password_email
|
||||
from app.dashboard.views.account_setting import send_reset_password_email
|
||||
from app.db import Session
|
||||
from app.email_utils import (
|
||||
email_domain_can_be_used_as_mailbox,
|
||||
email_can_be_used_as_mailbox,
|
||||
personal_email_already_used,
|
||||
send_email,
|
||||
render,
|
||||
)
|
||||
from app.extensions import db, limiter
|
||||
from app.events.auth_event import LoginEvent, RegisterEvent
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import User, ApiKey, SocialAuth, AccountActivation
|
||||
from app.utils import sanitize_email, canonicalize_email
|
||||
|
||||
|
||||
@api_bp.route("/auth/login", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit(
|
||||
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
|
||||
)
|
||||
@limiter.limit("10/minute")
|
||||
def auth_login():
|
||||
"""
|
||||
Authenticate user
|
||||
|
@ -47,28 +49,39 @@ def auth_login():
|
|||
if not data:
|
||||
return jsonify(error="request body cannot be empty"), 400
|
||||
|
||||
email = data.get("email").strip().lower()
|
||||
password = data.get("password")
|
||||
device = data.get("device")
|
||||
|
||||
user = User.filter_by(email=email).first()
|
||||
email = sanitize_email(data.get("email"))
|
||||
canonical_email = canonicalize_email(data.get("email"))
|
||||
|
||||
user = User.get_by(email=email) or User.get_by(email=canonical_email)
|
||||
|
||||
if not user or not user.check_password(password):
|
||||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
LoginEvent(LoginEvent.ActionType.failed, LoginEvent.Source.api).send()
|
||||
return jsonify(error="Email or password incorrect"), 400
|
||||
elif user.disabled:
|
||||
LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send()
|
||||
return jsonify(error="Account disabled"), 400
|
||||
elif user.delete_on is not None:
|
||||
LoginEvent(
|
||||
LoginEvent.ActionType.scheduled_to_be_deleted, LoginEvent.Source.api
|
||||
).send()
|
||||
return jsonify(error="Account scheduled for deletion"), 400
|
||||
elif not user.activated:
|
||||
return jsonify(error="Account not activated"), 400
|
||||
LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send()
|
||||
return jsonify(error="Account not activated"), 422
|
||||
elif user.fido_enabled():
|
||||
# allow user who has TOTP enabled to continue using the mobile app
|
||||
if not user.enable_otp:
|
||||
return jsonify(error="Currently we don't support FIDO on mobile yet"), 403
|
||||
|
||||
LoginEvent(LoginEvent.ActionType.success, LoginEvent.Source.api).send()
|
||||
return jsonify(**auth_payload(user, device)), 200
|
||||
|
||||
|
||||
@api_bp.route("/auth/register", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit("10/minute")
|
||||
def auth_register():
|
||||
"""
|
||||
User signs up - will need to activate their account with an activation code.
|
||||
|
@ -83,43 +96,49 @@ def auth_register():
|
|||
if not data:
|
||||
return jsonify(error="request body cannot be empty"), 400
|
||||
|
||||
email = data.get("email").strip().lower()
|
||||
dirty_email = data.get("email")
|
||||
email = canonicalize_email(dirty_email)
|
||||
password = data.get("password")
|
||||
|
||||
if DISABLE_REGISTRATION:
|
||||
RegisterEvent(RegisterEvent.ActionType.failed, RegisterEvent.Source.api).send()
|
||||
return jsonify(error="registration is closed"), 400
|
||||
if not email_domain_can_be_used_as_mailbox(email) or personal_email_already_used(
|
||||
email
|
||||
):
|
||||
if not email_can_be_used_as_mailbox(email) or personal_email_already_used(email):
|
||||
RegisterEvent(
|
||||
RegisterEvent.ActionType.invalid_email, RegisterEvent.Source.api
|
||||
).send()
|
||||
return jsonify(error=f"cannot use {email} as personal inbox"), 400
|
||||
|
||||
if not password or len(password) < 8:
|
||||
RegisterEvent(RegisterEvent.ActionType.failed, RegisterEvent.Source.api).send()
|
||||
return jsonify(error="password too short"), 400
|
||||
|
||||
LOG.debug("create user %s", email)
|
||||
user = User.create(email=email, name="", password=password)
|
||||
db.session.flush()
|
||||
if len(password) > 100:
|
||||
RegisterEvent(RegisterEvent.ActionType.failed, RegisterEvent.Source.api).send()
|
||||
return jsonify(error="password too long"), 400
|
||||
|
||||
LOG.d("create user %s", email)
|
||||
user = User.create(email=email, name=dirty_email, password=password)
|
||||
Session.flush()
|
||||
|
||||
# create activation code
|
||||
code = "".join([str(random.randint(0, 9)) for _ in range(6)])
|
||||
code = "".join([str(secrets.choice(string.digits)) for _ in range(6)])
|
||||
AccountActivation.create(user_id=user.id, code=code)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
send_email(
|
||||
email,
|
||||
f"Just one more step to join SimpleLogin",
|
||||
render("transactional/code-activation.txt", code=code),
|
||||
"Just one more step to join SimpleLogin",
|
||||
render("transactional/code-activation.txt.jinja2", code=code),
|
||||
render("transactional/code-activation.html", code=code),
|
||||
)
|
||||
|
||||
RegisterEvent(RegisterEvent.ActionType.success, RegisterEvent.Source.api).send()
|
||||
return jsonify(msg="User needs to confirm their account"), 200
|
||||
|
||||
|
||||
@api_bp.route("/auth/activate", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit(
|
||||
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
|
||||
)
|
||||
@limiter.limit("10/minute")
|
||||
def auth_activate():
|
||||
"""
|
||||
User enters the activation code to confirm their account.
|
||||
|
@ -136,47 +155,42 @@ def auth_activate():
|
|||
if not data:
|
||||
return jsonify(error="request body cannot be empty"), 400
|
||||
|
||||
email = data.get("email").strip().lower()
|
||||
email = sanitize_email(data.get("email"))
|
||||
canonical_email = canonicalize_email(data.get("email"))
|
||||
code = data.get("code")
|
||||
|
||||
user = User.get_by(email=email)
|
||||
user = User.get_by(email=email) or User.get_by(email=canonical_email)
|
||||
|
||||
# do not use a different message to avoid exposing existing email
|
||||
if not user or user.activated:
|
||||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
return jsonify(error="Wrong email or code"), 400
|
||||
|
||||
account_activation = AccountActivation.get_by(user_id=user.id)
|
||||
if not account_activation:
|
||||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
return jsonify(error="Wrong email or code"), 400
|
||||
|
||||
if account_activation.code != code:
|
||||
# decrement nb tries
|
||||
account_activation.tries -= 1
|
||||
db.session.commit()
|
||||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
Session.commit()
|
||||
|
||||
if account_activation.tries == 0:
|
||||
AccountActivation.delete(account_activation.id)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
return jsonify(error="Too many wrong tries"), 410
|
||||
|
||||
return jsonify(error="Wrong email or code"), 400
|
||||
|
||||
LOG.debug("activate user %s", user)
|
||||
LOG.d("activate user %s", user)
|
||||
user.activated = True
|
||||
AccountActivation.delete(account_activation.id)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
return jsonify(msg="Account is activated, user can login now"), 200
|
||||
|
||||
|
||||
@api_bp.route("/auth/reactivate", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit("10/minute")
|
||||
def auth_reactivate():
|
||||
"""
|
||||
User asks for another activation code
|
||||
|
@ -190,8 +204,10 @@ def auth_reactivate():
|
|||
if not data:
|
||||
return jsonify(error="request body cannot be empty"), 400
|
||||
|
||||
email = data.get("email").strip().lower()
|
||||
user = User.get_by(email=email)
|
||||
email = sanitize_email(data.get("email"))
|
||||
canonical_email = canonicalize_email(data.get("email"))
|
||||
|
||||
user = User.get_by(email=email) or User.get_by(email=canonical_email)
|
||||
|
||||
# do not use a different message to avoid exposing existing email
|
||||
if not user or user.activated:
|
||||
|
@ -200,17 +216,17 @@ def auth_reactivate():
|
|||
account_activation = AccountActivation.get_by(user_id=user.id)
|
||||
if account_activation:
|
||||
AccountActivation.delete(account_activation.id)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
# create activation code
|
||||
code = "".join([str(random.randint(0, 9)) for _ in range(6)])
|
||||
code = "".join([str(secrets.choice(string.digits)) for _ in range(6)])
|
||||
AccountActivation.create(user_id=user.id, code=code)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
send_email(
|
||||
email,
|
||||
f"Just one more step to join SimpleLogin",
|
||||
render("transactional/code-activation.txt", code=code),
|
||||
"Just one more step to join SimpleLogin",
|
||||
render("transactional/code-activation.txt.jinja2", code=code),
|
||||
render("transactional/code-activation.html", code=code),
|
||||
)
|
||||
|
||||
|
@ -218,7 +234,7 @@ def auth_reactivate():
|
|||
|
||||
|
||||
@api_bp.route("/auth/facebook", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit("10/minute")
|
||||
def auth_facebook():
|
||||
"""
|
||||
Authenticate user with Facebook
|
||||
|
@ -244,35 +260,35 @@ def auth_facebook():
|
|||
|
||||
graph = facebook.GraphAPI(access_token=facebook_token)
|
||||
user_info = graph.get_object("me", fields="email,name")
|
||||
email = user_info.get("email").strip().lower()
|
||||
email = sanitize_email(user_info.get("email"))
|
||||
|
||||
user = User.get_by(email=email)
|
||||
|
||||
if not user:
|
||||
if DISABLE_REGISTRATION:
|
||||
return jsonify(error="registration is closed"), 400
|
||||
if not email_domain_can_be_used_as_mailbox(
|
||||
if not email_can_be_used_as_mailbox(email) or personal_email_already_used(
|
||||
email
|
||||
) or personal_email_already_used(email):
|
||||
):
|
||||
return jsonify(error=f"cannot use {email} as personal inbox"), 400
|
||||
|
||||
LOG.d("create facebook user with %s", user_info)
|
||||
user = User.create(email=email.lower(), name=user_info["name"], activated=True)
|
||||
db.session.commit()
|
||||
user = User.create(email=email, name=user_info["name"], activated=True)
|
||||
Session.commit()
|
||||
email_utils.send_welcome_email(user)
|
||||
|
||||
if not SocialAuth.get_by(user_id=user.id, social="facebook"):
|
||||
SocialAuth.create(user_id=user.id, social="facebook")
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
return jsonify(**auth_payload(user, device)), 200
|
||||
|
||||
|
||||
@api_bp.route("/auth/google", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit("10/minute")
|
||||
def auth_google():
|
||||
"""
|
||||
Authenticate user with Facebook
|
||||
Authenticate user with Google
|
||||
Input:
|
||||
google_token: Google access token
|
||||
device: to create an ApiKey associated with this device
|
||||
|
@ -298,32 +314,32 @@ def auth_google():
|
|||
build = googleapiclient.discovery.build("oauth2", "v2", credentials=cred)
|
||||
|
||||
user_info = build.userinfo().get().execute()
|
||||
email = user_info.get("email").strip().lower()
|
||||
email = sanitize_email(user_info.get("email"))
|
||||
|
||||
user = User.get_by(email=email)
|
||||
|
||||
if not user:
|
||||
if DISABLE_REGISTRATION:
|
||||
return jsonify(error="registration is closed"), 400
|
||||
if not email_domain_can_be_used_as_mailbox(
|
||||
if not email_can_be_used_as_mailbox(email) or personal_email_already_used(
|
||||
email
|
||||
) or personal_email_already_used(email):
|
||||
):
|
||||
return jsonify(error=f"cannot use {email} as personal inbox"), 400
|
||||
|
||||
LOG.d("create Google user with %s", user_info)
|
||||
user = User.create(email=email.lower(), name="", activated=True)
|
||||
db.session.commit()
|
||||
user = User.create(email=email, name="", activated=True)
|
||||
Session.commit()
|
||||
email_utils.send_welcome_email(user)
|
||||
|
||||
if not SocialAuth.get_by(user_id=user.id, social="google"):
|
||||
SocialAuth.create(user_id=user.id, social="google")
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
return jsonify(**auth_payload(user, device)), 200
|
||||
|
||||
|
||||
def auth_payload(user, device) -> dict:
|
||||
ret = {"name": user.name, "email": user.email, "mfa_enabled": user.enable_otp}
|
||||
ret = {"name": user.name or "", "email": user.email, "mfa_enabled": user.enable_otp}
|
||||
|
||||
# do not give api_key, user can only obtain api_key after OTP verification
|
||||
if user.enable_otp:
|
||||
|
@ -335,15 +351,18 @@ def auth_payload(user, device) -> dict:
|
|||
if not api_key:
|
||||
LOG.d("create new api key for %s and %s", user, device)
|
||||
api_key = ApiKey.create(user.id, device)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
ret["mfa_key"] = None
|
||||
ret["api_key"] = api_key.code
|
||||
|
||||
# so user is automatically logged in on the web
|
||||
login_user(user)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@api_bp.route("/auth/forgot_password", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit("2/minute")
|
||||
def forgot_password():
|
||||
"""
|
||||
User forgot password
|
||||
|
@ -358,9 +377,10 @@ def forgot_password():
|
|||
if not data or not data.get("email"):
|
||||
return jsonify(error="request body must contain email"), 400
|
||||
|
||||
email = data.get("email").strip().lower()
|
||||
email = sanitize_email(data.get("email"))
|
||||
canonical_email = canonicalize_email(data.get("email"))
|
||||
|
||||
user = User.get_by(email=email)
|
||||
user = User.get_by(email=email) or User.get_by(email=canonical_email)
|
||||
|
||||
if user:
|
||||
send_reset_password_email(user)
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
import pyotp
|
||||
from flask import jsonify, request
|
||||
from flask_cors import cross_origin
|
||||
from flask_login import login_user
|
||||
from itsdangerous import Signer
|
||||
|
||||
from app.api.base import api_bp
|
||||
from app.config import FLASK_SECRET
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.email_utils import send_invalid_totp_login_email
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import User, ApiKey
|
||||
|
||||
|
||||
@api_bp.route("/auth/mfa", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit("10/minute")
|
||||
def auth_mfa():
|
||||
"""
|
||||
Validate the OTP Token
|
||||
|
@ -53,17 +55,21 @@ def auth_mfa():
|
|||
)
|
||||
|
||||
totp = pyotp.TOTP(user.otp_secret)
|
||||
if not totp.verify(mfa_token):
|
||||
if not totp.verify(mfa_token, valid_window=2):
|
||||
send_invalid_totp_login_email(user, "TOTP")
|
||||
return jsonify(error="Wrong TOTP Token"), 400
|
||||
|
||||
ret = {"name": user.name, "email": user.email}
|
||||
ret = {"name": user.name or "", "email": user.email}
|
||||
|
||||
api_key = ApiKey.get_by(user_id=user.id, name=device)
|
||||
if not api_key:
|
||||
LOG.d("create new api key for %s and %s", user, device)
|
||||
api_key = ApiKey.create(user.id, device)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
ret["api_key"] = api_key.code
|
||||
|
||||
# so user is logged in automatically on the web
|
||||
login_user(user)
|
||||
|
||||
return jsonify(**ret), 200
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
from flask import g, request
|
||||
from flask import jsonify
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.db import Session
|
||||
from app.models import CustomDomain, DomainDeletedAlias, Mailbox, DomainMailbox
|
||||
|
||||
|
||||
def custom_domain_to_dict(custom_domain: CustomDomain):
|
||||
return {
|
||||
"id": custom_domain.id,
|
||||
"domain_name": custom_domain.domain,
|
||||
"is_verified": custom_domain.verified,
|
||||
"nb_alias": custom_domain.nb_alias(),
|
||||
"creation_date": custom_domain.created_at.format(),
|
||||
"creation_timestamp": custom_domain.created_at.timestamp,
|
||||
"catch_all": custom_domain.catch_all,
|
||||
"name": custom_domain.name,
|
||||
"random_prefix_generation": custom_domain.random_prefix_generation,
|
||||
"mailboxes": [
|
||||
{"id": mb.id, "email": mb.email} for mb in custom_domain.mailboxes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@api_bp.route("/custom_domains", methods=["GET"])
|
||||
@require_api_auth
|
||||
def get_custom_domains():
|
||||
user = g.user
|
||||
custom_domains = CustomDomain.filter_by(
|
||||
user_id=user.id, is_sl_subdomain=False
|
||||
).all()
|
||||
|
||||
return jsonify(custom_domains=[custom_domain_to_dict(cd) for cd in custom_domains])
|
||||
|
||||
|
||||
@api_bp.route("/custom_domains/<int:custom_domain_id>/trash", methods=["GET"])
|
||||
@require_api_auth
|
||||
def get_custom_domain_trash(custom_domain_id: int):
|
||||
user = g.user
|
||||
custom_domain = CustomDomain.get(custom_domain_id)
|
||||
if not custom_domain or custom_domain.user_id != user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
domain_deleted_aliases = DomainDeletedAlias.filter_by(
|
||||
domain_id=custom_domain.id
|
||||
).all()
|
||||
|
||||
return jsonify(
|
||||
aliases=[
|
||||
{
|
||||
"alias": dda.email,
|
||||
"deletion_timestamp": dda.created_at.timestamp,
|
||||
}
|
||||
for dda in domain_deleted_aliases
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/custom_domains/<int:custom_domain_id>", methods=["PATCH"])
|
||||
@require_api_auth
|
||||
def update_custom_domain(custom_domain_id):
|
||||
"""
|
||||
Update alias note
|
||||
Input:
|
||||
custom_domain_id: in url
|
||||
In body:
|
||||
catch_all (optional): boolean
|
||||
random_prefix_generation (optional): boolean
|
||||
name (optional): in body
|
||||
mailbox_ids (optional): array of mailbox_id
|
||||
Output:
|
||||
200
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify(error="request body cannot be empty"), 400
|
||||
|
||||
user = g.user
|
||||
custom_domain: CustomDomain = CustomDomain.get(custom_domain_id)
|
||||
|
||||
if not custom_domain or custom_domain.user_id != user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
changed = False
|
||||
if "catch_all" in data:
|
||||
catch_all = data.get("catch_all")
|
||||
custom_domain.catch_all = catch_all
|
||||
changed = True
|
||||
|
||||
if "random_prefix_generation" in data:
|
||||
random_prefix_generation = data.get("random_prefix_generation")
|
||||
custom_domain.random_prefix_generation = random_prefix_generation
|
||||
changed = True
|
||||
|
||||
if "name" in data:
|
||||
name = data.get("name")
|
||||
custom_domain.name = name
|
||||
changed = True
|
||||
|
||||
if "mailbox_ids" in data:
|
||||
mailbox_ids = [int(m_id) for m_id in data.get("mailbox_ids")]
|
||||
if mailbox_ids:
|
||||
# check if mailbox is not tempered with
|
||||
mailboxes = []
|
||||
for mailbox_id in mailbox_ids:
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
if not mailbox or mailbox.user_id != user.id or not mailbox.verified:
|
||||
return jsonify(error="Forbidden"), 400
|
||||
mailboxes.append(mailbox)
|
||||
|
||||
# first remove all existing domain-mailboxes links
|
||||
DomainMailbox.filter_by(domain_id=custom_domain.id).delete()
|
||||
Session.flush()
|
||||
|
||||
for mailbox in mailboxes:
|
||||
DomainMailbox.create(domain_id=custom_domain.id, mailbox_id=mailbox.id)
|
||||
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
Session.commit()
|
||||
|
||||
# refresh
|
||||
custom_domain = CustomDomain.get(custom_domain_id)
|
||||
return jsonify(custom_domain=custom_domain_to_dict(custom_domain)), 200
|
|
@ -0,0 +1,49 @@
|
|||
from flask import g
|
||||
from flask import jsonify
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.models import Alias, Client, CustomDomain
|
||||
from app.alias_utils import alias_export_csv
|
||||
|
||||
|
||||
@api_bp.route("/export/data", methods=["GET"])
|
||||
@require_api_auth
|
||||
def export_data():
|
||||
"""
|
||||
Get user data
|
||||
Output:
|
||||
Alias, custom domain and app info
|
||||
|
||||
"""
|
||||
user = g.user
|
||||
|
||||
data = {
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"aliases": [],
|
||||
"apps": [],
|
||||
"custom_domains": [],
|
||||
}
|
||||
|
||||
for alias in Alias.filter_by(user_id=user.id).all(): # type: Alias
|
||||
data["aliases"].append(dict(email=alias.email, enabled=alias.enabled))
|
||||
|
||||
for custom_domain in CustomDomain.filter_by(user_id=user.id).all():
|
||||
data["custom_domains"].append(custom_domain.domain)
|
||||
|
||||
for app in Client.filter_by(user_id=user.id): # type: Client
|
||||
data["apps"].append(dict(name=app.name, home_url=app.home_url))
|
||||
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@api_bp.route("/export/aliases", methods=["GET"])
|
||||
@require_api_auth
|
||||
def export_aliases():
|
||||
"""
|
||||
Get user aliases as importable CSV file
|
||||
Output:
|
||||
Importable CSV file
|
||||
|
||||
"""
|
||||
return alias_export_csv(g.user)
|
|
@ -1,23 +1,37 @@
|
|||
from smtplib import SMTPRecipientsRefused
|
||||
|
||||
import arrow
|
||||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import request
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.config import JOB_DELETE_MAILBOX
|
||||
from app.dashboard.views.mailbox import send_verification_email
|
||||
from app.dashboard.views.mailbox_detail import verify_mailbox_change
|
||||
from app.db import Session
|
||||
from app.email_utils import (
|
||||
mailbox_already_used,
|
||||
email_domain_can_be_used_as_mailbox,
|
||||
email_can_be_used_as_mailbox,
|
||||
)
|
||||
from app.extensions import db
|
||||
from app.models import Mailbox
|
||||
from app.email_validation import is_valid_email
|
||||
from app.log import LOG
|
||||
from app.models import Mailbox, Job
|
||||
from app.utils import sanitize_email
|
||||
|
||||
|
||||
def mailbox_to_dict(mailbox: Mailbox):
|
||||
return {
|
||||
"id": mailbox.id,
|
||||
"email": mailbox.email,
|
||||
"verified": mailbox.verified,
|
||||
"default": mailbox.user.default_mailbox_id == mailbox.id,
|
||||
"creation_timestamp": mailbox.created_at.timestamp,
|
||||
"nb_alias": mailbox.nb_alias(),
|
||||
}
|
||||
|
||||
|
||||
@api_bp.route("/mailboxes", methods=["POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def create_mailbox():
|
||||
"""
|
||||
|
@ -25,18 +39,19 @@ def create_mailbox():
|
|||
Input:
|
||||
email: in body
|
||||
Output:
|
||||
the new mailbox
|
||||
- id
|
||||
- email
|
||||
- verified
|
||||
|
||||
the new mailbox dict
|
||||
"""
|
||||
user = g.user
|
||||
mailbox_email = request.get_json().get("email").lower().strip()
|
||||
mailbox_email = sanitize_email(request.get_json().get("email"))
|
||||
|
||||
if mailbox_already_used(mailbox_email, user):
|
||||
if not user.is_premium():
|
||||
return jsonify(error="Only premium plan can add additional mailbox"), 400
|
||||
|
||||
if not is_valid_email(mailbox_email):
|
||||
return jsonify(error=f"{mailbox_email} invalid"), 400
|
||||
elif mailbox_already_used(mailbox_email, user):
|
||||
return jsonify(error=f"{mailbox_email} already used"), 400
|
||||
elif not email_domain_can_be_used_as_mailbox(mailbox_email):
|
||||
elif not email_can_be_used_as_mailbox(mailbox_email):
|
||||
return (
|
||||
jsonify(
|
||||
error=f"{mailbox_email} cannot be used. Please note a mailbox cannot "
|
||||
|
@ -46,29 +61,26 @@ def create_mailbox():
|
|||
)
|
||||
else:
|
||||
new_mailbox = Mailbox.create(email=mailbox_email, user_id=user.id)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
send_verification_email(user, new_mailbox)
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
id=new_mailbox.id,
|
||||
email=new_mailbox.email,
|
||||
verified=new_mailbox.verified,
|
||||
default=user.default_mailbox_id == new_mailbox.id,
|
||||
),
|
||||
jsonify(mailbox_to_dict(new_mailbox)),
|
||||
201,
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/mailboxes/<mailbox_id>", methods=["DELETE"])
|
||||
@cross_origin()
|
||||
@api_bp.route("/mailboxes/<int:mailbox_id>", methods=["DELETE"])
|
||||
@require_api_auth
|
||||
def delete_mailbox(mailbox_id):
|
||||
"""
|
||||
Delete mailbox
|
||||
Input:
|
||||
mailbox_id: in url
|
||||
(optional) transfer_aliases_to: in body. Id of the new mailbox for the aliases.
|
||||
If omitted or the value is set to -1,
|
||||
the aliases of the mailbox will be deleted too.
|
||||
Output:
|
||||
200 if deleted successfully
|
||||
|
||||
|
@ -82,14 +94,44 @@ def delete_mailbox(mailbox_id):
|
|||
if mailbox.id == user.default_mailbox_id:
|
||||
return jsonify(error="You cannot delete the default mailbox"), 400
|
||||
|
||||
Mailbox.delete(mailbox_id)
|
||||
db.session.commit()
|
||||
data = request.get_json() or {}
|
||||
transfer_mailbox_id = data.get("transfer_aliases_to")
|
||||
if transfer_mailbox_id and int(transfer_mailbox_id) >= 0:
|
||||
transfer_mailbox = Mailbox.get(transfer_mailbox_id)
|
||||
|
||||
if not transfer_mailbox or transfer_mailbox.user_id != user.id:
|
||||
return (
|
||||
jsonify(error="You must transfer the aliases to a mailbox you own."),
|
||||
403,
|
||||
)
|
||||
|
||||
if transfer_mailbox_id == mailbox_id:
|
||||
return (
|
||||
jsonify(
|
||||
error="You can not transfer the aliases to the mailbox you want to delete."
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
if not transfer_mailbox.verified:
|
||||
return jsonify(error="Your new mailbox is not verified"), 400
|
||||
|
||||
# Schedule delete account job
|
||||
LOG.w("schedule delete mailbox job for %s", mailbox)
|
||||
Job.create(
|
||||
name=JOB_DELETE_MAILBOX,
|
||||
payload={
|
||||
"mailbox_id": mailbox.id,
|
||||
"transfer_mailbox_id": transfer_mailbox_id,
|
||||
},
|
||||
run_at=arrow.now(),
|
||||
commit=True,
|
||||
)
|
||||
|
||||
return jsonify(deleted=True), 200
|
||||
|
||||
|
||||
@api_bp.route("/mailboxes/<mailbox_id>", methods=["PUT"])
|
||||
@cross_origin()
|
||||
@api_bp.route("/mailboxes/<int:mailbox_id>", methods=["PUT"])
|
||||
@require_api_auth
|
||||
def update_mailbox(mailbox_id):
|
||||
"""
|
||||
|
@ -114,15 +156,22 @@ def update_mailbox(mailbox_id):
|
|||
if "default" in data:
|
||||
is_default = data.get("default")
|
||||
if is_default:
|
||||
if not mailbox.verified:
|
||||
return (
|
||||
jsonify(
|
||||
error="Unverified mailbox cannot be used as default mailbox"
|
||||
),
|
||||
400,
|
||||
)
|
||||
user.default_mailbox_id = mailbox.id
|
||||
changed = True
|
||||
|
||||
if "email" in data:
|
||||
new_email = data.get("email").lower().strip()
|
||||
new_email = sanitize_email(data.get("email"))
|
||||
|
||||
if mailbox_already_used(new_email, user):
|
||||
return jsonify(error=f"{new_email} already used"), 400
|
||||
elif not email_domain_can_be_used_as_mailbox(new_email):
|
||||
elif not email_can_be_used_as_mailbox(new_email):
|
||||
return (
|
||||
jsonify(
|
||||
error=f"{new_email} cannot be used. Please note a mailbox cannot "
|
||||
|
@ -146,39 +195,42 @@ def update_mailbox(mailbox_id):
|
|||
changed = True
|
||||
|
||||
if changed:
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
return jsonify(updated=True), 200
|
||||
|
||||
|
||||
@api_bp.route("/mailboxes", methods=["GET"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_mailboxes():
|
||||
"""
|
||||
Get mailboxes
|
||||
Get verified mailboxes
|
||||
Output:
|
||||
- mailboxes: list of alias:
|
||||
- id
|
||||
- email
|
||||
- default: boolean - whether the mailbox is the default one
|
||||
- creation_timestamp
|
||||
- nb_alias
|
||||
- mailboxes: list of mailbox dict
|
||||
"""
|
||||
user = g.user
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
mailboxes=[
|
||||
{
|
||||
"id": mb.id,
|
||||
"email": mb.email,
|
||||
"default": user.default_mailbox_id == mb.id,
|
||||
"creation_timestamp": mb.created_at.timestamp,
|
||||
"nb_alias": mb.nb_alias(),
|
||||
}
|
||||
for mb in user.mailboxes()
|
||||
]
|
||||
),
|
||||
jsonify(mailboxes=[mailbox_to_dict(mb) for mb in user.mailboxes()]),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/v2/mailboxes", methods=["GET"])
|
||||
@require_api_auth
|
||||
def get_mailboxes_v2():
|
||||
"""
|
||||
Get all mailboxes - including unverified mailboxes
|
||||
Output:
|
||||
- mailboxes: list of mailbox dict
|
||||
"""
|
||||
user = g.user
|
||||
mailboxes = []
|
||||
|
||||
for mailbox in Mailbox.filter_by(user_id=user.id):
|
||||
mailboxes.append(mailbox)
|
||||
|
||||
return (
|
||||
jsonify(mailboxes=[mailbox_to_dict(mb) for mb in mailboxes]),
|
||||
200,
|
||||
)
|
||||
|
|
|
@ -1,24 +1,22 @@
|
|||
from flask import g
|
||||
from flask import jsonify, request
|
||||
from flask_cors import cross_origin
|
||||
from itsdangerous import SignatureExpired
|
||||
|
||||
from app import parallel_limiter
|
||||
from app.alias_suffix import check_suffix_signature, verify_prefix_suffix
|
||||
from app.alias_utils import check_alias_prefix
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.api.serializer import (
|
||||
serialize_alias_info,
|
||||
get_alias_info,
|
||||
serialize_alias_info_v2,
|
||||
get_alias_info_v2,
|
||||
)
|
||||
from app.config import MAX_NB_EMAIL_FREE_PLAN
|
||||
from app.dashboard.views.custom_alias import verify_prefix_suffix, signer
|
||||
from app.extensions import db
|
||||
from app.config import MAX_NB_EMAIL_FREE_PLAN, ALIAS_LIMIT
|
||||
from app.db import Session
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
Alias,
|
||||
AliasUsedOn,
|
||||
User,
|
||||
CustomDomain,
|
||||
DeletedAlias,
|
||||
DomainDeletedAlias,
|
||||
Mailbox,
|
||||
|
@ -27,80 +25,10 @@ from app.models import (
|
|||
from app.utils import convert_to_id
|
||||
|
||||
|
||||
@api_bp.route("/alias/custom/new", methods=["POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def new_custom_alias():
|
||||
"""
|
||||
Create a new custom alias
|
||||
Input:
|
||||
alias_prefix, for ex "www_groupon_com"
|
||||
alias_suffix, either .random_letters@simplelogin.co or @my-domain.com
|
||||
optional "hostname" in args
|
||||
optional "note"
|
||||
Output:
|
||||
201 if success
|
||||
409 if the alias already exists
|
||||
|
||||
"""
|
||||
LOG.warning("/alias/custom/new is obsolete")
|
||||
user: User = g.user
|
||||
if not user.can_create_new_alias():
|
||||
LOG.d("user %s cannot create any custom alias", user)
|
||||
return (
|
||||
jsonify(
|
||||
error="You have reached the limitation of a free account with the maximum of "
|
||||
f"{MAX_NB_EMAIL_FREE_PLAN} aliases, please upgrade your plan to create more aliases"
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
hostname = request.args.get("hostname")
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify(error="request body cannot be empty"), 400
|
||||
|
||||
alias_prefix = data.get("alias_prefix", "").strip().lower()
|
||||
alias_suffix = data.get("alias_suffix", "").strip().lower()
|
||||
note = data.get("note")
|
||||
alias_prefix = convert_to_id(alias_prefix)
|
||||
|
||||
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
|
||||
return jsonify(error="wrong alias prefix or suffix"), 400
|
||||
|
||||
full_alias = alias_prefix + alias_suffix
|
||||
if (
|
||||
Alias.get_by(email=full_alias)
|
||||
or DeletedAlias.get_by(email=full_alias)
|
||||
or DomainDeletedAlias.get_by(email=full_alias)
|
||||
):
|
||||
LOG.d("full alias already used %s", full_alias)
|
||||
return jsonify(error=f"alias {full_alias} already exists"), 409
|
||||
|
||||
alias = Alias.create(
|
||||
user_id=user.id, email=full_alias, mailbox_id=user.default_mailbox_id, note=note
|
||||
)
|
||||
|
||||
if alias_suffix.startswith("@"):
|
||||
alias_domain = alias_suffix[1:]
|
||||
domain = CustomDomain.get_by(domain=alias_domain)
|
||||
if domain:
|
||||
LOG.d("set alias %s to domain %s", full_alias, domain)
|
||||
alias.custom_domain_id = domain.id
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if hostname:
|
||||
AliasUsedOn.create(alias_id=alias.id, hostname=hostname, user_id=alias.user_id)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(alias=full_alias, **serialize_alias_info(get_alias_info(alias))), 201
|
||||
|
||||
|
||||
@api_bp.route("/v2/alias/custom/new", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit(ALIAS_LIMIT)
|
||||
@require_api_auth
|
||||
@parallel_limiter.lock(name="alias_creation")
|
||||
def new_custom_alias_v2():
|
||||
"""
|
||||
Create a new custom alias
|
||||
|
@ -133,19 +61,18 @@ def new_custom_alias_v2():
|
|||
if not data:
|
||||
return jsonify(error="request body cannot be empty"), 400
|
||||
|
||||
alias_prefix = data.get("alias_prefix", "").strip().lower()
|
||||
alias_prefix = data.get("alias_prefix", "").strip().lower().replace(" ", "")
|
||||
signed_suffix = data.get("signed_suffix", "").strip()
|
||||
note = data.get("note")
|
||||
alias_prefix = convert_to_id(alias_prefix)
|
||||
|
||||
# hypothesis: user will click on the button in the 600 secs
|
||||
try:
|
||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||
except SignatureExpired:
|
||||
LOG.error("Alias creation time expired for %s", user)
|
||||
return jsonify(error="alias creation is expired, please try again"), 400
|
||||
alias_suffix = check_suffix_signature(signed_suffix)
|
||||
if not alias_suffix:
|
||||
LOG.w("Alias creation time expired for %s", user)
|
||||
return jsonify(error="Alias creation time is expired, please retry"), 412
|
||||
except Exception:
|
||||
LOG.error("Alias suffix is tampered, user %s", user)
|
||||
LOG.w("Alias suffix is tampered, user %s", user)
|
||||
return jsonify(error="Tampered suffix"), 400
|
||||
|
||||
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
|
||||
|
@ -160,32 +87,24 @@ def new_custom_alias_v2():
|
|||
LOG.d("full alias already used %s", full_alias)
|
||||
return jsonify(error=f"alias {full_alias} already exists"), 409
|
||||
|
||||
custom_domain_id = None
|
||||
if alias_suffix.startswith("@"):
|
||||
alias_domain = alias_suffix[1:]
|
||||
domain = CustomDomain.get_by(domain=alias_domain)
|
||||
|
||||
# check if the alias is currently in the domain trash
|
||||
if domain and DomainDeletedAlias.get_by(domain_id=domain.id, email=full_alias):
|
||||
LOG.d(f"Alias {full_alias} is currently in the {domain.domain} trash. ")
|
||||
return jsonify(error=f"alias {full_alias} in domain trash"), 409
|
||||
|
||||
if domain:
|
||||
custom_domain_id = domain.id
|
||||
if ".." in full_alias:
|
||||
return (
|
||||
jsonify(error="2 consecutive dot signs aren't allowed in an email address"),
|
||||
400,
|
||||
)
|
||||
|
||||
alias = Alias.create(
|
||||
user_id=user.id,
|
||||
email=full_alias,
|
||||
mailbox_id=user.default_mailbox_id,
|
||||
note=note,
|
||||
custom_domain_id=custom_domain_id,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
if hostname:
|
||||
AliasUsedOn.create(alias_id=alias.id, hostname=hostname, user_id=alias.user_id)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
return (
|
||||
jsonify(alias=full_alias, **serialize_alias_info_v2(get_alias_info_v2(alias))),
|
||||
|
@ -194,8 +113,9 @@ def new_custom_alias_v2():
|
|||
|
||||
|
||||
@api_bp.route("/v3/alias/custom/new", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit(ALIAS_LIMIT)
|
||||
@require_api_auth
|
||||
@parallel_limiter.lock(name="alias_creation")
|
||||
def new_custom_alias_v3():
|
||||
"""
|
||||
Create a new custom alias
|
||||
|
@ -230,14 +150,26 @@ def new_custom_alias_v3():
|
|||
if not data:
|
||||
return jsonify(error="request body cannot be empty"), 400
|
||||
|
||||
alias_prefix = data.get("alias_prefix", "").strip().lower()
|
||||
signed_suffix = data.get("signed_suffix", "").strip()
|
||||
if not isinstance(data, dict):
|
||||
return jsonify(error="request body does not follow the required format"), 400
|
||||
|
||||
alias_prefix = data.get("alias_prefix", "").strip().lower().replace(" ", "")
|
||||
signed_suffix = data.get("signed_suffix", "") or ""
|
||||
signed_suffix = signed_suffix.strip()
|
||||
|
||||
mailbox_ids = data.get("mailbox_ids")
|
||||
note = data.get("note")
|
||||
name = data.get("name")
|
||||
if name:
|
||||
name = name.replace("\n", "")
|
||||
alias_prefix = convert_to_id(alias_prefix)
|
||||
|
||||
if not check_alias_prefix(alias_prefix):
|
||||
return jsonify(error="alias prefix invalid format or too long"), 400
|
||||
|
||||
# check if mailbox is not tempered with
|
||||
if not isinstance(mailbox_ids, list):
|
||||
return jsonify(error="mailbox_ids must be an array of id"), 400
|
||||
mailboxes = []
|
||||
for mailbox_id in mailbox_ids:
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
|
@ -250,12 +182,12 @@ def new_custom_alias_v3():
|
|||
|
||||
# hypothesis: user will click on the button in the 600 secs
|
||||
try:
|
||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||
except SignatureExpired:
|
||||
LOG.error("Alias creation time expired for %s", user)
|
||||
return jsonify(error="alias creation is expired, please try again"), 400
|
||||
alias_suffix = check_suffix_signature(signed_suffix)
|
||||
if not alias_suffix:
|
||||
LOG.w("Alias creation time expired for %s", user)
|
||||
return jsonify(error="Alias creation time is expired, please retry"), 412
|
||||
except Exception:
|
||||
LOG.error("Alias suffix is tampered, user %s", user)
|
||||
LOG.w("Alias suffix is tampered, user %s", user)
|
||||
return jsonify(error="Tampered suffix"), 400
|
||||
|
||||
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
|
||||
|
@ -270,12 +202,11 @@ def new_custom_alias_v3():
|
|||
LOG.d("full alias already used %s", full_alias)
|
||||
return jsonify(error=f"alias {full_alias} already exists"), 409
|
||||
|
||||
custom_domain_id = None
|
||||
if alias_suffix.startswith("@"):
|
||||
alias_domain = alias_suffix[1:]
|
||||
domain = CustomDomain.get_by(domain=alias_domain)
|
||||
if domain:
|
||||
custom_domain_id = domain.id
|
||||
if ".." in full_alias:
|
||||
return (
|
||||
jsonify(error="2 consecutive dot signs aren't allowed in an email address"),
|
||||
400,
|
||||
)
|
||||
|
||||
alias = Alias.create(
|
||||
user_id=user.id,
|
||||
|
@ -283,20 +214,20 @@ def new_custom_alias_v3():
|
|||
note=note,
|
||||
name=name or None,
|
||||
mailbox_id=mailboxes[0].id,
|
||||
custom_domain_id=custom_domain_id,
|
||||
)
|
||||
db.session.flush()
|
||||
Session.flush()
|
||||
|
||||
for i in range(1, len(mailboxes)):
|
||||
AliasMailbox.create(
|
||||
alias_id=alias.id, mailbox_id=mailboxes[i].id,
|
||||
alias_id=alias.id,
|
||||
mailbox_id=mailboxes[i].id,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
if hostname:
|
||||
AliasUsedOn.create(alias_id=alias.id, hostname=hostname, user_id=alias.user_id)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
return (
|
||||
jsonify(alias=full_alias, **serialize_alias_info_v2(get_alias_info_v2(alias))),
|
||||
|
|
|
@ -1,21 +1,27 @@
|
|||
import tldextract
|
||||
from flask import g
|
||||
from flask import jsonify, request
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from app import parallel_limiter
|
||||
from app.alias_suffix import get_alias_suffixes
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.api.serializer import (
|
||||
get_alias_info_v2,
|
||||
serialize_alias_info_v2,
|
||||
)
|
||||
from app.config import MAX_NB_EMAIL_FREE_PLAN
|
||||
from app.extensions import db
|
||||
from app.config import MAX_NB_EMAIL_FREE_PLAN, ALIAS_LIMIT
|
||||
from app.db import Session
|
||||
from app.errors import AliasInTrashError
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import Alias, AliasUsedOn, AliasGeneratorEnum
|
||||
from app.utils import convert_to_id
|
||||
|
||||
|
||||
@api_bp.route("/alias/random/new", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit(ALIAS_LIMIT)
|
||||
@require_api_auth
|
||||
@parallel_limiter.lock(name="alias_creation")
|
||||
def new_random_alias():
|
||||
"""
|
||||
Create a new random alias
|
||||
|
@ -41,6 +47,52 @@ def new_random_alias():
|
|||
if data:
|
||||
note = data.get("note")
|
||||
|
||||
alias = None
|
||||
|
||||
# custom alias suggestion and suffix
|
||||
hostname = request.args.get("hostname")
|
||||
if hostname and user.include_website_in_one_click_alias:
|
||||
LOG.d("Use %s to create new alias", hostname)
|
||||
# keep only the domain name of hostname, ignore TLD and subdomain
|
||||
# for ex www.groupon.com -> groupon
|
||||
ext = tldextract.extract(hostname)
|
||||
prefix_suggestion = ext.domain
|
||||
prefix_suggestion = convert_to_id(prefix_suggestion)
|
||||
|
||||
suffixes = get_alias_suffixes(user)
|
||||
# use the first suffix
|
||||
suggested_alias = prefix_suggestion + suffixes[0].suffix
|
||||
|
||||
alias = Alias.get_by(email=suggested_alias)
|
||||
|
||||
# cannot use this alias as it belongs to another user
|
||||
if alias and not alias.user_id == user.id:
|
||||
LOG.d("%s belongs to another user", alias)
|
||||
alias = None
|
||||
elif alias and alias.user_id == user.id:
|
||||
# make sure alias was created for this website
|
||||
if AliasUsedOn.get_by(
|
||||
alias_id=alias.id, hostname=hostname, user_id=alias.user_id
|
||||
):
|
||||
LOG.d("Use existing alias %s", alias)
|
||||
else:
|
||||
LOG.d("%s wasn't created for this website %s", alias, hostname)
|
||||
alias = None
|
||||
elif not alias:
|
||||
LOG.d("create new alias %s", suggested_alias)
|
||||
try:
|
||||
alias = Alias.create(
|
||||
user_id=user.id,
|
||||
email=suggested_alias,
|
||||
note=note,
|
||||
mailbox_id=user.default_mailbox_id,
|
||||
commit=True,
|
||||
)
|
||||
except AliasInTrashError:
|
||||
LOG.i("Alias %s is in trash", suggested_alias)
|
||||
alias = None
|
||||
|
||||
if not alias:
|
||||
scheme = user.alias_generator
|
||||
mode = request.args.get("mode")
|
||||
if mode:
|
||||
|
@ -49,15 +101,15 @@ def new_random_alias():
|
|||
elif mode == "uuid":
|
||||
scheme = AliasGeneratorEnum.uuid.value
|
||||
else:
|
||||
return jsonify(error=f"{mode} must be either word or alias"), 400
|
||||
return jsonify(error=f"{mode} must be either word or uuid"), 400
|
||||
|
||||
alias = Alias.create_new_random(user=user, scheme=scheme, note=note)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
hostname = request.args.get("hostname")
|
||||
if hostname:
|
||||
AliasUsedOn.create(alias_id=alias.id, hostname=hostname, user_id=alias.user_id)
|
||||
db.session.commit()
|
||||
if hostname and not AliasUsedOn.get_by(alias_id=alias.id, hostname=hostname):
|
||||
AliasUsedOn.create(
|
||||
alias_id=alias.id, hostname=hostname, user_id=alias.user_id, commit=True
|
||||
)
|
||||
|
||||
return (
|
||||
jsonify(alias=alias.email, **serialize_alias_info_v2(get_alias_info_v2(alias))),
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import request
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.config import PAGE_LIMIT
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.models import Notification
|
||||
|
||||
|
||||
@api_bp.route("/notifications", methods=["GET"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_notifications():
|
||||
"""
|
||||
|
@ -24,6 +22,7 @@ def get_notifications():
|
|||
- notifications: list of notifications.
|
||||
- id
|
||||
- message
|
||||
- title
|
||||
- read
|
||||
- created_at
|
||||
"""
|
||||
|
@ -34,7 +33,7 @@ def get_notifications():
|
|||
return jsonify(error="page must be provided in request query"), 400
|
||||
|
||||
notifications = (
|
||||
Notification.query.filter_by(user_id=user.id)
|
||||
Notification.filter_by(user_id=user.id)
|
||||
.order_by(Notification.read, Notification.created_at.desc())
|
||||
.limit(PAGE_LIMIT + 1) # load a record more to know whether there's more
|
||||
.offset(page * PAGE_LIMIT)
|
||||
|
@ -50,6 +49,7 @@ def get_notifications():
|
|||
{
|
||||
"id": notification.id,
|
||||
"message": notification.message,
|
||||
"title": notification.title,
|
||||
"read": notification.read,
|
||||
"created_at": notification.created_at.humanize(),
|
||||
}
|
||||
|
@ -60,8 +60,7 @@ def get_notifications():
|
|||
)
|
||||
|
||||
|
||||
@api_bp.route("/notifications/<notification_id>/read", methods=["POST"])
|
||||
@cross_origin()
|
||||
@api_bp.route("/notifications/<int:notification_id>/read", methods=["POST"])
|
||||
@require_api_auth
|
||||
def mark_as_read(notification_id):
|
||||
"""
|
||||
|
@ -79,6 +78,6 @@ def mark_as_read(notification_id):
|
|||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
notification.read = True
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
return jsonify(done=True), 200
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
import arrow
|
||||
from flask import g
|
||||
from flask import jsonify
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.models import (
|
||||
PhoneReservation,
|
||||
PhoneMessage,
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/phone/reservations/<int:reservation_id>", methods=["GET", "POST"])
|
||||
@require_api_auth
|
||||
def phone_messages(reservation_id):
|
||||
"""
|
||||
Return messages during this reservation
|
||||
Output:
|
||||
- messages: list of alias:
|
||||
- id
|
||||
- from_number
|
||||
- body
|
||||
- created_at: e.g. 5 minutes ago
|
||||
|
||||
"""
|
||||
user = g.user
|
||||
reservation: PhoneReservation = PhoneReservation.get(reservation_id)
|
||||
if not reservation or reservation.user_id != user.id:
|
||||
return jsonify(error="Invalid reservation"), 400
|
||||
|
||||
phone_number = reservation.number
|
||||
messages = PhoneMessage.filter(
|
||||
PhoneMessage.number_id == phone_number.id,
|
||||
PhoneMessage.created_at > reservation.start,
|
||||
PhoneMessage.created_at < reservation.end,
|
||||
).all()
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
messages=[
|
||||
{
|
||||
"id": message.id,
|
||||
"from_number": message.from_number,
|
||||
"body": message.body,
|
||||
"created_at": message.created_at.humanize(),
|
||||
}
|
||||
for message in messages
|
||||
],
|
||||
ended=reservation.end < arrow.now(),
|
||||
),
|
||||
200,
|
||||
)
|
|
@ -0,0 +1,148 @@
|
|||
import arrow
|
||||
from flask import jsonify, g, request
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
User,
|
||||
AliasGeneratorEnum,
|
||||
SLDomain,
|
||||
CustomDomain,
|
||||
SenderFormatEnum,
|
||||
AliasSuffixEnum,
|
||||
)
|
||||
from app.proton.utils import perform_proton_account_unlink
|
||||
|
||||
|
||||
def setting_to_dict(user: User):
|
||||
ret = {
|
||||
"notification": user.notification,
|
||||
"alias_generator": "word"
|
||||
if user.alias_generator == AliasGeneratorEnum.word.value
|
||||
else "uuid",
|
||||
"random_alias_default_domain": user.default_random_alias_domain(),
|
||||
# return the default sender format (AT) in case user uses a non-supported sender format
|
||||
"sender_format": SenderFormatEnum.get_name(user.sender_format)
|
||||
or SenderFormatEnum.AT.name,
|
||||
"random_alias_suffix": AliasSuffixEnum.get_name(user.random_alias_suffix),
|
||||
}
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@api_bp.route("/setting")
|
||||
@require_api_auth
|
||||
def get_setting():
|
||||
"""
|
||||
Return user setting
|
||||
"""
|
||||
user = g.user
|
||||
|
||||
return jsonify(setting_to_dict(user))
|
||||
|
||||
|
||||
@api_bp.route("/setting", methods=["PATCH"])
|
||||
@require_api_auth
|
||||
def update_setting():
|
||||
"""
|
||||
Update user setting
|
||||
Input:
|
||||
- notification: bool
|
||||
- alias_generator: word|uuid
|
||||
- random_alias_default_domain: str
|
||||
"""
|
||||
user = g.user
|
||||
data = request.get_json() or {}
|
||||
|
||||
if "notification" in data:
|
||||
user.notification = data["notification"]
|
||||
|
||||
if "alias_generator" in data:
|
||||
alias_generator = data["alias_generator"]
|
||||
if alias_generator not in ["word", "uuid"]:
|
||||
return jsonify(error="Invalid alias_generator"), 400
|
||||
|
||||
if alias_generator == "word":
|
||||
user.alias_generator = AliasGeneratorEnum.word.value
|
||||
else:
|
||||
user.alias_generator = AliasGeneratorEnum.uuid.value
|
||||
|
||||
if "sender_format" in data:
|
||||
sender_format = data["sender_format"]
|
||||
if not SenderFormatEnum.has_name(sender_format):
|
||||
return jsonify(error="Invalid sender_format"), 400
|
||||
|
||||
user.sender_format = SenderFormatEnum.get_value(sender_format)
|
||||
user.sender_format_updated_at = arrow.now()
|
||||
|
||||
if "random_alias_suffix" in data:
|
||||
random_alias_suffix = data["random_alias_suffix"]
|
||||
if not AliasSuffixEnum.has_name(random_alias_suffix):
|
||||
return jsonify(error="Invalid random_alias_suffix"), 400
|
||||
|
||||
user.random_alias_suffix = AliasSuffixEnum.get_value(random_alias_suffix)
|
||||
|
||||
if "random_alias_default_domain" in data:
|
||||
default_domain = data["random_alias_default_domain"]
|
||||
sl_domain: SLDomain = SLDomain.get_by(domain=default_domain)
|
||||
if sl_domain:
|
||||
if sl_domain.premium_only and not user.is_premium():
|
||||
return jsonify(error="You cannot use this domain"), 400
|
||||
|
||||
user.default_alias_public_domain_id = sl_domain.id
|
||||
user.default_alias_custom_domain_id = None
|
||||
else:
|
||||
custom_domain = CustomDomain.get_by(domain=default_domain)
|
||||
if not custom_domain:
|
||||
return jsonify(error="invalid domain"), 400
|
||||
|
||||
# sanity check
|
||||
if custom_domain.user_id != user.id or not custom_domain.verified:
|
||||
LOG.w("%s cannot use domain %s", user, default_domain)
|
||||
return jsonify(error="invalid domain"), 400
|
||||
else:
|
||||
user.default_alias_custom_domain_id = custom_domain.id
|
||||
user.default_alias_public_domain_id = None
|
||||
|
||||
Session.commit()
|
||||
return jsonify(setting_to_dict(user))
|
||||
|
||||
|
||||
@api_bp.route("/setting/domains")
|
||||
@require_api_auth
|
||||
def get_available_domains_for_random_alias():
|
||||
"""
|
||||
Available domains for random alias
|
||||
"""
|
||||
user = g.user
|
||||
|
||||
ret = [
|
||||
(is_sl, domain) for is_sl, domain in user.available_domains_for_random_alias()
|
||||
]
|
||||
|
||||
return jsonify(ret)
|
||||
|
||||
|
||||
@api_bp.route("/v2/setting/domains")
|
||||
@require_api_auth
|
||||
def get_available_domains_for_random_alias_v2():
|
||||
"""
|
||||
Available domains for random alias
|
||||
"""
|
||||
user = g.user
|
||||
|
||||
ret = [
|
||||
{"domain": domain, "is_custom": not is_sl}
|
||||
for is_sl, domain in user.available_domains_for_random_alias()
|
||||
]
|
||||
|
||||
return jsonify(ret)
|
||||
|
||||
|
||||
@api_bp.route("/setting/unlink_proton_account", methods=["DELETE"])
|
||||
@require_api_auth
|
||||
def unlink_proton_account():
|
||||
user = g.user
|
||||
perform_proton_account_unlink(user)
|
||||
return jsonify({"ok": True})
|
|
@ -0,0 +1,27 @@
|
|||
from flask import jsonify, g, request
|
||||
from sqlalchemy_utils.types.arrow import arrow
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.db import Session
|
||||
|
||||
|
||||
@api_bp.route("/sudo", methods=["PATCH"])
|
||||
@require_api_auth
|
||||
def enter_sudo():
|
||||
"""
|
||||
Enter sudo mode
|
||||
|
||||
Input
|
||||
- password: user password to validate request to enter sudo mode
|
||||
"""
|
||||
user = g.user
|
||||
data = request.get_json() or {}
|
||||
if "password" not in data:
|
||||
return jsonify(error="Invalid password"), 403
|
||||
if not user.check_password(data["password"]):
|
||||
return jsonify(error="Invalid password"), 403
|
||||
|
||||
g.api_key.sudo_mode_at = arrow.now()
|
||||
Session.commit()
|
||||
|
||||
return jsonify(ok=True)
|
|
@ -0,0 +1,46 @@
|
|||
from flask import jsonify, g
|
||||
from sqlalchemy_utils.types.arrow import arrow
|
||||
|
||||
from app.api.base import api_bp, require_api_sudo, require_api_auth
|
||||
from app import config
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import Job, ApiToCookieToken
|
||||
|
||||
|
||||
@api_bp.route("/user", methods=["DELETE"])
|
||||
@require_api_sudo
|
||||
def delete_user():
|
||||
"""
|
||||
Delete the user. Requires sudo mode.
|
||||
|
||||
"""
|
||||
# Schedule delete account job
|
||||
LOG.w("schedule delete account job for %s", g.user)
|
||||
Job.create(
|
||||
name=config.JOB_DELETE_ACCOUNT,
|
||||
payload={"user_id": g.user.id},
|
||||
run_at=arrow.now(),
|
||||
commit=True,
|
||||
)
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@api_bp.route("/user/cookie_token", methods=["GET"])
|
||||
@require_api_auth
|
||||
@limiter.limit("5/minute")
|
||||
def get_api_session_token():
|
||||
"""
|
||||
Get a temporary token to exchange it for a cookie based session
|
||||
Output:
|
||||
200 and a temporary random token
|
||||
{
|
||||
token: "asdli3ldq39h9hd3",
|
||||
}
|
||||
"""
|
||||
token = ApiToCookieToken.create(
|
||||
user=g.user,
|
||||
api_key_id=g.api_key.id,
|
||||
commit=True,
|
||||
)
|
||||
return jsonify({"token": token.code})
|
|
@ -1,23 +1,161 @@
|
|||
from flask import jsonify, g
|
||||
from flask_cors import cross_origin
|
||||
import base64
|
||||
import dataclasses
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
from flask import jsonify, g, request, make_response
|
||||
|
||||
from app import s3, config
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.config import SESSION_COOKIE_NAME
|
||||
from app.dashboard.views.index import get_stats
|
||||
from app.db import Session
|
||||
from app.models import ApiKey, File, PartnerUser, User
|
||||
from app.proton.utils import get_proton_partner
|
||||
from app.session import logout_session
|
||||
from app.utils import random_string
|
||||
|
||||
|
||||
def get_connected_proton_address(user: User) -> Optional[str]:
|
||||
proton_partner = get_proton_partner()
|
||||
partner_user = PartnerUser.get_by(user_id=user.id, partner_id=proton_partner.id)
|
||||
if partner_user is None:
|
||||
return None
|
||||
return partner_user.partner_email
|
||||
|
||||
|
||||
def user_to_dict(user: User) -> dict:
|
||||
ret = {
|
||||
"name": user.name or "",
|
||||
"is_premium": user.is_premium(),
|
||||
"email": user.email,
|
||||
"in_trial": user.in_trial(),
|
||||
"max_alias_free_plan": user.max_alias_for_free_account(),
|
||||
"connected_proton_address": None,
|
||||
"can_create_reverse_alias": user.can_create_contacts(),
|
||||
}
|
||||
|
||||
if config.CONNECT_WITH_PROTON:
|
||||
ret["connected_proton_address"] = get_connected_proton_address(user)
|
||||
|
||||
if user.profile_picture_id:
|
||||
ret["profile_picture_url"] = user.profile_picture.get_url()
|
||||
else:
|
||||
ret["profile_picture_url"] = None
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@api_bp.route("/user_info")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def user_info():
|
||||
"""
|
||||
Return user info given the api-key
|
||||
|
||||
Output as json
|
||||
- name
|
||||
- is_premium
|
||||
- email
|
||||
- in_trial
|
||||
- max_alias_free
|
||||
- is_connected_with_proton
|
||||
- can_create_reverse_alias
|
||||
"""
|
||||
user = g.user
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"name": user.name,
|
||||
"is_premium": user.is_premium(),
|
||||
"email": user.email,
|
||||
"in_trial": user.in_trial(),
|
||||
}
|
||||
)
|
||||
return jsonify(user_to_dict(user))
|
||||
|
||||
|
||||
@api_bp.route("/user_info", methods=["PATCH"])
|
||||
@require_api_auth
|
||||
def update_user_info():
|
||||
"""
|
||||
Input
|
||||
- profile_picture (optional): base64 of the profile picture. Set to null to remove the profile picture
|
||||
- name (optional)
|
||||
"""
|
||||
user = g.user
|
||||
data = request.get_json() or {}
|
||||
|
||||
if "profile_picture" in data:
|
||||
if data["profile_picture"] is None:
|
||||
if user.profile_picture_id:
|
||||
file = user.profile_picture
|
||||
user.profile_picture_id = None
|
||||
Session.flush()
|
||||
if file:
|
||||
File.delete(file.id)
|
||||
s3.delete(file.path)
|
||||
Session.flush()
|
||||
else:
|
||||
raw_data = base64.decodebytes(data["profile_picture"].encode())
|
||||
file_path = random_string(30)
|
||||
file = File.create(user_id=user.id, path=file_path)
|
||||
Session.flush()
|
||||
s3.upload_from_bytesio(file_path, BytesIO(raw_data))
|
||||
user.profile_picture_id = file.id
|
||||
Session.flush()
|
||||
|
||||
if "name" in data:
|
||||
user.name = data["name"]
|
||||
|
||||
Session.commit()
|
||||
|
||||
return jsonify(user_to_dict(user))
|
||||
|
||||
|
||||
@api_bp.route("/api_key", methods=["POST"])
|
||||
@require_api_auth
|
||||
def create_api_key():
|
||||
"""Used to create a new api key
|
||||
Input:
|
||||
- device
|
||||
|
||||
Output:
|
||||
- api_key
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify(error="request body cannot be empty"), 400
|
||||
|
||||
device = data.get("device")
|
||||
|
||||
api_key = ApiKey.create(user_id=g.user.id, name=device)
|
||||
Session.commit()
|
||||
|
||||
return jsonify(api_key=api_key.code), 201
|
||||
|
||||
|
||||
@api_bp.route("/logout", methods=["GET"])
|
||||
@require_api_auth
|
||||
def logout():
|
||||
"""
|
||||
Log user out on the web, i.e. remove the cookie
|
||||
|
||||
Output:
|
||||
- 200
|
||||
"""
|
||||
logout_session()
|
||||
response = make_response(jsonify(msg="User is logged out"), 200)
|
||||
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@api_bp.route("/stats")
|
||||
@require_api_auth
|
||||
def user_stats():
|
||||
"""
|
||||
Return stats
|
||||
|
||||
Output as json
|
||||
- nb_alias
|
||||
- nb_forward
|
||||
- nb_reply
|
||||
- nb_block
|
||||
|
||||
"""
|
||||
user = g.user
|
||||
stats = get_stats(user)
|
||||
|
||||
return jsonify(dataclasses.asdict(stats))
|
||||
|
|
|
@ -9,9 +9,33 @@ from .views import (
|
|||
github,
|
||||
google,
|
||||
facebook,
|
||||
proton,
|
||||
change_email,
|
||||
mfa,
|
||||
fido,
|
||||
social,
|
||||
recovery,
|
||||
api_to_cookie,
|
||||
oidc,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"login",
|
||||
"logout",
|
||||
"register",
|
||||
"activate",
|
||||
"resend_activation",
|
||||
"reset_password",
|
||||
"forgot_password",
|
||||
"github",
|
||||
"google",
|
||||
"facebook",
|
||||
"proton",
|
||||
"change_email",
|
||||
"mfa",
|
||||
"fido",
|
||||
"social",
|
||||
"recovery",
|
||||
"api_to_cookie",
|
||||
"oidc",
|
||||
]
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Change Email
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
|
||||
{% if incorrect_code %}
|
||||
<div class="text-danger text-center h4">
|
||||
The link is incorrect. <br><br>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
Please go to <a href="{{ url_for('dashboard.setting') }}">settings</a>
|
||||
page to re-send confirmation email.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if expired_code %}
|
||||
<div class="text-danger text-center h4">
|
||||
The link is already expired. <br><br>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
Please go to <a href="{{ url_for('dashboard.setting') }}">settings</a>
|
||||
page to re-send confirmation email.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -1,56 +0,0 @@
|
|||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Login
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
|
||||
|
||||
{% if show_resend_activation %}
|
||||
<div class="text-center text-muted small mb-4">
|
||||
You haven't received the activation email?
|
||||
<a href="{{ url_for('auth.resend_activation') }}">Resend</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="card" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<div class="card-body p-6">
|
||||
<div class="card-title">Welcome back!</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email address</label>
|
||||
{{ form.email(class="form-control", type="email", autofocus="true") }}
|
||||
{{ render_field_errors(form.email) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Password
|
||||
</label>
|
||||
{{ form.password(class="form-control", type="password") }}
|
||||
{{ render_field_errors(form.password) }}
|
||||
<div class="text-muted">
|
||||
<a href="{{ url_for('auth.forgot_password') }}" class="small">
|
||||
I forgot my password
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary btn-block">Log in</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center text-muted mt-2">
|
||||
Don't have an account yet? <a href="{{ url_for('auth.register') }}">Sign up</a>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-muted mt-5">
|
||||
<a href="{{ url_for('auth.social') }}">Social Login</a> is now deprecated
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -1,48 +0,0 @@
|
|||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Register
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
<form class="card" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<div class="card-body p-6">
|
||||
<div class="card-title">Create new account</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email address</label>
|
||||
{{ form.email(class="form-control", type="email") }}
|
||||
{{ render_field_errors(form.email) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
{{ form.password(class="form-control", type="password") }}
|
||||
{{ render_field_errors(form.password) }}
|
||||
</div>
|
||||
|
||||
<!-- TODO: add terms
|
||||
<div class="form-group">
|
||||
<label class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input"/>
|
||||
<span class="custom-control-label">Agree the <a href="terms.html">terms and policy</a></span>
|
||||
</label>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<small class="text-center mt-3">
|
||||
By clicking Create Account, you agree to abide by
|
||||
<a href="https://simplelogin.io/terms">SimpleLogin's Terms and Conditions.</a>
|
||||
</small>
|
||||
|
||||
<div class="mt-2">
|
||||
<button type="submit" class="btn btn-primary btn-block">Create Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-center text-muted mb-6">
|
||||
Already have account? <a href="{{ url_for('auth.login') }}">Sign in</a>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -1,22 +0,0 @@
|
|||
{% extends "single.html" %}
|
||||
|
||||
{% block title %}
|
||||
Activation Email Sent
|
||||
{% endblock %}
|
||||
|
||||
{% block single_content %}
|
||||
<div class="card">
|
||||
<div class="card-body p-6 text-center">
|
||||
|
||||
<h1 class="h4">
|
||||
An email to validate your email is on its way.
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
Please check your inbox/spam folder.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -1,46 +0,0 @@
|
|||
{% extends "single.html" %}
|
||||
|
||||
|
||||
{% block title %}
|
||||
Social Login
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block single_content %}
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
<div class="card-title text-center">Social login
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('auth.github_login', next=next_url) }}"
|
||||
class="btn btn-block btn-social btn-github">
|
||||
<i class="fa fa-github"></i> Sign in with Github
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('auth.google_login', next=next_url) }}"
|
||||
class="btn btn-block btn-social btn-google">
|
||||
<i class="fa fa-google"></i> Sign in with Google
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('auth.facebook_login', next=next_url) }}"
|
||||
class="btn btn-block btn-social btn-facebook">
|
||||
<i class="fa fa-facebook"></i> Sign in with Facebook
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center p-3" style="font-size: 12px; font-weight: 300; margin: auto">
|
||||
<span class="badge badge-warning">Warning</span>
|
||||
Please note that social login is now <b>deprecated</b>. <br><br>
|
||||
|
||||
Though practical, these social providers do not respect your privacy and therefore we recommend using
|
||||
email/password.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-muted mt-2">
|
||||
<a href="{{ url_for('auth.register') }}">Sign up</a> / <a href="{{ url_for('auth.login') }}">Login</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
|
@ -3,9 +3,11 @@ from flask_login import login_user, current_user
|
|||
|
||||
from app import email_utils
|
||||
from app.auth.base import auth_bp
|
||||
from app.extensions import db, limiter
|
||||
from app.db import Session
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import ActivationCode
|
||||
from app.utils import sanitize_next_url
|
||||
|
||||
|
||||
@auth_bp.route("/activate", methods=["GET", "POST"])
|
||||
|
@ -46,19 +48,22 @@ def activate():
|
|||
user = activation_code.user
|
||||
user.activated = True
|
||||
login_user(user)
|
||||
email_utils.send_welcome_email(user)
|
||||
|
||||
# activation code is to be used only once
|
||||
ActivationCode.delete(activation_code.id)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
flash("Your account has been activated", "success")
|
||||
|
||||
email_utils.send_welcome_email(user)
|
||||
|
||||
# The activation link contains the original page, for ex authorize page
|
||||
if "next" in request.args:
|
||||
next_url = request.args.get("next")
|
||||
LOG.debug("redirect user to %s", next_url)
|
||||
next_url = sanitize_next_url(request.args.get("next"))
|
||||
LOG.d("redirect user to %s", next_url)
|
||||
return redirect(next_url)
|
||||
else:
|
||||
LOG.debug("redirect user to dashboard")
|
||||
LOG.d("redirect user to dashboard")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
# todo: redirect to account_activated page when more features are added into the browser extension
|
||||
# return redirect(url_for("onboarding.account_activated"))
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
import arrow
|
||||
from flask import redirect, url_for, request, flash
|
||||
from flask_login import login_user
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.models import ApiToCookieToken
|
||||
from app.utils import sanitize_next_url
|
||||
|
||||
|
||||
@auth_bp.route("/api_to_cookie", methods=["GET"])
|
||||
def api_to_cookie():
|
||||
code = request.args.get("token")
|
||||
if not code:
|
||||
flash("Missing token", "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
token = ApiToCookieToken.get_by(code=code)
|
||||
if not token or token.created_at < arrow.now().shift(minutes=-5):
|
||||
flash("Missing token", "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
user = token.user
|
||||
ApiToCookieToken.delete(token.id, commit=True)
|
||||
login_user(user)
|
||||
|
||||
next_url = sanitize_next_url(request.args.get("next"))
|
||||
if next_url:
|
||||
return redirect(next_url)
|
||||
else:
|
||||
return redirect(url_for("dashboard.index"))
|
|
@ -2,28 +2,37 @@ from flask import request, flash, render_template, redirect, url_for
|
|||
from flask_login import login_user
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.extensions import db
|
||||
from app.models import EmailChange
|
||||
from app.db import Session
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import EmailChange, ResetPasswordCode
|
||||
|
||||
|
||||
@auth_bp.route("/change_email", methods=["GET", "POST"])
|
||||
@limiter.limit("3/hour")
|
||||
def change_email():
|
||||
code = request.args.get("code")
|
||||
|
||||
email_change: EmailChange = EmailChange.get_by(code=code)
|
||||
|
||||
if not email_change:
|
||||
return render_template("auth/change_email.html", incorrect_code=True)
|
||||
return render_template("auth/change_email.html")
|
||||
|
||||
if email_change.is_expired():
|
||||
return render_template("auth/change_email.html", expired_code=True)
|
||||
# delete the expired email
|
||||
EmailChange.delete(email_change.id)
|
||||
Session.commit()
|
||||
return render_template("auth/change_email.html")
|
||||
|
||||
user = email_change.user
|
||||
old_email = user.email
|
||||
user.email = email_change.new_email
|
||||
|
||||
EmailChange.delete(email_change.id)
|
||||
db.session.commit()
|
||||
ResetPasswordCode.filter_by(user_id=user.id).delete()
|
||||
Session.commit()
|
||||
|
||||
LOG.i(f"User {user} has changed their email from {old_email} to {user.email}")
|
||||
flash("Your new email has been updated", "success")
|
||||
|
||||
login_user(user)
|
||||
|
|
|
@ -9,10 +9,11 @@ from app.config import (
|
|||
FACEBOOK_CLIENT_ID,
|
||||
FACEBOOK_CLIENT_SECRET,
|
||||
)
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import User, SocialAuth
|
||||
from .login_utils import after_login
|
||||
from ...utils import sanitize_email, sanitize_next_url
|
||||
|
||||
_authorization_base_url = "https://www.facebook.com/dialog/oauth"
|
||||
_token_url = "https://graph.facebook.com/oauth/access_token"
|
||||
|
@ -29,7 +30,7 @@ def facebook_login():
|
|||
# to avoid flask-login displaying the login error message
|
||||
session.pop("_flashes", None)
|
||||
|
||||
next_url = request.args.get("next")
|
||||
next_url = sanitize_next_url(request.args.get("next"))
|
||||
|
||||
# Facebook does not allow to append param to redirect_uri
|
||||
# we need to pass the next url by session
|
||||
|
@ -91,7 +92,7 @@ def facebook_callback():
|
|||
)
|
||||
return redirect(url_for("auth.register"))
|
||||
|
||||
email = email.strip().lower()
|
||||
email = sanitize_email(email)
|
||||
user = User.get_by(email=email)
|
||||
|
||||
picture_url = facebook_user_data.get("picture", {}).get("data", {}).get("url")
|
||||
|
@ -101,7 +102,7 @@ def facebook_callback():
|
|||
LOG.d("set user profile picture to %s", picture_url)
|
||||
file = create_file_from_url(user, picture_url)
|
||||
user.profile_picture_id = file.id
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
else:
|
||||
flash(
|
||||
|
@ -114,13 +115,13 @@ def facebook_callback():
|
|||
# The activation link contains the original page, for ex authorize page
|
||||
if "facebook_next_url" in session:
|
||||
next_url = session["facebook_next_url"]
|
||||
LOG.debug("redirect user to %s", next_url)
|
||||
LOG.d("redirect user to %s", next_url)
|
||||
|
||||
# reset the next_url to avoid user getting redirected at each login :)
|
||||
session.pop("facebook_next_url", None)
|
||||
|
||||
if not SocialAuth.get_by(user_id=user.id, social="facebook"):
|
||||
SocialAuth.create(user_id=user.id, social="facebook")
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
return after_login(user, next_url)
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import json
|
||||
import secrets
|
||||
from time import time
|
||||
|
||||
import webauthn
|
||||
from flask import (
|
||||
request,
|
||||
|
@ -18,9 +20,11 @@ from wtforms import HiddenField, validators, BooleanField
|
|||
from app.auth.base import auth_bp
|
||||
from app.config import MFA_USER_ID
|
||||
from app.config import RP_ID, URL
|
||||
from app.extensions import db, limiter
|
||||
from app.db import Session
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import User, Fido, MfaBrowser
|
||||
from app.utils import sanitize_next_url
|
||||
|
||||
|
||||
class FidoTokenForm(FlaskForm):
|
||||
|
@ -52,13 +56,13 @@ def fido():
|
|||
auto_activate = True
|
||||
fido_token_form = FidoTokenForm()
|
||||
|
||||
next_url = request.args.get("next")
|
||||
next_url = sanitize_next_url(request.args.get("next"))
|
||||
|
||||
if request.cookies.get("mfa"):
|
||||
browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
|
||||
if browser and not browser.is_expired() and browser.user_id == user.id:
|
||||
login_user(user)
|
||||
flash(f"Welcome back {user.name}!", "success")
|
||||
flash("Welcome back!", "success")
|
||||
# Redirect user to correct page
|
||||
return redirect(next_url or url_for("dashboard.index"))
|
||||
else:
|
||||
|
@ -69,7 +73,7 @@ def fido():
|
|||
if fido_token_form.validate_on_submit():
|
||||
try:
|
||||
sk_assertion = json.loads(fido_token_form.sk_assertion.data)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
flash("Key verification failed. Error: Invalid Payload", "warning")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
@ -94,25 +98,26 @@ def fido():
|
|||
)
|
||||
new_sign_count = webauthn_assertion_response.verify()
|
||||
except Exception as e:
|
||||
LOG.error(f"An error occurred in WebAuthn verification process: {e}")
|
||||
LOG.w(f"An error occurred in WebAuthn verification process: {e}")
|
||||
flash("Key verification failed.", "warning")
|
||||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
auto_activate = False
|
||||
else:
|
||||
user.fido_sign_count = new_sign_count
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
del session[MFA_USER_ID]
|
||||
|
||||
session["sudo_time"] = int(time())
|
||||
login_user(user)
|
||||
flash(f"Welcome back {user.name}!", "success")
|
||||
flash("Welcome back!", "success")
|
||||
|
||||
# Redirect user to correct page
|
||||
response = make_response(redirect(next_url or url_for("dashboard.index")))
|
||||
|
||||
if fido_token_form.remember.data:
|
||||
browser = MfaBrowser.create_new(user=user)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
response.set_cookie(
|
||||
"mfa",
|
||||
value=browser.token,
|
||||
|
@ -150,6 +155,13 @@ def fido():
|
|||
webauthn_users, challenge
|
||||
)
|
||||
webauthn_assertion_options = webauthn_assertion_options.assertion_dict
|
||||
try:
|
||||
# HACK: We need to upgrade to webauthn > 1 so it can support specifying the transports
|
||||
for credential in webauthn_assertion_options["allowCredentials"]:
|
||||
del credential["transports"]
|
||||
except KeyError:
|
||||
# Should never happen but...
|
||||
pass
|
||||
|
||||
return render_template(
|
||||
"auth/fido.html",
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
from flask import request, render_template, redirect, url_for, flash, g
|
||||
from flask import request, render_template, flash, g
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.dashboard.views.setting import send_reset_password_email
|
||||
from app.dashboard.views.account_setting import send_reset_password_email
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import User
|
||||
from app.utils import sanitize_email, canonicalize_email
|
||||
|
||||
|
||||
class ForgotPasswordForm(FlaskForm):
|
||||
|
@ -14,25 +16,26 @@ class ForgotPasswordForm(FlaskForm):
|
|||
|
||||
@auth_bp.route("/forgot_password", methods=["GET", "POST"])
|
||||
@limiter.limit(
|
||||
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
|
||||
"10/hour", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
|
||||
)
|
||||
def forgot_password():
|
||||
form = ForgotPasswordForm(request.form)
|
||||
|
||||
if form.validate_on_submit():
|
||||
email = form.email.data.strip().lower()
|
||||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
|
||||
flash(
|
||||
"If your email is correct, you are going to receive an email to reset your password",
|
||||
"success",
|
||||
)
|
||||
|
||||
user = User.get_by(email=email)
|
||||
email = sanitize_email(form.email.data)
|
||||
canonical_email = canonicalize_email(email)
|
||||
user = User.get_by(email=email) or User.get_by(email=canonical_email)
|
||||
|
||||
if user:
|
||||
LOG.d("Send forgot password email to %s", user)
|
||||
send_reset_password_email(user)
|
||||
return redirect(url_for("auth.forgot_password"))
|
||||
|
||||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
|
||||
return render_template("auth/forgot_password.html", form=form)
|
||||
|
|
|
@ -4,10 +4,10 @@ from requests_oauthlib import OAuth2Session
|
|||
from app.auth.base import auth_bp
|
||||
from app.auth.views.login_utils import after_login
|
||||
from app.config import GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, URL
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import User, SocialAuth
|
||||
from app.utils import encode_url
|
||||
from app.utils import encode_url, sanitize_email, sanitize_next_url
|
||||
|
||||
_authorization_base_url = "https://github.com/login/oauth/authorize"
|
||||
_token_url = "https://github.com/login/oauth/access_token"
|
||||
|
@ -19,7 +19,7 @@ _redirect_uri = URL + "/auth/github/callback"
|
|||
|
||||
@auth_bp.route("/github/login")
|
||||
def github_login():
|
||||
next_url = request.args.get("next")
|
||||
next_url = sanitize_next_url(request.args.get("next"))
|
||||
if next_url:
|
||||
redirect_uri = _redirect_uri + "?next=" + encode_url(next_url)
|
||||
else:
|
||||
|
@ -75,14 +75,14 @@ def github_callback():
|
|||
break
|
||||
|
||||
if not email:
|
||||
LOG.error(f"cannot get email for github user {github_user_data} {emails}")
|
||||
LOG.e(f"cannot get email for github user {github_user_data} {emails}")
|
||||
flash(
|
||||
"Cannot get a valid email from Github, please another way to login/sign up",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
email = email.strip().lower()
|
||||
email = sanitize_email(email)
|
||||
user = User.get_by(email=email)
|
||||
|
||||
if not user:
|
||||
|
@ -94,9 +94,9 @@ def github_callback():
|
|||
|
||||
if not SocialAuth.get_by(user_id=user.id, social="github"):
|
||||
SocialAuth.create(user_id=user.id, social="github")
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
# The activation link contains the original page, for ex authorize page
|
||||
next_url = request.args.get("next") if request.args else None
|
||||
next_url = sanitize_next_url(request.args.get("next")) if request.args else None
|
||||
|
||||
return after_login(user, next_url)
|
||||
|
|
|
@ -4,10 +4,10 @@ from requests_oauthlib import OAuth2Session
|
|||
from app import s3
|
||||
from app.auth.base import auth_bp
|
||||
from app.config import URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import User, File, SocialAuth
|
||||
from app.utils import random_string
|
||||
from app.utils import random_string, sanitize_email, sanitize_next_url
|
||||
from .login_utils import after_login
|
||||
|
||||
_authorization_base_url = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
|
@ -29,7 +29,7 @@ def google_login():
|
|||
# to avoid flask-login displaying the login error message
|
||||
session.pop("_flashes", None)
|
||||
|
||||
next_url = request.args.get("next")
|
||||
next_url = sanitize_next_url(request.args.get("next"))
|
||||
|
||||
# Google does not allow to append param to redirect_url
|
||||
# we need to pass the next url by session
|
||||
|
@ -79,7 +79,7 @@ def google_callback():
|
|||
"https://www.googleapis.com/oauth2/v1/userinfo"
|
||||
).json()
|
||||
|
||||
email = google_user_data["email"].strip().lower()
|
||||
email = sanitize_email(google_user_data["email"])
|
||||
user = User.get_by(email=email)
|
||||
|
||||
picture_url = google_user_data.get("picture")
|
||||
|
@ -89,7 +89,7 @@ def google_callback():
|
|||
LOG.d("set user profile picture to %s", picture_url)
|
||||
file = create_file_from_url(user, picture_url)
|
||||
user.profile_picture_id = file.id
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
else:
|
||||
flash(
|
||||
"Sorry you cannot sign up via Google, please use email/password sign-up instead",
|
||||
|
@ -101,14 +101,14 @@ def google_callback():
|
|||
# The activation link contains the original page, for ex authorize page
|
||||
if "google_next_url" in session:
|
||||
next_url = session["google_next_url"]
|
||||
LOG.debug("redirect user to %s", next_url)
|
||||
LOG.d("redirect user to %s", next_url)
|
||||
|
||||
# reset the next_url to avoid user getting redirected at each login :)
|
||||
session.pop("google_next_url", None)
|
||||
|
||||
if not SocialAuth.get_by(user_id=user.id, social="google"):
|
||||
SocialAuth.create(user_id=user.id, social="google")
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
return after_login(user, next_url)
|
||||
|
||||
|
@ -119,7 +119,7 @@ def create_file_from_url(user, url) -> File:
|
|||
|
||||
s3.upload_from_url(url, file_path)
|
||||
|
||||
db.session.flush()
|
||||
Session.flush()
|
||||
LOG.d("upload file %s to s3", file)
|
||||
|
||||
return file
|
||||
|
|
|
@ -5,9 +5,12 @@ from wtforms import StringField, validators
|
|||
|
||||
from app.auth.base import auth_bp
|
||||
from app.auth.views.login_utils import after_login
|
||||
from app.config import CONNECT_WITH_PROTON, CONNECT_WITH_OIDC_ICON, OIDC_CLIENT_ID
|
||||
from app.events.auth_event import LoginEvent
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import User
|
||||
from app.utils import sanitize_email, sanitize_next_url, canonicalize_email
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
|
@ -20,29 +23,52 @@ class LoginForm(FlaskForm):
|
|||
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
|
||||
)
|
||||
def login():
|
||||
next_url = sanitize_next_url(request.args.get("next"))
|
||||
|
||||
if current_user.is_authenticated:
|
||||
if next_url:
|
||||
LOG.d("user is already authenticated, redirect to %s", next_url)
|
||||
return redirect(next_url)
|
||||
else:
|
||||
LOG.d("user is already authenticated, redirect to dashboard")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
form = LoginForm(request.form)
|
||||
next_url = request.args.get("next")
|
||||
|
||||
show_resend_activation = False
|
||||
|
||||
if form.validate_on_submit():
|
||||
user = User.filter_by(email=form.email.data.strip().lower()).first()
|
||||
email = sanitize_email(form.email.data)
|
||||
canonical_email = canonicalize_email(email)
|
||||
user = User.get_by(email=email) or User.get_by(email=canonical_email)
|
||||
|
||||
if not user or not user.check_password(form.password.data):
|
||||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
form.password.data = None
|
||||
flash("Email or password incorrect", "error")
|
||||
LoginEvent(LoginEvent.ActionType.failed).send()
|
||||
elif user.disabled:
|
||||
flash(
|
||||
"Your account is disabled. Please contact SimpleLogin team to re-enable your account.",
|
||||
"error",
|
||||
)
|
||||
LoginEvent(LoginEvent.ActionType.disabled_login).send()
|
||||
elif user.delete_on is not None:
|
||||
flash(
|
||||
f"Your account is scheduled to be deleted on {user.delete_on}",
|
||||
"error",
|
||||
)
|
||||
LoginEvent(LoginEvent.ActionType.scheduled_to_be_deleted).send()
|
||||
elif not user.activated:
|
||||
show_resend_activation = True
|
||||
flash(
|
||||
"Please check your inbox for the activation email. You can also have this email re-sent",
|
||||
"error",
|
||||
)
|
||||
LoginEvent(LoginEvent.ActionType.not_activated).send()
|
||||
else:
|
||||
LoginEvent(LoginEvent.ActionType.success).send()
|
||||
return after_login(user, next_url)
|
||||
|
||||
return render_template(
|
||||
|
@ -50,4 +76,7 @@ def login():
|
|||
form=form,
|
||||
next_url=next_url,
|
||||
show_resend_activation=show_resend_activation,
|
||||
connect_with_proton=CONNECT_WITH_PROTON,
|
||||
connect_with_oidc=OIDC_CLIENT_ID is not None,
|
||||
connect_with_oidc_icon=CONNECT_WITH_OIDC_ICON,
|
||||
)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from time import time
|
||||
from typing import Optional
|
||||
|
||||
from flask import session, redirect, url_for, request
|
||||
|
@ -8,12 +9,14 @@ from app.log import LOG
|
|||
from app.models import Referral
|
||||
|
||||
|
||||
def after_login(user, next_url):
|
||||
def after_login(user, next_url, login_from_proton: bool = False):
|
||||
"""
|
||||
Redirect to the correct page after login.
|
||||
If the user is logged in with Proton, do not look at fido nor otp
|
||||
If user enables MFA: redirect user to MFA page
|
||||
Otherwise redirect to dashboard page if no next_url
|
||||
"""
|
||||
if not login_from_proton:
|
||||
if user.fido_enabled():
|
||||
# Use the same session for FIDO so that we can easily
|
||||
# switch between these two 2FA option
|
||||
|
@ -28,16 +31,17 @@ def after_login(user, next_url):
|
|||
return redirect(url_for("auth.mfa", next=next_url))
|
||||
else:
|
||||
return redirect(url_for("auth.mfa"))
|
||||
else:
|
||||
LOG.debug("log user %s in", user)
|
||||
|
||||
LOG.d("log user %s in", user)
|
||||
login_user(user)
|
||||
session["sudo_time"] = int(time())
|
||||
|
||||
# User comes to login page from another page
|
||||
if next_url:
|
||||
LOG.debug("redirect user to %s", next_url)
|
||||
LOG.d("redirect user to %s", next_url)
|
||||
return redirect(next_url)
|
||||
else:
|
||||
LOG.debug("redirect user to dashboard")
|
||||
LOG.d("redirect user to dashboard")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
|
||||
|
@ -53,4 +57,12 @@ def get_referral() -> Optional[Referral]:
|
|||
ref_code = request.cookies.get(_REFERRAL_COOKIE)
|
||||
referral = Referral.get_by(code=ref_code)
|
||||
|
||||
if not referral:
|
||||
if "slref" in session:
|
||||
ref_code = session["slref"]
|
||||
referral = Referral.get_by(code=ref_code)
|
||||
|
||||
if referral:
|
||||
LOG.d("referral found %s", referral)
|
||||
|
||||
return referral
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
from flask import redirect, url_for, flash, make_response
|
||||
from flask_login import logout_user
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.config import SESSION_COOKIE_NAME
|
||||
from app.session import logout_session
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
def logout():
|
||||
logout_user()
|
||||
logout_session()
|
||||
flash("You are logged out", "success")
|
||||
response = make_response(redirect(url_for("auth.login")))
|
||||
response.delete_cookie("slapp")
|
||||
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||
response.delete_cookie("mfa")
|
||||
response.delete_cookie("dark-mode")
|
||||
|
||||
|
|
|
@ -15,8 +15,11 @@ from wtforms import BooleanField, StringField, validators
|
|||
|
||||
from app.auth.base import auth_bp
|
||||
from app.config import MFA_USER_ID, URL
|
||||
from app.extensions import db, limiter
|
||||
from app.db import Session
|
||||
from app.email_utils import send_invalid_totp_login_email
|
||||
from app.extensions import limiter
|
||||
from app.models import User, MfaBrowser
|
||||
from app.utils import sanitize_next_url
|
||||
|
||||
|
||||
class OtpTokenForm(FlaskForm):
|
||||
|
@ -46,13 +49,13 @@ def mfa():
|
|||
return redirect(url_for("auth.login"))
|
||||
|
||||
otp_token_form = OtpTokenForm()
|
||||
next_url = request.args.get("next")
|
||||
next_url = sanitize_next_url(request.args.get("next"))
|
||||
|
||||
if request.cookies.get("mfa"):
|
||||
browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
|
||||
if browser and not browser.is_expired() and browser.user_id == user.id:
|
||||
login_user(user)
|
||||
flash(f"Welcome back {user.name}!", "success")
|
||||
flash("Welcome back!", "success")
|
||||
# Redirect user to correct page
|
||||
return redirect(next_url or url_for("dashboard.index"))
|
||||
else:
|
||||
|
@ -64,20 +67,20 @@ def mfa():
|
|||
|
||||
token = otp_token_form.token.data.replace(" ", "")
|
||||
|
||||
if totp.verify(token) and user.last_otp != token:
|
||||
if totp.verify(token, valid_window=2) and user.last_otp != token:
|
||||
del session[MFA_USER_ID]
|
||||
user.last_otp = token
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
login_user(user)
|
||||
flash(f"Welcome back {user.name}!", "success")
|
||||
flash("Welcome back!", "success")
|
||||
|
||||
# Redirect user to correct page
|
||||
response = make_response(redirect(next_url or url_for("dashboard.index")))
|
||||
|
||||
if otp_token_form.remember.data:
|
||||
browser = MfaBrowser.create_new(user=user)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
response.set_cookie(
|
||||
"mfa",
|
||||
value=browser.token,
|
||||
|
@ -94,6 +97,7 @@ def mfa():
|
|||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
otp_token_form.token.data = None
|
||||
send_invalid_totp_login_email(user, "TOTP")
|
||||
|
||||
return render_template(
|
||||
"auth/mfa.html",
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
from flask import request, session, redirect, flash, url_for
|
||||
from requests_oauthlib import OAuth2Session
|
||||
|
||||
import requests
|
||||
|
||||
from app import config
|
||||
from app.auth.base import auth_bp
|
||||
from app.auth.views.login_utils import after_login
|
||||
from app.config import (
|
||||
URL,
|
||||
OIDC_SCOPES,
|
||||
OIDC_NAME_FIELD,
|
||||
)
|
||||
from app.db import Session
|
||||
from app.email_utils import send_welcome_email
|
||||
from app.log import LOG
|
||||
from app.models import User, SocialAuth
|
||||
from app.utils import sanitize_email, sanitize_next_url
|
||||
|
||||
|
||||
# need to set explicitly redirect_uri instead of leaving the lib to pre-fill redirect_uri
|
||||
# when served behind nginx, the redirect_uri is localhost... and not the real url
|
||||
redirect_uri = URL + "/auth/oidc/callback"
|
||||
|
||||
SESSION_STATE_KEY = "oauth_state"
|
||||
SESSION_NEXT_KEY = "oauth_redirect_next"
|
||||
|
||||
|
||||
@auth_bp.route("/oidc/login")
|
||||
def oidc_login():
|
||||
if config.OIDC_CLIENT_ID is None or config.OIDC_CLIENT_SECRET is None:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
next_url = sanitize_next_url(request.args.get("next"))
|
||||
|
||||
auth_url = requests.get(config.OIDC_WELL_KNOWN_URL).json()["authorization_endpoint"]
|
||||
|
||||
oidc = OAuth2Session(
|
||||
config.OIDC_CLIENT_ID, scope=[OIDC_SCOPES], redirect_uri=redirect_uri
|
||||
)
|
||||
authorization_url, state = oidc.authorization_url(auth_url)
|
||||
|
||||
# State is used to prevent CSRF, keep this for later.
|
||||
session[SESSION_STATE_KEY] = state
|
||||
session[SESSION_NEXT_KEY] = next_url
|
||||
return redirect(authorization_url)
|
||||
|
||||
|
||||
@auth_bp.route("/oidc/callback")
|
||||
def oidc_callback():
|
||||
if SESSION_STATE_KEY not in session:
|
||||
flash("Invalid state, please retry", "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
if config.OIDC_CLIENT_ID is None or config.OIDC_CLIENT_SECRET is None:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
# user clicks on cancel
|
||||
if "error" in request.args:
|
||||
flash("Please use another sign in method then", "warning")
|
||||
return redirect("/")
|
||||
|
||||
oidc_configuration = requests.get(config.OIDC_WELL_KNOWN_URL).json()
|
||||
user_info_url = oidc_configuration["userinfo_endpoint"]
|
||||
token_url = oidc_configuration["token_endpoint"]
|
||||
|
||||
oidc = OAuth2Session(
|
||||
config.OIDC_CLIENT_ID,
|
||||
state=session[SESSION_STATE_KEY],
|
||||
scope=[OIDC_SCOPES],
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
oidc.fetch_token(
|
||||
token_url,
|
||||
client_secret=config.OIDC_CLIENT_SECRET,
|
||||
authorization_response=request.url,
|
||||
)
|
||||
|
||||
oidc_user_data = oidc.get(user_info_url)
|
||||
if oidc_user_data.status_code != 200:
|
||||
LOG.e(
|
||||
f"cannot get oidc user data {oidc_user_data.status_code} {oidc_user_data.text}"
|
||||
)
|
||||
flash(
|
||||
"Cannot get user data from OIDC, please use another way to login/sign up",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("auth.login"))
|
||||
oidc_user_data = oidc_user_data.json()
|
||||
|
||||
email = oidc_user_data.get("email")
|
||||
|
||||
if not email:
|
||||
LOG.e(f"cannot get email for OIDC user {oidc_user_data} {email}")
|
||||
flash(
|
||||
"Cannot get a valid email from OIDC, please another way to login/sign up",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
email = sanitize_email(email)
|
||||
user = User.get_by(email=email)
|
||||
|
||||
if not user and config.DISABLE_REGISTRATION:
|
||||
flash(
|
||||
"Sorry you cannot sign up via the OIDC provider. Please sign-up first with your email.",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("auth.register"))
|
||||
elif not user:
|
||||
user = create_user(email, oidc_user_data)
|
||||
|
||||
if not SocialAuth.get_by(user_id=user.id, social="oidc"):
|
||||
SocialAuth.create(user_id=user.id, social="oidc")
|
||||
Session.commit()
|
||||
|
||||
# The activation link contains the original page, for ex authorize page
|
||||
next_url = session[SESSION_NEXT_KEY]
|
||||
session[SESSION_NEXT_KEY] = None
|
||||
|
||||
return after_login(user, next_url)
|
||||
|
||||
|
||||
def create_user(email, oidc_user_data):
|
||||
new_user = User.create(
|
||||
email=email,
|
||||
name=oidc_user_data.get(OIDC_NAME_FIELD),
|
||||
password="",
|
||||
activated=True,
|
||||
)
|
||||
LOG.i(f"Created new user for login request from OIDC. New user {new_user.id}")
|
||||
Session.commit()
|
||||
|
||||
send_welcome_email(new_user)
|
||||
|
||||
return new_user
|
|
@ -0,0 +1,190 @@
|
|||
import requests
|
||||
from flask import request, session, redirect, flash, url_for
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_login import current_user
|
||||
from requests_oauthlib import OAuth2Session
|
||||
from typing import Optional
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.auth.views.login_utils import after_login
|
||||
from app.config import (
|
||||
PROTON_BASE_URL,
|
||||
PROTON_CLIENT_ID,
|
||||
PROTON_CLIENT_SECRET,
|
||||
PROTON_EXTRA_HEADER_NAME,
|
||||
PROTON_EXTRA_HEADER_VALUE,
|
||||
PROTON_VALIDATE_CERTS,
|
||||
URL,
|
||||
)
|
||||
from app.log import LOG
|
||||
from app.models import ApiKey, User
|
||||
from app.proton.proton_client import HttpProtonClient, convert_access_token
|
||||
from app.proton.proton_callback_handler import (
|
||||
ProtonCallbackHandler,
|
||||
Action,
|
||||
)
|
||||
from app.proton.utils import get_proton_partner
|
||||
from app.utils import sanitize_next_url, sanitize_scheme
|
||||
|
||||
_authorization_base_url = PROTON_BASE_URL + "/oauth/authorize"
|
||||
_token_url = PROTON_BASE_URL + "/oauth/token"
|
||||
|
||||
# need to set explicitly redirect_uri instead of leaving the lib to pre-fill redirect_uri
|
||||
# when served behind nginx, the redirect_uri is localhost... and not the real url
|
||||
_redirect_uri = URL + "/auth/proton/callback"
|
||||
|
||||
SESSION_ACTION_KEY = "oauth_action"
|
||||
SESSION_STATE_KEY = "oauth_state"
|
||||
DEFAULT_SCHEME = "auth.simplelogin"
|
||||
|
||||
|
||||
def get_api_key_for_user(user: User) -> str:
|
||||
ak = ApiKey.create(
|
||||
user_id=user.id,
|
||||
name="Created via Login with Proton on mobile app",
|
||||
commit=True,
|
||||
)
|
||||
return ak.code
|
||||
|
||||
|
||||
def extract_action() -> Optional[Action]:
|
||||
action = request.args.get("action")
|
||||
if action is not None:
|
||||
if action == "link":
|
||||
return Action.Link
|
||||
elif action == "login":
|
||||
return Action.Login
|
||||
else:
|
||||
LOG.w(f"Unknown action received: {action}")
|
||||
return None
|
||||
return Action.Login
|
||||
|
||||
|
||||
def get_action_from_state() -> Action:
|
||||
oauth_action = session[SESSION_ACTION_KEY]
|
||||
if oauth_action == Action.Login.value:
|
||||
return Action.Login
|
||||
elif oauth_action == Action.Link.value:
|
||||
return Action.Link
|
||||
raise Exception(f"Unknown action in state: {oauth_action}")
|
||||
|
||||
|
||||
@auth_bp.route("/proton/login")
|
||||
def proton_login():
|
||||
if PROTON_CLIENT_ID is None or PROTON_CLIENT_SECRET is None:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
action = extract_action()
|
||||
if action is None:
|
||||
return redirect(url_for("auth.login"))
|
||||
if action == Action.Link and not current_user.is_authenticated:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
next_url = sanitize_next_url(request.args.get("next"))
|
||||
if next_url:
|
||||
session["oauth_next"] = next_url
|
||||
elif "oauth_next" in session:
|
||||
del session["oauth_next"]
|
||||
|
||||
scheme = sanitize_scheme(request.args.get("scheme"))
|
||||
if scheme:
|
||||
session["oauth_scheme"] = scheme
|
||||
elif "oauth_scheme" in session:
|
||||
del session["oauth_scheme"]
|
||||
|
||||
mode = request.args.get("mode", "session")
|
||||
if mode == "apikey":
|
||||
session["oauth_mode"] = "apikey"
|
||||
else:
|
||||
session["oauth_mode"] = "session"
|
||||
|
||||
proton = OAuth2Session(PROTON_CLIENT_ID, redirect_uri=_redirect_uri)
|
||||
authorization_url, state = proton.authorization_url(_authorization_base_url)
|
||||
|
||||
# State is used to prevent CSRF, keep this for later.
|
||||
session[SESSION_STATE_KEY] = state
|
||||
session[SESSION_ACTION_KEY] = action.value
|
||||
return redirect(authorization_url)
|
||||
|
||||
|
||||
@auth_bp.route("/proton/callback")
|
||||
def proton_callback():
|
||||
if SESSION_STATE_KEY not in session or SESSION_STATE_KEY not in session:
|
||||
flash("Invalid state, please retry", "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
if PROTON_CLIENT_ID is None or PROTON_CLIENT_SECRET is None:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
# user clicks on cancel
|
||||
if "error" in request.args:
|
||||
flash("Please use another sign in method then", "warning")
|
||||
return redirect("/")
|
||||
|
||||
proton = OAuth2Session(
|
||||
PROTON_CLIENT_ID,
|
||||
state=session[SESSION_STATE_KEY],
|
||||
redirect_uri=_redirect_uri,
|
||||
)
|
||||
|
||||
def check_status_code(response: requests.Response) -> requests.Response:
|
||||
if response.status_code != 200:
|
||||
raise Exception(
|
||||
f"Bad Proton API response [status={response.status_code}]: {response.json()}"
|
||||
)
|
||||
return response
|
||||
|
||||
proton.register_compliance_hook("access_token_response", check_status_code)
|
||||
|
||||
headers = None
|
||||
if PROTON_EXTRA_HEADER_NAME and PROTON_EXTRA_HEADER_VALUE:
|
||||
headers = {PROTON_EXTRA_HEADER_NAME: PROTON_EXTRA_HEADER_VALUE}
|
||||
|
||||
try:
|
||||
token = proton.fetch_token(
|
||||
_token_url,
|
||||
client_secret=PROTON_CLIENT_SECRET,
|
||||
authorization_response=request.url,
|
||||
verify=PROTON_VALIDATE_CERTS,
|
||||
method="GET",
|
||||
include_client_id=True,
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.warning(f"Error fetching Proton token: {e}")
|
||||
flash("There was an error in the login process", "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
credentials = convert_access_token(token["access_token"])
|
||||
action = get_action_from_state()
|
||||
|
||||
proton_client = HttpProtonClient(
|
||||
PROTON_BASE_URL, credentials, get_remote_address(), verify=PROTON_VALIDATE_CERTS
|
||||
)
|
||||
handler = ProtonCallbackHandler(proton_client)
|
||||
proton_partner = get_proton_partner()
|
||||
|
||||
next_url = session.get("oauth_next")
|
||||
if action == Action.Login:
|
||||
res = handler.handle_login(proton_partner)
|
||||
elif action == Action.Link:
|
||||
res = handler.handle_link(current_user, proton_partner)
|
||||
else:
|
||||
raise Exception(f"Unknown Action: {action.name}")
|
||||
|
||||
if res.flash_message is not None:
|
||||
flash(res.flash_message, res.flash_category)
|
||||
|
||||
oauth_scheme = session.get("oauth_scheme")
|
||||
if session.get("oauth_mode", "session") == "apikey":
|
||||
apikey = get_api_key_for_user(res.user)
|
||||
scheme = oauth_scheme or DEFAULT_SCHEME
|
||||
return redirect(f"{scheme}:///login?apikey={apikey}")
|
||||
|
||||
if res.redirect_to_login:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
if next_url and next_url[0] == "/" and oauth_scheme:
|
||||
next_url = f"{oauth_scheme}://{next_url}"
|
||||
|
||||
redirect_url = next_url or res.redirect
|
||||
return after_login(res.user, redirect_url, login_from_proton=True)
|
|
@ -6,9 +6,12 @@ from wtforms import StringField, validators
|
|||
|
||||
from app.auth.base import auth_bp
|
||||
from app.config import MFA_USER_ID
|
||||
from app.extensions import db, limiter
|
||||
from app.db import Session
|
||||
from app.email_utils import send_invalid_totp_login_email
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import User, RecoveryCode
|
||||
from app.utils import sanitize_next_url
|
||||
|
||||
|
||||
class RecoveryForm(FlaskForm):
|
||||
|
@ -35,11 +38,11 @@ def recovery_route():
|
|||
return redirect(url_for("auth.login"))
|
||||
|
||||
recovery_form = RecoveryForm()
|
||||
next_url = request.args.get("next")
|
||||
next_url = sanitize_next_url(request.args.get("next"))
|
||||
|
||||
if recovery_form.validate_on_submit():
|
||||
code = recovery_form.code.data
|
||||
recovery_code = RecoveryCode.get_by(user_id=user.id, code=code)
|
||||
recovery_code = RecoveryCode.find_by_user_code(user, code)
|
||||
|
||||
if recovery_code:
|
||||
if recovery_code.used:
|
||||
|
@ -50,22 +53,23 @@ def recovery_route():
|
|||
del session[MFA_USER_ID]
|
||||
|
||||
login_user(user)
|
||||
flash(f"Welcome back {user.name}!", "success")
|
||||
flash("Welcome back!", "success")
|
||||
|
||||
recovery_code.used = True
|
||||
recovery_code.used_at = arrow.now()
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
# User comes to login page from another page
|
||||
if next_url:
|
||||
LOG.debug("redirect user to %s", next_url)
|
||||
LOG.d("redirect user to %s", next_url)
|
||||
return redirect(next_url)
|
||||
else:
|
||||
LOG.debug("redirect user to dashboard")
|
||||
LOG.d("redirect user to dashboard")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
else:
|
||||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
flash("Incorrect code", "error")
|
||||
send_invalid_totp_login_email(user, "recovery")
|
||||
|
||||
return render_template("auth/recovery.html", recovery_form=recovery_form)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import requests
|
||||
from flask import request, flash, render_template, redirect, url_for
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
|
@ -5,22 +6,25 @@ from wtforms import StringField, validators
|
|||
|
||||
from app import email_utils, config
|
||||
from app.auth.base import auth_bp
|
||||
from app.config import CONNECT_WITH_PROTON, CONNECT_WITH_OIDC_ICON
|
||||
from app.auth.views.login_utils import get_referral
|
||||
from app.config import URL
|
||||
from app.config import URL, HCAPTCHA_SECRET, HCAPTCHA_SITEKEY
|
||||
from app.db import Session
|
||||
from app.email_utils import (
|
||||
email_domain_can_be_used_as_mailbox,
|
||||
email_can_be_used_as_mailbox,
|
||||
personal_email_already_used,
|
||||
)
|
||||
from app.extensions import db
|
||||
from app.events.auth_event import RegisterEvent
|
||||
from app.log import LOG
|
||||
from app.models import User, ActivationCode
|
||||
from app.utils import random_string, encode_url
|
||||
from app.models import User, ActivationCode, DailyMetric
|
||||
from app.utils import random_string, encode_url, sanitize_email, canonicalize_email
|
||||
|
||||
|
||||
class RegisterForm(FlaskForm):
|
||||
email = StringField("Email", validators=[validators.DataRequired()])
|
||||
password = StringField(
|
||||
"Password", validators=[validators.DataRequired(), validators.Length(min=8)]
|
||||
"Password",
|
||||
validators=[validators.DataRequired(), validators.Length(min=8, max=100)],
|
||||
)
|
||||
|
||||
|
||||
|
@ -39,37 +43,81 @@ def register():
|
|||
next_url = request.args.get("next")
|
||||
|
||||
if form.validate_on_submit():
|
||||
email = form.email.data.strip().lower()
|
||||
if not email_domain_can_be_used_as_mailbox(email):
|
||||
# only check if hcaptcha is enabled
|
||||
if HCAPTCHA_SECRET:
|
||||
# check with hCaptcha
|
||||
token = request.form.get("h-captcha-response")
|
||||
params = {"secret": HCAPTCHA_SECRET, "response": token}
|
||||
hcaptcha_res = requests.post(
|
||||
"https://hcaptcha.com/siteverify", data=params
|
||||
).json()
|
||||
# return something like
|
||||
# {'success': True,
|
||||
# 'challenge_ts': '2020-07-23T10:03:25',
|
||||
# 'hostname': '127.0.0.1'}
|
||||
if not hcaptcha_res["success"]:
|
||||
LOG.w(
|
||||
"User put wrong captcha %s %s",
|
||||
form.email.data,
|
||||
hcaptcha_res,
|
||||
)
|
||||
flash("Wrong Captcha", "error")
|
||||
RegisterEvent(RegisterEvent.ActionType.catpcha_failed).send()
|
||||
return render_template(
|
||||
"auth/register.html",
|
||||
form=form,
|
||||
next_url=next_url,
|
||||
HCAPTCHA_SITEKEY=HCAPTCHA_SITEKEY,
|
||||
)
|
||||
|
||||
email = canonicalize_email(form.email.data)
|
||||
if not email_can_be_used_as_mailbox(email):
|
||||
flash("You cannot use this email address as your personal inbox.", "error")
|
||||
RegisterEvent(RegisterEvent.ActionType.email_in_use).send()
|
||||
else:
|
||||
if personal_email_already_used(email):
|
||||
sanitized_email = sanitize_email(form.email.data)
|
||||
if personal_email_already_used(email) or personal_email_already_used(
|
||||
sanitized_email
|
||||
):
|
||||
flash(f"Email {email} already used", "error")
|
||||
RegisterEvent(RegisterEvent.ActionType.email_in_use).send()
|
||||
else:
|
||||
LOG.debug("create user %s", email)
|
||||
LOG.d("create user %s", email)
|
||||
user = User.create(
|
||||
email=email,
|
||||
name="",
|
||||
name=form.email.data,
|
||||
password=form.password.data,
|
||||
referral=get_referral(),
|
||||
)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
try:
|
||||
send_activation_email(user, next_url)
|
||||
except:
|
||||
RegisterEvent(RegisterEvent.ActionType.success).send()
|
||||
DailyMetric.get_or_create_today_metric().nb_new_web_non_proton_user += 1
|
||||
Session.commit()
|
||||
except Exception:
|
||||
flash("Invalid email, are you sure the email is correct?", "error")
|
||||
RegisterEvent(RegisterEvent.ActionType.invalid_email).send()
|
||||
return redirect(url_for("auth.register"))
|
||||
|
||||
return render_template("auth/register_waiting_activation.html")
|
||||
|
||||
return render_template("auth/register.html", form=form, next_url=next_url)
|
||||
return render_template(
|
||||
"auth/register.html",
|
||||
form=form,
|
||||
next_url=next_url,
|
||||
HCAPTCHA_SITEKEY=HCAPTCHA_SITEKEY,
|
||||
connect_with_proton=CONNECT_WITH_PROTON,
|
||||
connect_with_oidc=config.OIDC_CLIENT_ID is not None,
|
||||
connect_with_oidc_icon=CONNECT_WITH_OIDC_ICON,
|
||||
)
|
||||
|
||||
|
||||
def send_activation_email(user, next_url):
|
||||
# the activation code is valid for 1h
|
||||
activation = ActivationCode.create(user_id=user.id, code=random_string(30))
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
|
||||
# Send user activation email
|
||||
activation_link = f"{URL}/auth/activate?code={activation.code}"
|
||||
|
@ -77,4 +125,4 @@ def send_activation_email(user, next_url):
|
|||
LOG.d("redirect user to %s after activation", next_url)
|
||||
activation_link = activation_link + "&next=" + encode_url(next_url)
|
||||
|
||||
email_utils.send_activation_email(user.email, user.name, activation_link)
|
||||
email_utils.send_activation_email(user.email, activation_link)
|
||||
|
|
|
@ -4,8 +4,10 @@ from wtforms import StringField, validators
|
|||
|
||||
from app.auth.base import auth_bp
|
||||
from app.auth.views.register import send_activation_email
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import User
|
||||
from app.utils import sanitize_email, canonicalize_email
|
||||
|
||||
|
||||
class ResendActivationForm(FlaskForm):
|
||||
|
@ -13,11 +15,14 @@ class ResendActivationForm(FlaskForm):
|
|||
|
||||
|
||||
@auth_bp.route("/resend_activation", methods=["GET", "POST"])
|
||||
@limiter.limit("10/hour")
|
||||
def resend_activation():
|
||||
form = ResendActivationForm(request.form)
|
||||
|
||||
if form.validate_on_submit():
|
||||
user = User.filter_by(email=form.email.data.strip().lower()).first()
|
||||
email = sanitize_email(form.email.data)
|
||||
canonical_email = canonicalize_email(email)
|
||||
user = User.get_by(email=email) or User.get_by(email=canonical_email)
|
||||
|
||||
if not user:
|
||||
flash("There is no such email", "warning")
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
from flask import request, flash, render_template, redirect, url_for, g
|
||||
from flask_login import login_user
|
||||
import uuid
|
||||
|
||||
from flask import request, flash, render_template, url_for, g
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.extensions import db, limiter
|
||||
from app.auth.views.login_utils import after_login
|
||||
from app.db import Session
|
||||
from app.extensions import limiter
|
||||
from app.models import ResetPasswordCode
|
||||
|
||||
|
||||
class ResetPasswordForm(FlaskForm):
|
||||
password = StringField(
|
||||
"Password", validators=[validators.DataRequired(), validators.Length(min=8)]
|
||||
"Password",
|
||||
validators=[validators.DataRequired(), validators.Length(min=8, max=100)],
|
||||
)
|
||||
|
||||
|
||||
|
@ -50,17 +54,22 @@ def reset_password():
|
|||
return render_template("auth/reset_password.html", form=form, error=error)
|
||||
|
||||
user.set_password(new_password)
|
||||
|
||||
flash("Your new password has been set", "success")
|
||||
|
||||
# this can be served to activate user too
|
||||
user.activated = True
|
||||
|
||||
# remove the reset password code
|
||||
ResetPasswordCode.delete(reset_password_code.id)
|
||||
# remove all reset password codes
|
||||
ResetPasswordCode.filter_by(user_id=user.id).delete()
|
||||
|
||||
db.session.commit()
|
||||
login_user(user)
|
||||
# change the alternative_id to log user out on other browsers
|
||||
user.alternative_id = str(uuid.uuid4())
|
||||
|
||||
return redirect(url_for("dashboard.index"))
|
||||
Session.commit()
|
||||
|
||||
# do not use login_user(user) here
|
||||
# to make sure user needs to go through MFA if enabled
|
||||
return after_login(user, url_for("dashboard.index"))
|
||||
|
||||
return render_template("auth/reset_password.html", form=form)
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
SHA1 = "dev"
|
||||
BUILD_TIME = "1652365083"
|
445
app/config.py
445
app/config.py
|
@ -1,12 +1,13 @@
|
|||
import os
|
||||
import random
|
||||
import socket
|
||||
import string
|
||||
import subprocess
|
||||
from ast import literal_eval
|
||||
from typing import Callable, List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
SHA1 = subprocess.getoutput("git rev-parse HEAD")
|
||||
ROOT_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
|
@ -19,6 +20,21 @@ def get_abs_path(file_path: str):
|
|||
return os.path.join(ROOT_DIR, file_path)
|
||||
|
||||
|
||||
def sl_getenv(env_var: str, default_factory: Callable = None):
|
||||
"""
|
||||
Get env value, convert into Python object
|
||||
Args:
|
||||
env_var (str): env var, example: SL_DB
|
||||
default_factory: returns value if this env var is not set.
|
||||
|
||||
"""
|
||||
value = os.getenv(env_var)
|
||||
if value is None:
|
||||
return default_factory()
|
||||
|
||||
return literal_eval(value)
|
||||
|
||||
|
||||
config_file = os.environ.get("CONFIG")
|
||||
if config_file:
|
||||
config_file = get_abs_path(config_file)
|
||||
|
@ -27,14 +43,11 @@ if config_file:
|
|||
else:
|
||||
load_dotenv()
|
||||
|
||||
RESET_DB = "RESET_DB" in os.environ
|
||||
COLOR_LOG = "COLOR_LOG" in os.environ
|
||||
|
||||
# Allow user to have 1 year of premium: set the expiration_date to 1 year more
|
||||
PROMO_CODE = "SIMPLEISBETTER"
|
||||
|
||||
# Debug mode
|
||||
DEBUG = os.environ["DEBUG"] if "DEBUG" in os.environ else False
|
||||
# Server url
|
||||
URL = os.environ["URL"]
|
||||
print(">>> URL:", URL)
|
||||
|
@ -53,65 +66,83 @@ EMAIL_DOMAIN = os.environ["EMAIL_DOMAIN"].lower()
|
|||
SUPPORT_EMAIL = os.environ["SUPPORT_EMAIL"]
|
||||
SUPPORT_NAME = os.environ.get("SUPPORT_NAME", "Son from SimpleLogin")
|
||||
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL")
|
||||
# to receive monitoring daily report
|
||||
MONITORING_EMAIL = os.environ.get("MONITORING_EMAIL")
|
||||
|
||||
# VERP: mail_from set to BOUNCE_PREFIX + email_log.id + BOUNCE_SUFFIX
|
||||
BOUNCE_PREFIX = os.environ.get("BOUNCE_PREFIX") or "bounce+"
|
||||
BOUNCE_SUFFIX = os.environ.get("BOUNCE_SUFFIX") or f"+@{EMAIL_DOMAIN}"
|
||||
|
||||
# Used for VERP during reply phase. It's similar to BOUNCE_PREFIX.
|
||||
# It's needed when sending emails from custom domain to respect DMARC.
|
||||
# BOUNCE_PREFIX_FOR_REPLY_PHASE should never be used in any existing alias
|
||||
# and can't be used for creating a new alias on custom domain
|
||||
# Note BOUNCE_PREFIX_FOR_REPLY_PHASE doesn't have the trailing plus sign (+) as BOUNCE_PREFIX
|
||||
BOUNCE_PREFIX_FOR_REPLY_PHASE = (
|
||||
os.environ.get("BOUNCE_PREFIX_FOR_REPLY_PHASE") or "bounce_reply"
|
||||
)
|
||||
|
||||
# VERP for transactional email: mail_from set to BOUNCE_PREFIX + email_log.id + BOUNCE_SUFFIX
|
||||
TRANSACTIONAL_BOUNCE_PREFIX = (
|
||||
os.environ.get("TRANSACTIONAL_BOUNCE_PREFIX") or "transactional+"
|
||||
)
|
||||
TRANSACTIONAL_BOUNCE_SUFFIX = (
|
||||
os.environ.get("TRANSACTIONAL_BOUNCE_SUFFIX") or f"+@{EMAIL_DOMAIN}"
|
||||
)
|
||||
|
||||
try:
|
||||
MAX_NB_EMAIL_FREE_PLAN = int(os.environ["MAX_NB_EMAIL_FREE_PLAN"])
|
||||
except Exception:
|
||||
print("MAX_NB_EMAIL_FREE_PLAN is not set, use 5 as default value")
|
||||
MAX_NB_EMAIL_FREE_PLAN = 5
|
||||
|
||||
MAX_NB_EMAIL_OLD_FREE_PLAN = int(os.environ.get("MAX_NB_EMAIL_OLD_FREE_PLAN", 15))
|
||||
|
||||
# maximum number of directory a premium user can create
|
||||
MAX_NB_DIRECTORY = 50
|
||||
|
||||
# transactional email sender
|
||||
SENDER = os.environ.get("SENDER")
|
||||
|
||||
# the directory to store bounce emails
|
||||
SENDER_DIR = os.environ.get("SENDER_DIR")
|
||||
MAX_NB_SUBDOMAIN = 5
|
||||
|
||||
ENFORCE_SPF = "ENFORCE_SPF" in os.environ
|
||||
|
||||
# allow to override postfix server locally
|
||||
# override postfix server locally
|
||||
# use 240.0.0.1 here instead of 10.0.0.1 as existing SL instances use the 240.0.0.0 network
|
||||
POSTFIX_SERVER = os.environ.get("POSTFIX_SERVER", "240.0.0.1")
|
||||
|
||||
DISABLE_REGISTRATION = "DISABLE_REGISTRATION" in os.environ
|
||||
|
||||
# allow using a different postfix port, useful when developing locally
|
||||
POSTFIX_PORT = None
|
||||
if "POSTFIX_PORT" in os.environ:
|
||||
POSTFIX_PORT = int(os.environ["POSTFIX_PORT"])
|
||||
|
||||
# Use port 587 instead of 25 when sending emails through Postfix
|
||||
# Useful when calling Postfix from an external network
|
||||
POSTFIX_SUBMISSION_TLS = "POSTFIX_SUBMISSION_TLS" in os.environ
|
||||
|
||||
if "OTHER_ALIAS_DOMAINS" in os.environ:
|
||||
OTHER_ALIAS_DOMAINS = eval(
|
||||
os.environ["OTHER_ALIAS_DOMAINS"]
|
||||
) # ["domain1.com", "domain2.com"]
|
||||
if POSTFIX_SUBMISSION_TLS:
|
||||
default_postfix_port = 587
|
||||
else:
|
||||
OTHER_ALIAS_DOMAINS = []
|
||||
default_postfix_port = 25
|
||||
POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", default_postfix_port))
|
||||
POSTFIX_TIMEOUT = os.environ.get("POSTFIX_TIMEOUT", 3)
|
||||
|
||||
# ["domain1.com", "domain2.com"]
|
||||
OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list)
|
||||
OTHER_ALIAS_DOMAINS = [d.lower().strip() for d in OTHER_ALIAS_DOMAINS]
|
||||
|
||||
# List of domains user can use to create alias
|
||||
if "ALIAS_DOMAINS" in os.environ:
|
||||
ALIAS_DOMAINS = eval(os.environ["ALIAS_DOMAINS"]) # ["domain1.com", "domain2.com"]
|
||||
ALIAS_DOMAINS = sl_getenv("ALIAS_DOMAINS") # ["domain1.com", "domain2.com"]
|
||||
else:
|
||||
ALIAS_DOMAINS = OTHER_ALIAS_DOMAINS + [EMAIL_DOMAIN]
|
||||
|
||||
ALIAS_DOMAINS = [d.lower().strip() for d in ALIAS_DOMAINS]
|
||||
|
||||
# ["domain1.com", "domain2.com"]
|
||||
PREMIUM_ALIAS_DOMAINS = sl_getenv("PREMIUM_ALIAS_DOMAINS", list)
|
||||
PREMIUM_ALIAS_DOMAINS = [d.lower().strip() for d in PREMIUM_ALIAS_DOMAINS]
|
||||
|
||||
# the alias domain used when creating the first alias for user
|
||||
FIRST_ALIAS_DOMAIN = os.environ.get("FIRST_ALIAS_DOMAIN") or EMAIL_DOMAIN
|
||||
|
||||
# list of (priority, email server)
|
||||
EMAIL_SERVERS_WITH_PRIORITY = eval(
|
||||
os.environ["EMAIL_SERVERS_WITH_PRIORITY"]
|
||||
) # [(10, "email.hostname.")]
|
||||
|
||||
# these emails are ignored when computing stats
|
||||
if os.environ.get("IGNORED_EMAILS"):
|
||||
IGNORED_EMAILS = eval(os.environ.get("IGNORED_EMAILS"))
|
||||
else:
|
||||
IGNORED_EMAILS = []
|
||||
# e.g. [(10, "mx1.hostname."), (10, "mx2.hostname.")]
|
||||
EMAIL_SERVERS_WITH_PRIORITY = sl_getenv("EMAIL_SERVERS_WITH_PRIORITY")
|
||||
|
||||
# disable the alias suffix, i.e. the ".random_word" part
|
||||
DISABLE_ALIAS_SUFFIX = "DISABLE_ALIAS_SUFFIX" in os.environ
|
||||
|
@ -119,63 +150,63 @@ DISABLE_ALIAS_SUFFIX = "DISABLE_ALIAS_SUFFIX" in os.environ
|
|||
# the email address that receives all unsubscription request
|
||||
UNSUBSCRIBER = os.environ.get("UNSUBSCRIBER")
|
||||
|
||||
DKIM_PRIVATE_KEY_PATH = get_abs_path(os.environ["DKIM_PRIVATE_KEY_PATH"])
|
||||
DKIM_PUBLIC_KEY_PATH = get_abs_path(os.environ["DKIM_PUBLIC_KEY_PATH"])
|
||||
DKIM_SELECTOR = b"dkim"
|
||||
# due to a typo, both UNSUBSCRIBER and OLD_UNSUBSCRIBER are supported
|
||||
OLD_UNSUBSCRIBER = os.environ.get("OLD_UNSUBSCRIBER")
|
||||
|
||||
DKIM_SELECTOR = b"dkim"
|
||||
DKIM_PRIVATE_KEY = None
|
||||
|
||||
if "DKIM_PRIVATE_KEY_PATH" in os.environ:
|
||||
DKIM_PRIVATE_KEY_PATH = get_abs_path(os.environ["DKIM_PRIVATE_KEY_PATH"])
|
||||
with open(DKIM_PRIVATE_KEY_PATH) as f:
|
||||
DKIM_PRIVATE_KEY = f.read()
|
||||
|
||||
|
||||
with open(DKIM_PUBLIC_KEY_PATH) as f:
|
||||
DKIM_DNS_VALUE = (
|
||||
f.read()
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replace("\r", "")
|
||||
.replace("\n", "")
|
||||
)
|
||||
|
||||
|
||||
DKIM_HEADERS = [b"from", b"to"]
|
||||
|
||||
# Database
|
||||
DB_URI = os.environ["DB_URI"]
|
||||
DB_CONN_NAME = os.environ.get("DB_CONN_NAME", "webapp")
|
||||
|
||||
# Flask secret
|
||||
FLASK_SECRET = os.environ["FLASK_SECRET"]
|
||||
if not FLASK_SECRET:
|
||||
raise RuntimeError("FLASK_SECRET is empty. Please define it.")
|
||||
SESSION_COOKIE_NAME = "slapp"
|
||||
MAILBOX_SECRET = FLASK_SECRET + "mailbox"
|
||||
CUSTOM_ALIAS_SECRET = FLASK_SECRET + "custom_alias"
|
||||
UNSUBSCRIBE_SECRET = FLASK_SECRET + "unsub"
|
||||
|
||||
# AWS
|
||||
AWS_REGION = "eu-west-3"
|
||||
AWS_REGION = os.environ.get("AWS_REGION") or "eu-west-3"
|
||||
BUCKET = os.environ.get("BUCKET")
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY")
|
||||
|
||||
CLOUDWATCH_LOG_GROUP = CLOUDWATCH_LOG_STREAM = ""
|
||||
ENABLE_CLOUDWATCH = "ENABLE_CLOUDWATCH" in os.environ
|
||||
if ENABLE_CLOUDWATCH:
|
||||
CLOUDWATCH_LOG_GROUP = os.environ["CLOUDWATCH_LOG_GROUP"]
|
||||
CLOUDWATCH_LOG_STREAM = os.environ["CLOUDWATCH_LOG_STREAM"]
|
||||
AWS_ENDPOINT_URL = os.environ.get("AWS_ENDPOINT_URL", None)
|
||||
|
||||
# Paddle
|
||||
try:
|
||||
PADDLE_VENDOR_ID = int(os.environ["PADDLE_VENDOR_ID"])
|
||||
PADDLE_MONTHLY_PRODUCT_ID = int(os.environ["PADDLE_MONTHLY_PRODUCT_ID"])
|
||||
PADDLE_YEARLY_PRODUCT_ID = int(os.environ["PADDLE_YEARLY_PRODUCT_ID"])
|
||||
except:
|
||||
except (KeyError, ValueError):
|
||||
print("Paddle param not set")
|
||||
PADDLE_VENDOR_ID = -1
|
||||
PADDLE_MONTHLY_PRODUCT_ID = -1
|
||||
PADDLE_YEARLY_PRODUCT_ID = -1
|
||||
|
||||
# Other Paddle product IDS
|
||||
PADDLE_MONTHLY_PRODUCT_IDS = sl_getenv("PADDLE_MONTHLY_PRODUCT_IDS", list)
|
||||
PADDLE_MONTHLY_PRODUCT_IDS.append(PADDLE_MONTHLY_PRODUCT_ID)
|
||||
|
||||
PADDLE_YEARLY_PRODUCT_IDS = sl_getenv("PADDLE_YEARLY_PRODUCT_IDS", list)
|
||||
PADDLE_YEARLY_PRODUCT_IDS.append(PADDLE_YEARLY_PRODUCT_ID)
|
||||
|
||||
PADDLE_PUBLIC_KEY_PATH = get_abs_path(
|
||||
os.environ.get("PADDLE_PUBLIC_KEY_PATH", "local_data/paddle.key.pub")
|
||||
)
|
||||
|
||||
PADDLE_AUTH_CODE = os.environ.get("PADDLE_AUTH_CODE")
|
||||
|
||||
PADDLE_COUPON_ID = os.environ.get("PADDLE_COUPON_ID")
|
||||
|
||||
# OpenID keys, used to sign id_token
|
||||
OPENID_PRIVATE_KEY_PATH = get_abs_path(
|
||||
os.environ.get("OPENID_PRIVATE_KEY_PATH", "local_data/jwtRS256.key")
|
||||
|
@ -185,8 +216,10 @@ OPENID_PUBLIC_KEY_PATH = get_abs_path(
|
|||
)
|
||||
|
||||
# Used to generate random email
|
||||
# words.txt is a list of English words and doesn't contain any "bad" word
|
||||
# words_alpha.txt comes from https://github.com/dwyl/english-words and also contains bad words.
|
||||
WORDS_FILE_PATH = get_abs_path(
|
||||
os.environ.get("WORDS_FILE_PATH", "local_data/words_alpha.txt")
|
||||
os.environ.get("WORDS_FILE_PATH", "local_data/words.txt")
|
||||
)
|
||||
|
||||
# Used to generate random email
|
||||
|
@ -201,17 +234,33 @@ else:
|
|||
|
||||
print("WARNING: Use a temp directory for GNUPGHOME", GNUPGHOME)
|
||||
|
||||
# Github, Google, Facebook client id and secrets
|
||||
# Github, Google, Facebook, OIDC client id and secrets
|
||||
GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID")
|
||||
GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET")
|
||||
|
||||
|
||||
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID")
|
||||
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET")
|
||||
|
||||
FACEBOOK_CLIENT_ID = os.environ.get("FACEBOOK_CLIENT_ID")
|
||||
FACEBOOK_CLIENT_SECRET = os.environ.get("FACEBOOK_CLIENT_SECRET")
|
||||
|
||||
CONNECT_WITH_OIDC_ICON = os.environ.get("CONNECT_WITH_OIDC_ICON")
|
||||
OIDC_WELL_KNOWN_URL = os.environ.get("OIDC_WELL_KNOWN_URL")
|
||||
OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID")
|
||||
OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET")
|
||||
OIDC_SCOPES = os.environ.get("OIDC_SCOPES")
|
||||
OIDC_NAME_FIELD = os.environ.get("OIDC_NAME_FIELD", "name")
|
||||
|
||||
PROTON_CLIENT_ID = os.environ.get("PROTON_CLIENT_ID")
|
||||
PROTON_CLIENT_SECRET = os.environ.get("PROTON_CLIENT_SECRET")
|
||||
PROTON_BASE_URL = os.environ.get(
|
||||
"PROTON_BASE_URL", "https://account.protonmail.com/api"
|
||||
)
|
||||
PROTON_VALIDATE_CERTS = "PROTON_VALIDATE_CERTS" in os.environ
|
||||
CONNECT_WITH_PROTON = "CONNECT_WITH_PROTON" in os.environ
|
||||
PROTON_EXTRA_HEADER_NAME = os.environ.get("PROTON_EXTRA_HEADER_NAME")
|
||||
PROTON_EXTRA_HEADER_VALUE = os.environ.get("PROTON_EXTRA_HEADER_VALUE")
|
||||
|
||||
# in seconds
|
||||
AVATAR_URL_EXPIRATION = 3600 * 24 * 7 # 1h*24h/d*7d=1week
|
||||
|
||||
|
@ -221,12 +270,18 @@ MFA_USER_ID = "mfa_user_id"
|
|||
FLASK_PROFILER_PATH = os.environ.get("FLASK_PROFILER_PATH")
|
||||
FLASK_PROFILER_PASSWORD = os.environ.get("FLASK_PROFILER_PASSWORD")
|
||||
|
||||
|
||||
# Job names
|
||||
JOB_ONBOARDING_1 = "onboarding-1"
|
||||
JOB_ONBOARDING_2 = "onboarding-2"
|
||||
JOB_ONBOARDING_3 = "onboarding-3"
|
||||
JOB_ONBOARDING_4 = "onboarding-4"
|
||||
JOB_BATCH_IMPORT = "batch-import"
|
||||
JOB_DELETE_ACCOUNT = "delete-account"
|
||||
JOB_DELETE_MAILBOX = "delete-mailbox"
|
||||
JOB_DELETE_DOMAIN = "delete-domain"
|
||||
JOB_SEND_USER_REPORT = "send-user-report"
|
||||
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
|
||||
JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events"
|
||||
|
||||
# for pagination
|
||||
PAGE_LIMIT = 20
|
||||
|
@ -235,12 +290,12 @@ PAGE_LIMIT = 20
|
|||
LOCAL_FILE_UPLOAD = "LOCAL_FILE_UPLOAD" in os.environ
|
||||
UPLOAD_DIR = None
|
||||
|
||||
# Greylisting features
|
||||
# Rate Limiting
|
||||
# nb max of activity (forward/reply) an alias can have during 1 min
|
||||
MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS = 5
|
||||
MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS = 10
|
||||
|
||||
# nb max of activity (forward/reply) a mailbox can have during 1 min
|
||||
MAX_ACTIVITY_DURING_MINUTE_PER_MAILBOX = 10
|
||||
MAX_ACTIVITY_DURING_MINUTE_PER_MAILBOX = 15
|
||||
|
||||
if LOCAL_FILE_UPLOAD:
|
||||
print("Upload files to local dir")
|
||||
|
@ -251,20 +306,11 @@ if LOCAL_FILE_UPLOAD:
|
|||
|
||||
LANDING_PAGE_URL = os.environ.get("LANDING_PAGE_URL") or "https://simplelogin.io"
|
||||
|
||||
STATUS_PAGE_URL = os.environ.get("STATUS_PAGE_URL") or "https://status.simplelogin.io"
|
||||
|
||||
# Loading PGP keys when mail_handler runs. To be used locally when init_app is not called.
|
||||
LOAD_PGP_EMAIL_HANDLER = "LOAD_PGP_EMAIL_HANDLER" in os.environ
|
||||
|
||||
DISPOSABLE_FILE_PATH = get_abs_path(
|
||||
os.environ.get("DISPOSABLE_FILE_PATH", "local_data/local_disposable_domains.txt")
|
||||
)
|
||||
|
||||
with open(get_abs_path(DISPOSABLE_FILE_PATH), "r") as f:
|
||||
DISPOSABLE_EMAIL_DOMAINS = f.readlines()
|
||||
DISPOSABLE_EMAIL_DOMAINS = [d.strip().lower() for d in DISPOSABLE_EMAIL_DOMAINS]
|
||||
DISPOSABLE_EMAIL_DOMAINS = [
|
||||
d for d in DISPOSABLE_EMAIL_DOMAINS if not d.startswith("#")
|
||||
]
|
||||
|
||||
# Used when querying info on Apple API
|
||||
# for iOS App
|
||||
APPLE_API_SECRET = os.environ.get("APPLE_API_SECRET")
|
||||
|
@ -279,15 +325,266 @@ MAX_ALERT_24H = 4
|
|||
# When a reverse-alias receives emails from un unknown mailbox
|
||||
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX = "reverse_alias_unknown_mailbox"
|
||||
|
||||
# When somebody is trying to spoof a reply
|
||||
ALERT_DMARC_FAILED_REPLY_PHASE = "dmarc_failed_reply_phase"
|
||||
|
||||
# When a forwarding email is bounced
|
||||
ALERT_BOUNCE_EMAIL = "bounce"
|
||||
|
||||
ALERT_BOUNCE_EMAIL_REPLY_PHASE = "bounce-when-reply"
|
||||
|
||||
# When a forwarding email is detected as spam
|
||||
ALERT_SPAM_EMAIL = "spam"
|
||||
|
||||
# When an email is sent from a mailbox to an alias - a cycle
|
||||
ALERT_SEND_EMAIL_CYCLE = "cycle"
|
||||
|
||||
ALERT_NON_REVERSE_ALIAS_REPLY_PHASE = "non_reverse_alias_reply_phase"
|
||||
|
||||
ALERT_FROM_ADDRESS_IS_REVERSE_ALIAS = "from_address_is_reverse_alias"
|
||||
|
||||
ALERT_TO_NOREPLY = "to_noreply"
|
||||
|
||||
ALERT_SPF = "spf"
|
||||
|
||||
ALERT_INVALID_TOTP_LOGIN = "invalid_totp_login"
|
||||
|
||||
# when a mailbox is also an alias
|
||||
# happens when user adds a mailbox with their domain
|
||||
# then later adds this domain into SimpleLogin
|
||||
ALERT_MAILBOX_IS_ALIAS = "mailbox_is_alias"
|
||||
|
||||
AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN = "custom_domain_mx_record_issue"
|
||||
|
||||
# alert when a new alias is about to be created on a disabled directory
|
||||
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION = "alert_directory_disabled_alias_creation"
|
||||
|
||||
ALERT_COMPLAINT_REPLY_PHASE = "alert_complaint_reply_phase"
|
||||
ALERT_COMPLAINT_FORWARD_PHASE = "alert_complaint_forward_phase"
|
||||
ALERT_COMPLAINT_TRANSACTIONAL_PHASE = "alert_complaint_transactional_phase"
|
||||
|
||||
ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc"
|
||||
|
||||
ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER = "alert_dual_sub_with_partner"
|
||||
ALERT_WARN_MULTIPLE_SUBSCRIPTIONS = "alert_multiple_subscription"
|
||||
|
||||
# <<<<< END ALERT EMAIL >>>>
|
||||
|
||||
# Disable onboarding emails
|
||||
DISABLE_ONBOARDING = "DISABLE_ONBOARDING" in os.environ
|
||||
|
||||
HCAPTCHA_SECRET = os.environ.get("HCAPTCHA_SECRET")
|
||||
HCAPTCHA_SITEKEY = os.environ.get("HCAPTCHA_SITEKEY")
|
||||
|
||||
PLAUSIBLE_HOST = os.environ.get("PLAUSIBLE_HOST")
|
||||
PLAUSIBLE_DOMAIN = os.environ.get("PLAUSIBLE_DOMAIN")
|
||||
|
||||
# server host
|
||||
HOST = socket.gethostname()
|
||||
|
||||
SPAMASSASSIN_HOST = os.environ.get("SPAMASSASSIN_HOST")
|
||||
# by default use a tolerant score
|
||||
if "MAX_SPAM_SCORE" in os.environ:
|
||||
MAX_SPAM_SCORE = float(os.environ["MAX_SPAM_SCORE"])
|
||||
else:
|
||||
MAX_SPAM_SCORE = 5.5
|
||||
|
||||
# use a more restrictive score when replying
|
||||
if "MAX_REPLY_PHASE_SPAM_SCORE" in os.environ:
|
||||
MAX_REPLY_PHASE_SPAM_SCORE = float(os.environ["MAX_REPLY_PHASE_SPAM_SCORE"])
|
||||
else:
|
||||
MAX_REPLY_PHASE_SPAM_SCORE = 5
|
||||
|
||||
PGP_SENDER_PRIVATE_KEY = None
|
||||
PGP_SENDER_PRIVATE_KEY_PATH = os.environ.get("PGP_SENDER_PRIVATE_KEY_PATH")
|
||||
if PGP_SENDER_PRIVATE_KEY_PATH:
|
||||
with open(get_abs_path(PGP_SENDER_PRIVATE_KEY_PATH)) as f:
|
||||
PGP_SENDER_PRIVATE_KEY = f.read()
|
||||
|
||||
# the signer address that signs outgoing encrypted emails
|
||||
PGP_SIGNER = os.environ.get("PGP_SIGNER")
|
||||
|
||||
# emails that have empty From address is sent from this special reverse-alias
|
||||
NOREPLY = os.environ.get("NOREPLY", f"noreply@{EMAIL_DOMAIN}")
|
||||
|
||||
# list of no reply addresses
|
||||
NOREPLIES = sl_getenv("NOREPLIES", list) or [NOREPLY]
|
||||
|
||||
COINBASE_WEBHOOK_SECRET = os.environ.get("COINBASE_WEBHOOK_SECRET")
|
||||
COINBASE_CHECKOUT_ID = os.environ.get("COINBASE_CHECKOUT_ID")
|
||||
COINBASE_API_KEY = os.environ.get("COINBASE_API_KEY")
|
||||
try:
|
||||
COINBASE_YEARLY_PRICE = float(os.environ["COINBASE_YEARLY_PRICE"])
|
||||
except Exception:
|
||||
COINBASE_YEARLY_PRICE = 30.00
|
||||
|
||||
ALIAS_LIMIT = os.environ.get("ALIAS_LIMIT") or "100/day;50/hour;5/minute"
|
||||
|
||||
ENABLE_SPAM_ASSASSIN = "ENABLE_SPAM_ASSASSIN" in os.environ
|
||||
|
||||
ALIAS_RANDOM_SUFFIX_LENGTH = int(os.environ.get("ALIAS_RAND_SUFFIX_LENGTH", 5))
|
||||
|
||||
try:
|
||||
HIBP_SCAN_INTERVAL_DAYS = int(os.environ.get("HIBP_SCAN_INTERVAL_DAYS"))
|
||||
except Exception:
|
||||
HIBP_SCAN_INTERVAL_DAYS = 7
|
||||
HIBP_API_KEYS = sl_getenv("HIBP_API_KEYS", list) or []
|
||||
HIBP_MAX_ALIAS_CHECK = 10_000
|
||||
HIBP_RPM = int(os.environ.get("HIBP_API_RPM", 100))
|
||||
HIBP_SKIP_PARTNER_ALIAS = os.environ.get("HIBP_SKIP_PARTNER_ALIAS")
|
||||
|
||||
KEEP_OLD_DATA_DAYS = 30
|
||||
|
||||
POSTMASTER = os.environ.get("POSTMASTER")
|
||||
|
||||
# store temporary files, especially for debugging
|
||||
TEMP_DIR = os.environ.get("TEMP_DIR")
|
||||
|
||||
# Store unsent emails
|
||||
SAVE_UNSENT_DIR = os.environ.get("SAVE_UNSENT_DIR")
|
||||
if SAVE_UNSENT_DIR and not os.path.isdir(SAVE_UNSENT_DIR):
|
||||
try:
|
||||
os.makedirs(SAVE_UNSENT_DIR)
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
# enable the alias automation disable: an alias can be automatically disabled if it has too many bounces
|
||||
ALIAS_AUTOMATIC_DISABLE = "ALIAS_AUTOMATIC_DISABLE" in os.environ
|
||||
|
||||
# whether the DKIM signing is handled by Rspamd
|
||||
RSPAMD_SIGN_DKIM = "RSPAMD_SIGN_DKIM" in os.environ
|
||||
|
||||
TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN")
|
||||
|
||||
PHONE_PROVIDER_1_HEADER = "X-SimpleLogin-Secret"
|
||||
PHONE_PROVIDER_1_SECRET = os.environ.get("PHONE_PROVIDER_1_SECRET")
|
||||
|
||||
PHONE_PROVIDER_2_HEADER = os.environ.get("PHONE_PROVIDER_2_HEADER")
|
||||
PHONE_PROVIDER_2_SECRET = os.environ.get("PHONE_PROVIDER_2_SECRET")
|
||||
|
||||
ZENDESK_HOST = os.environ.get("ZENDESK_HOST")
|
||||
ZENDESK_API_TOKEN = os.environ.get("ZENDESK_API_TOKEN")
|
||||
ZENDESK_ENABLED = "ZENDESK_ENABLED" in os.environ
|
||||
|
||||
DMARC_CHECK_ENABLED = "DMARC_CHECK_ENABLED" in os.environ
|
||||
|
||||
# Bounces can happen after 5 days
|
||||
VERP_MESSAGE_LIFETIME = 5 * 86400
|
||||
VERP_PREFIX = os.environ.get("VERP_PREFIX") or "sl"
|
||||
# Generate with python3 -c 'import secrets; print(secrets.token_hex(28))'
|
||||
VERP_EMAIL_SECRET = os.environ.get("VERP_EMAIL_SECRET") or (
|
||||
FLASK_SECRET + "pleasegenerateagoodrandomtoken"
|
||||
)
|
||||
if len(VERP_EMAIL_SECRET) < 32:
|
||||
raise RuntimeError(
|
||||
"Please, set VERP_EMAIL_SECRET to a random string at least 32 chars long"
|
||||
)
|
||||
ALIAS_TRANSFER_TOKEN_SECRET = os.environ.get("ALIAS_TRANSFER_TOKEN_SECRET") or (
|
||||
FLASK_SECRET + "aliastransfertoken"
|
||||
)
|
||||
|
||||
|
||||
def get_allowed_redirect_domains() -> List[str]:
|
||||
allowed_domains = sl_getenv("ALLOWED_REDIRECT_DOMAINS", list)
|
||||
if allowed_domains:
|
||||
return allowed_domains
|
||||
parsed_url = urlparse(URL)
|
||||
return [parsed_url.hostname]
|
||||
|
||||
|
||||
ALLOWED_REDIRECT_DOMAINS = get_allowed_redirect_domains()
|
||||
|
||||
|
||||
def setup_nameservers():
|
||||
nameservers = os.environ.get("NAMESERVERS", "1.1.1.1")
|
||||
return nameservers.split(",")
|
||||
|
||||
|
||||
NAMESERVERS = setup_nameservers()
|
||||
|
||||
DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = os.environ.get(
|
||||
"DISABLE_CREATE_CONTACTS_FOR_FREE_USERS", False
|
||||
)
|
||||
|
||||
|
||||
# Expect format hits,seconds:hits,seconds...
|
||||
# Example 1,10:4,60 means 1 in the last 10 secs or 4 in the last 60 secs
|
||||
def getRateLimitFromConfig(
|
||||
env_var: string, default: string = ""
|
||||
) -> list[tuple[int, int]]:
|
||||
value = os.environ.get(env_var, default)
|
||||
if not value:
|
||||
return []
|
||||
entries = [entry for entry in value.split(":")]
|
||||
limits = []
|
||||
for entry in entries:
|
||||
fields = entry.split(",")
|
||||
limit = (int(fields[0]), int(fields[1]))
|
||||
limits.append(limit)
|
||||
return limits
|
||||
|
||||
|
||||
ALIAS_CREATE_RATE_LIMIT_FREE = getRateLimitFromConfig(
|
||||
"ALIAS_CREATE_RATE_LIMIT_FREE", "10,900:50,3600"
|
||||
)
|
||||
ALIAS_CREATE_RATE_LIMIT_PAID = getRateLimitFromConfig(
|
||||
"ALIAS_CREATE_RATE_LIMIT_PAID", "50,900:200,3600"
|
||||
)
|
||||
PARTNER_API_TOKEN_SECRET = os.environ.get("PARTNER_API_TOKEN_SECRET") or (
|
||||
FLASK_SECRET + "partnerapitoken"
|
||||
)
|
||||
|
||||
JOB_MAX_ATTEMPTS = 5
|
||||
JOB_TAKEN_RETRY_WAIT_MINS = 30
|
||||
|
||||
# MEM_STORE
|
||||
MEM_STORE_URI = os.environ.get("MEM_STORE_URI", None)
|
||||
|
||||
# Recovery codes hash salt
|
||||
RECOVERY_CODE_HMAC_SECRET = os.environ.get("RECOVERY_CODE_HMAC_SECRET") or (
|
||||
FLASK_SECRET + "generatearandomtoken"
|
||||
)
|
||||
if not RECOVERY_CODE_HMAC_SECRET or len(RECOVERY_CODE_HMAC_SECRET) < 16:
|
||||
raise RuntimeError(
|
||||
"Please define RECOVERY_CODE_HMAC_SECRET in your configuration with a random string at least 16 chars long"
|
||||
)
|
||||
|
||||
|
||||
# the minimum rspamd spam score above which emails that fail DMARC should be quarantined
|
||||
if "MIN_RSPAMD_SCORE_FOR_FAILED_DMARC" in os.environ:
|
||||
MIN_RSPAMD_SCORE_FOR_FAILED_DMARC = float(
|
||||
os.environ["MIN_RSPAMD_SCORE_FOR_FAILED_DMARC"]
|
||||
)
|
||||
else:
|
||||
MIN_RSPAMD_SCORE_FOR_FAILED_DMARC = None
|
||||
|
||||
# run over all reverse alias for an alias and replace them with sender address
|
||||
ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT = (
|
||||
"ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT" in os.environ
|
||||
)
|
||||
|
||||
if ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT:
|
||||
# max number of reverse alias that can be replaced
|
||||
MAX_NB_REVERSE_ALIAS_REPLACEMENT = int(
|
||||
os.environ["MAX_NB_REVERSE_ALIAS_REPLACEMENT"]
|
||||
)
|
||||
|
||||
# Only used for tests
|
||||
SKIP_MX_LOOKUP_ON_CHECK = False
|
||||
|
||||
DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ
|
||||
|
||||
SUBSCRIPTION_CHANGE_WEBHOOK = os.environ.get("SUBSCRIPTION_CHANGE_WEBHOOK", None)
|
||||
MAX_API_KEYS = int(os.environ.get("MAX_API_KEYS", 30))
|
||||
|
||||
UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None)
|
||||
UPCLOUD_PASSWORD = os.environ.get("UPCLOUD_PASSWORD", None)
|
||||
UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", None)
|
||||
|
||||
STORE_TRANSACTIONAL_EMAILS = "STORE_TRANSACTIONAL_EMAILS" in os.environ
|
||||
|
||||
EVENT_WEBHOOK = os.environ.get("EVENT_WEBHOOK", None)
|
||||
|
||||
# We want it disabled by default, so only skip if defined
|
||||
EVENT_WEBHOOK_SKIP_VERIFY_SSL = "EVENT_WEBHOOK_SKIP_VERIFY_SSL" in os.environ
|
||||
EVENT_WEBHOOK_DISABLE = "EVENT_WEBHOOK_DISABLE" in os.environ
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
from app.db import Session
|
||||
from app.dns_utils import get_cname_record
|
||||
from app.models import CustomDomain
|
||||
|
||||
|
||||
class CustomDomainValidation:
|
||||
def __init__(self, dkim_domain: str):
|
||||
self.dkim_domain = dkim_domain
|
||||
self._dkim_records = {
|
||||
(f"{key}._domainkey", f"{key}._domainkey.{self.dkim_domain}")
|
||||
for key in ("dkim", "dkim02", "dkim03")
|
||||
}
|
||||
|
||||
def get_dkim_records(self) -> {str: str}:
|
||||
"""
|
||||
Get a list of dkim records to set up. It will be
|
||||
|
||||
"""
|
||||
return self._dkim_records
|
||||
|
||||
def validate_dkim_records(self, custom_domain: CustomDomain) -> dict[str, str]:
|
||||
"""
|
||||
Check if dkim records are properly set for this custom domain.
|
||||
Returns empty list if all records are ok. Other-wise return the records that aren't properly configured
|
||||
"""
|
||||
invalid_records = {}
|
||||
for prefix, expected_record in self.get_dkim_records():
|
||||
custom_record = f"{prefix}.{custom_domain.domain}"
|
||||
dkim_record = get_cname_record(custom_record)
|
||||
if dkim_record != expected_record:
|
||||
invalid_records[custom_record] = dkim_record or "empty"
|
||||
# HACK: If dkim is enabled, don't disable it to give users time to update their CNAMES
|
||||
if custom_domain.dkim_verified:
|
||||
return invalid_records
|
||||
custom_domain.dkim_verified = len(invalid_records) == 0
|
||||
Session.commit()
|
||||
return invalid_records
|
|
@ -3,8 +3,10 @@ from .views import (
|
|||
pricing,
|
||||
setting,
|
||||
custom_alias,
|
||||
subdomain,
|
||||
billing,
|
||||
alias_log,
|
||||
alias_export,
|
||||
unsubscribe,
|
||||
api_key,
|
||||
custom_domain,
|
||||
|
@ -13,6 +15,7 @@ from .views import (
|
|||
mfa_setup,
|
||||
mfa_cancel,
|
||||
fido_setup,
|
||||
coupon,
|
||||
fido_manage,
|
||||
domain_detail,
|
||||
lifetime_licence,
|
||||
|
@ -21,6 +24,50 @@ from .views import (
|
|||
mailbox_detail,
|
||||
refused_email,
|
||||
referral,
|
||||
recovery_code,
|
||||
contact_detail,
|
||||
setup_done,
|
||||
batch_import,
|
||||
alias_transfer,
|
||||
app,
|
||||
delete_account,
|
||||
notification,
|
||||
support,
|
||||
account_setting,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"index",
|
||||
"pricing",
|
||||
"setting",
|
||||
"custom_alias",
|
||||
"subdomain",
|
||||
"billing",
|
||||
"alias_log",
|
||||
"alias_export",
|
||||
"unsubscribe",
|
||||
"api_key",
|
||||
"custom_domain",
|
||||
"alias_contact_manager",
|
||||
"enter_sudo",
|
||||
"mfa_setup",
|
||||
"mfa_cancel",
|
||||
"fido_setup",
|
||||
"coupon",
|
||||
"fido_manage",
|
||||
"domain_detail",
|
||||
"lifetime_licence",
|
||||
"directory",
|
||||
"mailbox",
|
||||
"mailbox_detail",
|
||||
"refused_email",
|
||||
"referral",
|
||||
"contact_detail",
|
||||
"setup_done",
|
||||
"batch_import",
|
||||
"alias_transfer",
|
||||
"app",
|
||||
"delete_account",
|
||||
"notification",
|
||||
"support",
|
||||
"account_setting",
|
||||
]
|
||||
|
|
|
@ -1,162 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "dashboard" %}
|
||||
|
||||
{% block title %}
|
||||
Alias Contact Manager
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="h3"> {{ alias.email }} contacts
|
||||
<a class="ml-3 text-info" style="font-size: 12px" data-toggle="collapse" href="#howtouse" role="button"
|
||||
aria-expanded="false" aria-controls="collapseExample">
|
||||
How to use <i class="fe fe-chevrons-down"></i>
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div class="alert alert-primary collapse" id="howtouse" role="alert">
|
||||
<p>
|
||||
To send an email from your alias to a contact, says <b>friend@example.com</b>, you need to: <br>
|
||||
|
||||
1. Create a special email address called <em>reverse-alias</em> for friend@example.com using the form below
|
||||
<br>
|
||||
2. Send the email to the reverse-alias <em>instead of</em> friend@example.com
|
||||
<br>
|
||||
3. SimpleLogin will send this email <em>from the alias</em> to friend@example.com for you
|
||||
</p>
|
||||
<p>
|
||||
This might sound complicated but trust us, only the first time is a bit awkward.
|
||||
</p>
|
||||
<p>
|
||||
{% if alias.mailbox_id %}
|
||||
{% if alias.mailboxes | length == 1 %}
|
||||
Make sure you send the email from the mailbox <b>{{ alias.mailbox.email }}</b>.
|
||||
{% else %}
|
||||
Make sure you send the email from one of the following mailboxes: <br>
|
||||
{% for mailbox in alias.mailboxes %}
|
||||
- <b>{{ mailbox.email }}</b> <br>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
This is because only the mailbox that owns the alias can send emails from it.
|
||||
{% else %}
|
||||
Make sure you send the email from your personal email address ({{ current_user.email }}).
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create">
|
||||
{{ new_contact_form.csrf_token }}
|
||||
|
||||
<label class="form-label">Where do you want to send the email?</label>
|
||||
|
||||
{{ new_contact_form.email(class="form-control", placeholder="First Last <email@example.com>") }}
|
||||
{{ render_field_errors(new_contact_form.email) }}
|
||||
<button class="btn btn-primary mt-2">Create reverse-alias</button>
|
||||
</form>
|
||||
|
||||
<div class="row">
|
||||
{% for contact in contacts %}
|
||||
<div class="col-md-6">
|
||||
<div class="my-2 p-2 card {% if contact.id == highlight_contact_id %} highlight-row {% endif %}">
|
||||
<div>
|
||||
<span>
|
||||
<a href="{{ 'mailto:' + contact.website_send_to() }}"
|
||||
data-toggle="tooltip"
|
||||
title="You can click on this to open your email client. Or use the copy button 👉"
|
||||
class="font-weight-bold">*************************</a>
|
||||
|
||||
<span class="clipboard btn btn-sm btn-success copy-btn" data-toggle="tooltip"
|
||||
title="Copy to clipboard"
|
||||
data-clipboard-text="{{ contact.website_send_to() }}">
|
||||
Copy reverse-alias
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Contact <b>{{ contact.website_email }}</b>
|
||||
{% if contact.pgp_finger_print %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="PGP Enabled">🗝</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-2 text-muted small-text">
|
||||
Created {{ contact.created_at | dt }} <br>
|
||||
|
||||
{% if contact.last_reply() %}
|
||||
{% set email_log = contact.last_reply() %}
|
||||
Last email sent {{ email_log.created_at | dt }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('dashboard.contact_detail_route', contact_id=contact.id) }}">Edit ➡</a>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="delete">
|
||||
<input type="hidden" name="contact-id" value="{{ contact.id }}">
|
||||
<span class="card-link btn btn-link float-right delete-forward-email text-danger">
|
||||
Delete
|
||||
</span>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col">
|
||||
<nav aria-label="Contact navigation">
|
||||
<ul class="pagination">
|
||||
<li class="page-item">
|
||||
<a class="btn btn-outline-secondary {% if page == 0 %}disabled{% endif %}"
|
||||
href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id, page=page-1) }}">
|
||||
Previous</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}"
|
||||
href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id, page=page+1) }}">
|
||||
Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(".delete-forward-email").on("click", function (e) {
|
||||
let that = $(this);
|
||||
|
||||
bootbox.confirm({
|
||||
message: "All activities associated with this reverse-alias will also be deleted, please confirm.",
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes, delete it',
|
||||
className: 'btn-danger'
|
||||
},
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
className: 'btn-outline-primary'
|
||||
}
|
||||
},
|
||||
callback: function (result) {
|
||||
if (result) {
|
||||
that.closest("form").submit();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,161 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "dashboard" %}
|
||||
{% block head %}
|
||||
<style>
|
||||
{# https://bootsnipp.com/snippets/rljEW#}
|
||||
.card-counter {
|
||||
box-shadow: 2px 2px 10px #DADADA;
|
||||
margin: 5px;
|
||||
padding: 20px 10px;
|
||||
background-color: #fff;
|
||||
height: 100px;
|
||||
border-radius: 5px;
|
||||
transition: .3s linear all;
|
||||
}
|
||||
|
||||
.card-counter:hover {
|
||||
box-shadow: 4px 4px 20px #DADADA;
|
||||
transition: .3s linear all;
|
||||
}
|
||||
|
||||
.card-counter.primary {
|
||||
background-color: #007bff;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.card-counter.danger {
|
||||
background-color: #ef5350;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.card-counter.success {
|
||||
background-color: #66bb6a;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.card-counter.info {
|
||||
background-color: #26c6da;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.card-counter i {
|
||||
font-size: 2em;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.card-counter .count-numbers {
|
||||
position: absolute;
|
||||
right: 35px;
|
||||
top: 20px;
|
||||
font-size: 32px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-counter .count-name {
|
||||
position: absolute;
|
||||
right: 35px;
|
||||
top: 65px;
|
||||
text-transform: capitalize;
|
||||
opacity: 0.5;
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block title %}
|
||||
Alias Activity
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<h1 class="h3">
|
||||
{{ alias.email }}
|
||||
</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card-counter primary">
|
||||
<i class="fa fa-at"></i>
|
||||
<span class="count-numbers">{{ total }}</span>
|
||||
<span class="count-name">Email Handled</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card-counter primary">
|
||||
<i class="fa fa-paper-plane"></i>
|
||||
<span class="count-numbers">{{ email_forwarded }}</span>
|
||||
<span class="count-name">Email Forwarded</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card-counter primary">
|
||||
<i class="fa fa-reply"></i>
|
||||
<span class="count-numbers">{{ email_replied }}</span>
|
||||
<span class="count-name">Email Replied</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card-counter danger">
|
||||
<i class="fa fa-ban"></i>
|
||||
<span class="count-numbers">{{ email_blocked }}</span>
|
||||
<span class="count-name">Email Blocked</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4">
|
||||
{% for log in logs %}
|
||||
<div class="col-lg-6">
|
||||
<div class="my-2 p-2 card border-light">
|
||||
|
||||
<div class="font-weight-bold">{{ log.when | dt }}
|
||||
<div class="float-right pr-3">
|
||||
{% if log.bounced %}
|
||||
⚠️
|
||||
{% else %}
|
||||
|
||||
{% if log.is_reply %}
|
||||
<i class="fa fa-reply"></i>
|
||||
{% elif log.blocked %}
|
||||
<i class="fa fa-ban text-danger"></i>
|
||||
{% else %}
|
||||
<i class="fa fa-paper-plane"></i>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if log.bounced %}
|
||||
<div>
|
||||
<span class="mr-2">{{ log.website_email }}</span>
|
||||
<img src="{{ url_for('static', filename='arrows/forward-arrow.svg') }}" class="arrow">
|
||||
<span class="ml-2">{{ log.alias }}</span>
|
||||
<img src="{{ url_for('static', filename='arrows/blocked-arrow.svg') }}" class="arrow">
|
||||
<span class="ml-2">{{ log.email_log.bounced_mailbox() }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
{{ log.website_email }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<nav aria-label="Alias log navigation">
|
||||
<ul class="pagination">
|
||||
<li class="page-item">
|
||||
<a class="btn btn-outline-secondary {% if page_id == 0 %}disabled{% endif %}"
|
||||
href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id-1) }}">Previous</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}"
|
||||
href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id+1) }}">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{% endblock %}
|
|
@ -1,153 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% block title %}
|
||||
API Key
|
||||
{% endblock %}
|
||||
|
||||
{% set active_page = "api_key" %}
|
||||
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="h3"> API Key
|
||||
<a class="ml-3 text-info" style="font-size: 12px" data-toggle="collapse" href="#howtouse" role="button"
|
||||
aria-expanded="false" aria-controls="collapseExample">
|
||||
What is this? <i class="fe fe-chevrons-down"></i>
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div class="alert alert-primary collapse" id="howtouse" role="alert">
|
||||
An API key is used by the SimpleLogin browser extensions.
|
||||
<br><br>You can install the official SimpleLogin browser extensions through the following links:
|
||||
<a href="https://chrome.google.com/webstore/detail/simplelogin-extension/dphilobhebphkdjbpfohgikllaljmgbn"
|
||||
target="_blank">Chrome<i class="fe fe-external-link"></i></a>,
|
||||
<a href="https://addons.mozilla.org/en-GB/firefox/addon/simplelogin/"
|
||||
target="_blank">Firefox<i class="fe fe-external-link"></i></a> &
|
||||
<a href="https://apps.apple.com/us/app/simplelogin/id1494051017?mt=12&fbclid=IwAR0M0nnEKgoieMkmx91TSXrtcScj7GouqRxGgXeJz2un_5ydhIKlbAI79Io"
|
||||
target="_blank">Safari<i class="fe fe-external-link"></i></a>
|
||||
<br>
|
||||
<span class="text-danger">
|
||||
⚠️API Keys should be kept secret and treated like passwords, they can be used to gain access to your account.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for api_key in api_keys %}
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ api_key.name }}</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">
|
||||
{% if api_key.last_used %}
|
||||
Last used: {{ api_key.last_used | dt }} <br>
|
||||
Used: {{ api_key.times }} times.
|
||||
{% else %}
|
||||
Never used
|
||||
{% endif %}
|
||||
</h6>
|
||||
|
||||
<div class="input-group">
|
||||
<input class="form-control" id="apikey-{{ api_key.id }}" readonly value="**********">
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text">
|
||||
<i class="fe fe-eye toggle-api-key" data-show="off" data-secret="{{ api_key.code }}"
|
||||
></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<button class="clipboard btn btn-primary" data-clipboard-action="copy"
|
||||
data-clipboard-text="{{ api_key.code }}"
|
||||
data-clipboard-target="#apikey-{{ api_key.id }}">
|
||||
Copy <i class="fe fe-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="delete">
|
||||
<input type="hidden" name="api-key-id" value="{{ api_key.id }}">
|
||||
<span class="card-link btn btn-link float-right text-danger delete-api-key">
|
||||
Delete
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{{ new_api_key_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="create">
|
||||
<h2 class="h4">New API Key</h2>
|
||||
|
||||
{{ new_api_key_form.name(class="form-control", placeholder="Chrome") }}
|
||||
{{ render_field_errors(new_api_key_form.name) }}
|
||||
<div class="small-text">Name of the api key, e.g. where it will be used.</div>
|
||||
|
||||
<button class="btn btn-lg btn-success mt-2">Create</button>
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(".delete-api-key").on("click", function (e) {
|
||||
let that = $(this);
|
||||
|
||||
bootbox.confirm({
|
||||
message: "If this api key is currently in use, you need to replace it with another api key, please confirm.",
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes, delete it',
|
||||
className: 'btn-danger'
|
||||
},
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
className: 'btn-outline-primary'
|
||||
}
|
||||
},
|
||||
callback: function (result) {
|
||||
if (result) {
|
||||
that.closest("form").submit();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
});
|
||||
|
||||
$(".toggle-api-key").on('click', function (event) {
|
||||
let that = $(this);
|
||||
let apiInput = that.parent().parent().parent().find("input");
|
||||
if (that.attr("data-show") === "off") {
|
||||
let apiKey = $(this).attr("data-secret");
|
||||
apiInput.val(apiKey);
|
||||
that.addClass("fe-eye-off");
|
||||
that.removeClass("fe-eye");
|
||||
that.attr("data-show", "on");
|
||||
} else {
|
||||
that.removeClass("fe-eye-off");
|
||||
that.addClass("fe-eye");
|
||||
apiInput.val("**********");
|
||||
that.attr("data-show", "off");
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,120 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "dashboard" %}
|
||||
|
||||
{% block title %}
|
||||
Custom Alias
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="h3">New Email Alias</h1>
|
||||
|
||||
{% if user_custom_domains|length == 0 and not DISABLE_ALIAS_SUFFIX %}
|
||||
<div class="row">
|
||||
<div class="col p-1">
|
||||
<div class="alert alert-primary" role="alert">
|
||||
You might notice a random word after the dot(<em>.</em>) in the alias.
|
||||
This part is to avoid a person taking all the "nice" aliases like
|
||||
<b>hello@{{ FIRST_ALIAS_DOMAIN }}</b>,
|
||||
<b>me@{{ FIRST_ALIAS_DOMAIN }}</b>, etc. <br>
|
||||
If you add your own domain, this restriction is removed and you can fully customize the alias. <br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-6 mb-1 p-1" style="min-width: 4em">
|
||||
<input name="prefix" class="form-control"
|
||||
id="prefix"
|
||||
type="text"
|
||||
pattern="[0-9a-z-_]{1,}"
|
||||
title="Only lowercase letters, numbers, dashes (-) and underscores (_) are currently supported."
|
||||
placeholder="Email alias, for example newsletter-123_xyz"
|
||||
autofocus required>
|
||||
<div class="small-text">
|
||||
Only lowercase letters, numbers, dashes (-) and underscores (_) are currently supported.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-sm-6 p-1">
|
||||
<select class="form-control" name="suffix">
|
||||
{% for suffix in suffixes %}
|
||||
<option value="{{ suffix[2] }}">
|
||||
{% if suffix[0] %}
|
||||
{{ suffix[1] }} (your domain)
|
||||
{% else %}
|
||||
{{ suffix[1] }}
|
||||
{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col p-1">
|
||||
<select data-width="100%"
|
||||
class="mailbox-select" id="mailboxes" multiple name="mailboxes">
|
||||
{% for mailbox in mailboxes %}
|
||||
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}
|
||||
selected {% endif %}>
|
||||
{{ mailbox.email }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="small-text">
|
||||
The mailbox(es) that owns this alias.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col p-1">
|
||||
<textarea name="note"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="Note, can be anything to help you remember why you created this alias. This field is optional."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col p-1">
|
||||
<span id="submit" class="btn btn-primary mt-1">Create</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$('.mailbox-select').multipleSelect();
|
||||
|
||||
$("#submit").on("click", async function () {
|
||||
let that = $(this);
|
||||
let mailbox_ids = $(`#mailboxes`).val();
|
||||
let prefix = $('#prefix').val();
|
||||
|
||||
if (mailbox_ids.length == 0) {
|
||||
toastr.error("You must select at least a mailbox", "Error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prefix) {
|
||||
toastr.error("Alias cannot be empty", "Error");
|
||||
return;
|
||||
}
|
||||
|
||||
that.closest("form").submit();
|
||||
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
{% set active_page = "custom_domain" %}
|
||||
|
||||
{% block title %}
|
||||
Custom Domains
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="h3"> Custom Domains
|
||||
<a class="ml-3 text-info" style="font-size: 12px" data-toggle="collapse" href="#howtouse" role="button"
|
||||
aria-expanded="false" aria-controls="collapseExample">
|
||||
What is this? <i class="fe fe-chevrons-down"></i>
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
{% if not current_user.is_premium() %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This feature is only available for Premium users. <a href="https://app.simplelogin.io/dashboard/pricing" target="_blank">Upgrade<i class="fe fe-external-link"></i></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-primary collapse" id="howtouse" role="alert">
|
||||
Here you can add your own domain, you can then add custom aliases using it.
|
||||
Catch-all domains can also be setup to create new aliases on the fly.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for custom_domain in custom_domains %}
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card" style="">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}">{{ custom_domain.domain }}</a>
|
||||
{% if custom_domain.verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="Domain Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DNS Setup Needed">
|
||||
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id) }}"
|
||||
class="text-decoration-none">🚫
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">
|
||||
Created {{ custom_domain.created_at | dt }} <br>
|
||||
<span class="font-weight-bold">{{ custom_domain.nb_alias() }}</span> aliases.
|
||||
</h6>
|
||||
|
||||
<a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}">Details ➡</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{{ new_custom_domain_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="create">
|
||||
|
||||
<h2 class="h4">New Domain</h2>
|
||||
|
||||
{{ new_custom_domain_form.domain(class="form-control", placeholder="my-domain.com", maxlength=128) }}
|
||||
{{ render_field_errors(new_custom_domain_form.domain) }}
|
||||
<div class="small-text">Please use full path domain, for ex <em>my-subdomain.my-domain.com</em></div>
|
||||
|
||||
<button class="btn btn-lg btn-success mt-2">Create</button>
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,199 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
{% set active_page = "directory" %}
|
||||
|
||||
{% block title %}
|
||||
Directory
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="h3"> Directories
|
||||
<a class="ml-3 text-info" style="font-size: 12px" data-toggle="collapse" href="#howtouse" role="button"
|
||||
aria-expanded="false" aria-controls="collapseExample">
|
||||
How to use <i class="fe fe-chevrons-down"></i>
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
|
||||
{% if not current_user.is_premium() %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This feature is only available in premium plan.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-primary collapse" id="howtouse" role="alert">
|
||||
<div>
|
||||
Directory allows you to create aliases <b>on the fly</b>.
|
||||
</div>
|
||||
<div class="mt-2 pb-2">
|
||||
1️⃣ Pick a name for your directory, says <em>my_directory</em> <br>
|
||||
|
||||
2️⃣ Quickly use one of the following formats to create an alias on-the-fly <b>without creating this alias
|
||||
beforehand</b>
|
||||
</div>
|
||||
<div class="pl-3 py-2 bg-secondary">
|
||||
<em>my_directory/<b>anything</b>@{{ FIRST_ALIAS_DOMAIN }}</em> or <br>
|
||||
<em>my_directory+<b>anything</b>@{{ FIRST_ALIAS_DOMAIN }}</em> or <br>
|
||||
<em>my_directory#<b>anything</b>@{{ FIRST_ALIAS_DOMAIN }}</em> <br>
|
||||
</div>
|
||||
<em><b>anything</b></em> is any string composed of lowercase character. <br>
|
||||
|
||||
You can find more info on directory on our <a href="https://simplelogin.io/blog/alias-directory/">blog post</a>.
|
||||
|
||||
<div class="mt-2">
|
||||
You can use this feature on the following domains:
|
||||
{% for alias_domain in ALIAS_DOMAINS %}
|
||||
<div class="font-weight-bold">{{ alias_domain }} </div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="h4 text-primary mt-3">
|
||||
ℹ️
|
||||
The alias will be created the first time it receives an email.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for dir in dirs %}
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card" style="">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
{{ dir.name }}
|
||||
</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">
|
||||
Created {{ dir.created_at | dt }} <br>
|
||||
<span class="font-weight-bold">{{ dir.nb_alias() }}</span> aliases.
|
||||
|
||||
<br><br>
|
||||
|
||||
<b>Mailboxes:</b> <i class="fe fe-info" data-toggle="tooltip"
|
||||
title="Aliases created with this directory are automatically owned by these mailboxes"></i>
|
||||
<br>
|
||||
|
||||
{% set dir_mailboxes=dir.mailboxes %}
|
||||
<form method="post" class="mt-2">
|
||||
<input type="hidden" name="form-name" value="update">
|
||||
<input type="hidden" name="dir-id" value="{{ dir.id }}">
|
||||
|
||||
<select data-width="100%" required
|
||||
class="mailbox-select" multiple name="mailbox_ids">
|
||||
{% for mailbox in mailboxes %}
|
||||
<option value="{{ mailbox.id }}" {% if mailbox in dir_mailboxes %}
|
||||
selected {% endif %}>
|
||||
{{ mailbox.email }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="mt-2 btn btn-outline-primary btn-sm">Update</button>
|
||||
</form>
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div class="card-footer p-0">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="delete">
|
||||
<input type="hidden" class="dir-name" value="{{ dir.name }}">
|
||||
<input type="hidden" name="dir-id" value="{{ dir.id }}">
|
||||
<span class="card-link btn btn-link float-right text-danger delete-dir">
|
||||
Delete
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
|
||||
<form method="post" class="mt-2">
|
||||
{{ new_dir_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="create">
|
||||
|
||||
<h2 class="h4">New Directory</h2>
|
||||
|
||||
{{ new_dir_form.name(class="form-control", placeholder="my-directory",
|
||||
pattern="[0-9a-z-_]{3,}",
|
||||
title="Only letter, number, dash (-), underscore (_) can be used. Directory name must be at least 3 characters.") }}
|
||||
{{ render_field_errors(new_dir_form.name) }}
|
||||
<div class="small-text">
|
||||
Directory name must be at least 3 characters.
|
||||
Only lowercase letters, numbers, dashes (-) and underscores (_) are currently supported.
|
||||
</div>
|
||||
|
||||
<div class="mt-3 small-text alert alert-info">
|
||||
By default, aliases created with directory are "owned" by your default
|
||||
mailbox <b>{{ current_user.default_mailbox.email }}</b>. <br>
|
||||
You can however choose the mailbox(es) that new alias automatically belongs to by setting this below
|
||||
option.
|
||||
|
||||
</div>
|
||||
|
||||
<select data-width="100%"
|
||||
class="mailbox-select" multiple name="mailbox_ids">
|
||||
{% for mailbox in mailboxes %}
|
||||
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}
|
||||
selected {% endif %}>
|
||||
{{ mailbox.email }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<button id="btn-create-directory" class="btn btn-lg btn-success mt-2">Create</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(".delete-dir").on("click", function (e) {
|
||||
let directory = $(this).parent().find(".dir-name").val();
|
||||
|
||||
let that = $(this);
|
||||
let message = `All aliases associated with <b>${directory}</b> directory will also be deleted, ` +
|
||||
" please confirm.";
|
||||
|
||||
bootbox.confirm({
|
||||
message: message,
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes, delete it',
|
||||
className: 'btn-danger'
|
||||
},
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
className: 'btn-outline-primary'
|
||||
}
|
||||
},
|
||||
callback: function (result) {
|
||||
if (result) {
|
||||
that.closest("form").submit();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
$('.mailbox-select').multipleSelect();
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,283 +0,0 @@
|
|||
{% extends 'dashboard/domain_detail/base.html' %}
|
||||
|
||||
{% set domain_detail_page = "dns" %}
|
||||
|
||||
{% block title %}
|
||||
{{ custom_domain.domain }} DNS
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block domain_detail_content %}
|
||||
<div class="p-4 mr-auto" style="max-width: 60rem;">
|
||||
<h1 class="h2"> {{ custom_domain.domain }} </h1>
|
||||
<div class="">Please follow the steps below to set up your domain.</div>
|
||||
|
||||
<div class="small-text mb-5">
|
||||
DNS changes could take up to 24 hours to propagate. In practice, it's a lot faster though (~1
|
||||
minute or in our experience).
|
||||
</div>
|
||||
|
||||
<div id="mx-form">
|
||||
<div class="font-weight-bold">1. MX record
|
||||
|
||||
{% if custom_domain.verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="MX Record Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="MX Record Not Verified">🚫 </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-2">Add the following MX DNS record to your domain. <br>
|
||||
Please note that there's a point (<em>.</em>) at the end target addresses.
|
||||
This is to make sure the <i>absolute</i> address is used.
|
||||
<br>
|
||||
Also some domain registrars (Namecheap, CloudFlare, etc) might use <em>@</em> for the root domain.
|
||||
</div>
|
||||
|
||||
{% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
|
||||
<div class="mb-3 p-3 dns-record">
|
||||
Record: MX <br>
|
||||
Domain: {{ custom_domain.domain }} or @ <br>
|
||||
Priority: {{ priority }} <br>
|
||||
Target: <em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="{{ email_server }}">{{ email_server }}</em>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<form method="post" action="#mx-form">
|
||||
<input type="hidden" name="form-name" value="check-mx">
|
||||
{% if custom_domain.verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if not mx_ok %}
|
||||
<div class="text-danger mt-4">
|
||||
Your DNS is not correctly set. The MX record we obtain is:
|
||||
<div class="mb-3 p-3 dns-record">
|
||||
{% if not mx_errors %}
|
||||
(Empty)
|
||||
{% endif %}
|
||||
{% for r in mx_errors %}
|
||||
{{ r }} <br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if custom_domain.verified %}
|
||||
Please make sure to fix this ASAP - your aliases might not work properly.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="spf-form">
|
||||
<div class="font-weight-bold">2. SPF (Optional)
|
||||
{% if custom_domain.spf_verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="SPF Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="SPF Not Verified">🚫 </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
SPF <a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework" target="_blank">(Wikipedia↗)</a> is an email
|
||||
authentication method
|
||||
designed to detect forging sender addresses during the delivery of the email. <br>
|
||||
Setting up SPF is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder.
|
||||
</div>
|
||||
|
||||
<div class="mb-2">Add the following TXT DNS record to your domain.</div>
|
||||
|
||||
<div class="mb-2 p-3 dns-record">
|
||||
Record: TXT <br>
|
||||
Domain: {{ custom_domain.domain }} or @ <br>
|
||||
Value:
|
||||
<em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="{{ spf_record }}">
|
||||
{{ spf_record }}
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<form method="post" action="#spf-form">
|
||||
<input type="hidden" name="form-name" value="check-spf">
|
||||
{% if custom_domain.spf_verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if not spf_ok %}
|
||||
<div class="text-danger mt-4">
|
||||
Your DNS is not correctly set. The TXT record we obtain is:
|
||||
<div class="mb-3 p-3 dns-record">
|
||||
{% if not spf_errors %}
|
||||
(Empty)
|
||||
{% endif %}
|
||||
|
||||
{% for r in spf_errors %}
|
||||
{{ r }} <br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if custom_domain.spf_verified %}
|
||||
Without SPF setup, emails you sent from your alias might end up in Spam/Junk folder.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="dkim-form">
|
||||
<div class="font-weight-bold">3. DKIM (Optional)
|
||||
{% if custom_domain.dkim_verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="SPF Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DKIM Not Verified">🚫 </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
DKIM <a href="https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail" target="_blank">(Wikipedia↗)</a> is an
|
||||
email
|
||||
authentication method
|
||||
designed to avoid email spoofing. <br>
|
||||
Setting up DKIM is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder.
|
||||
</div>
|
||||
|
||||
<div class="mb-2">Add the following CNAME DNS record to your domain.</div>
|
||||
|
||||
<div class="mb-2 p-3 dns-record">
|
||||
Record: CNAME <br>
|
||||
Domain: <em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="dkim._domainkey">dkim._domainkey</em>.{{ custom_domain.domain }} <br>
|
||||
Value:
|
||||
<em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="{{ dkim_cname }}" style="overflow-wrap: break-word">
|
||||
{{ dkim_cname }}
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<form method="post" action="#dkim-form">
|
||||
<input type="hidden" name="form-name" value="check-dkim">
|
||||
{% if custom_domain.dkim_verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if not dkim_ok %}
|
||||
<div class="text-danger mt-4">
|
||||
Your DNS is not correctly set.
|
||||
{% if dkim_errors %}
|
||||
The CNAME record we obtain for
|
||||
<em>dkim._domainkey.{{ custom_domain.domain }}</em> is:
|
||||
|
||||
<div class="mb-3 p-3 dns-record">
|
||||
{% for r in dkim_errors %}
|
||||
{{ r }} <br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if custom_domain.dkim_verified %}
|
||||
Without DKIM setup, emails you sent from your alias might end up in Spam/Junk folder.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="dmarc-form">
|
||||
<div class="font-weight-bold">4. DMARC (Optional)
|
||||
{% if custom_domain.dmarc_verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DMARC Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DMARC Not Verified">🚫 </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
DMARC <a href="https://en.wikipedia.org/wiki/DMARC" target="_blank">(Wikipedia↗)</a>
|
||||
is designed to protect the domain from unauthorized use, commonly known as email spoofing. <br>
|
||||
Built around SPF and DKIM, a DMARC policy tells the receiving mail server what to do if
|
||||
neither of those authentication methods passes.
|
||||
</div>
|
||||
|
||||
<div class="mb-2">Add the following TXT DNS record to your domain.</div>
|
||||
|
||||
<div class="mb-2 p-3 dns-record">
|
||||
Record: TXT <br>
|
||||
Domain: <em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="_dmarc">_dmarc</em>.{{ custom_domain.domain }} <br>
|
||||
Value:
|
||||
<em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="{{ dmarc_record }}">
|
||||
{{ dmarc_record }}
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<form method="post" action="#dmarc-form">
|
||||
<input type="hidden" name="form-name" value="check-dmarc">
|
||||
{% if custom_domain.dmarc_verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if not dmarc_ok %}
|
||||
<div class="text-danger mt-4">
|
||||
Your DNS is not correctly set.
|
||||
The TXT record we obtain is:
|
||||
<div class="mb-3 p-3" style="background-color: #eee">
|
||||
{% if not dmarc_errors %}
|
||||
(Empty)
|
||||
{% endif %}
|
||||
|
||||
{% for r in dmarc_errors %}
|
||||
{{ r }} <br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if custom_domain.dmarc_verified %}
|
||||
Without DMARC setup, emails sent from your alias might end up in the Spam/Junk folder.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,117 +0,0 @@
|
|||
{% extends 'dashboard/domain_detail/base.html' %}
|
||||
|
||||
{% set domain_detail_page = "info" %}
|
||||
|
||||
{% block title %}
|
||||
{{ custom_domain.domain }} Info
|
||||
{% endblock %}
|
||||
|
||||
{% block domain_detail_content %}
|
||||
<h1 class="h3"> {{ custom_domain.domain }}
|
||||
{% if custom_domain.verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DNS Setup OK">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DNS Setup Needed">
|
||||
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id) }}"
|
||||
class="text-decoration-none">🚫
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<div class="small-text">Created {{ custom_domain.created_at | dt }}</div>
|
||||
|
||||
{{ nb_alias }} aliases
|
||||
|
||||
<hr>
|
||||
<div>Catch All</div>
|
||||
<div class="small-text">
|
||||
This feature allows you to create aliases <b>on the fly</b>.
|
||||
Simply use <em>anything@{{ custom_domain.domain }}</em>
|
||||
next time you need an email address. <br>
|
||||
The alias will be created the first time it receives an email.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="switch-catch-all">
|
||||
<label class="custom-switch cursor mt-2 pl-0"
|
||||
data-toggle="tooltip"
|
||||
{% if custom_domain.catch_all %}
|
||||
title="Disable catch-all"
|
||||
{% else %}
|
||||
title="Enable catch-all"
|
||||
{% endif %}
|
||||
>
|
||||
<input type="checkbox" class="custom-switch-input"
|
||||
{{ "checked" if custom_domain.catch_all else "" }}>
|
||||
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div>Default Alias name</div>
|
||||
<div class="small-text">
|
||||
This name will be used as the default alias name when you send
|
||||
or reply from an alias, unless overwritten by the alias specific name.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="set-name">
|
||||
<div class="form-group">
|
||||
<input class="form-control"
|
||||
value="{{ custom_domain.name or "" }}"
|
||||
name="alias-name"
|
||||
placeholder="Alias name">
|
||||
</div>
|
||||
<button class="btn btn-primary">Save</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h3 class="mb-0">Delete Domain</h3>
|
||||
<div class="small-text mb-3">Please note that this operation is irreversible.
|
||||
All aliases associated with this domain will be also deleted.
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="delete">
|
||||
<span class="delete-custom-domain btn btn-outline-danger">Delete domain</span>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(".custom-switch-input").change(function (e) {
|
||||
$(this).closest("form").submit();
|
||||
});
|
||||
|
||||
$(".delete-custom-domain").on("click", function (e) {
|
||||
let that = $(this);
|
||||
|
||||
bootbox.confirm({
|
||||
message: "All aliases associated with <b>{{ custom_domain.domain }}</b> will be also deleted, " +
|
||||
" please confirm.",
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes, delete it',
|
||||
className: 'btn-danger'
|
||||
},
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
className: 'btn-outline-primary'
|
||||
}
|
||||
},
|
||||
callback: function (result) {
|
||||
if (result) {
|
||||
that.closest("form").submit();
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,31 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
{% set active_page = "setting" %}
|
||||
{% block title %}
|
||||
SUDO MODE
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block default_content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="h2">Entering Sudo Mode</h1>
|
||||
<p>
|
||||
You are trying to change sensitive settings
|
||||
</p>
|
||||
<p>
|
||||
Please enter the password of your account so that we can ensure it's you.
|
||||
</p>
|
||||
|
||||
<form method="post">
|
||||
{{ password_check_form.csrf_token }}
|
||||
|
||||
<div class="font-weight-bold mt-5">Password</div>
|
||||
|
||||
{{ password_check_form.password(class="form-control", autofocus="true") }}
|
||||
{{ render_field_errors(password_check_form.password) }}
|
||||
<button class="btn btn-lg btn-danger mt-2">Submit</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,731 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "dashboard" %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.alias-activity {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-group-border-left {
|
||||
border-left: 1px #fbfbfb4f solid;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
Alias
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<!-- Global Stats -->
|
||||
<div class="row">
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="card">
|
||||
<div class="card-body p-3 text-center">
|
||||
<div class="h1 m-0">{{ stats.nb_alias }}</div>
|
||||
<div class="text-muted">Aliases</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="card">
|
||||
<div class="card-body p-3 text-center">
|
||||
<div class="h1 m-0">{{ stats.nb_forward }}</div>
|
||||
<div class="text-muted">Forwards</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="card">
|
||||
<div class="card-body p-3 text-center">
|
||||
<div class="h1 m-0">{{ stats.nb_reply }}</div>
|
||||
<div class="text-muted">Replies</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="card">
|
||||
<div class="card-body p-3 text-center">
|
||||
<div class="h1 m-0">{{ stats.nb_block }}</div>
|
||||
<div class="text-muted">Blocks</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- END Global Stats -->
|
||||
|
||||
|
||||
<div class="row mb-3">
|
||||
|
||||
<div class="col-lg-6 pt-1" style="max-width: 25em">
|
||||
<div class="btn-group" role="group">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create-custom-email">
|
||||
<button data-toggle="tooltip"
|
||||
title="Create a custom alias"
|
||||
class="btn btn-primary mr-2"><i class="fa fa-plus"></i> New Email Alias
|
||||
</button>
|
||||
</form>
|
||||
<div class="btn-group" role="group">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create-random-email">
|
||||
<button data-toggle="tooltip"
|
||||
title="Create a totally random alias"
|
||||
class="btn btn-success"><i class="fa fa-random"></i> Random Alias
|
||||
</button>
|
||||
</form>
|
||||
<button id="btnGroupDrop1" type="button" class="btn btn-success dropdown-toggle btn-group-border-left"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right border-left" aria-labelledby="btnGroupDrop1">
|
||||
<div class="">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create-random-email">
|
||||
<input type="hidden" name="generator_scheme" value="{{ AliasGeneratorEnum.word.value }}">
|
||||
<button class="dropdown-item">By Random Words</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create-random-email">
|
||||
<input type="hidden" name="generator_scheme" value="{{ AliasGeneratorEnum.uuid.value }}">
|
||||
<button class="dropdown-item">By UUID</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="filter-app" class="col-lg-auto pt-1 flex-grow-1">
|
||||
<div class="float-right d-flex">
|
||||
|
||||
<!-- Filter Control -->
|
||||
<div v-if="showFilter" id="filter-control">
|
||||
<form method="get" class="form-inline">
|
||||
<select name="sort"
|
||||
onchange="this.form.submit()"
|
||||
class="form-control mr-3 shadow">
|
||||
<option value="" {% if sort == "" %} selected {% endif %}>
|
||||
Sort by most recent activity
|
||||
</option>
|
||||
<option value="old2new" {% if sort == "old2new" %} selected {% endif %}>
|
||||
Alias Old-Recent
|
||||
</option>
|
||||
<option value="old2new" {% if sort == "new2old" %} selected {% endif %}>
|
||||
Alias Recent-Old
|
||||
</option>
|
||||
<option value="a2z" {% if sort == "a2z" %} selected {% endif %}>
|
||||
Alias A-Z
|
||||
</option>
|
||||
<option value="z2a" {% if sort == "z2a" %} selected {% endif %}>
|
||||
Alias Z-A
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select name="filter"
|
||||
onchange="this.form.submit()"
|
||||
class="form-control mr-3 shadow">
|
||||
<option value="" {% if filter == "" %} selected {% endif %}>
|
||||
All Aliases
|
||||
</option>
|
||||
<option value="enabled" {% if filter == "enabled" %} selected {% endif %}>
|
||||
Only Enabled Aliases
|
||||
</option>
|
||||
<option value="disabled" {% if filter == "disabled" %} selected {% endif %}>
|
||||
Only Disabled Aliases
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input type="search" name="query" placeholder="Enter to search for alias"
|
||||
class="form-control shadow mr-2"
|
||||
style="max-width: 15em"
|
||||
value="{{ query }}">
|
||||
|
||||
{% if query or sort or filter %}
|
||||
<a href="{{ url_for('dashboard.index') }}"
|
||||
class="btn btn-light">Reset</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<a v-if="!showFilter" @click="toggleFilter()" class="btn btn-outline-secondary">
|
||||
<i class="fe fe-chevrons-left"></i> Filters
|
||||
</a>
|
||||
|
||||
<a v-if="showFilter" @click="toggleFilter()" class="btn btn-outline-secondary">
|
||||
<i class="fe fe-chevrons-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for alias_info in alias_infos %}
|
||||
{% set alias = alias_info.alias %}
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card p-4 shadow-sm {% if alias_info.alias.id == highlight_alias_id %} highlight-row {% endif %} ">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<span class="clipboard cursor mb-0"
|
||||
{% if loop.index ==1 %}
|
||||
data-intro="This is an <em>alias</em>. <br><br>
|
||||
<b>All</b> emails sent to an alias will be <em>forwarded</em> to your inbox. <br><br>
|
||||
Alias is a great way to hide your personal email address so feel free to
|
||||
use it whenever possible, for example when signing up for a newsletter or creating a new account on a suspicious website 😎"
|
||||
data-step="2"
|
||||
{% endif %}
|
||||
{% if alias.enabled %}
|
||||
data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
data-clipboard-text="{{ alias.email }}"
|
||||
{% endif %}
|
||||
>
|
||||
<span class="font-weight-bold">{{ alias.email }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col text-right">
|
||||
<label class="custom-switch cursor"
|
||||
data-toggle="tooltip"
|
||||
{% if alias.enabled %}
|
||||
title="Disable alias - you will stop receiving emails sent to this alias"
|
||||
{% else %}
|
||||
title="Enable alias - you will start receiving emails sent to this alias"
|
||||
{% endif %}
|
||||
|
||||
{% if loop.index ==1 %}
|
||||
data-intro="By turning off an alias, emails sent to this alias will <em>not</em>
|
||||
be forwarded to your inbox. <br><br>
|
||||
This should be used with care as others might
|
||||
not be able to reach you after ...
|
||||
"
|
||||
data-step="3"
|
||||
{% endif %}
|
||||
style="padding-left: 0px"
|
||||
>
|
||||
<input type="checkbox" class="enable-disable-alias custom-switch-input"
|
||||
data-alias="{{ alias.id }}"
|
||||
data-alias-email="{{ alias.email }}"
|
||||
{{ "checked" if alias.enabled else "" }}>
|
||||
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Activity -->
|
||||
<div class="row mb-2">
|
||||
<div class="col">
|
||||
<div style="font-size: 12px">
|
||||
{% if alias_info.latest_email_log != None %}
|
||||
{% set email_log = alias_info.latest_email_log %}
|
||||
{% set contact = alias_info.latest_contact %}
|
||||
|
||||
{% if email_log.is_reply %}
|
||||
{{ contact.website_email }}
|
||||
<i class="fa fa-reply mr-2" data-toggle="tooltip" title="Email reply/sent from alias"></i>
|
||||
{{ email_log.created_at | dt }}
|
||||
{% elif email_log.bounced %}
|
||||
<span class="text-danger">
|
||||
{{ contact.website_email }}
|
||||
<i class="fa fa-warning mr-2" data-toggle="tooltip"
|
||||
title="Email bounced and cannot be forwarded to your mailbox"></i>
|
||||
{{ email_log.created_at | dt }}
|
||||
</span>
|
||||
{% elif email_log.blocked %}
|
||||
{{ contact.website_email }}
|
||||
<i class="fa fa-ban mr-2 text-danger" data-toggle="tooltip" title="Email blocked"></i>
|
||||
{{ email_log.created_at | dt }}
|
||||
{% else %}
|
||||
{{ contact.website_email }}
|
||||
<i class="fa fa-paper-plane mr-2" data-toggle="tooltip" title="Email forwarded to alias"></i>
|
||||
{{ email_log.created_at | dt }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
No Activity. Alias created {{ alias.created_at | dt }}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END Email Activity -->
|
||||
|
||||
<!-- Send Email && More button -->
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id) }}"
|
||||
id="send-email-{{ alias.id }}"
|
||||
{% if loop.index ==1 %}
|
||||
data-intro="Not only alias can receive emails, it can <em>send</em> emails too! <br><br>
|
||||
You can add a new <em>contact</em> for your alias here. <br><br>"
|
||||
data-step="4"
|
||||
{% endif %}
|
||||
class="btn btn-sm btn-outline-primary {% if not alias.enabled %} disabled {% endif %}"
|
||||
data-toggle="tooltip"
|
||||
title="Not only an alias can receive emails, it can send emails too"
|
||||
>
|
||||
Send Email <i class="fe fe-send"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col text-right">
|
||||
<a class="btn btn-sm" data-toggle="collapse" href="#alias-{{ alias.id }}" role="button"
|
||||
aria-expanded="false">
|
||||
More <i class="fe fe-chevron-down"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END Send Email && More button -->
|
||||
|
||||
<!-- Collapse section -->
|
||||
<div class="collapse mt-2" id="alias-{{ alias.id }}">
|
||||
|
||||
{% if alias_info.latest_email_log != None %}
|
||||
<div style="font-size: 12px">
|
||||
Alias created {{ alias.created_at | dt }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<span class="alias-activity">{{ alias_info.nb_forward }}</span> forwards,
|
||||
<span class="alias-activity">{{ alias_info.nb_blocked }}</span> blocks,
|
||||
<span class="alias-activity">{{ alias_info.nb_reply }}</span> replies
|
||||
<a href="{{ url_for('dashboard.alias_log', alias_id=alias.id) }}"
|
||||
class="btn btn-sm btn-link">
|
||||
See All →
|
||||
</a>
|
||||
|
||||
{% if mailboxes|length > 1 %}
|
||||
<div class="small-text">Current mailbox</div>
|
||||
<div class="d-flex">
|
||||
<div class="flex-grow-1 mr-2">
|
||||
<select required id="mailbox-{{ alias.id }}"
|
||||
data-width="100%"
|
||||
class="mailbox-select" multiple name="mailbox">
|
||||
{% for mailbox in mailboxes %}
|
||||
<option value="{{ mailbox.id }}" {% if alias_info.contain_mailbox(mailbox.id) %}
|
||||
selected {% endif %}>
|
||||
{{ mailbox.email }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<a data-alias="{{ alias.id }}"
|
||||
class="save-mailbox btn btn-sm btn-outline-info w-100">
|
||||
Update
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% elif alias_info.mailbox != None and alias_info.mailbox.email != current_user.email %}
|
||||
<div class="small-text">
|
||||
Owned by <b>{{ alias_info.mailbox.email }}</b> mailbox
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="small-text mt-2">Alias Note</div>
|
||||
<div class="d-flex">
|
||||
<div class="flex-grow-1 mr-2">
|
||||
<textarea
|
||||
id="note-{{ alias.id }}"
|
||||
name="note"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
placeholder="e.g. where the alias is used or why is it created">{{ alias.note or "" }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<a data-alias="{{ alias.id }}"
|
||||
class="save-note btn btn-sm btn-outline-success w-100">
|
||||
Save
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="small-text mt-2" data-toogle="tooltip"
|
||||
title="Alias name is used when you send or reply from alias">
|
||||
Alias name
|
||||
<i class="fe fe-help-circle"></i>
|
||||
</div>
|
||||
|
||||
<div class="d-flex">
|
||||
<div class="flex-grow-1 mr-2">
|
||||
<input id="alias-name-{{ alias.id }}"
|
||||
value="{{ alias.name or '' }}" class="form-control"
|
||||
placeholder="{{ alias.custom_domain.name or "Alias name" }}">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="">
|
||||
<a data-alias="{{ alias.id }}"
|
||||
class="save-alias-name btn btn-sm btn-outline-primary w-100">
|
||||
Save
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if alias.mailbox_support_pgp() %}
|
||||
<div class="small-text mt-2" data-toogle="tooltip"
|
||||
title="You can decide to turn off the PGP for an alias. This can be useful if the sender already encrypts the emails">
|
||||
PGP
|
||||
<i class="fe fe-help-circle"></i>
|
||||
</div>
|
||||
<div>
|
||||
<label class="custom-switch cursor pl-0">
|
||||
<input type="checkbox" class="enable-disable-pgp custom-switch-input"
|
||||
data-alias="{{ alias.id }}"
|
||||
data-alias-email="{{ alias.email }}"
|
||||
{{ "checked" if alias.pgp_enabled() else "" }}>
|
||||
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="delete-email">
|
||||
<input type="hidden" name="alias-id" value="{{ alias.id }}">
|
||||
<input type="hidden" name="alias" class="alias" value="{{ alias.email }}">
|
||||
|
||||
<span class="delete-email btn btn-link btn-sm float-right text-danger">
|
||||
Delete <i class="dropdown-icon fe fe-trash-2 text-danger"></i>
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- END Collapse section -->
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<nav aria-label="Alias navigation">
|
||||
<ul class="pagination">
|
||||
<li class="page-item">
|
||||
<a class="btn btn-outline-secondary {% if page == 0 %}disabled{% endif %}"
|
||||
href="{{ url_for('dashboard.index', page=page-1, query=query, sort=sort, filter=filter) }}">
|
||||
Previous</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}"
|
||||
href="{{ url_for('dashboard.index', page=page+1, query=query, sort=sort, filter=filter) }}">
|
||||
Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if client_users %}
|
||||
<div class="row">
|
||||
<h3 class="page-title col"
|
||||
data-intro="Here you can find the list of website/app on which
|
||||
you have used the <em>Connect with SimpleLogin</em> button <br><br>
|
||||
You also see what information that SimpleLogin has communicated to these website/app when you sign in."
|
||||
data-step="5"
|
||||
>
|
||||
Apps
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards row-deck mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-outline table-vcenter text-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
App
|
||||
</th>
|
||||
<th>
|
||||
Info
|
||||
<i class="fe fe-help-circle" data-toggle="tooltip"
|
||||
title="Info sent to this app/website"></i>
|
||||
</th>
|
||||
<th class="text-center">
|
||||
First used
|
||||
<i class="fe fe-help-circle" data-toggle="tooltip"
|
||||
title="The first time you have used the SimpleLogin on this app/website"></i>
|
||||
</th>
|
||||
<!--<th class="text-center">Last used</th>-->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for client_user in client_users %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ client_user.client.name }}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% for scope, val in client_user.get_user_info().items() %}
|
||||
<div>
|
||||
{% if scope == "email" %}
|
||||
Email: <a href="mailto:{{ val }}">{{ val }}</a>
|
||||
{% elif scope == "name" %}
|
||||
Name: {{ val }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
|
||||
<td class="text-center">
|
||||
{{ client_user.created_at | dt }}
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$('.mailbox-select').multipleSelect();
|
||||
|
||||
{% if show_intro %}
|
||||
// only show intro when screen is big enough to show "developer" tab
|
||||
if (window.innerWidth >= 1024) {
|
||||
introJs().start();
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
$(".delete-email").on("click", function (e) {
|
||||
let alias = $(this).parent().find(".alias").val();
|
||||
let message = `Once <b>${alias}</b> is deleted, people/apps ` +
|
||||
"who used to contact you via this alias cannot reach you any more," +
|
||||
" please confirm.";
|
||||
let that = $(this);
|
||||
|
||||
bootbox.confirm({
|
||||
message: message,
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes, delete it',
|
||||
className: 'btn-danger'
|
||||
},
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
className: 'btn-outline-primary'
|
||||
}
|
||||
},
|
||||
callback: function (result) {
|
||||
if (result) {
|
||||
that.closest("form").submit();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
});
|
||||
|
||||
$(".enable-disable-alias").change(async function (e) {
|
||||
let aliasId = $(this).data("alias");
|
||||
let alias = $(this).data("alias-email");
|
||||
|
||||
try {
|
||||
let res = await fetch(`/api/aliases/${aliasId}/toggle`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
let json = await res.json();
|
||||
|
||||
if (json.enabled) {
|
||||
toastr.success(`${alias} is enabled`);
|
||||
$(`#send-email-${aliasId}`).removeClass("disabled");
|
||||
} else {
|
||||
toastr.success(`${alias} is disabled`);
|
||||
$(`#send-email-${aliasId}`).addClass("disabled");
|
||||
}
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
var oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
} catch (e) {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
var oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
})
|
||||
|
||||
$(".enable-disable-pgp").change(async function (e) {
|
||||
let aliasId = $(this).data("alias");
|
||||
let alias = $(this).data("alias-email");
|
||||
var oldValue = !$(this).prop("checked");
|
||||
let newValue = !oldValue;
|
||||
|
||||
try {
|
||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
disable_pgp: oldValue,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (newValue) {
|
||||
toastr.success(`PGP is enabled for ${alias}`);
|
||||
} else {
|
||||
toastr.info(`PGP is disabled for ${alias}`);
|
||||
}
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
} catch (e) {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
})
|
||||
|
||||
$(".save-note").on("click", async function () {
|
||||
let aliasId = $(this).data("alias");
|
||||
let note = $(`#note-${aliasId}`).val();
|
||||
|
||||
try {
|
||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
note: note,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toastr.success(`Note Saved`);
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
var oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
} catch (e) {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
var oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
$(".save-mailbox").on("click", async function () {
|
||||
let aliasId = $(this).data("alias");
|
||||
let mailbox_ids = $(`#mailbox-${aliasId}`).val();
|
||||
|
||||
if (mailbox_ids.length == 0) {
|
||||
toastr.error("You must select at least a mailbox", "Error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mailbox_ids: mailbox_ids,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toastr.success(`Mailbox Updated`);
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
var oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
} catch (e) {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
var oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
$(".save-alias-name").on("click", async function () {
|
||||
let aliasId = $(this).data("alias");
|
||||
let name = $(`#alias-name-${aliasId}`).val();
|
||||
|
||||
try {
|
||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toastr.success(`Alias Name Saved`);
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
}
|
||||
} catch (e) {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
}
|
||||
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
var app = new Vue({
|
||||
el: '#filter-app',
|
||||
delimiters: ["[[", "]]"], // necessary to avoid conflict with jinja
|
||||
data: {
|
||||
showFilter: false
|
||||
},
|
||||
methods: {
|
||||
async toggleFilter() {
|
||||
let that = this;
|
||||
that.showFilter = !that.showFilter;
|
||||
store.set('showFilter', that.showFilter);
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (store.get("showFilter"))
|
||||
this.showFilter = true;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,157 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
{% set active_page = "mailbox" %}
|
||||
|
||||
{% block title %}
|
||||
Mailboxes
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="h3"> Mailboxes
|
||||
<a class="ml-3 text-info" style="font-size: 12px" data-toggle="collapse" href="#howtouse" role="button"
|
||||
aria-expanded="false" aria-controls="collapseExample">
|
||||
How to use <i class="fe fe-chevrons-down"></i>
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
{% if not current_user.is_premium() %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This feature is only available in premium plan.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-primary collapse" id="howtouse" role="alert">
|
||||
A <em>mailbox</em> is just another personal email address. When creating a new alias, you could choose the
|
||||
mailbox that <em>owns</em> this alias, i.e: <br>
|
||||
- all emails sent to this alias will be forwarded to this mailbox <br>
|
||||
- from this mailbox, you can reply/send emails from the alias. <br><br>
|
||||
|
||||
When you signed up, a mailbox is automatically created with your email <b>{{ current_user.email }}</b>
|
||||
<br><br>
|
||||
|
||||
The mailbox doesn't have to be your email: it can be your friend's email
|
||||
if you want to create aliases for your buddy.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for mailbox in mailboxes %}
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
{{ mailbox.email }}
|
||||
{% if mailbox.verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="Mailbox Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="Mailbox Not Verified">
|
||||
🚫
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if mailbox.pgp_finger_print %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="PGP Enabled">🗝</span>
|
||||
{% endif %}
|
||||
|
||||
{% if mailbox.id == current_user.default_mailbox_id %}
|
||||
<div class="badge badge-primary float-right" data-toggle="tooltip"
|
||||
title="When a new random alias is created, it belongs to the default mailbox">Default Mailbox
|
||||
</div>
|
||||
{% endif %}
|
||||
</h5>
|
||||
|
||||
<h6 class="card-subtitle mb-2 text-muted">
|
||||
Created {{ mailbox.created_at | dt }} <br>
|
||||
<span class="font-weight-bold">{{ mailbox.nb_alias() }}</span> aliases. <br>
|
||||
|
||||
</h6>
|
||||
|
||||
<a href="{{ url_for('dashboard.mailbox_detail_route', mailbox_id=mailbox.id) }}">Edit ➡</a>
|
||||
</div>
|
||||
|
||||
<div class="card-footer p-0">
|
||||
<div class="row">
|
||||
{% if mailbox.verified %}
|
||||
<div class="col">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="set-default">
|
||||
<input type="hidden" class="mailbox" value="{{ mailbox.email }}">
|
||||
<input type="hidden" name="mailbox-id" value="{{ mailbox.id }}">
|
||||
<button class="card-link btn btn-link
|
||||
{% if mailbox.id == current_user.default_mailbox_id %} disabled {% endif %}"
|
||||
>
|
||||
Set As Default Mailbox
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="delete">
|
||||
<input type="hidden" class="mailbox" value="{{ mailbox.email }}">
|
||||
<input type="hidden" name="mailbox-id" value="{{ mailbox.id }}">
|
||||
<span class="card-link btn btn-link text-danger float-right delete-mailbox
|
||||
{% if mailbox.id == current_user.default_mailbox_id %} disabled {% endif %}">
|
||||
Delete
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form method="post" class="mt-2">
|
||||
{{ new_mailbox_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="create">
|
||||
|
||||
<h2 class="h4">New Mailbox</h2>
|
||||
|
||||
{{ new_mailbox_form.email(class="form-control", placeholder="email@example.com") }}
|
||||
{{ render_field_errors(new_mailbox_form.email) }}
|
||||
<div class="small-text">
|
||||
A verification email will be sent to this email address.
|
||||
</div>
|
||||
|
||||
<button class="btn btn-lg btn-success mt-2">Create</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(".delete-mailbox").on("click", function (e) {
|
||||
let mailbox = $(this).parent().find(".mailbox").val();
|
||||
|
||||
let that = $(this);
|
||||
let message = `All aliases owned by this mailbox <b>${mailbox}</b> will be also deleted, ` +
|
||||
" please confirm.";
|
||||
|
||||
bootbox.confirm({
|
||||
message: message,
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes, delete it',
|
||||
className: 'btn-danger'
|
||||
},
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
className: 'btn-outline-primary'
|
||||
}
|
||||
},
|
||||
callback: function (result) {
|
||||
if (result) {
|
||||
that.closest("form").submit();
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,159 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "mailbox" %}
|
||||
|
||||
{% block title %}
|
||||
Mailbox {{ mailbox.email }}
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="h3">{{ mailbox.email }}
|
||||
{% if mailbox.verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="Mailbox Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="Mailbox Not Verified">
|
||||
🚫
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if mailbox.pgp_finger_print %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="PGP Enabled">🗝</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
{% if not mailbox.verified %}
|
||||
<div class="alert alert-info">
|
||||
Mailbox not verified, please check your inbox/spam folder for the verification email.
|
||||
<br>
|
||||
To receive the verification email again, you can delete and re-add the mailbox.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Change email -->
|
||||
<div class="card">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="form-name" value="update-email">
|
||||
{{ change_email_form.csrf_token }}
|
||||
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Change Mailbox Address
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Address</label>
|
||||
|
||||
<!-- Not allow user to change mailbox if there's a pending change -->
|
||||
{{ change_email_form.email(class="form-control", value=mailbox.email, readonly=pending_email != None) }}
|
||||
{{ render_field_errors(change_email_form.email) }}
|
||||
|
||||
{% if pending_email %}
|
||||
<div class="mt-2">
|
||||
<span class="text-danger">Pending change: {{ pending_email }}</span>
|
||||
<a href="{{ url_for('dashboard.cancel_mailbox_change_route', mailbox_id=mailbox.id) }}"
|
||||
class="btn btn-secondary btn-sm">
|
||||
Cancel mailbox change
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-primary">Change</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- END Change email -->
|
||||
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="pgp">
|
||||
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Pretty Good Privacy (PGP)
|
||||
<div class="small-text">
|
||||
By importing your PGP Public Key into SimpleLogin, all emails sent to {{ mailbox.email }} are
|
||||
<b>encrypted</b> with your key.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not current_user.is_premium() %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This feature is only available in premium plan.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">PGP Public Key</label>
|
||||
|
||||
<textarea name="pgp"
|
||||
{% if not current_user.is_premium() %} disabled {% endif %}
|
||||
class="form-control" rows=10
|
||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" name="action"
|
||||
{% if not current_user.is_premium() %} disabled {% endif %}
|
||||
value="save">Save
|
||||
</button>
|
||||
{% if mailbox.pgp_finger_print %}
|
||||
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% if spf_available %}
|
||||
<hr>
|
||||
<h2 class="h4">Advanced Options</h2>
|
||||
|
||||
<div class="card" id="spf">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="force-spf">
|
||||
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Enforce SPF
|
||||
<div class="small-text">
|
||||
To avoid email-spoofing, SimpleLogin blocks email that
|
||||
<em data-toggle="tooltip"
|
||||
title="Email that has your mailbox as envelope-sender address">seems</em> to come from your
|
||||
mailbox
|
||||
but sent from <em data-toggle="tooltip"
|
||||
title="IP Address that is not known by your mailbox email service">unknown</em>
|
||||
IP address.
|
||||
<br>
|
||||
Only turn off this option if you know what you're doing :).
|
||||
</div>
|
||||
</div>
|
||||
<label class="custom-switch cursor mt-2 pl-0"
|
||||
data-toggle="tooltip"
|
||||
{% if mailbox.force_spf %}
|
||||
title="Disable SPF enforcement"
|
||||
{% else %}
|
||||
title="Enable SPF enforcement"
|
||||
{% endif %}
|
||||
>
|
||||
<input type="checkbox" name="spf-status" class="custom-switch-input"
|
||||
{{ "checked" if mailbox.force_spf else "" }}>
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(".custom-switch-input").change(function (e) {
|
||||
$(this).closest("form").submit();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
{% set active_page = "setting" %}
|
||||
{% block title %}
|
||||
Cancel MFA
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block default_content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="h2">Two Factor Authentication</h1>
|
||||
<p>
|
||||
To remove 2FA please enter your 2FA code from the authenticator app.
|
||||
</p>
|
||||
|
||||
<form method="post">
|
||||
{{ otp_token_form.csrf_token }}
|
||||
|
||||
<div class="font-weight-bold mt-5">Token</div>
|
||||
<div class="small-text">The 6-digit 2FA code.</div>
|
||||
|
||||
{{ otp_token_form.token(class="form-control", autofocus="true") }}
|
||||
{{ render_field_errors(otp_token_form.token) }}
|
||||
<button class="btn btn-lg btn-danger mt-2">Remove 2FA</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,102 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "dashboard" %}
|
||||
|
||||
{% block title %}
|
||||
Pricing
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<script src="https://cdn.paddle.com/paddle/paddle.js"></script>
|
||||
<script>
|
||||
if (window.Paddle === undefined) {
|
||||
console.log("cannot load Paddle from CDN");
|
||||
document.write('<script src="/static/vendor/paddle.js"><\/script>')
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="row">
|
||||
<div class="col-sm-6 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<div class="h3">Premium</div>
|
||||
|
||||
<ul class="list-unstyled leading-loose mb-3">
|
||||
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Unlimited aliases</li>
|
||||
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Unlimited custom domains
|
||||
</li>
|
||||
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Catch-all (or wildcard) aliases
|
||||
</li>
|
||||
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Up to 50 directories (or usernames)
|
||||
</li>
|
||||
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Unlimited mailboxes
|
||||
</li>
|
||||
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
PGP Encryption
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="small-text">More information is available on our <a href="https://simplelogin.io/pricing" target="_blank">Pricing
|
||||
Page <i class="fe fe-external-link"></i>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-lg-6">
|
||||
<div class="display-6 my-3">
|
||||
🔐 Secure payments by
|
||||
<a href="https://paddle.com" target="_blank">Paddle<i class="fe fe-external-link"></i></a></li>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% set sub = current_user.get_subscription() %}
|
||||
{% if sub and sub.cancelled %}
|
||||
<div class="alert alert-primary" role="alert">
|
||||
You have an active subscription until {{ sub.next_bill_date.strftime("%Y-%m-%d") }}. <br>
|
||||
Please note that if you re-subscribe now, this will be a completely
|
||||
new subscription and
|
||||
your payment method will be charged <b>immediately</b>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
Paddle supported payment methods include bank cards (Mastercard, Visa, American Express, etc) or PayPal. <br>
|
||||
Send us an email at <a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a> if you need other payment options
|
||||
(e.g. IBAN transfer).
|
||||
</div>
|
||||
|
||||
<button class="btn btn-success" onclick="upgrade({{ PADDLE_MONTHLY_PRODUCT_ID }})">
|
||||
Monthly <br>
|
||||
$2.99/month
|
||||
</button>
|
||||
|
||||
<button class="btn btn-primary" onclick="upgrade({{ PADDLE_YEARLY_PRODUCT_ID }})">
|
||||
Yearly <br>
|
||||
$29.99/year
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script type="text/javascript">
|
||||
Paddle.Setup({vendor: {{ PADDLE_VENDOR_ID }}});
|
||||
|
||||
function upgrade(productId) {
|
||||
Paddle.Checkout.open({
|
||||
product: productId,
|
||||
email: "{{ current_user.email }}",
|
||||
success: "{{ success_url }}"
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
|
@ -1,40 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
{% set active_page = "setting" %}
|
||||
{% block title %}
|
||||
Recovery Codes
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="h3">Recovery codes</h1>
|
||||
<p>
|
||||
In the event you lose access to your authenticator app you can use one of these recovery codes to gain access to your account again.
|
||||
Each code can only be used once, make sure to store them in a safe place.
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
{% for recovery_code in recovery_codes %}
|
||||
{% if recovery_code.used %}
|
||||
<li>
|
||||
<span style="text-decoration: line-through">{{ recovery_code.code }}</span>.
|
||||
Used {{ recovery_code.used_at | dt }}.
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
{{ recovery_code.code }}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<form method="post" class="mt-6">
|
||||
<input type="submit" class="btn btn-outline-primary" value="Generate New Codes">
|
||||
</form>
|
||||
<div class="small-text">
|
||||
Warning: Generating new codes will invalidate the older ones.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,123 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% block title %}
|
||||
Referral
|
||||
{% endblock %}
|
||||
|
||||
{% set active_page = "setting" %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="col">
|
||||
<h1 class="h3 mb-5"> Referrals </h1>
|
||||
|
||||
{% if referrals|length == 0 %}
|
||||
<div class="alert alert-info">
|
||||
You don't have any referral code yet. Let's create the first one and start inviting your friends! <br>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% for referral in referrals %}
|
||||
<div class="card p-4 shadow-sm {% if referral.id == highlight_id %} highlight-row {% endif %}">
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="update">
|
||||
<input type="hidden" name="referral-id" value="{{ referral.id }}">
|
||||
|
||||
<b>Name</b>
|
||||
<div class="d-flex mb-3">
|
||||
<div class="mr-2">
|
||||
<input name="name" class="form-control" value="{{ referral.name or '' }}">
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-outline-success">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
{% if referral.nb_user() > 0 %}
|
||||
<div class="mb-3">
|
||||
<b class="h1">{{ referral.nb_user() }}</b>
|
||||
{% if referral.nb_user() == 1 %} person {% else %} people {% endif %}
|
||||
has protected their emails thanks to you!
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2">
|
||||
Please use this referral link to invite your friends trying out SimpleLogin: <br>
|
||||
|
||||
<div class="d-flex mb-5 mt-2" style="max-width: 40em">
|
||||
<div class="flex-grow-1 mr-2">
|
||||
<input class="form-control" id="referral-{{ referral.id }}" readonly
|
||||
value="{{ referral.link() }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="clipboard btn btn-outline-primary" data-clipboard-action="copy"
|
||||
data-clipboard-text="{{ referral.link() }}"
|
||||
data-clipboard-target="#referral-{{ referral.id }}">
|
||||
Copy <i class="fe fe-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
You can also use the referral code <b>{{ referral.code }}</b> when sharing any link on SimpleLogin. <br>
|
||||
Just append <em>?slref={{ referral.code }}</em> to any link on SimpleLogin website.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="delete">
|
||||
<input type="hidden" name="referral-id" value="{{ referral.id }}">
|
||||
<span class="delete-referral float-right btn btn-outline-danger">Delete</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if referrals|length > 0 %}
|
||||
<hr>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="mt-6">
|
||||
<input type="hidden" name="form-name" value="create">
|
||||
<input name="name" class="form-control"
|
||||
placeholder="Referral name, something to help you remember why you create it :)">
|
||||
<button class="btn btn-success mt-2">Create a new referral code</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(".delete-referral").on("click", function (e) {
|
||||
let that = $(this);
|
||||
|
||||
bootbox.confirm({
|
||||
message: "This operation is irreversible, please confirm.",
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes, delete it',
|
||||
className: 'btn-danger'
|
||||
},
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
className: 'btn-outline-primary'
|
||||
}
|
||||
},
|
||||
callback: function (result) {
|
||||
if (result) {
|
||||
that.closest("form").submit();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,64 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% block title %}
|
||||
Quarantine
|
||||
{% endblock %}
|
||||
|
||||
{% set active_page = "setting" %}
|
||||
|
||||
{% block default_content %}
|
||||
<div class="col">
|
||||
<h1 class="h3 mb-5"> Quarantine </h1>
|
||||
|
||||
<div class="alert alert-info">
|
||||
This page shows all emails that are potentially spams or malicious.
|
||||
Usually these emails have been <b>refused</b> (or bounced) by your mailbox. <br>
|
||||
|
||||
- If an email is indeed spam, this means the alias is now in the hands of a spammer,
|
||||
in this case you should <b>disable</b> this alias. <br>
|
||||
|
||||
- Otherwise, you should create a <b>filter</b> to avoid your email provider from blocking these emails. <br>
|
||||
<a href="mailto:hi@simplelogin.io">Contact us↗</a> if you need any help.
|
||||
|
||||
</div>
|
||||
|
||||
{% if email_logs|length == 0 %}
|
||||
<div class="my-4 p-4 card">
|
||||
You don't have any emails in Quarantine.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for email_log in email_logs %}
|
||||
{% set refused_email = email_log.refused_email %}
|
||||
{% set forward = email_log.forward %}
|
||||
{% set alias = forward.alias %}
|
||||
|
||||
<div class="card p-4 shadow-sm {% if email_log.id == highlight_id %} highlight-row {% endif %}">
|
||||
<div class="small-text">
|
||||
Sent {{ refused_email.created_at | dt }}
|
||||
</div>
|
||||
|
||||
From: {{ forward.website_email }} <br>
|
||||
|
||||
<span>
|
||||
To: {{ alias.email }}
|
||||
<a href='{{ url_for("dashboard.index", highlight_alias_id=alias.id) }}'
|
||||
class="btn btn-sm btn-outline-danger">Disable Alias</a>
|
||||
</span>
|
||||
|
||||
{% if refused_email.deleted %}
|
||||
<div>
|
||||
Email deleted {{ refused_email.delete_at | dt }}
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{{ refused_email.get_url() }}" download
|
||||
class="mt-4">Download →</a>
|
||||
<div class="small-text">This will download a ".eml" file that you can open in your favorite email client</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,344 +0,0 @@
|
|||
{% extends 'default.html' %}
|
||||
|
||||
{% set active_page = "setting" %}
|
||||
|
||||
{% block title %}
|
||||
Settings
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.card-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
|
||||
<div class="col pb-3">
|
||||
<!-- Change email -->
|
||||
<div class="card">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="form-name" value="update-email">
|
||||
{{ change_email_form.csrf_token }}
|
||||
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Email Address
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email</label>
|
||||
|
||||
<!-- Not allow user to change email if there's a pending change -->
|
||||
{{ change_email_form.email(class="form-control", value=current_user.email, readonly=pending_email != None) }}
|
||||
{{ render_field_errors(change_email_form.email) }}
|
||||
|
||||
{% if pending_email %}
|
||||
<div class="mt-2">
|
||||
<span class="text-danger">Pending email change: {{ pending_email }}</span>
|
||||
<a href="{{ url_for('dashboard.resend_email_change') }}" class="btn btn-secondary btn-sm">Resend
|
||||
confirmation email</a>
|
||||
<a href="{{ url_for('dashboard.cancel_email_change') }}" class="btn btn-secondary btn-sm">Cancel email
|
||||
change</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-outline-primary">Change Email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- END Change email -->
|
||||
|
||||
<!-- Change name & profile picture -->
|
||||
<div class="card">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{{ form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="update-profile">
|
||||
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Profile
|
||||
</div>
|
||||
<div>
|
||||
This information will be filled in automatically when you use "Sign in with SimpleLogin" button.
|
||||
</div>
|
||||
<div class="form-group mt-3">
|
||||
<label class="form-label">Name</label>
|
||||
{{ form.name(class="form-control", value=current_user.name) }}
|
||||
{{ render_field_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-label">Profile picture</div>
|
||||
{{ form.profile_picture(class="form-control-file") }}
|
||||
{{ render_field_errors(form.profile_picture) }}
|
||||
{% if current_user.profile_picture_id %}
|
||||
<img src="{{ current_user.profile_picture_url() }}" class="profile-picture">
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-outline-primary">Update</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- END change name & profile picture -->
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Security Key (WebAuthn)</div>
|
||||
<div class="mb-3">
|
||||
You can secure your account by linking either your FIDO-supported physical key such as Yubikey, Google
|
||||
Titan,
|
||||
or a device with appropriate hardware to your account.
|
||||
</div>
|
||||
{% if current_user.fido_uuid is none %}
|
||||
<a href="{{ url_for('dashboard.fido_setup') }}" class="btn btn-outline-primary">Setup WebAuthn</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('dashboard.fido_manage') }}" class="btn btn-outline-info">Manage WebAuthn</a>
|
||||
<a href="{{ url_for('dashboard.recovery_code_route') }}" class="btn btn-outline-secondary">Recovery Codes</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="totp">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Two Factor Authentication</div>
|
||||
<div class="mb-3">
|
||||
Secure your account with 2FA, you'll be asked for a code generated through an app when you login. <br>
|
||||
</div>
|
||||
{% if not current_user.enable_otp %}
|
||||
<a href="{{ url_for('dashboard.mfa_setup') }}" class="btn btn-outline-primary">Setup TOTP</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('dashboard.mfa_cancel') }}" class="btn btn-outline-danger">Disable TOTP</a>
|
||||
<a href="{{ url_for('dashboard.recovery_code_route') }}" class="btn btn-outline-secondary">Recovery Codes</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Change password
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
You will receive an email containing instructions on how to change your password.
|
||||
</div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="change-password">
|
||||
<button class="btn btn-outline-primary">Change password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Random Alias</div>
|
||||
<div class="mb-3">Change the way random aliases are generated by default.</div>
|
||||
<form method="post" class="form-inline">
|
||||
<input type="hidden" name="form-name" value="change-alias-generator">
|
||||
<select class="form-control mr-sm-2" name="alias-generator-scheme">
|
||||
<option value="{{ AliasGeneratorEnum.word.value }}"
|
||||
{% if current_user.alias_generator == AliasGeneratorEnum.word.value %} selected {% endif %} >Based on
|
||||
Random {{ AliasGeneratorEnum.word.name.capitalize() }}</option>
|
||||
<option value="{{ AliasGeneratorEnum.uuid.value }}"
|
||||
{% if current_user.alias_generator == AliasGeneratorEnum.uuid.value %} selected {% endif %} >Based
|
||||
on {{ AliasGeneratorEnum.uuid.name.upper() }}</option>
|
||||
</select>
|
||||
<button class="btn btn-outline-primary">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="notification">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Newsletters</div>
|
||||
<div class="mb-3">
|
||||
We will occasionally send you emails with new feature announcements.
|
||||
</div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="notification-preference">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="notification" name="notification" {% if current_user.notification %}
|
||||
checked {% endif %} class="form-check-input">
|
||||
<label for="notification">I want to be emailed when new features are released.</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title mb-3">Current Plan</div>
|
||||
|
||||
{% if current_user.get_subscription() %}
|
||||
You are on the {{ current_user.get_subscription().plan_name() }} plan. <br>
|
||||
<a href="{{ url_for('dashboard.billing') }}" class="btn btn-outline-primary">
|
||||
Manage Subscription
|
||||
</a>
|
||||
{% if current_user.lifetime %}
|
||||
You have however lifetime access to the Premium plan now so make sure to cancel the previous plan :).
|
||||
{% endif %}
|
||||
{% elif manual_sub %}
|
||||
You are on the Premium plan which expires {{ manual_sub.end_at | dt }}
|
||||
({{ manual_sub.end_at.format("YYYY-MM-DD") }}).
|
||||
{% if manual_sub.is_giveaway %}
|
||||
<br>
|
||||
To gain additional features and support Simple Login you can upgrade to a Premium plan. <br>
|
||||
<a href="{{ url_for('dashboard.pricing') }}" class="btn btn-sm btn-outline-primary">Upgrade</a>
|
||||
{% endif %}
|
||||
{% elif current_user.lifetime %}
|
||||
You have lifetime access to the Premium plan.
|
||||
{% if not current_user.paid_lifetime %}
|
||||
<br>
|
||||
To support Simple Login you can switch to a paid plan. <br>
|
||||
<a href="{{ url_for('dashboard.pricing') }}" class="btn btn-sm btn-outline-primary">Upgrade</a>
|
||||
{% endif %}
|
||||
{% elif current_user.in_trial() %}
|
||||
Your Premium trial expires {{ current_user.trial_end | dt }}.
|
||||
{% else %}
|
||||
You are on the Free plan.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="sender-format">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Sender address format</div>
|
||||
<div class="mt-1 mb-3">
|
||||
When your alias receives an email, says from: <b>John Wick <john@wick.com></b>,
|
||||
SimpleLogin forwards it to your mailbox. <br>
|
||||
|
||||
Due to some email constraints, SimpleLogin cannot keep the sender email address
|
||||
in the original form and needs to <b>transform</b> it to one of the formats below.
|
||||
</div>
|
||||
|
||||
<form method="post" action="#sender-format">
|
||||
<input type="hidden" name="form-name" value="change-sender-format">
|
||||
|
||||
<select class="form-control mr-sm-2" name="sender-format">
|
||||
<option value="{{ SenderFormatEnum.VIA.value }}"
|
||||
{% if current_user.sender_format == SenderFormatEnum.VIA.value %} selected {% endif %}>
|
||||
john@wick.com via SimpleLogin
|
||||
</option>
|
||||
<option value="{{ SenderFormatEnum.AT.value }}"
|
||||
{% if current_user.sender_format == SenderFormatEnum.AT.value %} selected {% endif %}>
|
||||
John Wick - john at wick.com
|
||||
</option>
|
||||
<option value="{{ SenderFormatEnum.A.value }}"
|
||||
{% if current_user.sender_format == SenderFormatEnum.A.value %} selected {% endif %}>
|
||||
John Wick - john(a)wick.com
|
||||
</option>
|
||||
<option value="{{ SenderFormatEnum.FULL.value }}"
|
||||
{% if current_user.sender_format == SenderFormatEnum.FULL.value %} selected {% endif %}>
|
||||
John Wick - john@wick.com
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button class="btn btn-outline-primary mt-3">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Replace reverse alias</div>
|
||||
<div class="mb-3">
|
||||
When replying to a forwarded email, the <b>reverse-alias</b> is usually included in the attached message.
|
||||
You can turn on this option to <b>replace</b> the reverse-alias by the alias. However this breaks PGP-signed
|
||||
emails
|
||||
so please only enable it if you know what you're doing :).
|
||||
</div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="replace-ra">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="replace-ra" name="replace-ra"
|
||||
{% if current_user.replace_reverse_alias %} checked {% endif %} class="form-check-input">
|
||||
<label for="replace-ra">Enable replacing reverse alias</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Quarantine</div>
|
||||
<div class="mb-3">
|
||||
When an email sent to your alias is classified as spam or refused by your email provider,
|
||||
it usually means your alias has been leaked to a spammer. <br>
|
||||
In this case SimpleLogin will <b>keep</b> a copy of this email (so it isn't lost)
|
||||
and notify you so you can take a look at its content and take appropriate actions. <br>
|
||||
|
||||
The emails are deleted in 7 days.
|
||||
This is an exceptional case where SimpleLogin stores the email.
|
||||
</div>
|
||||
<a href="{{ url_for('dashboard.refused_email_route') }}" class="btn btn-outline-primary">
|
||||
See refused emails
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Export Data</div>
|
||||
<div class="mb-3">
|
||||
You can download all aliases you have created on SimpleLogin along with other data.
|
||||
</div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="export-data">
|
||||
<button class="btn btn-outline-info">Export Data</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Delete Account</div>
|
||||
<div class="mb-3">Please note that this operation is irreversible.
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="delete-account">
|
||||
<span class="delete-account btn btn-outline-danger">Delete account</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(".delete-account").on("click", function (e) {
|
||||
let that = $(this);
|
||||
|
||||
bootbox.confirm({
|
||||
message: "All your data including your aliases will be deleted, " +
|
||||
"other people might not be able to reach you after, " +
|
||||
" please confirm.",
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes, delete my account',
|
||||
className: 'btn-danger'
|
||||
},
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
className: 'btn-outline-primary'
|
||||
}
|
||||
},
|
||||
callback: function (result) {
|
||||
if (result) {
|
||||
that.closest("form").submit();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import arrow
|
||||
from flask import (
|
||||
render_template,
|
||||
request,
|
||||
redirect,
|
||||
url_for,
|
||||
flash,
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import email_utils
|
||||
from app.config import (
|
||||
URL,
|
||||
FIRST_ALIAS_DOMAIN,
|
||||
ALIAS_RANDOM_SUFFIX_LENGTH,
|
||||
CONNECT_WITH_PROTON,
|
||||
)
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.dashboard.views.enter_sudo import sudo_required
|
||||
from app.dashboard.views.mailbox_detail import ChangeEmailForm
|
||||
from app.db import Session
|
||||
from app.email_utils import (
|
||||
email_can_be_used_as_mailbox,
|
||||
personal_email_already_used,
|
||||
)
|
||||
from app.extensions import limiter
|
||||
from app.jobs.export_user_data_job import ExportUserDataJob
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
BlockBehaviourEnum,
|
||||
PlanEnum,
|
||||
ResetPasswordCode,
|
||||
EmailChange,
|
||||
User,
|
||||
Alias,
|
||||
AliasGeneratorEnum,
|
||||
SenderFormatEnum,
|
||||
UnsubscribeBehaviourEnum,
|
||||
)
|
||||
from app.proton.utils import perform_proton_account_unlink
|
||||
from app.utils import (
|
||||
random_string,
|
||||
CSRFValidationForm,
|
||||
canonicalize_email,
|
||||
)
|
||||
|
||||
|
||||
@dashboard_bp.route("/account_setting", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@sudo_required
|
||||
@limiter.limit("5/minute", methods=["POST"])
|
||||
def account_setting():
|
||||
change_email_form = ChangeEmailForm()
|
||||
csrf_form = CSRFValidationForm()
|
||||
|
||||
email_change = EmailChange.get_by(user_id=current_user.id)
|
||||
if email_change:
|
||||
pending_email = email_change.new_email
|
||||
else:
|
||||
pending_email = None
|
||||
|
||||
if request.method == "POST":
|
||||
if not csrf_form.validate():
|
||||
flash("Invalid request", "warning")
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
if request.form.get("form-name") == "update-email":
|
||||
if change_email_form.validate():
|
||||
# whether user can proceed with the email update
|
||||
new_email_valid = True
|
||||
new_email = canonicalize_email(change_email_form.email.data)
|
||||
if new_email != current_user.email and not pending_email:
|
||||
# check if this email is not already used
|
||||
if personal_email_already_used(new_email) or Alias.get_by(
|
||||
email=new_email
|
||||
):
|
||||
flash(f"Email {new_email} already used", "error")
|
||||
new_email_valid = False
|
||||
elif not email_can_be_used_as_mailbox(new_email):
|
||||
flash(
|
||||
"You cannot use this email address as your personal inbox.",
|
||||
"error",
|
||||
)
|
||||
new_email_valid = False
|
||||
# a pending email change with the same email exists from another user
|
||||
elif EmailChange.get_by(new_email=new_email):
|
||||
other_email_change: EmailChange = EmailChange.get_by(
|
||||
new_email=new_email
|
||||
)
|
||||
LOG.w(
|
||||
"Another user has a pending %s with the same email address. Current user:%s",
|
||||
other_email_change,
|
||||
current_user,
|
||||
)
|
||||
|
||||
if other_email_change.is_expired():
|
||||
LOG.d(
|
||||
"delete the expired email change %s", other_email_change
|
||||
)
|
||||
EmailChange.delete(other_email_change.id)
|
||||
Session.commit()
|
||||
else:
|
||||
flash(
|
||||
"You cannot use this email address as your personal inbox.",
|
||||
"error",
|
||||
)
|
||||
new_email_valid = False
|
||||
|
||||
if new_email_valid:
|
||||
email_change = EmailChange.create(
|
||||
user_id=current_user.id,
|
||||
code=random_string(
|
||||
60
|
||||
), # todo: make sure the code is unique
|
||||
new_email=new_email,
|
||||
)
|
||||
Session.commit()
|
||||
send_change_email_confirmation(current_user, email_change)
|
||||
flash(
|
||||
"A confirmation email is on the way, please check your inbox",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("dashboard.account_setting"))
|
||||
elif request.form.get("form-name") == "change-password":
|
||||
flash(
|
||||
"You are going to receive an email containing instructions to change your password",
|
||||
"success",
|
||||
)
|
||||
send_reset_password_email(current_user)
|
||||
return redirect(url_for("dashboard.account_setting"))
|
||||
elif request.form.get("form-name") == "send-full-user-report":
|
||||
if ExportUserDataJob(current_user).store_job_in_db():
|
||||
flash(
|
||||
"You will receive your SimpleLogin data via email shortly",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
flash("An export of your data is currently in progress", "error")
|
||||
|
||||
partner_sub = None
|
||||
partner_name = None
|
||||
|
||||
return render_template(
|
||||
"dashboard/account_setting.html",
|
||||
csrf_form=csrf_form,
|
||||
PlanEnum=PlanEnum,
|
||||
SenderFormatEnum=SenderFormatEnum,
|
||||
BlockBehaviourEnum=BlockBehaviourEnum,
|
||||
change_email_form=change_email_form,
|
||||
pending_email=pending_email,
|
||||
AliasGeneratorEnum=AliasGeneratorEnum,
|
||||
UnsubscribeBehaviourEnum=UnsubscribeBehaviourEnum,
|
||||
partner_sub=partner_sub,
|
||||
partner_name=partner_name,
|
||||
FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
|
||||
ALIAS_RAND_SUFFIX_LENGTH=ALIAS_RANDOM_SUFFIX_LENGTH,
|
||||
connect_with_proton=CONNECT_WITH_PROTON,
|
||||
)
|
||||
|
||||
|
||||
def send_reset_password_email(user):
|
||||
"""
|
||||
generate a new ResetPasswordCode and send it over email to user
|
||||
"""
|
||||
# the activation code is valid for 1h
|
||||
reset_password_code = ResetPasswordCode.create(
|
||||
user_id=user.id, code=random_string(60)
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}"
|
||||
|
||||
email_utils.send_reset_password_email(user.email, reset_password_link)
|
||||
|
||||
|
||||
def send_change_email_confirmation(user: User, email_change: EmailChange):
|
||||
"""
|
||||
send confirmation email to the new email address
|
||||
"""
|
||||
|
||||
link = f"{URL}/auth/change_email?code={email_change.code}"
|
||||
|
||||
email_utils.send_change_email(email_change.new_email, user.email, link)
|
||||
|
||||
|
||||
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
|
||||
@limiter.limit("5/hour")
|
||||
@login_required
|
||||
@sudo_required
|
||||
def resend_email_change():
|
||||
form = CSRFValidationForm()
|
||||
if not form.validate():
|
||||
flash("Invalid request. Please try again", "warning")
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
email_change = EmailChange.get_by(user_id=current_user.id)
|
||||
if email_change:
|
||||
# extend email change expiration
|
||||
email_change.expired = arrow.now().shift(hours=12)
|
||||
Session.commit()
|
||||
|
||||
send_change_email_confirmation(current_user, email_change)
|
||||
flash("A confirmation email is on the way, please check your inbox", "success")
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
else:
|
||||
flash(
|
||||
"You have no pending email change. Redirect back to Setting page", "warning"
|
||||
)
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
|
||||
|
||||
@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@sudo_required
|
||||
def cancel_email_change():
|
||||
form = CSRFValidationForm()
|
||||
if not form.validate():
|
||||
flash("Invalid request. Please try again", "warning")
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
email_change = EmailChange.get_by(user_id=current_user.id)
|
||||
if email_change:
|
||||
EmailChange.delete(email_change.id)
|
||||
Session.commit()
|
||||
flash("Your email change is cancelled", "success")
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
else:
|
||||
flash(
|
||||
"You have no pending email change. Redirect back to Setting page", "warning"
|
||||
)
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
|
||||
|
||||
@dashboard_bp.route("/unlink_proton_account", methods=["POST"])
|
||||
@login_required
|
||||
@sudo_required
|
||||
def unlink_proton_account():
|
||||
csrf_form = CSRFValidationForm()
|
||||
if not csrf_form.validate():
|
||||
flash("Invalid request", "warning")
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
|
||||
perform_proton_account_unlink(current_user)
|
||||
flash("Your Proton account has been unlinked", "success")
|
||||
return redirect(url_for("dashboard.setting"))
|
|
@ -1,17 +1,31 @@
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from operator import or_
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask import render_template, request, redirect, flash
|
||||
from flask import url_for
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from sqlalchemy import and_, func, case
|
||||
from wtforms import StringField, validators, ValidationError
|
||||
|
||||
from app.config import EMAIL_DOMAIN, PAGE_LIMIT
|
||||
# Need to import directly from config to allow modification from the tests
|
||||
from app import config, parallel_limiter
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.email_utils import parseaddr_unicode
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.email_utils import (
|
||||
generate_reply_email,
|
||||
parse_full_address,
|
||||
)
|
||||
from app.email_validation import is_valid_email
|
||||
from app.errors import (
|
||||
CannotCreateContactForReverseAlias,
|
||||
ErrContactErrorUpgradeNeeded,
|
||||
ErrAddressInvalid,
|
||||
ErrContactAlreadyExists,
|
||||
)
|
||||
from app.log import LOG
|
||||
from app.models import Alias, Contact
|
||||
from app.utils import random_string
|
||||
from app.models import Alias, Contact, EmailLog, User
|
||||
from app.utils import sanitize_email, CSRFValidationForm
|
||||
|
||||
|
||||
def email_validator():
|
||||
|
@ -31,26 +45,193 @@ def email_validator():
|
|||
if email.find("<") + 1 < email.find(">"):
|
||||
email_part = email[email.find("<") + 1 : email.find(">")].strip()
|
||||
|
||||
if re.match(r"^[A-Za-z0-9\.\+_-]+@[A-Za-z0-9\._-]+\.[a-zA-Z]*$", email_part):
|
||||
return
|
||||
|
||||
if not is_valid_email(email_part):
|
||||
raise ValidationError(message)
|
||||
|
||||
return _check
|
||||
|
||||
|
||||
def create_contact(user: User, alias: Alias, contact_address: str) -> Contact:
|
||||
"""
|
||||
Create a contact for a user. Can be restricted for new free users by enabling DISABLE_CREATE_CONTACTS_FOR_FREE_USERS.
|
||||
Can throw exceptions:
|
||||
- ErrAddressInvalid
|
||||
- ErrContactAlreadyExists
|
||||
- ErrContactUpgradeNeeded - If DISABLE_CREATE_CONTACTS_FOR_FREE_USERS this exception will be raised for new free users
|
||||
"""
|
||||
if not contact_address:
|
||||
raise ErrAddressInvalid("Empty address")
|
||||
try:
|
||||
contact_name, contact_email = parse_full_address(contact_address)
|
||||
except ValueError:
|
||||
raise ErrAddressInvalid(contact_address)
|
||||
|
||||
contact_email = sanitize_email(contact_email)
|
||||
if not is_valid_email(contact_email):
|
||||
raise ErrAddressInvalid(contact_email)
|
||||
|
||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
||||
if contact:
|
||||
raise ErrContactAlreadyExists(contact)
|
||||
|
||||
if not user.can_create_contacts():
|
||||
raise ErrContactErrorUpgradeNeeded()
|
||||
|
||||
contact = Contact.create(
|
||||
user_id=alias.user_id,
|
||||
alias_id=alias.id,
|
||||
website_email=contact_email,
|
||||
name=contact_name,
|
||||
reply_email=generate_reply_email(contact_email, alias),
|
||||
)
|
||||
|
||||
LOG.d(
|
||||
"create reverse-alias for %s %s, reverse alias:%s",
|
||||
contact_address,
|
||||
alias,
|
||||
contact.reply_email,
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
return contact
|
||||
|
||||
|
||||
class NewContactForm(FlaskForm):
|
||||
email = StringField(
|
||||
"Email", validators=[validators.DataRequired(), email_validator()]
|
||||
)
|
||||
|
||||
|
||||
@dashboard_bp.route("/alias_contact_manager/<alias_id>/", methods=["GET", "POST"])
|
||||
@dataclass
|
||||
class ContactInfo(object):
|
||||
contact: Contact
|
||||
|
||||
nb_forward: int
|
||||
nb_reply: int
|
||||
|
||||
latest_email_log: EmailLog
|
||||
|
||||
|
||||
def get_contact_infos(
|
||||
alias: Alias, page=0, contact_id=None, query: str = ""
|
||||
) -> [ContactInfo]:
|
||||
"""if contact_id is set, only return the contact info for this contact"""
|
||||
sub = (
|
||||
Session.query(
|
||||
Contact.id,
|
||||
func.sum(case([(EmailLog.is_reply, 1)], else_=0)).label("nb_reply"),
|
||||
func.sum(
|
||||
case(
|
||||
[
|
||||
(
|
||||
and_(
|
||||
EmailLog.is_reply.is_(False),
|
||||
EmailLog.blocked.is_(False),
|
||||
),
|
||||
1,
|
||||
)
|
||||
],
|
||||
else_=0,
|
||||
)
|
||||
).label("nb_forward"),
|
||||
func.max(EmailLog.created_at).label("max_email_log_created_at"),
|
||||
)
|
||||
.join(
|
||||
EmailLog,
|
||||
EmailLog.contact_id == Contact.id,
|
||||
isouter=True,
|
||||
)
|
||||
.filter(Contact.alias_id == alias.id)
|
||||
.group_by(Contact.id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
q = (
|
||||
Session.query(
|
||||
Contact,
|
||||
EmailLog,
|
||||
sub.c.nb_reply,
|
||||
sub.c.nb_forward,
|
||||
)
|
||||
.join(
|
||||
EmailLog,
|
||||
EmailLog.contact_id == Contact.id,
|
||||
isouter=True,
|
||||
)
|
||||
.filter(Contact.alias_id == alias.id)
|
||||
.filter(Contact.id == sub.c.id)
|
||||
.filter(
|
||||
or_(
|
||||
EmailLog.created_at == sub.c.max_email_log_created_at,
|
||||
# no email log yet for this contact
|
||||
sub.c.max_email_log_created_at.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if query:
|
||||
q = q.filter(
|
||||
or_(
|
||||
Contact.website_email.ilike(f"%{query}%"),
|
||||
Contact.name.ilike(f"%{query}%"),
|
||||
)
|
||||
)
|
||||
|
||||
if contact_id:
|
||||
q = q.filter(Contact.id == contact_id)
|
||||
|
||||
latest_activity = case(
|
||||
[
|
||||
(EmailLog.created_at > Contact.created_at, EmailLog.created_at),
|
||||
(EmailLog.created_at < Contact.created_at, Contact.created_at),
|
||||
],
|
||||
else_=Contact.created_at,
|
||||
)
|
||||
q = (
|
||||
q.order_by(latest_activity.desc())
|
||||
.limit(config.PAGE_LIMIT)
|
||||
.offset(page * config.PAGE_LIMIT)
|
||||
)
|
||||
|
||||
ret = []
|
||||
for contact, latest_email_log, nb_reply, nb_forward in q:
|
||||
contact_info = ContactInfo(
|
||||
contact=contact,
|
||||
nb_forward=nb_forward,
|
||||
nb_reply=nb_reply,
|
||||
latest_email_log=latest_email_log,
|
||||
)
|
||||
ret.append(contact_info)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def delete_contact(alias: Alias, contact_id: int):
|
||||
contact = Contact.get(contact_id)
|
||||
|
||||
if not contact:
|
||||
flash("Unknown error. Refresh the page", "warning")
|
||||
elif contact.alias_id != alias.id:
|
||||
flash("You cannot delete reverse-alias", "warning")
|
||||
else:
|
||||
delete_contact_email = contact.website_email
|
||||
Contact.delete(contact_id)
|
||||
Session.commit()
|
||||
|
||||
flash(f"Reverse-alias for {delete_contact_email} has been deleted", "success")
|
||||
|
||||
|
||||
@dashboard_bp.route("/alias_contact_manager/<int:alias_id>/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@parallel_limiter.lock(name="contact_creation")
|
||||
def alias_contact_manager(alias_id):
|
||||
highlight_contact_id = None
|
||||
if request.args.get("highlight_contact_id"):
|
||||
try:
|
||||
highlight_contact_id = int(request.args.get("highlight_contact_id"))
|
||||
except ValueError:
|
||||
flash("Invalid contact id", "error")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
alias = Alias.get(alias_id)
|
||||
|
||||
|
@ -58,6 +239,8 @@ def alias_contact_manager(alias_id):
|
|||
if request.args.get("page"):
|
||||
page = int(request.args.get("page"))
|
||||
|
||||
query = request.args.get("query") or ""
|
||||
|
||||
# sanity check
|
||||
if not alias:
|
||||
flash("You do not have access to this page", "warning")
|
||||
|
@ -68,53 +251,26 @@ def alias_contact_manager(alias_id):
|
|||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
new_contact_form = NewContactForm()
|
||||
csrf_form = CSRFValidationForm()
|
||||
|
||||
if request.method == "POST":
|
||||
if not csrf_form.validate():
|
||||
flash("Invalid request", "warning")
|
||||
return redirect(request.url)
|
||||
if request.form.get("form-name") == "create":
|
||||
if new_contact_form.validate():
|
||||
contact_addr = new_contact_form.email.data.strip().lower()
|
||||
|
||||
# generate a reply_email, make sure it is unique
|
||||
# not use while to avoid infinite loop
|
||||
reply_email = f"ra+{random_string(25)}@{EMAIL_DOMAIN}"
|
||||
for _ in range(1000):
|
||||
reply_email = f"ra+{random_string(25)}@{EMAIL_DOMAIN}"
|
||||
if not Contact.get_by(reply_email=reply_email):
|
||||
break
|
||||
|
||||
contact_address = new_contact_form.email.data.strip()
|
||||
try:
|
||||
contact_name, contact_email = parseaddr_unicode(contact_addr)
|
||||
except Exception:
|
||||
flash(f"{contact_addr} is invalid", "error")
|
||||
return redirect(
|
||||
url_for("dashboard.alias_contact_manager", alias_id=alias_id,)
|
||||
)
|
||||
contact_email = contact_email.lower()
|
||||
|
||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
||||
# already been added
|
||||
if contact:
|
||||
flash(f"{contact_email} is already added", "error")
|
||||
return redirect(
|
||||
url_for(
|
||||
"dashboard.alias_contact_manager",
|
||||
alias_id=alias_id,
|
||||
highlight_contact_id=contact.id,
|
||||
)
|
||||
)
|
||||
|
||||
contact = Contact.create(
|
||||
user_id=alias.user_id,
|
||||
alias_id=alias.id,
|
||||
website_email=contact_email,
|
||||
name=contact_name,
|
||||
reply_email=reply_email,
|
||||
)
|
||||
|
||||
LOG.d("create reverse-alias for %s", contact_addr)
|
||||
db.session.commit()
|
||||
flash(f"Reverse alias for {contact_addr} is created", "success")
|
||||
|
||||
contact = create_contact(current_user, alias, contact_address)
|
||||
except (
|
||||
ErrContactErrorUpgradeNeeded,
|
||||
ErrAddressInvalid,
|
||||
ErrContactAlreadyExists,
|
||||
CannotCreateContactForReverseAlias,
|
||||
) as excp:
|
||||
flash(excp.error_for_user(), "error")
|
||||
return redirect(request.url)
|
||||
flash(f"Reverse alias for {contact_address} is created", "success")
|
||||
return redirect(
|
||||
url_for(
|
||||
"dashboard.alias_contact_manager",
|
||||
|
@ -124,48 +280,45 @@ def alias_contact_manager(alias_id):
|
|||
)
|
||||
elif request.form.get("form-name") == "delete":
|
||||
contact_id = request.form.get("contact-id")
|
||||
contact = Contact.get(contact_id)
|
||||
|
||||
if not contact:
|
||||
flash("Unknown error. Refresh the page", "warning")
|
||||
return redirect(
|
||||
url_for("dashboard.alias_contact_manager", alias_id=alias_id)
|
||||
)
|
||||
elif contact.alias_id != alias.id:
|
||||
flash("You cannot delete reverse-alias", "warning")
|
||||
delete_contact(alias, contact_id)
|
||||
return redirect(
|
||||
url_for("dashboard.alias_contact_manager", alias_id=alias_id)
|
||||
)
|
||||
|
||||
delete_contact_email = contact.website_email
|
||||
Contact.delete(contact_id)
|
||||
db.session.commit()
|
||||
|
||||
flash(
|
||||
f"Reverse-alias for {delete_contact_email} has been deleted", "success"
|
||||
)
|
||||
|
||||
elif request.form.get("form-name") == "search":
|
||||
query = request.form.get("query")
|
||||
return redirect(
|
||||
url_for("dashboard.alias_contact_manager", alias_id=alias_id)
|
||||
url_for(
|
||||
"dashboard.alias_contact_manager",
|
||||
alias_id=alias_id,
|
||||
query=query,
|
||||
highlight_contact_id=highlight_contact_id,
|
||||
)
|
||||
)
|
||||
|
||||
contact_infos = get_contact_infos(alias, page, query=query)
|
||||
last_page = len(contact_infos) < config.PAGE_LIMIT
|
||||
nb_contact = Contact.filter(Contact.alias_id == alias.id).count()
|
||||
|
||||
# if highlighted contact isn't included, fetch it
|
||||
# make sure highlighted contact is at array start
|
||||
contacts = alias.get_contacts(page)
|
||||
contact_ids = [contact.id for contact in contacts]
|
||||
|
||||
last_page = len(contacts) < PAGE_LIMIT
|
||||
|
||||
if highlight_contact_id not in contact_ids:
|
||||
contact = Contact.get(highlight_contact_id)
|
||||
if contact and contact.alias_id == alias.id:
|
||||
contacts.insert(0, contact)
|
||||
contact_ids = [contact_info.contact.id for contact_info in contact_infos]
|
||||
if highlight_contact_id and highlight_contact_id not in contact_ids:
|
||||
contact_infos = (
|
||||
get_contact_infos(alias, contact_id=highlight_contact_id, query=query)
|
||||
+ contact_infos
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"dashboard/alias_contact_manager.html",
|
||||
contacts=contacts,
|
||||
contact_infos=contact_infos,
|
||||
alias=alias,
|
||||
new_contact_form=new_contact_form,
|
||||
highlight_contact_id=highlight_contact_id,
|
||||
page=page,
|
||||
last_page=last_page,
|
||||
query=query,
|
||||
nb_contact=nb_contact,
|
||||
can_create_contacts=current_user.can_create_contacts(),
|
||||
csrf_form=csrf_form,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
from app.dashboard.base import dashboard_bp
|
||||
from flask_login import login_required, current_user
|
||||
from app.alias_utils import alias_export_csv
|
||||
from app.dashboard.views.enter_sudo import sudo_required
|
||||
from app.extensions import limiter
|
||||
|
||||
|
||||
@dashboard_bp.route("/alias_export", methods=["GET"])
|
||||
@login_required
|
||||
@sudo_required
|
||||
@limiter.limit("2/minute")
|
||||
def alias_export_route():
|
||||
return alias_export_csv(current_user)
|
|
@ -4,7 +4,7 @@ from flask_login import login_required, current_user
|
|||
|
||||
from app.config import PAGE_LIMIT
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.models import Alias, EmailLog, Contact
|
||||
|
||||
|
||||
|
@ -17,6 +17,7 @@ class AliasLog:
|
|||
blocked: bool
|
||||
bounced: bool
|
||||
email_log: EmailLog
|
||||
contact: Contact
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
|
@ -42,18 +43,18 @@ def alias_log(alias_id, page_id):
|
|||
|
||||
logs = get_alias_log(alias, page_id)
|
||||
base = (
|
||||
db.session.query(Contact, EmailLog)
|
||||
Session.query(Contact, EmailLog)
|
||||
.filter(Contact.id == EmailLog.contact_id)
|
||||
.filter(Contact.alias_id == alias.id)
|
||||
)
|
||||
total = base.count()
|
||||
email_forwarded = (
|
||||
base.filter(EmailLog.is_reply == False)
|
||||
.filter(EmailLog.blocked == False)
|
||||
base.filter(EmailLog.is_reply.is_(False))
|
||||
.filter(EmailLog.blocked.is_(False))
|
||||
.count()
|
||||
)
|
||||
email_replied = base.filter(EmailLog.is_reply == True).count()
|
||||
email_blocked = base.filter(EmailLog.blocked == True).count()
|
||||
email_replied = base.filter(EmailLog.is_reply.is_(True)).count()
|
||||
email_blocked = base.filter(EmailLog.blocked.is_(True)).count()
|
||||
last_page = (
|
||||
len(logs) < PAGE_LIMIT
|
||||
) # lightweight pagination without counting all objects
|
||||
|
@ -65,7 +66,7 @@ def get_alias_log(alias: Alias, page_id=0) -> [AliasLog]:
|
|||
logs: [AliasLog] = []
|
||||
|
||||
q = (
|
||||
db.session.query(Contact, EmailLog)
|
||||
Session.query(Contact, EmailLog)
|
||||
.filter(Contact.id == EmailLog.contact_id)
|
||||
.filter(Contact.alias_id == alias.id)
|
||||
.order_by(EmailLog.id.desc())
|
||||
|
@ -83,8 +84,9 @@ def get_alias_log(alias: Alias, page_id=0) -> [AliasLog]:
|
|||
blocked=email_log.blocked,
|
||||
bounced=email_log.bounced,
|
||||
email_log=email_log,
|
||||
contact=contact,
|
||||
)
|
||||
logs.append(al)
|
||||
logs = sorted(logs, key=lambda l: l.when, reverse=True)
|
||||
logs = sorted(logs, key=lambda log: log.when, reverse=True)
|
||||
|
||||
return logs
|
||||
|
|
|
@ -0,0 +1,171 @@
|
|||
import base64
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
import arrow
|
||||
from flask import render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import config
|
||||
from app.alias_utils import transfer_alias
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.dashboard.views.enter_sudo import sudo_required
|
||||
from app.db import Session
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
Alias,
|
||||
)
|
||||
from app.models import Mailbox
|
||||
from app.utils import CSRFValidationForm
|
||||
|
||||
|
||||
def hmac_alias_transfer_token(transfer_token: str) -> str:
|
||||
alias_hmac = hmac.new(
|
||||
config.ALIAS_TRANSFER_TOKEN_SECRET.encode("utf-8"),
|
||||
transfer_token.encode("utf-8"),
|
||||
"sha3_224",
|
||||
)
|
||||
return base64.urlsafe_b64encode(alias_hmac.digest()).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
@dashboard_bp.route("/alias_transfer/send/<int:alias_id>/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@sudo_required
|
||||
def alias_transfer_send_route(alias_id):
|
||||
alias = Alias.get(alias_id)
|
||||
if not alias or alias.user_id != current_user.id:
|
||||
flash("You cannot see this page", "warning")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
if current_user.newsletter_alias_id == alias.id:
|
||||
flash(
|
||||
"This alias is currently used for receiving the newsletter and cannot be transferred",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
alias_transfer_url = None
|
||||
csrf_form = CSRFValidationForm()
|
||||
|
||||
if request.method == "POST":
|
||||
if not csrf_form.validate():
|
||||
flash("Invalid request", "warning")
|
||||
return redirect(request.url)
|
||||
# generate a new transfer_token
|
||||
if request.form.get("form-name") == "create":
|
||||
transfer_token = f"{alias.id}.{secrets.token_urlsafe(32)}"
|
||||
alias.transfer_token = hmac_alias_transfer_token(transfer_token)
|
||||
alias.transfer_token_expiration = arrow.utcnow().shift(hours=24)
|
||||
Session.commit()
|
||||
alias_transfer_url = (
|
||||
config.URL
|
||||
+ "/dashboard/alias_transfer/receive"
|
||||
+ f"?token={transfer_token}"
|
||||
)
|
||||
flash("Share alias URL created", "success")
|
||||
# request.form.get("form-name") == "remove"
|
||||
else:
|
||||
alias.transfer_token = None
|
||||
alias.transfer_token_expiration = None
|
||||
Session.commit()
|
||||
alias_transfer_url = None
|
||||
flash("Share URL deleted", "success")
|
||||
|
||||
return render_template(
|
||||
"dashboard/alias_transfer_send.html",
|
||||
alias=alias,
|
||||
alias_transfer_url=alias_transfer_url,
|
||||
link_active=alias.transfer_token_expiration is not None
|
||||
and alias.transfer_token_expiration > arrow.utcnow(),
|
||||
csrf_form=csrf_form,
|
||||
)
|
||||
|
||||
|
||||
@dashboard_bp.route("/alias_transfer/receive", methods=["GET", "POST"])
|
||||
@limiter.limit("5/minute")
|
||||
@login_required
|
||||
def alias_transfer_receive_route():
|
||||
"""
|
||||
URL has ?alias_id=signed_alias_id
|
||||
"""
|
||||
token = request.args.get("token")
|
||||
if not token:
|
||||
flash("Invalid transfer token", "error")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
hashed_token = hmac_alias_transfer_token(token)
|
||||
# TODO: Don't allow unhashed tokens once all the tokens have been migrated to the new format
|
||||
alias = Alias.get_by(transfer_token=token) or Alias.get_by(
|
||||
transfer_token=hashed_token
|
||||
)
|
||||
|
||||
if not alias:
|
||||
flash("Invalid link", "error")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
# TODO: Don't allow none once all the tokens have been migrated to the new format
|
||||
if (
|
||||
alias.transfer_token_expiration is not None
|
||||
and alias.transfer_token_expiration < arrow.utcnow()
|
||||
):
|
||||
flash("Expired link, please request a new one", "error")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
# alias already belongs to this user
|
||||
if alias.user_id == current_user.id:
|
||||
flash("You already own this alias", "warning")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
# check if user has not exceeded the alias quota
|
||||
if not current_user.can_create_new_alias():
|
||||
LOG.d("%s can't receive new alias", current_user)
|
||||
flash(
|
||||
"You have reached free plan limit, please upgrade to create new aliases",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
mailboxes = current_user.mailboxes()
|
||||
|
||||
if request.method == "POST":
|
||||
mailbox_ids = request.form.getlist("mailbox_ids")
|
||||
# check if mailbox is not tempered with
|
||||
mailboxes = []
|
||||
for mailbox_id in mailbox_ids:
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
if (
|
||||
not mailbox
|
||||
or mailbox.user_id != current_user.id
|
||||
or not mailbox.verified
|
||||
):
|
||||
flash("Something went wrong, please retry", "warning")
|
||||
return redirect(request.url)
|
||||
mailboxes.append(mailbox)
|
||||
|
||||
if not mailboxes:
|
||||
flash("You must select at least 1 mailbox", "warning")
|
||||
return redirect(request.url)
|
||||
|
||||
LOG.d(
|
||||
"transfer alias %s from %s to %s with %s with token %s",
|
||||
alias,
|
||||
alias.user,
|
||||
current_user,
|
||||
mailboxes,
|
||||
token,
|
||||
)
|
||||
transfer_alias(alias, current_user, mailboxes)
|
||||
|
||||
# reset transfer token
|
||||
alias.transfer_token = None
|
||||
alias.transfer_token_expiration = None
|
||||
Session.commit()
|
||||
|
||||
flash(f"You are now owner of {alias.email}", "success")
|
||||
return redirect(url_for("dashboard.index", highlight_alias_id=alias.id))
|
||||
|
||||
return render_template(
|
||||
"dashboard/alias_transfer_receive.html",
|
||||
alias=alias,
|
||||
mailboxes=mailboxes,
|
||||
)
|
|
@ -3,27 +3,61 @@ from flask_login import login_required, current_user
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app import config
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.extensions import db
|
||||
from app.dashboard.views.enter_sudo import sudo_required
|
||||
from app.db import Session
|
||||
from app.extensions import limiter
|
||||
from app.models import ApiKey
|
||||
from app.utils import CSRFValidationForm
|
||||
|
||||
|
||||
class NewApiKeyForm(FlaskForm):
|
||||
name = StringField("Name", validators=[validators.DataRequired()])
|
||||
|
||||
|
||||
def clean_up_unused_or_old_api_keys(user_id: int):
|
||||
total_keys = ApiKey.filter_by(user_id=user_id).count()
|
||||
if total_keys <= config.MAX_API_KEYS:
|
||||
return
|
||||
# Remove oldest unused
|
||||
for api_key in (
|
||||
ApiKey.filter_by(user_id=user_id, last_used=None)
|
||||
.order_by(ApiKey.created_at.asc())
|
||||
.all()
|
||||
):
|
||||
Session.delete(api_key)
|
||||
total_keys -= 1
|
||||
if total_keys <= config.MAX_API_KEYS:
|
||||
return
|
||||
# Clean up oldest used
|
||||
for api_key in (
|
||||
ApiKey.filter_by(user_id=user_id).order_by(ApiKey.last_used.asc()).all()
|
||||
):
|
||||
Session.delete(api_key)
|
||||
total_keys -= 1
|
||||
if total_keys <= config.MAX_API_KEYS:
|
||||
return
|
||||
|
||||
|
||||
@dashboard_bp.route("/api_key", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@sudo_required
|
||||
@limiter.limit("10/hour")
|
||||
def api_key():
|
||||
api_keys = (
|
||||
ApiKey.query.filter(ApiKey.user_id == current_user.id)
|
||||
ApiKey.filter(ApiKey.user_id == current_user.id)
|
||||
.order_by(ApiKey.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
csrf_form = CSRFValidationForm()
|
||||
new_api_key_form = NewApiKeyForm()
|
||||
|
||||
if request.method == "POST":
|
||||
if not csrf_form.validate():
|
||||
flash("Invalid request", "warning")
|
||||
return redirect(request.url)
|
||||
if request.form.get("form-name") == "delete":
|
||||
api_key_id = request.form.get("api-key-id")
|
||||
|
||||
|
@ -38,23 +72,31 @@ def api_key():
|
|||
|
||||
name = api_key.name
|
||||
ApiKey.delete(api_key_id)
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
flash(f"API Key {name} has been deleted", "success")
|
||||
|
||||
return redirect(url_for("dashboard.api_key"))
|
||||
|
||||
elif request.form.get("form-name") == "create":
|
||||
if new_api_key_form.validate():
|
||||
clean_up_unused_or_old_api_keys(current_user.id)
|
||||
new_api_key = ApiKey.create(
|
||||
name=new_api_key_form.name.data, user_id=current_user.id
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
Session.commit()
|
||||
flash(f"New API Key {new_api_key.name} has been created", "success")
|
||||
return redirect(url_for("dashboard.api_key"))
|
||||
return render_template(
|
||||
"dashboard/new_api_key.html", api_key=new_api_key
|
||||
)
|
||||
|
||||
elif request.form.get("form-name") == "delete-all":
|
||||
ApiKey.delete_all(current_user.id)
|
||||
Session.commit()
|
||||
flash("All API Keys have been deleted", "success")
|
||||
|
||||
return redirect(url_for("dashboard.api_key"))
|
||||
|
||||
return render_template(
|
||||
"dashboard/api_key.html", api_keys=api_keys, new_api_key_form=new_api_key_form
|
||||
"dashboard/api_key.html",
|
||||
api_keys=api_keys,
|
||||
new_api_key_form=new_api_key_form,
|
||||
csrf_form=csrf_form,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
from flask import render_template, request, flash, redirect
|
||||
from flask_login import login_required, current_user
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.db import Session
|
||||
from app.models import (
|
||||
ClientUser,
|
||||
)
|
||||
|
||||
|
||||
@dashboard_bp.route("/app", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def app_route():
|
||||
"""
|
||||
List of apps that user has used via the "Sign in with SimpleLogin"
|
||||
"""
|
||||
|
||||
client_users = (
|
||||
ClientUser.filter_by(user_id=current_user.id)
|
||||
.options(joinedload(ClientUser.client))
|
||||
.options(joinedload(ClientUser.alias))
|
||||
.all()
|
||||
)
|
||||
|
||||
sorted(client_users, key=lambda cu: cu.client.name)
|
||||
|
||||
if request.method == "POST":
|
||||
client_user_id = request.form.get("client-user-id")
|
||||
client_user = ClientUser.get(client_user_id)
|
||||
if not client_user or client_user.user_id != current_user.id:
|
||||
flash(
|
||||
"Unknown error, sorry for the inconvenience, refresh the page", "error"
|
||||
)
|
||||
return redirect(request.url)
|
||||
|
||||
client = client_user.client
|
||||
ClientUser.delete(client_user_id)
|
||||
Session.commit()
|
||||
|
||||
flash(f"Link with {client.name} has been removed", "success")
|
||||
return redirect(request.url)
|
||||
|
||||
return render_template(
|
||||
"dashboard/app.html",
|
||||
client_users=client_users,
|
||||
)
|
|
@ -0,0 +1,82 @@
|
|||
import arrow
|
||||
from flask import render_template, flash, request, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import s3
|
||||
from app.config import JOB_BATCH_IMPORT
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.dashboard.views.enter_sudo import sudo_required
|
||||
from app.db import Session
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import File, BatchImport, Job
|
||||
from app.utils import random_string, CSRFValidationForm
|
||||
|
||||
|
||||
@dashboard_bp.route("/batch_import", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@sudo_required
|
||||
@limiter.limit("10/minute", methods=["POST"])
|
||||
def batch_import_route():
|
||||
# only for users who have custom domains
|
||||
if not current_user.verified_custom_domains():
|
||||
flash("Alias batch import is only available for custom domains", "warning")
|
||||
|
||||
if current_user.disable_import:
|
||||
flash(
|
||||
"you cannot use the import feature, please contact SimpleLogin team",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
batch_imports = BatchImport.filter_by(
|
||||
user_id=current_user.id, processed=False
|
||||
).all()
|
||||
|
||||
csrf_form = CSRFValidationForm()
|
||||
|
||||
if request.method == "POST":
|
||||
if not csrf_form.validate():
|
||||
flash("Invalid request", "warning")
|
||||
return redirect(request.url)
|
||||
if len(batch_imports) > 10:
|
||||
flash(
|
||||
"You have too many imports already. Please wait until some get cleaned up",
|
||||
"error",
|
||||
)
|
||||
return render_template(
|
||||
"dashboard/batch_import.html",
|
||||
batch_imports=batch_imports,
|
||||
csrf_form=csrf_form,
|
||||
)
|
||||
|
||||
alias_file = request.files["alias-file"]
|
||||
|
||||
file_path = random_string(20) + ".csv"
|
||||
file = File.create(user_id=current_user.id, path=file_path)
|
||||
s3.upload_from_bytesio(file_path, alias_file)
|
||||
Session.flush()
|
||||
LOG.d("upload file %s to s3 at %s", file, file_path)
|
||||
|
||||
bi = BatchImport.create(user_id=current_user.id, file_id=file.id)
|
||||
Session.flush()
|
||||
LOG.d("Add a batch import job %s for %s", bi, current_user)
|
||||
|
||||
# Schedule batch import job
|
||||
Job.create(
|
||||
name=JOB_BATCH_IMPORT,
|
||||
payload={"batch_import_id": bi.id},
|
||||
run_at=arrow.now(),
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
flash(
|
||||
"The file has been uploaded successfully and the import will start shortly",
|
||||
"success",
|
||||
)
|
||||
|
||||
return redirect(url_for("dashboard.batch_import_route"))
|
||||
|
||||
return render_template(
|
||||
"dashboard/batch_import.html", batch_imports=batch_imports, csrf_form=csrf_form
|
||||
)
|
|
@ -3,7 +3,7 @@ from flask_login import login_required, current_user
|
|||
|
||||
from app.config import PADDLE_MONTHLY_PRODUCT_ID, PADDLE_YEARLY_PRODUCT_ID
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import Subscription, PlanEnum
|
||||
from app.paddle_utils import cancel_subscription, change_plan
|
||||
|
@ -13,7 +13,7 @@ from app.paddle_utils import cancel_subscription, change_plan
|
|||
@login_required
|
||||
def billing():
|
||||
# sanity check: make sure this page is only for user who has paddle subscription
|
||||
sub: Subscription = current_user.get_subscription()
|
||||
sub: Subscription = current_user.get_paddle_subscription()
|
||||
|
||||
if not sub:
|
||||
flash("You don't have any active subscription", "warning")
|
||||
|
@ -21,12 +21,12 @@ def billing():
|
|||
|
||||
if request.method == "POST":
|
||||
if request.form.get("form-name") == "cancel":
|
||||
LOG.warning(f"User {current_user} cancels their subscription")
|
||||
LOG.w(f"User {current_user} cancels their subscription")
|
||||
success = cancel_subscription(sub.subscription_id)
|
||||
|
||||
if success:
|
||||
sub.cancelled = True
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
flash("Your subscription has been canceled successfully", "success")
|
||||
else:
|
||||
flash(
|
||||
|
@ -37,13 +37,18 @@ def billing():
|
|||
|
||||
return redirect(url_for("dashboard.billing"))
|
||||
elif request.form.get("form-name") == "change-monthly":
|
||||
LOG.debug(f"User {current_user} changes to monthly plan")
|
||||
success = change_plan(sub.subscription_id, PADDLE_MONTHLY_PRODUCT_ID)
|
||||
LOG.d(f"User {current_user} changes to monthly plan")
|
||||
success, msg = change_plan(
|
||||
current_user, sub.subscription_id, PADDLE_MONTHLY_PRODUCT_ID
|
||||
)
|
||||
|
||||
if success:
|
||||
sub.plan = PlanEnum.monthly
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
flash("Your subscription has been updated", "success")
|
||||
else:
|
||||
if msg:
|
||||
flash(msg, "error")
|
||||
else:
|
||||
flash(
|
||||
"Something went wrong, sorry for the inconvenience. Please retry. "
|
||||
|
@ -53,13 +58,18 @@ def billing():
|
|||
|
||||
return redirect(url_for("dashboard.billing"))
|
||||
elif request.form.get("form-name") == "change-yearly":
|
||||
LOG.debug(f"User {current_user} changes to yearly plan")
|
||||
success = change_plan(sub.subscription_id, PADDLE_YEARLY_PRODUCT_ID)
|
||||
LOG.d(f"User {current_user} changes to yearly plan")
|
||||
success, msg = change_plan(
|
||||
current_user, sub.subscription_id, PADDLE_YEARLY_PRODUCT_ID
|
||||
)
|
||||
|
||||
if success:
|
||||
sub.plan = PlanEnum.yearly
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
flash("Your subscription has been updated", "success")
|
||||
else:
|
||||
if msg:
|
||||
flash(msg, "error")
|
||||
else:
|
||||
flash(
|
||||
"Something went wrong, sorry for the inconvenience. Please retry. "
|
||||
|
|
|
@ -1,10 +1,20 @@
|
|||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.models import Contact
|
||||
from app.pgp_utils import PGPException, load_public_key
|
||||
from app.pgp_utils import PGPException, load_public_key_and_check
|
||||
|
||||
|
||||
class PGPContactForm(FlaskForm):
|
||||
action = StringField(
|
||||
"action",
|
||||
validators=[validators.DataRequired(), validators.AnyOf(("save", "remove"))],
|
||||
)
|
||||
pgp = StringField("pgp", validators=[validators.Optional()])
|
||||
|
||||
|
||||
@dashboard_bp.route("/contact/<int:contact_id>/", methods=["GET", "POST"])
|
||||
|
@ -16,40 +26,50 @@ def contact_detail_route(contact_id):
|
|||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
alias = contact.alias
|
||||
pgp_form = PGPContactForm()
|
||||
|
||||
if request.method == "POST":
|
||||
if request.form.get("form-name") == "pgp":
|
||||
if request.form.get("action") == "save":
|
||||
if not pgp_form.validate():
|
||||
flash("Invalid request", "warning")
|
||||
return redirect(request.url)
|
||||
if pgp_form.action.data == "save":
|
||||
if not current_user.is_premium():
|
||||
flash("Only premium plan can add PGP Key", "warning")
|
||||
return redirect(
|
||||
url_for("dashboard.contact_detail_route", contact_id=contact_id)
|
||||
)
|
||||
|
||||
contact.pgp_public_key = request.form.get("pgp")
|
||||
if not pgp_form.pgp.data:
|
||||
flash("Invalid pgp key")
|
||||
else:
|
||||
contact.pgp_public_key = pgp_form.pgp.data
|
||||
try:
|
||||
contact.pgp_finger_print = load_public_key(contact.pgp_public_key)
|
||||
contact.pgp_finger_print = load_public_key_and_check(
|
||||
contact.pgp_public_key
|
||||
)
|
||||
except PGPException:
|
||||
flash("Cannot add the public key, please verify it", "error")
|
||||
else:
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
flash(
|
||||
f"PGP public key for {contact.email} is saved successfully",
|
||||
"success",
|
||||
)
|
||||
return redirect(
|
||||
url_for("dashboard.contact_detail_route", contact_id=contact_id)
|
||||
url_for(
|
||||
"dashboard.contact_detail_route", contact_id=contact_id
|
||||
)
|
||||
elif request.form.get("action") == "remove":
|
||||
)
|
||||
elif pgp_form.action.data == "remove":
|
||||
# Free user can decide to remove contact PGP key
|
||||
contact.pgp_public_key = None
|
||||
contact.pgp_finger_print = None
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
flash(f"PGP public key for {contact.email} is removed", "success")
|
||||
return redirect(
|
||||
url_for("dashboard.contact_detail_route", contact_id=contact_id)
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"dashboard/contact_detail.html", contact=contact, alias=alias
|
||||
"dashboard/contact_detail.html", contact=contact, alias=alias, pgp_form=pgp_form
|
||||
)
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
import arrow
|
||||
from flask import render_template, flash, redirect, url_for, request
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app import parallel_limiter
|
||||
from app.config import PADDLE_VENDOR_ID, PADDLE_COUPON_ID
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
ManualSubscription,
|
||||
Coupon,
|
||||
Subscription,
|
||||
AppleSubscription,
|
||||
CoinbaseSubscription,
|
||||
LifetimeCoupon,
|
||||
)
|
||||
|
||||
|
||||
class CouponForm(FlaskForm):
|
||||
code = StringField("Coupon Code", validators=[validators.DataRequired()])
|
||||
|
||||
|
||||
@dashboard_bp.route("/coupon", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@parallel_limiter.lock()
|
||||
def coupon_route():
|
||||
coupon_form = CouponForm()
|
||||
|
||||
if coupon_form.validate_on_submit():
|
||||
code = coupon_form.code.data
|
||||
if LifetimeCoupon.get_by(code=code):
|
||||
LOG.d("redirect %s to lifetime page instead", current_user)
|
||||
flash("Redirect to the lifetime coupon page instead", "success")
|
||||
return redirect(url_for("dashboard.lifetime_licence"))
|
||||
|
||||
# handle case user already has an active subscription via another channel (Paddle, Apple, etc)
|
||||
can_use_coupon = True
|
||||
|
||||
if current_user.lifetime:
|
||||
can_use_coupon = False
|
||||
|
||||
sub: Subscription = current_user.get_paddle_subscription()
|
||||
if sub:
|
||||
can_use_coupon = False
|
||||
|
||||
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=current_user.id)
|
||||
if apple_sub and apple_sub.is_valid():
|
||||
can_use_coupon = False
|
||||
|
||||
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
|
||||
user_id=current_user.id
|
||||
)
|
||||
if coinbase_subscription and coinbase_subscription.is_active():
|
||||
can_use_coupon = False
|
||||
|
||||
if coupon_form.validate_on_submit():
|
||||
code = coupon_form.code.data
|
||||
|
||||
coupon: Coupon = Coupon.get_by(code=code)
|
||||
if coupon and not coupon.used:
|
||||
if coupon.expires_date and coupon.expires_date < arrow.now():
|
||||
flash(
|
||||
f"The coupon was expired on {coupon.expires_date.humanize()}",
|
||||
"error",
|
||||
)
|
||||
return redirect(request.url)
|
||||
|
||||
updated = (
|
||||
Session.query(Coupon)
|
||||
.filter_by(code=code, used=False)
|
||||
.update({"used_by_user_id": current_user.id, "used": True})
|
||||
)
|
||||
if updated != 1:
|
||||
flash("Coupon is not valid", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
manual_sub: ManualSubscription = ManualSubscription.get_by(
|
||||
user_id=current_user.id
|
||||
)
|
||||
if manual_sub:
|
||||
# renew existing subscription
|
||||
if manual_sub.end_at > arrow.now():
|
||||
manual_sub.end_at = manual_sub.end_at.shift(years=coupon.nb_year)
|
||||
else:
|
||||
manual_sub.end_at = arrow.now().shift(years=coupon.nb_year, days=1)
|
||||
Session.commit()
|
||||
flash(
|
||||
f"Your current subscription is extended to {manual_sub.end_at.humanize()}",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
ManualSubscription.create(
|
||||
user_id=current_user.id,
|
||||
end_at=arrow.now().shift(years=coupon.nb_year, days=1),
|
||||
comment="using coupon code",
|
||||
is_giveaway=coupon.is_giveaway,
|
||||
commit=True,
|
||||
)
|
||||
flash(
|
||||
"Your account has been upgraded to Premium, thanks for your support!",
|
||||
"success",
|
||||
)
|
||||
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
else:
|
||||
flash(f"Code *{code}* expired or invalid", "warning")
|
||||
|
||||
return render_template(
|
||||
"dashboard/coupon.html",
|
||||
coupon_form=coupon_form,
|
||||
PADDLE_VENDOR_ID=PADDLE_VENDOR_ID,
|
||||
PADDLE_COUPON_ID=PADDLE_COUPON_ID,
|
||||
can_use_coupon=can_use_coupon,
|
||||
# a coupon is only valid until this date
|
||||
# this is to avoid using the coupon to renew an account forever
|
||||
max_coupon_date=arrow.now().shift(years=1, days=-1),
|
||||
)
|
|
@ -1,57 +1,40 @@
|
|||
from email_validator import validate_email, EmailNotValidError
|
||||
from flask import render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from itsdangerous import TimestampSigner, SignatureExpired
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app import parallel_limiter
|
||||
from app.alias_suffix import (
|
||||
get_alias_suffixes,
|
||||
check_suffix_signature,
|
||||
verify_prefix_suffix,
|
||||
)
|
||||
from app.alias_utils import check_alias_prefix
|
||||
from app.config import (
|
||||
DISABLE_ALIAS_SUFFIX,
|
||||
ALIAS_DOMAINS,
|
||||
CUSTOM_ALIAS_SECRET,
|
||||
ALIAS_LIMIT,
|
||||
)
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.email_utils import email_belongs_to_alias_domains
|
||||
from app.extensions import db
|
||||
from app.db import Session
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
Alias,
|
||||
CustomDomain,
|
||||
DeletedAlias,
|
||||
Mailbox,
|
||||
User,
|
||||
AliasMailbox,
|
||||
DomainDeletedAlias,
|
||||
)
|
||||
from app.utils import convert_to_id, random_word, word_exist
|
||||
|
||||
signer = TimestampSigner(CUSTOM_ALIAS_SECRET)
|
||||
|
||||
|
||||
def available_suffixes(user: User) -> [bool, str, str]:
|
||||
"""Return (is_custom_domain, alias-suffix, time-signed alias-suffix)"""
|
||||
user_custom_domains = [cd.domain for cd in user.verified_custom_domains()]
|
||||
|
||||
# List of (is_custom_domain, alias-suffix, time-signed alias-suffix)
|
||||
suffixes = []
|
||||
|
||||
# put custom domain first
|
||||
for alias_domain in user_custom_domains:
|
||||
suffix = "@" + alias_domain
|
||||
suffixes.append((True, suffix, signer.sign(suffix).decode()))
|
||||
|
||||
# then default domain
|
||||
for domain in ALIAS_DOMAINS:
|
||||
suffix = ("" if DISABLE_ALIAS_SUFFIX else "." + random_word()) + "@" + domain
|
||||
suffixes.append((False, suffix, signer.sign(suffix).decode()))
|
||||
|
||||
return suffixes
|
||||
from app.utils import CSRFValidationForm
|
||||
|
||||
|
||||
@dashboard_bp.route("/custom_alias", methods=["GET", "POST"])
|
||||
@limiter.limit(ALIAS_LIMIT, methods=["POST"])
|
||||
@login_required
|
||||
@parallel_limiter.lock(name="alias_creation")
|
||||
def custom_alias():
|
||||
# check if user has not exceeded the alias quota
|
||||
if not current_user.can_create_new_alias():
|
||||
# notify admin
|
||||
LOG.error("user %s tries to create custom alias", current_user)
|
||||
LOG.d("%s can't create new alias", current_user)
|
||||
flash(
|
||||
"You have reached free plan limit, please upgrade to create new aliases",
|
||||
"warning",
|
||||
|
@ -59,17 +42,33 @@ def custom_alias():
|
|||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
user_custom_domains = [cd.domain for cd in current_user.verified_custom_domains()]
|
||||
# List of (is_custom_domain, alias-suffix, time-signed alias-suffix)
|
||||
suffixes = available_suffixes(current_user)
|
||||
alias_suffixes = get_alias_suffixes(current_user)
|
||||
at_least_a_premium_domain = False
|
||||
for alias_suffix in alias_suffixes:
|
||||
if not alias_suffix.is_custom and alias_suffix.is_premium:
|
||||
at_least_a_premium_domain = True
|
||||
break
|
||||
|
||||
csrf_form = CSRFValidationForm()
|
||||
mailboxes = current_user.mailboxes()
|
||||
|
||||
if request.method == "POST":
|
||||
alias_prefix = request.form.get("prefix").strip().lower()
|
||||
signed_suffix = request.form.get("suffix")
|
||||
if not csrf_form.validate():
|
||||
flash("Invalid request", "warning")
|
||||
return redirect(request.url)
|
||||
alias_prefix = request.form.get("prefix").strip().lower().replace(" ", "")
|
||||
signed_alias_suffix = request.form.get("signed-alias-suffix")
|
||||
mailbox_ids = request.form.getlist("mailboxes")
|
||||
alias_note = request.form.get("note")
|
||||
|
||||
if not check_alias_prefix(alias_prefix):
|
||||
flash(
|
||||
"Only lowercase letters, numbers, dashes (-), dots (.) and underscores (_) "
|
||||
"are currently supported for alias prefix. Cannot be more than 40 letters",
|
||||
"error",
|
||||
)
|
||||
return redirect(request.url)
|
||||
|
||||
# check if mailbox is not tempered with
|
||||
mailboxes = []
|
||||
for mailbox_id in mailbox_ids:
|
||||
|
@ -80,74 +79,83 @@ def custom_alias():
|
|||
or not mailbox.verified
|
||||
):
|
||||
flash("Something went wrong, please retry", "warning")
|
||||
return redirect(url_for("dashboard.custom_alias"))
|
||||
return redirect(request.url)
|
||||
mailboxes.append(mailbox)
|
||||
|
||||
if not mailboxes:
|
||||
flash("At least one mailbox must be selected", "error")
|
||||
return redirect(url_for("dashboard.custom_alias"))
|
||||
return redirect(request.url)
|
||||
|
||||
# hypothesis: user will click on the button in the 600 secs
|
||||
try:
|
||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||
except SignatureExpired:
|
||||
LOG.warning("Alias creation time expired for %s", current_user)
|
||||
suffix = check_suffix_signature(signed_alias_suffix)
|
||||
if not suffix:
|
||||
LOG.w("Alias creation time expired for %s", current_user)
|
||||
flash("Alias creation time is expired, please retry", "warning")
|
||||
return redirect(url_for("dashboard.custom_alias"))
|
||||
return redirect(request.url)
|
||||
except Exception:
|
||||
LOG.error("Alias suffix is tampered, user %s", current_user)
|
||||
LOG.w("Alias suffix is tampered, user %s", current_user)
|
||||
flash("Unknown error, refresh the page", "error")
|
||||
return redirect(url_for("dashboard.custom_alias"))
|
||||
return redirect(request.url)
|
||||
|
||||
if verify_prefix_suffix(current_user, alias_prefix, alias_suffix):
|
||||
full_alias = alias_prefix + alias_suffix
|
||||
if verify_prefix_suffix(current_user, alias_prefix, suffix):
|
||||
full_alias = alias_prefix + suffix
|
||||
|
||||
if (
|
||||
Alias.get_by(email=full_alias)
|
||||
or DeletedAlias.get_by(email=full_alias)
|
||||
or DomainDeletedAlias.get_by(email=full_alias)
|
||||
):
|
||||
LOG.d("full alias already used %s", full_alias)
|
||||
flash(
|
||||
f"Alias {full_alias} already exists, please choose another one",
|
||||
"warning",
|
||||
if ".." in full_alias:
|
||||
flash("Your alias can't contain 2 consecutive dots (..)", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
try:
|
||||
validate_email(
|
||||
full_alias, check_deliverability=False, allow_smtputf8=False
|
||||
)
|
||||
except EmailNotValidError as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(request.url)
|
||||
|
||||
general_error_msg = f"{full_alias} cannot be used"
|
||||
|
||||
if Alias.get_by(email=full_alias):
|
||||
alias = Alias.get_by(email=full_alias)
|
||||
if alias.user_id == current_user.id:
|
||||
flash(f"You already have this alias {full_alias}", "error")
|
||||
else:
|
||||
custom_domain_id = None
|
||||
# get the custom_domain_id if alias is created with a custom domain
|
||||
if alias_suffix.startswith("@"):
|
||||
alias_domain = alias_suffix[1:]
|
||||
domain = CustomDomain.get_by(domain=alias_domain)
|
||||
|
||||
# check if the alias is currently in the domain trash
|
||||
if domain and DomainDeletedAlias.get_by(
|
||||
domain_id=domain.id, email=full_alias
|
||||
):
|
||||
flash(
|
||||
f"Alias {full_alias} is currently in the {domain.domain} trash. "
|
||||
f"Please remove it from the trash in order to re-create it.",
|
||||
"warning",
|
||||
flash(general_error_msg, "error")
|
||||
elif DomainDeletedAlias.get_by(email=full_alias):
|
||||
domain_deleted_alias: DomainDeletedAlias = DomainDeletedAlias.get_by(
|
||||
email=full_alias
|
||||
)
|
||||
custom_domain = domain_deleted_alias.domain
|
||||
flash(
|
||||
f"You have deleted this alias before. You can restore it on "
|
||||
f"{custom_domain.domain} 'Deleted Alias' page",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("dashboard.custom_alias"))
|
||||
|
||||
if domain:
|
||||
custom_domain_id = domain.id
|
||||
elif DeletedAlias.get_by(email=full_alias):
|
||||
flash(general_error_msg, "error")
|
||||
|
||||
else:
|
||||
try:
|
||||
alias = Alias.create(
|
||||
user_id=current_user.id,
|
||||
email=full_alias,
|
||||
note=alias_note,
|
||||
mailbox_id=mailboxes[0].id,
|
||||
custom_domain_id=custom_domain_id,
|
||||
)
|
||||
db.session.flush()
|
||||
Session.flush()
|
||||
except IntegrityError:
|
||||
LOG.w("Alias %s already exists", full_alias)
|
||||
Session.rollback()
|
||||
flash("Unknown error, please retry", "error")
|
||||
return redirect(url_for("dashboard.custom_alias"))
|
||||
|
||||
for i in range(1, len(mailboxes)):
|
||||
AliasMailbox.create(
|
||||
alias_id=alias.id, mailbox_id=mailboxes[i].id,
|
||||
alias_id=alias.id,
|
||||
mailbox_id=mailboxes[i].id,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
Session.commit()
|
||||
flash(f"Alias {full_alias} has been created", "success")
|
||||
|
||||
return redirect(url_for("dashboard.index", highlight_alias_id=alias.id))
|
||||
|
@ -158,57 +166,8 @@ def custom_alias():
|
|||
return render_template(
|
||||
"dashboard/custom_alias.html",
|
||||
user_custom_domains=user_custom_domains,
|
||||
suffixes=suffixes,
|
||||
alias_suffixes=alias_suffixes,
|
||||
at_least_a_premium_domain=at_least_a_premium_domain,
|
||||
mailboxes=mailboxes,
|
||||
csrf_form=csrf_form,
|
||||
)
|
||||
|
||||
|
||||
def verify_prefix_suffix(user, alias_prefix, alias_suffix) -> bool:
|
||||
"""verify if user could create an alias with the given prefix and suffix"""
|
||||
if not alias_prefix or not alias_suffix: # should be caught on frontend
|
||||
return False
|
||||
|
||||
user_custom_domains = [cd.domain for cd in user.verified_custom_domains()]
|
||||
alias_prefix = alias_prefix.strip()
|
||||
alias_prefix = convert_to_id(alias_prefix)
|
||||
|
||||
# make sure alias_suffix is either .random_word@simplelogin.co or @my-domain.com
|
||||
alias_suffix = alias_suffix.strip()
|
||||
if alias_suffix.startswith("@"):
|
||||
alias_domain = alias_suffix[1:]
|
||||
# alias_domain can be either custom_domain or if DISABLE_ALIAS_SUFFIX, one of the default ALIAS_DOMAINS
|
||||
if DISABLE_ALIAS_SUFFIX:
|
||||
if (
|
||||
alias_domain not in user_custom_domains
|
||||
and alias_domain not in ALIAS_DOMAINS
|
||||
):
|
||||
LOG.error("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
else:
|
||||
if alias_domain not in user_custom_domains:
|
||||
LOG.error("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
else:
|
||||
if not alias_suffix.startswith("."):
|
||||
LOG.error("User %s submits a wrong alias suffix %s", user, alias_suffix)
|
||||
return False
|
||||
|
||||
full_alias = alias_prefix + alias_suffix
|
||||
if not email_belongs_to_alias_domains(full_alias):
|
||||
LOG.error(
|
||||
"Alias suffix should end with one of the alias domains %s",
|
||||
user,
|
||||
alias_suffix,
|
||||
)
|
||||
return False
|
||||
|
||||
random_word_part = alias_suffix[1 : alias_suffix.find("@")]
|
||||
if not word_exist(random_word_part):
|
||||
LOG.error(
|
||||
"alias suffix %s needs to start with a random word, user %s",
|
||||
alias_suffix,
|
||||
user,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue