Compare commits
2448 Commits
v0.1.0
...
065b354b2a
| Author | SHA1 | Date | |
|---|---|---|---|
| 065b354b2a | |||
| cc7428d06b | |||
| 8a8fc72520 | |||
| d36b7b920f | |||
| 994b3547c3 | |||
| 7881847062 | |||
| c740c941a7 | |||
| 6aaa5f7fd7 | |||
| 90e70cf9fd | |||
| d92e4fe31f | |||
| 6dce04ebf0 | |||
| 780386ff28 | |||
| 950e10a13c | |||
| 72c64b5fee | |||
| 1d89af2869 | |||
| 4e8bbc0e5c | |||
| c6bb0c8585 | |||
| 806505af82 | |||
| decbf49b0e | |||
| 916ca0a460 | |||
| 555ecc1aaf | |||
| 2e6bb9a262 | |||
| 75b4d78d78 | |||
| 4fdb29b119 | |||
| 3da6150802 | |||
| c2e49a991a | |||
| b8ad2bc17c | |||
| fe265259d7 | |||
| 69d66c8634 | |||
| 729c04880f | |||
| 7cfb5c7457 | |||
| 93630a821d | |||
| 501765d669 | |||
| 91fa3b65f3 | |||
| b82533233e | |||
| 9c4a0493ee | |||
| 9aa6c4102e | |||
| f058fa53eb | |||
| 4ff73b475a | |||
| ba85852543 | |||
| c7c84c74d3 | |||
| 5fbff39c4f | |||
| 285fef6eba | |||
| 97fb73ad43 | |||
| ce0c9ec65e | |||
| ed084e1ce0 | |||
| ec84065f73 | |||
| 2e8aedcfa6 | |||
| 422f18443a | |||
| 0fbf185223 | |||
| 91cdffa9a6 | |||
| 89199eed8b | |||
| 1a65bb9db6 | |||
| 9772d9ca62 | |||
| 5ee93a5db1 | |||
| 85c4f686da | |||
| 1f066b0d1e | |||
| 38ddb7aaa3 | |||
| a22931f96b | |||
| dcee067aea | |||
| 302d118ae0 | |||
| 09eea96cdc | |||
| 205dea9e58 | |||
| 089eabb806 | |||
| dd63500202 | |||
| 13488efdaf | |||
| edcf33f49c | |||
| d601e01029 | |||
| 4d7c3650b5 | |||
| a0fd468309 | |||
| 0b7471879d | |||
| 282b74c95e | |||
| 5ce1dc973c | |||
| 7ac1e0b69f | |||
| 127bed1026 | |||
| d517a1862b | |||
| 8e5202b375 | |||
| 301fb58917 | |||
| aedebb1461 | |||
| 1336377598 | |||
| 3b58dcbc4d | |||
| 23a5f0a96f | |||
| b81d2ec63c | |||
| cabe365907 | |||
| 54f61ecb18 | |||
| 404cd62d7b | |||
| f0f3143605 | |||
| b729fdedca | |||
| 973cec2c6a | |||
| 6e61aba050 | |||
| 40a8deba12 | |||
| 0ba762ba6e | |||
| 732c256db2 | |||
| d2ce801277 | |||
| 4845fdedfd | |||
| f5a7e2e2d6 | |||
| a84f48ae8a | |||
| c4c9503e31 | |||
| 8c1f499ed8 | |||
| c6eb3cfb74 | |||
| d7a0d253c4 | |||
| 9d275a3051 | |||
| 8192a48bc5 | |||
| 247181830c | |||
| d5dfdc8c05 | |||
| d95fcd8b00 | |||
| 40a366e68d | |||
| 593ae10783 | |||
| 75d9e11bab | |||
| 6d37d42e50 | |||
| f9e9c9285f | |||
| 314bf876f2 | |||
| 61c52cc888 | |||
| b42f226aba | |||
| 767b841866 | |||
| fd08666f49 | |||
| eb662681a1 | |||
| ef15eccc33 | |||
| e0286ff133 | |||
| 2baffe3f11 | |||
| 196657ee86 | |||
| b4c4aafc99 | |||
| b907a79223 | |||
| 0d213de580 | |||
| 2e97d699e7 | |||
| 22e4738b7a | |||
| 4b68641043 | |||
| 3a66a3d655 | |||
| 76d1d3aef3 | |||
| fe2aa5d28b | |||
| f474bbf09a | |||
| c4d02db879 | |||
| 75b36ec547 | |||
| 43b7aea76e | |||
| 13a4a47d40 | |||
| fd9f380922 | |||
| a5fdb9d6b7 | |||
| 983c65e4f8 | |||
| fa568056d3 | |||
| ea8b2812d4 | |||
| b6a2aea8fd | |||
| fa047bf303 | |||
| 4177ac3d46 | |||
| 7647f4f5b9 | |||
| bab03dbde1 | |||
| 85db6e96af | |||
| 548461ade0 | |||
| ecbf182173 | |||
| ab38d33e31 | |||
| 0020f4ede9 | |||
| b470cb63b7 | |||
| 32f2eaef3c | |||
| a7fc3c78eb | |||
| d42b3ecb5e | |||
| 2d4a603d11 | |||
| 7a369328b6 | |||
| 545667e502 | |||
| 8387a81a59 | |||
| 1e3087ccf0 | |||
| bee7772bfd | |||
| a8b6f72ee6 | |||
| 9484cf514d | |||
| 70958c00d3 | |||
| 9467335536 | |||
| f916fdff06 | |||
| ced346506e | |||
| e0cd96f87e | |||
| ed7e906903 | |||
| 3bb7f5abf4 | |||
| 5d801a2343 | |||
| 0d9e59dad9 | |||
| 5fd1396b5c | |||
| b3b9d9293b | |||
| 8b405e513f | |||
| 2bfcfbf03d | |||
| 10388a5ffa | |||
| a127a381cc | |||
| 4aa0dc20af | |||
| 012cc04107 | |||
| 25a84ad3af | |||
| c0036d230a | |||
| 869970a4ab | |||
| 42f8aa410c | |||
| 80e1223505 | |||
| fc9581580c | |||
| b0e6764bfe | |||
| 03fef81414 | |||
| 8dcb8648a5 | |||
| 50b4c96a99 | |||
| c9b894fdbe | |||
| a2f1d944ad | |||
| be4ec2bcce | |||
| c1a728c391 | |||
| 46ff0ecd3b | |||
| 8db69f64c8 | |||
| 8447dd7ae6 | |||
| 543cc5f656 | |||
| 9664bac47f | |||
| 42ae323568 | |||
| a357fb8136 | |||
| 3b487ca0d9 | |||
| 91e98f3126 | |||
| 7ecacaeb05 | |||
| 598ae9fa06 | |||
| 0803a5930f | |||
| 9aaf3284c0 | |||
| b27f9c12de | |||
| a730ebab8f | |||
| f7d0e2279a | |||
| e41dd1c1f8 | |||
| 98274ab864 | |||
| a3261acc82 | |||
| 7d9cfc4ced | |||
| b556efa510 | |||
| 4b72bfd76d | |||
| 0f532094ca | |||
| 7e48cca4ab | |||
| 98aa535193 | |||
| 48ef9acc19 | |||
| e304f4d3fa | |||
| 83a34ae322 | |||
| 43a6d1be0f | |||
| 89fb8a099e | |||
| 853b0d430e | |||
| 88b63d0222 | |||
| 618ad4cac2 | |||
| 9b4dd5600a | |||
| ca959fb9ce | |||
| ee9b281919 | |||
| 1a0630846d | |||
| 9585cbc8a9 | |||
| 19c0ca8191 | |||
| 3b0b95ac4a | |||
| 1691c320cc | |||
| caf88a9488 | |||
| b295b99d3d | |||
| 3cf1276fa7 | |||
| 5ae763273a | |||
| e39965e7b5 | |||
| af36fe9212 | |||
| 6eb7fa27f6 | |||
| 0dd0597c3b | |||
| f0a74a6108 | |||
| 6829eddde5 | |||
| 1c596c4a15 | |||
| ab88b0bf44 | |||
| d462d0164c | |||
| d4d1342c70 | |||
| a157c1961a | |||
| 9a037ace5a | |||
| c64b4502cb | |||
| dc41bf8e10 | |||
| 0ce66d9070 | |||
| 6e369f39a4 | |||
| fb25f589fb | |||
| 8651755d7a | |||
| 277da30339 | |||
| 2fb509beb2 | |||
| 6634d5b791 | |||
| 41739d97e7 | |||
| 43bc04012d | |||
| 43154832b6 | |||
| 91a00cb5b3 | |||
| 526d7e50ec | |||
| cc0996e0d2 | |||
| 8be5e8aa1d | |||
| 022dd3303b | |||
| 2865635013 | |||
| c276f261f9 | |||
| ee7e98bb00 | |||
| 554ce37475 | |||
| 1938d972ff | |||
| 630859bc25 | |||
| 8ea8a9fe2a | |||
| f5e4d82efc | |||
| 958515b9e0 | |||
| 5131e3d6e3 | |||
| b5a18c86dc | |||
| 2f3e26dbe5 | |||
| 3313ccf051 | |||
| 2ada077b38 | |||
| 31c36f0edf | |||
| e74d290016 | |||
| 28337ae228 | |||
| e252378898 | |||
| 1cc0cd7ae6 | |||
| 088e9a339d | |||
| b009c7b6e5 | |||
| 6bb69b0c27 | |||
| 842683da25 | |||
| d39816bb9f | |||
| e856aefd7b | |||
| f54c4998ef | |||
| 59a138d417 | |||
| 0dc2825e5d | |||
| 76af5d946a | |||
| c35cbbda15 | |||
| ece58b60ec | |||
| d95e34a597 | |||
| a09d7b57f9 | |||
| a535fbcef1 | |||
| 931d5e8395 | |||
| b37450db15 | |||
| 2dd7fd30de | |||
| fb55cd1b33 | |||
| e9b4392163 | |||
| 861e4c036b | |||
| e825323bb0 | |||
| aebd65449b | |||
| 0a8f62741a | |||
| b1cefa5a34 | |||
| a12038e40c | |||
| b2fab42170 | |||
| e9c3001c28 | |||
| 44039438e0 | |||
| 372ea29edd | |||
| 1eb958a21b | |||
| 89dd306bb4 | |||
| c170cb42e6 | |||
| 78f3beaf2f | |||
| 482d025c90 | |||
| 11c943efef | |||
| 6debea6dbb | |||
| a7a8b9a2fb | |||
| 47cc046e60 | |||
| 5a9f4ec3b4 | |||
| 70aa19c623 | |||
| d2771f6fa9 | |||
| 5e4637c6ad | |||
| a9a7d28082 | |||
| ffce01c612 | |||
| 7a3ec9468f | |||
| 5850e0e298 | |||
| fdf6548cc9 | |||
| ee8aa2bb8e | |||
| eccea273e6 | |||
| e143c8f098 | |||
| 81226c3bb2 | |||
| c3db8cee2d | |||
| 9061fc3188 | |||
| b9539c4aba | |||
| deabe178df | |||
| a5320cf929 | |||
| 79842d9171 | |||
| 3daff44155 | |||
| cd2cce4268 | |||
| ad132d5637 | |||
| c8b3daa915 | |||
| 96561ec2be | |||
| 608411feab | |||
| 516e3a5613 | |||
| 2431152cec | |||
| 17f604b6aa | |||
| 3fe51dce63 | |||
| 2c454f001e | |||
| 4781cb34eb | |||
| 9faea14e36 | |||
| bd704a8c15 | |||
| bb9a19bcb2 | |||
| 9ff1334584 | |||
| de27c8e6c5 | |||
| ba278e47ff | |||
| 7d70859107 | |||
| 6430a52027 | |||
| 45be96cf68 | |||
| 837a62a534 | |||
| 707283fd66 | |||
| 10b9c09192 | |||
| ed8c5c96ac | |||
| 3ba91c590e | |||
| 44dc45de51 | |||
| 83bd68e7f4 | |||
| dafbc115c4 | |||
| 5de1e32cd8 | |||
| 8ae5c1ea99 | |||
| 29651f674a | |||
| b1dff5ef51 | |||
| bb0971ea17 | |||
| 8a020b666c | |||
| 8b34750426 | |||
| 32cb2b2354 | |||
| 4c8bb5a0b7 | |||
| d3abb279e3 | |||
| 952731a2d4 | |||
| df23cb8cdd | |||
| 87a21a1a4f | |||
| 7c3c1bbd6a | |||
| 03c342f6f6 | |||
| b0e01d36ab | |||
| bb84e8af13 | |||
| f42ee9cf67 | |||
| 8a0232aedf | |||
| f3ccd3b66d | |||
| b690316aa7 | |||
| 8a0777be4c | |||
| 013f44f64a | |||
| 9f8dbf77df | |||
| 274fb8b4e2 | |||
| 48a06c6570 | |||
| 5485242baf | |||
| ec7c4c7461 | |||
| 2259719935 | |||
| f8fc955408 | |||
| 4684de9705 | |||
| 52bab6f726 | |||
| 765e64d96f | |||
| f93610b5e0 | |||
| 5d1480cabc | |||
| 5faf3bfe66 | |||
| 5cb7eca340 | |||
| 9a2f682379 | |||
| fd4036f0c8 | |||
| c854dbaab4 | |||
| 0c75ed47ac | |||
| fa467e72f9 | |||
| 93e05d5634 | |||
| 745efe1222 | |||
| e1dcf56ca9 | |||
| 3aa33a48e9 | |||
| 29547bccb1 | |||
| 4823760fd1 | |||
| 8584e84af9 | |||
| af586a0432 | |||
| ce752c992c | |||
| 7b49a9f142 | |||
| 2fc5e91cc4 | |||
| f6d03bf5df | |||
| a17a2cc377 | |||
| beea6fe733 | |||
| 85b05f9e7e | |||
| d3ab2b94b7 | |||
| b21fff5b15 | |||
| 234e7a55ff | |||
| d4cf8fe077 | |||
| 2b2a266533 | |||
| 4b35103e34 | |||
| 81a5585029 | |||
| d2b89e629a | |||
| cab86eec68 | |||
| 295f5cc14a | |||
| 6395e3b5c1 | |||
| a42c5fa988 | |||
| 46e275d843 | |||
| 13ada3575a | |||
| 3b0e0f1a3f | |||
| 512acc5a49 | |||
| 1f101fea3e | |||
| 83bd8f23f4 | |||
| af56c3057c | |||
| 53a8ad71c6 | |||
| 600ae2bd58 | |||
| 60b6ed51cd | |||
| 8a947ef224 | |||
| d936b64cf9 | |||
| ab828ebdab | |||
| b444e8ee31 | |||
| 45f1177a73 | |||
| 64e7dc5e12 | |||
| e62bebb7fa | |||
| 3990a072ca | |||
| a5bd12945d | |||
| 7938e7c7c8 | |||
| 130a157abc | |||
| cce19ae957 | |||
| e90340fec4 | |||
| 22061e535a | |||
| 23a85d6162 | |||
| 2cb47bfd75 | |||
| 3ce7f6e99a | |||
| 77b083c41b | |||
| b8fcdacb84 | |||
| d893193e73 | |||
| 73f8446d07 | |||
| 5692bec216 | |||
| e88491268b | |||
| 94cd5dc21a | |||
| 697f69d5d7 | |||
| 76fff27b3f | |||
| 4f664dbfc3 | |||
| d9b726cdf9 | |||
| cc792b9c0f | |||
| b916217b28 | |||
| 6c4b7059ed | |||
| 9d0e294ed2 | |||
| dc6d8398b1 | |||
| a50d2e7e72 | |||
| ea5cfe60f2 | |||
| 5b37ea4d78 | |||
| 46dd2888a6 | |||
| 564c9e1d95 | |||
| 4d0d3959a9 | |||
| e9e6644e7f | |||
| 6a19131ea1 | |||
| 4d9643dcb2 | |||
| 5dc4ad60ba | |||
| 2e5dd7d513 | |||
| efe088f591 | |||
| d5016e853e | |||
| d334bd7b9a | |||
| 21edf0157a | |||
| 388167705a | |||
| 2423b37cbb | |||
| 786796d457 | |||
| 0ed9216260 | |||
| eb13f10121 | |||
| 76ce6f6f9c | |||
| eb305139f5 | |||
| 8df73f202a | |||
| c22751de6f | |||
| c3f1cb0c61 | |||
| fc1fc58aa1 | |||
| e4b5e96534 | |||
| 66303a8965 | |||
| 9589dd2486 | |||
| 3d5b887e23 | |||
| b967a214cb | |||
| 5a9877588f | |||
| fc5f8e4633 | |||
| 028bca50ea | |||
| 6853bbfb68 | |||
| d4fee27a3d | |||
| 245fdd78e4 | |||
| cbe784172e | |||
| bf21e45cba | |||
| 359c430a39 | |||
| 669a217180 | |||
| e9507241ed | |||
| f2536749f6 | |||
| 118558d25b | |||
| d9cd270ff4 | |||
| 9dee449f10 | |||
| ae19ca4383 | |||
| 32fed8d6fb | |||
| ec325c9e6b | |||
| 5fbb29abd3 | |||
| f06c6523a2 | |||
| 02514fc457 | |||
| 5d88287ae2 | |||
| 00f1d0418f | |||
| 18b270debb | |||
| d947164eb6 | |||
| 1a1bb6077c | |||
| 05b5cab12b | |||
| a82fdd4946 | |||
| 4def7ed60c | |||
| d50ce0140f | |||
| 51678aee04 | |||
| 019689087d | |||
| 0c1d77f7ae | |||
| 8de51e6e71 | |||
| dc993da218 | |||
| 983f7fec0f | |||
| ce74c4817b | |||
| bc363438f1 | |||
| 979b16d520 | |||
| 9686eb020f | |||
| 88dea9acaa | |||
| c75fdfea1c | |||
| 538d2b8205 | |||
| 30d36a3b07 | |||
| 95bcd8e4c8 | |||
| 1a8ce7d58d | |||
| 4700446ca0 | |||
| 67bc81d3e2 | |||
| 878a3a018e | |||
| e463c2dc95 | |||
| 422cf49517 | |||
| 77d2426c14 | |||
| 1c4dc55bb6 | |||
| ba72f421dc | |||
| 36d1e01008 | |||
| e52c7037c7 | |||
| f5235ba08e | |||
| adc4899ea6 | |||
| 34c5a1750e | |||
| c75a902d84 | |||
| 7e2e1a4ad3 | |||
| d4603a1892 | |||
| 642e51bc0c | |||
| 5591abdb3b | |||
| ce9378c43f | |||
| 3ae72623ad | |||
| affc02655b | |||
| a469d66358 | |||
| 757f9e5b02 | |||
| 8368b02be8 | |||
| e15a5617e6 | |||
| f604b2c766 | |||
| d6dc9f8170 | |||
| a71be1bf05 | |||
| bcf11631d6 | |||
| 989183c8be | |||
| 8bd0fd88af | |||
| 20e2444307 | |||
| 8154bd712b | |||
| 4d0e376568 | |||
| 32cf41a7a0 | |||
| e85a4701ed | |||
| b79ffafaee | |||
| 8f6adaa417 | |||
| 0e634d83f4 | |||
| af8cbe0b15 | |||
| 411130db4e | |||
| c099443783 | |||
| 23ffdbb163 | |||
| 0b48502a10 | |||
| 25681f622d | |||
| f196ce969b | |||
| 0408c470fc | |||
| 01aeb945ff | |||
| 601a1f83c6 | |||
| 2a470742e0 | |||
| 8ba1e1997f | |||
| 27ae401a7f | |||
| 81727d3b1e | |||
| 06a0501633 | |||
| 781c2d9044 | |||
| 15e4ad00ee | |||
| 8064a00252 | |||
| f2d0fe407b | |||
| 9589657fd5 | |||
| 790837076f | |||
| 6d923027a0 | |||
| 13d5759e84 | |||
| efe39c7390 | |||
| c00770201b | |||
| 4eff3a337f | |||
| 451385011e | |||
| cd4d230d29 | |||
| ab6d4ee6fc | |||
| 274aa6a17c | |||
| 2f8d4ad5e4 | |||
| fe59d3b280 | |||
| e2c99c4f04 | |||
| 127393b64a | |||
| f3d240442b | |||
| 55bf8b9e30 | |||
| eadcf7768f | |||
| 876bf8cc31 | |||
| 6b5aac0111 | |||
| dc4a4e1463 | |||
| 0677ed07db | |||
| ecf6fbd187 | |||
| 351cebe169 | |||
| 0f94a90882 | |||
| 04996d784f | |||
| aafcfeda84 | |||
| 7283b724b1 | |||
| 0d55912f6c | |||
| 60108e26c7 | |||
| be129cd3c6 | |||
| f210bfa9f4 | |||
| 263113a67f | |||
| 3b29303237 | |||
| 6e5f857e97 | |||
| 791c0ea26e | |||
| 84523d8b8a | |||
| d35e127b9e | |||
| ebe00d3271 | |||
| 14b4e40039 | |||
| 15d1d269ae | |||
| e90b76c80e | |||
| e28e27080a | |||
| 2268496dcb | |||
| 3781327c58 | |||
| 51c33d7e83 | |||
| 975a56e7d9 | |||
| 29a87dcfaf | |||
| cad53d0bfc | |||
| 56a3905df1 | |||
| 428a1f2156 | |||
| b5233399e6 | |||
| f8878c5405 | |||
| 8dcaa457f9 | |||
| b24ebdb83e | |||
| d41a2141a7 | |||
| 09a1dd0358 | |||
| 531c4a44d5 | |||
| ceecff8c24 | |||
| f32cc4ab04 | |||
| 8fa46281e0 | |||
| f7bc4b3ab6 | |||
| ad4f5bd88d | |||
| e4cb66718d | |||
| 175b272fa0 | |||
| ca0fb9446b | |||
| 6eb749dca2 | |||
| 880b614636 | |||
| d146a99c65 | |||
| fd99c784b3 | |||
| 22f9c5243a | |||
| 67f5aaa5ee | |||
| 713b621169 | |||
| 80df5f95aa | |||
| 1e492d8724 | |||
| 602f15fe2e | |||
| 3335533a18 | |||
| d385358aa3 | |||
| d6ee8a416f | |||
| c5aa37037f | |||
| 6050f5deab | |||
| 5d07d1a70d | |||
| bae330c6f3 | |||
| ea17994c6c | |||
| c3d29ee2f8 | |||
| 515b9af61a | |||
| 4ba3893b83 | |||
| bcb6c4f419 | |||
| 53f101fb60 | |||
| 8da4f65048 | |||
| 428bcba56e | |||
| 68e896d8eb | |||
| eef62722a4 | |||
| e3dcb2ce0c | |||
| 0cf89562cd | |||
| 8b06731cdb | |||
| 36abd1acec | |||
| 06ef2220d6 | |||
| 29d14bb5ef | |||
| cd2b99a44c | |||
| 0413f8c0aa | |||
| ca5c451d36 | |||
| c19b87275d | |||
| 7a374a509a | |||
| 01aa2cf0a4 | |||
| 5c9eb5dc5a | |||
| b05a53ffe3 | |||
| 0387551c43 | |||
| 773f808a35 | |||
| 07477eb5f8 | |||
| 5cb129311a | |||
| 6215f489f2 | |||
| 0140fc7622 | |||
| fbaf6086e3 | |||
| 5a1b649011 | |||
| 6da42686a9 | |||
| 82b98eca95 | |||
| a54275d307 | |||
| e1e61e8570 | |||
| ebc7e7256a | |||
| 93887ec2bb | |||
| 8dce0f2d6a | |||
| 620ccf317f | |||
| 7983f17e7f | |||
| b60c0b29f8 | |||
| 5400a1424c | |||
| 3296d21f6a | |||
| 2e1a9362fc | |||
| 53aa4ff390 | |||
| 3c100b2543 | |||
| b37cde5a8c | |||
| e13efdc11f | |||
| 303f599f7d | |||
| a68c45a923 | |||
| 96b7c69283 | |||
| 801c0f8572 | |||
| 90e862fbb1 | |||
| 1eb997d2c0 | |||
| 1d314b1b09 | |||
| a077cccc2e | |||
| 6fb7e63e88 | |||
| 3621245212 | |||
| dfa573b49b | |||
| a69db9d299 | |||
| 481618037d | |||
| 57ead2937b | |||
| e6d8cbcdd6 | |||
| a7554d884f | |||
| 468a4b1bac | |||
| c1e4cd4bf1 | |||
| 4413f2c411 | |||
| b1349f57cd | |||
| 4a6f7eb43c | |||
| 8f0e6ba95a | |||
| e9c175d2af | |||
| 5dc0e925c1 | |||
| 787eaad352 | |||
| 4bab8db7c0 | |||
| b6e96586a5 | |||
| 7127c5539a | |||
| fe7736a7f6 | |||
| 29dcaaae47 | |||
| 9090c5c223 | |||
| 4336d1ed1a | |||
| 8edc3640f5 | |||
| 39e81af782 | |||
| cc16f57a44 | |||
| e7e7caae3b | |||
| e09c62cf8d | |||
| 8d5fe8f0f1 | |||
| f9d8293fd2 | |||
| 4111eb0838 | |||
| cd37e2ab1d | |||
| 2c730b3e25 | |||
| ee47ee91c3 | |||
| 5a47c74f83 | |||
| 0c4b8f006a | |||
| 0023454d9a | |||
| 45e6c56934 | |||
| 51eb8fa377 | |||
| f905dcb3fd | |||
| 583676314a | |||
| 8616183660 | |||
| ce4bca8272 | |||
| 8c71f03f6f | |||
| c5c4ddecbe | |||
| ceecd9d524 | |||
| a5a526e554 | |||
| 88864fd4f0 | |||
| 10f2b39203 | |||
| 6e1899c6ad | |||
| b94dc8eb83 | |||
| 70eea8ff33 | |||
| 881a9c122a | |||
| 9e9cac0c2e | |||
| 83b2a3645d | |||
| ecfca1c742 | |||
| 6222b6edae | |||
| baa6850fcb | |||
| cab13cee3c | |||
| b4bff49104 | |||
| cde26b76b1 | |||
| a20ef34280 | |||
| 5606d40451 | |||
| b3a666f876 | |||
| 626d3895aa | |||
| e338c7190d | |||
| adfd12ef52 | |||
| 817291c9a7 | |||
| c4d20c539f | |||
| d089eee133 | |||
| 387df07659 | |||
| 5767acb29b | |||
| 607c1ddc48 | |||
| a6d45f5009 | |||
| 6e9f427182 | |||
| 62ad1749e8 | |||
| 9c695ee46d | |||
| 1d2002e92f | |||
| c7b809415c | |||
| 55fa9ca686 | |||
| 09a6ea46b2 | |||
| 56ba4d88f4 | |||
| fbc8c5e8c7 | |||
| 8a65282820 | |||
| 0d8c5f3dbe | |||
| dbbbe6805d | |||
| 4656002106 | |||
| d3758ec02f | |||
| 81812bb31d | |||
| ab6f9839ef | |||
| d036f66d4c | |||
| dc24186ccb | |||
| f6fbcd8608 | |||
| 381d063295 | |||
| 65a0e48988 | |||
| b1a928b990 | |||
| b7973772b3 | |||
| 20b65fd885 | |||
| 850fbffdde | |||
| 0af5b194fc | |||
| c421038808 | |||
| 68c078038a | |||
| be5b1a52ea | |||
| 86c5b882c2 | |||
| b7d2653fb5 | |||
| 34a752b8d8 | |||
| de217a1bbf | |||
| 3087296263 | |||
| 78ba43480b | |||
| e7e2cc8081 | |||
| 0f6b61ce6c | |||
| 9182e8f2ef | |||
| c7870a79e5 | |||
| 312172fcd8 | |||
| 4a83ba84d3 | |||
| 76a9a20d89 | |||
| 567902a407 | |||
| fca8211c4f | |||
| d37b023e11 | |||
| f175644843 | |||
| 13c4ad10c5 | |||
| 550cd848b0 | |||
| 25e0c43c0b | |||
| fd9d23995d | |||
| 3a467d758e | |||
| 60b6adfa1e | |||
| a44ac333ab | |||
| deea0deb3f | |||
| 97178227ef | |||
| fd1242490f | |||
| 1ac633bdd7 | |||
| 44d4349f12 | |||
| df31be61e8 | |||
| 68b08c1e8a | |||
| f97cca6dcc | |||
| 2c9bb12da9 | |||
| b059055a93 | |||
| 1092cc2fdc | |||
| d5e75b2a37 | |||
| 0a5f8862ad | |||
| 433a225b9d | |||
| 6dfff84ab7 | |||
| 91b6047f2e | |||
| 94ef7f450b | |||
| e5cf92f84e | |||
| 399b5c03a2 | |||
| f9b7be2f74 | |||
| 66f7cc6f88 | |||
| af03597e86 | |||
| ab4fc8faf5 | |||
| fc2c5a8e6c | |||
| 1d23558dff | |||
| ce65d0257a | |||
| 78c5b1704a | |||
| 00f8b6d950 | |||
| e829bdccb5 | |||
| 6a7627e8c6 | |||
| 6787d0591e | |||
| d78dada5ec | |||
| 74844b9a99 | |||
| a29ff0d553 | |||
| 6632dd64b3 | |||
| a8c912c4c2 | |||
| 2a02816127 | |||
| 66b950b4aa | |||
| 639bd9c5cd | |||
| 2bf8c0b501 | |||
| 847d5121aa | |||
| ab6e89594e | |||
| 0662427cec | |||
| d3b283f623 | |||
| 385d97ba15 | |||
| 09574f1c75 | |||
| 34247be52c | |||
| 56b1e1f565 | |||
| 7c6a3081ee | |||
| beeeb1c059 | |||
| 47f70098df | |||
| 4e6b708834 | |||
| a359b07ef3 | |||
| bb0524c559 | |||
| d70ea1987a | |||
| 001de8a1e0 | |||
| f045e8ffcd | |||
| f8be1222d6 | |||
| 8848fe8b33 | |||
| 32524dac56 | |||
| a50ecf4d9c | |||
| 98fda4e5d5 | |||
| f0c111f02a | |||
| bcc36e1533 | |||
| 872639fefa | |||
| e83b959930 | |||
| 3f8de39683 | |||
| 9430f57a0b | |||
| 703ceb44e4 | |||
| 3954db9b99 | |||
| 7abb5972eb | |||
| 377a4899b7 | |||
| d769e833e7 | |||
| 9786f96fe5 | |||
| bd2a672c12 | |||
| 96cb45dd45 | |||
| cf370c083b | |||
| 43e1780dc8 | |||
| 6aac810450 | |||
| f14c283a83 | |||
| e78b2cafb1 | |||
| a9eaf011cd | |||
| 7fca519fd9 | |||
| a9a37b0c97 | |||
| 8f55bd0df1 | |||
| 85e88949c4 | |||
| 80bcebbf66 | |||
| 7cae873830 | |||
| 7a1b27927f | |||
| 306da60752 | |||
| 4274b90b1e | |||
| 0b5721671d | |||
| 30575d15d0 | |||
| 0ca2f8b4a7 | |||
| 2e01e5530c | |||
| 13cc6a2cf0 | |||
| 35ba5dcc9f | |||
| ab58109e5e | |||
| 18a6d25ed6 | |||
| a0e3a269a0 | |||
| 1658d0758c | |||
| 6f0c59bba4 | |||
| 1e98a0df55 | |||
| fa77d3e837 | |||
| a21bc7aad7 | |||
| e665fac956 | |||
| e60c633e56 | |||
| a444526743 | |||
| b65a246fcb | |||
| c5e8a50033 | |||
| 6021c24da9 | |||
| 0e4cd10376 | |||
| f2c043a299 | |||
| 596787b998 | |||
| bb3a0c4444 | |||
| 624b9cb20b | |||
| 5a6c25d616 | |||
| eb178e7bed | |||
| 373d71c124 | |||
| 6618cfeceb | |||
| 58c1382570 | |||
| 721eb122bd | |||
| 95205d2f1d | |||
| b6efa91879 | |||
| 29d7ee09c8 | |||
| 68cb5bc523 | |||
| bd96b2398a | |||
| 1579882475 | |||
| b077b99806 | |||
| 9797e7e58f | |||
| 00f62fd608 | |||
| 833e767e6c | |||
| 3e7b3297aa | |||
| 8e170a69e8 | |||
| 00d6f5d473 | |||
| 3c363788d8 | |||
| cc920cff9a | |||
| 5d78d56f0c | |||
| b9b47c4428 | |||
| a5382e9fdd | |||
| 376f5b2650 | |||
| 61c5f75006 | |||
| 9a6148fe6e | |||
| ff6a558e96 | |||
| 3ad45bebb7 | |||
| 6b152bd778 | |||
| aacde2dfde | |||
| 6971eccb22 | |||
| a5e7c483ef | |||
| 319f97bf9e | |||
| da31a67c52 | |||
| af355e5b85 | |||
| ca9fe264b4 | |||
| c07e937702 | |||
| 371b88c6cd | |||
| 782bc11950 | |||
| 50c3fee7dc | |||
| 51c4e06e59 | |||
| 2a84f44f2c | |||
| 4878c7258d | |||
| e2a4e0cb3f | |||
| fd3457af84 | |||
| 8c0a9062a2 | |||
| 10d301aa3c | |||
| d7193847c5 | |||
| 8ea079679c | |||
| 95c7b498ff | |||
| 1156499b05 | |||
| e92aaffc94 | |||
| 8dc38912c9 | |||
| d228bf12bb | |||
| 7dff8a2ed5 | |||
| 814fe02949 | |||
| da702d6316 | |||
| fd909023f9 | |||
| c94c455b8b | |||
| d39b0ee077 | |||
| 99a2f40a4e | |||
| acaad355ed | |||
| ad4b351a32 | |||
| 2902fae1df | |||
| a0b9ca7fae | |||
| 05d8f8b9ab | |||
| d074a9d54a | |||
| d0274013cf | |||
| 1e0169a9b7 | |||
| c619d2ecad | |||
| a27a2556aa | |||
| 8207373a05 | |||
| 986fab9cbf | |||
| 2025551f3c | |||
| 3d934ab018 | |||
| 6a59ed0984 | |||
| 8fce3f2bcc | |||
| eca0574e41 | |||
| fa044f5972 | |||
| 6d758f338b | |||
| 28322bad5e | |||
| a9805b8fff | |||
| eb16b7fbb8 | |||
| 70428b6c96 | |||
| 85557c2879 | |||
| 5bf7f77520 | |||
| 3cdc7c947f | |||
| 84fc6b2ffb | |||
| e7612f6f0c | |||
| 58097331da | |||
| e053528abf | |||
| 568abb6e03 | |||
| 852c7899b5 | |||
| f4998da4cd | |||
| 9d9e6ef9bd | |||
| fb367174b6 | |||
| 87566010be | |||
| 2f7cdfd786 | |||
| 559e8259be | |||
| 929d3febb0 | |||
| 92df98c3fb | |||
| 019c993313 | |||
| 3c370b7ac7 | |||
| 1afd811aa8 | |||
| 0a999d56c7 | |||
| 4f6988d775 | |||
| 9f2bbe527e | |||
| 965be837a3 | |||
| f5f8b9a145 | |||
| f3b6c1266d | |||
| c675057ab1 | |||
| 20e95e35aa | |||
| f22666e756 | |||
| 3567ac170a | |||
| 260bd952d4 | |||
| e294f04b04 | |||
| 27fa5625be | |||
| 62623e6a23 | |||
| 9df436d31e | |||
| c03f74154b | |||
| 749bdfd164 | |||
| 6878d5260d | |||
| 4f21762533 | |||
| adebc96637 | |||
| 3a50e6d2de | |||
| b09b66adc3 | |||
| 6ef42a9303 | |||
| 922c338387 | |||
| dc4310c301 | |||
| 0b7fd647e6 | |||
| 081b270f04 | |||
| 29c09cb10a | |||
| cd2e6c1aae | |||
| d3e6756c22 | |||
| 7d31812055 | |||
| 8ce871e9bb | |||
| 30125f0faa | |||
| f4ea9a85f0 | |||
| 593e123610 | |||
| 4e8eddd868 | |||
| 041dbcb5c7 | |||
| 215a0163b6 | |||
| a38867ce01 | |||
| 9026c526f8 | |||
| c1f94a4499 | |||
| 2be329974e | |||
| abb26ac410 | |||
| c5f03165bc | |||
| 41452ac20b | |||
| a285707b53 | |||
| cc9a5eea36 | |||
| 89d5cc31af | |||
| f0e11e952c | |||
| 0f8431ff5c | |||
| 659b819011 | |||
| 6a62cfdef7 | |||
| f2ebd751d4 | |||
| 1414f54a12 | |||
| c3265c5bf6 | |||
| 13e322bb57 | |||
| 5cacfc8daf | |||
| 9bbe4d2dcf | |||
| b517409229 | |||
| 75a96e871a | |||
| 395f7dfd63 | |||
| b166f6ff56 | |||
| eb2b6d1002 | |||
| 6cb045453a | |||
| ffae9e81a7 | |||
| dc59d3954a | |||
| 7b26eb50bf | |||
| c0ab1ad793 | |||
| c73dcb51e4 | |||
| 8c7fc0fef9 | |||
| 76a2e24d06 | |||
| 3e6a054913 | |||
| 89b233e51b | |||
| 61f26e060e | |||
| 5649bb243d | |||
| ea90e97f92 | |||
| 3c624188d1 | |||
| 04f373e931 | |||
| b2e36a24fd | |||
| 8da3d2aa35 | |||
| 25c8b9baf8 | |||
| 1555052e1d | |||
| b1fbf91d6e | |||
| 5dfac0c085 | |||
| cb142a65f3 | |||
| b0a9b2366e | |||
| ed897d4105 | |||
| 2b71723ba1 | |||
| 60ba3b7977 | |||
| e0198da52c | |||
| 6365805715 | |||
| 5e7e3696bf | |||
| 166fae425d | |||
| f56bef40d8 | |||
| ad1eec7d47 | |||
| 5b241d2547 | |||
| 92a626fb21 | |||
| 5171f23c09 | |||
| 6dc0ebcac6 | |||
| 49f1f3c86b | |||
| 53ab441486 | |||
| 0e422b5a8f | |||
| 4f51480af9 | |||
| fb8fbbcf70 | |||
| 5256eff88d | |||
| 7c40157cba | |||
| 454e97c9f1 | |||
| 41d34af4c7 | |||
| a13f5bfb10 | |||
| da06fe4a7b | |||
| 061ea6aab4 | |||
| a19cc81391 | |||
| 871164b969 | |||
| 16fa77eb09 | |||
| a46399cbaf | |||
| a9e50d29d3 | |||
| e7d7f217a9 | |||
| 000c2b9ab0 | |||
| 37fdb161ea | |||
| 2d923bbdc9 | |||
| 07c55de024 | |||
| 30c463627a | |||
| 5eec635146 | |||
| 229d9c76c3 | |||
| 0119eadc14 | |||
| c1c656ab7e | |||
| e4a50bcd60 | |||
| d18e6df1c0 | |||
| af9aa726f4 | |||
| 27f8c90dae | |||
| b9a3c384d9 | |||
| eed7085756 | |||
| abb0c2ad16 | |||
| 9f7b40381c | |||
| ad9a390b58 | |||
| 5525635df1 | |||
| 863e0205ff | |||
| 2560a70e5e | |||
| b638a73e4d | |||
| 0a9cea4df3 | |||
| 15a98c3eac | |||
| 493c16087d | |||
| dd155a0f63 | |||
| 9ce1c8d397 | |||
| 3040435c06 | |||
| 685f93f05e | |||
| d465d9da1a | |||
| 50c2766014 | |||
| 4e1cbf13c6 | |||
| a26397131d | |||
| 7659e8f0f7 | |||
| 90b608bdc6 | |||
| fffe2a1ccb | |||
| fd7706de6d | |||
| d0a5c93e49 | |||
| 263bf08f34 | |||
| e050f30efa | |||
| c2b1adf588 | |||
| 647cd3c33f | |||
| 8fdbb39ee4 | |||
| ee029294f1 | |||
| 563e328ce3 | |||
| 8f543d7a84 | |||
| 62e09190f3 | |||
| 50c774fd78 | |||
| 10e4bcc723 | |||
| 964ad6d046 | |||
| 56fb76017d | |||
| 5a9141e10c | |||
| db94282207 | |||
| 9f6446c30c | |||
| d570ce361d | |||
| 868fcf2c5a | |||
| dd35a85316 | |||
| 5003f8b3a2 | |||
| d044f938e3 | |||
| e549779164 | |||
| e2f2b325a6 | |||
| 9860c1db54 | |||
| 7d820f5b88 | |||
| 61d6e5643c | |||
| b444de591a | |||
| 21c86c9dfa | |||
| 8e70754533 | |||
| 4270d74338 | |||
| db506fa992 | |||
| c1b06eaa6f | |||
| 6bd1d09fa8 | |||
| 70da228dcc | |||
| 9888efe437 | |||
| 65756b62a5 | |||
| 59a0d593d4 | |||
| d519b80b61 | |||
| e92725f38b | |||
| ec0cb0bbb7 | |||
| a4b26374f4 | |||
| dcac6a4bb0 | |||
| dd6eecb0c2 | |||
| fec100a273 | |||
| 8f944b1b46 | |||
| 69498003d8 | |||
| e019f557ff | |||
| 4b5611ef6c | |||
| ca44b2cc2c | |||
| 10e0972d79 | |||
| 28908d81a3 | |||
| 0503a50754 | |||
| 65a92042d6 | |||
| f554fdefd3 | |||
| bdbd4d5302 | |||
| 3ee1683349 | |||
| 3a7ad429c2 | |||
| 89bd055f02 | |||
| 835b3b7b8b | |||
| 934f90cdff | |||
| 92cc683b8e | |||
| 80d548e8bd | |||
| 7ec1efb85d | |||
| f5945a788f | |||
| 2d0e2e0cca | |||
| bff6ca7e9d | |||
| 06b4960984 | |||
| 2fe393204b | |||
| 876950a84e | |||
| 6292ef9dfb | |||
| 798fb8f937 | |||
| f6dd4c03c3 | |||
| f87fbddef7 | |||
| aa2e10440d | |||
| 34b0b793ba | |||
| 1f159bf826 | |||
| b8253b6dcc | |||
| 79fd9070e4 | |||
| 7b96cd0447 | |||
| 01bc9becc0 | |||
| 9a009b73dc | |||
| fe35cbae49 | |||
| c3a880e5f5 | |||
| 1c906113ab | |||
| 6f3dcd958d | |||
| 7a9f4cd64f | |||
| 9a67af7c55 | |||
| 501de6ffef | |||
| 210d978279 | |||
| a35771acc4 | |||
| 637faef690 | |||
| c800eb5d4d | |||
| 0e062ed065 | |||
| f2e89da724 | |||
| ac29f0bf98 | |||
| d174e99c80 | |||
| 5006a96181 | |||
| ce8c020477 | |||
| 98c96b8217 | |||
| 43404adf49 | |||
| 90ea462206 | |||
| 92a78f6f12 | |||
| be7fbd405e | |||
| 98e3c6ebfd | |||
| fbca205cca | |||
| d3c25a1aff | |||
| 84f2778bc0 | |||
| 688185c367 | |||
| bf48bfdd7c | |||
| bde0b01d06 | |||
| a1b7c8ad1d | |||
| 37ff0d1fab | |||
| 0c218df3ad | |||
| 259f27bf1b | |||
| f2bc8e44fc | |||
| c44bf73b42 | |||
| fbd19f9da4 | |||
| 50fc0783d4 | |||
| c372272394 | |||
| 22d653cc76 | |||
| 46dbfcbe77 | |||
| 91d51e660b | |||
| 76f5f12563 | |||
| 08bc0eff8c | |||
| be1d219fea | |||
| 52034ef55c | |||
| 47ab41088e | |||
| fb5484f44d | |||
| cfbab0432c | |||
| 34bf74da84 | |||
| 889f90015a | |||
| 1d0817b1b3 | |||
| 54150a9157 | |||
| a8a89ca089 | |||
| bb4eca1b0c | |||
| 6ce6fd3aa8 | |||
| 35ec18cfac | |||
| 3795e788bb | |||
| 4b239030c5 | |||
| 7162ce4a77 | |||
| 03f0e4a477 | |||
| a23a194660 | |||
| 45faa269a4 | |||
| 981a1aac4f | |||
| 70ccf7b691 | |||
| 8bc763be9b | |||
| 815bb08fa9 | |||
| 34773537c2 | |||
| 6c285a0856 | |||
| a062592043 | |||
| 8978e340c7 | |||
| 07c1bba829 | |||
| 4f836f5e3a | |||
| 2cfc24a808 | |||
| 07743368f4 | |||
| 592c04c5ab | |||
| bb8a72876b | |||
| b9b501edfa | |||
| 44fe7778b6 | |||
| 6ea5ad1619 | |||
| d9b819d1a1 | |||
| 5ac9eb5d5c | |||
| 1345603e09 | |||
| bd66408c3d | |||
| f75e078fed | |||
| 09fc82f7b7 | |||
| 7bc9a0357e | |||
| dc6420ccb0 | |||
| fadf72c245 | |||
| c8ff60d986 | |||
| e5cd8ffa61 | |||
| c36f58e491 | |||
| 45d348c0ef | |||
| ae26f00a36 | |||
| a6e765f51c | |||
| 3c428ade52 | |||
| 011020a945 | |||
| 368322f906 | |||
| a3ff181b6e | |||
| 720a5f8897 | |||
| 633cb44db6 | |||
| 73f234d8f5 | |||
| a49490baa7 | |||
| a90f08a85f | |||
| 17ee037525 | |||
| 75aa55d340 | |||
| d32cd793d0 | |||
| 29781bbac4 | |||
| 21ea36a4f7 | |||
| 5e99b9d555 | |||
| 3190608d36 | |||
| 4d0aecb8c2 | |||
| e1f420c3ae | |||
| cbf3dd9776 | |||
| 0ff97ac4e0 | |||
| 52b37c2a13 | |||
| 732fa3b9de | |||
| 4047aaf48a | |||
| bc3e7ae29b | |||
| ed87e56a33 | |||
| 28ce1e856c | |||
| 4c13b7ad02 | |||
| 49df497f35 | |||
| 5221ab481e | |||
| 6655d725ae | |||
| 220f9f15e5 | |||
| 1e8a27612f | |||
| 7ecec2bb64 | |||
| fceb92eb6f | |||
| 8b92051900 | |||
| 03f3e039e0 | |||
| 18ebf7baaf | |||
| 20b28f2a68 | |||
| 6d0fdc6860 | |||
| 9f0e82446e | |||
| cb69991f7f | |||
| 327fdd66e4 | |||
| 7d01b4bd5a | |||
| d15a862e5b | |||
| 5a31118c96 | |||
| 8eaeb1953b | |||
| 25674c04c8 | |||
| cd6e7c81e5 | |||
| d915de8ff9 | |||
| 1307d49762 | |||
| 2cffd4fbbb | |||
| 031209490f | |||
| 5d75629a73 | |||
| 27c4afd41b | |||
| 9db4a2430a | |||
| e1ac3732bd | |||
| 56ad572387 | |||
| 70beb45c4e | |||
| 698c0a62a2 | |||
| 8421649bcc | |||
| e8883781e5 | |||
| 77e9ae94cf | |||
| 30344ef5cb | |||
| fa0460abd0 | |||
| ee52db3f7c | |||
| 00c8259bd0 | |||
| 470a74f420 | |||
| 3d5a03a629 | |||
| cc8646cf1b | |||
| 308c89aa0b | |||
| de37c3da5a | |||
| 593b924f32 | |||
| bc3cb79f91 | |||
| 9622d5de06 | |||
| 2dddb77ca4 | |||
| 1d43eda9b7 | |||
| dbb1843285 | |||
| dfe1b853d1 | |||
| c7e4d4eaae | |||
| 7c59e8386e | |||
| 366311edbb | |||
| 2fc6a6ca77 | |||
| 9945cb7a94 | |||
| 50918756d7 | |||
| 43c37763d8 | |||
| 4e365f54af | |||
| 09ddf53b01 | |||
| 7fbfa71434 | |||
| ae46cd2332 | |||
| 772a22a182 | |||
| 636ac974b8 | |||
| 216c8211ac | |||
| 805d3e65e3 | |||
| 73c69c3761 | |||
| fe442f27f2 | |||
| 8b51f6ebaa | |||
| ab745ad56b | |||
| 62d3dc63d1 | |||
| fcfd9894a3 | |||
| df076b563a | |||
| 366fbff012 | |||
| d8f7175da9 | |||
| 2bd3845d22 | |||
| 720f83bd0b | |||
| c2fbd918dd | |||
| 902361e5d6 | |||
| 5a2576b368 | |||
| d71014a797 | |||
| d2eaf5c6da | |||
| 17d4fec256 | |||
| 4a96bac457 | |||
| 4977979b08 | |||
| 8fa19df113 | |||
| 217d37e3d3 | |||
| e86d4e05ce | |||
| 6fcb0a2b3c | |||
| 560edf9fbf | |||
| e532f372b5 | |||
| 1101796641 | |||
| c2757f68a6 | |||
| d648226d13 | |||
| 4987819227 | |||
| 8d27997e2e | |||
| b9ee94a47d | |||
| 8b531cc726 | |||
| 753fc762a0 | |||
| 80396a444e | |||
| 52dfee9ca6 | |||
| 80b8b9afdd | |||
| 20dc72022d | |||
| 9116f404db | |||
| 029e5f6d02 | |||
| 6ccaf89d86 | |||
| 0d706abbd3 | |||
| f4a27e59a3 | |||
| 5ab7d0e9b3 | |||
| d198634326 | |||
| d3762e6c46 | |||
| 157eb140eb | |||
| 83a0c27259 | |||
| 5c4a8e37c4 | |||
| e4faf64ea3 | |||
| a4849fa4f0 | |||
| 32155ca63d | |||
| 9ea3327517 | |||
| 946a7810a7 | |||
| ea8021b359 | |||
| caa27841ef | |||
| d1cd13723a | |||
| 0e946a4b3b | |||
| 051c319890 | |||
| f2baa4ae65 | |||
| 05a93667eb | |||
| c137156c97 | |||
| 8f8a94cd66 | |||
| 4cdb599bf3 | |||
| 77a5ccd796 | |||
| 99ae18d06d | |||
| 90318d5690 | |||
| f133692002 | |||
| 0b17251f94 | |||
| 24724bb19f | |||
| b23d630daa | |||
| fcb954ff40 | |||
| 889225301c | |||
| 816d0e7ceb | |||
| f2c0ffab99 | |||
| f1f61a9038 | |||
| 77439f675b | |||
| c57f17233a | |||
| c91a56547f | |||
| 10dc2d1713 | |||
| d3c8a520ca | |||
| 681f888529 | |||
| bce8c23b05 | |||
| 788bfa7d4b | |||
| 6d331c873b | |||
| a03df7ed36 | |||
| b0b330903c | |||
| 4e16f963a8 | |||
| 8ef4b5537c | |||
| 8273e06e43 | |||
| b91305c490 | |||
| de086aa29e | |||
| 4c69243bef | |||
| 57d8edea0a | |||
| a098c100d7 | |||
| 8fb209440d | |||
| d05736d0eb | |||
| c92a9e61b0 | |||
| 2e04affb00 | |||
| 731b6e8bad | |||
| 2b63e50837 | |||
| e22f512f22 | |||
| 9a83393290 | |||
| 30ebe49875 | |||
| e15a850cfe | |||
| 34ebc06a8d | |||
| a86428cc4d | |||
| efce4cc04e | |||
| a8484cfcaf | |||
| cc55b98e80 | |||
| 0620194c78 | |||
| a00a67f3d1 | |||
| 6b9ad1a1c8 | |||
| 3d0b993c45 | |||
| dc74ac0d0b | |||
| 7ed923b347 | |||
| 1f63aa8cdf | |||
| 84f7eab95d | |||
| 7d6c7f49e5 | |||
| 021e523d63 | |||
| 266dafa4a9 | |||
| 579c903398 | |||
| 5d2e880bc5 | |||
| 8085f7cf11 | |||
| aea4cf7e8b | |||
| 3d56bfa114 | |||
| a7280bf7ed | |||
| 085f9817fc | |||
| fcca77bca5 | |||
| 4bf2e94a9d | |||
| 95c2494545 | |||
| 7662e0eb02 | |||
| 9f438dd648 | |||
| 08a1f3a5f7 | |||
| 26d77de67a | |||
| 4f9ab9db75 | |||
| 0b83518921 | |||
| 0f8de8d699 | |||
| aae23c285e | |||
| daf73dc964 | |||
| bc0893b518 | |||
| a87bda09f7 | |||
| aa7652279e | |||
| d86dea4081 | |||
| 15d476fd40 | |||
| 453a9ff61c | |||
| 26e9a0ef2a | |||
| 7849b2f05c | |||
| 2cbcc40ca9 | |||
| 184dad8185 | |||
| 46a7cd441f | |||
| 55249e07a3 | |||
| d4850b4a18 | |||
| 93819d5894 | |||
| 4a4cec3d69 | |||
| 432993121c | |||
| 1ce0c62c30 | |||
| b1343ba92a | |||
| 84a96d80b7 | |||
| 4b249a0ebb | |||
| 58de308f30 | |||
| f151eb6197 | |||
| 9eaac329b9 | |||
| a33123022f | |||
| 3eac9af403 | |||
| a371058096 | |||
| ee003160e5 | |||
| 847349dcbd | |||
| a2d6aff28b | |||
| cc3e1f2978 | |||
| dc9bf1a1d8 | |||
| 32af32d02e | |||
| d91c99c177 | |||
| d0e8419b2e | |||
| dad3f1041e | |||
| 7b70b2db29 | |||
| e5a04596e1 | |||
| 297f8b9987 | |||
| 9eddab3dd8 | |||
| ec97d2df91 | |||
| 3dd39defc1 | |||
| c0cc9b5247 | |||
| dc13afc071 | |||
| 628bd48f73 | |||
| c827675e14 | |||
| 09fb474921 | |||
| 7e1338e081 | |||
| 7a5d7337cd | |||
| b80041433c | |||
| dd32ab83cb | |||
| c8db448dfc | |||
| 51edca66d1 | |||
| bb5529098f | |||
| d5a54dd1fb | |||
| 329119fc3b | |||
| e43cf26bb5 | |||
| 93bc3bf94b | |||
| 675b5f039a | |||
| b3e4e39b49 | |||
| 4dc072f56d | |||
| b5d72c89f2 | |||
| d2b3900ed4 | |||
| 4e44553c07 | |||
| ec6b5fb155 | |||
| 16e53feaf4 | |||
| 182bdb34cd | |||
| 59d03b54d7 | |||
| 445969c449 | |||
| 29214a3bf3 | |||
| d979dc3535 | |||
| 399413a270 | |||
| d9c8142c51 | |||
| c951285049 | |||
| 0e372969b3 | |||
| 2d51f7b2be | |||
| 02a5dcf9ba | |||
| 9a70cfe24c | |||
| 023d875a00 | |||
| 640f74c612 | |||
| 768b005200 | |||
| fb315127f9 | |||
| 756e6cac5a | |||
| daae5b68cd | |||
| 0e391bee50 | |||
| 9627e65d6d | |||
| 830974500b | |||
| 0438964e17 | |||
| 226d44651f | |||
| db71ac5279 | |||
| 7e2a0b1483 | |||
| a219444953 | |||
| 5fec41055e | |||
| 5107e451d8 | |||
| 3b34cdbda2 | |||
| 35fcd32a96 | |||
| cf325b4267 | |||
| 860ef610b7 | |||
| 5c5e6a60a7 | |||
| 435c38fb27 | |||
| d640b7a5f5 | |||
| ae25b4eb4e | |||
| 315a686fed | |||
| 4dd79b07d9 | |||
| e88d803232 | |||
| 1489854444 | |||
| c5f9e276b4 | |||
| b2a8b359d1 | |||
| 72f6a9e9a3 | |||
| 809172bf34 | |||
| c34887240e | |||
| f041e7cb7d | |||
| 5eca777891 | |||
| a9e3b79eb1 | |||
| 0a376217c6 | |||
| 687d062bfc | |||
| e123498895 | |||
| a917d16c26 | |||
| 0884af038d | |||
| 72619f3dad | |||
| cf120dbcbf | |||
| 9906f1b1a7 | |||
| ea32bfa5fc | |||
| 14f6de8af1 | |||
| 176d5a15c5 | |||
| cfc7a5bd49 | |||
| 4b68ccf678 | |||
| cb57b216d0 | |||
| 222951139c | |||
| 821c7bfbd3 | |||
| 722c1d7917 | |||
| 4d065bc724 | |||
| 2a2cb3acc9 | |||
| 4a16b82700 | |||
| ea97f8cf7a | |||
| 28f113d992 | |||
| 46caf46ef7 | |||
| 185758b638 | |||
| 1e047aed80 | |||
| 8ef7676b4f | |||
| b2fc24d5ae | |||
| 065cd9dff8 | |||
| 532c762553 | |||
| 4857055eaf | |||
| 604379bf85 | |||
| 3d0f793cf6 | |||
| 9f8634ac11 | |||
| 6ab70b97b4 | |||
| 8c164ec833 | |||
| 09d47459b6 | |||
| 2916106f27 | |||
| 6b4292596a | |||
| 78aebb7d6c | |||
| 771bb35e9f | |||
| 453ed4227d | |||
| 88a284a21b | |||
| 9488b85705 | |||
| 8be3fd7ed4 | |||
| 8fdc0019ea | |||
| b21dd73ff2 | |||
| 24432701dd | |||
| 802cdabd75 | |||
| 8c83af1543 | |||
| deb465377e | |||
| bed2aef7f8 | |||
| 57b2b2492f | |||
| 87cdaee547 | |||
| a8b0ef2483 | |||
| 63e976e5cb | |||
| f73830d37b | |||
| 6511c4e810 | |||
| 008c58f52b | |||
| fa4a17f47b | |||
| 3fc2a763b4 | |||
| 1b2726a55c | |||
| 229fa27b73 | |||
| 072f44245d | |||
| dc837c430f | |||
| 429e270a9e | |||
| 985cefc297 | |||
| 285fc0ced3 | |||
| 777e14b6d4 | |||
| d18e8211ca | |||
| 2984980a54 | |||
| 2302f31f18 | |||
| f673677c2a | |||
| acc9bf77fb | |||
| 678769da0e | |||
| 2389208c95 | |||
| 28a1080ccc | |||
| 432d3fd3c3 | |||
| 8fc73b866b | |||
| dbc62aac93 | |||
| 79802a46fd | |||
| 3d8ecb42c1 | |||
| bcaf554ae4 | |||
| 3ac4a921e0 | |||
| 128f86b643 | |||
| 828fc690b6 | |||
| 55d06640b5 | |||
| 3874a8da21 | |||
| 6a3ecd5b09 | |||
| afcd4f7262 | |||
| b0ae731fa6 | |||
| 0b678fe69a | |||
| 4cecc78a74 | |||
| 92273d2fc6 | |||
| 04ec749c3c | |||
| 165377816c | |||
| 729904e1c3 | |||
| 6bc4fa0a82 | |||
| f12403672b | |||
| 97daff8d4f | |||
| a32451fd7f | |||
| ca14770971 | |||
| 35ac0695c7 | |||
| 589b614a53 | |||
| 64ea3e05d8 | |||
| 5d0e115438 | |||
| 9f35c1eded | |||
| ff07346fe9 | |||
| 22fffc2f8c | |||
| 205363dd42 | |||
| d2297b882f | |||
| 48bf8dbc5b | |||
| 9585c760d5 | |||
| 8c2cd0aa4d | |||
| 2e680b04c9 | |||
| e2b81f7b57 | |||
| c38b277887 | |||
| a1f6304b22 | |||
| 09d7f56efc | |||
| 9221f3fc96 | |||
| 6b30a0aebc | |||
| 158563f387 | |||
| b0fc5752e2 | |||
| 28903615ed | |||
| caa83c0432 | |||
| 045f4a42db | |||
| 3275bc9cae | |||
| 6d14eaefe1 | |||
| 03274725be | |||
| 0951006063 | |||
| 616bfc6a2a | |||
| c0bfe429ee | |||
| c1d90485a1 | |||
| 6c30527684 | |||
| 4ac751f492 | |||
| de7b137257 | |||
| 0bf689fa8d | |||
| f31ef1649f | |||
| c66bc62c41 | |||
| 9ee59215c8 | |||
| a991adecaf | |||
| 601b1d5c89 | |||
| 8b593883a7 | |||
| bb549c9a89 | |||
| 9f2005622a | |||
| 4238bde13a | |||
| 2d4ce1aac0 | |||
| 37269abde2 | |||
| 54038eabd4 | |||
| 57922e3071 | |||
| ad36dfd448 | |||
| 6e334d2efb | |||
| d3dc1401fd | |||
| 0f59c9911d | |||
| 0ebfd2bc76 | |||
| 734625c1e3 | |||
| eaaea8a64f | |||
| c026ab1777 | |||
| 2c7193efea | |||
| 21f5ef469b | |||
| e7f9eb6e06 | |||
| 9db9c382ab | |||
| b2ba626cde | |||
| 85d93c4f4b | |||
| 09f6dd8d82 | |||
| 2aa6df48c6 | |||
| 2a16260b05 | |||
| c28158b041 | |||
| 661199850c | |||
| 67b69a45cc | |||
| 579322578b | |||
| 0de65042b0 | |||
| f9643f8651 | |||
| fc9ac4c40e | |||
| 84843066f2 | |||
| af3d5c4654 | |||
| 676f3daf50 | |||
| 54970015cb | |||
| 2b49430530 | |||
| feea5f3518 | |||
| 04e580b40f | |||
| f07a3ba97d | |||
| f44e3a81ab | |||
| 77d3bd019e | |||
| 72725d5ab4 | |||
| aa717ed1fe | |||
| 292b49ba79 | |||
| c600eb5d5a | |||
| 6d2f788fa2 | |||
| b8acff3e7c | |||
| b8bdb03fc0 | |||
| 7257abefb4 | |||
| 3095613a76 | |||
| db12b64b3a | |||
| a081edde25 | |||
| 5496c4a10a | |||
| c968ded99a | |||
| 4224a833b4 | |||
| df470f1a5e | |||
| ed0100a82c | |||
| 0ad72e8334 | |||
| 50ee5d1f49 | |||
| 94283a8da2 | |||
| 86e9a3e838 | |||
| d77b9ef7c9 | |||
| e29afa3155 | |||
| 7376fbe7a1 | |||
| 04e98e1c39 | |||
| ddca6e7ec9 | |||
| 1ac12ac668 | |||
| 399936e3ab | |||
| 3d086992dc | |||
| 62ded1290c | |||
| 33cbdfbd13 | |||
| f5ce6ed4bc | |||
| eb9a2ac2e0 | |||
| 8bed529d05 | |||
| 41a8b8007a | |||
| 141dc843f3 | |||
| 902825404e | |||
| 749eaaab30 | |||
| 8f5767b992 | |||
| 715f0c5853 | |||
| a960fd3d56 | |||
| 17b8ac6d0b | |||
| 4ac78fe4d1 | |||
| 957bcf790f | |||
| 66f0b38008 | |||
| f6a2246aab | |||
| fa3e941069 | |||
| 9bb049f746 | |||
| c41f2a4d65 | |||
| 06ff9f2499 | |||
| f91f9fcc94 | |||
| 9a626b0d4f | |||
| ac2adaf4ba | |||
| 3ab198615b | |||
| 7c2831098c | |||
| 2454e22ea2 | |||
| b1fb40ca61 | |||
| e403e938c3 | |||
| 952ba1f1ea | |||
| a25690c2d3 | |||
| 6b6b9c61d7 | |||
| fcc5e522a7 | |||
| c33c0487cf | |||
| 1753a6c247 | |||
| 195f513416 | |||
| c6d38bb3d7 | |||
| 9b2fba9600 | |||
| c88f6501fa | |||
| c511346160 | |||
| 0b02daac1d | |||
| 2390c085e4 | |||
| 698d94feed | |||
| a9a6d39127 | |||
| 395bd31898 | |||
| 7e24492ce8 | |||
| 19d3d80013 | |||
| 8c7875d7ea | |||
| 110ce0d4c6 | |||
| ff8c57fdab | |||
| 54ccdc57bf | |||
| 2463f06ba1 | |||
| 88d5b1f98f | |||
| 43522e9c81 | |||
| 6e798f739f | |||
| 5b5f1280af | |||
| 2188e8dd78 | |||
| 8659e9ea37 | |||
| 4cff481d61 | |||
| 6da910d8fb | |||
| cc08ad46e3 | |||
| 5b52c1adbb | |||
| a20958a89b | |||
| dea36d4b80 | |||
| 6cb7e4caf7 | |||
| 6e41668b25 | |||
| d3e1acddc5 | |||
| a9c511eb2e | |||
| ffef33a9fc | |||
| 982917ddbb | |||
| 07406a50bb | |||
| 78f325e127 | |||
| e1bb97a1db | |||
| 831952806d | |||
| 4c57b7a009 | |||
| 683188f67a | |||
| 848e5271ab | |||
| 7ca5614c44 | |||
| 70fc781a03 | |||
| aafdbab781 | |||
| 5dd0f7ea10 | |||
| 9393d9105c | |||
| 2c3856be3c | |||
| dc746a51a5 | |||
| 0f2c9354f0 | |||
| af00032ee9 | |||
| 5d7e685dc4 | |||
| bfcb79c02b | |||
| ebee6273b0 | |||
| b45900e5bc | |||
| 9f0657683a | |||
| 35b8d8ca25 | |||
| 8f7095ce19 | |||
| d9c8dd20e9 | |||
| 8dcc462a30 | |||
| b561948030 | |||
| 2cbd8684cf | |||
| 107c9fce94 | |||
| 4c18b3e059 | |||
| 9960ec4d58 | |||
| 2a09e048e4 | |||
| 9711f3ba72 | |||
| 0622d2b81b | |||
| a372d1fb60 | |||
| 99e55e730e | |||
| 57d1e915e6 | |||
| 96ad6228bd | |||
| 678f9fbe87 | |||
| 714933df56 | |||
| f0ef9ad51e | |||
| 0255213934 | |||
| 0ad92a999c | |||
| b06456d573 | |||
| 6f1fc2c9b4 | |||
| 44a1982d87 | |||
| 6b0cf5aa96 | |||
| 2cfac7a772 | |||
| 99aaf35e0b | |||
| 942ed1fb55 | |||
| 25f83a98e3 | |||
| ed4040f2ec | |||
| 41034de676 | |||
| 2db0f1a6c8 | |||
| 6b06cc7ef5 | |||
| 2bf26212af | |||
| 9d273c172d | |||
| 6ae3bc82bb | |||
| c782002274 | |||
| cd0906d041 | |||
| f936ecca33 | |||
| 8794e3bc53 | |||
| 4503e2a222 | |||
| 015725f88c | |||
| 39451f0e37 | |||
| 2bb0b7faa5 | |||
| 8db7c3769a | |||
| d4e32de882 | |||
| db75dea9ee | |||
| 489bba9c4b | |||
| 4accc49d60 | |||
| 5a47cd5216 | |||
| 439608cf27 | |||
| f9df7a1b5a | |||
| 78c801994a | |||
| 882fd68cf9 | |||
| 3433b73edf | |||
| a235d6a8cd | |||
| d76e88af21 | |||
| 3f6c6c443a | |||
| 13c6d406cf | |||
| 19fea4e761 | |||
| c84c96dcd1 | |||
| cdd8ccc2d4 | |||
| 09210d5d40 | |||
| 8f44a26037 | |||
| 5e986b2d04 | |||
| 298c0922cb | |||
| dc127ea6a3 | |||
| 522ed94c32 | |||
| 6edf66a599 | |||
| 475ec24528 | |||
| b555af0df7 | |||
| f90430e544 | |||
| 4ccb75818c | |||
| c5c9ed24c3 | |||
| 386aa0adc1 | |||
| 0b26b75699 | |||
| d013f67c70 | |||
| dc7c0e61fd | |||
| 89bd041d29 | |||
| ac730d6086 | |||
| 427eaed544 | |||
| 00f783c0b6 | |||
| 8e1e53d55e | |||
| 5c9a5c13b8 | |||
| c1f03a8e75 | |||
| 1affa83620 | |||
| 2aa8180113 | |||
| e6001d83a4 | |||
| 8550c8fde9 | |||
| 48d9a09307 | |||
| 9d0b874488 | |||
| 062a13b2c2 | |||
| 937f8723ed | |||
| 0c5cabbd79 | |||
| 95d8f710d8 | |||
| bb9b8b34e5 | |||
| 54c1164bd7 | |||
| 89c1158d95 | |||
| 9cae189941 | |||
| 53a31cd4c4 | |||
| d72a615481 | |||
| 8dcffa80a8 | |||
| 7cf53acd18 | |||
| 748cf68055 | |||
| 9cd22bdc06 | |||
| 5830d4b91c | |||
| b42f7f566b | |||
| db8223ca98 | |||
| 9403afc392 | |||
| 2b2d3b9517 | |||
| 3eebdfcdb3 | |||
| f7766bc3d4 | |||
| f2614abbdd | |||
| 58824ea879 | |||
| 9ef7a18847 | |||
| 58c0167696 | |||
| 9adfd286f9 | |||
| 4e8f530fbb | |||
| 3e694b0772 | |||
| 2fd5b04d4d | |||
| 4688b9a9c9 | |||
| 153e0035ba | |||
| 88b6ecc557 | |||
| 9df55874a6 | |||
| 87d4ab827a | |||
| 318166f23a | |||
| cc3f712659 | |||
| ee399d8a08 | |||
| 96c233d5c5 | |||
| 652b3e1954 | |||
| a8b76d5537 | |||
| e041b70cdd | |||
| 63bf912b3e | |||
| 0cbe7b1655 | |||
| 7bbec29c5b | |||
| 7cec7dbac8 | |||
| 09a19b5f42 | |||
| 8fe765c097 | |||
| b11e8e07c4 | |||
| f72763306d | |||
| e3d1a476e2 | |||
| f0bc86d42f | |||
| a62e806175 | |||
| 5bcf5bf93e | |||
| 4f35ba0931 | |||
| 2bcdfe778a | |||
| c89da1d0f7 | |||
| 46a1eda029 | |||
| 1c39819112 | |||
| 0efe617c03 | |||
| 10df947efe | |||
| fb7790ba4a | |||
| a9338ed822 | |||
| eaab8cdd93 | |||
| edfcd0dc6e | |||
| 838b56089b | |||
| 178810f908 | |||
| 69f5aca853 | |||
| 8e6aece9ae | |||
| 59b883ff7f | |||
| 548d34fbf4 | |||
| bb6345ccfa | |||
| d59a10f718 | |||
| aab1c5419a | |||
| 9b83785b95 | |||
| 099b710eb1 | |||
| a5424afc38 | |||
| 626325066d | |||
| 37195f6008 | |||
| fcbc68cefe | |||
| 9241953e31 | |||
| 651a912498 | |||
| a05f6fb6b5 | |||
| d6440d31f2 | |||
| 88d5dc2f17 | |||
| a17ad85858 | |||
| 4b49c1f30f | |||
| a9e36b9a59 | |||
| 80429bbfb8 | |||
| f39e20d7a7 | |||
| a03bac5d74 | |||
| 1aff09598a | |||
| 4036a71ee1 | |||
| a966be2f46 | |||
| a0b3bc1cab | |||
| eb2e2b0a26 | |||
| 55ad7b2e81 | |||
| dbcd2897a4 | |||
| 68a6d1c166 | |||
| fe82ec6fc2 | |||
| 5f2819a961 | |||
| 812bfc7cf5 | |||
| d164cafd33 | |||
| 3ada4183d9 | |||
| fa68621b41 | |||
| 4f2b9d39da | |||
| fd01c9269f | |||
| 82d150e53a | |||
| 251f3fe2da | |||
| 4f0e1e6b3d | |||
| a5dbf5d4b7 | |||
| 38baf77c30 | |||
| bfb8b03fc9 | |||
| 2dd38d9b03 | |||
| 307c64bc1e | |||
| 782e3a85f9 | |||
| 3bae6e749a | |||
| 530ef6b83e | |||
| 5b334eb2d5 | |||
| 28dc2e425a | |||
| 83b72e7403 | |||
| 01e1f65ffe | |||
| 9b15f888e6 | |||
| 171b8afa8e | |||
| 27f2f9f13a | |||
| 2c3983cead | |||
| bebd043d58 | |||
| a1c828fe62 | |||
| dfb885f38d | |||
| 702c095544 | |||
| b3e886d444 | |||
| 46d85e92cd | |||
| 0d84f2857f | |||
| ac6c80db90 | |||
| e608e85d56 | |||
| 58104a9a4d | |||
| 8d68dcabb5 | |||
| a5e5389d6c | |||
| 8de862c82f | |||
| 5141303ee1 | |||
| 3d95680cbc | |||
| 78663b873c | |||
| 0a106026dd | |||
| 999ca6274c | |||
| 36b9177069 | |||
| 8cf7bf859b | |||
| 2e54b62f60 | |||
| df46069d92 | |||
| e31014dde4 | |||
| f9a14581e1 | |||
| 95ec005894 | |||
| 73d271b8bc | |||
| 0c3b56e44a | |||
| 736f340979 | |||
| 49e62d35c3 | |||
| 810bce7495 | |||
| aff876aa05 | |||
| 9511644ce6 | |||
| 21d73e5f69 | |||
| d62f3fb936 | |||
| 0a011b6075 | |||
| 4e561dc764 | |||
| a55256ad82 | |||
| ab26ef64a5 | |||
| 6d1610eee0 | |||
| 2ba143d6ea | |||
| bd542ac308 | |||
| e71ffd1a77 | |||
| 1ac968f63c | |||
| dc4f62a085 | |||
| ad91d4f1ce | |||
| 8d4c7512ab | |||
| 12d5837526 | |||
| c82d2abab4 | |||
| 26cb717a08 | |||
| 0b1cc0ef5b | |||
| 05dc9138b4 | |||
| a7dcacb26c | |||
| 3ac3f871e4 | |||
| b180c0bbe6 | |||
| e4987f3bde | |||
| ee2690c2cc | |||
| 4cad26f793 | |||
| 84f3d5fec5 | |||
| bb3939d570 | |||
| b303f708f5 | |||
| d39550e090 | |||
| 22d956f38a | |||
| dab7728138 | |||
| 4038b6bc51 | |||
| bcaf3a246c | |||
| 4fb115fcbc | |||
| bfd4b2b6de | |||
| c0cd3fc5c2 | |||
| f95c4393d2 | |||
| e2eb5fabcc | |||
| 7275e8ff0d | |||
| accfc3df12 | |||
| 35392483d9 | |||
| 9770851fd4 | |||
| e013a6f087 | |||
| 1ec9ff20b1 | |||
| 9b0dea80c9 | |||
| eea1ea7ed0 | |||
| 85cd46bfc7 | |||
| a7ca394864 | |||
| e178a0795a | |||
| e8b0470ceb | |||
| c08abfdfdf | |||
| b1c765eb51 | |||
| 4b0f7d45e8 | |||
| 9e6271b1dc | |||
| a96eb31dc9 | |||
| 221a7809b6 | |||
| 8c33243c90 | |||
| c4b07b98aa | |||
| 1fda80a86b | |||
| 1287c729f2 | |||
| c069faa6f4 | |||
| 286fd91b2b | |||
| 33250d2f3d | |||
| 44ca940ca3 | |||
| 3b0ef7a96d | |||
| dfb6c593e4 | |||
| 853b01e2ca | |||
| 5a924fa382 | |||
| d4985a024d | |||
| 2797266de6 | |||
| b476cc91ca | |||
| 9e3aa19a09 | |||
| 7443e8a532 | |||
| 1d6dbf63c0 | |||
| e17687f80d | |||
| 27f4e14a4e | |||
| 8d5de98218 | |||
| dbf5c0a5bd | |||
| c1422f789a | |||
| 613af9399a | |||
| 34a6108bb2 | |||
| 66a5508abe | |||
| 3a273ea64f | |||
| 6f88e6ef26 | |||
| fd7905833e | |||
| 1977e436d6 | |||
| 0dfb3d00e9 | |||
| 785ec9bdb1 | |||
| eeccc4fd49 | |||
| efea9a7c37 | |||
| 04eafd6705 | |||
| 73b554aa48 | |||
| d3ddcf4c20 | |||
| ed9ea2f1d3 | |||
| 7eae3e9923 | |||
| 03d95033d7 | |||
| 9a79606565 | |||
| c5a101aad2 | |||
| 2dbf4d652d | |||
| 7364380312 | |||
| b7fe70aba3 | |||
| bca9982c57 | |||
| 7c16435010 | |||
| a79def625c | |||
| 8d5f804a60 | |||
| 0167381f0d | |||
| 4e2c4b39bb | |||
| 7bfc84abc8 | |||
| 5ac6c64079 | |||
| e7c4261b86 | |||
| 163b75e81b | |||
| 949132ef5a | |||
| 5617d31ed8 | |||
| 832d865397 |
@@ -0,0 +1,167 @@
|
||||
name: Build Docker Image
|
||||
|
||||
on:
|
||||
# 自动触发:push 到 custom 分支时跑(force-push 后的 rebase 也会触发,可接受)
|
||||
# paths-ignore:纯文档/配置改动跳过,避免浪费 ~10 分钟构建
|
||||
# ⚠️ 已知 quirk(2026-05-02 验证):empty commit(git commit --allow-empty)
|
||||
# 不会触发 paths-ignore 过滤的 workflow,Gitea 把 zero-paths-changed 当作
|
||||
# "vacuously matches ignore list" 跳过。要强制触发必须至少改一个非 ignore 路径
|
||||
# 的真实文件(改这个 yml 自己最稳)。
|
||||
push:
|
||||
branches: [custom]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
- 'LICENSE'
|
||||
- 'screenshot/**'
|
||||
# sync-upstream.yml 改的是 main reset 逻辑,跟 build 无关
|
||||
# build-image.yml 自己留着会触发,作为 workflow 改动的 self-test
|
||||
- '.gitea/workflows/sync-upstream.yml'
|
||||
# 手动触发:保留作为应急通道(重新打包旧 commit、用自定义 tag 等等)
|
||||
# 注意:手动触发也会跑 deploy job —— 如果只想 build 不部署,临时把 deploy
|
||||
# job 注释掉或在 deploy 里加 if 条件
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: '要打包的分支(仅手动触发生效)'
|
||||
required: true
|
||||
default: 'custom'
|
||||
tag:
|
||||
description: '镜像 tag(留空则用 commit short hash)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# 并发控制:同一分支的连续 push 只跑最新的,旧 in-progress run 会被取消
|
||||
# 例:连续 3 次 push,第 1 次 build 跑了 30s,第 2 次开始 → 取消第 1,第 2 跑;
|
||||
# 期间第 3 次又来 → 取消第 2,第 3 跑。最后只构建+部署最新代码,省 CI 时间。
|
||||
# group 包含 ref 是为了不同分支的 build 互不干扰(虽然当前只有 custom 用)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout target branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# workflow_dispatch 时用用户填的 branch;push 触发时 inputs.branch 为空,
|
||||
# fallback 到 github.ref_name(即触发的分支名,push 到 custom 时就是 custom)
|
||||
ref: ${{ inputs.branch || github.ref_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# 钉到 v0.13.2(自带 runc 1.1.x),避免 runc 1.2+ 的 procfs 安全检查
|
||||
# 在 DSM 老内核(4.4.x)上撞 openat2/fsmount 不存在导致 build 失败
|
||||
driver-opts: |
|
||||
image=moby/buildkit:v0.13.2
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.zhengchentao.win
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||
|
||||
- name: Determine image tag and revision
|
||||
id: meta
|
||||
run: |
|
||||
if [ -n "${{ inputs.tag }}" ]; then
|
||||
IMAGE_TAG="${{ inputs.tag }}"
|
||||
else
|
||||
IMAGE_TAG="$(git rev-parse --short HEAD)"
|
||||
fi
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
echo "full_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "==> Image tag: $IMAGE_TAG"
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
# 上游 Dockerfile 用 BUILD_PIPELINE 作为 CI 跳过开关:
|
||||
# 设为 "1" 时 pkg/exchangerates 跳过依赖第三方 API 的活测试
|
||||
# (加拿大银行/乌兹别克央行 API 国内不稳,跑就超时)
|
||||
# CHECK_3RD_API 留空 → 三方 API 测试不跑;想跑设 "1"
|
||||
build-args: |
|
||||
BUILD_PIPELINE=1
|
||||
# OCI 标签:
|
||||
# - source 让 Gitea 收包时自动把镜像关联到对应 repo(不再需要手动去
|
||||
# "包设置 → 链接到仓库")
|
||||
# - revision 把构建时的 commit full SHA 烙进镜像 manifest,
|
||||
# docker inspect 能反推回源码版本
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://git.zhengchentao.win/zhengchen.tao/ezbookkeeping
|
||||
org.opencontainers.image.revision=${{ steps.meta.outputs.full_sha }}
|
||||
tags: |
|
||||
git.zhengchentao.win/zhengchen.tao/ezbookkeeping:${{ steps.meta.outputs.image_tag }}
|
||||
git.zhengchentao.win/zhengchen.tao/ezbookkeeping:latest
|
||||
|
||||
- name: Build summary
|
||||
# 把构建出的镜像 tag 与源 commit 显式列在 Action run summary 区,
|
||||
# 方便从 UI 一眼看到本次 build 产出。always() 保证 build 失败也输出。
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## Build Summary"
|
||||
echo ""
|
||||
echo "| 项 | 值 |"
|
||||
echo "|---|---|"
|
||||
echo "| 触发方式 | \`${{ github.event_name }}\` |"
|
||||
echo "| 源分支 | \`${{ inputs.branch || github.ref_name }}\` |"
|
||||
echo "| 源 commit (full) | \`${{ steps.meta.outputs.full_sha }}\` |"
|
||||
echo "| 源 commit (short) | \`${{ steps.meta.outputs.image_tag }}\` |"
|
||||
echo "| 镜像 tag | \`git.zhengchentao.win/zhengchen.tao/ezbookkeeping:${{ steps.meta.outputs.image_tag }}\` + \`:latest\` |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
deploy:
|
||||
# needs: build 串起来 —— build 失败 deploy 自动跳过,无需 if 条件
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# 登录 Gitea Container Registry,否则 docker compose pull 私有镜像 401。
|
||||
# 跟 build job 那步是同一个 PACKAGES_TOKEN,但每个 job 跑在独立 runner 上,
|
||||
# 凭据不会从 build job 继承,必须在这里再登一次。
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.zhengchentao.win
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||
|
||||
- name: Pull and restart ezbookkeeping
|
||||
# 部署逻辑直接内联在这。runner 容器挂了 host docker.sock,
|
||||
# 所以这里 docker 命令直接操作的是宿主机 docker daemon,
|
||||
# 容器层面相当于 "ssh 到 NAS 跑 docker compose"。
|
||||
#
|
||||
# NAS_INFRA_TOKEN secret 仅在 nas-infra 是私有仓库时需要;
|
||||
# 公开仓库不设这个 secret 也能拉。
|
||||
env:
|
||||
NAS_INFRA_TOKEN: ${{ secrets.NAS_INFRA_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
# 决定 clone URL:有 token 用 token(私有),没有用裸 URL(公开)
|
||||
if [ -n "$NAS_INFRA_TOKEN" ]; then
|
||||
CLONE_URL="https://x-access-token:${NAS_INFRA_TOKEN}@git.zhengchentao.win/dev/nas-infra.git"
|
||||
else
|
||||
CLONE_URL="https://git.zhengchentao.win/dev/nas-infra.git"
|
||||
fi
|
||||
|
||||
git clone --depth 1 "$CLONE_URL" "$TMPDIR/nas-infra"
|
||||
cd "$TMPDIR/nas-infra/ezbookkeeping"
|
||||
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# 简单 health:列容器状态 + 输出最近日志
|
||||
sleep 3
|
||||
docker compose ps
|
||||
docker compose logs --tail=30 ezbookkeeping
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Sync from upstream
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: '要同步的 release tag(留空则同步到 upstream/main 的最新 tag)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.SYNC_TOKEN }}
|
||||
|
||||
- name: Sync main to release tag
|
||||
run: |
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@gitea.local"
|
||||
git remote add upstream https://git.zhengchentao.win/mirror/ezbookkeeping.git
|
||||
git fetch upstream --tags
|
||||
|
||||
if [ -n "${{ inputs.tag }}" ]; then
|
||||
TARGET="${{ inputs.tag }}"
|
||||
else
|
||||
TARGET=$(git tag -l --sort=-v:refname | head -n 1)
|
||||
fi
|
||||
|
||||
echo "==> Syncing main to $TARGET"
|
||||
git rev-parse "$TARGET" || { echo "❌ Tag $TARGET not found"; exit 1; }
|
||||
|
||||
git checkout -B main origin/main
|
||||
git reset --hard "$TARGET"
|
||||
git push origin main --force-with-lease
|
||||
git push origin --tags
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Bug Report
|
||||
description: Report a bug in ezBookkeeping
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: checkboxes
|
||||
attributes:
|
||||
label: Before You Submit
|
||||
description: Please check whether the following items have been completed.
|
||||
options:
|
||||
- label: I've already checked this bug hasn't been raised in [issues](https://github.com/mayswind/ezbookkeeping/issues)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please provide a brief description of this bug.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Please describe the steps to reproduce this bug.
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: ezbookkeeping-version
|
||||
attributes:
|
||||
label: ezBookkeeping Version
|
||||
description: ezBookkeeping version and commit hash of your instance, e.g. "v1.0.0 (20e2444)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: server-os
|
||||
attributes:
|
||||
label: Server Operating System
|
||||
description: The operating system information you are using to deploy ezBookkeeping, e.g "Debian GNU/Linux 11 amd64"
|
||||
|
||||
- type: input
|
||||
id: server-database
|
||||
attributes:
|
||||
label: Database
|
||||
description: The database system you are using, e.g. "MariaDB v11.7.2"
|
||||
|
||||
- type: dropdown
|
||||
id: reproduce-on-demo-site
|
||||
attributes:
|
||||
label: Can you reproduce this bug on the ezBookkeeping demo site?
|
||||
description: |
|
||||
ezBookkeeping demo site: https://ezbookkeeping-demo.mayswind.net/
|
||||
options:
|
||||
- "No"
|
||||
- "Yes"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: If you can, provide any related screenshots or logs here.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Frequently Asked Questions
|
||||
url: https://ezbookkeeping.mayswind.net/faq
|
||||
about: Please check whether your question has already been mentioned here.
|
||||
- name: Usage Questions
|
||||
url: https://github.com/mayswind/ezbookkeeping/discussions/categories/q-a
|
||||
about: Questions about using ezBookkeeping can be discussed in GitHub Discussions.
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Feature Request
|
||||
description: Request a feature or enhancement for ezBookkeeping
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: checkboxes
|
||||
attributes:
|
||||
label: Before You Submit
|
||||
description: Please check whether the following items have been completed.
|
||||
options:
|
||||
- label: I've already checked this request hasn't been raised in [issues](https://github.com/mayswind/ezbookkeeping/issues)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: Please describe your feature request.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: If you can, provide any other context or screenshots about this feature request here.
|
||||
@@ -0,0 +1,124 @@
|
||||
name: Build docker image and package for linux
|
||||
|
||||
inputs:
|
||||
release-build:
|
||||
required: false
|
||||
type: string
|
||||
build-unix-time:
|
||||
required: false
|
||||
type: string
|
||||
build-date:
|
||||
required: false
|
||||
type: string
|
||||
check-3rd-api:
|
||||
required: false
|
||||
type: string
|
||||
skip-tests:
|
||||
required: false
|
||||
type: string
|
||||
platform:
|
||||
required: true
|
||||
type: string
|
||||
platform-name:
|
||||
required: true
|
||||
type: string
|
||||
docker-push:
|
||||
required: true
|
||||
type: boolean
|
||||
docker-image-name:
|
||||
required: true
|
||||
type: string
|
||||
docker-username:
|
||||
required: false
|
||||
type: string
|
||||
docker-password:
|
||||
required: false
|
||||
type: string
|
||||
docker-bake-meta-file-path:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-meta-artifact-name:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-digests-file-path:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-digests-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
package-file-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
package-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Download docker bake meta artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.docker-bake-meta-artifact-name }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: ${{ inputs.docker-username != '' && inputs.docker-password != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-password }}
|
||||
|
||||
- name: Build docker for ${{ inputs.platform-name }}
|
||||
id: bake
|
||||
uses: docker/bake-action@v6
|
||||
with:
|
||||
files: |
|
||||
./docker-bake.hcl
|
||||
cwd://${{ inputs.docker-bake-meta-file-path }}
|
||||
source: .
|
||||
targets: image
|
||||
set: |
|
||||
*.tags=${{ inputs.docker-image-name }}
|
||||
*.platform=${{ inputs.platform }}
|
||||
*.args.RELEASE_BUILD=${{ inputs.release-build }}
|
||||
*.args.BUILD_PIPELINE=1
|
||||
*.args.BUILD_UNIXTIME=${{ inputs.build-unix-time }}
|
||||
*.args.BUILD_DATE=${{ inputs.build-date }}
|
||||
*.args.CHECK_3RD_API=${{ inputs.check-3rd-api }}
|
||||
*.args.SKIP_TESTS=${{ inputs.skip-tests }}
|
||||
*.output=type=image,push-by-digest=true,name-canonical=true,push=${{ inputs.docker-push }}
|
||||
*.output+=type=local,dest=${{ runner.temp }}/package
|
||||
|
||||
- name: Export digests file for ${{ inputs.platform-name }}
|
||||
shell: bash
|
||||
run: |
|
||||
digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}"
|
||||
mkdir -p ${{ inputs.docker-bake-digests-file-path }}
|
||||
touch "${{ inputs.docker-bake-digests-file-path }}/${digest#sha256:}"
|
||||
|
||||
- name: Build package file for ${{ inputs.platform-name }}
|
||||
shell: bash
|
||||
run: |
|
||||
cd ${{ runner.temp }}/package/ezbookkeeping
|
||||
tar -czf ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-${{ inputs.platform-name }}.tar.gz *
|
||||
|
||||
- name: Upload ${{ inputs.platform-name }} digests artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.docker-bake-digests-artifact-name-prefix }}-${{ inputs.platform-name }}
|
||||
path: ${{ inputs.docker-bake-digests-file-path }}/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload artifact for ${{ inputs.platform-name }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-${{ inputs.platform-name }}
|
||||
path: ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-${{ inputs.platform-name }}.tar.gz
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Build backend file for windows
|
||||
|
||||
inputs:
|
||||
go-version:
|
||||
required: false
|
||||
default: "1.25.7"
|
||||
mingw-version:
|
||||
required: false
|
||||
default: "15.2.0"
|
||||
mingw-revison:
|
||||
required: false
|
||||
default: "v13-rev1"
|
||||
release-build:
|
||||
required: false
|
||||
type: string
|
||||
build-unix-time:
|
||||
required: false
|
||||
type: string
|
||||
build-date:
|
||||
required: false
|
||||
type: string
|
||||
check-3rd-api:
|
||||
required: false
|
||||
type: string
|
||||
skip-tests:
|
||||
required: false
|
||||
type: string
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ inputs.go-version }}
|
||||
|
||||
- name: Install MinGW
|
||||
shell: pwsh
|
||||
run: |
|
||||
$mingwVersion = "${{ inputs.mingw-version }}"
|
||||
$mingwRevision = "${{ inputs.mingw-revison }}"
|
||||
$url = "https://github.com/niXman/mingw-builds-binaries/releases/download/${mingwVersion}-rt_${mingwRevision}/x86_64-${mingwVersion}-release-posix-seh-ucrt-rt_${mingwRevision}.7z"
|
||||
$archive = "C:\mingw.7z"
|
||||
$mingwDir = "C:\mingw64"
|
||||
|
||||
Write-Host "Downloading MinGW from ${url}"
|
||||
Invoke-WebRequest -Uri ${url} -OutFile ${archive}
|
||||
|
||||
Remove-Item -Recurse -Force ${mingwDir}
|
||||
New-Item -ItemType Directory -Path ${mingwDir}
|
||||
|
||||
Write-Host "Extracting MinGW to ${mingwDir}"
|
||||
7z x ${archive} -oC:\
|
||||
"${mingwDir}\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
- name: Build backend for windows-x64
|
||||
shell: pwsh
|
||||
env:
|
||||
RELEASE_BUILD: "${{ inputs.release-build }}"
|
||||
BUILD_PIPELINE: "1"
|
||||
BUILD_UNIXTIME: "${{ inputs.build-unix-time }}"
|
||||
BUILD_DATE: "${{ inputs.build-date }}"
|
||||
CHECK_3RD_API: "${{ inputs.check-3rd-api }}"
|
||||
SKIP_TESTS: "${{ inputs.skip-tests }}"
|
||||
run: |
|
||||
.\build.ps1 backend
|
||||
|
||||
- name: Upload windows backend artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
|
||||
path: ezbookkeeping.exe
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Build packages for windows
|
||||
|
||||
inputs:
|
||||
package-file-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
package-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download windows-x64 backend file
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
|
||||
path: ${{ runner.temp }}\backend
|
||||
|
||||
- name: Download linux-amd64 packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-linux-amd64
|
||||
path: ${{ runner.temp }}\package
|
||||
|
||||
- name: Extract frontend files from linux-amd64 package
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path package
|
||||
tar -xzf (Get-ChildItem ${{ runner.temp }}\package\${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz) -C package
|
||||
|
||||
- name: Package windows-x64 package
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path "ezbookkeeping"
|
||||
New-Item -ItemType Directory -Path "ezbookkeeping\data"
|
||||
New-Item -ItemType Directory -Path "ezbookkeeping\storage"
|
||||
New-Item -ItemType Directory -Path "ezbookkeeping\log"
|
||||
Copy-Item ${{ runner.temp }}\backend\ezbookkeeping.exe -Destination ezbookkeeping\
|
||||
Copy-Item -Recurse -Force package\public -Destination ezbookkeeping\public
|
||||
Copy-Item -Recurse -Force conf -Destination ezbookkeeping\conf
|
||||
Copy-Item -Recurse -Force templates -Destination ezbookkeeping\templates
|
||||
Copy-Item .\LICENSE -Destination ezbookkeeping\
|
||||
Push-Location ezbookkeeping
|
||||
7z a -r -tzip -mx9 ..\${{ inputs.package-file-name-prefix }}-windows-x64.zip *
|
||||
Pop-Location
|
||||
Remove-Item -Recurse -Force ezbookkeeping
|
||||
|
||||
- name: Upload windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-windows-x64
|
||||
path: ${{ inputs.package-file-name-prefix }}-windows-x64.zip
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Push linux docker multi-arch image to registry
|
||||
|
||||
inputs:
|
||||
docker-image-name:
|
||||
required: true
|
||||
type: string
|
||||
docker-username:
|
||||
required: true
|
||||
type: string
|
||||
docker-password:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-meta-file-path:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-meta-artifact-name:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-digests-file-path:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-digests-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
docker-image-tags:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download docker bake meta artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.docker-bake-meta-artifact-name }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Download digests artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: ${{ inputs.docker-bake-digests-artifact-name-prefix }}-*
|
||||
merge-multiple: true
|
||||
path: ${{ inputs.docker-bake-digests-file-path }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-password }}
|
||||
|
||||
- name: Create manifest and push
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.docker-bake-digests-file-path }}
|
||||
run: |
|
||||
docker buildx imagetools create $(echo "${{ inputs.docker-image-tags }}" | xargs -I {} echo -n " -t {}") $(printf '${{ inputs.docker-image-name }}@sha256:%s ' *)
|
||||
@@ -0,0 +1,352 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FRONTEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'src', 'locales');
|
||||
const BACKEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'pkg', 'locales');
|
||||
const OUTPUT_DIR = process.argv[2] || path.join(__dirname, '..', '..', 'i18n-badge');
|
||||
|
||||
const DEFAULT_LANGUAGE_TAG = 'en';
|
||||
|
||||
const BACKEND_SKIP_STRUCTS = new Set([
|
||||
'GlobalTextItems',
|
||||
'DefaultTypes',
|
||||
'DataConverterTextItems',
|
||||
]);
|
||||
|
||||
function discoverFrontendLanguages() {
|
||||
const indexPath = path.join(FRONTEND_LOCALES_DIR, 'index.ts');
|
||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||
|
||||
const importMap = {};
|
||||
const importRegex = /import\s+(\w+)\s+from\s+['"]\.\/([\w_]+\.json)['"]/g;
|
||||
let match;
|
||||
|
||||
while ((match = importRegex.exec(content)) !== null) {
|
||||
importMap[match[1]] = match[2];
|
||||
}
|
||||
|
||||
const result = {};
|
||||
const langRegex = /['"]([^'"]+)['"]\s*:\s*\{[^}]*content\s*:\s*(\w+)/g;
|
||||
|
||||
while ((match = langRegex.exec(content)) !== null) {
|
||||
const tag = match[1];
|
||||
const varName = match[2];
|
||||
|
||||
if (importMap[varName]) {
|
||||
result[tag] = importMap[varName];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function discoverBackendLanguages() {
|
||||
const allLocalesPath = path.join(BACKEND_LOCALES_DIR, 'all_locales.go');
|
||||
const content = fs.readFileSync(allLocalesPath, 'utf-8');
|
||||
|
||||
const result = {};
|
||||
const entryRegex = /"([^"]+)"\s*:\s*\{[^}]*Content\s*:\s*(\w+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = entryRegex.exec(content)) !== null) {
|
||||
const tag = match[1];
|
||||
const fileName = tag.toLowerCase().replace(/-/g, '_') + '.go';
|
||||
const filePath = path.join(BACKEND_LOCALES_DIR, fileName);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
result[tag] = fileName;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function flattenJSON(obj, prefix) {
|
||||
const result = {};
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? prefix + '.' + key : key;
|
||||
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
Object.assign(result, flattenJSON(obj[key], fullKey));
|
||||
} else {
|
||||
result[fullKey] = obj[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function shouldSkipFrontendKey(key) {
|
||||
if (key.startsWith('global.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('default.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('currency.')) {
|
||||
if (key.startsWith('currency.unit.')) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (key.startsWith('mapprovider.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('encoding.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('document.')) {
|
||||
if (key.startsWith('document.anchor.')) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isFrontendAlwaysTranslatedKey(key) {
|
||||
if (key.startsWith('language.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('format.')) {
|
||||
if (key.startsWith('format.misc.')) {
|
||||
if (key === 'format.misc.multiTextJoinSeparator') {
|
||||
return true;
|
||||
} else if (key === 'format.misc.eachMonthDayInMonthDays') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else if (key.startsWith('datetime.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('timezone.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('currency.')) {
|
||||
if (key === 'currency.name.EUR') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (key.startsWith('parameter.')) {
|
||||
if (key === 'parameter.id') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (key === 'OK') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractGoStringFields(content) {
|
||||
const fields = [];
|
||||
const structBlockRegex = /(\w+):\s*&\w+\{([^}]*)\}/gs;
|
||||
let blockMatch;
|
||||
|
||||
while ((blockMatch = structBlockRegex.exec(content)) !== null) {
|
||||
const structName = blockMatch[1];
|
||||
const blockBody = blockMatch[2];
|
||||
const fieldRegex = /(\w+):\s+"((?:[^"\\]|\\.)*)"/g;
|
||||
let fieldMatch;
|
||||
|
||||
while ((fieldMatch = fieldRegex.exec(blockBody)) !== null) {
|
||||
fields.push({
|
||||
struct: structName,
|
||||
name: fieldMatch[1],
|
||||
value: fieldMatch[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function getProgressColor(progress) {
|
||||
if (progress >= 95) {
|
||||
return 'brightgreen';
|
||||
} else if (progress >= 90) {
|
||||
return 'green';
|
||||
} else if (progress >= 70) {
|
||||
return 'yellowgreen';
|
||||
} else if (progress >= 50) {
|
||||
return 'yellow';
|
||||
} else if (progress >= 20) {
|
||||
return 'orange';
|
||||
} else {
|
||||
return 'red';
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const frontendLangs = discoverFrontendLanguages();
|
||||
const backendLangs = discoverBackendLanguages();
|
||||
const allTags = new Set([...Object.keys(frontendLangs), ...Object.keys(backendLangs)]);
|
||||
|
||||
console.log('Discovered ' + allTags.size + ' languages: ' + [...allTags].sort().join(', '));
|
||||
|
||||
const defaultFrontendJSON = JSON.parse(fs.readFileSync(path.join(FRONTEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.json`), 'utf-8'));
|
||||
const defaultFrontendItemsMap = flattenJSON(defaultFrontendJSON, '');
|
||||
const defaultFrontendKeys = Object.keys(defaultFrontendItemsMap);
|
||||
const frontendTranslatableKeys = defaultFrontendKeys.filter(function (k) {
|
||||
return !shouldSkipFrontendKey(k);
|
||||
});
|
||||
const frontendSkippedCount = defaultFrontendKeys.length - frontendTranslatableKeys.length;
|
||||
const frontendTotal = frontendTranslatableKeys.length;
|
||||
|
||||
const defaultBackendContent = fs.readFileSync(path.join(BACKEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.go`), 'utf-8');
|
||||
const defaultBackendItems = extractGoStringFields(defaultBackendContent);
|
||||
const defaultBackendTranslatableItems = defaultBackendItems.filter(function (f) {
|
||||
return !BACKEND_SKIP_STRUCTS.has(f.struct);
|
||||
});
|
||||
const backendSkippedCount = defaultBackendItems.length - defaultBackendTranslatableItems.length;
|
||||
const backendTotal = defaultBackendTranslatableItems.length;
|
||||
|
||||
console.log('Frontend: ' + frontendTotal + ' translatable keys (' + frontendSkippedCount + ' excluded)');
|
||||
console.log('Backend: ' + backendTotal + ' translatable fields (' + backendSkippedCount + ' excluded)');
|
||||
|
||||
const results = {};
|
||||
const untranslatedKeys = {};
|
||||
|
||||
for (const tag of allTags) {
|
||||
results[tag] = {
|
||||
languageTag: tag,
|
||||
frontendTranslated: 0,
|
||||
frontendTotal: frontendTotal,
|
||||
backendTranslated: 0,
|
||||
backendTotal: backendTotal
|
||||
};
|
||||
untranslatedKeys[tag] = [];
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(frontendLangs)) {
|
||||
if (tag === DEFAULT_LANGUAGE_TAG) {
|
||||
results[tag].frontendTranslated = frontendTotal;
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = frontendLangs[tag];
|
||||
const filePath = path.join(FRONTEND_LOCALES_DIR, file);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
const kv = flattenJSON(json, '');
|
||||
let translated = 0;
|
||||
|
||||
for (const key of frontendTranslatableKeys) {
|
||||
if (kv[key] !== undefined && kv[key] !== '' && (kv[key] !== defaultFrontendItemsMap[key] || isFrontendAlwaysTranslatedKey(key))) {
|
||||
translated++;
|
||||
} else {
|
||||
untranslatedKeys[tag].push({ source: path.join('src', 'locales', file), key: key, defaultValue: defaultFrontendItemsMap[key], value: kv[key] });
|
||||
}
|
||||
}
|
||||
|
||||
results[tag].frontendTranslated = translated;
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(backendLangs)) {
|
||||
if (tag === DEFAULT_LANGUAGE_TAG) {
|
||||
results[tag].backendTranslated = backendTotal;
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = backendLangs[tag];
|
||||
const filePath = path.join(BACKEND_LOCALES_DIR, file);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const fields = extractGoStringFields(content).filter(function (f) {
|
||||
return !BACKEND_SKIP_STRUCTS.has(f.struct);
|
||||
});
|
||||
let translated = 0;
|
||||
|
||||
for (let i = 0; i < defaultBackendTranslatableItems.length; i++) {
|
||||
if (i < fields.length && fields[i].value !== defaultBackendTranslatableItems[i].value) {
|
||||
translated++;
|
||||
} else {
|
||||
untranslatedKeys[tag].push({ source: path.join('pkg', 'locales', file), key: defaultBackendTranslatableItems[i].struct + '.' + defaultBackendTranslatableItems[i].name, defaultValue: defaultBackendTranslatableItems[i].value, value: (i < fields.length) ? fields[i].value : null });
|
||||
}
|
||||
}
|
||||
|
||||
results[tag].backendTranslated = translated;
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(results)) {
|
||||
const r = results[tag];
|
||||
const totalTranslated = r.frontendTranslated + r.backendTranslated;
|
||||
const totalItems = r.frontendTotal + r.backendTotal;
|
||||
r.totalProgress = Math.round((totalTranslated / totalItems) * 10000) / 100;
|
||||
}
|
||||
|
||||
const sortedResults = {};
|
||||
var sortedTags = Object.keys(results).sort();
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
sortedResults[tag] = results[tag];
|
||||
}
|
||||
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
var badgesDir = path.join(OUTPUT_DIR, 'badges');
|
||||
|
||||
if (!fs.existsSync(badgesDir)) {
|
||||
fs.mkdirSync(badgesDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, 'i18n-progress.json'),
|
||||
JSON.stringify(sortedResults, null, 4) + '\n'
|
||||
);
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
const data = sortedResults[tag];
|
||||
const badge = {
|
||||
schemaVersion: 1,
|
||||
label: 'translation',
|
||||
message: data.totalProgress + '%',
|
||||
color: getProgressColor(data.totalProgress)
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(badgesDir, tag + '.json'),
|
||||
JSON.stringify(badge, null, 4) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
var untranslatedDir = path.join(OUTPUT_DIR, 'untranslated');
|
||||
|
||||
if (!fs.existsSync(untranslatedDir)) {
|
||||
fs.mkdirSync(untranslatedDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
const items = untranslatedKeys[tag] || [];
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(untranslatedDir, tag + '.json'),
|
||||
JSON.stringify(items, null, 4) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
const data = sortedResults[tag];
|
||||
const missingCount = (untranslatedKeys[tag] || []).length;
|
||||
console.log(tag + ': ' + data.totalProgress + '% (frontend: ' + data.frontendTranslated + '/' + data.frontendTotal + ', backend: ' + data.backendTranslated + '/' + data.backendTotal + ', untranslated: ' + missingCount + ')');
|
||||
}
|
||||
|
||||
console.log('\nResults written to ' + OUTPUT_DIR);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- myrequirement
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set lowercase image name
|
||||
run: echo "IMAGE_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/${{ env.IMAGE_NAME }}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,150 @@
|
||||
name: Build for Non-Main Branches
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
- myrequirement
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
|
||||
build-date: ${{ steps.variable.outputs.build_date }}
|
||||
docker-tags: ${{ steps.meta.outputs.tags }}
|
||||
docker-labels: ${{ steps.meta.outputs.labels }}
|
||||
ezbookkeeping-docker-bake-meta-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
ezbookkeeping-docker-bake-meta-artifact-name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
|
||||
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
|
||||
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
|
||||
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
|
||||
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=dev-${{ github.run_id }}
|
||||
|
||||
- name: Set up variables
|
||||
id: variable
|
||||
run: |
|
||||
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_bake_meta_artifact_name=ezbookkeeping-build-dev-docker-bake-meta-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-dev-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-dev-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Rename docker bake meta file
|
||||
run: |
|
||||
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
|
||||
|
||||
- name: Upload docker bake meta artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux-docker-and-package-x86:
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: linux/amd64
|
||||
platform-name: linux-amd64
|
||||
docker-push: false
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-linux-docker-and-package-arm:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
needs:
|
||||
- setup
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/arm64/v8
|
||||
platform-name: linux-arm64
|
||||
- platform: linux/arm/v7
|
||||
platform-name: linux-armv7
|
||||
- platform: linux/arm/v6
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: ${{ matrix.platform }}
|
||||
platform-name: ${{ matrix.platform-name }}
|
||||
docker-push: false
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-windows-backend:
|
||||
needs:
|
||||
- setup
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-windows-package:
|
||||
needs:
|
||||
- setup
|
||||
- build-windows-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-windows-package
|
||||
with:
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
@@ -0,0 +1,204 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
|
||||
build-date: ${{ steps.variable.outputs.build_date }}
|
||||
docker-version: ${{ steps.meta.outputs.version }}
|
||||
docker-tags: ${{ steps.meta.outputs.tags }}
|
||||
docker-labels: ${{ steps.meta.outputs.labels }}
|
||||
ezbookkeeping-docker-bake-meta-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
ezbookkeeping-docker-bake-meta-artifact-name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
|
||||
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
|
||||
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
|
||||
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
|
||||
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up variables
|
||||
id: variable
|
||||
run: |
|
||||
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_bake_meta_artifact_name=ezbookkeeping-build-release-docker-bake-meta-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-release-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-release-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-release-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Rename docker bake meta file
|
||||
run: |
|
||||
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
|
||||
|
||||
- name: Upload docker bake meta artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux-docker-and-package-x86:
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
release-build: 1
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: linux/amd64
|
||||
platform-name: linux-amd64
|
||||
docker-push: true
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-linux-docker-and-package-arm:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
needs:
|
||||
- setup
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/arm64/v8
|
||||
platform-name: linux-arm64
|
||||
- platform: linux/arm/v7
|
||||
platform-name: linux-armv7
|
||||
- platform: linux/arm/v6
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
release-build: 1
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: ${{ matrix.platform }}
|
||||
platform-name: ${{ matrix.platform-name }}
|
||||
docker-push: true
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
push-linux-docker:
|
||||
needs:
|
||||
- setup
|
||||
- build-linux-docker-and-package-x86
|
||||
- build-linux-docker-and-package-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/push-linux-docker
|
||||
with:
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
docker-image-tags: ${{ needs.setup.outputs.docker-tags }}
|
||||
|
||||
build-windows-backend:
|
||||
needs:
|
||||
- setup
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
release-build: 1
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-windows-package:
|
||||
needs:
|
||||
- setup
|
||||
- build-windows-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-windows-package
|
||||
with:
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
publish-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- setup
|
||||
- build-linux-docker-and-package-x86
|
||||
- build-linux-docker-and-package-arm
|
||||
- build-windows-package
|
||||
- push-linux-docker
|
||||
steps:
|
||||
- name: Download all packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}-*
|
||||
merge-multiple: true
|
||||
path: release-files
|
||||
|
||||
- name: Publish Release ${{ github.ref_name }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: ./release-files/*
|
||||
draft: true
|
||||
@@ -0,0 +1,177 @@
|
||||
name: Build Snapshot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
|
||||
build-date: ${{ steps.variable.outputs.build_date }}
|
||||
docker-version: ${{ steps.meta.outputs.version }}
|
||||
docker-tags: ${{ steps.meta.outputs.tags }}
|
||||
docker-labels: ${{ steps.meta.outputs.labels }}
|
||||
ezbookkeeping-docker-bake-meta-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
ezbookkeeping-docker-bake-meta-artifact-name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
|
||||
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
|
||||
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
|
||||
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
|
||||
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}-${{ github.run_id }}
|
||||
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
|
||||
type=raw,value=latest-snapshot
|
||||
|
||||
- name: Set up variables
|
||||
id: variable
|
||||
run: |
|
||||
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_bake_meta_artifact_name=ezbookkeeping-build-dev-docker-bake-meta-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-dev-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-dev-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Rename docker bake meta file
|
||||
run: |
|
||||
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
|
||||
|
||||
- name: Upload docker bake meta artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux-docker-and-package-x86:
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: linux/amd64
|
||||
platform-name: linux-amd64
|
||||
docker-push: true
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-linux-docker-and-package-arm:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
needs:
|
||||
- setup
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/arm64/v8
|
||||
platform-name: linux-arm64
|
||||
- platform: linux/arm/v7
|
||||
platform-name: linux-armv7
|
||||
- platform: linux/arm/v6
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: ${{ matrix.platform }}
|
||||
platform-name: ${{ matrix.platform-name }}
|
||||
docker-push: true
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
push-linux-docker:
|
||||
needs:
|
||||
- setup
|
||||
- build-linux-docker-and-package-x86
|
||||
- build-linux-docker-and-package-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/push-linux-docker
|
||||
with:
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
docker-image-tags: ${{ needs.setup.outputs.docker-tags }}
|
||||
|
||||
build-windows-backend:
|
||||
needs:
|
||||
- setup
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-windows-package:
|
||||
needs:
|
||||
- setup
|
||||
- build-windows-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-windows-package
|
||||
with:
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
-
|
||||
name: Set up docker tag
|
||||
id: vars
|
||||
run: echo ::set-output name=RELEASE_TAG::${GITHUB_REF/refs\/tags\/v/}
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6
|
||||
push: true
|
||||
build-args: |
|
||||
RELEASE_BUILD=1
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:${{ steps.vars.outputs.RELEASE_TAG }}
|
||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:latest
|
||||
@@ -1,41 +0,0 @@
|
||||
name: Docker Snapshot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
-
|
||||
name: Set up docker tag
|
||||
id: vars
|
||||
run: echo ::set-output name=BUILD_DATE::$(date '+%Y%m%d')
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:SNAPSHOT-${{ steps.vars.outputs.BUILD_DATE }}
|
||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:latest-snapshot
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Update i18n Translation Progress Badges
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/locales/**'
|
||||
- 'pkg/locales/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-i18n-progress:
|
||||
if: vars.UPDATE_I18N_BADGE_REPO == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Update translation progress data
|
||||
run: |
|
||||
node .github/scripts/update-i18n-progress.js ${{ runner.temp }}/i18n-badge
|
||||
|
||||
- name: Checkout badge repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: mayswind/ezbookkeeping-i18n-badge
|
||||
token: ${{ secrets.I18N_BADGE_REPO_TOKEN }}
|
||||
path: ezbookkeeping-i18n-badge
|
||||
|
||||
- name: Update badge data
|
||||
run: |
|
||||
rm -rf ezbookkeeping-i18n-badge/i18n-progress.json
|
||||
cp ${{ runner.temp }}/i18n-badge/i18n-progress.json ezbookkeeping-i18n-badge/
|
||||
mkdir -p ezbookkeeping-i18n-badge/badges
|
||||
rm -rf ezbookkeeping-i18n-badge/badges/*
|
||||
cp ${{ runner.temp }}/i18n-badge/badges/*.json ezbookkeeping-i18n-badge/badges/
|
||||
mkdir -p ezbookkeeping-i18n-badge/untranslated
|
||||
rm -rf ezbookkeeping-i18n-badge/untranslated/*
|
||||
cp ${{ runner.temp }}/i18n-badge/untranslated/*.json ezbookkeeping-i18n-badge/untranslated/
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
cd ezbookkeeping-i18n-badge
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "Update i18n progress data (${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})"
|
||||
git push
|
||||
fi
|
||||
|
||||
- name: Purge GitHub camo image cache
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CAMO_URLS=$(curl -s -H "Accept: application/vnd.github.html+json" -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/${{ github.repository }}/readme" | grep -oP 'https://camo\.githubusercontent\.com/[^"]+' | sort -u)
|
||||
|
||||
if [ -z "$CAMO_URLS" ]; then
|
||||
echo "No camo URLs found, skipping cache purge"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for url in $CAMO_URLS; do
|
||||
echo "Purging: $url"
|
||||
curl -s -X PURGE "$url" > /dev/null
|
||||
done
|
||||
|
||||
echo "Purged $(echo "$CAMO_URLS" | wc -l) camo URLs"
|
||||
+19
@@ -144,3 +144,22 @@ dist/
|
||||
# Visual Studio Code
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# Roo Code
|
||||
.roo/
|
||||
|
||||
# Binary and build files
|
||||
ezbookkeeping
|
||||
!**/ezbookkeeping/
|
||||
package/
|
||||
|
||||
# Environment variable files
|
||||
.env
|
||||
**/.env
|
||||
|
||||
# Other directories
|
||||
data/
|
||||
storage/
|
||||
log/
|
||||
|
||||
.claude/
|
||||
@@ -0,0 +1,127 @@
|
||||
# CLAUDE.md
|
||||
|
||||
本项目是 [mayswind/ezbookkeeping](https://github.com/mayswind/ezbookkeeping) 的个人 fork。
|
||||
|
||||
> **本文件**:仓库分支模型、上游同步流程、CI 故障排查 —— meta 层
|
||||
> **[`FORK.md`](FORK.md)**:fork 相对上游的具体改动清单(feature 维度 + 进度状态)
|
||||
> **个人笔记**:通用 fork 工作流决策框架在 `fork-工作流决策框架.md`(不入库)
|
||||
|
||||
本文件只记录**这个仓库的具体事实**,避免 Claude 会话误判。
|
||||
|
||||
---
|
||||
|
||||
## 仓库拓扑
|
||||
|
||||
```
|
||||
github.com/mayswind/ezbookkeeping (上游)
|
||||
│ Gitea pull mirror(后台异步)
|
||||
▼
|
||||
git.zhengchentao.win/mirror/ezbookkeeping (只读镜像)
|
||||
│ CI workflow 拉过来
|
||||
▼
|
||||
git.zhengchentao.win/dev/ezbookkeeping (origin,本地唯一 remote)
|
||||
```
|
||||
|
||||
本地 `git remote -v` 只有 origin 一项,**没有手工配 upstream**。上游同步通过 custom 分支上的 workflow 在服务端完成,不是本地操作。
|
||||
|
||||
---
|
||||
|
||||
## 两个分支的职责(必须先理解,否则会改错地方)
|
||||
|
||||
| 分支 | 职责 | force push? |
|
||||
|---|---|---|
|
||||
| `main` | **锚定上游 release tag**(当前 v1.4.0)。被 `.gitea/workflows/sync-upstream.yml` `git reset --hard <tag>` 覆写。**别在 main 上做任何改动** | 是(由 CI 做) |
|
||||
| `custom` | **所有个人改动 + workflow 文件都在这**:信用额度功能、UI 调整、个人需求清单、`.gitea/workflows/*.yml` 等。具体改动清单见 [`FORK.md`](FORK.md)。日常开发分支,**default branch** | 是(rebase 后人工做) |
|
||||
|
||||
⚠️ **default branch 是 `custom`**。`git clone` 默认 checkout custom,直接是开发分支。
|
||||
|
||||
### 历史:曾经存在过的 ci 分支(已退役)
|
||||
|
||||
2026-05-02 之前曾经有第三个分支 `ci`,最初设计是把 `.gitea/workflows/*.yml` 单独放它上面以"meta/code 分离"。两周后发现 Gitea Actions runs 列表显示的 commit 是 workflow 文件所在 commit(即 ci 的 HEAD),不是被构建的代码 commit,UX 误导性强。
|
||||
|
||||
把 workflow 挪回 custom 之后:
|
||||
- runs 列表 commit = 真实代码 commit ✅
|
||||
- `git clone` 默认落 custom 直接是开发分支 ✅
|
||||
- rebase 上游时 workflow 跟 custom 一起平移 ✅
|
||||
- 代价:失去"workflow 与代码完全独立"的设计美感 —— 这个分离原本就是过度设计
|
||||
|
||||
**ci 分支于 2026-05-02 删除**,仅保留这段说明给后续 Claude 会话理解 git log 里"workflow 文件迁到 custom"这条提交(commit `555ecc1a`)的来龙去脉。**workflow 改动直接在 custom 上做**。
|
||||
|
||||
---
|
||||
|
||||
## custom 分支 workflow 清单
|
||||
|
||||
`.gitea/workflows/` 当前有 2 个 workflow(2026-05-04 起 build+deploy 合并为单 workflow 双 job):
|
||||
|
||||
| 文件 | 触发 | 干什么 | 状态 |
|
||||
|---|---|---|---|
|
||||
| `sync-upstream.yml` | 手动(`workflow_dispatch`,可填 tag) | 服务端把 `dev/main` 强制 reset 到 mirror 上的指定 release tag(默认最新),然后 `push --force-with-lease` + 推 tags | ✅ 在用 |
|
||||
| `build-image.yml` | **自动**(push 到 custom 触发,`paths-ignore` 屏蔽 `**.md` / `.gitignore` / `LICENSE` / `screenshot/**` / `sync-upstream.yml`)+ 手动备选 | **两个 job 串联在同一 run 里**:①`build` job 装 buildkit v0.13.2 → 登录 Gitea registry → 构建镜像(带 OCI 标签 source/revision,Gitea 自动关联包到 repo)→ push 到 `git.zhengchentao.win/dev/ezbookkeeping:<hash>` 与 `:latest`,`build-args: BUILD_PIPELINE=1` 跳过活 API 测试。②`deploy` job (`needs: build`) 登录 registry → clone nas-infra → `docker compose pull && up -d` 重启 ezbookkeeping。私有 nas-infra 需要 `secrets.NAS_INFRA_TOKEN`,公开仓库不需要。UI 上 Actions 列表显示一条 run,run 详情里 dependency graph 显示 build → deploy | ✅ 日常发布通道 + 自动 CD |
|
||||
|
||||
**已删**:
|
||||
- `docker-snapshot.yml` / `docker-release.yml`(2026-05-02,依赖未配的 `secrets.DOCKER_REPO`,永远失败)
|
||||
- `deploy.yml`(2026-05-04,合并进 `build-image.yml` 作为第二个 job,理由:原先 `workflow_run` 链触发会在 Actions 列表产生两条独立 run,UX 割裂;合并后单 run + dependency graph 看 build/deploy 状态一目了然)
|
||||
|
||||
需要时再从 git 历史 cherry-pick 回来。
|
||||
|
||||
---
|
||||
|
||||
## 同步发布流程(rebase 模型)
|
||||
|
||||
1. 上游出新 release(如 v1.4.0)→ Gitea pull mirror 自动把 tag 同步到 mirror
|
||||
2. 人工触发 `Sync from upstream` workflow → 服务端把 dev/main reset 到该 tag
|
||||
3. 本地 `git fetch && git checkout custom && git rebase origin/main`
|
||||
4. 解冲突(如有)→ 验证 → `git push --force-with-lease origin custom`
|
||||
5. **build-image workflow 自动触发**(force-push 也算 push 事件),构建新镜像;不需要手动点
|
||||
|
||||
日常 feature commit 流程(全自动 CD):
|
||||
|
||||
1. 在 custom 上改代码 → commit → push
|
||||
2. **自动触发 build job**(除非只改了 `**.md` / `.gitignore` / `LICENSE` / `screenshot/**` / `sync-upstream.yml`)
|
||||
3. build 成功 → **同 run 内 deploy job 接力跑**(`needs: build` 串联):clone nas-infra → docker compose pull → up -d
|
||||
4. 整条 push → build → deploy 链路无人工介入,UI 上是单条 run
|
||||
|
||||
**并发取消策略**:`build-image.yml` 设了 `concurrency.cancel-in-progress: true`,连续多次 push 时**只构建+部署最新那一次**(同 run 里 build/deploy 是原子单元,一起取消)。例:连续 3 次 push 间隔 30 秒,第 1 次 build 跑到 30%、第 2 次到来取消它、第 3 次又取消第 2,最终只 build + deploy 第 3 次的代码。省 CI 时间又保证最终一致性。
|
||||
|
||||
如果想跳过 build/deploy(例如手动多次 push 调试),commit 时只改文档相关文件即可(落在 paths-ignore 范围内)。如果想强制重打某个旧 commit,去 Actions UI 手动触发 `Build Docker Image`,填要打包的 branch / tag —— 注意手动触发也会跑 deploy job,**没有"只重新部署不重新 build"的单点入口了**(合并的代价,原 `deploy.yml` 那条路径已废)。临时只想重启容器:直接到 NAS 上 `docker compose up -d` 或在 Actions UI 临时禁用 deploy job。
|
||||
|
||||
**为什么 rebase 不 merge**:个人项目,无团队协作语义要保留,线性历史更清爽。
|
||||
|
||||
---
|
||||
|
||||
## 给后续 Claude 会话的明确提示
|
||||
|
||||
- 用户说"我的分支" / "切换到我的分支" → 指 `custom`
|
||||
- 用户说"rebase main" → 指 `git rebase origin/main`,目标是把 custom 的改动叠到最新上游 tag 之上
|
||||
- **不要在 `main` 分支上提交任何东西**(会被 CI 覆写)
|
||||
- **workflow 文件改动直接在 custom 上做**(2026-05-02 起,不再是 ci 分支)
|
||||
- force-push custom 是常规操作,但每次用 `--force-with-lease`,不直接 `--force`
|
||||
- 如果发现本地配了 upstream remote,那是历史遗留,不要依赖;以 origin/main 为准
|
||||
- `.claude/` 在 `.gitignore` 里(个人本地配置不入库),但 `CLAUDE.md` 本身入库
|
||||
|
||||
---
|
||||
|
||||
## 同步历史
|
||||
|
||||
- **2026-05-01**:rebase custom → origin/main (v1.4.0)。22 个 custom-only 提交(含一个旧的 `Merge branch 'main' into myrequirement` commit)压平为 21 个线性提交。已 force-push origin/custom(`08c69042` → `fe265259`)。
|
||||
- **2026-05-02**:修 Gitea Actions `Build Docker Image` 工作流。三层故障,全部不在本仓库代码里:
|
||||
- **TLS 雷**:`docker login` 走 host 进程不命中 PREROUTING REDIRECT,且 v6 撞 DSM nginx 的 CF Origin Cert。NAS 侧修:iptables 补 OUTPUT 对称规则 + `/etc/hosts` 显式 v4 兜底。详见 obsidian vault [[NAS/notes/内网证书路径]] §三.5/§三.6
|
||||
- **buildkit 内核兼容**:runc 1.2+ 撞 DSM 4.4 内核。`.gitea/workflows/build-image.yml` 钉 `moby/buildkit:v0.13.2`(commit `acdbb5bf`)
|
||||
- **backend 单元测试撞活 API**:`pkg/exchangerates/` 的 `TestExchangeRatesApiLatestExchangeRateHandler_*` 跑活 API(加拿大银行 / 乌兹别克央行),国内访问超时。upstream Dockerfile 已设 `ARG BUILD_PIPELINE`,测试代码看到 `BUILD_PIPELINE=1 && CHECK_3RD_API!=1` 时早退。修:workflow 加 `build-args: BUILD_PIPELINE=1`(commit `2dd8f099`),对齐上游 GH Actions
|
||||
- **2026-05-02 (后续)**:workflow 文件从 ci 分支迁到 custom,default branch 切到 custom(commit `555ecc1a`),随后**删掉 ci 分支**。原因:Gitea Actions runs 列表的 commit 字段一直显示 ci 的 workflow commit,不是被构建的代码 commit,UX 误导性强。挪到 custom 后列表直接显示真实代码 commit。同时清理上游残留的 `docker-release.yml` / `docker-snapshot.yml`(依赖未配的 `secrets.DOCKER_REPO`,永远失败)。仓库回到朴素的 main + custom 双分支模型
|
||||
- **2026-05-02 (numpad fix)**:FORK.md #11 定位 + 修复。小键盘点击卡顿真因是 `.numpad-button` 的 `touch-action: none`(上游 e178a079 引入)与 F7 tap 处理叠加,改为 `touch-action: manipulation`(commit `75b4d78d`)
|
||||
- **2026-05-04**:把 `deploy.yml` 合并进 `build-image.yml` 作为第二个 job(`needs: build`),删除 `deploy.yml`。原先 `workflow_run` 链路会在 Actions 列表产生两条独立 run(build 完一条、deploy 又一条),用户视角割裂;合并后 UI 列表单条 run,run 详情里 dependency graph 显示 build → deploy 串联。代价:失去"不 rebuild 只 redeploy"的 UI 单点触发,临时只想重启容器需直接 ssh NAS 跑 compose。`paths-ignore` 移除已不存在的 `deploy.yml` 项
|
||||
|
||||
## 给后续 Claude 会话:CI 故障排查路径
|
||||
|
||||
如果 Gitea Actions build 又炸,按 NAS 域问题 vs 仓库代码问题分别排查:
|
||||
|
||||
| 现象 | 大概率位置 | 文档 |
|
||||
|---|---|---|
|
||||
| `Login to Gitea Container Registry` 步骤报 `x509: certificate signed by unknown authority` | NAS 网络层(iptables / dnsmasq / DSM nginx 占 443) | obsidian vault `NAS/notes/内网证书路径.md` + `NAS/notes/IPv6 设计.md` |
|
||||
| `Build and push` 步骤里 `RUN ...` 在第二条之内就炸 `unsafe procfs detected` 之类 | buildkit/runc 与 DSM 内核版本 | `.gitea/workflows/build-image.yml` 的 `driver-opts` |
|
||||
| `Failed to pass unit testing` / `Failed to pass lint checking`(build.sh 报) | **先看 Dockerfile 顶部 `ARG`**,多半是 CI 跳过开关没传(如 `BUILD_PIPELINE` / `CHECK_3RD_API` / `SKIP_TESTS`)。**不要先去改测试代码** | `Dockerfile` 顶部 ARG + `.gitea/workflows/build-image.yml` 的 `build-args` |
|
||||
| `actions/checkout` 报 fetch 失败 | Gitea SSH/HTTPS 路径或 token 权限 | gitea-runner 的 `GITEA_RUNNER_REGISTRATION_TOKEN` + NPM `git.zhengchentao.win` 的 Advanced 配置 |
|
||||
| Dockerfile 里某条指令业务逻辑报错 | 真正的代码问题 | 本仓库 `Dockerfile` |
|
||||
|
||||
**通用排查原则**:build.sh 报的"测试失败 / lint 失败"先看是不是上游已经设计了 CI 跳过路径。Dockerfile 的 `ARG` + `build.sh` 内的 `os.Getenv()` 检查通常成对出现(如 `BUILD_PIPELINE=1` → 跳过 3rd API 测试,`SKIP_TESTS=...` → 跳过指定测试名)。对齐上游 `.github/actions/` 下的传参,绝大多数情况能直接对齐。
|
||||
+22
-4
@@ -1,7 +1,17 @@
|
||||
# Build backend binary file
|
||||
FROM golang:1.16.5-alpine3.13 AS be-builder
|
||||
FROM golang:1.25.7-alpine3.23 AS be-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ARG BUILD_UNIXTIME
|
||||
ARG BUILD_DATE
|
||||
ARG CHECK_3RD_API
|
||||
ARG SKIP_TESTS
|
||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||
ENV BUILD_PIPELINE=$BUILD_PIPELINE
|
||||
ENV BUILD_UNIXTIME=$BUILD_UNIXTIME
|
||||
ENV BUILD_DATE=$BUILD_DATE
|
||||
ENV CHECK_3RD_API=$CHECK_3RD_API
|
||||
ENV SKIP_TESTS=$SKIP_TESTS
|
||||
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
||||
COPY . .
|
||||
RUN docker/backend-build-pre-setup.sh
|
||||
@@ -9,9 +19,15 @@ RUN apk add git gcc g++ libc-dev
|
||||
RUN ./build.sh backend
|
||||
|
||||
# Build frontend files
|
||||
FROM node:14.17.0-alpine3.13 AS fe-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine3.23 AS fe-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ARG BUILD_UNIXTIME
|
||||
ARG BUILD_DATE
|
||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||
ENV BUILD_PIPELINE=$BUILD_PIPELINE
|
||||
ENV BUILD_UNIXTIME=$BUILD_UNIXTIME
|
||||
ENV BUILD_DATE=$BUILD_DATE
|
||||
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
||||
COPY . .
|
||||
RUN docker/frontend-build-pre-setup.sh
|
||||
@@ -19,7 +35,7 @@ RUN apk add git
|
||||
RUN ./build.sh frontend
|
||||
|
||||
# Package docker image
|
||||
FROM alpine:3.13.5
|
||||
FROM alpine:3.23.3
|
||||
LABEL maintainer="MaysWind <i@mayswind.net>"
|
||||
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
|
||||
RUN apk --no-cache add tzdata
|
||||
@@ -27,11 +43,13 @@ COPY docker/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
RUN mkdir -p /ezbookkeeping && chown 1000:1000 /ezbookkeeping \
|
||||
&& mkdir -p /ezbookkeeping/data && chown 1000:1000 /ezbookkeeping/data \
|
||||
&& mkdir -p /ezbookkeeping/log && chown 1000:1000 /ezbookkeeping/log
|
||||
&& mkdir -p /ezbookkeeping/log && chown 1000:1000 /ezbookkeeping/log \
|
||||
&& mkdir -p /ezbookkeeping/storage && chown 1000:1000 /ezbookkeeping/storage
|
||||
WORKDIR /ezbookkeeping
|
||||
COPY --from=be-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/ezbookkeeping /ezbookkeeping/ezbookkeeping
|
||||
COPY --from=fe-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/dist /ezbookkeeping/public
|
||||
COPY --chown=1000:1000 conf /ezbookkeeping/conf
|
||||
COPY --chown=1000:1000 templates /ezbookkeeping/templates
|
||||
COPY --chown=1000:1000 LICENSE /ezbookkeeping/LICENSE
|
||||
USER 1000:1000
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# ezBookkeeping 个人 fork 改动清单
|
||||
|
||||
> 本文件记录这个 fork 相对上游 [mayswind/ezbookkeeping](https://github.com/mayswind/ezbookkeeping) 的所有定制改动 + 进度状态。
|
||||
|
||||
> 关联文档:
|
||||
> - [`CLAUDE.md`](CLAUDE.md) —— 仓库分支模型 / 上游同步流程 / CI 排查路径(meta 层)
|
||||
> - 部署:见自家 NAS infra repo `git.zhengchentao.win/dev/nas-infra` 的 README(compose-level)
|
||||
>
|
||||
> 标注:❌ 难/暂缓 | ❓ 待定 | 🔍 调查中 | 🟢 已完成
|
||||
|
||||
---
|
||||
|
||||
## 一、账户功能
|
||||
|
||||
### 1. 🟢 信用卡账户:额度与可用额度
|
||||
**描述:** 为信用卡类型账户新增「信用额度」字段,在账户列表显示可用额度。
|
||||
|
||||
**已完成:**
|
||||
- 后端:`AccountExtend` JSON blob 新增 `CreditLimit` 字段(无需数据库迁移)
|
||||
- API:`AccountCreateRequest` / `AccountModifyRequest` / `AccountInfoResponse` 增加 `creditLimit`
|
||||
- 前端 model:`Account` 类增加 `creditLimit` 字段,同步序列化/反序列化
|
||||
- 移动端 EditPage:CreditCard 分类时显示信用额度输入项(数字键盘)
|
||||
- 桌面端 EditDialog:CreditCard 分类时显示信用额度输入框(`amount-input`)
|
||||
- 移动端 ListPage:账户名下方显示「可用额度: ¥xxx」(= `creditLimit + balance`)
|
||||
- 桌面端 ListPage:账户卡片余额旁显示「Available: ¥xxx」
|
||||
- 语言包:中英繁均已添加 `"Credit Limit"` / `"Available"`
|
||||
|
||||
### 2. 🟢 按账户筛选交易时顶部显示账户信息卡
|
||||
**描述:** 在按单个账户筛选的交易列表顶部,显示账户图标、名称和余额/可用额度。
|
||||
|
||||
**已完成:**
|
||||
- 仅单账户筛选时(`queryAllFilterAccountIdsCount === 1`)显示,多账户/全量时隐藏
|
||||
- 信用卡账户显示「欠款 · 可用 ¥xxx」,普通账户显示余额
|
||||
- 移动端:toolbar 下方插入账户信息卡片;桌面端:日期范围行下方插入 tonal 样式账户卡片
|
||||
- 多子账户(`MultiSubAccounts`)一级账户:使用 `getAccountSubAccountBalance` 获取汇总余额及正确币种,修复 `currency = '---'` 导致货币符号不显示的问题
|
||||
- 涉及文件:`src/views/mobile/transactions/ListPage.vue`、`src/views/desktop/transactions/ListPage.vue`
|
||||
|
||||
### 3. 🟢 账户编辑页直接修改余额(自动插入调整记录)
|
||||
**描述:** 在账户编辑页修改余额字段,保存时自动计算差值并插入一条「余额调整」类型交易。
|
||||
|
||||
**已完成:**
|
||||
- 后端:移除「账户已有交易时不允许添加 ModifyBalance」的限制
|
||||
- 后端:`Amount` 与 `RelatedAccountAmount` 均存储 delta;创建时 `balance += delta`,删除时 `balance -= delta`,修改时 `balance = balance - oldDelta + newDelta`
|
||||
- 后端响应:`ToTransactionInfoResponse` 对 ModifyBalance 类型返回 `RelatedAccountAmount` 作为 `sourceAmount`
|
||||
- 前端 store:新增 `adjustAccountBalance({ accountId, targetBalance, currentBalance })` 函数,发送 `sourceAmount = delta`
|
||||
- 移动端 EditPage:余额字段对已有账户解除只读;保存时若余额变化先调 `adjustAccountBalance`;捕获 `NothingWillBeUpdated (200004)` 视为成功
|
||||
- 桌面端 EditDialog:同上逻辑,支持多子账户逐一调整
|
||||
- 「调整余额」入口仅在账户编辑页,已从账户列表「更多」菜单移除
|
||||
- 涉及文件:`pkg/services/transactions.go`、`pkg/models/transaction.go`、`src/stores/transaction.ts`、`src/views/mobile/accounts/EditPage.vue`、`src/views/desktop/accounts/list/dialogs/EditDialog.vue`
|
||||
|
||||
---
|
||||
|
||||
## 二、记账页面
|
||||
|
||||
### 4. 🟢 记账页选择账户后显示余额/可用额度
|
||||
**描述:** 在记账页面选择账户后,在账户行显示该账户的当前余额或信用卡可用额度。
|
||||
|
||||
**已完成:**
|
||||
- 信用卡账户(有 `creditLimit`):显示「欠款金额 · 可用 ¥xxx」
|
||||
- 普通负债账户:显示欠款正数;普通资产账户:显示余额
|
||||
- 转账类型时源账户和目标账户均显示
|
||||
- 移动端:账户列表项 `footer` 字段;桌面端:`two-column-select` 的 `custom-selection-secondary-text`
|
||||
- 涉及文件:`src/views/mobile/transactions/EditPage.vue`、`src/views/desktop/transactions/list/dialogs/EditDialog.vue`
|
||||
|
||||
### 5. ❓ 记录上次选择的账户(待定)
|
||||
**描述:** 新建交易时,默认选中上次使用的账户。
|
||||
|
||||
**实现思路:**
|
||||
- 保存账户 ID 到 `localStorage`,打开记账页时读取并预选
|
||||
- 涉及文件:`src/views/mobile/transactions/EditPage.vue`、`src/lib/settings.ts`
|
||||
|
||||
---
|
||||
|
||||
## 三、小键盘
|
||||
|
||||
### 6. 🟢 小键盘布局调整
|
||||
**描述:** 调整数字键盘布局。
|
||||
|
||||
**已完成:**
|
||||
```
|
||||
[数值显示区 ] [ ⌫ ]
|
||||
7 8 9 ×
|
||||
4 5 6 −
|
||||
1 2 3 +
|
||||
C 0 . OK
|
||||
```
|
||||
- ⌫ 单击退格,长按清除;C 清除全部
|
||||
- 涉及文件:`src/components/mobile/NumberPadSheet.vue`
|
||||
|
||||
---
|
||||
|
||||
## 四、交易详情
|
||||
|
||||
### 7. 🟢 交易详情页增加「编辑」和「删除」入口(仅移动端)
|
||||
**描述:** 移动端交易详情页三点菜单中增加编辑和删除操作。PC 端详情直接在编辑弹窗中展示,无需额外入口。
|
||||
|
||||
**已完成:**
|
||||
- 三点菜单:第一项「Edit」跳转编辑页,最后一项红色「Delete」确认后删除并返回
|
||||
- 从详情返回编辑页后自动刷新数据
|
||||
- 涉及文件:`src/views/mobile/transactions/EditPage.vue`
|
||||
|
||||
---
|
||||
|
||||
## 五、交易时间选择
|
||||
|
||||
### 8. ❌ 点击交易时间标题默认打开日期选择(已回滚)
|
||||
**描述:** 原想让点击「Transaction Time」标题行时默认弹日期选择器。
|
||||
|
||||
**为何回滚:** 改动改的是 `template #header` 那行 label 的点击 handler(`'time'` → `'date'`),实际操作中用户点的是 `template #title` 里的日期/时间文本。上游早在 commit `368322f9` 已实现"点哪走哪"的智能路由——点日期开日期选择器、点时间开时间选择器。所以这条改动**用户视角无可见差异**,纯空改,回滚到上游行为。
|
||||
|
||||
**留档教训:** 改 UI 行为前先把"用户实际点哪个元素"摸清楚,别只看着 DOM 结构想当然。`#header` slot 只是上方的 label 行,正常用户极少触发。
|
||||
|
||||
---
|
||||
|
||||
## 六、分类选择
|
||||
|
||||
### 9. 🟢 分类选择默认全部展开(仅移动端)
|
||||
**描述:** 在移动端记账页选择分类时,默认展开所有一级分类。PC 端使用不同的分类选择组件,无需此设置。
|
||||
|
||||
**已完成:**
|
||||
- 设置存 localStorage(字段 `expandCategoryTreeByDefault`,默认 `false`),即时生效无需 reload;已加入云同步白名单(`ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES`),可跨设备同步
|
||||
- `TreeViewSelectionSheet` 新增可选 prop `defaultExpanded`,`:opened` 改为 `props.defaultExpanded || isPrimaryItemHasSecondaryValue(item)`
|
||||
- 移动端 EditPage 三个分类 sheet(支出/收入/转账)均传入 `:default-expanded`
|
||||
- 设置仅在移动端设置页显示,PC 端无对应入口
|
||||
- 涉及文件:`src/components/mobile/TreeViewSelectionSheet.vue`、`src/views/mobile/transactions/EditPage.vue`、`src/views/mobile/SettingsPage.vue`
|
||||
|
||||
---
|
||||
|
||||
## 七、性能与动画
|
||||
|
||||
### 10. 🟢 全局动画加速(仅移动端)
|
||||
**描述:** 移动端全局页面跳转及各类弹层动画加速。
|
||||
|
||||
**已完成:**
|
||||
- 页面跳转、Sheet、ActionSheet、Popup、Dialog、Popover 动画时长从 300ms → 150ms
|
||||
- Tab 切换动画保持原样(设置中已有开关可控制)
|
||||
- 涉及文件:`src/styles/mobile/global.scss`
|
||||
|
||||
### 11. 🟢 小键盘点击卡顿(修正:范围非全局)
|
||||
**描述:** 移动端点击按钮有延迟感。
|
||||
|
||||
**真因(2026-05-02 定位):** **不是**全局点击/接口响应问题。诊断后确认仅小键盘有卡顿,其他按钮正常。根因是上游在 `.numpad-button` 上设了 `touch-action: none`(commit `e178a079` "code refactor" by MaysWind),与 F7 内部 tap 处理叠加后让 click 事件合成慢一拍。backspace(个人新增 `.numpad-backspace-button` 类)不受影响,刚好佐证范围。
|
||||
|
||||
**已完成:**
|
||||
- `.numpad-button` 的 `touch-action: none` 改为 `touch-action: manipulation`
|
||||
- `manipulation` 是 W3C 标准的"快速点击"值:禁双击缩放(消除老 300ms 延迟)但保留 click 事件正常合成
|
||||
- 涉及文件:`src/components/mobile/NumberPadSheet.vue`
|
||||
|
||||
**附带认知:** 原 #11 假设是"全局点击响应慢"或"接口慢",与 #12 离线缓存挂钩调研。实际诊断后跟那两条都无关,纯 CSS `touch-action` 与框架 tap 处理叠加导致。该认知值得记录避免后续误诊路径。
|
||||
|
||||
---
|
||||
|
||||
## 八、离线 / 缓存
|
||||
|
||||
### 12. ❌ 本地优先 / 离线数据缓存(暂缓)
|
||||
**描述:** 交易数据本地缓存,优先展示缓存数据,后台静默拉取更新。
|
||||
|
||||
**现状:** Service Worker 已实现静态资源缓存,但交易业务数据目前不做本地缓存。
|
||||
|
||||
**为何难:**
|
||||
- 需引入 IndexedDB 存储交易/账户/分类数据
|
||||
- 需处理本地与服务端的数据同步、冲突解决
|
||||
- 属于架构级改动,工作量较大
|
||||
|
||||
---
|
||||
|
||||
## 进度总览
|
||||
|
||||
| # | 需求 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | 信用卡额度 | 🟢 已完成 |
|
||||
| 2 | 账户信息卡片 | 🟢 已完成 |
|
||||
| 3 | 调整余额入口 | 🟢 已完成 |
|
||||
| 4 | 记账页显示余额 | 🟢 已完成 |
|
||||
| 5 | 记住上次账户 | ❓ 待定 |
|
||||
| 6 | 小键盘布局 | 🟢 已完成 |
|
||||
| 7 | 详情编辑/删除 | 🟢 已完成 |
|
||||
| 8 | 点击时间默认日期 | ❌ 已回滚(无效改动) |
|
||||
| 9 | 分类默认展开 | 🟢 已完成 |
|
||||
| 10 | 全局动画加速 | 🟢 已完成 |
|
||||
| 11 | 小键盘点击卡顿(touch-action 修复) | 🟢 已完成 |
|
||||
| 12 | 离线缓存 | ❌ 暂缓 |
|
||||
@@ -1,6 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2021 MaysWind (i@mayswind.net)
|
||||
Copyright (c) 2020-2026 MaysWind (i@mayswind.net)
|
||||
Copyright (c) 2026 Zhengchen Tao (fork modifications)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,71 +1,184 @@
|
||||
# ezBookkeeping
|
||||
|
||||
> ## Personal fork notice
|
||||
>
|
||||
> This repository is a personal fork of [mayswind/ezbookkeeping](https://github.com/mayswind/ezbookkeeping) (MIT) with the following custom additions on top of upstream releases:
|
||||
>
|
||||
> - **Credit card accounts**: credit-limit field; account list shows available credit
|
||||
> - **Account-filtered transactions**: when filtering by a single account, show an account info card on top (icon / name / balance / available credit)
|
||||
> - **Account editing**: edit the balance field directly; a "balance adjustment" transaction is generated automatically
|
||||
> - **Add-transaction page**: live display of balance or available credit after selecting an account
|
||||
> - **Numpad**: custom layout (4-column calculator style) + `touch-action` fix for tap latency
|
||||
> - **Mobile animations**: generic transitions 300ms → 150ms
|
||||
> - **Transaction detail**: edit / delete entries added to the mobile three-dot menu
|
||||
> - **Category picker**: optional "expand all by default" on mobile (cloud-sync allowlisted)
|
||||
>
|
||||
> Full list with implementation details: [`FORK.md`](FORK.md)
|
||||
> Branch model / upstream sync / CI troubleshooting: [`CLAUDE.md`](CLAUDE.md)
|
||||
>
|
||||
> All modifications are released under the same MIT License as upstream — see [`LICENSE`](LICENSE).
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> Upstream README content follows below.
|
||||
|
||||
---
|
||||
|
||||
[](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
||||
[](https://github.com/mayswind/ezbookkeeping/actions)
|
||||
[](https://goreportcard.com/report/github.com/mayswind/ezbookkeeping)
|
||||
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
||||
[](https://github.com/mayswind/ezbookkeeping/releases)
|
||||
[](https://github.com/mayswind/ezbookkeeping/actions)
|
||||
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
||||
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
||||
[](https://deepwiki.com/mayswind/ezbookkeeping)
|
||||
|
||||
[](https://hellogithub.com/en/repository/mayswind/ezbookkeeping)
|
||||
[](https://trendshift.io/repositories/12917)
|
||||
|
||||
## Introduction
|
||||
ezBookkeeping is a lightweight personal bookkeeping app hosted by yourself. It can be deployed on almost all platforms, including Windows, macOS and Linux on x86, amd64 and ARM architectures. You can even deploy it on an raspberry device. It also supports many different databases, including sqlite and mysql. With docker, you can just deploy it via one command without complicated configuration.
|
||||
ezBookkeeping is a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features. It helps you record daily transactions, import data from various sources, and quickly search and filter your bills. You can analyze historical data using built-in charts or perform custom queries with your own chart dimensions to better understand spending patterns and financial trends. ezBookkeeping is easy to deploy, and you can start it with just one single Docker command. Designed to be resource-efficient, it runs smoothly on devices such as Raspberry Pi, NAS, and MicroServers.
|
||||
|
||||
ezBookkeeping offers tailored interfaces for both mobile and desktop devices. With support for PWA (Progressive Web Apps), you can even [add it to your mobile home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) and use it like a native app.
|
||||
|
||||
Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
|
||||
|
||||
## Features
|
||||
1. Open source & Self-hosted
|
||||
2. Lightweight & Fast
|
||||
3. Easy to install
|
||||
* Docker support
|
||||
* Multiple database support (sqlite, mysql, etc.)
|
||||
* Multiple os & architecture support (Windows, macOS, Linux & x86, amd64, ARM)
|
||||
4. User-friendly interface
|
||||
* Close to native app experience (for mobile device)
|
||||
* Two-level account & two-level category support
|
||||
* Plentiful preset categories
|
||||
* Searching & filtering history records
|
||||
* Data statistics
|
||||
* Dark theme
|
||||
5. Multiple currency support & automatically updating exchange rates
|
||||
6. Multiple timezone support
|
||||
7. Multi-language support
|
||||
8. Two-factor authentication
|
||||
9. Application lock (WebAuthn support)
|
||||
10. Data export
|
||||
- **Open Source & Self-Hosted**
|
||||
- Built for privacy and control
|
||||
- **Lightweight & Fast**
|
||||
- Minimal resource usage, runs smoothly even on low-resource devices
|
||||
- **Easy Installation**
|
||||
- Docker support
|
||||
- Supports SQLite, MySQL, PostgreSQL
|
||||
- Cross-platform (Windows, macOS, Linux)
|
||||
- Works on x86, amd64, ARM architectures
|
||||
- **User-Friendly Interface**
|
||||
- UI optimized for both mobile and desktop
|
||||
- PWA support for native-like mobile experience
|
||||
- Dark mode
|
||||
- **AI-Powered Features**
|
||||
- Receipt image recognition
|
||||
- MCP (Model Context Protocol) support for AI integration
|
||||
- Agent Skill and API command-line script tools support for AI integration
|
||||
- **Powerful Bookkeeping**
|
||||
- Two-level accounts and categories
|
||||
- Image attachments for transactions
|
||||
- Location tracking with maps
|
||||
- Scheduled transactions
|
||||
- Advanced filtering, search, visualization and analysis
|
||||
- **Localization & Internationalization**
|
||||
- Multi-language and multi-currency support
|
||||
- Multiple exchange rate sources with automatic updates
|
||||
- Multi-timezone support
|
||||
- Custom formats for dates, numbers and currencies
|
||||
- **Security**
|
||||
- Two-factor authentication (2FA)
|
||||
- OIDC external authentication
|
||||
- Login rate limiting
|
||||
- Application lock (PIN code / WebAuthn)
|
||||
- **Data Import & Export**
|
||||
- Supports CSV, OFX, QFX, QIF, IIF, Camt.052, Camt.053, MT940, GnuCash, Firefly III, Beancount and more
|
||||
|
||||
For a full list of features, visit the [Full Feature List](https://ezbookkeeping.mayswind.net/comparison/).
|
||||
|
||||
## Screenshots
|
||||
### Mobile Device
|
||||
[](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/en.png)
|
||||
### Desktop Version
|
||||
[](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/desktop/en.png)
|
||||
|
||||
### Mobile Version
|
||||
[](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/en.png)
|
||||
|
||||
## Installation
|
||||
### Ship with docker
|
||||
### Run with Docker
|
||||
Visit [Docker Hub](https://hub.docker.com/r/mayswind/ezbookkeeping) to see all images and tags.
|
||||
|
||||
Latest Release:
|
||||
**Latest Release:**
|
||||
|
||||
$ docker run -p8080:8080 mayswind/ezbookkeeping
|
||||
|
||||
Latest Daily Build:
|
||||
**Latest Daily Build:**
|
||||
|
||||
$ docker run -p8080:8080 mayswind/ezbookkeeping:latest-snapshot
|
||||
|
||||
### Install from binary
|
||||
Latest release: [https://github.com/mayswind/ezbookkeeping/releases](https://github.com/mayswind/ezbookkeeping/releases)
|
||||
### Install from Binary
|
||||
Download the latest release: [https://github.com/mayswind/ezbookkeeping/releases](https://github.com/mayswind/ezbookkeeping/releases)
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
$ ./ezbookkeeping server run
|
||||
|
||||
ezBookkeeping will listen at port 8080 as default. Then you can visit http://{YOUR_HOST_ADDRESS}:8080/ .
|
||||
**Windows**
|
||||
|
||||
### Build from source
|
||||
Make sure you have [Golang](https://golang.org/), [GCC](http://gcc.gnu.org/), [Node.js](https://nodejs.org/) and [NPM](https://www.npmjs.com/) installed. Then download the source code, and follow these steps:
|
||||
> .\ezbookkeeping.exe server run
|
||||
|
||||
By default, ezBookkeeping listens on port 8080. You can then visit `http://{YOUR_HOST_ADDRESS}:8080/` .
|
||||
|
||||
### Build from Source
|
||||
Make sure you have [Golang](https://golang.org/), [GCC](https://gcc.gnu.org/), [Node.js](https://nodejs.org/) and [NPM](https://www.npmjs.com/) installed. Then download the source code, and follow these steps:
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
$ ./build.sh package -o ezbookkeeping.tar.gz
|
||||
|
||||
All the files will be packaged in `ezbookkeeping.tar.gz`.
|
||||
|
||||
You can also build docker image, make sure you have [docker](https://www.docker.com/) installed, then follow these steps:
|
||||
**Windows**
|
||||
|
||||
> .\build.bat package -o ezbookkeeping.zip
|
||||
|
||||
or
|
||||
|
||||
PS > .\build.ps1 package -Output ezbookkeeping.zip
|
||||
|
||||
All the files will be packaged in `ezbookkeeping.zip`.
|
||||
|
||||
You can also build a Docker image. Make sure you have [Docker](https://www.docker.com/) installed, then follow these steps:
|
||||
|
||||
**Linux**
|
||||
|
||||
$ ./build.sh docker
|
||||
|
||||
## Documents
|
||||
1. [English](http://ezbookkeeping.mayswind.net)
|
||||
1. [简体中文 (Simplified Chinese)](http://ezbookkeeping.mayswind.net/zh_Hans)
|
||||
## Contributing
|
||||
We welcome contributions of all kinds.
|
||||
|
||||
If you find a bug, please [submit an issue](https://github.com/mayswind/ezbookkeeping/issues) on GitHub.
|
||||
|
||||
If you would like to contribute code, you can fork the repository and open a pull request.
|
||||
|
||||
Improvements to documentation, feature suggestions, and other forms of feedback are also appreciated.
|
||||
|
||||
You can view existing contributors on the [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors).
|
||||
|
||||
## Translating
|
||||
Help make ezBookkeeping accessible to users around the world. We welcome help to improve existing translations or add new ones. If you would like to contribute a translation, please refer to the [translation guide](https://ezbookkeeping.mayswind.net/translating).
|
||||
|
||||
Currently available translations:
|
||||
|
||||
| Tag | Language | Progress | Contributors |
|
||||
| --- | --- | --- | --- |
|
||||
| de | Deutsch |  | [@chrgm](https://github.com/chrgm), [@1270o1](https://github.com/1270o1) |
|
||||
| en | English |  | / |
|
||||
| es | Español |  | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
|
||||
| fr | Français |  | [@brieucdlf](https://github.com/brieucdlf) |
|
||||
| it | Italiano |  | [@waron97](https://github.com/waron97) |
|
||||
| ja | 日本語 |  | [@tkymmm](https://github.com/tkymmm) |
|
||||
| kn | ಕನ್ನಡ |  | [@Darshanbm05](https://github.com/Darshanbm05) |
|
||||
| ko | 한국어 |  | [@overworks](https://github.com/overworks) |
|
||||
| nl | Nederlands |  | [@automagics](https://github.com/automagics) |
|
||||
| pt-BR | Português (Brasil) |  | [@thecodergus](https://github.com/thecodergus), [@balaios](https://github.com/balaios) |
|
||||
| ru | Русский |  | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
|
||||
| sl | Slovenščina |  | [@thehijacker](https://github.com/thehijacker) |
|
||||
| ta | தமிழ் |  | [@hhharsha36](https://github.com/hhharsha36) |
|
||||
| th | ไทย |  | [@natthavat28](https://github.com/natthavat28) |
|
||||
| tr | Türkçe |  | [@aydnykn](https://github.com/aydnykn) |
|
||||
| uk | Українська |  | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
||||
| vi | Tiếng Việt |  | [@f97](https://github.com/f97) |
|
||||
| zh-Hans | 中文 (简体) |  | / |
|
||||
| zh-Hant | 中文 (繁體) |  | / |
|
||||
|
||||
## Documentation
|
||||
1. [English](https://ezbookkeeping.mayswind.net)
|
||||
1. [中文 (简体)](https://ezbookkeeping.mayswind.net/zh_Hans)
|
||||
|
||||
## License
|
||||
[MIT](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
@echo off
|
||||
|
||||
set "TYPE="
|
||||
set "NO_LINT=0"
|
||||
set "NO_TEST=0"
|
||||
set "SKIP_TESTS=%SKIP_TESTS%"
|
||||
set "RELEASE=%RELEASE_BUILD%"
|
||||
set "RELEASE_TYPE=unknown"
|
||||
set "VERSION="
|
||||
set "COMMIT_HASH="
|
||||
set "BUILD_UNIXTIME=%BUILD_UNIXTIME%"
|
||||
set "BUILD_DATE=%BUILD_DATE%"
|
||||
set "PACKAGE_FILENAME="
|
||||
for /f %%a in ('"prompt $E$S & echo on & for %%b in (1) do rem"') do set "ESC=%%a"
|
||||
|
||||
if "%~1"=="" call :show_help & goto :end
|
||||
goto :pre_parse_args
|
||||
|
||||
:echo_red
|
||||
echo %ESC%[91m%~1%ESC%[0m
|
||||
goto :eof
|
||||
|
||||
:set_unixtime
|
||||
setlocal enableextensions
|
||||
for /f %%x in ('wmic path win32_utctime get /format:list ^| findstr "="') do set %%x
|
||||
set /a z=(14-100%Month%%%100)/12, y=10000%Year%%%10000-z
|
||||
set /a ut=y*365+y/4-y/100+y/400+(153*(100%Month%%%100+12*z-3)+2)/5+Day-719469
|
||||
set /a ut=ut*86400+100%Hour%%%100*3600+100%Minute%%%100*60+100%Second%%%100
|
||||
endlocal & set "%1=%ut%" & goto :eof
|
||||
|
||||
:set_date
|
||||
setlocal enableextensions
|
||||
for /f %%x in ('wmic path win32_localtime get /format:list ^| findstr "="') do set %%x
|
||||
if %Month% lss 10 set "Month=0%Month%"
|
||||
if %Day% lss 10 set "Day=0%Day%"
|
||||
endlocal & set "%1=%Year%%Month%%Day%" & goto :eof
|
||||
|
||||
:check_dependency
|
||||
if "%~1"=="" goto :eof
|
||||
where /q %~1 || call :echo_red "Error: "%~1" is required." && goto :end
|
||||
|
||||
shift
|
||||
goto :check_dependency
|
||||
|
||||
:show_help
|
||||
echo ezBookkeeping build script for Windows
|
||||
echo.
|
||||
echo Usage:
|
||||
echo build.cmd type [options]
|
||||
echo.
|
||||
echo Types:
|
||||
echo backend Build backend binary file
|
||||
echo frontend Build frontend files
|
||||
echo package Build package archive
|
||||
echo.
|
||||
echo Options:
|
||||
echo /r, --release Build release (The script will use environment variable "RELEASE_BUILD" to detect whether this is release building by default)
|
||||
echo /o, --output ^<filename^> Package file name (For "package" type only)
|
||||
echo --no-lint Do not execute lint check before building
|
||||
echo --no-test Do not execute unit testing before building (You can use environment variable "SKIP_TESTS" to skip specified tests)
|
||||
echo /h, --help Show help
|
||||
goto :eof
|
||||
|
||||
:pre_parse_args
|
||||
if "%~1"=="" goto :post_parse_args
|
||||
|
||||
if /i "%~1"=="backend" set "TYPE=%~1" & shift
|
||||
if /i "%~1"=="frontend" set "TYPE=%~1" & shift
|
||||
if /i "%~1"=="package" set "TYPE=%~1" & shift
|
||||
|
||||
:parse_args
|
||||
if "%~1"=="" goto :post_parse_args
|
||||
|
||||
if /i "%~1"=="/r" set "RELEASE=1" & shift & goto :parse_args
|
||||
if /i "%~1"=="-r" set "RELEASE=1" & shift & goto :parse_args
|
||||
if /i "%~1"=="--release" set "RELEASE=1" & shift & goto :parse_args
|
||||
|
||||
if /i "%~1"=="/o" set "PACKAGE_FILENAME=%~2" & shift & shift & goto :parse_args
|
||||
if /i "%~1"=="-o" set "PACKAGE_FILENAME=%~2" & shift & shift & goto :parse_args
|
||||
if /i "%~1"=="--output" set "PACKAGE_FILENAME=%~2" & shift & shift & goto :parse_args
|
||||
|
||||
if /i "%~1"=="--no-lint" set "NO_LINT=1" & shift & goto :parse_args
|
||||
if /i "%~1"=="--no-test" set "NO_TEST=1" & shift & goto :parse_args
|
||||
|
||||
if /i "%~1"=="/h" call :show_help & goto :end
|
||||
if /i "%~1"=="-h" call :show_help & goto :end
|
||||
if /i "%~1"=="--help" call :show_help & goto :end
|
||||
|
||||
call :echo_red "Invalid argument: %~1" & call :show_help & goto :end
|
||||
|
||||
:post_parse_args
|
||||
if "%RELEASE%"=="" set "RELEASE=0"
|
||||
|
||||
if "%RELEASE%"=="0" (
|
||||
set "RELEASE_TYPE=snapshot"
|
||||
) else (
|
||||
set "RELEASE_TYPE=release"
|
||||
)
|
||||
|
||||
:check_type_dependencies
|
||||
if not defined TYPE call :echo_red "Error: No specified type" & call :show_help & goto :end
|
||||
|
||||
call :check_dependency "git"
|
||||
if "%TYPE%"=="backend" call :check_dependency "go" "gcc"
|
||||
if "%TYPE%"=="frontend" call :check_dependency "node" "npm"
|
||||
if "%TYPE%"=="package" call :check_dependency "go" "gcc" "node" "npm" "7z"
|
||||
|
||||
if not "%errorlevel%"=="0" goto :end
|
||||
|
||||
:set_build_parameters
|
||||
for /f "tokens=2 delims=:" %%x in ('findstr "\"version\": \"*\"," package.json') do set "VERSION=%%x"
|
||||
set VERSION=%VERSION: =%
|
||||
set VERSION=%VERSION:,=%
|
||||
set VERSION=%VERSION:"=%
|
||||
for /f %%x in ('git rev-parse --short^=7 HEAD') do set "COMMIT_HASH=%%x"
|
||||
|
||||
if "%BUILD_UNIXTIME%"=="" (
|
||||
call :set_unixtime BUILD_UNIXTIME
|
||||
)
|
||||
|
||||
if "%BUILD_DATE%"=="" (
|
||||
call :set_date BUILD_DATE
|
||||
)
|
||||
|
||||
:main
|
||||
if "%TYPE%"=="backend" call :build_backend & goto :end
|
||||
if "%TYPE%"=="frontend" call :build_frontend & goto :end
|
||||
if "%TYPE%"=="package" call :build_package & goto :end
|
||||
goto :end
|
||||
|
||||
:build_backend
|
||||
setlocal enabledelayedexpansion
|
||||
echo Pulling backend dependencies...
|
||||
call go get .
|
||||
|
||||
if "%NO_LINT%"=="0" (
|
||||
echo Executing backend lint checking...
|
||||
call go vet -v .\...
|
||||
|
||||
if !errorlevel! neq 0 (
|
||||
call :echo_red "Error: Failed to pass lint checking"
|
||||
goto :end
|
||||
)
|
||||
)
|
||||
|
||||
if "%NO_TEST%"=="0" (
|
||||
echo Executing backend unit testing...
|
||||
call go clean -cache
|
||||
|
||||
if "%SKIP_TESTS%"=="" (
|
||||
call go test .\... -v
|
||||
) else (
|
||||
echo (Skip unit test "%SKIP_TESTS%")
|
||||
call go test .\... -v -skip "%SKIP_TESTS%"
|
||||
)
|
||||
|
||||
if !errorlevel! neq 0 (
|
||||
call :echo_red "Error: Failed to pass unit testing"
|
||||
goto :end
|
||||
)
|
||||
)
|
||||
|
||||
endlocal
|
||||
|
||||
set "CGO_ENABLED=1"
|
||||
|
||||
setlocal
|
||||
set "backend_build_extra_arguments=-X main.Version=%VERSION%"
|
||||
set "backend_build_extra_arguments=%backend_build_extra_arguments% -X main.CommitHash=%COMMIT_HASH%"
|
||||
|
||||
if "%RELEASE%"=="0" (
|
||||
set "backend_build_extra_arguments=%backend_build_extra_arguments% -X main.BuildUnixTime=%BUILD_UNIXTIME%"
|
||||
)
|
||||
|
||||
echo Building backend binary file (%RELEASE_TYPE%)...
|
||||
|
||||
call go build -a -v -trimpath -tags timetzdata -ldflags "-w -s -linkmode external -extldflags '-static' %backend_build_extra_arguments%" -o ezbookkeeping.exe ezbookkeeping.go
|
||||
endlocal
|
||||
|
||||
set "CGO_ENABLED="
|
||||
|
||||
goto :eof
|
||||
|
||||
:build_frontend
|
||||
setlocal enabledelayedexpansion
|
||||
echo Pulling frontend dependencies...
|
||||
call npm install
|
||||
|
||||
if "%NO_LINT%"=="0" (
|
||||
echo Executing frontend lint checking...
|
||||
|
||||
call npm run lint
|
||||
|
||||
if !errorlevel! neq 0 (
|
||||
call :echo_red "Error: Failed to pass lint checking"
|
||||
goto :end
|
||||
)
|
||||
)
|
||||
|
||||
if "%NO_TEST%"=="0" (
|
||||
echo Executing frontend unit testing...
|
||||
|
||||
call npm run test
|
||||
|
||||
if !errorlevel! neq 0 (
|
||||
call :echo_red "Error: Failed to pass unit testing"
|
||||
goto :end
|
||||
)
|
||||
)
|
||||
|
||||
endlocal
|
||||
|
||||
echo Building frontend files(%RELEASE_TYPE%)...
|
||||
|
||||
if "%RELEASE%"=="0" (
|
||||
set "buildUnixTime=%BUILD_UNIXTIME%"
|
||||
call npm run build
|
||||
set "buildUnixTime="
|
||||
) else (
|
||||
call npm run build
|
||||
)
|
||||
|
||||
goto :eof
|
||||
|
||||
:build_package
|
||||
setlocal enabledelayedexpansion
|
||||
set "package_file_name=%VERSION%"
|
||||
|
||||
if "%RELEASE%"=="0" (
|
||||
set "build_date="
|
||||
set "package_file_name=%package_file_name%-%build_date%"
|
||||
)
|
||||
|
||||
set "package_file_name=ezbookkeeping-%package_file_name%-windows.zip"
|
||||
|
||||
if defined PACKAGE_FILENAME set "package_file_name=%PACKAGE_FILENAME%"
|
||||
|
||||
echo Building package archive "%package_file_name%" (%RELEASE_TYPE%)...
|
||||
|
||||
call :build_backend
|
||||
|
||||
if !errorlevel! neq 0 (
|
||||
goto :end
|
||||
)
|
||||
|
||||
call :build_frontend
|
||||
|
||||
if !errorlevel! neq 0 (
|
||||
goto :end
|
||||
)
|
||||
|
||||
rmdir package /s /q
|
||||
mkdir package
|
||||
mkdir package\data
|
||||
mkdir package\storage
|
||||
mkdir package\log
|
||||
xcopy ezbookkeeping.exe package\
|
||||
xcopy dist package\public /e /i
|
||||
xcopy conf package\conf /e /i
|
||||
xcopy templates package\templates /e /i
|
||||
xcopy LICENSE package\
|
||||
|
||||
cd package
|
||||
|
||||
if !errorlevel! neq 0 (
|
||||
call :echo_red "Error: Build Failed"
|
||||
goto :end
|
||||
)
|
||||
|
||||
call 7z a -r -tzip -mx9 ..\%package_file_name% *
|
||||
|
||||
cd ..
|
||||
endlocal
|
||||
|
||||
goto :eof
|
||||
|
||||
:end
|
||||
set "TYPE="
|
||||
set "NO_LINT="
|
||||
set "NO_TEST="
|
||||
set "RELEASE="
|
||||
set "RELEASE_TYPE="
|
||||
set "VERSION="
|
||||
set "COMMIT_HASH="
|
||||
set "BUILD_UNIXTIME="
|
||||
set "BUILD_DATE="
|
||||
set "PACKAGE_FILENAME="
|
||||
exit /B
|
||||
@@ -0,0 +1,237 @@
|
||||
param(
|
||||
[string]$Type,
|
||||
[switch]$NoLint,
|
||||
[switch]$NoTest,
|
||||
[string]$Output,
|
||||
[switch]$Release,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$script:SkipTests = $env:SKIP_TESTS
|
||||
$script:ReleaseType = "unknown"
|
||||
$script:Version = ""
|
||||
$script:CommitHash = ""
|
||||
$script:BuildUnixTime = $env:BUILD_UNIXTIME
|
||||
$script:BuildDate = $env:BUILD_DATE
|
||||
|
||||
function Write-Red($msg) {
|
||||
Write-Host $msg -ForegroundColor Red
|
||||
}
|
||||
|
||||
function Check-Dependency {
|
||||
param([string[]]$commands)
|
||||
foreach ($cmd in $commands) {
|
||||
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||
Write-Red "Error: `"$cmd`" is required."
|
||||
exit 127
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Show-Help {
|
||||
Write-Host "ezBookkeeping build script for Windows PowerShell"
|
||||
Write-Host ""
|
||||
Write-Host "Usage:"
|
||||
Write-Host " build.ps1 type [options]"
|
||||
Write-Host ""
|
||||
Write-Host "Types:"
|
||||
Write-Host " backend Build backend binary file"
|
||||
Write-Host " frontend Build frontend files"
|
||||
Write-Host " package Build package archive"
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Release Build release (The script will use environment variable `"RELEASE_BUILD`" to detect whether this is release building by default)"
|
||||
Write-Host " -Output <filename> Package file name (for `"package`" type only)"
|
||||
Write-Host " -NoLint Do not execute lint check before building"
|
||||
Write-Host " -NoTest Do not execute unit testing before building (You can use environment variable `"SKIP_TESTS`" to skip specified tests)"
|
||||
Write-Host " -Help Show help"
|
||||
}
|
||||
|
||||
function Parse-Args {
|
||||
if (-not $Type) {
|
||||
Show-Help
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Release -or $env:RELEASE_BUILD) {
|
||||
$script:ReleaseType = "release"
|
||||
} else {
|
||||
$script:ReleaseType = "snapshot"
|
||||
}
|
||||
}
|
||||
|
||||
function Check-Type-Dependencies {
|
||||
Check-Dependency "git"
|
||||
|
||||
switch ($Type.ToLower()) {
|
||||
"backend" {
|
||||
Check-Dependency "go","gcc"
|
||||
}
|
||||
"frontend" {
|
||||
Check-Dependency "node","npm"
|
||||
}
|
||||
"package" {
|
||||
Check-Dependency "go","gcc","node","npm","7z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Set-Build-Parameters {
|
||||
$script:Version = (Get-Content package.json | ConvertFrom-Json).version
|
||||
$script:CommitHash = git rev-parse --short=7 HEAD
|
||||
|
||||
if (-not $BuildUnixTime) {
|
||||
$script:BuildUnixTime = [int][double]::Parse((Get-Date -UFormat %s))
|
||||
}
|
||||
|
||||
if (-not $BuildDate) {
|
||||
$script:BuildDate = Get-Date -Format "yyyyMMdd"
|
||||
}
|
||||
}
|
||||
|
||||
function Build-Backend {
|
||||
Write-Host "Pulling backend dependencies..."
|
||||
go get .
|
||||
|
||||
if (-not $NoLint) {
|
||||
Write-Host "Executing backend lint checking..."
|
||||
go vet -v .\...
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Red "Error: Failed to pass lint checking"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $NoTest) {
|
||||
Write-Host "Executing backend unit testing..."
|
||||
go clean -cache
|
||||
|
||||
if (-not $SkipTests) {
|
||||
go test .\... -v
|
||||
} else {
|
||||
Write-Host "(Skip unit test `"$SkipTests`")"
|
||||
go test .\... -v -skip "$SkipTests"
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Red "Error: Failed to pass unit testing"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$backend_build_extra_arguments = "-X main.Version=$Version "
|
||||
$backend_build_extra_arguments = "$backend_build_extra_arguments -X main.CommitHash=$CommitHash"
|
||||
|
||||
if (-not $Release) {
|
||||
$backend_build_extra_arguments += " -X main.BuildUnixTime=$BuildUnixTime"
|
||||
}
|
||||
|
||||
Write-Host "Building backend binary file ($ReleaseType)..."
|
||||
|
||||
$env:CGO_ENABLED = 1
|
||||
go build -a -v -trimpath -tags timetzdata -ldflags "-w -s -linkmode external -extldflags '-static' $backend_build_extra_arguments" -o ezbookkeeping.exe ezbookkeeping.go
|
||||
|
||||
Remove-Item Env:\CGO_ENABLED -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Build-Frontend {
|
||||
Write-Host "Pulling frontend dependencies..."
|
||||
npm install
|
||||
|
||||
if (-not $NoLint) {
|
||||
Write-Host "Executing frontend lint checking..."
|
||||
npm run lint
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Red "Error: Failed to pass lint checking"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $NoTest) {
|
||||
Write-Host "Executing frontend unit testing..."
|
||||
|
||||
npm run test
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Red "Error: Failed to pass unit testing"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Building frontend files ($ReleaseType)..."
|
||||
|
||||
if (-not $Release) {
|
||||
$env:buildUnixTime = $BuildUnixTime
|
||||
npm run build
|
||||
Remove-Item Env:\buildUnixTime -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
npm run build
|
||||
}
|
||||
}
|
||||
|
||||
function Build-Package {
|
||||
$packageFileName = "ezbookkeeping-$Version"
|
||||
|
||||
if (-not $Release) {
|
||||
$packageFileName = "$packageFileName-$BuildDate"
|
||||
}
|
||||
|
||||
$packageFileName = "$packageFileName-windows.zip"
|
||||
|
||||
if ($Output) {
|
||||
$packageFileName = $Output
|
||||
}
|
||||
|
||||
Write-Host "Building package archive '$packageFileName' ($ReleaseType)..."
|
||||
|
||||
Build-Backend
|
||||
Build-Frontend
|
||||
|
||||
Remove-Item package -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path "package"
|
||||
New-Item -ItemType Directory -Path "package\data"
|
||||
New-Item -ItemType Directory -Path "package\storage"
|
||||
New-Item -ItemType Directory -Path "package\log"
|
||||
|
||||
Copy-Item ezbookkeeping.exe package\
|
||||
Copy-Item dist package\public -Recurse
|
||||
Copy-Item conf package\conf -Recurse
|
||||
Copy-Item templates package\templates -Recurse
|
||||
Copy-Item LICENSE package\
|
||||
|
||||
Push-Location package
|
||||
7z a -r -tzip -mx9 "..\$packageFileName" *
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
function Main {
|
||||
if ($Help) {
|
||||
Show-Help
|
||||
exit 0
|
||||
}
|
||||
|
||||
Parse-Args
|
||||
Check-Type-Dependencies
|
||||
Set-Build-Parameters
|
||||
|
||||
switch ($Type) {
|
||||
"backend" {
|
||||
Build-Backend
|
||||
}
|
||||
"frontend" {
|
||||
Build-Frontend
|
||||
}
|
||||
"package" {
|
||||
Build-Package
|
||||
}
|
||||
default {
|
||||
Write-Red "Invalid type: $Type"
|
||||
Show-Help
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Main
|
||||
@@ -1,11 +1,15 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
TYPE=""
|
||||
NO_LINT="0"
|
||||
NO_TEST="0"
|
||||
SKIP_TESTS="${SKIP_TESTS}"
|
||||
RELEASE=${RELEASE_BUILD:-"0"}
|
||||
RELEASE_TYPE="unknown"
|
||||
VERSION=""
|
||||
COMMIT_HASH=""
|
||||
BUILD_UNIXTIME=""
|
||||
BUILD_UNIXTIME="${BUILD_UNIXTIME}"
|
||||
BUILD_DATE="${BUILD_DATE}"
|
||||
PACKAGE_FILENAME=""
|
||||
DOCKER_TAG=""
|
||||
|
||||
@@ -31,16 +35,18 @@ Usage:
|
||||
build.sh type [options]
|
||||
|
||||
Types:
|
||||
backend Build backend binary file
|
||||
frontend Build frontend files
|
||||
package Build package archive
|
||||
docker Build docker image
|
||||
backend Build backend binary file
|
||||
frontend Build frontend files
|
||||
package Build package archive
|
||||
docker Build docker image
|
||||
|
||||
Options:
|
||||
-r, --release Build release (The script will use environment variable "RELEASE_BUILD" to detect whether this is release building by default)
|
||||
-o, --output Package file name (For "package" type only)
|
||||
-t, --tag Docker tag (For "docker" type only)
|
||||
-h, --help Show help
|
||||
-r, --release Build release (The script will use environment variable "RELEASE_BUILD" to detect whether this is release building by default)
|
||||
-o, --output <filename> Package file name (For "package" type only)
|
||||
-t, --tag Docker tag (For "docker" type only)
|
||||
--no-lint Do not execute lint check before building
|
||||
--no-test Do not execute unit testing before building (You can use environment variable "SKIP_TESTS" to skip specified tests)
|
||||
-h, --help Show help
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -63,6 +69,12 @@ parse_args() {
|
||||
DOCKER_TAG="$2"
|
||||
shift
|
||||
;;
|
||||
--no-lint)
|
||||
NO_LINT="1"
|
||||
;;
|
||||
--no-test)
|
||||
NO_TEST="1"
|
||||
;;
|
||||
--help | -h)
|
||||
show_help
|
||||
exit 0
|
||||
@@ -106,11 +118,48 @@ check_type_dependencies() {
|
||||
|
||||
set_build_parameters() {
|
||||
VERSION="$(grep '"version": ' package.json | awk -F ':' '{print $2}' | tr -d ' ' | tr -d ',' | tr -d '"')"
|
||||
COMMIT_HASH="$(git rev-parse --short HEAD)"
|
||||
BUILD_UNIXTIME="$(date '+%s')"
|
||||
COMMIT_HASH="$(git rev-parse --short=7 HEAD)"
|
||||
|
||||
if [ -z "$BUILD_UNIXTIME" ]; then
|
||||
BUILD_UNIXTIME="$(date '+%s')"
|
||||
fi
|
||||
|
||||
if [ -z "$BUILD_DATE" ]; then
|
||||
BUILD_DATE="$(date '+%Y%m%d')"
|
||||
fi
|
||||
}
|
||||
|
||||
build_backend() {
|
||||
echo "Pulling backend dependencies..."
|
||||
go get .
|
||||
|
||||
if [ "$NO_LINT" = "0" ]; then
|
||||
echo "Executing backend lint checking..."
|
||||
go vet -v ./...
|
||||
|
||||
if [ "$?" != "0" ]; then
|
||||
echo_red "Error: Failed to pass lint checking"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$NO_TEST" = "0" ]; then
|
||||
echo "Executing backend unit testing..."
|
||||
go clean -cache
|
||||
|
||||
if [ -z "$SKIP_TESTS" ]; then
|
||||
go test ./... -v
|
||||
else
|
||||
echo "(Skip unit test \"$SKIP_TESTS\")"
|
||||
go test ./... -v -skip "$SKIP_TESTS"
|
||||
fi
|
||||
|
||||
if [ "$?" != "0" ]; then
|
||||
echo_red "Error: Failed to pass unit testing"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
backend_build_extra_arguments="-X main.Version=$VERSION"
|
||||
backend_build_extra_arguments="$backend_build_extra_arguments -X main.CommitHash=$COMMIT_HASH"
|
||||
|
||||
@@ -125,24 +174,44 @@ build_backend() {
|
||||
}
|
||||
|
||||
build_frontend() {
|
||||
frontend_build_arguments="";
|
||||
|
||||
if [ "$RELEASE" = "0" ]; then
|
||||
frontend_build_arguments="--buildUnixTime=$BUILD_UNIXTIME"
|
||||
fi
|
||||
|
||||
echo "Pulling frontend dependencies..."
|
||||
npm install
|
||||
|
||||
if [ "$NO_LINT" = "0" ]; then
|
||||
echo "Executing frontend lint checking..."
|
||||
npm run lint
|
||||
|
||||
if [ "$?" != "0" ]; then
|
||||
echo_red "Error: Failed to pass lint checking"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$NO_TEST" = "0" ]; then
|
||||
echo "Executing frontend unit testing..."
|
||||
|
||||
npm run test
|
||||
|
||||
if [ "$?" != "0" ]; then
|
||||
echo_red "Error: Failed to pass unit testing"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Building frontend files ($RELEASE_TYPE)..."
|
||||
npm run build -- "$frontend_build_arguments"
|
||||
|
||||
if [ "$RELEASE" = "0" ]; then
|
||||
buildUnixTime=$BUILD_UNIXTIME npm run build
|
||||
else
|
||||
npm run build
|
||||
fi
|
||||
}
|
||||
|
||||
build_package() {
|
||||
package_file_name="$VERSION";
|
||||
|
||||
if [ "$RELEASE" = "0" ]; then
|
||||
package_file_name="$package_file_name-$(date '+%Y%m%d')"
|
||||
package_file_name="$package_file_name-$BUILD_DATE"
|
||||
fi
|
||||
|
||||
package_file_name="ezbookkeeping-$package_file_name-$(arch).tar.gz"
|
||||
@@ -158,9 +227,13 @@ build_package() {
|
||||
|
||||
rm -rf package
|
||||
mkdir package
|
||||
mkdir package/data
|
||||
mkdir package/storage
|
||||
mkdir package/log
|
||||
cp ezbookkeeping package/
|
||||
cp -R dist package/public
|
||||
cp -R conf package/conf
|
||||
cp -R templates package/templates
|
||||
cp LICENSE package/
|
||||
|
||||
cd package || { echo_red "Error: Build Failed"; exit 1; }
|
||||
@@ -172,7 +245,7 @@ build_docker() {
|
||||
docker_tag="$VERSION"
|
||||
|
||||
if [ "$RELEASE" = "0" ]; then
|
||||
docker_tag="SNAPSHOT-$(date '+%Y%m%d')";
|
||||
docker_tag="SNAPSHOT-$BUILD_DATE";
|
||||
fi
|
||||
|
||||
docker_tag="ezbookkeeping:$docker_tag"
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
func bindAction(fn core.CliHandlerFunc) cli.ActionFunc {
|
||||
return func(ctx context.Context, cmd *cli.Command) error {
|
||||
c := core.WrapCilContext(ctx, cmd)
|
||||
return fn(c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/cron"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
)
|
||||
|
||||
// CronJobs represents the cron command
|
||||
var CronJobs = &cli.Command{
|
||||
Name: "cron",
|
||||
Usage: "ezBookkeeping cron job utilities",
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List all enabled cron jobs",
|
||||
Action: bindAction(listAllCronJobs),
|
||||
},
|
||||
{
|
||||
Name: "run",
|
||||
Usage: "Run specified cron job",
|
||||
Action: bindAction(runCronJob),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Cron job name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func listAllCronJobs(c *core.CliContext) error {
|
||||
config, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = cron.InitializeCronJobSchedulerContainer(c, config, false)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[cron_jobs.listAllCronJobs] initializes cron job scheduler failed, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
cronJobs := cron.Container.GetAllJobs()
|
||||
|
||||
if len(cronJobs) < 1 {
|
||||
log.CliErrorf(c, "[cron_jobs.listAllCronJobs] there are no enabled cron jobs")
|
||||
return err
|
||||
}
|
||||
|
||||
for i := 0; i < len(cronJobs); i++ {
|
||||
if i > 0 {
|
||||
fmt.Printf("---\n")
|
||||
}
|
||||
|
||||
cronJob := cronJobs[i]
|
||||
|
||||
fmt.Printf("[Name] %s\n", cronJob.Name)
|
||||
fmt.Printf("[Description] %s\n", cronJob.Description)
|
||||
fmt.Printf("[Interval] Every %s\n", cronJob.Period.GetInterval())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCronJob(c *core.CliContext) error {
|
||||
config, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = cron.InitializeCronJobSchedulerContainer(c, config, false)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[cron_jobs.runCronJob] initializes cron job scheduler failed, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
jobName := c.String("name")
|
||||
err = cron.Container.SyncRunJobNow(jobName)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[cron_jobs.runCronJob] failed to run cron job \"%s\", because %s", jobName, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[cron_jobs.runCronJob] run cron job \"%s\" successfully", jobName)
|
||||
|
||||
return nil
|
||||
}
|
||||
+75
-18
@@ -1,8 +1,9 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/datastore"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
@@ -12,36 +13,36 @@ import (
|
||||
var Database = &cli.Command{
|
||||
Name: "database",
|
||||
Usage: "ezBookkeeping database maintenance",
|
||||
Subcommands: []*cli.Command{
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "update",
|
||||
Usage: "Update database structure",
|
||||
Action: updateDatabaseStructure,
|
||||
Action: bindAction(updateDatabaseStructure),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func updateDatabaseStructure(c *cli.Context) error {
|
||||
func updateDatabaseStructure(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[database.updateDatabaseStructure] starting maintaining")
|
||||
log.CliInfof(c, "[database.updateDatabaseStructure] starting maintaining")
|
||||
|
||||
err = updateAllDatabaseTablesStructure()
|
||||
err = updateAllDatabaseTablesStructure(c)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[database.updateDatabaseStructure] update database table structure failed, because %s", err.Error())
|
||||
log.CliErrorf(c, "[database.updateDatabaseStructure] update database table structure failed, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[database.updateDatabaseStructure] all tables maintained successfully")
|
||||
log.CliInfof(c, "[database.updateDatabaseStructure] all tables maintained successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAllDatabaseTablesStructure() error {
|
||||
func updateAllDatabaseTablesStructure(c *core.CliContext) error {
|
||||
var err error
|
||||
|
||||
err = datastore.Container.UserStore.SyncStructs(new(models.User))
|
||||
@@ -50,7 +51,7 @@ func updateAllDatabaseTablesStructure() error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[database.updateAllDatabaseTablesStructure] user table maintained successfully")
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserStore.SyncStructs(new(models.TwoFactor))
|
||||
|
||||
@@ -58,7 +59,7 @@ func updateAllDatabaseTablesStructure() error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[database.updateAllDatabaseTablesStructure] two factor table maintained successfully")
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] two-factor table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserStore.SyncStructs(new(models.TwoFactorRecoveryCode))
|
||||
|
||||
@@ -66,7 +67,7 @@ func updateAllDatabaseTablesStructure() error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[database.updateAllDatabaseTablesStructure] two factor recovery code table maintained successfully")
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] two-factor recovery code table maintained successfully")
|
||||
|
||||
err = datastore.Container.TokenStore.SyncStructs(new(models.TokenRecord))
|
||||
|
||||
@@ -74,7 +75,7 @@ func updateAllDatabaseTablesStructure() error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[database.updateAllDatabaseTablesStructure] token record table maintained successfully")
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] token record table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.Account))
|
||||
|
||||
@@ -82,7 +83,7 @@ func updateAllDatabaseTablesStructure() error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[database.updateAllDatabaseTablesStructure] account table maintained successfully")
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] account table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.Transaction))
|
||||
|
||||
@@ -90,7 +91,7 @@ func updateAllDatabaseTablesStructure() error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction table maintained successfully")
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionCategory))
|
||||
|
||||
@@ -98,7 +99,15 @@ func updateAllDatabaseTablesStructure() error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction category table maintained successfully")
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction category table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTagGroup))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction tag group table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTag))
|
||||
|
||||
@@ -106,7 +115,7 @@ func updateAllDatabaseTablesStructure() error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction tag table maintained successfully")
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction tag table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTagIndex))
|
||||
|
||||
@@ -114,7 +123,55 @@ func updateAllDatabaseTablesStructure() error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction tag index table maintained successfully")
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction tag index table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTemplate))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction template table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionPictureInfo))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction picture table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.UserCustomExchangeRate))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user custom exchange rate table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.UserApplicationCloudSetting))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user application cloud settings table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.UserExternalAuth))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user external auth table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.InsightsExplorer))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] insights explorer table maintained successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+146
-16
@@ -4,77 +4,152 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/datastore"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/mail"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/storage"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/uuid"
|
||||
)
|
||||
|
||||
func initializeSystem(c *cli.Context) (*settings.Config, error) {
|
||||
func initializeSystem(c *core.CliContext) (*settings.Config, error) {
|
||||
var err error
|
||||
configFilePath := c.String("conf-path")
|
||||
isDisableBootLog := c.Bool("no-boot-log")
|
||||
|
||||
if configFilePath != "" {
|
||||
if _, err = os.Stat(configFilePath); err != nil {
|
||||
log.BootErrorf("[initializer.initializeSystem] cannot load configuration from custom config path %s, because file not exists", configFilePath)
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] cannot load configuration from custom config path %s, because file not exists", configFilePath)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.BootInfof("[initializer.initializeSystem] will loading configuration from custom config path %s", configFilePath)
|
||||
if !isDisableBootLog {
|
||||
log.BootInfof(c, "[initializer.initializeSystem] will loading configuration from custom config path %s", configFilePath)
|
||||
}
|
||||
} else {
|
||||
configFilePath, err = settings.GetDefaultConfigFilePath()
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[initializer.initializeSystem] cannot get default configuration path, because %s", err.Error())
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] cannot get default configuration path, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.BootInfof("[initializer.initializeSystem] will load configuration from default config path %s", configFilePath)
|
||||
if !isDisableBootLog {
|
||||
log.BootInfof(c, "[initializer.initializeSystem] will load configuration from default config path %s", configFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
config, err := settings.LoadConfiguration(configFilePath)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[initializer.initializeSystem] cannot load configuration, because %s", err.Error())
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] cannot load configuration, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config.SecretKeyNoSet {
|
||||
log.BootWarnf(c, "[initializer.initializeSystem] \"secret_key\" in config file is not set, please change it to keep your user data safe")
|
||||
}
|
||||
|
||||
settings.SetCurrentConfig(config)
|
||||
|
||||
err = datastore.InitializeDataStore(config)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[initializer.initializeSystem] initializes data store failed, because %s", err.Error())
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] initializes data store failed, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = log.SetLoggerConfiguration(config)
|
||||
err = log.SetLoggerConfiguration(config, isDisableBootLog)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[initializer.initializeSystem] sets logger configuration failed, because %s", err.Error())
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] sets logger configuration failed, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = storage.InitializeStorageContainer(config)
|
||||
|
||||
if err != nil {
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] initializes object storage failed, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = llm.InitializeLargeLanguageModelProvider(config)
|
||||
|
||||
if err != nil {
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] initializes large language model provider failed, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = uuid.InitializeUuidGenerator(config)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[initializer.initializeSystem] initializes uuid generator failed, because %s", err.Error())
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] initializes uuid generator failed, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = duplicatechecker.InitializeDuplicateChecker(config)
|
||||
|
||||
if err != nil {
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] initializes duplicate checker failed, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = avatars.InitializeAvatarProvider(config)
|
||||
|
||||
if err != nil {
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] initializes avatar provider failed, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = mail.InitializeMailer(config)
|
||||
|
||||
if err != nil {
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] initializes mailer failed, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = exchangerates.InitializeExchangeRatesDataSource(config)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[initializer.initializeSystem] initializes exchange rates data source failed, because %s", err.Error())
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] initializes exchange rates data source failed, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfgJson, _ := json.Marshal(getConfigWithoutSensitiveData(config))
|
||||
log.BootInfof("[initializer.initializeSystem] has loaded configuration %s", cfgJson)
|
||||
|
||||
if !isDisableBootLog {
|
||||
log.BootInfof(c, "[initializer.initializeSystem] has loaded configuration %s", cfgJson)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
@@ -87,8 +162,63 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
|
||||
return config
|
||||
}
|
||||
|
||||
clonedConfig.DatabaseConfig.DatabasePassword = "****"
|
||||
clonedConfig.SecretKey = "****"
|
||||
if clonedConfig.DatabaseConfig.DatabasePassword != "" {
|
||||
clonedConfig.DatabaseConfig.DatabasePassword = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.SMTPConfig.SMTPPasswd != "" {
|
||||
clonedConfig.SMTPConfig.SMTPPasswd = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.MinIOConfig.SecretAccessKey != "" {
|
||||
clonedConfig.MinIOConfig.SecretAccessKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.SecretKey != "" {
|
||||
clonedConfig.SecretKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.AmapApplicationSecret != "" {
|
||||
clonedConfig.AmapApplicationSecret = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.WebDAVConfig != nil && clonedConfig.WebDAVConfig.Password != "" {
|
||||
clonedConfig.WebDAVConfig.Password = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig != nil {
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAIAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAIAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicCompatibleAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicCompatibleAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.LMStudioToken != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.LMStudioToken = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.GoogleAIAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.GoogleAIAPIKey = "****"
|
||||
}
|
||||
}
|
||||
|
||||
if clonedConfig.OAuth2ClientSecret != "" {
|
||||
clonedConfig.OAuth2ClientSecret = "****"
|
||||
}
|
||||
|
||||
return clonedConfig
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// SecurityUtils represents the security command
|
||||
var SecurityUtils = &cli.Command{
|
||||
Name: "security",
|
||||
Usage: "ezBookkeeping security utilities",
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "gen-secret-key",
|
||||
Usage: "Generate a random secret key",
|
||||
Action: bindAction(genSecretKey),
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "length",
|
||||
Aliases: []string{"l"},
|
||||
Required: false,
|
||||
DefaultText: "32",
|
||||
Usage: "The length of secret key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func genSecretKey(c *core.CliContext) error {
|
||||
length := c.Int("length")
|
||||
|
||||
if length <= 0 {
|
||||
length = 32
|
||||
}
|
||||
|
||||
secretKey, err := utils.GetRandomNumberOrLetter(length)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("[Secret Key] %s\n", secretKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
+619
-45
@@ -2,13 +2,15 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
clis "github.com/mayswind/ezbookkeeping/pkg/cli"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -16,11 +18,11 @@ import (
|
||||
var UserData = &cli.Command{
|
||||
Name: "userdata",
|
||||
Usage: "ezBookkeeping user data maintenance",
|
||||
Subcommands: []*cli.Command{
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "user-add",
|
||||
Usage: "Add new user",
|
||||
Action: addNewUser,
|
||||
Action: bindAction(addNewUser),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
@@ -57,7 +59,7 @@ var UserData = &cli.Command{
|
||||
{
|
||||
Name: "user-get",
|
||||
Usage: "Get specified user info",
|
||||
Action: getUserInfo,
|
||||
Action: bindAction(getUserInfo),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
@@ -70,7 +72,7 @@ var UserData = &cli.Command{
|
||||
{
|
||||
Name: "user-modify-password",
|
||||
Usage: "Modify user password",
|
||||
Action: modifyUserPassword,
|
||||
Action: bindAction(modifyUserPassword),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
@@ -86,10 +88,132 @@ var UserData = &cli.Command{
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-enable",
|
||||
Usage: "Enable specified user",
|
||||
Action: bindAction(enableUser),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-disable",
|
||||
Usage: "Disable specified user",
|
||||
Action: bindAction(disableUser),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-set-restrict-features",
|
||||
Usage: "Set restrictions of user features",
|
||||
Action: bindAction(setUserFeatureRestriction),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "features",
|
||||
Aliases: []string{"t"},
|
||||
Required: true,
|
||||
Usage: "Specific feature types (feature types separated by commas)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-add-restrict-features",
|
||||
Usage: "Add restrictions of user features",
|
||||
Action: bindAction(addUserFeatureRestriction),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "features",
|
||||
Aliases: []string{"t"},
|
||||
Required: true,
|
||||
Usage: "Specific feature types (feature types separated by commas)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-remove-restrict-features",
|
||||
Usage: "Remove restrictions of user features",
|
||||
Action: bindAction(removeUserFeatureRestriction),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "features",
|
||||
Aliases: []string{"t"},
|
||||
Required: true,
|
||||
Usage: "Specific feature types (feature types separated by commas)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-resend-verify-email",
|
||||
Usage: "Resend user verify email",
|
||||
Action: bindAction(resendUserVerifyEmail),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-set-email-verified",
|
||||
Usage: "Set user email address verified",
|
||||
Action: bindAction(setUserEmailVerified),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-set-email-unverified",
|
||||
Usage: "Set user email address unverified",
|
||||
Action: bindAction(setUserEmailUnverified),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-delete",
|
||||
Usage: "Delete specified user",
|
||||
Action: deleteUser,
|
||||
Action: bindAction(deleteUser),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
@@ -102,7 +226,7 @@ var UserData = &cli.Command{
|
||||
{
|
||||
Name: "user-2fa-disable",
|
||||
Usage: "Disable user 2fa setting",
|
||||
Action: disableUser2FA,
|
||||
Action: bindAction(disableUser2FA),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
@@ -115,7 +239,7 @@ var UserData = &cli.Command{
|
||||
{
|
||||
Name: "user-session-list",
|
||||
Usage: "List all user sessions",
|
||||
Action: listUserTokens,
|
||||
Action: bindAction(listUserTokens),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
@@ -125,10 +249,61 @@ var UserData = &cli.Command{
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-session-new",
|
||||
Usage: "Create new session for user",
|
||||
Action: bindAction(createNewUserToken),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "type",
|
||||
Aliases: []string{"t"},
|
||||
Required: false,
|
||||
Usage: "Specific token type, supports \"api\" and \"mcp\", default is \"api\"",
|
||||
},
|
||||
&cli.Int64Flag{
|
||||
Name: "expiresInSeconds",
|
||||
Aliases: []string{"e"},
|
||||
Required: true,
|
||||
Usage: "Token expiration time in seconds (0 - 4294967295, 0 means no expiration).",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-session-revoke",
|
||||
Usage: "Revoke the specified user session",
|
||||
Action: bindAction(revokeUserToken),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Aliases: []string{"t"},
|
||||
Required: false,
|
||||
Usage: "Specific token content",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-session-clear",
|
||||
Usage: "Clear user all sessions",
|
||||
Action: clearUserTokens,
|
||||
Action: bindAction(clearUserTokens),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "send-password-reset-mail",
|
||||
Usage: "Send password reset mail",
|
||||
Action: bindAction(sendPasswordResetMail),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
@@ -141,7 +316,7 @@ var UserData = &cli.Command{
|
||||
{
|
||||
Name: "transaction-check",
|
||||
Usage: "Check whether user all transactions and accounts are correct",
|
||||
Action: checkUserTransactionAndAccount,
|
||||
Action: bindAction(checkUserTransactionAndAccount),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
@@ -151,10 +326,48 @@ var UserData = &cli.Command{
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "transaction-tag-index-fix-transaction-time",
|
||||
Usage: "Fix the transaction tag index data which does not have transaction time",
|
||||
Action: bindAction(fixTransactionTagIndexNotHaveTransactionTime),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "transaction-import",
|
||||
Usage: "Import transactions to specified user",
|
||||
Action: bindAction(importUserTransaction),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "file",
|
||||
Aliases: []string{"f"},
|
||||
Required: true,
|
||||
Usage: "Specific import file path (e.g. transaction.csv)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "type",
|
||||
Aliases: []string{"t"},
|
||||
Required: true,
|
||||
Usage: "Import file type (supports \"ezbookkeeping_csv\", \"ezbookkeeping_tsv\")",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "transaction-export",
|
||||
Usage: "Export user all transactions to csv file",
|
||||
Action: exportUserTransaction,
|
||||
Usage: "Export user all transactions to file",
|
||||
Action: bindAction(exportUserTransaction),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
@@ -168,12 +381,18 @@ var UserData = &cli.Command{
|
||||
Required: true,
|
||||
Usage: "Specific exported file path (e.g. transaction.csv)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "type",
|
||||
Aliases: []string{"t"},
|
||||
Required: false,
|
||||
Usage: "Export file type, support csv or tsv, default is csv",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func addNewUser(c *cli.Context) error {
|
||||
func addNewUser(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
@@ -189,7 +408,7 @@ func addNewUser(c *cli.Context) error {
|
||||
user, err := clis.UserData.AddNewUser(c, username, email, nickname, password, defaultCurrency)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[user_data.addNewUser] error occurs when adding new user")
|
||||
log.CliErrorf(c, "[user_data.addNewUser] error occurs when adding new user")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -198,7 +417,7 @@ func addNewUser(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getUserInfo(c *cli.Context) error {
|
||||
func getUserInfo(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
@@ -209,7 +428,7 @@ func getUserInfo(c *cli.Context) error {
|
||||
user, err := clis.UserData.GetUserByUsername(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[user_data.getUserInfo] error occurs when getting user data")
|
||||
log.CliErrorf(c, "[user_data.getUserInfo] error occurs when getting user data")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -218,7 +437,7 @@ func getUserInfo(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func modifyUserPassword(c *cli.Context) error {
|
||||
func modifyUserPassword(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
@@ -230,16 +449,211 @@ func modifyUserPassword(c *cli.Context) error {
|
||||
err = clis.UserData.ModifyUserPassword(c, username, password)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[user_data.modifyUserPassword] error occurs when modifying user password")
|
||||
log.CliErrorf(c, "[user_data.modifyUserPassword] error occurs when modifying user password")
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[user_data.modifyUserPassword] password of user \"%s\" has been changed", username)
|
||||
log.CliInfof(c, "[user_data.modifyUserPassword] password of user \"%s\" has been changed", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteUser(c *cli.Context) error {
|
||||
func sendPasswordResetMail(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
err = clis.UserData.SendPasswordResetMail(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.sendPasswordResetMail] error occurs when sending password reset email")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.sendPasswordResetMail] a password reset email for user \"%s\" has been sent", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func enableUser(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
err = clis.UserData.EnableUser(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.enableUser] error occurs when setting user enabled")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.enableUser] user \"%s\" has been set enabled", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func disableUser(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
err = clis.UserData.DisableUser(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.disableUser] error occurs when setting user disabled")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.disableUser] user \"%s\" has been set disabled", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setUserFeatureRestriction(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
|
||||
err = clis.UserData.SetUserFeatureRestrictions(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.setUserFeatureRestriction] error occurs when setting user feature restriction")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.setUserFeatureRestriction] user \"%s\" has been set new feature restriction", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addUserFeatureRestriction(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
|
||||
|
||||
if featureRestriction < 1 {
|
||||
log.CliErrorf(c, "[user_data.addUserFeatureRestriction] nothing has been modified")
|
||||
return nil
|
||||
}
|
||||
|
||||
err = clis.UserData.AddUserFeatureRestrictions(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.addUserFeatureRestriction] error occurs when adding user feature restriction")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.addUserFeatureRestriction] user \"%s\" has been add new feature restriction", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeUserFeatureRestriction(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
|
||||
|
||||
if featureRestriction < 1 {
|
||||
log.CliErrorf(c, "[user_data.removeUserFeatureRestriction] nothing has been modified")
|
||||
return nil
|
||||
}
|
||||
|
||||
err = clis.UserData.RemoveUserFeatureRestrictions(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.removeUserFeatureRestriction] error occurs when removing user feature restriction")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.removeUserFeatureRestriction] user \"%s\" has been removed new feature restriction", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resendUserVerifyEmail(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
err = clis.UserData.ResendVerifyEmail(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.resendUserVerifyEmail] error occurs when resending user verify email")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.resendUserVerifyEmail] verify email for user \"%s\" has been resent", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setUserEmailVerified(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
err = clis.UserData.SetUserEmailVerified(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.setUserEmailVerified] error occurs when setting user email address verified")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.setUserEmailVerified] user \"%s\" email address has been set verified", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setUserEmailUnverified(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
err = clis.UserData.SetUserEmailUnverified(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.setUserEmailUnverified] error occurs when setting user email address unverified")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.setUserEmailUnverified] user \"%s\" email address has been set unverified", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteUser(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
@@ -250,16 +664,16 @@ func deleteUser(c *cli.Context) error {
|
||||
err = clis.UserData.DeleteUser(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[user_data.deleteUser] error occurs when deleting user")
|
||||
log.CliErrorf(c, "[user_data.deleteUser] error occurs when deleting user")
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[user_data.deleteUser] user \"%s\" has been deleted", username)
|
||||
log.CliInfof(c, "[user_data.deleteUser] user \"%s\" has been deleted", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func disableUser2FA(c *cli.Context) error {
|
||||
func disableUser2FA(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
@@ -270,16 +684,16 @@ func disableUser2FA(c *cli.Context) error {
|
||||
err = clis.UserData.DisableUserTwoFactorAuthorization(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[user_data.disableUser2FA] error occurs when disabling user two factor authorization")
|
||||
log.CliErrorf(c, "[user_data.disableUser2FA] error occurs when disabling user two-factor authorization")
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[user_data.disableUser2FA] two factor authorization of user \"%s\" has been disabled", username)
|
||||
log.CliInfof(c, "[user_data.disableUser2FA] two-factor authorization of user \"%s\" has been disabled", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listUserTokens(c *cli.Context) error {
|
||||
func listUserTokens(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
@@ -290,7 +704,7 @@ func listUserTokens(c *cli.Context) error {
|
||||
tokens, err := clis.UserData.ListUserTokens(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[user_data.listUserTokens] error occurs when getting user tokens")
|
||||
log.CliErrorf(c, "[user_data.listUserTokens] error occurs when getting user tokens")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -305,7 +719,65 @@ func listUserTokens(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearUserTokens(c *cli.Context) error {
|
||||
func createNewUserToken(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
tokenType := c.String("type")
|
||||
expiresInSeconds := c.Int64("expiresInSeconds")
|
||||
|
||||
if tokenType == "" {
|
||||
tokenType = "api"
|
||||
}
|
||||
|
||||
if tokenType != "api" && tokenType != "mcp" {
|
||||
log.CliErrorf(c, "[user_data.createNewUserToken] token type is invalid")
|
||||
return nil
|
||||
}
|
||||
|
||||
if expiresInSeconds < 0 || expiresInSeconds > 4294967295 {
|
||||
log.CliErrorf(c, "[user_data.createNewUserToken] expiresInSeconds is out of range (0 - 4294967295)")
|
||||
return nil
|
||||
}
|
||||
|
||||
token, tokenString, err := clis.UserData.CreateNewUserToken(c, username, tokenType, expiresInSeconds)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.createNewUserToken] error occurs when creating user token")
|
||||
return err
|
||||
}
|
||||
|
||||
printTokenInfo(token)
|
||||
fmt.Printf("[NewToken] %s\n", tokenString)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeUserToken(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token := c.String("token")
|
||||
err = clis.UserData.RevokeUserToken(c, token)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.revokeUserToken] error occurs when revoking user token")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.revokeUserToken] the specified user token has been revoked successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearUserTokens(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
@@ -316,16 +788,16 @@ func clearUserTokens(c *cli.Context) error {
|
||||
err = clis.UserData.ClearUserTokens(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[user_data.clearUserTokens] error occurs when clearing user tokens")
|
||||
log.CliErrorf(c, "[user_data.clearUserTokens] error occurs when clearing user tokens")
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[user_data.clearUserTokens] all tokens of user \"%s\" has been cleared", username)
|
||||
log.CliInfof(c, "[user_data.clearUserTokens] all tokens of user \"%s\" has been cleared", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkUserTransactionAndAccount(c *cli.Context) error {
|
||||
func checkUserTransactionAndAccount(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
@@ -334,21 +806,44 @@ func checkUserTransactionAndAccount(c *cli.Context) error {
|
||||
|
||||
username := c.String("username")
|
||||
|
||||
log.BootInfof("[user_data.checkUserTransactionAndAccount] starting checking user \"%s\" data", username)
|
||||
log.CliInfof(c, "[user_data.checkUserTransactionAndAccount] starting checking user \"%s\" data", username)
|
||||
|
||||
_, err = clis.UserData.CheckTransactionAndAccount(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[user_data.checkUserTransactionAndAccount] error occurs when checking user data")
|
||||
log.CliErrorf(c, "[user_data.checkUserTransactionAndAccount] error occurs when checking user data")
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[user_data.checkUserTransactionAndAccount] user transactions and accounts data has been checked successfully, there is no problem with user data")
|
||||
log.CliInfof(c, "[user_data.checkUserTransactionAndAccount] user transactions and accounts data has been checked successfully, there is no problem with user data")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportUserTransaction(c *cli.Context) error {
|
||||
func fixTransactionTagIndexNotHaveTransactionTime(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
|
||||
log.CliInfof(c, "[user_data.fixTransactionTagIndexNotHaveTransactionTime] starting fixing user \"%s\" transaction tag index data", username)
|
||||
|
||||
_, err = clis.UserData.FixTransactionTagIndexWithTransactionTime(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.fixTransactionTagIndexNotHaveTransactionTime] error occurs when fixing user data")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.fixTransactionTagIndexNotHaveTransactionTime] user transaction tag index data has been fixed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportUserTransaction(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
@@ -357,36 +852,95 @@ func exportUserTransaction(c *cli.Context) error {
|
||||
|
||||
username := c.String("username")
|
||||
filePath := c.String("file")
|
||||
fileType := c.String("type")
|
||||
|
||||
if fileType == "" {
|
||||
fileType = "csv"
|
||||
}
|
||||
|
||||
if fileType != "csv" && fileType != "tsv" {
|
||||
log.CliErrorf(c, "[user_data.exportUserTransaction] export file type is not supported")
|
||||
return errs.ErrNotSupported
|
||||
}
|
||||
|
||||
if filePath == "" {
|
||||
log.BootErrorf("[user_data.exportUserTransaction] export file path is not specified")
|
||||
log.CliErrorf(c, "[user_data.exportUserTransaction] export file path is unspecified")
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
fileExists, err := utils.IsExists(filePath)
|
||||
|
||||
if fileExists {
|
||||
log.BootErrorf("[user_data.exportUserTransaction] specified file path already exists")
|
||||
log.CliErrorf(c, "[user_data.exportUserTransaction] specified file path already exists")
|
||||
return os.ErrExist
|
||||
}
|
||||
|
||||
log.BootInfof("[user_data.exportUserTransaction] starting exporting user \"%s\" data", username)
|
||||
log.CliInfof(c, "[user_data.exportUserTransaction] starting exporting user \"%s\" data", username)
|
||||
|
||||
content, err := clis.UserData.ExportTransaction(c, username)
|
||||
content, err := clis.UserData.ExportTransaction(c, username, fileType)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[user_data.exportUserTransaction] error occurs when exporting user data")
|
||||
log.CliErrorf(c, "[user_data.exportUserTransaction] error occurs when exporting user data")
|
||||
return err
|
||||
}
|
||||
|
||||
err = utils.WriteFile(filePath, content)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[user_data.exportUserTransaction] failed to write to %s", filePath)
|
||||
log.CliErrorf(c, "[user_data.exportUserTransaction] failed to write to %s", filePath)
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[user_data.exportUserTransaction] user transactions have been exported to %s", filePath)
|
||||
log.CliInfof(c, "[user_data.exportUserTransaction] user transactions have been exported to %s", filePath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func importUserTransaction(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
filePath := c.String("file")
|
||||
filetype := c.String("type")
|
||||
|
||||
if filePath == "" {
|
||||
log.CliErrorf(c, "[user_data.importUserTransaction] import file path is not specified")
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
fileExists, err := utils.IsExists(filePath)
|
||||
|
||||
if !fileExists {
|
||||
log.CliErrorf(c, "[user_data.importUserTransaction] import file does not exist")
|
||||
return os.ErrExist
|
||||
}
|
||||
|
||||
if filetype != "ezbookkeeping_csv" && filetype != "ezbookkeeping_tsv" {
|
||||
log.CliErrorf(c, "[user_data.importUserTransaction] unknown file type \"%s\"", filetype)
|
||||
return errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.importUserTransaction] failed to load import file")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.importUserTransaction] start importing transactions to user \"%s\"", username)
|
||||
|
||||
err = clis.UserData.ImportTransaction(c, username, filetype, data)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.importUserTransaction] error occurs when importing user data")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.importUserTransaction] transactions have been imported to user \"%s\"", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -398,9 +952,28 @@ func printUserInfo(user *models.User) {
|
||||
fmt.Printf("[Nickname] %s\n", user.Nickname)
|
||||
fmt.Printf("[Password] %s\n", user.Password)
|
||||
fmt.Printf("[Salt] %s\n", user.Salt)
|
||||
fmt.Printf("[DefaultAccountId] %d\n", user.DefaultAccountId)
|
||||
fmt.Printf("[TransactionEditScope] %s (%d)\n", user.TransactionEditScope, user.TransactionEditScope)
|
||||
fmt.Printf("[Language] %s\n", user.Language)
|
||||
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
|
||||
fmt.Printf("[FirstDayOfWeek] %s\n", user.FirstDayOfWeek)
|
||||
fmt.Printf("[TransactionEditScope] %s\n", user.TransactionEditScope)
|
||||
fmt.Printf("[FirstDayOfWeek] %s (%d)\n", user.FirstDayOfWeek, user.FirstDayOfWeek)
|
||||
fmt.Printf("[FiscalYearStart] %s (%d)\n", user.FiscalYearStart, user.FiscalYearStart)
|
||||
fmt.Printf("[CalendarDisplayType] %s (%d)\n", user.CalendarDisplayType, user.CalendarDisplayType)
|
||||
fmt.Printf("[DateDisplayType] %s (%d)\n", user.DateDisplayType, user.DateDisplayType)
|
||||
fmt.Printf("[LongDateFormat] %s (%d)\n", user.LongDateFormat, user.LongDateFormat)
|
||||
fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat)
|
||||
fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat)
|
||||
fmt.Printf("[ShortTimeFormat] %s (%d)\n", user.ShortTimeFormat, user.ShortTimeFormat)
|
||||
fmt.Printf("[FiscalYearFormat] %s (%d)\n", user.FiscalYearFormat, user.FiscalYearFormat)
|
||||
fmt.Printf("[CurrencyDisplayType] %s (%d)\n", user.CurrencyDisplayType, user.CurrencyDisplayType)
|
||||
fmt.Printf("[NumeralSystem] %s (%d)\n", user.NumeralSystem, user.NumeralSystem)
|
||||
fmt.Printf("[DecimalSeparator] %s (%d)\n", user.DecimalSeparator, user.DecimalSeparator)
|
||||
fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol)
|
||||
fmt.Printf("[DigitGrouping] %s (%d)\n", user.DigitGrouping, user.DigitGrouping)
|
||||
fmt.Printf("[CoordinateDisplayType] %s (%d)\n", user.CoordinateDisplayType, user.CoordinateDisplayType)
|
||||
fmt.Printf("[ExpenseAmountColor] %s (%d)\n", user.ExpenseAmountColor, user.ExpenseAmountColor)
|
||||
fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor)
|
||||
fmt.Printf("[FeatureRestriction] %s (%d)\n", user.FeatureRestriction, user.FeatureRestriction)
|
||||
fmt.Printf("[Deleted] %t\n", user.Deleted)
|
||||
fmt.Printf("[EmailVerified] %t\n", user.EmailVerified)
|
||||
fmt.Printf("[CreatedAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(user.CreatedUnixTime), user.CreatedUnixTime)
|
||||
@@ -421,5 +994,6 @@ func printUserInfo(user *models.User) {
|
||||
func printTokenInfo(token *models.TokenRecord) {
|
||||
fmt.Printf("[CreatedAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.CreatedUnixTime), token.CreatedUnixTime)
|
||||
fmt.Printf("[ExpiredAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.ExpiredUnixTime), token.ExpiredUnixTime)
|
||||
fmt.Printf("[LastSeen] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.LastSeenUnixTime), token.LastSeenUnixTime)
|
||||
fmt.Printf("[UserAgent] %s\n", token.UserAgent)
|
||||
}
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/mail"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/requestid"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// Utilities represents the utilities command
|
||||
var Utilities = &cli.Command{
|
||||
Name: "utility",
|
||||
Usage: "ezBookkeeping utilities",
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "parse-default-request-id",
|
||||
Usage: "Parse a request id which is generated by default request generator and show the details",
|
||||
Action: bindAction(parseRequestId),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Required: true,
|
||||
Usage: "Request ID",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "send-test-mail",
|
||||
Usage: "Send an email to specified e-mail address",
|
||||
Action: bindAction(sendTestMail),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "to",
|
||||
Required: true,
|
||||
Usage: "To e-mail address",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func parseRequestId(c *core.CliContext) error {
|
||||
config, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = requestid.InitializeRequestIdGenerator(c, config)
|
||||
defaultGenerator, err := requestid.NewDefaultRequestIdGenerator(c, config)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestId := c.String("id")
|
||||
requestIdInfo, err := defaultGenerator.ParseRequestIdInfo(requestId)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newRequestId := defaultGenerator.GenerateRequestId(net.IPv4zero.String(), 0)
|
||||
newRequestIdInfo, err := defaultGenerator.ParseRequestIdInfo(newRequestId)
|
||||
printRequestIdInfo(requestId, requestIdInfo, newRequestIdInfo)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendTestMail(c *core.CliContext) error {
|
||||
config, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !config.EnableSMTP {
|
||||
return errs.ErrSMTPServerNotEnabled
|
||||
}
|
||||
|
||||
toAddress := c.String("to")
|
||||
|
||||
err = mail.Container.SendMail(&mail.MailMessage{
|
||||
To: toAddress,
|
||||
Subject: "ezBookkeeping test e-mail",
|
||||
Body: "This is a test e-mail",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Test e-mail has been sent")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printRequestIdInfo(requestId string, requestIdInfo *requestid.RequestIdInfo, newRequestIdInfo *requestid.RequestIdInfo) {
|
||||
fmt.Printf("[RequestId] %s\n", requestId)
|
||||
fmt.Printf("[ServerUniqId] %d (Current Server %d)\n", requestIdInfo.ServerUniqId, newRequestIdInfo.ServerUniqId)
|
||||
fmt.Printf("[InstanceUniqId] %d (Current Server %d)\n", requestIdInfo.InstanceUniqId, newRequestIdInfo.InstanceUniqId)
|
||||
|
||||
displayTime, err := utils.ParseFromElapsedSeconds(int(requestIdInfo.SecondsElapsedToday))
|
||||
|
||||
if err == nil {
|
||||
fmt.Printf("[SecondsElapsedToday] %d (%s)\n", requestIdInfo.SecondsElapsedToday, displayTime)
|
||||
} else {
|
||||
fmt.Printf("[SecondsElapsedToday] %d\n", requestIdInfo.SecondsElapsedToday)
|
||||
}
|
||||
|
||||
fmt.Printf("[RequestSeqId] %d\n", requestIdInfo.RequestSeqId)
|
||||
fmt.Printf("[IsClientIpv6] %t\n", requestIdInfo.IsClientIpv6)
|
||||
|
||||
if requestIdInfo.IsClientIpv6 {
|
||||
fmt.Printf("[ClientIpv6Hash] %d\n", requestIdInfo.ClientIp)
|
||||
} else {
|
||||
ip := make(net.IP, 4)
|
||||
binary.BigEndian.PutUint32(ip, requestIdInfo.ClientIp)
|
||||
fmt.Printf("[ClientIpv4] %s\n", ip.String())
|
||||
}
|
||||
|
||||
fmt.Printf("[ClientPort] %d\n", requestIdInfo.ClientPort)
|
||||
}
|
||||
+435
-50
@@ -2,18 +2,25 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cache"
|
||||
"github.com/gin-contrib/cache/persistence"
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/api"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/cron"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/mcp"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/middlewares"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/requestid"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
@@ -25,52 +32,75 @@ import (
|
||||
var WebServer = &cli.Command{
|
||||
Name: "server",
|
||||
Usage: "ezBookkeeping web server operation",
|
||||
Subcommands: []*cli.Command{
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "run",
|
||||
Usage: "Run ezBookkeeping web server",
|
||||
Action: startWebServer,
|
||||
Action: bindAction(startWebServer),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func startWebServer(c *cli.Context) error {
|
||||
func startWebServer(c *core.CliContext) error {
|
||||
config, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof("[server.startWebServer] static root path is %s", config.StaticRootPath)
|
||||
log.BootInfof(c, "[webserver.startWebServer] static root path is %s", config.StaticRootPath)
|
||||
|
||||
if config.AutoUpdateDatabase {
|
||||
err = updateAllDatabaseTablesStructure()
|
||||
err = updateAllDatabaseTablesStructure(c)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[server.startWebServer] update database table structure failed, because %s", err.Error())
|
||||
log.BootErrorf(c, "[webserver.startWebServer] update database table structure failed, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = requestid.InitializeRequestIdGenerator(config)
|
||||
err = requestid.InitializeRequestIdGenerator(c, config)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[server.startWebServer] initializes requestid generator failed, because %s", err.Error())
|
||||
log.BootErrorf(c, "[webserver.startWebServer] initializes requestid generator failed, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
serverInfo := fmt.Sprintf("current server id is %d, current instance id is %d", requestid.Container.Current.GetCurrentServerUniqId(), requestid.Container.Current.GetCurrentInstanceUniqId())
|
||||
err = mcp.InitializeMCPHandlers(config)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf(c, "[webserver.startWebServer] initializes mcp handlers failed, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
err = oauth2.InitializeOAuth2Provider(config)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf(c, "[webserver.startWebServer] initializes oauth 2.0 provider failed, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
err = cron.InitializeCronJobSchedulerContainer(c, config, true)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf(c, "[webserver.startWebServer] initializes cron job scheduler failed, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
serverInfo := fmt.Sprintf("current server id is %d, current instance id is %d", requestid.Container.GetCurrentServerUniqId(), requestid.Container.GetCurrentInstanceUniqId())
|
||||
uuidServerInfo := ""
|
||||
if config.UuidGeneratorType == settings.InternalUuidGeneratorType {
|
||||
uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId)
|
||||
}
|
||||
|
||||
log.BootInfof("[server.startWebServer] %s%s", serverInfo, uuidServerInfo)
|
||||
log.BootInfof(c, "[webserver.startWebServer] %s%s", serverInfo, uuidServerInfo)
|
||||
|
||||
if config.Mode == settings.MODE_PRODUCTION {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
workboxFileNames := utils.ListFileNamesWithPrefixAndSuffix(config.StaticRootPath, "workbox-", ".js")
|
||||
|
||||
router := gin.New()
|
||||
router.Use(bindMiddleware(middlewares.Recovery))
|
||||
|
||||
@@ -82,13 +112,19 @@ func startWebServer(c *cli.Context) error {
|
||||
_ = v.RegisterValidation("notBlank", validators.NotBlank)
|
||||
_ = v.RegisterValidation("validUsername", validators.ValidUsername)
|
||||
_ = v.RegisterValidation("validEmail", validators.ValidEmail)
|
||||
_ = v.RegisterValidation("validNickname", validators.ValidNickname)
|
||||
_ = v.RegisterValidation("validCurrency", validators.ValidCurrency)
|
||||
_ = v.RegisterValidation("validHexRGBColor", validators.ValidHexRGBColor)
|
||||
_ = v.RegisterValidation("validAmountFilter", validators.ValidAmountFilter)
|
||||
_ = v.RegisterValidation("validTagFilter", validators.ValidTagFilter)
|
||||
_ = v.RegisterValidation("validFiscalYearStart", validators.ValidateFiscalYearStart)
|
||||
}
|
||||
|
||||
router.NoRoute(bindApi(api.Default.ApiNotFound))
|
||||
router.NoMethod(bindApi(api.Default.MethodNotAllowed))
|
||||
|
||||
serverSettingsCacheStore := persistence.NewInMemoryStore(time.Minute)
|
||||
|
||||
router.StaticFile("/", filepath.Join(config.StaticRootPath, "index.html"))
|
||||
router.Static("/js", filepath.Join(config.StaticRootPath, "js"))
|
||||
router.Static("/css", filepath.Join(config.StaticRootPath, "css"))
|
||||
@@ -100,28 +136,30 @@ func startWebServer(c *cli.Context) error {
|
||||
router.StaticFile("favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
|
||||
router.StaticFile("touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
|
||||
router.StaticFile("manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
|
||||
router.StaticFile("sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
|
||||
router.GET("/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
|
||||
|
||||
mobileEntryRoute := router.Group("/mobile")
|
||||
mobileEntryRoute.Use(bindMiddleware(middlewares.ServerSettingsCookie(config)))
|
||||
{
|
||||
mobileEntryRoute.StaticFile("/", filepath.Join(config.StaticRootPath, "mobile.html"))
|
||||
for i := 0; i < len(workboxFileNames); i++ {
|
||||
router.StaticFile("/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
|
||||
}
|
||||
|
||||
router.StaticFile("/mobile", filepath.Join(config.StaticRootPath, "mobile.html"))
|
||||
router.Static("/mobile/js", filepath.Join(config.StaticRootPath, "js"))
|
||||
router.Static("/mobile/css", filepath.Join(config.StaticRootPath, "css"))
|
||||
router.Static("/mobile/img", filepath.Join(config.StaticRootPath, "img"))
|
||||
router.Static("/mobile/fonts", filepath.Join(config.StaticRootPath, "fonts"))
|
||||
router.Static("/mobile/sw", filepath.Join(config.StaticRootPath, "sw"))
|
||||
router.StaticFile("/mobile/favicon.ico", filepath.Join(config.StaticRootPath, "favicon.ico"))
|
||||
router.StaticFile("/mobile/favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
|
||||
router.StaticFile("/mobile/touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
|
||||
router.StaticFile("/mobile/manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
|
||||
router.StaticFile("/mobile/sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
|
||||
router.GET("/mobile/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
|
||||
|
||||
desktopEntryRoute := router.Group("/desktop")
|
||||
desktopEntryRoute.Use(bindMiddleware(middlewares.ServerSettingsCookie(config)))
|
||||
{
|
||||
desktopEntryRoute.StaticFile("/", filepath.Join(config.StaticRootPath, "desktop.html"))
|
||||
for i := 0; i < len(workboxFileNames); i++ {
|
||||
router.StaticFile("/mobile/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
|
||||
}
|
||||
|
||||
router.StaticFile("/desktop", filepath.Join(config.StaticRootPath, "desktop.html"))
|
||||
router.Static("/desktop/js", filepath.Join(config.StaticRootPath, "js"))
|
||||
router.Static("/desktop/css", filepath.Join(config.StaticRootPath, "css"))
|
||||
router.Static("/desktop/img", filepath.Join(config.StaticRootPath, "img"))
|
||||
@@ -130,62 +168,207 @@ func startWebServer(c *cli.Context) error {
|
||||
router.StaticFile("/desktop/favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
|
||||
router.StaticFile("/desktop/touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
|
||||
router.StaticFile("/desktop/manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
|
||||
router.StaticFile("/desktop/sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
|
||||
router.GET("/desktop/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
|
||||
|
||||
for i := 0; i < len(workboxFileNames); i++ {
|
||||
router.StaticFile("/desktop/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
|
||||
}
|
||||
|
||||
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
|
||||
avatarRoute := router.Group("/avatar")
|
||||
avatarRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
|
||||
{
|
||||
avatarRoute.GET("/:fileName", bindImage(api.Users.UserGetAvatarHandler))
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableTransactionPictures {
|
||||
pictureRoute := router.Group("/pictures")
|
||||
pictureRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
|
||||
{
|
||||
pictureRoute.GET("/:fileName", bindImage(api.TransactionPictures.TransactionPictureGetHandler))
|
||||
}
|
||||
}
|
||||
|
||||
router.GET("/healthz.json", bindApi(api.Healths.HealthStatusHandler))
|
||||
|
||||
proxyRoute := router.Group("/proxy")
|
||||
proxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
|
||||
{
|
||||
if config.EnableMapDataFetchProxy {
|
||||
if config.MapProvider == settings.OpenStreetMapProvider ||
|
||||
config.MapProvider == settings.OpenStreetMapHumanitarianStyleProvider ||
|
||||
config.MapProvider == settings.OpenTopoMapProvider ||
|
||||
config.MapProvider == settings.OPNVKarteMapProvider ||
|
||||
config.MapProvider == settings.CyclOSMMapProvider ||
|
||||
config.MapProvider == settings.CartoDBMapProvider ||
|
||||
config.MapProvider == settings.TomTomMapProvider ||
|
||||
config.MapProvider == settings.TianDiTuProvider ||
|
||||
config.MapProvider == settings.CustomProvider {
|
||||
proxyRoute.GET("/map/tile/:zoomLevel/:coordinateX/:fileName", bindProxy(api.MapImages.MapTileImageProxyHandler))
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.TianDiTuProvider ||
|
||||
(config.MapProvider == settings.CustomProvider && config.CustomMapTileServerAnnotationLayerUrl != "") {
|
||||
proxyRoute.GET("/map/annotation/:zoomLevel/:coordinateX/:fileName", bindProxy(api.MapImages.MapAnnotationImageProxyHandler))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationInternalProxyMethod {
|
||||
amapApiProxyRoute := router.Group("/_AMapService")
|
||||
amapApiProxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByCookie(config)))
|
||||
{
|
||||
amapApiProxyRoute.GET("/*action", bindProxy(api.AmapApis.AmapApiProxyHandler))
|
||||
}
|
||||
}
|
||||
|
||||
qrCodeRoute := router.Group("/qrcode")
|
||||
qrCodeRoute.Use(bindMiddleware(middlewares.RequestId(config)))
|
||||
{
|
||||
qrCodeCacheStore := persistence.NewInMemoryStore(time.Minute)
|
||||
qrCodeRoute.GET("/mobile_url.png", bindCachedImage(api.QrCodes.MobileUrlQrCodeHandler, qrCodeCacheStore))
|
||||
}
|
||||
|
||||
if config.EnableMCPServer {
|
||||
mcpRoute := router.Group("/mcp")
|
||||
mcpRoute.Use(bindMiddleware(middlewares.RequestId(config)))
|
||||
mcpRoute.Use(bindMiddleware(middlewares.RequestLog))
|
||||
mcpRoute.Use(bindMiddleware(middlewares.MCPServerIpLimit(config)))
|
||||
mcpRoute.Use(bindMiddleware(middlewares.JWTMCPAuthorization(config)))
|
||||
{
|
||||
mcpRoute.POST("", bindJSONRPCApi(map[string]core.JSONRPCApiHandlerFunc{
|
||||
"initialize": api.ModelContextProtocols.InitializeHandler,
|
||||
"resources/list": api.ModelContextProtocols.ListResourcesHandler,
|
||||
"resources/read": api.ModelContextProtocols.ReadResourceHandler,
|
||||
"tools/list": api.ModelContextProtocols.ListToolsHandler,
|
||||
"tools/call": api.ModelContextProtocols.CallToolHandler,
|
||||
"ping": api.ModelContextProtocols.PingHandler,
|
||||
}, map[string]int{
|
||||
"notifications/initialized": http.StatusAccepted,
|
||||
}))
|
||||
mcpRoute.GET("", bindApi(api.Default.MethodNotAllowed))
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableOAuth2Login {
|
||||
oauth2Route := router.Group("/oauth2")
|
||||
oauth2Route.Use(bindMiddleware(middlewares.RequestId(config)))
|
||||
oauth2Route.Use(bindMiddleware(middlewares.RequestLog))
|
||||
{
|
||||
oauth2Route.GET("/login", bindRedirect(api.OAuth2Authentications.LoginHandler))
|
||||
oauth2Route.GET("/callback", bindRedirect(api.OAuth2Authentications.CallbackHandler))
|
||||
}
|
||||
}
|
||||
|
||||
apiRoute := router.Group("/api")
|
||||
|
||||
apiRoute.Use(bindMiddleware(middlewares.RequestId(config)))
|
||||
apiRoute.Use(bindMiddleware(middlewares.RequestLog))
|
||||
{
|
||||
apiRoute.POST("/authorize.json", bindApi(api.Authorizations.AuthorizeHandler))
|
||||
if config.EnableInternalAuth {
|
||||
apiRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.AuthorizeHandler, config))
|
||||
}
|
||||
|
||||
if config.EnableTwoFactor {
|
||||
if config.EnableInternalAuth && config.EnableTwoFactor {
|
||||
twoFactorRoute := apiRoute.Group("/2fa")
|
||||
twoFactorRoute.Use(bindMiddleware(middlewares.JWTTwoFactorAuthorization))
|
||||
twoFactorRoute.Use(bindMiddleware(middlewares.JWTTwoFactorAuthorization(config)))
|
||||
{
|
||||
twoFactorRoute.POST("/authorize.json", bindApi(api.Authorizations.TwoFactorAuthorizeHandler))
|
||||
twoFactorRoute.POST("/recovery.json", bindApi(api.Authorizations.TwoFactorAuthorizeByRecoveryCodeHandler))
|
||||
twoFactorRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeHandler, config))
|
||||
twoFactorRoute.POST("/recovery.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeByRecoveryCodeHandler, config))
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableUserRegister {
|
||||
apiRoute.POST("/register.json", bindApi(api.Users.UserRegisterHandler))
|
||||
}
|
||||
|
||||
if config.EnableDataExport {
|
||||
dataRoute := apiRoute.Group("/data")
|
||||
dataRoute.Use(bindMiddleware(middlewares.HeaderInQueryString))
|
||||
dataRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
|
||||
if config.EnableOAuth2Login {
|
||||
oauth2Route := apiRoute.Group("/oauth2")
|
||||
oauth2Route.Use(bindMiddleware(middlewares.JWTOAuth2CallbackAuthorization(config)))
|
||||
{
|
||||
dataRoute.GET("/export.csv", bindCsv(api.DataManagements.ExportDataHandler))
|
||||
oauth2Route.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.OAuth2CallbackAuthorizeHandler, config))
|
||||
}
|
||||
}
|
||||
|
||||
apiRoute.GET("/logout.json", bindApi(api.Tokens.TokenRevokeCurrentHandler))
|
||||
if config.EnableInternalAuth && config.EnableUserRegister {
|
||||
apiRoute.POST("/register.json", bindApiWithTokenUpdate(api.Users.UserRegisterHandler, config))
|
||||
}
|
||||
|
||||
if config.EnableUserVerifyEmail {
|
||||
apiRoute.POST("/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByUnloginUserHandler))
|
||||
|
||||
emailVerifyRoute := apiRoute.Group("/verify_email")
|
||||
emailVerifyRoute.Use(bindMiddleware(middlewares.JWTEmailVerifyAuthorization(config)))
|
||||
{
|
||||
emailVerifyRoute.POST("/by_token.json", bindApi(api.Users.UserEmailVerifyHandler))
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableInternalAuth && config.EnableUserForgetPassword {
|
||||
apiRoute.POST("/forget_password/request.json", bindApi(api.ForgetPasswords.UserForgetPasswordRequestHandler))
|
||||
|
||||
resetPasswordRoute := apiRoute.Group("/forget_password/reset")
|
||||
resetPasswordRoute.Use(bindMiddleware(middlewares.JWTResetPasswordAuthorization(config)))
|
||||
{
|
||||
resetPasswordRoute.POST("/by_token.json", bindApi(api.ForgetPasswords.UserResetPasswordHandler))
|
||||
}
|
||||
}
|
||||
|
||||
apiRoute.GET("/logout.json", bindApiWithTokenUpdate(api.Tokens.TokenRevokeCurrentHandler, config))
|
||||
|
||||
apiV1Route := apiRoute.Group("/v1")
|
||||
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization))
|
||||
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization(config)))
|
||||
apiV1Route.Use(bindMiddleware(middlewares.APITokenIpLimit(config)))
|
||||
{
|
||||
// Tokens
|
||||
apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler))
|
||||
apiV1Route.POST("/tokens/generate/api.json", bindApi(api.Tokens.TokenGenerateAPIHandler))
|
||||
apiV1Route.POST("/tokens/generate/mcp.json", bindApi(api.Tokens.TokenGenerateMCPHandler))
|
||||
apiV1Route.POST("/tokens/revoke.json", bindApi(api.Tokens.TokenRevokeHandler))
|
||||
apiV1Route.POST("/tokens/revoke_all.json", bindApi(api.Tokens.TokenRevokeAllHandler))
|
||||
apiV1Route.POST("/tokens/refresh.json", bindApi(api.Tokens.TokenRefreshHandler))
|
||||
apiV1Route.POST("/tokens/refresh.json", bindApiWithTokenUpdate(api.Tokens.TokenRefreshHandler, config))
|
||||
|
||||
// Users
|
||||
apiV1Route.GET("/users/profile/get.json", bindApi(api.Users.UserProfileHandler))
|
||||
apiV1Route.POST("/users/profile/update.json", bindApi(api.Users.UserUpdateProfileHandler))
|
||||
apiV1Route.POST("/users/profile/update.json", bindApiWithTokenUpdate(api.Users.UserUpdateProfileHandler, config))
|
||||
|
||||
// Two Factor Authorization
|
||||
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
|
||||
apiV1Route.POST("/users/avatar/update.json", bindApi(api.Users.UserUpdateAvatarHandler))
|
||||
apiV1Route.POST("/users/avatar/remove.json", bindApi(api.Users.UserRemoveAvatarHandler))
|
||||
}
|
||||
|
||||
if config.EnableUserVerifyEmail {
|
||||
apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler))
|
||||
}
|
||||
|
||||
// External Authentications
|
||||
if config.EnableOAuth2Login {
|
||||
apiV1Route.GET("/users/external_auth/list.json", bindApi(api.UserExternalAuths.ExternalAuthListHanlder))
|
||||
apiV1Route.POST("/users/external_auth/unlink.json", bindApi(api.UserExternalAuths.UnlinkExternalAuthHandler))
|
||||
}
|
||||
|
||||
// Application Cloud Settings
|
||||
apiV1Route.GET("/users/settings/cloud/get.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsGetHandler))
|
||||
apiV1Route.POST("/users/settings/cloud/update.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsUpdateHandler))
|
||||
apiV1Route.POST("/users/settings/cloud/disable.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsDisableHandler))
|
||||
|
||||
// Two-Factor Authorization
|
||||
if config.EnableTwoFactor {
|
||||
apiV1Route.GET("/users/2fa/status.json", bindApi(api.TwoFactorAuthorizations.TwoFactorStatusHandler))
|
||||
apiV1Route.POST("/users/2fa/enable/request.json", bindApi(api.TwoFactorAuthorizations.TwoFactorEnableRequestHandler))
|
||||
apiV1Route.POST("/users/2fa/enable/confirm.json", bindApi(api.TwoFactorAuthorizations.TwoFactorEnableConfirmHandler))
|
||||
apiV1Route.POST("/users/2fa/enable/confirm.json", bindApiWithTokenUpdate(api.TwoFactorAuthorizations.TwoFactorEnableConfirmHandler, config))
|
||||
apiV1Route.POST("/users/2fa/disable.json", bindApi(api.TwoFactorAuthorizations.TwoFactorDisableHandler))
|
||||
apiV1Route.POST("/users/2fa/recovery/regenerate.json", bindApi(api.TwoFactorAuthorizations.TwoFactorRecoveryCodeRegenerateHandler))
|
||||
}
|
||||
|
||||
// Data
|
||||
apiV1Route.POST("/data/clear.json", bindApi(api.DataManagements.ClearDataHandler))
|
||||
apiV1Route.GET("/data/statistics.json", bindApi(api.DataManagements.DataStatisticsHandler))
|
||||
apiV1Route.POST("/data/clear/all.json", bindApi(api.DataManagements.ClearAllDataHandler))
|
||||
apiV1Route.POST("/data/clear/transactions.json", bindApi(api.DataManagements.ClearAllTransactionsHandler))
|
||||
apiV1Route.POST("/data/clear/transactions/by_account.json", bindApi(api.DataManagements.ClearAllTransactionsByAccountHandler))
|
||||
|
||||
if config.EnableDataExport {
|
||||
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataToEzbookkeepingCSVHandler))
|
||||
apiV1Route.GET("/data/export.tsv", bindTsv(api.DataManagements.ExportDataToEzbookkeepingTSVHandler))
|
||||
}
|
||||
|
||||
// Accounts
|
||||
apiV1Route.GET("/accounts/list.json", bindApi(api.Accounts.AccountListHandler))
|
||||
@@ -195,19 +378,37 @@ func startWebServer(c *cli.Context) error {
|
||||
apiV1Route.POST("/accounts/hide.json", bindApi(api.Accounts.AccountHideHandler))
|
||||
apiV1Route.POST("/accounts/move.json", bindApi(api.Accounts.AccountMoveHandler))
|
||||
apiV1Route.POST("/accounts/delete.json", bindApi(api.Accounts.AccountDeleteHandler))
|
||||
apiV1Route.POST("/accounts/sub_account/delete.json", bindApi(api.Accounts.SubAccountDeleteHandler))
|
||||
|
||||
// Transactions
|
||||
apiV1Route.GET("/transactions/count.json", bindApi(api.Transactions.TransactionCountHandler))
|
||||
apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler))
|
||||
apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler))
|
||||
apiV1Route.GET("/transactions/list/all.json", bindApi(api.Transactions.TransactionListAllHandler))
|
||||
apiV1Route.GET("/transactions/reconciliation_statements.json", bindApi(api.Transactions.TransactionReconciliationStatementHandler))
|
||||
apiV1Route.GET("/transactions/statistics.json", bindApi(api.Transactions.TransactionStatisticsHandler))
|
||||
apiV1Route.GET("/transactions/statistics/trends.json", bindApi(api.Transactions.TransactionStatisticsTrendsHandler))
|
||||
apiV1Route.GET("/transactions/statistics/asset_trends.json", bindApi(api.Transactions.TransactionStatisticsAssetTrendsHandler))
|
||||
apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler))
|
||||
apiV1Route.GET("/transactions/amounts/by_month.json", bindApi(api.Transactions.TransactionMonthAmountsHandler))
|
||||
apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler))
|
||||
apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler))
|
||||
apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler))
|
||||
apiV1Route.POST("/transactions/move/all.json", bindApi(api.Transactions.TransactionMoveAllBetweenAccountsHandler))
|
||||
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
|
||||
|
||||
if config.EnableDataImport {
|
||||
apiV1Route.POST("/transactions/parse_custom_file.json", bindApi(api.Transactions.TransactionParseImportCustomFileDataHandler))
|
||||
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
|
||||
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
|
||||
apiV1Route.GET("/transactions/import/process.json", bindApi(api.Transactions.TransactionImportProcessHandler))
|
||||
}
|
||||
|
||||
// Transaction Pictures
|
||||
if config.EnableTransactionPictures {
|
||||
apiV1Route.POST("/transaction/pictures/upload.json", bindApi(api.TransactionPictures.TransactionPictureUploadHandler))
|
||||
apiV1Route.POST("/transaction/pictures/remove_unused.json", bindApi(api.TransactionPictures.TransactionPictureRemoveUnusedHandler))
|
||||
}
|
||||
|
||||
// Transaction Categories
|
||||
apiV1Route.GET("/transaction/categories/list.json", bindApi(api.TransactionCategories.CategoryListHandler))
|
||||
apiV1Route.GET("/transaction/categories/get.json", bindApi(api.TransactionCategories.CategoryGetHandler))
|
||||
@@ -218,37 +419,76 @@ func startWebServer(c *cli.Context) error {
|
||||
apiV1Route.POST("/transaction/categories/move.json", bindApi(api.TransactionCategories.CategoryMoveHandler))
|
||||
apiV1Route.POST("/transaction/categories/delete.json", bindApi(api.TransactionCategories.CategoryDeleteHandler))
|
||||
|
||||
// Transaction Tag Groups
|
||||
apiV1Route.GET("/transaction/tags/groups/list.json", bindApi(api.TransactionTagGroups.TagGroupListHandler))
|
||||
apiV1Route.GET("/transaction/tags/groups/get.json", bindApi(api.TransactionTagGroups.TagGroupGetHandler))
|
||||
apiV1Route.POST("/transaction/tags/groups/add.json", bindApi(api.TransactionTagGroups.TagGroupCreateHandler))
|
||||
apiV1Route.POST("/transaction/tags/groups/modify.json", bindApi(api.TransactionTagGroups.TagGroupModifyHandler))
|
||||
apiV1Route.POST("/transaction/tags/groups/move.json", bindApi(api.TransactionTagGroups.TagGroupMoveHandler))
|
||||
apiV1Route.POST("/transaction/tags/groups/delete.json", bindApi(api.TransactionTagGroups.TagGroupDeleteHandler))
|
||||
|
||||
// Transaction Tags
|
||||
apiV1Route.GET("/transaction/tags/list.json", bindApi(api.TransactionTags.TagListHandler))
|
||||
apiV1Route.GET("/transaction/tags/get.json", bindApi(api.TransactionTags.TagGetHandler))
|
||||
apiV1Route.POST("/transaction/tags/add.json", bindApi(api.TransactionTags.TagCreateHandler))
|
||||
apiV1Route.POST("/transaction/tags/add_batch.json", bindApi(api.TransactionTags.TagCreateBatchHandler))
|
||||
apiV1Route.POST("/transaction/tags/modify.json", bindApi(api.TransactionTags.TagModifyHandler))
|
||||
apiV1Route.POST("/transaction/tags/hide.json", bindApi(api.TransactionTags.TagHideHandler))
|
||||
apiV1Route.POST("/transaction/tags/move.json", bindApi(api.TransactionTags.TagMoveHandler))
|
||||
apiV1Route.POST("/transaction/tags/delete.json", bindApi(api.TransactionTags.TagDeleteHandler))
|
||||
|
||||
// Transaction Templates
|
||||
apiV1Route.GET("/transaction/templates/list.json", bindApi(api.TransactionTemplates.TemplateListHandler))
|
||||
apiV1Route.GET("/transaction/templates/get.json", bindApi(api.TransactionTemplates.TemplateGetHandler))
|
||||
apiV1Route.POST("/transaction/templates/add.json", bindApi(api.TransactionTemplates.TemplateCreateHandler))
|
||||
apiV1Route.POST("/transaction/templates/modify.json", bindApi(api.TransactionTemplates.TemplateModifyHandler))
|
||||
apiV1Route.POST("/transaction/templates/hide.json", bindApi(api.TransactionTemplates.TemplateHideHandler))
|
||||
apiV1Route.POST("/transaction/templates/move.json", bindApi(api.TransactionTemplates.TemplateMoveHandler))
|
||||
apiV1Route.POST("/transaction/templates/delete.json", bindApi(api.TransactionTemplates.TemplateDeleteHandler))
|
||||
|
||||
// Insights Explorers
|
||||
apiV1Route.GET("/insights/explorers/list.json", bindApi(api.InsightsExplorers.InsightsExplorerListHandler))
|
||||
apiV1Route.GET("/insights/explorers/get.json", bindApi(api.InsightsExplorers.InsightsExplorerGetHandler))
|
||||
apiV1Route.POST("/insights/explorers/add.json", bindApi(api.InsightsExplorers.InsightsExplorerCreateHandler))
|
||||
apiV1Route.POST("/insights/explorers/modify.json", bindApi(api.InsightsExplorers.InsightsExplorerModifyHandler))
|
||||
apiV1Route.POST("/insights/explorers/hide.json", bindApi(api.InsightsExplorers.InsightsExplorerHideHandler))
|
||||
apiV1Route.POST("/insights/explorers/move.json", bindApi(api.InsightsExplorers.InsightsExplorerMoveHandler))
|
||||
apiV1Route.POST("/insights/explorers/delete.json", bindApi(api.InsightsExplorers.InsightsExplorerDeleteHandler))
|
||||
|
||||
// Large Language Models
|
||||
if config.ReceiptImageRecognitionLLMConfig != nil && config.ReceiptImageRecognitionLLMConfig.LLMProvider != "" {
|
||||
if config.TransactionFromAIImageRecognition {
|
||||
apiV1Route.POST("/llm/transactions/recognize_receipt_image.json", bindApi(api.LargeLanguageModels.RecognizeReceiptImageHandler))
|
||||
}
|
||||
}
|
||||
|
||||
// Exchange Rates
|
||||
apiV1Route.GET("/exchange_rates/latest.json", bindApi(api.ExchangeRates.LatestExchangeRateHandler))
|
||||
apiV1Route.POST("/exchange_rates/user_custom/update.json", bindApi(api.ExchangeRates.UserCustomExchangeRateUpdateHandler))
|
||||
apiV1Route.POST("/exchange_rates/user_custom/delete.json", bindApi(api.ExchangeRates.UserCustomExchangeRateDeleteHandler))
|
||||
|
||||
// System
|
||||
apiV1Route.GET("/systems/version.json", bindApi(api.Systems.VersionHandler))
|
||||
}
|
||||
}
|
||||
|
||||
listenAddr := fmt.Sprintf("%s:%d", config.HttpAddr, config.HttpPort)
|
||||
|
||||
if config.Protocol == settings.SCHEME_SOCKET {
|
||||
log.BootInfof("[server.startWebServer] will run at socks:%s", config.UnixSocketPath)
|
||||
log.BootInfof(c, "[webserver.startWebServer] will run at socks:%s", config.UnixSocketPath)
|
||||
err = router.RunUnix(config.UnixSocketPath)
|
||||
} else if config.Protocol == settings.SCHEME_HTTP {
|
||||
log.BootInfof("[server.startWebServer] will run at http://%s", listenAddr)
|
||||
log.BootInfof(c, "[webserver.startWebServer] will run at http://%s", listenAddr)
|
||||
err = router.Run(listenAddr)
|
||||
} else if config.Protocol == settings.SCHEME_HTTPS {
|
||||
log.BootInfof("[server.startWebServer] will run at https://%s", listenAddr)
|
||||
log.BootInfof(c, "[webserver.startWebServer] will run at https://%s", listenAddr)
|
||||
err = router.RunTLS(listenAddr, config.CertFile, config.CertKeyFile)
|
||||
} else {
|
||||
err = errs.ErrInvalidProtocol
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf("[server.startWebServer] cannot start, because %s", err)
|
||||
log.BootErrorf(c, "[webserver.startWebServer] cannot start, because %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -257,13 +497,26 @@ func startWebServer(c *cli.Context) error {
|
||||
|
||||
func bindMiddleware(fn core.MiddlewareHandlerFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
fn(core.WrapContext(c))
|
||||
fn(core.WrapWebContext(c))
|
||||
}
|
||||
}
|
||||
|
||||
func bindRedirect(fn core.RedirectHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
url, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintJsonErrorResult(c, err)
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapContext(ginCtx)
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
result, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
@@ -274,15 +527,147 @@ func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func bindApiWithTokenUpdate(fn core.ApiHandlerFunc, config *settings.Config) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
result, err := fn(c)
|
||||
|
||||
if err == nil && config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationInternalProxyMethod {
|
||||
middlewares.AmapApiProxyAuthCookie(c, config)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
utils.PrintJsonErrorResult(c, err)
|
||||
} else {
|
||||
utils.PrintJsonSuccessResult(c, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bindJSONRPCApi(fns map[string]core.JSONRPCApiHandlerFunc, skipMethods map[string]int) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
|
||||
var jsonRPCRequest core.JSONRPCRequest
|
||||
reqErr := c.ShouldBindBodyWithJSON(&jsonRPCRequest)
|
||||
|
||||
if reqErr != nil {
|
||||
utils.PrintJSONRPCErrorResult(c, nil, errs.NewIncompleteOrIncorrectSubmissionError(reqErr))
|
||||
return
|
||||
}
|
||||
|
||||
if skipMethods != nil {
|
||||
httpStatusCode, exists := skipMethods[jsonRPCRequest.Method]
|
||||
|
||||
if exists {
|
||||
c.AbortWithStatus(httpStatusCode)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fn, exists := fns[jsonRPCRequest.Method]
|
||||
|
||||
if !exists {
|
||||
utils.PrintJSONRPCErrorResult(c, &jsonRPCRequest, errs.ErrApiNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := fn(c, &jsonRPCRequest)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintJSONRPCErrorResult(c, &jsonRPCRequest, err)
|
||||
} else {
|
||||
utils.PrintJSONRPCSuccessResult(c, &jsonRPCRequest, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bindEventStreamApi(fn core.EventStreamApiHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
utils.SetEventStreamHeader(c)
|
||||
err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.WriteEventStreamJsonErrorResult(c, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
|
||||
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
result, _, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintDataErrorResult(c, "text/javascript", err)
|
||||
} else {
|
||||
utils.PrintDataSuccessResult(c, "text/javascript; charset=utf-8", "", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapContext(ginCtx)
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
result, fileName, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintDataErrorResult(c, "text/text", err)
|
||||
} else {
|
||||
utils.PrintDataSuccessResult(c, "text/csv", fileName, result)
|
||||
utils.PrintDataSuccessResult(c, "text/csv; charset=utf-8", fileName, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bindTsv(fn core.DataHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
result, fileName, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintDataErrorResult(c, "text/text", err)
|
||||
} else {
|
||||
utils.PrintDataSuccessResult(c, "text/tab-separated-values; charset=utf-8", fileName, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bindImage(fn core.ImageHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
result, contentType, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintDataErrorResult(c, "text/text", err)
|
||||
} else {
|
||||
utils.PrintDataSuccessResult(c, contentType, "", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bindCachedImage(fn core.ImageHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
|
||||
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
result, contentType, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintDataErrorResult(c, "text/text", err)
|
||||
} else {
|
||||
utils.PrintDataSuccessResult(c, contentType, "", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func bindProxy(fn core.ProxyHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
proxy, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintDataErrorResult(c, "text/text", err)
|
||||
} else {
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+464
-24
@@ -1,7 +1,4 @@
|
||||
[global]
|
||||
# Application instance name
|
||||
app_name = ezBookkeeping
|
||||
|
||||
# Either "production", "development"
|
||||
mode = production
|
||||
|
||||
@@ -18,25 +15,35 @@ http_port = 8080
|
||||
# The domain name used to access ezBookkeeping
|
||||
domain = localhost
|
||||
|
||||
# The full url used to access ezBookkeeping in browser
|
||||
# The full url used to access ezBookkeeping in browser, supports placeholders: %(protocol)s, %(domain)s, %(http_port)s
|
||||
root_url = %(protocol)s://%(domain)s:%(http_port)s/
|
||||
|
||||
# https certification and its key file
|
||||
cert_file =
|
||||
cert_key_file =
|
||||
|
||||
# Unix socket path, for "socket" only
|
||||
# Unix socket path, for "socket" protocol only
|
||||
unix_socket =
|
||||
|
||||
# Static file root path (relative or absolute)
|
||||
# Static file root path (relative or absolute path)
|
||||
static_root_path = public
|
||||
|
||||
# Enable GZip
|
||||
enable_gzip = false
|
||||
|
||||
# Set to true to log each request and execution times
|
||||
# Set to true to log each request and execution time
|
||||
log_request = true
|
||||
|
||||
# Add X-Request-Id header to response to track user request or error, default is true
|
||||
request_id_header = true
|
||||
|
||||
[mcp]
|
||||
# Set to true to enable MCP (Model Context Protocol) server (via http / https web server) for AI/LLM access
|
||||
enable_mcp = false
|
||||
|
||||
# MCP server allowed remote IPs, a comma-separated list of allowed remote IPs (asterisk * for any addresses, e.g. 192.168.1.* means any IPs in the 192.168.1.x subnet), leave blank to allow all remote IPs
|
||||
mcp_allowed_remote_ips =
|
||||
|
||||
[database]
|
||||
# Either "mysql", "postgres" or "sqlite3"
|
||||
type = sqlite3
|
||||
@@ -47,27 +54,40 @@ name = ezbookkeeping
|
||||
user = root
|
||||
passwd =
|
||||
|
||||
# For "postgres" only, Either "disable", "require" or "verify-full"
|
||||
# For "postgres" database only, Either "disable", "require" or "verify-full"
|
||||
ssl_mode = disable
|
||||
|
||||
# For "sqlite3" only, absolute path of db file
|
||||
# For "sqlite3" database only, database file path (relative or absolute path)
|
||||
db_path = data/ezbookkeeping.db
|
||||
|
||||
# Max idle connection number, default is 2
|
||||
# Max idle connection number (0 - 65535, 0 means no idle connections are retained), default is 2
|
||||
max_idle_conn = 2
|
||||
|
||||
# Max opened connection number, default is 0 (unlimited)
|
||||
# Max opened connection number (0 - 65535), default is 0 (unlimited)
|
||||
max_open_conn = 0
|
||||
|
||||
# Max connection lifetime (seconds), default is 14400 (4 hours)
|
||||
# Max connection lifetime (0 - 4294967295 seconds), default is 14400 (4 hours)
|
||||
conn_max_lifetime = 14400
|
||||
|
||||
# Set to true to log each sql statement and execution times
|
||||
# Set to true to log each sql statement and execution time
|
||||
log_query = false
|
||||
|
||||
# Set to true to automatically update database structure when starting web server
|
||||
auto_update_database = true
|
||||
|
||||
[mail]
|
||||
# Set to true to enable sending mail by SMTP server
|
||||
enable_smtp = false
|
||||
|
||||
# SMTP Server connection configuration
|
||||
smtp_host = 127.0.0.1:25
|
||||
smtp_user =
|
||||
smtp_passwd =
|
||||
smtp_skip_tls_verify = false
|
||||
|
||||
# Mail from address. This can be just an email address, or the "Name" <user@domain.com> format.
|
||||
from_address =
|
||||
|
||||
[log]
|
||||
# Either "console", "file", default is "console"
|
||||
# Use space to separate multiple modes, e.g. "console file"
|
||||
@@ -76,43 +96,463 @@ mode = console file
|
||||
# Either "debug", "info", "warn", "error", default is "info"
|
||||
level = info
|
||||
|
||||
# For "file" only, absolute path of log file
|
||||
# For "file" mode only, log file path (relative or absolute path)
|
||||
log_path = log/ezbookkeeping.log
|
||||
|
||||
# For "file" only, request log file path (relative or absolute path). Leave blank if you want to write request log in default log file
|
||||
request_log_path =
|
||||
|
||||
# For "file" only, query log file path (relative or absolute path). Leave blank if you want to write query log in default log file
|
||||
query_log_path =
|
||||
|
||||
# For "file" only, whether rotate the log files
|
||||
log_file_rotate = false
|
||||
|
||||
# For "file" only, maximum size (1 - 4294967295 bytes) of the log file before it gets rotated
|
||||
log_file_max_size = 104857600
|
||||
|
||||
# For "file" only, maximum number of days to retain old log files. Set to 0 to retain all logs
|
||||
log_file_max_days = 7
|
||||
|
||||
[storage]
|
||||
# Object storage type, supports "local_filesystem", "minio" and "webdav" currently
|
||||
type = local_filesystem
|
||||
|
||||
# For "local_filesystem" storage only, the storage root path (relative or absolute path)
|
||||
local_filesystem_path = storage/
|
||||
|
||||
# For "minio" storage only, the minio connection configuration
|
||||
minio_endpoint = 127.0.0.1:9000
|
||||
minio_location =
|
||||
minio_access_key_id =
|
||||
minio_secret_access_key =
|
||||
|
||||
# For "minio" storage only, whether enable ssl for minio connection
|
||||
minio_use_ssl = false
|
||||
|
||||
# For "minio" storage only, set to true to skip tls verification when connect minio
|
||||
minio_skip_tls_verify = false
|
||||
|
||||
# For "minio" storage only, the minio bucket
|
||||
minio_bucket = ezbookkeeping
|
||||
|
||||
# For "minio" storage only, the root path to store files in minio
|
||||
minio_root_path = /
|
||||
|
||||
# For "webdav" storage only, the webdav url
|
||||
webdav_url =
|
||||
|
||||
# For "webdav" storage only, the webdav username
|
||||
webdav_username =
|
||||
|
||||
# For "webdav" storage only, the webdav password
|
||||
webdav_password =
|
||||
|
||||
# For "webdav" storage only, the webdav root path to store files
|
||||
webdav_root_path = /
|
||||
|
||||
# For "webdav" storage only, requesting webdav url timeout (0 - 4294967295 milliseconds)
|
||||
# Set to 0 to disable timeout for requesting webdav url, default is 10000 (10 seconds)
|
||||
webdav_request_timeout = 10000
|
||||
|
||||
# For "webdav" storage only, proxy for requesting webdav url, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
|
||||
webdav_proxy = system
|
||||
|
||||
# For "webdav" storage only, set to true to skip tls verification when connect webdav
|
||||
webdav_skip_tls_verify = false
|
||||
|
||||
[llm]
|
||||
# Set to true to enable creating transactions from AI image recognition results, requires "llm_provider" and its related model id to be configured properly in "llm_image_recognition" section
|
||||
transaction_from_ai_image_recognition = false
|
||||
|
||||
# Maximum allowed AI recognition picture file size (1 - 4294967295 bytes)
|
||||
max_ai_recognition_picture_size = 10485760
|
||||
|
||||
[llm_image_recognition]
|
||||
# Large Language Model (LLM) provider for receipt image recognition, supports the following types: "openai", "openai_compatible", "anthropic", "anthropic_compatible", "openrouter", "ollama", "lm_studio", "google_ai"
|
||||
llm_provider =
|
||||
|
||||
# For "openai" llm provider only, OpenAI API secret key, please visit https://platform.openai.com/api-keys for more information
|
||||
openai_api_key =
|
||||
|
||||
# For "openai" llm provider only, receipt image recognition model for creating transactions from images
|
||||
openai_model_id =
|
||||
|
||||
# For "openai_compatible" llm provider only, OpenAI compatible API base url, e.g. "https://api.openai.com/v1/"
|
||||
openai_compatible_base_url =
|
||||
|
||||
# For "openai_compatible" llm provider only, OpenAI compatible API secret key
|
||||
openai_compatible_api_key =
|
||||
|
||||
# For "openai_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
||||
openai_compatible_model_id =
|
||||
|
||||
# For "anthropic" llm provider only, Anthropic API key, please visit https://platform.claude.com/settings/keys for more information
|
||||
anthropic_api_key =
|
||||
|
||||
# For "anthropic" llm provider only, receipt image recognition model for creating transactions from images
|
||||
anthropic_model_id =
|
||||
|
||||
# For "anthropic" llm provider only, maximum allowed number of generated tokens for creating transactions from images, default is 1024
|
||||
anthropic_max_tokens = 1024
|
||||
|
||||
# For "anthropic_compatible" llm provider only, Anthropic compatible API base url, e.g. "https://api.anthropic.com/v1/"
|
||||
anthropic_compatible_base_url =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, Anthropic compatible API version, e.g. "2023-06-01". If the LLM service does not require API versioning, leave it blank
|
||||
anthropic_compatible_api_version =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, Anthropic compatible API secret key
|
||||
anthropic_compatible_api_key =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
||||
anthropic_compatible_model_id =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, maximum allowed number of generated tokens for creating transactions from images, default is 1024
|
||||
anthropic_compatible_max_tokens = 1024
|
||||
|
||||
# For "openrouter" llm provider only, OpenRouter API key, please visit https://openrouter.ai/settings/keys for more information
|
||||
openrouter_api_key =
|
||||
|
||||
# For "openrouter" llm provider only, receipt image recognition model for creating transactions from images
|
||||
openrouter_model_id =
|
||||
|
||||
# For "ollama" llm provider only, Ollama server url, e.g. "http://127.0.0.1:11434/"
|
||||
ollama_server_url =
|
||||
|
||||
# For "ollama" llm provider only, receipt image recognition model for creating transactions from images
|
||||
ollama_model_id =
|
||||
|
||||
# For "lm_studio" llm provider only, LM Studio server url, e.g. "http://127.0.0.1:1234/"
|
||||
lm_studio_server_url =
|
||||
|
||||
# For "lm_studio" llm provider only, LM Studio API token, if "require authentication" is not enabled in LM Studio, leave it blank
|
||||
lm_studio_token =
|
||||
|
||||
# For "lm_studio" llm provider only, receipt image recognition model for creating transactions from images
|
||||
lm_studio_model_id =
|
||||
|
||||
# For "google_ai" llm provider only, Google AI Studio API key, please visit https://aistudio.google.com/apikey for more information
|
||||
google_ai_api_key =
|
||||
|
||||
# For "google_ai" llm provider only, receipt image recognition model for creating transactions from images
|
||||
google_ai_model_id =
|
||||
|
||||
# Requesting large language model api timeout (0 - 4294967295 milliseconds)
|
||||
# Set to 0 to disable timeout for requesting large language model api, default is 60000 (60 seconds)
|
||||
request_timeout = 60000
|
||||
|
||||
# Proxy for ezbookkeeping server requesting large language model api, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
|
||||
proxy = system
|
||||
|
||||
# Set to true to skip tls verification when request large language model api
|
||||
skip_tls_verify = false
|
||||
|
||||
[uuid]
|
||||
# Uuid generator type, supports "internal" currently
|
||||
generator_type = internal
|
||||
|
||||
# For "internal" only, each server must have unique id
|
||||
# For "internal" uuid generator only, each server must have unique id (0 - 255)
|
||||
server_id = 0
|
||||
|
||||
[duplicate_checker]
|
||||
# Duplicate checker type, supports "in_memory" currently
|
||||
checker_type = in_memory
|
||||
|
||||
# For "in_memory" duplicate checker only, cleanup expired data interval seconds (1 - 4294967295), default is 60 (1 minutes)
|
||||
cleanup_interval = 60
|
||||
|
||||
# The minimum interval seconds (0 - 4294967295) between duplicate submissions on the same page (exiting and re-entering the edit page / edit dialog is considered as a new session)
|
||||
# Set to 0 to disable duplicate checker for new data submissions, default is 300 (5 minutes)
|
||||
duplicate_submissions_interval = 300
|
||||
|
||||
[cron]
|
||||
# Set to true to clean up expired tokens periodically
|
||||
enable_remove_expired_tokens = true
|
||||
|
||||
# Set to true to create scheduled transactions based on the user's templates
|
||||
enable_create_scheduled_transaction = true
|
||||
|
||||
[security]
|
||||
# Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping
|
||||
secret_key =
|
||||
|
||||
# Set to true to enable two factor authorization
|
||||
enable_two_factor = true
|
||||
|
||||
# Token expired seconds, default is 2592000 (30 days)
|
||||
# Token expired seconds (60 - 4294967295), default is 2592000 (30 days)
|
||||
token_expired_time = 2592000
|
||||
|
||||
# Temporary token expired seconds, default is 300 (5 minutes)
|
||||
# Token minimum refresh interval (0 - 4294967295), the value should be less than token expired time
|
||||
# Set to 0 to refresh the token every time when refreshing the front end, default is 86400 (1 day)
|
||||
token_min_refresh_interval = 86400
|
||||
|
||||
# Temporary token expired seconds (60 - 4294967295), default is 300 (5 minutes)
|
||||
temporary_token_expired_time = 300
|
||||
|
||||
# Add X-Request-Id header to response to track user request or error, default is true
|
||||
request_id_header = true
|
||||
# Email verify token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
|
||||
email_verify_token_expired_time = 3600
|
||||
|
||||
# Password reset token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
|
||||
password_reset_token_expired_time = 3600
|
||||
|
||||
# Set to true to enable API token generation
|
||||
enable_api_token = false
|
||||
|
||||
# Allowed remote IPs for using the API token, a comma-separated list of allowed remote IPs (asterisk * for any addresses, e.g. 192.168.1.* means any IPs in the 192.168.1.x subnet), leave blank to allow all remote IPs
|
||||
api_token_allowed_remote_ips =
|
||||
|
||||
# Maximum count of password / token check failures (0 - 4294967295) per IP per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
||||
max_failures_per_ip_per_minute = 5
|
||||
|
||||
# Maximum count of password / token check failures (0 - 4294967295) per user per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
||||
max_failures_per_user_per_minute = 5
|
||||
|
||||
[auth]
|
||||
# Set to true to enable internal authentication
|
||||
enable_internal_auth = true
|
||||
|
||||
# Set to true to enable OAuth 2.0 authentication
|
||||
enable_oauth2_auth = false
|
||||
|
||||
# For "internal" authentication only, set to true to enable two-factor authorization
|
||||
enable_two_factor = true
|
||||
|
||||
# For "internal" authentication only, set to true to allow users to reset password
|
||||
enable_forget_password = true
|
||||
|
||||
# For "internal" authentication only, set to true to require email must be verified when use forget password
|
||||
forget_password_require_email_verify = false
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 provider, supports "oidc", "nextcloud", "gitea" and "github" currently
|
||||
oauth2_provider =
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 client ID
|
||||
oauth2_client_id =
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 client secret
|
||||
oauth2_client_secret =
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 provider user identifier claim name, supports "email" and "username", default is "email"
|
||||
oauth2_user_identifier = email
|
||||
|
||||
# For "oauth2" authentication only, set to true to use PKCE
|
||||
oauth2_use_pkce = false
|
||||
|
||||
# For "oauth2" authentication only, if the user returned by OAuth 2.0 is not registered, automatically create a new user (requires "enable_register" to be set to true)
|
||||
oauth2_auto_register = true
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 state expired seconds (60 - 4294967295), default is 300 (5 minutes)
|
||||
oauth2_state_expired_time = 300
|
||||
|
||||
# For "oauth2" authentication only, requesting OAuth 2.0 api timeout (0 - 4294967295 milliseconds)
|
||||
# Set to 0 to disable timeout for requesting OAuth 2.0 api, default is 10000 (10 seconds)
|
||||
oauth2_request_timeout = 10000
|
||||
|
||||
# For "oauth2" authentication only, proxy for ezbookkeeping server requesting OAuth 2.0 api, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
|
||||
oauth2_proxy = system
|
||||
|
||||
# For "oauth2" authentication only, set to true to skip tls verification when request OAuth 2.0 api
|
||||
oauth2_skip_tls_verify = false
|
||||
|
||||
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, OIDC provider issuer url. Make sure the ".well-known" directory is available under this path. For example, if it's set to "https://auth.example.com", the discovery URL should be "https://auth.example.com/.well-known/openid-configuration".
|
||||
oidc_provider_base_url =
|
||||
|
||||
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, set to true to check whether the issuer url in the discovery response matches the above "oidc_provider_base_url"
|
||||
oidc_provider_check_issuer_url = true
|
||||
|
||||
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, set to true to replace the text "Connect ID" in the "Log in with Connect ID" button with the below custom provider name
|
||||
enable_oidc_display_name = false
|
||||
|
||||
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, the custom provider name to replace the text in the "Log in with Connect ID" button, it supports multi-language configuration
|
||||
# Add an underscore and a language tag after the setting key to configure the display name in that language
|
||||
# For example, oidc_custom_display_name_zh_hans means the display name in Chinese (Simplified)
|
||||
oidc_custom_display_name =
|
||||
|
||||
# For "oauth2" authentication and "nextcloud" OAuth 2.0 provider only, Nextcloud base url, e.g. "https://cloud.example.org/"
|
||||
nextcloud_base_url =
|
||||
|
||||
# For "oauth2" authentication and "gitea" OAuth 2.0 provider only, Gitea base url, e.g. "https://git.example.com/"
|
||||
gitea_base_url =
|
||||
|
||||
[user]
|
||||
# Set to true to allow users to register account by themselves
|
||||
enable_register = true
|
||||
|
||||
# Set to true to allow users to verify email address
|
||||
enable_email_verify = false
|
||||
|
||||
# Set to true to require email must be verified when login
|
||||
enable_force_email_verify = false
|
||||
|
||||
# Set to true to allow users to upload transaction pictures
|
||||
enable_transaction_picture = true
|
||||
|
||||
# Maximum allowed transaction picture file size (1 - 4294967295 bytes)
|
||||
max_transaction_picture_size = 10485760
|
||||
|
||||
# Set to true to allow users to create scheduled transaction
|
||||
enable_scheduled_transaction = true
|
||||
|
||||
# User avatar provider, supports the following types:
|
||||
# "internal": Use the internal object storage to store user avatar (refer to "storage" settings), supports updating avatar by user self
|
||||
# "gravatar": https://gravatar.com
|
||||
# Leave blank if you want to disable user avatar
|
||||
avatar_provider = internal
|
||||
|
||||
# For "internal" avatar provider only, maximum allowed user avatar file size (1 - 4294967295 bytes)
|
||||
max_user_avatar_size = 1048576
|
||||
|
||||
# The default feature restrictions after user registration (feature types separated by commas), leave blank for no restrictions
|
||||
# Supports the following feature types:
|
||||
# 1: Update Password
|
||||
# 2: Update Email
|
||||
# 3: Update Profile Basic Info
|
||||
# 4: Update Avatar
|
||||
# 5: Logout Other Session
|
||||
# 6: Enable Two-Factor Authentication
|
||||
# 7: Disable Enable Two-Factor Authentication
|
||||
# 8: Forget Password
|
||||
# 9: Import Transactions
|
||||
# 10: Export Transactions
|
||||
# 11: Clear All Data
|
||||
# 12: Sync Application Settings
|
||||
# 13: MCP (Model Context Protocol) Access
|
||||
# 14: Create Transactions from AI Image Recognition
|
||||
# 15: OAuth 2.0 Login
|
||||
# 16: Unlink Third-party Login
|
||||
# 17: Generate API Token
|
||||
default_feature_restrictions =
|
||||
|
||||
[data]
|
||||
# Set to true to allow users to export their data
|
||||
enable_export = true
|
||||
|
||||
# Set to true to allow users to import their data
|
||||
enable_import = true
|
||||
|
||||
# Maximum allowed import file size (1 - 4294967295 bytes)
|
||||
max_import_file_size = 10485760
|
||||
|
||||
[tip]
|
||||
# Set to true to display custom tips in login page
|
||||
enable_tips_in_login_page = false
|
||||
|
||||
# The custom tips displayed in login page, it supports multi-language configuration
|
||||
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
|
||||
# For example, login_page_tips_content_zh_hans means the notification content in Chinese (Simplified)
|
||||
login_page_tips_content =
|
||||
|
||||
[notification]
|
||||
# Set to true to display custom notification in home page every time users register
|
||||
enable_notification_after_register = false
|
||||
|
||||
# The notification content displayed each time users register, it supports multi-language configuration
|
||||
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
|
||||
# For example, after_login_notification_content_zh_hans means the notification content in Chinese (Simplified)
|
||||
after_register_notification_content =
|
||||
|
||||
# Set to true to display custom notification in home page every time users login
|
||||
enable_notification_after_login = false
|
||||
|
||||
# The notification content displayed each time users log in, it supports multi-language configuration
|
||||
after_login_notification_content =
|
||||
|
||||
# Set to true to display custom notification in home page every time users open the app
|
||||
enable_notification_after_open = false
|
||||
|
||||
# The notification content displayed each time users open the app, it supports multi-language configuration
|
||||
after_open_notification_content =
|
||||
|
||||
[map]
|
||||
# Map provider, supports the following types:
|
||||
# "openstreetmap": https://www.openstreetmap.org
|
||||
# "openstreetmap_humanitarian": http://map.hotosm.org
|
||||
# "opentopomap": https://opentopomap.org
|
||||
# "opnvkarte": https://publictransportmap.org
|
||||
# "cyclosm": https://www.cyclosm.org
|
||||
# "cartodb": https://carto.com/basemaps
|
||||
# "tomtom": https://www.tomtom.com
|
||||
# "tianditu": https://www.tianditu.gov.cn
|
||||
# "googlemap": https://map.google.com
|
||||
# "baidumap": https://map.baidu.com
|
||||
# "amap": https://amap.com
|
||||
# "custom": custom map tile server url
|
||||
# Leave blank if you want to disable map
|
||||
map_provider = openstreetmap
|
||||
|
||||
# Set to true to use the ezbookkeeping server to forward map data requests, for "openstreetmap", "openstreetmap_humanitarian", "opentopomap", "opnvkarte", "cyclosm", "cartodb", "tomtom", "tianditu" or "custom"
|
||||
map_data_fetch_proxy = false
|
||||
|
||||
# Proxy for ezbookkeeping server requesting original map data when map_data_fetch_proxy is set to true, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
|
||||
proxy = system
|
||||
|
||||
# For "tomtom" map provider only, TomTom map API key, please visit https://developer.tomtom.com/how-to-get-tomtom-api-key for more information
|
||||
tomtom_map_api_key =
|
||||
|
||||
# For "tianditu" map provider only, TianDiTu map application key, please visit https://console.tianditu.gov.cn/api/register for more information
|
||||
tianditu_map_app_key =
|
||||
|
||||
# For "googlemap" map provider only, Google map JavaScript API key, please visit https://developers.google.com/maps/get-started for more information
|
||||
google_map_api_key =
|
||||
|
||||
# For "baidumap" map provider only, Baidu map JavaScript API application key, please visit https://lbsyun.baidu.com/index.php?title=jspopular3.0/guide/getkey for more information
|
||||
baidu_map_ak =
|
||||
|
||||
# For "amap" map provider only, Amap JavaScript API application key, please visit https://lbs.amap.com/api/javascript-api/guide/abc/prepare for more information
|
||||
amap_application_key =
|
||||
|
||||
# For "amap" map provider only, Amap JavaScript API security verification method, supports the following methods:
|
||||
# "internal_proxy": use the internal proxy to request amap api with amap application secret (default)
|
||||
# "external_proxy": use an external proxy to request amap api (amap application secret should be set by external proxy)
|
||||
# "plain_text": append amap application secret to frontend request directly (insecurity for public network)
|
||||
# Please visit https://developer.amap.com/api/jsapi-v2/guide/abc/load for more information
|
||||
amap_security_verification_method = internal_proxy
|
||||
|
||||
# For "amap" map provider only, Amap JavaScript API application secret, this setting must be provided when "amap_security_verification_method" is set to "internal_proxy" or "plain_text", please visit https://lbs.amap.com/api/javascript-api/guide/abc/prepare for more information
|
||||
amap_application_secret =
|
||||
|
||||
# For "amap" map provider only, Amap JavaScript API external proxy url, this setting must be provided when "amap_security_verification_method" is set to "external_proxy"
|
||||
amap_api_external_proxy_url =
|
||||
|
||||
# For "custom" map provider only, the tile layer url of custom map tile server, supports {x}, {y} (coordinates) and {z} (zoom level) placeholders, like "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
custom_map_tile_server_url =
|
||||
|
||||
# For "custom" map provider only, the optional annotation layer url of custom map tile server, supports {x}, {y} (coordinates) and {z} (zoom level) placeholders
|
||||
custom_map_tile_server_annotation_url =
|
||||
|
||||
# For "custom" map provider only, the min zoom level (0 - 255) for custom map tile server, default is 1
|
||||
custom_map_tile_server_min_zoom_level = 1
|
||||
|
||||
# For "custom" map provider only, the max zoom level (0 - 255) for custom map tile server, default is 18
|
||||
custom_map_tile_server_max_zoom_level = 18
|
||||
|
||||
# For "custom" map provider only, the default zoom level (0 - 255) for custom map tile server, default is 14
|
||||
custom_map_tile_server_default_zoom_level = 14
|
||||
|
||||
[exchange_rates]
|
||||
# Exchange rates data source, supports "euro_central_bank", "bank_of_canada", "reserve_bank_of_australia", "czech_national_bank", "national_bank_of_poland" currently
|
||||
# Exchange rates data source, supports the following types:
|
||||
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
|
||||
# "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
|
||||
# "danmarks_national_bank": https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates
|
||||
# "euro_central_bank": https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html
|
||||
# "national_bank_of_georgia": https://nbg.gov.ge/en/monetary-policy/currency
|
||||
# "central_bank_of_hungary": https://www.mnb.hu/en/arfolyamok
|
||||
# "bank_of_israel": https://www.boi.org.il/en/economic-roles/financial-markets/exchange-rates/
|
||||
# "central_bank_of_myanmar": https://forex.cbm.gov.mm/index.php/fxrate
|
||||
# "norges_bank": https://www.norges-bank.no/en/topics/Statistics/exchange_rates/
|
||||
# "national_bank_of_poland": https://nbp.pl/en/statistic-and-financial-reporting/rates/
|
||||
# "national_bank_of_romania": https://www.bnr.ro/Exchange-rates-1224.aspx
|
||||
# "bank_of_russia": https://www.cbr.ru/eng/currency_base/daily/
|
||||
# "swiss_national_bank": https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates
|
||||
# "national_bank_of_ukraine": https://bank.gov.ua/ua/markets/exchangerates
|
||||
# "central_bank_of_uzbekistan": https://cbu.uz/en/arkhiv-kursov-valyut/
|
||||
# "user_custom": users set their own exchange rates data in the UI
|
||||
data_source = euro_central_bank
|
||||
|
||||
# Requesting exchange rates data timeout (milliseconds), default is 10000 (10 seconds)
|
||||
# Requesting exchange rates data timeout (0 - 4294967295 milliseconds)
|
||||
# Set to 0 to disable timeout for requesting exchange rates data, default is 10000 (10 seconds)
|
||||
request_timeout = 10000
|
||||
|
||||
# Proxy for ezbookkeeping server requesting exchange rates data, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
|
||||
proxy = system
|
||||
|
||||
# Set to true to skip tls verification when request exchange rates data
|
||||
skip_tls_verify = false
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"code": [
|
||||
"jiangshengwu",
|
||||
"vigdail",
|
||||
"f97",
|
||||
"Miguelonlonlon",
|
||||
"seb26",
|
||||
"nktlitvinenko",
|
||||
"lvdou-bing",
|
||||
"dshemin",
|
||||
"lucdsouza",
|
||||
"OuIChien",
|
||||
"RasterCrow"
|
||||
],
|
||||
"translators": {
|
||||
"de": [
|
||||
"chrgm",
|
||||
"1270o1"
|
||||
],
|
||||
"en": [],
|
||||
"es": [
|
||||
"Miguelonlonlon",
|
||||
"abrugues",
|
||||
"AndresTeller",
|
||||
"diegofercri"
|
||||
],
|
||||
"fr": [
|
||||
"brieucdlf"
|
||||
],
|
||||
"it": [
|
||||
"waron97"
|
||||
],
|
||||
"ja": [
|
||||
"tkymmm"
|
||||
],
|
||||
"kn": [
|
||||
"Darshanbm05"
|
||||
],
|
||||
"ko": [
|
||||
"overworks"
|
||||
],
|
||||
"nl": [
|
||||
"automagics"
|
||||
],
|
||||
"pt-BR": [
|
||||
"thecodergus",
|
||||
"balaios"
|
||||
],
|
||||
"ru": [
|
||||
"artegoser",
|
||||
"dshemin"
|
||||
],
|
||||
"sl": [
|
||||
"thehijacker"
|
||||
],
|
||||
"ta": [
|
||||
"hhharsha36"
|
||||
],
|
||||
"th": [
|
||||
"natthavat28"
|
||||
],
|
||||
"tr": [
|
||||
"aydnykn"
|
||||
],
|
||||
"uk": [
|
||||
"nktlitvinenko"
|
||||
],
|
||||
"vi": [
|
||||
"f97"
|
||||
],
|
||||
"zh-Hans": [],
|
||||
"zh-Hant": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
variable "DEFAULT_TAG" {
|
||||
default = "ezbookkeeping:local"
|
||||
}
|
||||
|
||||
// Special target: https://github.com/docker/metadata-action#bake-definition
|
||||
target "docker-metadata-action" {
|
||||
tags = ["${DEFAULT_TAG}"]
|
||||
}
|
||||
|
||||
// Default target if none specified
|
||||
group "default" {
|
||||
targets = ["image-local"]
|
||||
}
|
||||
|
||||
target "image" {
|
||||
inherits = ["docker-metadata-action"]
|
||||
context = "./"
|
||||
dockerfile = "Dockerfile"
|
||||
}
|
||||
|
||||
target "image-local" {
|
||||
inherits = ["image"]
|
||||
output = ["type=docker"]
|
||||
}
|
||||
|
||||
target "image-all" {
|
||||
inherits = ["image"]
|
||||
platforms = [
|
||||
"linux/amd64",
|
||||
"linux/arm64",
|
||||
"linux/arm/v7",
|
||||
"linux/arm/v6"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
import vueTsEslintConfig from '@vue/eslint-config-typescript';
|
||||
|
||||
export default [
|
||||
...pluginVue.configs['flat/essential'],
|
||||
...vueTsEslintConfig(),
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'**/*.{js,jsx,cjs,mjs}'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'**/*.{vue,ts,tsx,mts,js,jsx,cjs,mjs}'
|
||||
],
|
||||
rules: {
|
||||
'vue/valid-v-slot': ['error', {
|
||||
allowModifiers: true
|
||||
}]
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=ezBookkeeping, a lightweight personal bookkeeping app hosted by yourself.
|
||||
Description=ezBookkeeping, a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features.
|
||||
After=syslog.target
|
||||
After=network.target
|
||||
After=mariadb.service mysqld.service postgresql.service
|
||||
|
||||
+17
-4
@@ -1,14 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/cmd"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -24,24 +26,35 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
core.Version = Version
|
||||
core.CommitHash = CommitHash
|
||||
core.BuildTime = BuildUnixTime
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: "ezBookkeeping",
|
||||
Usage: "A lightweight personal bookkeeping app hosted by yourself.",
|
||||
Usage: "A lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features.",
|
||||
Version: GetFullVersion(),
|
||||
Commands: []*cli.Command{
|
||||
cmd.WebServer,
|
||||
cmd.Database,
|
||||
cmd.UserData,
|
||||
cmd.CronJobs,
|
||||
cmd.SecurityUtils,
|
||||
cmd.Utilities,
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "conf-path",
|
||||
Usage: "Custom config `FILE` path",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-boot-log",
|
||||
Usage: "Disable boot log",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := app.Run(os.Args)
|
||||
err := cmd.Run(context.Background(), os.Args)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to run ezBookkeeping with %s: %v", os.Args, err)
|
||||
|
||||
@@ -1,21 +1,111 @@
|
||||
module github.com/mayswind/ezbookkeeping
|
||||
|
||||
go 1.14
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/gin-contrib/gzip v0.0.3
|
||||
github.com/gin-gonic/gin v1.6.3
|
||||
github.com/go-playground/validator/v10 v10.4.1
|
||||
github.com/go-sql-driver/mysql v1.5.0
|
||||
github.com/lib/pq v1.8.0
|
||||
github.com/mattn/go-sqlite3 v1.14.4
|
||||
github.com/pquerna/otp v1.3.0
|
||||
github.com/sirupsen/logrus v1.7.0
|
||||
github.com/smartystreets/goconvey v1.6.4 // indirect
|
||||
github.com/stretchr/testify v1.6.1
|
||||
github.com/urfave/cli/v2 v2.3.0
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897
|
||||
gopkg.in/ini.v1 v1.62.0
|
||||
xorm.io/xorm v1.0.5
|
||||
github.com/boombuler/barcode v1.1.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
||||
github.com/gin-contrib/cache v1.4.3
|
||||
github.com/gin-contrib/gzip v1.2.6
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-co-op/gocron/v2 v2.19.1
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/invopop/jsonschema v0.13.0
|
||||
github.com/lib/pq v1.12.1
|
||||
github.com/mattn/go-sqlite3 v1.14.38
|
||||
github.com/minio/minio-go/v7 v7.0.99
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v3 v3.8.0
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/text v0.35.0
|
||||
gopkg.in/ini.v1 v1.67.1
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
xorm.io/builder v0.3.13
|
||||
xorm.io/xorm v1.3.11
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
|
||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gomodule/redigo v1.9.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
github.com/tealeg/xlsx v1.0.5 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,141 +1,242 @@
|
||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20200428022330-06a60b6afbbc/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a h1:c5k29baTzznteWs+9dxrtqpNxgtQ3V5NbU8d6laLK9Q=
|
||||
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a/go.mod h1:xbpgo9r3xURoPa/l3sLKLGcnWlkz9UkfFsQ7lW0S6h8=
|
||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WRbLFVwwcBQ0rr5p+gzkKb6ol8c=
|
||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw=
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gin-contrib/gzip v0.0.3 h1:etUaeesHhEORpZMp18zoOhepboiWnFtXrBZxszWUn4k=
|
||||
github.com/gin-contrib/gzip v0.0.3/go.mod h1:YxxswVZIqOvcHEQpsSn+QF5guQtO1dCfy0shBPy4jFc=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
|
||||
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
|
||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
|
||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
||||
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
|
||||
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
|
||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cache v1.4.3 h1:6rmIlmTf2Vyfd/ue53+BLdTxC7hrQ7FqRgfjz31nEEE=
|
||||
github.com/gin-contrib/cache v1.4.3/go.mod h1:Znf5Qa8HTQ+QHku6ODf72WOPnJ2fHUd2nXD6mSi+6+g=
|
||||
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
|
||||
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI=
|
||||
github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s=
|
||||
github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/lib/pq v1.7.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg=
|
||||
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
|
||||
github.com/mattn/go-sqlite3 v1.14.4 h1:4rQjbDxdu9fSgI/r3KN72G3c2goxknAqHHgPWWs8UlI=
|
||||
github.com/mattn/go-sqlite3 v1.14.4/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.12.1 h1:x1nbl/338GLqeDJ/FAiILallhAsqubLzEZu/pXtHUow=
|
||||
github.com/lib/pq v1.12.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
|
||||
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE=
|
||||
github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs=
|
||||
github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E=
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
|
||||
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
|
||||
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
|
||||
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
|
||||
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
xorm.io/builder v0.3.7 h1:2pETdKRK+2QG4mLX4oODHEhn5Z8j1m8sXa7jfu+/SZI=
|
||||
xorm.io/builder v0.3.7/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
||||
xorm.io/xorm v1.0.5 h1:LRr5PfOUb4ODPR63YwbowkNDwcolT2LnkwP/TUaMaB0=
|
||||
xorm.io/xorm v1.0.5/go.mod h1:uF9EtbhODq5kNWxMbnBEj8hRRZnlcNSz2t2N7HW/+A4=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
||||
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
||||
xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
|
||||
xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type JestConfigWithTsJest, createDefaultEsmPreset } from 'ts-jest';
|
||||
|
||||
const presetConfig = createDefaultEsmPreset({
|
||||
tsconfig: '<rootDir>/tsconfig.jest.json'
|
||||
});
|
||||
|
||||
const config: JestConfigWithTsJest = {
|
||||
...presetConfig,
|
||||
clearMocks: true,
|
||||
collectCoverage: false,
|
||||
moduleNameMapper: {
|
||||
"^@/(.*)$": "<rootDir>/src/$1"
|
||||
},
|
||||
testEnvironment: "node",
|
||||
testMatch: [
|
||||
"**/__tests__/**/*.[jt]s?(x)",
|
||||
"!**/__tests__/*_gen.[jt]s?(x)"
|
||||
]
|
||||
};
|
||||
|
||||
export default config;
|
||||
Generated
+15399
-13401
File diff suppressed because it is too large
Load Diff
+69
-49
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ezbookkeeping",
|
||||
"version": "0.1.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -12,60 +12,80 @@
|
||||
"url": "https://github.com/mayswind/ezbookkeeping/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
"serve": "cross-env NODE_ENV=development vite",
|
||||
"build": "cross-env NODE_ENV=production vite build",
|
||||
"serve:dist": "vite preview",
|
||||
"lint": "vue-tsc --noEmit && eslint . --fix",
|
||||
"test": "cross-env TS_NODE_PROJECT=\"./tsconfig.jest.json\" jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.1",
|
||||
"cbor-js": "^0.1.0",
|
||||
"core-js": "^3.6.5",
|
||||
"crypto-js": "^4.0.0",
|
||||
"framework7": "^5.7.14",
|
||||
"framework7-icons": "^3.0.1",
|
||||
"framework7-vue": "^5.7.14",
|
||||
"js-cookie": "^2.2.1",
|
||||
"line-awesome": "^1.3.0",
|
||||
"moment": "^2.29.1",
|
||||
"moment-timezone": "^0.5.33",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"ua-parser-js": "^0.7.28",
|
||||
"vue": "^2.6.12",
|
||||
"vue-clipboard2": "^0.3.1",
|
||||
"vue-i18n": "^8.24.3",
|
||||
"vue-pincode-input": "^0.4.0",
|
||||
"vuex": "^3.6.2"
|
||||
"@mdi/js": "7.4.47",
|
||||
"@vuepic/vue-datepicker": "12.1.0",
|
||||
"axios": "1.14.0",
|
||||
"cbor-js": "0.1.0",
|
||||
"chardet": "2.1.1",
|
||||
"clipboard": "2.0.11",
|
||||
"crypto-js": "4.2.0",
|
||||
"dom7": "4.0.6",
|
||||
"echarts": "6.0.0",
|
||||
"framework7": "9.0.3",
|
||||
"framework7-icons": "5.0.5",
|
||||
"framework7-vue": "9.0.3",
|
||||
"jalaali-js": "1.2.8",
|
||||
"leaflet": "1.9.4",
|
||||
"line-awesome": "1.3.0",
|
||||
"moment": "2.30.1",
|
||||
"moment-timezone": "0.6.1",
|
||||
"pinia": "3.0.4",
|
||||
"register-service-worker": "1.7.2",
|
||||
"skeleton-elements": "4.0.1",
|
||||
"swiper": "12.1.3",
|
||||
"ua-parser-js": "1.0.39",
|
||||
"vue": "3.5.31",
|
||||
"vue-echarts": "8.0.1",
|
||||
"vue-i18n": "11.3.0",
|
||||
"vue-router": "5.0.4",
|
||||
"vue3-perfect-scrollbar": "2.0.0",
|
||||
"vuedraggable": "4.1.0",
|
||||
"vuetify": "3.12.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^4.5.11",
|
||||
"@vue/cli-plugin-eslint": "^4.5.11",
|
||||
"@vue/cli-plugin-pwa": "^4.5.11",
|
||||
"@vue/cli-service": "^4.5.11",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-plugin-component": "^1.1.1",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"git-revision-webpack-plugin": "^3.0.6",
|
||||
"moment-locales-webpack-plugin": "^1.2.0",
|
||||
"vue-template-compiler": "^2.6.12"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/essential",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"rules": {}
|
||||
"@jest/globals": "30.3.0",
|
||||
"@tsconfig/node24": "24.0.4",
|
||||
"@types/cbor-js": "0.1.1",
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/git-rev-sync": "2.0.2",
|
||||
"@types/jalaali-js": "1.2.0",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/node": "25.5.0",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@vitejs/plugin-vue": "6.0.5",
|
||||
"@vue/eslint-config-typescript": "14.7.0",
|
||||
"@vue/tsconfig": "0.9.1",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "10.1.0",
|
||||
"eslint-plugin-vue": "10.8.0",
|
||||
"git-rev-sync": "3.0.2",
|
||||
"jest": "30.3.0",
|
||||
"postcss-preset-env": "11.2.0",
|
||||
"sass": "1.98.0",
|
||||
"ts-jest": "29.4.6",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.1",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
"vite-plugin-pwa": "1.2.0",
|
||||
"vite-plugin-vuetify": "2.1.3",
|
||||
"vue-tsc": "3.2.6"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"last 5 Chrome versions",
|
||||
"last 5 Firefox versions",
|
||||
"last 5 Safari versions",
|
||||
"last 5 Edge versions",
|
||||
"last 5 ChromeAndroid versions",
|
||||
"last 5 iOS versions",
|
||||
"not IE <= 11",
|
||||
"not dead"
|
||||
]
|
||||
}
|
||||
|
||||
+475
-88
@@ -4,41 +4,55 @@ import (
|
||||
"sort"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
// AccountsApi represents account api
|
||||
type AccountsApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
accounts *services.AccountService
|
||||
}
|
||||
|
||||
// Initialize an account api singleton instance
|
||||
var (
|
||||
Accounts = &AccountsApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
accounts: services.Accounts,
|
||||
}
|
||||
)
|
||||
|
||||
// AccountListHandler returns accounts list of current user
|
||||
func (a *AccountsApi) AccountListHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *AccountsApi) AccountListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountListReq models.AccountListRequest
|
||||
err := c.ShouldBindQuery(&accountListReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountListHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[accounts.AccountListHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
accounts, err := a.accounts.GetAllAccountsByUid(uid)
|
||||
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[accounts.AccountListHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[accounts.AccountListHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
userAllAccountResps := make([]*models.AccountInfoResponse, len(accounts))
|
||||
@@ -84,21 +98,21 @@ func (a *AccountsApi) AccountListHandler(c *core.Context) (interface{}, *errs.Er
|
||||
}
|
||||
|
||||
// AccountGetHandler returns one specific account of current user
|
||||
func (a *AccountsApi) AccountGetHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *AccountsApi) AccountGetHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountGetReq models.AccountGetRequest
|
||||
err := c.ShouldBindQuery(&accountGetReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountGetHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[accounts.AccountGetHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(uid, accountGetReq.Id)
|
||||
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountGetReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[accounts.AccountGetHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountGetReq.Id, uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[accounts.AccountGetHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountGetReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
accountRespMap := make(map[int64]*models.AccountInfoResponse)
|
||||
@@ -127,38 +141,60 @@ func (a *AccountsApi) AccountGetHandler(c *core.Context) (interface{}, *errs.Err
|
||||
}
|
||||
|
||||
// AccountCreateHandler saves a new account by request parameters for current user
|
||||
func (a *AccountsApi) AccountCreateHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountCreateReq models.AccountCreateRequest
|
||||
err := c.ShouldBindJSON(&accountCreateReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
clientTimezone, err := c.GetClientTimezone()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] cannot get client timezone, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
if accountCreateReq.Category < models.ACCOUNT_CATEGORY_CASH || accountCreateReq.Category > models.ACCOUNT_CATEGORY_CERTIFICATE_OF_DEPOSIT {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account category invalid, category is %d", accountCreateReq.Category)
|
||||
return nil, errs.ErrAccountCategoryInvalid
|
||||
}
|
||||
|
||||
if accountCreateReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountCreateReq.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] cannot set statement date with category \"%d\"", accountCreateReq.Category)
|
||||
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
|
||||
}
|
||||
|
||||
if accountCreateReq.Type == models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
|
||||
if len(accountCreateReq.SubAccounts) > 0 {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account cannot have any sub accounts")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account cannot have any sub-accounts")
|
||||
return nil, errs.ErrAccountCannotHaveSubAccounts
|
||||
}
|
||||
|
||||
if accountCreateReq.Currency == validators.ParentAccountCurrencyPlaceholder {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account cannot set currency placeholder")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account cannot set currency placeholder")
|
||||
return nil, errs.ErrAccountCurrencyInvalid
|
||||
}
|
||||
|
||||
if accountCreateReq.Balance != 0 && accountCreateReq.BalanceTime <= 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account balance time is not set")
|
||||
return nil, errs.ErrAccountBalanceTimeNotSet
|
||||
}
|
||||
} else if accountCreateReq.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
if len(accountCreateReq.SubAccounts) < 1 {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account does not have any sub accounts")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account does not have any sub-accounts")
|
||||
return nil, errs.ErrAccountHaveNoSubAccount
|
||||
}
|
||||
|
||||
if accountCreateReq.Currency != validators.ParentAccountCurrencyPlaceholder {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] parent account cannot set currency")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] parent account cannot set currency")
|
||||
return nil, errs.ErrParentAccountCannotSetCurrency
|
||||
}
|
||||
|
||||
if accountCreateReq.Balance != 0 {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] parent account cannot set balance")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] parent account cannot set balance")
|
||||
return nil, errs.ErrParentAccountCannotSetBalance
|
||||
}
|
||||
|
||||
@@ -166,45 +202,92 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (interface{}, *errs.
|
||||
subAccount := accountCreateReq.SubAccounts[i]
|
||||
|
||||
if subAccount.Category != accountCreateReq.Category {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] category of sub account not equals to parent")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] category of sub-account#%d not equals to parent", i)
|
||||
return nil, errs.ErrSubAccountCategoryNotEqualsToParent
|
||||
}
|
||||
|
||||
if subAccount.Type != models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] sub account type invalid")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d type invalid", i)
|
||||
return nil, errs.ErrSubAccountTypeInvalid
|
||||
}
|
||||
|
||||
if subAccount.Currency == validators.ParentAccountCurrencyPlaceholder {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] sub account cannot set currency placeholder")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d cannot set currency placeholder", i)
|
||||
return nil, errs.ErrAccountCurrencyInvalid
|
||||
}
|
||||
|
||||
if subAccount.Balance != 0 && subAccount.BalanceTime <= 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d balance time is not set", i)
|
||||
return nil, errs.ErrAccountBalanceTimeNotSet
|
||||
}
|
||||
|
||||
if subAccount.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d cannot set statement date", i)
|
||||
return nil, errs.ErrCannotSetStatementDateForSubAccount
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account type invalid, type is %d", accountCreateReq.Type)
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account type invalid, type is %d", accountCreateReq.Type)
|
||||
return nil, errs.ErrAccountTypeInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
maxOrderId, err := a.accounts.GetMaxDisplayOrder(uid, accountCreateReq.Category)
|
||||
maxOrderId, err := a.accounts.GetMaxDisplayOrder(c, uid, accountCreateReq.Category)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[accounts.AccountCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
mainAccount := a.createNewAccountModel(uid, &accountCreateReq, maxOrderId+1)
|
||||
childrenAccounts := a.createSubAccountModels(uid, &accountCreateReq)
|
||||
|
||||
err = a.accounts.CreateAccounts(mainAccount, childrenAccounts)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[accounts.AccountCreateHandler] failed to create account \"id:%d\" for user \"uid:%d\", because %s", mainAccount.AccountId, uid, err.Error())
|
||||
log.Errorf(c, "[accounts.AccountCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[accounts.AccountCreateHandler] user \"uid:%d\" has created a new account \"id:%d\" successfully", uid, mainAccount.AccountId)
|
||||
mainAccount := a.createNewAccountModel(uid, &accountCreateReq, false, maxOrderId+1)
|
||||
childrenAccounts, childrenAccountBalanceTimes := a.createSubAccountModels(uid, &accountCreateReq)
|
||||
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && accountCreateReq.ClientSessionId != "" {
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId)
|
||||
|
||||
if found {
|
||||
log.Infof(c, "[accounts.AccountCreateHandler] another account \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
|
||||
accountId, err := utils.StringToInt64(remark)
|
||||
|
||||
if err == nil {
|
||||
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.AccountCreateHandler] failed to get existed account \"id:%d\" for user \"uid:%d\", because %s", accountId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
|
||||
mainAccount, exists := accountMap[accountId]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
accountInfoResp := mainAccount.ToAccountInfoResponse()
|
||||
|
||||
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||
if accountAndSubAccounts[i].ParentAccountId == mainAccount.AccountId {
|
||||
subAccountResp := accountAndSubAccounts[i].ToAccountInfoResponse()
|
||||
accountInfoResp.SubAccounts = append(accountInfoResp.SubAccounts, subAccountResp)
|
||||
}
|
||||
}
|
||||
|
||||
return accountInfoResp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = a.accounts.CreateAccounts(c, mainAccount, accountCreateReq.BalanceTime, childrenAccounts, childrenAccountBalanceTimes, clientTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.AccountCreateHandler] failed to create account \"id:%d\" for user \"uid:%d\", because %s", mainAccount.AccountId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[accounts.AccountCreateHandler] user \"uid:%d\" has created a new account \"id:%d\" successfully", uid, mainAccount.AccountId)
|
||||
|
||||
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
|
||||
accountInfoResp := mainAccount.ToAccountInfoResponse()
|
||||
|
||||
if len(childrenAccounts) > 0 {
|
||||
@@ -219,55 +302,193 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (interface{}, *errs.
|
||||
}
|
||||
|
||||
// AccountModifyHandler saves an existed account by request parameters for current user
|
||||
func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountModifyReq models.AccountModifyRequest
|
||||
err := c.ShouldBindJSON(&accountModifyReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountModifyHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(uid, accountModifyReq.Id)
|
||||
if accountModifyReq.Id <= 0 {
|
||||
return nil, errs.ErrAccountIdInvalid
|
||||
}
|
||||
|
||||
clientTimezone, err := c.GetClientTimezone()
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[accounts.AccountModifyHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] cannot get client timezone, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
if accountModifyReq.Category < models.ACCOUNT_CATEGORY_CASH || accountModifyReq.Category > models.ACCOUNT_CATEGORY_CERTIFICATE_OF_DEPOSIT {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] account category invalid, category is %d", accountModifyReq.Category)
|
||||
return nil, errs.ErrAccountCategoryInvalid
|
||||
}
|
||||
|
||||
if accountModifyReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountModifyReq.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] cannot set statement date with category \"%d\"", accountModifyReq.Category)
|
||||
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountModifyReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
|
||||
mainAccount, exists := accountMap[accountModifyReq.Id]
|
||||
|
||||
if _, exists := accountMap[accountModifyReq.Id]; !exists {
|
||||
if !exists {
|
||||
return nil, errs.ErrAccountNotFound
|
||||
}
|
||||
|
||||
if len(accountModifyReq.SubAccounts)+1 != len(accountAndSubAccounts) {
|
||||
return nil, errs.ErrCannotAddOrDeleteSubAccountsWhenModify
|
||||
if accountModifyReq.Currency != nil && mainAccount.Currency != *accountModifyReq.Currency {
|
||||
return nil, errs.ErrNotSupportedChangeCurrency
|
||||
}
|
||||
|
||||
if accountModifyReq.Balance != nil {
|
||||
return nil, errs.ErrNotSupportedChangeBalance
|
||||
}
|
||||
|
||||
if accountModifyReq.BalanceTime != nil {
|
||||
return nil, errs.ErrNotSupportedChangeBalanceTime
|
||||
}
|
||||
|
||||
if mainAccount.Type == models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
|
||||
if len(accountModifyReq.SubAccounts) > 0 {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] account cannot have any sub-accounts")
|
||||
return nil, errs.ErrAccountCannotHaveSubAccounts
|
||||
}
|
||||
} else if mainAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
if len(accountModifyReq.SubAccounts) < 1 {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] account does not have any sub-accounts")
|
||||
return nil, errs.ErrAccountHaveNoSubAccount
|
||||
}
|
||||
|
||||
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
|
||||
subAccountReq := accountModifyReq.SubAccounts[i]
|
||||
|
||||
if subAccountReq.Category != accountModifyReq.Category {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] category of sub-account#%d not equals to parent", i)
|
||||
return nil, errs.ErrSubAccountCategoryNotEqualsToParent
|
||||
}
|
||||
|
||||
if subAccountReq.Id == 0 { // create new sub-account
|
||||
if subAccountReq.Currency == nil {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d not set currency", i)
|
||||
return nil, errs.ErrAccountCurrencyInvalid
|
||||
} else if subAccountReq.Currency != nil && *subAccountReq.Currency == validators.ParentAccountCurrencyPlaceholder {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d cannot set currency placeholder", i)
|
||||
return nil, errs.ErrAccountCurrencyInvalid
|
||||
}
|
||||
|
||||
if subAccountReq.Balance == nil {
|
||||
defaultBalance := int64(0)
|
||||
subAccountReq.Balance = &defaultBalance
|
||||
}
|
||||
|
||||
if *subAccountReq.Balance == 0 {
|
||||
defaultBalanceTime := int64(0)
|
||||
subAccountReq.BalanceTime = &defaultBalanceTime
|
||||
}
|
||||
|
||||
if *subAccountReq.Balance != 0 && (subAccountReq.BalanceTime == nil || *subAccountReq.BalanceTime <= 0) {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d balance time is not set", i)
|
||||
return nil, errs.ErrAccountBalanceTimeNotSet
|
||||
}
|
||||
} else { // modify existed sub-account
|
||||
subAccount, exists := accountMap[subAccountReq.Id]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrAccountNotFound
|
||||
}
|
||||
|
||||
if subAccountReq.Currency != nil && subAccount.Currency != *subAccountReq.Currency {
|
||||
return nil, errs.ErrNotSupportedChangeCurrency
|
||||
}
|
||||
|
||||
if subAccountReq.Balance != nil {
|
||||
return nil, errs.ErrNotSupportedChangeBalance
|
||||
}
|
||||
|
||||
if subAccountReq.BalanceTime != nil {
|
||||
return nil, errs.ErrNotSupportedChangeBalanceTime
|
||||
}
|
||||
}
|
||||
|
||||
if subAccountReq.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d cannot set statement date", i)
|
||||
return nil, errs.ErrCannotSetStatementDateForSubAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anythingUpdate := false
|
||||
var toUpdateAccounts []*models.Account
|
||||
var toAddAccounts []*models.Account
|
||||
var toAddAccountBalanceTimes []int64
|
||||
var toDeleteAccountIds []int64
|
||||
|
||||
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, accountMap[accountModifyReq.Id])
|
||||
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, mainAccount, false)
|
||||
|
||||
if toUpdateAccount != nil {
|
||||
if toUpdateAccount.Category != mainAccount.Category {
|
||||
maxOrderId, err := a.accounts.GetMaxDisplayOrder(c, uid, toUpdateAccount.Category)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
toUpdateAccount.DisplayOrder = maxOrderId + 1
|
||||
}
|
||||
|
||||
anythingUpdate = true
|
||||
toUpdateAccounts = append(toUpdateAccounts, toUpdateAccount)
|
||||
}
|
||||
|
||||
toDeleteAccountIds = a.getToDeleteSubAccountIds(&accountModifyReq, mainAccount, accountAndSubAccounts)
|
||||
|
||||
if len(toDeleteAccountIds) > 0 {
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
maxOrderId := int32(0)
|
||||
|
||||
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||
account := accountAndSubAccounts[i]
|
||||
|
||||
if account.AccountId != mainAccount.AccountId && account.DisplayOrder > maxOrderId {
|
||||
maxOrderId = account.DisplayOrder
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
|
||||
subAccountReq := accountModifyReq.SubAccounts[i]
|
||||
|
||||
if _, exists := accountMap[subAccountReq.Id]; !exists {
|
||||
return nil, errs.ErrAccountNotFound
|
||||
}
|
||||
|
||||
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id])
|
||||
|
||||
if toUpdateSubAccount != nil {
|
||||
anythingUpdate = true
|
||||
toUpdateAccounts = append(toUpdateAccounts, toUpdateSubAccount)
|
||||
maxOrderId = maxOrderId + 1
|
||||
newSubAccount := a.createNewSubAccountModelForModify(uid, mainAccount.Type, subAccountReq, maxOrderId)
|
||||
toAddAccounts = append(toAddAccounts, newSubAccount)
|
||||
|
||||
if subAccountReq.BalanceTime != nil {
|
||||
toAddAccountBalanceTimes = append(toAddAccountBalanceTimes, *subAccountReq.BalanceTime)
|
||||
} else {
|
||||
toAddAccountBalanceTimes = append(toAddAccountBalanceTimes, 0)
|
||||
}
|
||||
} else {
|
||||
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id], true)
|
||||
|
||||
if toUpdateSubAccount != nil {
|
||||
anythingUpdate = true
|
||||
toUpdateAccounts = append(toUpdateAccounts, toUpdateSubAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,14 +496,54 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
err = a.accounts.ModifyAccounts(uid, toUpdateAccounts)
|
||||
if len(toAddAccounts) > 0 && a.CurrentConfig().EnableDuplicateSubmissionsCheck && accountModifyReq.ClientSessionId != "" {
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_SUBACCOUNT, uid, accountModifyReq.ClientSessionId)
|
||||
|
||||
if found {
|
||||
log.Infof(c, "[accounts.AccountModifyHandler] another account \"id:%s\" modification has been created for user \"uid:%d\"", remark, uid)
|
||||
accountId, err := utils.StringToInt64(remark)
|
||||
|
||||
if err == nil {
|
||||
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get existed account \"id:%d\" for user \"uid:%d\", because %s", accountId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
|
||||
mainAccount, exists := accountMap[accountId]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
accountInfoResp := mainAccount.ToAccountInfoResponse()
|
||||
|
||||
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||
if accountAndSubAccounts[i].ParentAccountId == mainAccount.AccountId {
|
||||
subAccountResp := accountAndSubAccounts[i].ToAccountInfoResponse()
|
||||
accountInfoResp.SubAccounts = append(accountInfoResp.SubAccounts, subAccountResp)
|
||||
}
|
||||
}
|
||||
|
||||
return accountInfoResp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = a.accounts.ModifyAccounts(c, mainAccount, toUpdateAccounts, toAddAccounts, toAddAccountBalanceTimes, toDeleteAccountIds, clientTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[accounts.AccountModifyHandler] failed to update account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
|
||||
log.Errorf(c, "[accounts.AccountModifyHandler] failed to update account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[accounts.AccountModifyHandler] user \"uid:%d\" has updated account \"id:%d\" successfully", uid, accountModifyReq.Id)
|
||||
log.Infof(c, "[accounts.AccountModifyHandler] user \"uid:%d\" has updated account \"id:%d\" successfully", uid, accountModifyReq.Id)
|
||||
|
||||
if len(toAddAccounts) > 0 {
|
||||
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_SUBACCOUNT, uid, accountModifyReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
|
||||
}
|
||||
|
||||
accountRespMap := make(map[int64]*models.AccountInfoResponse)
|
||||
|
||||
@@ -292,7 +553,6 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
|
||||
|
||||
account.Type = oldAccount.Type
|
||||
account.ParentAccountId = oldAccount.ParentAccountId
|
||||
account.DisplayOrder = oldAccount.DisplayOrder
|
||||
account.Currency = oldAccount.Currency
|
||||
account.Balance = oldAccount.Balance
|
||||
|
||||
@@ -300,11 +560,23 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
|
||||
accountRespMap[accountResp.Id] = accountResp
|
||||
}
|
||||
|
||||
for i := 0; i < len(toAddAccounts); i++ {
|
||||
account := toAddAccounts[i]
|
||||
accountResp := account.ToAccountInfoResponse()
|
||||
accountRespMap[accountResp.Id] = accountResp
|
||||
}
|
||||
|
||||
deletedAccountIds := make(map[int64]bool)
|
||||
|
||||
for i := 0; i < len(toDeleteAccountIds); i++ {
|
||||
deletedAccountIds[toDeleteAccountIds[i]] = true
|
||||
}
|
||||
|
||||
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||
oldAccount := accountAndSubAccounts[i]
|
||||
_, exists := accountRespMap[oldAccount.AccountId]
|
||||
|
||||
if !exists {
|
||||
if !exists && !deletedAccountIds[oldAccount.AccountId] {
|
||||
oldAccountResp := oldAccount.ToAccountInfoResponse()
|
||||
accountRespMap[oldAccountResp.Id] = oldAccountResp
|
||||
}
|
||||
@@ -313,8 +585,19 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
|
||||
accountResp := accountRespMap[accountModifyReq.Id]
|
||||
|
||||
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||
if accountAndSubAccounts[i].ParentAccountId == accountResp.Id {
|
||||
subAccountResp := accountRespMap[accountAndSubAccounts[i].AccountId]
|
||||
account := accountAndSubAccounts[i]
|
||||
|
||||
if account.ParentAccountId == accountResp.Id && !deletedAccountIds[account.AccountId] {
|
||||
subAccountResp := accountRespMap[account.AccountId]
|
||||
accountResp.SubAccounts = append(accountResp.SubAccounts, subAccountResp)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(toAddAccounts); i++ {
|
||||
account := toAddAccounts[i]
|
||||
|
||||
if account.ParentAccountId == accountResp.Id {
|
||||
subAccountResp := accountRespMap[account.AccountId]
|
||||
accountResp.SubAccounts = append(accountResp.SubAccounts, subAccountResp)
|
||||
}
|
||||
}
|
||||
@@ -325,34 +608,34 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
|
||||
}
|
||||
|
||||
// AccountHideHandler hides an existed account by request parameters for current user
|
||||
func (a *AccountsApi) AccountHideHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *AccountsApi) AccountHideHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountHideReq models.AccountHideRequest
|
||||
err := c.ShouldBindJSON(&accountHideReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountHideHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[accounts.AccountHideHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.accounts.HideAccount(uid, []int64{accountHideReq.Id}, accountHideReq.Hidden)
|
||||
err = a.accounts.HideAccount(c, uid, []int64{accountHideReq.Id}, accountHideReq.Hidden)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[accounts.AccountHideHandler] failed to hide account \"id:%d\" for user \"uid:%d\", because %s", accountHideReq.Id, uid, err.Error())
|
||||
log.Errorf(c, "[accounts.AccountHideHandler] failed to hide account \"id:%d\" for user \"uid:%d\", because %s", accountHideReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[accounts.AccountHideHandler] user \"uid:%d\" has hidden account \"id:%d\"", uid, accountHideReq.Id)
|
||||
log.Infof(c, "[accounts.AccountHideHandler] user \"uid:%d\" has hidden account \"id:%d\"", uid, accountHideReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AccountMoveHandler moves display order of existed accounts by request parameters for current user
|
||||
func (a *AccountsApi) AccountMoveHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *AccountsApi) AccountMoveHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountMoveReq models.AccountMoveRequest
|
||||
err := c.ShouldBindJSON(&accountMoveReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountMoveHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[accounts.AccountMoveHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
@@ -370,40 +653,71 @@ func (a *AccountsApi) AccountMoveHandler(c *core.Context) (interface{}, *errs.Er
|
||||
accounts[i] = account
|
||||
}
|
||||
|
||||
err = a.accounts.ModifyAccountDisplayOrders(uid, accounts)
|
||||
err = a.accounts.ModifyAccountDisplayOrders(c, uid, accounts)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[accounts.AccountMoveHandler] failed to move accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[accounts.AccountMoveHandler] failed to move accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[accounts.AccountMoveHandler] user \"uid:%d\" has moved accounts", uid)
|
||||
log.Infof(c, "[accounts.AccountMoveHandler] user \"uid:%d\" has moved accounts", uid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AccountDeleteHandler deletes an existed account by request parameters for current user
|
||||
func (a *AccountsApi) AccountDeleteHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *AccountsApi) AccountDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountDeleteReq models.AccountDeleteRequest
|
||||
err := c.ShouldBindJSON(&accountDeleteReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[accounts.AccountDeleteHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[accounts.AccountDeleteHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.accounts.DeleteAccount(uid, accountDeleteReq.Id)
|
||||
err = a.accounts.DeleteAccount(c, uid, accountDeleteReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[accounts.AccountDeleteHandler] failed to delete account \"id:%d\" for user \"uid:%d\", because %s", accountDeleteReq.Id, uid, err.Error())
|
||||
log.Errorf(c, "[accounts.AccountDeleteHandler] failed to delete account \"id:%d\" for user \"uid:%d\", because %s", accountDeleteReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[accounts.AccountDeleteHandler] user \"uid:%d\" has deleted account \"id:%d\"", uid, accountDeleteReq.Id)
|
||||
log.Infof(c, "[accounts.AccountDeleteHandler] user \"uid:%d\" has deleted account \"id:%d\"", uid, accountDeleteReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.AccountCreateRequest, order int) *models.Account {
|
||||
// SubAccountDeleteHandler deletes an existed sub-account by request parameters for current user
|
||||
func (a *AccountsApi) SubAccountDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountDeleteReq models.AccountDeleteRequest
|
||||
err := c.ShouldBindJSON(&accountDeleteReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[accounts.SubAccountDeleteHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.accounts.DeleteSubAccount(c, uid, accountDeleteReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.SubAccountDeleteHandler] failed to delete sub-account \"id:%d\" for user \"uid:%d\", because %s", accountDeleteReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[accounts.SubAccountDeleteHandler] user \"uid:%d\" has deleted sub-account \"id:%d\"", uid, accountDeleteReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.AccountCreateRequest, isSubAccount bool, order int32) *models.Account {
|
||||
accountExtend := &models.AccountExtend{}
|
||||
|
||||
if !isSubAccount && accountCreateReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||
accountExtend.CreditCardStatementDate = &accountCreateReq.CreditCardStatementDate
|
||||
if accountCreateReq.CreditLimit > 0 {
|
||||
accountExtend.CreditLimit = &accountCreateReq.CreditLimit
|
||||
}
|
||||
}
|
||||
|
||||
return &models.Account{
|
||||
Uid: uid,
|
||||
Name: accountCreateReq.Name,
|
||||
@@ -415,33 +729,65 @@ func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.
|
||||
Currency: accountCreateReq.Currency,
|
||||
Balance: accountCreateReq.Balance,
|
||||
Comment: accountCreateReq.Comment,
|
||||
Extend: accountExtend,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models.AccountCreateRequest) []*models.Account {
|
||||
func (a *AccountsApi) createNewSubAccountModelForModify(uid int64, accountType models.AccountType, accountModifyReq *models.AccountModifyRequest, order int32) *models.Account {
|
||||
accountExtend := &models.AccountExtend{}
|
||||
|
||||
return &models.Account{
|
||||
Uid: uid,
|
||||
Name: accountModifyReq.Name,
|
||||
DisplayOrder: order,
|
||||
Category: accountModifyReq.Category,
|
||||
Type: accountType,
|
||||
Icon: accountModifyReq.Icon,
|
||||
Color: accountModifyReq.Color,
|
||||
Currency: *accountModifyReq.Currency,
|
||||
Balance: *accountModifyReq.Balance,
|
||||
Comment: accountModifyReq.Comment,
|
||||
Extend: accountExtend,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models.AccountCreateRequest) ([]*models.Account, []int64) {
|
||||
if len(accountCreateReq.SubAccounts) <= 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
childrenAccounts := make([]*models.Account, len(accountCreateReq.SubAccounts))
|
||||
childrenAccountBalanceTimes := make([]int64, len(accountCreateReq.SubAccounts))
|
||||
|
||||
for i := 0; i < len(accountCreateReq.SubAccounts); i++ {
|
||||
childrenAccounts[i] = a.createNewAccountModel(uid, accountCreateReq.SubAccounts[i], i+1)
|
||||
for i := int32(0); i < int32(len(accountCreateReq.SubAccounts)); i++ {
|
||||
childrenAccounts[i] = a.createNewAccountModel(uid, accountCreateReq.SubAccounts[i], true, i+1)
|
||||
childrenAccountBalanceTimes[i] = accountCreateReq.SubAccounts[i].BalanceTime
|
||||
}
|
||||
|
||||
return childrenAccounts
|
||||
return childrenAccounts, childrenAccountBalanceTimes
|
||||
}
|
||||
|
||||
func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account) *models.Account {
|
||||
func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account, isSubAccount bool) *models.Account {
|
||||
newAccountExtend := &models.AccountExtend{}
|
||||
|
||||
if !isSubAccount && accountModifyReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||
newAccountExtend.CreditCardStatementDate = &accountModifyReq.CreditCardStatementDate
|
||||
if accountModifyReq.CreditLimit > 0 {
|
||||
newAccountExtend.CreditLimit = &accountModifyReq.CreditLimit
|
||||
}
|
||||
}
|
||||
|
||||
newAccount := &models.Account{
|
||||
AccountId: oldAccount.AccountId,
|
||||
Uid: uid,
|
||||
Name: accountModifyReq.Name,
|
||||
Category: accountModifyReq.Category,
|
||||
Icon: accountModifyReq.Icon,
|
||||
Color: accountModifyReq.Color,
|
||||
Comment: accountModifyReq.Comment,
|
||||
Hidden: accountModifyReq.Hidden,
|
||||
AccountId: oldAccount.AccountId,
|
||||
Uid: uid,
|
||||
Name: accountModifyReq.Name,
|
||||
DisplayOrder: oldAccount.DisplayOrder,
|
||||
Category: accountModifyReq.Category,
|
||||
Icon: accountModifyReq.Icon,
|
||||
Color: accountModifyReq.Color,
|
||||
Comment: accountModifyReq.Comment,
|
||||
Extend: newAccountExtend,
|
||||
Hidden: accountModifyReq.Hidden,
|
||||
}
|
||||
|
||||
if newAccount.Name != oldAccount.Name ||
|
||||
@@ -453,5 +799,46 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
||||
return newAccount
|
||||
}
|
||||
|
||||
if (newAccount.Extend != nil && oldAccount.Extend == nil) ||
|
||||
(newAccount.Extend == nil && oldAccount.Extend != nil) {
|
||||
return newAccount
|
||||
}
|
||||
|
||||
oldAccountExtend := oldAccount.Extend
|
||||
|
||||
if newAccountExtend.CreditCardStatementDate != oldAccountExtend.CreditCardStatementDate {
|
||||
return newAccount
|
||||
}
|
||||
|
||||
if newAccountExtend.CreditLimit != oldAccountExtend.CreditLimit {
|
||||
if newAccountExtend.CreditLimit == nil || oldAccountExtend.CreditLimit == nil || *newAccountExtend.CreditLimit != *oldAccountExtend.CreditLimit {
|
||||
return newAccount
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AccountsApi) getToDeleteSubAccountIds(accountModifyReq *models.AccountModifyRequest, mainAccount *models.Account, accountAndSubAccounts []*models.Account) []int64 {
|
||||
newSubAccountIds := make(map[int64]bool, len(accountModifyReq.SubAccounts))
|
||||
|
||||
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
|
||||
newSubAccountIds[accountModifyReq.SubAccounts[i].Id] = true
|
||||
}
|
||||
|
||||
toDeleteAccountIds := make([]int64, 0)
|
||||
|
||||
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||
subAccount := accountAndSubAccounts[i]
|
||||
|
||||
if subAccount.AccountId == mainAccount.AccountId {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := newSubAccountIds[subAccount.AccountId]; !exists {
|
||||
toDeleteAccountIds = append(toDeleteAccountIds, subAccount.AccountId)
|
||||
}
|
||||
}
|
||||
|
||||
return toDeleteAccountIds
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const amapCustomMapStylesUrl = "https://webapi.amap.com/v4/map/styles"
|
||||
const amapOverseasMapUrl = "https://fmap01.amap.com/v3/vectormap"
|
||||
const amapRestApiUrl = "https://restapi.amap.com/"
|
||||
|
||||
// AmapApiProxy represents amap api proxy
|
||||
type AmapApiProxy struct {
|
||||
ApiUsingConfig
|
||||
}
|
||||
|
||||
// Initialize a amap api proxy singleton instance
|
||||
var (
|
||||
AmapApis = &AmapApiProxy{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// AmapApiProxyHandler returns amap api response
|
||||
func (p *AmapApiProxy) AmapApiProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
|
||||
var targetUrl string
|
||||
|
||||
if strings.HasPrefix(c.Request.RequestURI, "/_AMapService/v4/map/styles") {
|
||||
targetUrl = amapCustomMapStylesUrl + strings.TrimPrefix(c.Request.URL.Path, "/_AMapService/v4/map/styles")
|
||||
} else if strings.HasPrefix(c.Request.RequestURI, "/_AMapService/v3/vectormap") {
|
||||
targetUrl = amapOverseasMapUrl + strings.TrimPrefix(c.Request.URL.Path, "/_AMapService/v3/vectormap")
|
||||
} else {
|
||||
targetUrl = amapRestApiUrl + strings.TrimPrefix(c.Request.URL.Path, "/_AMapService/")
|
||||
}
|
||||
|
||||
director := func(req *http.Request) {
|
||||
targetRawUrl := fmt.Sprintf("%s?%s&jscode=%s", targetUrl, req.URL.RawQuery, p.CurrentConfig().AmapApplicationSecret)
|
||||
targetUrl, _ := url.Parse(targetRawUrl)
|
||||
|
||||
oldCookies := req.Cookies()
|
||||
req.Header.Del("Cookie")
|
||||
|
||||
for i := 0; i < len(oldCookies); i++ {
|
||||
if strings.HasPrefix(oldCookies[i].Name, "ebk_") {
|
||||
continue
|
||||
}
|
||||
|
||||
req.AddCookie(oldCookies[i])
|
||||
}
|
||||
|
||||
req.URL = targetUrl
|
||||
req.RequestURI = req.URL.RequestURI()
|
||||
req.Host = targetUrl.Host
|
||||
}
|
||||
|
||||
return &httputil.ReverseProxy{Director: director}, nil
|
||||
}
|
||||
+393
-47
@@ -1,61 +1,132 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// AuthorizationsApi represents authorization api
|
||||
type AuthorizationsApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
ApiWithUserInfo
|
||||
users *services.UserService
|
||||
userAppCloudSettings *services.UserApplicationCloudSettingsService
|
||||
tokens *services.TokenService
|
||||
twoFactorAuthorizations *services.TwoFactorAuthorizationService
|
||||
userExternalAuths *services.UserExternalAuthService
|
||||
}
|
||||
|
||||
// Initialize a authorization api singleton instance
|
||||
var (
|
||||
Authorizations = &AuthorizationsApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
ApiWithUserInfo: ApiWithUserInfo{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingAvatarProvider: ApiUsingAvatarProvider{
|
||||
container: avatars.Container,
|
||||
},
|
||||
},
|
||||
users: services.Users,
|
||||
userAppCloudSettings: services.UserApplicationCloudSettings,
|
||||
tokens: services.Tokens,
|
||||
twoFactorAuthorizations: services.TwoFactorAuthorizations,
|
||||
userExternalAuths: services.UserExternalAuths,
|
||||
}
|
||||
)
|
||||
|
||||
// AuthorizeHandler verifies and authorizes current login request
|
||||
func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableInternalAuth {
|
||||
return nil, errs.ErrCannotLoginByPassword
|
||||
}
|
||||
|
||||
var credential models.UserLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.ErrLoginNameOrPasswordInvalid
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserByUsernameOrEmailAndPassword(credential.LoginName, credential.Password)
|
||||
err = a.CheckFailureCount(c, 0)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because %s", credential.LoginName, err.Error())
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
user, uid, err := a.users.GetUserByUsernameOrEmailAndPassword(c, credential.LoginName, credential.Password)
|
||||
|
||||
if errs.IsCustomError(err) {
|
||||
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if failureCheckErr != nil {
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, failureCheckErr.Error())
|
||||
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because %s", credential.LoginName, err.Error())
|
||||
return nil, errs.ErrLoginNameOrPasswordWrong
|
||||
}
|
||||
|
||||
err = a.users.UpdateUserLastLoginTime(user.Uid)
|
||||
if user.Disabled {
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because user is disabled", credential.LoginName)
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
|
||||
hasValidEmailVerifyToken, err := a.tokens.ExistsValidTokenByType(c, user.Uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] failed check whether user \"uid:%d\" has valid verify email token, because %s", user.Uid, err.Error())
|
||||
hasValidEmailVerifyToken = false
|
||||
}
|
||||
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because user has not verified email", credential.LoginName)
|
||||
|
||||
return nil, errs.NewErrorWithContext(errs.ErrEmailIsNotVerified, map[string]any{
|
||||
"email": user.Email,
|
||||
"hasValidEmailVerifyToken": hasValidEmailVerifyToken,
|
||||
})
|
||||
}
|
||||
|
||||
err = a.users.UpdateUserLastLoginTime(c, user.Uid)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to update last login time for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] failed to update last login time for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
}
|
||||
|
||||
twoFactorEnable := a.tokens.CurrentConfig().EnableTwoFactor
|
||||
|
||||
if twoFactorEnable {
|
||||
twoFactorEnable, err = a.twoFactorAuthorizations.ExistsTwoFactorSetting(user.Uid)
|
||||
twoFactorEnable, err = a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, user.Uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to check two factor setting for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Errorf(c, "[authorizations.AuthorizeHandler] failed to check two-factor setting for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrSystemError)
|
||||
}
|
||||
}
|
||||
@@ -64,91 +135,156 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (interface{}, *err
|
||||
var claims *core.UserTokenClaims
|
||||
|
||||
if twoFactorEnable {
|
||||
token, claims, err = a.tokens.CreateRequire2FAToken(user, c)
|
||||
token, claims, err = a.tokens.CreateRequire2FAToken(c, user)
|
||||
} else {
|
||||
token, claims, err = a.tokens.CreateToken(user, c)
|
||||
token, claims, err = a.tokens.CreateToken(c, user)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Errorf(c, "[authorizations.AuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.ErrTokenGenerating
|
||||
}
|
||||
|
||||
if !twoFactorEnable {
|
||||
c.SetTextualToken(token)
|
||||
}
|
||||
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.InfofWithRequestId(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logined, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt)
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
|
||||
authResp := a.getAuthResponse(token, twoFactorEnable, user)
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||
}
|
||||
|
||||
log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logged in, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt)
|
||||
|
||||
authResp := a.getAuthResponse(c, token, twoFactorEnable, user, applicationCloudSettingSlice)
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
// TwoFactorAuthorizeHandler verifies and authorizes current 2fa login by passcode
|
||||
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableInternalAuth {
|
||||
return nil, errs.ErrCannotLoginByPassword
|
||||
}
|
||||
|
||||
var credential models.TwoFactorLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.ErrPasscodeInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(uid)
|
||||
err = a.CheckFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get two factor setting for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get two-factor setting for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrSystemError)
|
||||
}
|
||||
|
||||
if !totp.Validate(credential.Passcode, twoFactorSetting.Secret) {
|
||||
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
|
||||
|
||||
err = a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
return nil, errs.ErrPasscodeInvalid
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserById(uid)
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
oldTokenClaims := c.GetTokenClaims()
|
||||
err = a.tokens.DeleteTokenByClaims(oldTokenClaims)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
|
||||
if user.Disabled {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid)
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateToken(user, c)
|
||||
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid)
|
||||
return nil, errs.ErrEmailIsNotVerified
|
||||
}
|
||||
|
||||
oldTokenClaims := c.GetTokenClaims()
|
||||
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.ErrTokenGenerating
|
||||
}
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.InfofWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has authorized two factor via passcode, token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
|
||||
authResp := a.getAuthResponse(token, false, user)
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||
}
|
||||
|
||||
log.Infof(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has authorized two-factor via passcode, token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice)
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
// TwoFactorAuthorizeByRecoveryCodeHandler verifies and authorizes current 2fa login by recovery code
|
||||
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableInternalAuth {
|
||||
return nil, errs.ErrCannotLoginByPassword
|
||||
}
|
||||
|
||||
var credential models.TwoFactorRecoveryCodeLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.ErrTwoFactorRecoveryCodeInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
|
||||
err = a.CheckFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two factor setting for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two-factor setting for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrSystemError)
|
||||
}
|
||||
|
||||
@@ -156,46 +292,256 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Cont
|
||||
return nil, errs.ErrTwoFactorIsNotEnabled
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserById(uid)
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(uid, credential.RecoveryCode, user.Salt)
|
||||
if user.Disabled {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" is disabled", user.Uid)
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has not verified email", user.Uid)
|
||||
return nil, errs.ErrEmailIsNotVerified
|
||||
}
|
||||
|
||||
err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(c, uid, credential.RecoveryCode, user.Salt)
|
||||
|
||||
if errs.IsCustomError(err) {
|
||||
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if failureCheckErr != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, failureCheckErr.Error())
|
||||
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two factor recovery code for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two-factor recovery code for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrTwoFactorRecoveryCodeNotExist)
|
||||
}
|
||||
|
||||
oldTokenClaims := c.GetTokenClaims()
|
||||
err = a.tokens.DeleteTokenByClaims(oldTokenClaims)
|
||||
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateToken(user, c)
|
||||
token, claims, err := a.tokens.CreateToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.ErrTokenGenerating
|
||||
}
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.InfofWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has authorized two factor via recovery code \"%s\", token will be expired at %d", user.Uid, credential.RecoveryCode, claims.ExpiresAt)
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
|
||||
authResp := a.getAuthResponse(token, false, user)
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||
}
|
||||
|
||||
log.Infof(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has authorized two-factor via recovery code \"%s\", token will be expired at %d", user.Uid, credential.RecoveryCode, claims.ExpiresAt)
|
||||
|
||||
authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice)
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
func (a *AuthorizationsApi) getAuthResponse(token string, need2FA bool, user *models.User) *models.AuthResponse {
|
||||
// OAuth2CallbackAuthorizeHandler verifies and authorizes current OAuth 2.0 callback login
|
||||
func (a *AuthorizationsApi) OAuth2CallbackAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableOAuth2Login {
|
||||
return nil, errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
var credential models.OAuth2CallbackLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
var tokenContext models.OAuth2CallbackTokenContext
|
||||
err = json.Unmarshal([]byte(c.GetTokenContext()), &tokenContext)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] parse token context failed, because %s", err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
if !tokenContext.ExternalAuthType.IsValid() {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] external auth type \"%s\" is invalid", tokenContext.ExternalAuthType)
|
||||
return nil, errs.ErrInvalidOAuth2Provider
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.CheckFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid)
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid)
|
||||
return nil, errs.ErrEmailIsNotVerified
|
||||
}
|
||||
|
||||
oldTokenClaims := c.GetTokenClaims()
|
||||
|
||||
if oldTokenClaims.Type == core.USER_TOKEN_TYPE_OAUTH2_CALLBACK_REQUIRE_VERIFY {
|
||||
if credential.Password == "" {
|
||||
return nil, errs.ErrPasswordIsEmpty
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(credential.Password, user) {
|
||||
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if failureCheckErr != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot login for user \"uid:%d\", because %s", user.Uid, failureCheckErr.Error())
|
||||
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableTwoFactor {
|
||||
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrTwoFactorIsNotEnabled) {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to check two-factor setting for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrSystemError)
|
||||
}
|
||||
|
||||
if twoFactorSetting != nil {
|
||||
if credential.Passcode == "" {
|
||||
return nil, errs.ErrPasscodeEmpty
|
||||
}
|
||||
|
||||
if !totp.Validate(credential.Passcode, twoFactorSetting.Secret) {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
|
||||
|
||||
err = a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
return nil, errs.ErrPasscodeInvalid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userExternalAuth := &models.UserExternalAuth{
|
||||
Uid: user.Uid,
|
||||
ExternalAuthType: tokenContext.ExternalAuthType,
|
||||
ExternalUsername: tokenContext.ExternalUsername,
|
||||
ExternalEmail: tokenContext.ExternalEmail,
|
||||
}
|
||||
|
||||
err = a.userExternalAuths.CreateUserExternalAuth(c, userExternalAuth)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to create user external auth for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user external auth has been created for user \"uid:%d\"", user.Uid)
|
||||
} else if oldTokenClaims.Type == core.USER_TOKEN_TYPE_OAUTH2_CALLBACK {
|
||||
_, err = a.userExternalAuths.GetUserExternalAuthByUid(c, uid, tokenContext.ExternalAuthType)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get user external auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrUserExternalAuthNotFound)
|
||||
}
|
||||
} else {
|
||||
return nil, errs.ErrSystemError
|
||||
}
|
||||
|
||||
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
|
||||
}
|
||||
|
||||
var token string
|
||||
var claims *core.UserTokenClaims
|
||||
|
||||
if credential.Token != "" {
|
||||
_, claims, _, err = a.tokens.ParseToken(c, credential.Token)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to parse token, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidToken
|
||||
}
|
||||
|
||||
if claims.Uid != user.Uid {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] oauth 2.0 user \"uid:%d\" does not match current user \"uid:%d\"", user.Uid, claims.Uid)
|
||||
token = ""
|
||||
claims = nil
|
||||
} else {
|
||||
token = credential.Token
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
token, claims, err = a.tokens.CreateToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.ErrTokenGenerating
|
||||
}
|
||||
}
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||
}
|
||||
|
||||
log.Infof(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" has logged in, token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice)
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
func (a *AuthorizationsApi) getAuthResponse(c *core.WebContext, token string, need2FA bool, user *models.User, applicationCloudSettings *models.ApplicationCloudSettingSlice) *models.AuthResponse {
|
||||
return &models.AuthResponse{
|
||||
Token: token,
|
||||
Need2FA: need2FA,
|
||||
User: user.ToUserBasicInfo(),
|
||||
Token: token,
|
||||
Need2FA: need2FA,
|
||||
User: a.GetUserBasicInfo(user),
|
||||
ApplicationCloudSettings: applicationCloudSettings,
|
||||
NotificationContent: a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale()),
|
||||
}
|
||||
}
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const internalTransactionPictureUrlFormat = "%spictures/%d.%s"
|
||||
|
||||
// ApiUsingConfig represents an api that need to use config
|
||||
type ApiUsingConfig struct {
|
||||
container *settings.ConfigContainer
|
||||
}
|
||||
|
||||
// CurrentConfig returns the current config
|
||||
func (a *ApiUsingConfig) CurrentConfig() *settings.Config {
|
||||
return a.container.GetCurrentConfig()
|
||||
}
|
||||
|
||||
// GetTransactionPictureInfoResponse returns the view-object of transaction picture basic info according to the transaction picture model
|
||||
func (a *ApiUsingConfig) GetTransactionPictureInfoResponse(pictureInfo *models.TransactionPictureInfo) *models.TransactionPictureInfoBasicResponse {
|
||||
originalUrl := fmt.Sprintf(internalTransactionPictureUrlFormat, a.CurrentConfig().RootUrl, pictureInfo.PictureId, pictureInfo.PictureExtension)
|
||||
return pictureInfo.ToTransactionPictureInfoBasicResponse(originalUrl)
|
||||
}
|
||||
|
||||
// GetTransactionPictureInfoResponseList returns the view-object list of transaction picture basic info according to the transaction picture model
|
||||
func (a *ApiUsingConfig) GetTransactionPictureInfoResponseList(pictureInfos []*models.TransactionPictureInfo) models.TransactionPictureInfoBasicResponseSlice {
|
||||
pictureInfoResps := make(models.TransactionPictureInfoBasicResponseSlice, len(pictureInfos))
|
||||
|
||||
for i := 0; i < len(pictureInfos); i++ {
|
||||
pictureInfoResps[i] = a.GetTransactionPictureInfoResponse(pictureInfos[i])
|
||||
}
|
||||
|
||||
sort.Sort(pictureInfoResps)
|
||||
|
||||
return pictureInfoResps
|
||||
}
|
||||
|
||||
// GetAfterRegisterNotificationContent returns the notification content displayed each time users register
|
||||
func (a *ApiUsingConfig) GetAfterRegisterNotificationContent(userLanguage string, clientLanguage string) string {
|
||||
language := userLanguage
|
||||
|
||||
if language == "" {
|
||||
language = clientLanguage
|
||||
}
|
||||
|
||||
if !a.CurrentConfig().AfterRegisterNotification.Enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
if multiLanguageContent, exists := a.CurrentConfig().AfterRegisterNotification.MultiLanguageContent[language]; exists {
|
||||
return multiLanguageContent
|
||||
}
|
||||
|
||||
return a.CurrentConfig().AfterRegisterNotification.DefaultContent
|
||||
}
|
||||
|
||||
// GetAfterLoginNotificationContent returns the notification content displayed each time users log in
|
||||
func (a *ApiUsingConfig) GetAfterLoginNotificationContent(userLanguage string, clientLanguage string) string {
|
||||
language := userLanguage
|
||||
|
||||
if language == "" {
|
||||
language = clientLanguage
|
||||
}
|
||||
|
||||
if !a.CurrentConfig().AfterLoginNotification.Enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
if multiLanguageContent, exists := a.CurrentConfig().AfterLoginNotification.MultiLanguageContent[language]; exists {
|
||||
return multiLanguageContent
|
||||
}
|
||||
|
||||
return a.CurrentConfig().AfterLoginNotification.DefaultContent
|
||||
}
|
||||
|
||||
// GetAfterOpenNotificationContent returns the notification content displayed each time users open the app
|
||||
func (a *ApiUsingConfig) GetAfterOpenNotificationContent(userLanguage string, clientLanguage string) string {
|
||||
language := userLanguage
|
||||
|
||||
if language == "" {
|
||||
language = clientLanguage
|
||||
}
|
||||
|
||||
if !a.CurrentConfig().AfterOpenNotification.Enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
if multiLanguageContent, exists := a.CurrentConfig().AfterOpenNotification.MultiLanguageContent[language]; exists {
|
||||
return multiLanguageContent
|
||||
}
|
||||
|
||||
return a.CurrentConfig().AfterOpenNotification.DefaultContent
|
||||
}
|
||||
|
||||
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
|
||||
type ApiUsingDuplicateChecker struct {
|
||||
ApiUsingConfig
|
||||
container *duplicatechecker.DuplicateCheckerContainer
|
||||
}
|
||||
|
||||
// GetSubmissionRemark returns whether the same submission has been processed and related remark by the current duplicate checker
|
||||
func (a *ApiUsingDuplicateChecker) GetSubmissionRemark(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) (bool, string) {
|
||||
return a.container.GetSubmissionRemark(checkerType, uid, identification)
|
||||
}
|
||||
|
||||
// SetSubmissionRemarkWithCustomExpiration saves the identification and remark by the current duplicate checker with custom expiration time
|
||||
func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkWithCustomExpiration(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration) {
|
||||
a.container.SetSubmissionRemarkWithCustomExpiration(checkerType, uid, identification, remark, expiration)
|
||||
}
|
||||
|
||||
// SetSubmissionRemarkIfEnable saves the identification and remark by the current duplicate checker if the duplicate submission check is enabled
|
||||
func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string) {
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
|
||||
a.container.SetSubmissionRemark(checkerType, uid, identification, remark)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSubmissionRemark removes the identification and remark by the current duplicate checker
|
||||
func (a *ApiUsingDuplicateChecker) RemoveSubmissionRemark(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) {
|
||||
a.container.RemoveSubmissionRemark(checkerType, uid, identification)
|
||||
}
|
||||
|
||||
// RemoveSubmissionRemarkIfEnable removes the identification and remark by the current duplicate checker if the duplicate submission check is enabled
|
||||
func (a *ApiUsingDuplicateChecker) RemoveSubmissionRemarkIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) {
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
|
||||
a.container.RemoveSubmissionRemark(checkerType, uid, identification)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
|
||||
func (a *ApiUsingDuplicateChecker) CheckFailureCount(c *core.WebContext, uid int64) error {
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
|
||||
clientIp := c.ClientIP()
|
||||
ipFailureCount := a.container.GetFailureCount(clientIp)
|
||||
|
||||
if ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
|
||||
log.Warnf(c, "[base.CheckFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
|
||||
uidFailureCount := a.container.GetFailureCount(utils.Int64ToString(uid))
|
||||
|
||||
if uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
|
||||
log.Warnf(c, "[base.CheckFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAndIncreaseFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
|
||||
func (a *ApiUsingDuplicateChecker) CheckAndIncreaseFailureCount(c *core.WebContext, uid int64) error {
|
||||
clientIp := c.ClientIP()
|
||||
ipFailureCount := uint32(0)
|
||||
uidFailureCount := uint32(0)
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
|
||||
ipFailureCount = a.container.GetFailureCount(clientIp)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
|
||||
uidFailureCount = a.container.GetFailureCount(utils.Int64ToString(uid))
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount < a.CurrentConfig().MaxFailuresPerIpPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", previous failure count: %d", clientIp, ipFailureCount)
|
||||
a.container.IncreaseFailureCount(clientIp)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount < a.CurrentConfig().MaxFailuresPerUserPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", previous failure count: %d", uid, uidFailureCount)
|
||||
a.container.IncreaseFailureCount(utils.Int64ToString(uid))
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApiUsingAvatarProvider represents an api that need to use avatar provider
|
||||
type ApiUsingAvatarProvider struct {
|
||||
container *avatars.AvatarProviderContainer
|
||||
}
|
||||
|
||||
// GetAvatarUrl returns the avatar url by the current user avatar provider
|
||||
func (a *ApiUsingAvatarProvider) GetAvatarUrl(user *models.User) string {
|
||||
return a.container.GetAvatarUrl(user)
|
||||
}
|
||||
|
||||
// ApiWithUserInfo represents an api that can returns user info
|
||||
type ApiWithUserInfo struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingAvatarProvider
|
||||
}
|
||||
|
||||
// GetUserBasicInfo returns the view-object of user basic info according to the user model
|
||||
func (a *ApiWithUserInfo) GetUserBasicInfo(user *models.User) *models.UserBasicInfo {
|
||||
return user.ToUserBasicInfo(a.CurrentConfig().AvatarProvider, a.GetAvatarUrl(user))
|
||||
}
|
||||
+385
-109
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,125 +16,144 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const pageCountForClearTransactions = 1000
|
||||
const pageCountForDataExport = 1000
|
||||
|
||||
// DataManagementsApi represents data management api
|
||||
type DataManagementsApi struct {
|
||||
exporter *converters.EzBookKeepingCSVFileExporter
|
||||
tokens *services.TokenService
|
||||
users *services.UserService
|
||||
accounts *services.AccountService
|
||||
transactions *services.TransactionService
|
||||
categories *services.TransactionCategoryService
|
||||
tags *services.TransactionTagService
|
||||
ApiUsingConfig
|
||||
tokens *services.TokenService
|
||||
users *services.UserService
|
||||
accounts *services.AccountService
|
||||
transactions *services.TransactionService
|
||||
categories *services.TransactionCategoryService
|
||||
tags *services.TransactionTagService
|
||||
tagGroups *services.TransactionTagGroupService
|
||||
pictures *services.TransactionPictureService
|
||||
templates *services.TransactionTemplateService
|
||||
userCustomExchangeRates *services.UserCustomExchangeRatesService
|
||||
insightsExploreres *services.InsightsExplorerService
|
||||
}
|
||||
|
||||
// Initialize a data management api singleton instance
|
||||
var (
|
||||
DataManagements = &DataManagementsApi{
|
||||
exporter: &converters.EzBookKeepingCSVFileExporter{},
|
||||
tokens: services.Tokens,
|
||||
users: services.Users,
|
||||
accounts: services.Accounts,
|
||||
transactions: services.Transactions,
|
||||
categories: services.TransactionCategories,
|
||||
tags: services.TransactionTags,
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
tokens: services.Tokens,
|
||||
users: services.Users,
|
||||
accounts: services.Accounts,
|
||||
transactions: services.Transactions,
|
||||
categories: services.TransactionCategories,
|
||||
tags: services.TransactionTags,
|
||||
tagGroups: services.TransactionTagGroups,
|
||||
pictures: services.TransactionPictures,
|
||||
templates: services.TransactionTemplates,
|
||||
userCustomExchangeRates: services.UserCustomExchangeRates,
|
||||
insightsExploreres: services.InsightsExplorers,
|
||||
}
|
||||
)
|
||||
|
||||
// ExportDataHandler returns exported data in csv format
|
||||
func (a *DataManagementsApi) ExportDataHandler(c *core.Context) ([]byte, string, *errs.Error) {
|
||||
if !settings.Container.Current.EnableDataExport {
|
||||
return nil, "", errs.ErrDataExportNotAllowed
|
||||
}
|
||||
|
||||
timezone := time.Local
|
||||
utcOffset, err := c.GetClientTimezoneOffset()
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[data_managements.ExportDataHandler] cannot get client timezone offset, because %s", err.Error())
|
||||
} else {
|
||||
timezone = time.FixedZone("Client Timezone", int(utcOffset)*60)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.WarnfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, "", errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
accounts, err := a.accounts.GetAllAccountsByUid(uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
categories, err := a.categories.GetAllCategoriesByUid(uid, 0, -1)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
tags, err := a.tags.GetAllTagsByUid(uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
tagIndexs, err := a.tags.GetAllTagIdsOfAllTransactions(uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
accountMap := a.accounts.GetAccountMapByList(accounts)
|
||||
categoryMap := a.categories.GetCategoryMapByList(categories)
|
||||
tagMap := a.tags.GetTagMapByList(tags)
|
||||
|
||||
allTransactions, err := a.transactions.GetAllTransactions(uid, pageCountForDataExport, true)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
result, err := a.exporter.ToExportedContent(uid, timezone, allTransactions, accountMap, categoryMap, tagMap, tagIndexs)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get csv format exported data for \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
fileName := a.getFileName(user, timezone)
|
||||
|
||||
return result, fileName, nil
|
||||
// ExportDataToEzbookkeepingCSVHandler returns exported data in csv format
|
||||
func (a *DataManagementsApi) ExportDataToEzbookkeepingCSVHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
|
||||
return a.getExportedFileContent(c, "csv")
|
||||
}
|
||||
|
||||
// ClearDataHandler deletes all user data
|
||||
func (a *DataManagementsApi) ClearDataHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
// ExportDataToEzbookkeepingTSVHandler returns exported data in csv format
|
||||
func (a *DataManagementsApi) ExportDataToEzbookkeepingTSVHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
|
||||
return a.getExportedFileContent(c, "tsv")
|
||||
}
|
||||
|
||||
// DataStatisticsHandler returns user data statistics
|
||||
func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
totalAccountCount, err := a.accounts.GetTotalAccountCountByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total account count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
totalTransactionCategoryCount, err := a.categories.GetTotalCategoryCountByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction category count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
totalTransactionTagCount, err := a.tags.GetTotalTagCountByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction tag count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
totalTransactionCount, err := a.transactions.GetTotalTransactionCountByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
totalTransactionPictureCount, err := a.pictures.GetTotalTransactionPicturesCountByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction picture count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
totalInsightsExplorerCount, err := a.insightsExploreres.GetTotalInsightsExplorersCountByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total insights explorer count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
totalTransactionTemplateCount, err := a.templates.GetTotalNormalTemplateCountByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction template count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
totalScheduledTransactionCount, err := a.templates.GetTotalScheduledTemplateCountByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total scheduled transaction count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
dataStatisticsResp := &models.DataStatisticsResponse{
|
||||
TotalAccountCount: totalAccountCount,
|
||||
TotalTransactionCategoryCount: totalTransactionCategoryCount,
|
||||
TotalTransactionTagCount: totalTransactionTagCount,
|
||||
TotalTransactionCount: totalTransactionCount,
|
||||
TotalTransactionPictureCount: totalTransactionPictureCount,
|
||||
TotalInsightsExplorerCount: totalInsightsExplorerCount,
|
||||
TotalTransactionTemplateCount: totalTransactionTemplateCount,
|
||||
TotalScheduledTransactionCount: totalScheduledTransactionCount,
|
||||
}
|
||||
|
||||
return dataStatisticsResp, nil
|
||||
}
|
||||
|
||||
// ClearAllDataHandler deletes all user data
|
||||
func (a *DataManagementsApi) ClearAllDataHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var clearDataReq models.ClearDataRequest
|
||||
err := c.ShouldBindJSON(&clearDataReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[data_managements.ClearDataHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[data_managements.ClearAllDataHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(uid)
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.WarnfWithRequestId(c, "[data_managements.ClearDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Warnf(c, "[data_managements.ClearAllDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
@@ -143,36 +163,292 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.Context) (interface{}, *er
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
err = a.transactions.DeleteAllTransactions(uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[data_managements.ClearDataHandler] failed to delete all transactions, because %s", err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
err = a.categories.DeleteAllCategories(uid)
|
||||
err = a.templates.DeleteAllTemplates(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[data_managements.ClearDataHandler] failed to delete all transaction categories, because %s", err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction templates, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.tags.DeleteAllTags(uid)
|
||||
err = a.transactions.DeleteAllTransactions(c, uid, true)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[data_managements.ClearDataHandler] failed to delete all transaction tags, because %s", err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transactions, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid)
|
||||
err = a.categories.DeleteAllCategories(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction categories, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.tags.DeleteAllTags(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction tags, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.tagGroups.DeleteAllTagGroups(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction tag groups, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all user custom exchange rates, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.insightsExploreres.DeleteAllInsightsExplorers(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all insights explorers, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[data_managements.ClearAllDataHandler] user \"uid:%d\" has cleared all data", uid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *DataManagementsApi) getFileName(user *models.User, timezone *time.Location) string {
|
||||
currentTime := utils.FormatUnixTimeToLongDateTimeWithoutSecond(time.Now().Unix(), timezone)
|
||||
// ClearAllTransactionsHandler deletes all transactions
|
||||
func (a *DataManagementsApi) ClearAllTransactionsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var clearDataReq models.ClearDataRequest
|
||||
err := c.ShouldBindJSON(&clearDataReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[data_managements.ClearAllTransactionsHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[data_managements.ClearAllTransactionsHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(clearDataReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
err = a.transactions.DeleteAllTransactions(c, uid, false)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearAllTransactionsHandler] failed to delete all transactions, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[data_managements.ClearAllTransactionsHandler] user \"uid:%d\" has cleared all transactions", uid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ClearAllTransactionsByAccountHandler deletes all transactions of specified account
|
||||
func (a *DataManagementsApi) ClearAllTransactionsByAccountHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var clearDataReq models.ClearAccountTransactionsRequest
|
||||
err := c.ShouldBindJSON(&clearDataReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[data_managements.ClearAllTransactionsByAccountHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(clearDataReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
account, err := a.accounts.GetAccountByAccountId(c, uid, clearDataReq.AccountId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", uid, clearDataReq.AccountId, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if account.Hidden {
|
||||
return nil, errs.ErrCannotDeleteTransactionInHiddenAccount
|
||||
}
|
||||
|
||||
if account.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
return nil, errs.ErrCannotDeleteTransactionInParentAccount
|
||||
}
|
||||
|
||||
err = a.transactions.DeleteAllTransactionsOfAccount(c, uid, account.AccountId, pageCountForClearTransactions)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to delete all transactions in account \"id:%d\", because %s", account.AccountId, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[data_managements.ClearAllTransactionsByAccountHandler] user \"uid:%d\" has cleared all transactions in account \"id:%d\"", uid, account.AccountId)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType string) ([]byte, string, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableDataExport {
|
||||
return nil, "", errs.ErrDataExportNotAllowed
|
||||
}
|
||||
|
||||
var exportTransactionDataReq models.ExportTransactionDataRequest
|
||||
err := c.ShouldBindQuery(&exportTransactionDataReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[data_managements.getExportedFileContent] parse request failed, because %s", err.Error())
|
||||
return nil, "", errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
clientTimezone, err := c.GetClientTimezone()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[data_managements.getExportedFileContent] cannot get client timezone, because %s", err.Error())
|
||||
clientTimezone = time.Local
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[data_managements.getExportedFileContent] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, "", errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION) {
|
||||
return nil, "", errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.getExportedFileContent] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
categories, err := a.categories.GetAllCategoriesByUid(c, uid, 0, -1)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.getExportedFileContent] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
tags, err := a.tags.GetAllTagsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.getExportedFileContent] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
tagIndexes, err := a.tags.GetAllTagIdsMapOfAllTransactions(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.getExportedFileContent] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
accountMap := a.accounts.GetAccountMapByList(accounts)
|
||||
categoryMap := a.categories.GetCategoryMapByList(categories)
|
||||
tagMap := a.tags.GetTagMapByList(tags)
|
||||
|
||||
allAccountIds, err := a.accounts.GetAccountOrSubAccountIds(c, exportTransactionDataReq.AccountIds, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[data_managements.getExportedFileContent] get account error, because %s", err.Error())
|
||||
return nil, "", errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allCategoryIds, err := a.categories.GetCategoryOrSubCategoryIds(c, exportTransactionDataReq.CategoryIds, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[data_managements.getExportedFileContent] get transaction category error, because %s", err.Error())
|
||||
return nil, "", errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
noTags := exportTransactionDataReq.TagFilter == models.TransactionNoTagFilterValue
|
||||
var tagFilters []*models.TransactionTagFilter
|
||||
|
||||
if !noTags {
|
||||
tagFilters, err = models.ParseTransactionTagFilter(exportTransactionDataReq.TagFilter)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[data_managements.getExportedFileContent] parse transaction tag filters error, because %s", err.Error())
|
||||
return nil, "", errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
maxTransactionTime := int64(math.MaxInt64)
|
||||
minTransactionTime := int64(0)
|
||||
|
||||
if exportTransactionDataReq.MaxTime > 0 {
|
||||
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(exportTransactionDataReq.MaxTime)
|
||||
}
|
||||
|
||||
if exportTransactionDataReq.MinTime > 0 {
|
||||
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(exportTransactionDataReq.MinTime)
|
||||
}
|
||||
|
||||
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, exportTransactionDataReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, exportTransactionDataReq.AmountFilter, exportTransactionDataReq.Keyword, pageCountForDataExport, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.getExportedFileContent] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
dataExporter := converters.GetTransactionDataExporter(fileType)
|
||||
|
||||
if dataExporter == nil {
|
||||
return nil, "", errs.ErrNotImplemented
|
||||
}
|
||||
|
||||
result, err := dataExporter.ToExportedContent(c, uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexes)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.getExportedFileContent] failed to get exported data for \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, "", errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
fileName := a.getFileName(user, clientTimezone, fileType)
|
||||
|
||||
return result, fileName, nil
|
||||
}
|
||||
|
||||
func (a *DataManagementsApi) getFileName(user *models.User, clientTimezone *time.Location, fileExtension string) string {
|
||||
currentTime := utils.FormatUnixTimeToLongDateTimeWithoutSecond(time.Now().Unix(), clientTimezone)
|
||||
currentTime = strings.Replace(currentTime, "-", "_", -1)
|
||||
currentTime = strings.Replace(currentTime, " ", "_", -1)
|
||||
currentTime = strings.Replace(currentTime, ":", "_", -1)
|
||||
|
||||
return fmt.Sprintf("%s_%s.csv", user.Username, currentTime)
|
||||
return fmt.Sprintf("%s_%s.%s", user.Username, currentTime, fileExtension)
|
||||
}
|
||||
|
||||
+2
-2
@@ -14,11 +14,11 @@ var (
|
||||
)
|
||||
|
||||
// ApiNotFound returns api not found error
|
||||
func (a *DefaultApi) ApiNotFound(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *DefaultApi) ApiNotFound(c *core.WebContext) (any, *errs.Error) {
|
||||
return nil, errs.ErrApiNotFound
|
||||
}
|
||||
|
||||
// MethodNotAllowed returns method not allowed error
|
||||
func (a *DefaultApi) MethodNotAllowed(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *DefaultApi) MethodNotAllowed(c *core.WebContext) (any, *errs.Error) {
|
||||
return nil, errs.ErrMethodNotAllowed
|
||||
}
|
||||
|
||||
+75
-69
@@ -1,100 +1,106 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// ExchangeRatesApi represents exchange rate api
|
||||
type ExchangeRatesApi struct{}
|
||||
type ExchangeRatesApi struct {
|
||||
ApiUsingConfig
|
||||
users *services.UserService
|
||||
userCustomExchangeRates *services.UserCustomExchangeRatesService
|
||||
}
|
||||
|
||||
// Initialize a exchange rate api singleton instance
|
||||
var (
|
||||
ExchangeRates = &ExchangeRatesApi{}
|
||||
ExchangeRates = &ExchangeRatesApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
users: services.Users,
|
||||
userCustomExchangeRates: services.UserCustomExchangeRates,
|
||||
}
|
||||
)
|
||||
|
||||
// LatestExchangeRateHandler returns latest exchange rate data
|
||||
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
dataSource := exchangerates.Container.Current
|
||||
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
exchangeRateResponse, err := exchangerates.Container.GetLatestExchangeRates(c, c.GetCurrentUid(), a.CurrentConfig())
|
||||
|
||||
if dataSource == nil {
|
||||
return nil, errs.ErrInvalidExchangeRatesDataSource
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return exchangeRateResponse, nil
|
||||
}
|
||||
|
||||
// UserCustomExchangeRateUpdateHandler updates user custom exchange rates data by request parameters for current user
|
||||
func (a *ExchangeRatesApi) UserCustomExchangeRateUpdateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var customExchangeRateUpdateReq models.UserCustomExchangeRateUpdateRequest
|
||||
err := c.ShouldBindJSON(&customExchangeRateUpdateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(settings.Container.Current.ExchangeRatesRequestTimeout) * time.Millisecond,
|
||||
if err != nil {
|
||||
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] failed to get user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
urls := dataSource.GetRequestUrls()
|
||||
exchangeRateResps := make([]*models.LatestExchangeRateResponse, 0, len(urls))
|
||||
|
||||
for i := 0; i < len(urls); i++ {
|
||||
resp, err := client.Get(urls[i])
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[exchange_rates.LatestExchangeRateHandler] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.ErrorfWithRequestId(c, "[exchange_rates.LatestExchangeRateHandler] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not 200", uid)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
exchangeRateResp, err := dataSource.Parse(c, body)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[exchange_rates.LatestExchangeRateHandler] failed to parse response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailedToRequestRemoteApi)
|
||||
}
|
||||
|
||||
exchangeRateResps = append(exchangeRateResps, exchangeRateResp)
|
||||
if customExchangeRateUpdateReq.Currency == user.DefaultCurrency {
|
||||
return nil, errs.ErrCannotUpdateExchangeRateForDefaultCurrency
|
||||
}
|
||||
|
||||
lastExchangeRateResponse := exchangeRateResps[len(exchangeRateResps)-1]
|
||||
allExchangeRatesMap := make(map[string]string)
|
||||
newCustomExchangeRate, defaultCurrencyExchangeRate, err := a.userCustomExchangeRates.UpdateCustomExchangeRate(c, uid, customExchangeRateUpdateReq.Currency, customExchangeRateUpdateReq.Rate, user.DefaultCurrency)
|
||||
|
||||
for i := 0; i < len(exchangeRateResps); i++ {
|
||||
exchangeRateResp := exchangeRateResps[i]
|
||||
|
||||
for j := 0; j < len(exchangeRateResp.ExchangeRates); j++ {
|
||||
exchangeRate := exchangeRateResp.ExchangeRates[j]
|
||||
allExchangeRatesMap[exchangeRate.Currency] = exchangeRate.Rate
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] failed to update user custom exchange rate \"currency:%s\" for user \"uid:%d\", because %s", customExchangeRateUpdateReq.Currency, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allExchangeRatesMap[lastExchangeRateResponse.BaseCurrency] = "1"
|
||||
allExchangeRates := make(models.LatestExchangeRateSlice, 0, len(allExchangeRatesMap))
|
||||
|
||||
for currency, rate := range allExchangeRatesMap {
|
||||
allExchangeRates = append(allExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: currency,
|
||||
Rate: rate,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Sort(allExchangeRates)
|
||||
|
||||
finalExchangeRateResponse := &models.LatestExchangeRateResponse{
|
||||
DataSource: lastExchangeRateResponse.DataSource,
|
||||
ReferenceUrl: lastExchangeRateResponse.ReferenceUrl,
|
||||
UpdateTime: lastExchangeRateResponse.UpdateTime,
|
||||
BaseCurrency: lastExchangeRateResponse.BaseCurrency,
|
||||
ExchangeRates: allExchangeRates,
|
||||
}
|
||||
|
||||
return finalExchangeRateResponse, nil
|
||||
log.Infof(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] user \"uid:%d\" has updated user custom exchange rate \"currency:%s\" successfully", uid, customExchangeRateUpdateReq.Currency)
|
||||
return newCustomExchangeRate.ToUserCustomExchangeRateUpdateResponse(defaultCurrencyExchangeRate.Rate), nil
|
||||
}
|
||||
|
||||
// UserCustomExchangeRateDeleteHandler deletes an existed user custom exchange rates data by request parameters for current user
|
||||
func (a *ExchangeRatesApi) UserCustomExchangeRateDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var customExchangeRateDeleteReq models.UserCustomExchangeRateDeleteRequest
|
||||
err := c.ShouldBindJSON(&customExchangeRateDeleteReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] failed to get user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if customExchangeRateDeleteReq.Currency == user.DefaultCurrency {
|
||||
return nil, errs.ErrCannotDeleteExchangeRateForDefaultCurrency
|
||||
}
|
||||
|
||||
err = a.userCustomExchangeRates.DeleteCustomExchangeRate(c, uid, customExchangeRateDeleteReq.Currency)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] failed to delete user custom exchange rate \"currency:%s\" for user \"uid:%d\", because %s", customExchangeRateDeleteReq.Currency, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] user \"uid:%d\" has deleted user custom exchange rate \"currency:%s\"", uid, customExchangeRateDeleteReq.Currency)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
)
|
||||
|
||||
// InsightsExplorersApi represents insights explorers api
|
||||
type InsightsExplorersApi struct {
|
||||
insightsExploreres *services.InsightsExplorerService
|
||||
}
|
||||
|
||||
// Initialize a insights explorers api singleton instance
|
||||
var (
|
||||
InsightsExplorers = &InsightsExplorersApi{
|
||||
insightsExploreres: services.InsightsExplorers,
|
||||
}
|
||||
)
|
||||
|
||||
// InsightsExplorerListHandler returns insights explorer list of current user
|
||||
func (a *InsightsExplorersApi) InsightsExplorerListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
explorers, err := a.insightsExploreres.GetAllInsightsExplorerNamesByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerListHandler] failed to get insights explorers for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
explorerResps := make(models.InsightsExplorerInfoResponseSlice, len(explorers))
|
||||
|
||||
for i := 0; i < len(explorers); i++ {
|
||||
explorerResps[i], err = explorers[i].ToInsightsExplorerInfoResponse()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerListHandler] failed to get insights explorer response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrInsightsExplorerDataInvalid
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(explorerResps)
|
||||
|
||||
return explorerResps, nil
|
||||
}
|
||||
|
||||
// InsightsExplorerGetHandler returns one specific insights explorer of current user
|
||||
func (a *InsightsExplorersApi) InsightsExplorerGetHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var explorerGetReq models.InsightsExplorerGetRequest
|
||||
err := c.ShouldBindQuery(&explorerGetReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[explorers.InsightsExplorerGetHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
explorer, err := a.insightsExploreres.GetInsightsExplorerByExplorerId(c, uid, explorerGetReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerGetHandler] failed to get insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorerGetReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
explorerResp, err := explorer.ToInsightsExplorerInfoResponse()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerGetHandler] failed to get insights explorer response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrInsightsExplorerDataInvalid
|
||||
}
|
||||
|
||||
return explorerResp, nil
|
||||
}
|
||||
|
||||
// InsightsExplorerCreateHandler saves a new insights explorer by request parameters for current user
|
||||
func (a *InsightsExplorersApi) InsightsExplorerCreateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var explorerCreateReq models.InsightsExplorerCreateRequest
|
||||
err := c.ShouldBindJSON(&explorerCreateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[explorers.InsightsExplorerCreateHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
maxOrderId, err := a.insightsExploreres.GetMaxDisplayOrder(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
explorer, err := a.createNewInsightsExplorerModel(uid, &explorerCreateReq, maxOrderId+1)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerCreateHandler] failed to parse insights explorer data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrInsightsExplorerDataInvalid
|
||||
}
|
||||
|
||||
err = a.insightsExploreres.CreateInsightsExplorer(c, explorer)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerCreateHandler] failed to create insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorer.ExplorerId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[explorers.InsightsExplorerCreateHandler] user \"uid:%d\" has created a new insights explorer \"id:%d\" successfully", uid, explorer.ExplorerId)
|
||||
|
||||
explorerResp, err := explorer.ToInsightsExplorerInfoResponse()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerCreateHandler] failed to get insights explorer response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrInsightsExplorerDataInvalid
|
||||
}
|
||||
|
||||
return explorerResp, nil
|
||||
}
|
||||
|
||||
// InsightsExplorerModifyHandler saves an existed insights explorer by request parameters for current user
|
||||
func (a *InsightsExplorersApi) InsightsExplorerModifyHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var explorerModifyReq models.InsightsExplorerModifyRequest
|
||||
err := c.ShouldBindJSON(&explorerModifyReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[explorers.InsightsExplorerModifyHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
explorer, err := a.insightsExploreres.GetInsightsExplorerByExplorerId(c, uid, explorerModifyReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerModifyHandler] failed to get insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorerModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
newData, err := json.Marshal(explorerModifyReq.Data)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerModifyHandler] failed to parse insights explorer data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrInsightsExplorerDataInvalid
|
||||
}
|
||||
|
||||
newExplorer := &models.InsightsExplorer{
|
||||
ExplorerId: explorer.ExplorerId,
|
||||
Uid: uid,
|
||||
Name: explorerModifyReq.Name,
|
||||
Data: string(newData),
|
||||
}
|
||||
|
||||
if newExplorer.Name == explorer.Name && newExplorer.Data == explorer.Data {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
err = a.insightsExploreres.ModifyInsightsExplorer(c, newExplorer)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerModifyHandler] failed to update insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorerModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[explorers.InsightsExplorerModifyHandler] user \"uid:%d\" has updated insights explorer \"id:%d\" successfully", uid, explorerModifyReq.Id)
|
||||
|
||||
explorer.Name = newExplorer.Name
|
||||
explorer.Data = newExplorer.Data
|
||||
explorerResp, err := explorer.ToInsightsExplorerInfoResponse()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerModifyHandler] failed to get insights explorer response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrInsightsExplorerDataInvalid
|
||||
}
|
||||
|
||||
return explorerResp, nil
|
||||
}
|
||||
|
||||
// InsightsExplorerHideHandler hides a insights explorer by request parameters for current user
|
||||
func (a *InsightsExplorersApi) InsightsExplorerHideHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var explorerHideReq models.InsightsExplorerHideRequest
|
||||
err := c.ShouldBindJSON(&explorerHideReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[explorers.InsightsExplorerHideHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.insightsExploreres.HideInsightsExplorer(c, uid, []int64{explorerHideReq.Id}, explorerHideReq.Hidden)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerHideHandler] failed to hide insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorerHideReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[explorers.InsightsExplorerHideHandler] user \"uid:%d\" has hidden insights explorer \"id:%d\"", uid, explorerHideReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// InsightsExplorerMoveHandler moves display order of existed insights explorers by request parameters for current user
|
||||
func (a *InsightsExplorersApi) InsightsExplorerMoveHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var explorerMoveReq models.InsightsExplorerMoveRequest
|
||||
err := c.ShouldBindJSON(&explorerMoveReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[explorers.InsightsExplorerMoveHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
explorers := make([]*models.InsightsExplorer, len(explorerMoveReq.NewDisplayOrders))
|
||||
|
||||
for i := 0; i < len(explorerMoveReq.NewDisplayOrders); i++ {
|
||||
newDisplayOrder := explorerMoveReq.NewDisplayOrders[i]
|
||||
explorer := &models.InsightsExplorer{
|
||||
Uid: uid,
|
||||
ExplorerId: newDisplayOrder.Id,
|
||||
DisplayOrder: newDisplayOrder.DisplayOrder,
|
||||
}
|
||||
|
||||
explorers[i] = explorer
|
||||
}
|
||||
|
||||
err = a.insightsExploreres.ModifyInsightsExplorerDisplayOrders(c, uid, explorers)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerMoveHandler] failed to move insights explorers for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[explorers.InsightsExplorerMoveHandler] user \"uid:%d\" has moved insights explorers", uid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// InsightsExplorerDeleteHandler deletes an existed insights explorer by request parameters for current user
|
||||
func (a *InsightsExplorersApi) InsightsExplorerDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var explorerDeleteReq models.InsightsExplorerDeleteRequest
|
||||
err := c.ShouldBindJSON(&explorerDeleteReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[explorers.InsightsExplorerDeleteHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.insightsExploreres.DeleteInsightsExplorer(c, uid, explorerDeleteReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[explorers.InsightsExplorerDeleteHandler] failed to delete insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorerDeleteReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[explorers.InsightsExplorerDeleteHandler] user \"uid:%d\" has deleted insights explorer \"id:%d\"", uid, explorerDeleteReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *InsightsExplorersApi) createNewInsightsExplorerModel(uid int64, explorerCreateReq *models.InsightsExplorerCreateRequest, order int32) (*models.InsightsExplorer, error) {
|
||||
data, err := json.Marshal(explorerCreateReq.Data)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.InsightsExplorer{
|
||||
Uid: uid,
|
||||
Name: explorerCreateReq.Name,
|
||||
Data: string(data),
|
||||
DisplayOrder: order,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// ForgetPasswordsApi represents user forget password api
|
||||
type ForgetPasswordsApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
users *services.UserService
|
||||
tokens *services.TokenService
|
||||
forgetPasswords *services.ForgetPasswordService
|
||||
}
|
||||
|
||||
// Initialize a user api singleton instance
|
||||
var (
|
||||
ForgetPasswords = &ForgetPasswordsApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
users: services.Users,
|
||||
tokens: services.Tokens,
|
||||
forgetPasswords: services.ForgetPasswords,
|
||||
}
|
||||
)
|
||||
|
||||
// UserForgetPasswordRequestHandler generates password reset link and send user an email with this link
|
||||
func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var request models.ForgetPasswordRequest
|
||||
err := c.ShouldBindJSON(&request)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.ErrEmailIsEmptyOrInvalid
|
||||
}
|
||||
|
||||
err = a.CheckFailureCount(c, 0)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send forget password mail to \"%s\", because %s", request.Email, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserByEmail(c, request.Email)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
failureCheckErr := a.CheckAndIncreaseFailureCount(c, 0)
|
||||
|
||||
if failureCheckErr != nil {
|
||||
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send forget password mail to \"%s\", because %s", request.Email, failureCheckErr.Error())
|
||||
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" is disabled", user.Uid)
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if a.CurrentConfig().ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
|
||||
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" has not verified email", user.Uid)
|
||||
return nil, errs.ErrEmailIsNotVerified
|
||||
}
|
||||
|
||||
if !a.CurrentConfig().EnableSMTP {
|
||||
return nil, errs.ErrSMTPServerNotEnabled
|
||||
}
|
||||
|
||||
token, _, err := a.tokens.CreatePasswordResetToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.ErrTokenGenerating
|
||||
}
|
||||
|
||||
go func() {
|
||||
err = a.forgetPasswords.SendPasswordResetEmail(c, user, token, c.GetClientLocale())
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// UserResetPasswordHandler resets user password by request parameters
|
||||
func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var request models.PasswordResetRequest
|
||||
err := c.ShouldBindJSON(&request)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[forget_passwords.UserResetPasswordHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" is disabled", user.Uid)
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if a.CurrentConfig().ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
|
||||
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" has not verified email", user.Uid)
|
||||
return nil, errs.ErrEmailIsNotVerified
|
||||
}
|
||||
|
||||
if user.Email != request.Email {
|
||||
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] request email not equals the user email")
|
||||
return nil, errs.ErrEmailIsInvalid
|
||||
}
|
||||
|
||||
if a.users.IsPasswordEqualsUserPassword(request.Password, user) {
|
||||
oldTokenClaims := c.GetTokenClaims()
|
||||
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke password reset token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrNewPasswordEqualsOldInvalid
|
||||
}
|
||||
|
||||
userNew := &models.User{
|
||||
Uid: user.Uid,
|
||||
Salt: user.Salt,
|
||||
Password: request.Password,
|
||||
}
|
||||
|
||||
_, _, err = a.users.UpdateUser(c, userNew, false)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[forget_passwords.UserResetPasswordHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
err = a.tokens.DeleteTokensBeforeTime(c, uid, now)
|
||||
|
||||
if err == nil {
|
||||
log.Infof(c, "[forget_passwords.UserResetPasswordHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
|
||||
} else {
|
||||
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
)
|
||||
|
||||
// HealthsApi represents health api
|
||||
type HealthsApi struct{}
|
||||
|
||||
// Initialize a healths api singleton instance
|
||||
var (
|
||||
Healths = &HealthsApi{}
|
||||
)
|
||||
|
||||
// HealthStatusHandler returns the health status of current service
|
||||
func (a *HealthsApi) HealthStatusHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
result := make(map[string]string)
|
||||
|
||||
result["version"] = core.Version
|
||||
result["commit"] = core.CommitHash
|
||||
result["status"] = "ok"
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/templates"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// LargeLanguageModelsApi represents large language models api
|
||||
type LargeLanguageModelsApi struct {
|
||||
ApiUsingConfig
|
||||
transactionCategories *services.TransactionCategoryService
|
||||
transactionTags *services.TransactionTagService
|
||||
accounts *services.AccountService
|
||||
users *services.UserService
|
||||
}
|
||||
|
||||
// Initialize a large language models api singleton instance
|
||||
var (
|
||||
LargeLanguageModels = &LargeLanguageModelsApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
transactionCategories: services.TransactionCategories,
|
||||
transactionTags: services.TransactionTags,
|
||||
accounts: services.Accounts,
|
||||
users: services.Users,
|
||||
}
|
||||
)
|
||||
|
||||
// RecognizeReceiptImageHandler returns the recognized receipt image result
|
||||
func (a *LargeLanguageModelsApi) RecognizeReceiptImageHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if a.CurrentConfig().ReceiptImageRecognitionLLMConfig == nil || a.CurrentConfig().ReceiptImageRecognitionLLMConfig.LLMProvider == "" || !a.CurrentConfig().TransactionFromAIImageRecognition {
|
||||
return nil, errs.ErrLargeLanguageModelProviderNotEnabled
|
||||
}
|
||||
|
||||
clientTimezone, err := c.GetClientTimezone()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] cannot get client timezone, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return false, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CREATE_TRANSACTION_FROM_AI_IMAGE_RECOGNITION) {
|
||||
return false, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
form, err := c.MultipartForm()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrParameterInvalid
|
||||
}
|
||||
|
||||
imageFiles := form.File["image"]
|
||||
|
||||
if len(imageFiles) < 1 {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] there is no image in request for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrNoAIRecognitionImage
|
||||
}
|
||||
|
||||
if imageFiles[0].Size < 1 {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the size of image in request is zero for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrAIRecognitionImageIsEmpty
|
||||
}
|
||||
|
||||
if imageFiles[0].Size > int64(a.CurrentConfig().MaxAIRecognitionPictureFileSize) {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of image for user \"uid:%d\"", imageFiles[0].Size, a.CurrentConfig().MaxAIRecognitionPictureFileSize, uid)
|
||||
return nil, errs.ErrExceedMaxAIRecognitionImageFileSize
|
||||
}
|
||||
|
||||
fileExtension := utils.GetFileNameExtension(imageFiles[0].Filename)
|
||||
contentType := utils.GetImageContentType(fileExtension)
|
||||
|
||||
if contentType == "" {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the file extension \"%s\" of image in request is not supported for user \"uid:%d\"", fileExtension, uid)
|
||||
return nil, errs.ErrImageTypeNotSupported
|
||||
}
|
||||
|
||||
imageFile, err := imageFiles[0].Open()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get image file from request for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
defer imageFile.Close()
|
||||
|
||||
imageData, err := io.ReadAll(imageFile)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to read image file from request for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
accountMap := a.accounts.GetVisibleAccountNameMapByList(accounts)
|
||||
accountNames := make([]string, 0, len(accounts))
|
||||
|
||||
for i := 0; i < len(accounts); i++ {
|
||||
if accounts[i].Hidden || accounts[i].Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
continue
|
||||
}
|
||||
|
||||
accountNames = append(accountNames, accounts[i].Name)
|
||||
}
|
||||
|
||||
categories, err := a.transactionCategories.GetAllCategoriesByUid(c, uid, 0, -1)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
incomeCategoryMap := make(map[string]*models.TransactionCategory)
|
||||
incomeCategoryNames := make([]string, 0)
|
||||
|
||||
expenseCategoryMap := make(map[string]*models.TransactionCategory)
|
||||
expenseCategoryNames := make([]string, 0)
|
||||
|
||||
transferCategoryMap := make(map[string]*models.TransactionCategory)
|
||||
transferCategoryNames := make([]string, 0)
|
||||
|
||||
for i := 0; i < len(categories); i++ {
|
||||
category := categories[i]
|
||||
|
||||
if category.Hidden || category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
|
||||
continue
|
||||
}
|
||||
|
||||
if category.Type == models.CATEGORY_TYPE_INCOME {
|
||||
incomeCategoryMap[category.Name] = category
|
||||
incomeCategoryNames = append(incomeCategoryNames, category.Name)
|
||||
} else if category.Type == models.CATEGORY_TYPE_EXPENSE {
|
||||
expenseCategoryMap[category.Name] = category
|
||||
expenseCategoryNames = append(expenseCategoryNames, category.Name)
|
||||
} else if category.Type == models.CATEGORY_TYPE_TRANSFER {
|
||||
transferCategoryMap[category.Name] = category
|
||||
transferCategoryNames = append(transferCategoryNames, category.Name)
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := a.transactionTags.GetAllTagsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
tagMap := a.transactionTags.GetVisibleTagNameMapByList(tags)
|
||||
tagNames := make([]string, 0, len(tags))
|
||||
|
||||
for i := 0; i < len(tags); i++ {
|
||||
if tags[i].Hidden {
|
||||
continue
|
||||
}
|
||||
|
||||
tagNames = append(tagNames, tags[i].Name)
|
||||
}
|
||||
|
||||
systemPrompt, err := templates.GetTemplate(templates.SYSTEM_PROMPT_RECEIPT_IMAGE_RECOGNITION)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get system prompt template for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
systemPromptParams := map[string]any{
|
||||
"CurrentDateTime": utils.FormatUnixTimeToLongDateTime(time.Now().Unix(), clientTimezone),
|
||||
"AllExpenseCategoryNames": strings.Join(expenseCategoryNames, "\n"),
|
||||
"AllIncomeCategoryNames": strings.Join(incomeCategoryNames, "\n"),
|
||||
"AllTransferCategoryNames": strings.Join(transferCategoryNames, "\n"),
|
||||
"AllAccountNames": strings.Join(accountNames, "\n"),
|
||||
"AllTagNames": strings.Join(tagNames, "\n"),
|
||||
}
|
||||
|
||||
var bodyBuffer bytes.Buffer
|
||||
err = systemPrompt.Execute(&bodyBuffer, systemPromptParams)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get final system prompt from template for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
llmRequest := &data.LargeLanguageModelRequest{
|
||||
Stream: false,
|
||||
SystemPrompt: strings.ReplaceAll(bodyBuffer.String(), "\r\n", "\n"),
|
||||
UserPrompt: imageData,
|
||||
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
|
||||
UserPromptContentType: contentType,
|
||||
}
|
||||
|
||||
llmResponse, err := llm.Container.GetJsonResponseByReceiptImageRecognitionModel(c, c.GetCurrentUid(), a.CurrentConfig(), llmRequest)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get llm response user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if llmResponse == nil || len(llmResponse.Content) == 0 || strings.HasPrefix(llmResponse.Content, "{}") {
|
||||
return nil, errs.ErrNoTransactionInformationInImage
|
||||
}
|
||||
|
||||
var result *models.RecognizedReceiptImageResult
|
||||
|
||||
if err := json.Unmarshal([]byte(llmResponse.Content), &result); err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to unmarshal recognized receipt image result from llm response \"%s\" for user \"uid:%d\", because %s", llmResponse.Content, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return a.parseRecognizedReceiptImageResponse(c, uid, clientTimezone, result, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
func (a *LargeLanguageModelsApi) parseRecognizedReceiptImageResponse(c *core.WebContext, uid int64, clientTimezone *time.Location, recognizedResult *models.RecognizedReceiptImageResult, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (*models.RecognizedReceiptImageResponse, *errs.Error) {
|
||||
recognizedReceiptImageResponse := &models.RecognizedReceiptImageResponse{
|
||||
Type: models.TRANSACTION_TYPE_EXPENSE,
|
||||
}
|
||||
|
||||
if recognizedResult == nil {
|
||||
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed result is null")
|
||||
return nil, errs.ErrNoTransactionInformationInImage
|
||||
}
|
||||
|
||||
if recognizedResult.Type == "income" {
|
||||
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_INCOME
|
||||
|
||||
if len(recognizedResult.CategoryName) > 0 {
|
||||
category, exists := incomeCategoryMap[recognizedResult.CategoryName]
|
||||
|
||||
if exists {
|
||||
recognizedReceiptImageResponse.CategoryId = category.CategoryId
|
||||
}
|
||||
}
|
||||
} else if recognizedResult.Type == "expense" {
|
||||
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_EXPENSE
|
||||
|
||||
if len(recognizedResult.CategoryName) > 0 {
|
||||
category, exists := expenseCategoryMap[recognizedResult.CategoryName]
|
||||
|
||||
if exists {
|
||||
recognizedReceiptImageResponse.CategoryId = category.CategoryId
|
||||
}
|
||||
}
|
||||
} else if recognizedResult.Type == "transfer" {
|
||||
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_TRANSFER
|
||||
|
||||
if len(recognizedResult.CategoryName) > 0 {
|
||||
category, exists := transferCategoryMap[recognizedResult.CategoryName]
|
||||
|
||||
if exists {
|
||||
recognizedReceiptImageResponse.CategoryId = category.CategoryId
|
||||
}
|
||||
}
|
||||
} else if len(recognizedResult.Type) == 0 {
|
||||
return nil, errs.ErrNoTransactionInformationInImage
|
||||
} else {
|
||||
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed transaction type \"%s\" is invalid", recognizedResult.Type)
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
if len(recognizedResult.Time) > 0 {
|
||||
longDateTime := a.getLongDateTime(recognizedResult.Time)
|
||||
timestamp, err := utils.ParseFromLongDateTimeInTimeZone(longDateTime, clientTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed time \"%s\" is invalid", recognizedResult.Time)
|
||||
} else {
|
||||
recognizedReceiptImageResponse.Time = timestamp.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
if len(recognizedResult.Amount) > 0 {
|
||||
amount, err := utils.ParseAmount(recognizedResult.Amount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed amount \"%s\" is invalid", recognizedResult.Amount)
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
recognizedReceiptImageResponse.SourceAmount = amount
|
||||
|
||||
if recognizedReceiptImageResponse.Type == models.TRANSACTION_TYPE_TRANSFER && len(recognizedResult.DestinationAmount) > 0 {
|
||||
destinationAmount, err := utils.ParseAmount(recognizedResult.DestinationAmount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed destination amount \"%s\" is invalid", recognizedResult.DestinationAmount)
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
recognizedReceiptImageResponse.DestinationAmount = destinationAmount
|
||||
}
|
||||
}
|
||||
|
||||
if len(recognizedResult.AccountName) > 0 {
|
||||
account, exists := accountMap[recognizedResult.AccountName]
|
||||
|
||||
if exists {
|
||||
recognizedReceiptImageResponse.SourceAccountId = account.AccountId
|
||||
}
|
||||
}
|
||||
|
||||
if len(recognizedResult.DestinationAccountName) > 0 {
|
||||
account, exists := accountMap[recognizedResult.DestinationAccountName]
|
||||
|
||||
if exists {
|
||||
recognizedReceiptImageResponse.DestinationAccountId = account.AccountId
|
||||
}
|
||||
}
|
||||
|
||||
if len(recognizedResult.TagNames) > 0 {
|
||||
tagIds := make([]string, 0, len(recognizedResult.TagNames))
|
||||
|
||||
for i := 0; i < len(recognizedResult.TagNames); i++ {
|
||||
tagName := recognizedResult.TagNames[i]
|
||||
tag, exists := tagMap[tagName]
|
||||
|
||||
if exists {
|
||||
tagIds = append(tagIds, utils.Int64ToString(tag.TagId))
|
||||
}
|
||||
}
|
||||
|
||||
recognizedReceiptImageResponse.TagIds = tagIds
|
||||
}
|
||||
|
||||
if len(recognizedResult.Description) > 0 {
|
||||
recognizedReceiptImageResponse.Comment = recognizedResult.Description
|
||||
}
|
||||
|
||||
return recognizedReceiptImageResponse, nil
|
||||
}
|
||||
|
||||
func (a *LargeLanguageModelsApi) getLongDateTime(dateTime string) string {
|
||||
if utils.IsValidLongDateTimeFormat(dateTime) {
|
||||
return dateTime
|
||||
}
|
||||
|
||||
if utils.IsValidLongDateTimeWithoutSecondFormat(dateTime) {
|
||||
return dateTime + ":00"
|
||||
}
|
||||
|
||||
if utils.IsValidLongDateFormat(dateTime) {
|
||||
return dateTime + " 00:00:00"
|
||||
}
|
||||
|
||||
return dateTime
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/httpclient"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const openStreetMapTileImageUrlFormat = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" // https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
const openStreetMapHumanitarianStyleTileImageUrlFormat = "https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png" // https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png
|
||||
const openTopoMapTileImageUrlFormat = "https://tile.opentopomap.org/{z}/{x}/{y}.png" // https://tile.opentopomap.org/{z}/{x}/{y}.png
|
||||
const opnvKarteMapTileImageUrlFormat = "https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png" // https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png
|
||||
const cyclOSMMapTileImageUrlFormat = "https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png" // https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png
|
||||
const cartoDBMapTileImageUrlFormat = "https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{scale}.png" // https://{s}.basemaps.cartocdn.com/{style}/{z}/{x}/{y}{scale}.png
|
||||
const tomtomMapTileImageUrlFormat = "https://api.tomtom.com/map/1/tile/basic/main/{z}/{x}/{y}.png" // https://api.tomtom.com/map/{versionNumber}/tile/{layer}/{style}/{z}/{x}/{y}.png?key={key}&language={language}
|
||||
const tianDiTuMapTileImageUrlFormat = "https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}" // https://t{s}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={key}
|
||||
const tianDiTuMapAnnotationUrlFormat = "https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}" // https://t{s}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={key}
|
||||
|
||||
// MapImageProxy represents map image proxy
|
||||
type MapImageProxy struct {
|
||||
ApiUsingConfig
|
||||
mutex sync.Mutex
|
||||
transport *http.Transport
|
||||
}
|
||||
|
||||
// Initialize a map image proxy singleton instance
|
||||
var (
|
||||
MapImages = &MapImageProxy{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func (p *MapImageProxy) initializeHttpTransport() {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
if p.transport != nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.transport = http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpclient.SetProxyUrl(p.transport, p.CurrentConfig().MapProxy)
|
||||
}
|
||||
|
||||
// MapTileImageProxyHandler returns map tile image
|
||||
func (p *MapImageProxy) MapTileImageProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
|
||||
return p.mapImageProxyHandler(c, func(c *core.WebContext, mapProvider string) (string, *errs.Error) {
|
||||
if mapProvider == settings.OpenStreetMapProvider {
|
||||
return openStreetMapTileImageUrlFormat, nil
|
||||
} else if mapProvider == settings.OpenStreetMapHumanitarianStyleProvider {
|
||||
return openStreetMapHumanitarianStyleTileImageUrlFormat, nil
|
||||
} else if mapProvider == settings.OpenTopoMapProvider {
|
||||
return openTopoMapTileImageUrlFormat, nil
|
||||
} else if mapProvider == settings.OPNVKarteMapProvider {
|
||||
return opnvKarteMapTileImageUrlFormat, nil
|
||||
} else if mapProvider == settings.CyclOSMMapProvider {
|
||||
return cyclOSMMapTileImageUrlFormat, nil
|
||||
} else if mapProvider == settings.CartoDBMapProvider {
|
||||
return cartoDBMapTileImageUrlFormat, nil
|
||||
} else if mapProvider == settings.TomTomMapProvider {
|
||||
targetUrl := tomtomMapTileImageUrlFormat + "?key=" + p.CurrentConfig().TomTomMapAPIKey
|
||||
language := c.Query("language")
|
||||
|
||||
if language != "" {
|
||||
targetUrl = targetUrl + "&language=" + language
|
||||
}
|
||||
|
||||
return targetUrl, nil
|
||||
} else if mapProvider == settings.TianDiTuProvider {
|
||||
return tianDiTuMapTileImageUrlFormat + "&tk=" + p.CurrentConfig().TianDiTuAPIKey, nil
|
||||
} else if mapProvider == settings.CustomProvider {
|
||||
return p.CurrentConfig().CustomMapTileServerTileLayerUrl, nil
|
||||
}
|
||||
|
||||
return "", errs.ErrParameterInvalid
|
||||
})
|
||||
}
|
||||
|
||||
// MapAnnotationImageProxyHandler returns map annotation image
|
||||
func (p *MapImageProxy) MapAnnotationImageProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
|
||||
return p.mapImageProxyHandler(c, func(c *core.WebContext, mapProvider string) (string, *errs.Error) {
|
||||
if mapProvider == settings.TianDiTuProvider {
|
||||
return tianDiTuMapAnnotationUrlFormat + "&tk=" + p.CurrentConfig().TianDiTuAPIKey, nil
|
||||
} else if mapProvider == settings.CustomProvider {
|
||||
return p.CurrentConfig().CustomMapTileServerAnnotationLayerUrl, nil
|
||||
}
|
||||
|
||||
return "", errs.ErrParameterInvalid
|
||||
})
|
||||
}
|
||||
|
||||
func (p *MapImageProxy) mapImageProxyHandler(c *core.WebContext, fn func(c *core.WebContext, mapProvider string) (string, *errs.Error)) (*httputil.ReverseProxy, *errs.Error) {
|
||||
mapProvider := strings.Replace(c.Query("provider"), "-", "_", -1)
|
||||
targetUrl := ""
|
||||
|
||||
if mapProvider != p.CurrentConfig().MapProvider {
|
||||
return nil, errs.ErrMapProviderNotCurrent
|
||||
}
|
||||
|
||||
zoomLevel := c.Param("zoomLevel")
|
||||
coordinateX := c.Param("coordinateX")
|
||||
fileName := c.Param("fileName")
|
||||
fileNameParts := strings.Split(fileName, ".")
|
||||
coordinateY := fileNameParts[0]
|
||||
scale := c.Query("scale")
|
||||
|
||||
if len(fileNameParts) != 2 || fileNameParts[len(fileNameParts)-1] != "png" {
|
||||
return nil, errs.ErrImageExtensionNotSupported
|
||||
}
|
||||
|
||||
var err *errs.Error
|
||||
targetUrl, err = fn(c, mapProvider)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.transport == nil {
|
||||
p.initializeHttpTransport()
|
||||
}
|
||||
|
||||
director := func(req *http.Request) {
|
||||
imageRawUrl := targetUrl
|
||||
imageRawUrl = strings.Replace(imageRawUrl, "{z}", zoomLevel, -1)
|
||||
imageRawUrl = strings.Replace(imageRawUrl, "{x}", coordinateX, -1)
|
||||
imageRawUrl = strings.Replace(imageRawUrl, "{y}", coordinateY, -1)
|
||||
imageRawUrl = strings.Replace(imageRawUrl, "{scale}", scale, -1)
|
||||
imageUrl, _ := url.Parse(imageRawUrl)
|
||||
|
||||
req.URL = imageUrl
|
||||
req.RequestURI = req.URL.RequestURI()
|
||||
req.Host = imageUrl.Host
|
||||
}
|
||||
|
||||
return &httputil.ReverseProxy{
|
||||
Transport: p.transport,
|
||||
Director: director,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/mcp"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const mcpServerName = core.ApplicationName + "-mcp"
|
||||
|
||||
// ModelContextProtocolAPI represents model context protocol api
|
||||
type ModelContextProtocolAPI struct {
|
||||
ApiUsingConfig
|
||||
transactions *services.TransactionService
|
||||
transactionCategories *services.TransactionCategoryService
|
||||
transactionTags *services.TransactionTagService
|
||||
accounts *services.AccountService
|
||||
users *services.UserService
|
||||
tokens *services.TokenService
|
||||
}
|
||||
|
||||
// Initialize a model context protocol api singleton instance
|
||||
var (
|
||||
ModelContextProtocols = &ModelContextProtocolAPI{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
transactions: services.Transactions,
|
||||
transactionCategories: services.TransactionCategories,
|
||||
transactionTags: services.TransactionTags,
|
||||
accounts: services.Accounts,
|
||||
users: services.Users,
|
||||
tokens: services.Tokens,
|
||||
}
|
||||
)
|
||||
|
||||
// InitializeHandler returns the initialize response for model context protocol
|
||||
func (a *ModelContextProtocolAPI) InitializeHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||
var initRequest mcp.MCPInitializeRequest
|
||||
|
||||
if jsonRPCRequest.Params != nil {
|
||||
if err := json.Unmarshal(jsonRPCRequest.Params, &initRequest); err != nil {
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
} else {
|
||||
return nil, errs.ErrIncompleteOrIncorrectSubmission
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[model_context_protocols.InitializeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
tokenClaims := c.GetTokenClaims()
|
||||
userTokenId, err := utils.StringToInt64(tokenClaims.UserTokenId)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[model_context_protocols.InitializeHandler] parse user token id failed, because %s", err.Error())
|
||||
} else {
|
||||
tokenRecord := &models.TokenRecord{
|
||||
Uid: tokenClaims.Uid,
|
||||
UserTokenId: userTokenId,
|
||||
CreatedUnixTime: tokenClaims.IssuedAt,
|
||||
}
|
||||
|
||||
tokenId := a.tokens.GenerateTokenId(tokenRecord)
|
||||
|
||||
err = a.tokens.UpdateTokenLastSeen(c, tokenRecord)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[model_context_protocols.InitializeHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
protocolVersion := mcp.MCPProtocolVersion(initRequest.ProtocolVersion)
|
||||
_, exists := mcp.SupportedMCPVersion[protocolVersion]
|
||||
|
||||
if !exists {
|
||||
protocolVersion = mcp.LatestSupportedMCPVersion
|
||||
}
|
||||
|
||||
initResp := mcp.MCPInitializeResponse{
|
||||
ProtocolVersion: string(protocolVersion),
|
||||
Capabilities: &mcp.MCPCapabilities{
|
||||
Tools: &mcp.MCPToolCapabilities{
|
||||
ListChanged: false,
|
||||
},
|
||||
},
|
||||
ServerInfo: &mcp.MCPImplementation{
|
||||
Name: mcpServerName,
|
||||
Title: core.ApplicationName,
|
||||
Version: core.Version,
|
||||
},
|
||||
}
|
||||
|
||||
return initResp, nil
|
||||
}
|
||||
|
||||
// ListResourcesHandler returns the list of resources for model context protocol
|
||||
func (a *ModelContextProtocolAPI) ListResourcesHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[model_context_protocols.ListResourcesHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
listResourcesResp := mcp.MCPListResourcesResponse{
|
||||
Resources: make([]*mcp.MCPResource, 0),
|
||||
}
|
||||
|
||||
return listResourcesResp, nil
|
||||
}
|
||||
|
||||
// ReadResourceHandler returns the resource details for a specific resource in model context protocol
|
||||
func (a *ModelContextProtocolAPI) ReadResourceHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||
var readResourceReq mcp.MCPReadResourceRequest
|
||||
|
||||
if jsonRPCRequest.Params != nil {
|
||||
if err := json.Unmarshal(jsonRPCRequest.Params, &readResourceReq); err != nil {
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
} else {
|
||||
return nil, errs.ErrIncompleteOrIncorrectSubmission
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[model_context_protocols.ReadResourceHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
return nil, errs.ErrApiNotFound
|
||||
}
|
||||
|
||||
// ListToolsHandler returns the list of tools for model context protocol
|
||||
func (a *ModelContextProtocolAPI) ListToolsHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[model_context_protocols.ListToolsHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
mcpVersion := a.getMCPVersion(c)
|
||||
toolsInfo := mcp.Container.GetMCPTools()
|
||||
finalToolsInfos := make([]*mcp.MCPTool, len(toolsInfo))
|
||||
|
||||
for i := 0; i < len(toolsInfo); i++ {
|
||||
finalToolsInfos[i] = &mcp.MCPTool{
|
||||
Name: toolsInfo[i].Name,
|
||||
InputSchema: toolsInfo[i].InputSchema,
|
||||
Title: toolsInfo[i].Title,
|
||||
Description: toolsInfo[i].Description,
|
||||
}
|
||||
|
||||
if mcpVersion >= string(mcp.ToolResultStructuredContentMinVersion) {
|
||||
finalToolsInfos[i].OutputSchema = toolsInfo[i].OutputSchema
|
||||
}
|
||||
}
|
||||
|
||||
listToolsResp := mcp.MCPListToolsResponse{
|
||||
Tools: finalToolsInfos,
|
||||
}
|
||||
|
||||
return listToolsResp, nil
|
||||
}
|
||||
|
||||
// CallToolHandler returns the result of calling a specific tool for model context protocol
|
||||
func (a *ModelContextProtocolAPI) CallToolHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[model_context_protocols.CallToolHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
var callToolReq mcp.MCPCallToolRequest
|
||||
|
||||
if jsonRPCRequest.Params != nil {
|
||||
if err := json.Unmarshal(jsonRPCRequest.Params, &callToolReq); err != nil {
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
} else {
|
||||
return nil, errs.ErrIncompleteOrIncorrectSubmission
|
||||
}
|
||||
|
||||
result, err := mcp.Container.HandleTool(c, &callToolReq, user, a.CurrentConfig(), a)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PingHandler return the ping response for model context protocol
|
||||
func (a *ModelContextProtocolAPI) PingHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||
return core.O{}, nil
|
||||
}
|
||||
|
||||
// GetTransactionService implements the MCPAvailableServices interface
|
||||
func (a *ModelContextProtocolAPI) GetTransactionService() *services.TransactionService {
|
||||
return a.transactions
|
||||
}
|
||||
|
||||
// GetTransactionCategoryService implements the MCPAvailableServices interface
|
||||
func (a *ModelContextProtocolAPI) GetTransactionCategoryService() *services.TransactionCategoryService {
|
||||
return a.transactionCategories
|
||||
}
|
||||
|
||||
// GetTransactionTagService implements the MCPAvailableServices interface
|
||||
func (a *ModelContextProtocolAPI) GetTransactionTagService() *services.TransactionTagService {
|
||||
return a.transactionTags
|
||||
}
|
||||
|
||||
// GetAccountService implements the MCPAvailableServices interface
|
||||
func (a *ModelContextProtocolAPI) GetAccountService() *services.AccountService {
|
||||
return a.accounts
|
||||
}
|
||||
|
||||
// GetUserService implements the MCPAvailableServices interface
|
||||
func (a *ModelContextProtocolAPI) GetUserService() *services.UserService {
|
||||
return a.users
|
||||
}
|
||||
|
||||
// getMCPVersion returns the MCP protocol version from the request header
|
||||
func (a *ModelContextProtocolAPI) getMCPVersion(c *core.WebContext) string {
|
||||
return c.GetHeader(mcp.MCPProtocolVersionHeaderName)
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/locales"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const oauth2CallbackPageUrlSuccessFormat = "%sdesktop#/oauth2_callback?platform=%s&provider=%s&token=%s"
|
||||
const oauth2CallbackPageUrlNeedVerifyFormat = "%sdesktop#/oauth2_callback?platform=%s&provider=%s&userName=%s&token=%s"
|
||||
const oauth2CallbackPageUrlFailedFormat = "%sdesktop#/oauth2_callback?errorCode=%d&errorMessage=%s"
|
||||
const oauth2CallbackPageUrlErrorMessageFormat = "%sdesktop#/oauth2_callback?errorMessage=%s"
|
||||
|
||||
// OAuth2AuthenticationApi represents OAuth 2.0 authorization api
|
||||
type OAuth2AuthenticationApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
users *services.UserService
|
||||
tokens *services.TokenService
|
||||
userExternalAuths *services.UserExternalAuthService
|
||||
}
|
||||
|
||||
// Initialize a OAuth 2.0 authentication api singleton instance
|
||||
var (
|
||||
OAuth2Authentications = &OAuth2AuthenticationApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
users: services.Users,
|
||||
tokens: services.Tokens,
|
||||
userExternalAuths: services.UserExternalAuths,
|
||||
}
|
||||
)
|
||||
|
||||
// LoginHandler handles user login request via OAuth 2.0
|
||||
func (a *OAuth2AuthenticationApi) LoginHandler(c *core.WebContext) (string, *errs.Error) {
|
||||
var oauth2LoginReq models.OAuth2LoginRequest
|
||||
err := c.ShouldBindQuery(&oauth2LoginReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[oauth2_authentications.LoginHandler] parse request failed, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.NewIncompleteOrIncorrectSubmissionError(err))
|
||||
}
|
||||
|
||||
if oauth2LoginReq.Platform != "mobile" && oauth2LoginReq.Platform != "desktop" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2LoginRequest)
|
||||
}
|
||||
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, oauth2LoginReq.ClientSessionId)
|
||||
|
||||
if found {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] another oauth 2.0 state \"%s\" has been processing for client session id \"%s\"", remark, oauth2LoginReq.ClientSessionId)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrRepeatedRequest)
|
||||
}
|
||||
|
||||
uid := int64(0)
|
||||
|
||||
if oauth2LoginReq.Token != "" {
|
||||
_, claims, _, err := a.tokens.ParseToken(c, oauth2LoginReq.Token)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to parse token, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidToken)
|
||||
}
|
||||
|
||||
uid = claims.Uid
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrUserNotFound) {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to get user by id %d, because %s", uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_OAUTH2_LOGIN) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrNotPermittedToPerformThisAction)
|
||||
}
|
||||
}
|
||||
|
||||
verifier, err := utils.GetRandomNumberOrLowercaseLetter(64)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to generate random string for oauth 2.0 state, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrSystemError)
|
||||
}
|
||||
|
||||
remark = fmt.Sprintf("%s|%s|%d|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId, uid, verifier)
|
||||
state := fmt.Sprintf("%s|%s|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId, utils.MD5EncodeToString([]byte(remark)))
|
||||
|
||||
redirectUrl, err := oauth2.GetOAuth2AuthUrl(c, state, verifier)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to get oauth 2.0 auth url, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrSystemError))
|
||||
}
|
||||
|
||||
a.SetSubmissionRemarkWithCustomExpiration(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, oauth2LoginReq.ClientSessionId, remark, a.CurrentConfig().OAuth2StateExpiredTimeDuration)
|
||||
|
||||
return redirectUrl, nil
|
||||
}
|
||||
|
||||
// CallbackHandler handles OAuth 2.0 callback request
|
||||
func (a *OAuth2AuthenticationApi) CallbackHandler(c *core.WebContext) (string, *errs.Error) {
|
||||
var oauth2CallbackReq models.OAuth2CallbackRequest
|
||||
err := c.ShouldBindQuery(&oauth2CallbackReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[oauth2_authentications.CallbackHandler] parse request failed, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.NewIncompleteOrIncorrectSubmissionError(err))
|
||||
}
|
||||
|
||||
if oauth2CallbackReq.State == "" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrMissingOAuth2State)
|
||||
}
|
||||
|
||||
if oauth2CallbackReq.Code == "" {
|
||||
if oauth2CallbackReq.ErrorDescription != "" {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] oauth 2.0 provider returned error: %s, description: %s", oauth2CallbackReq.Error, oauth2CallbackReq.ErrorDescription)
|
||||
return a.redirectToErrorMessageCallbackPage(c, oauth2CallbackReq.ErrorDescription)
|
||||
}
|
||||
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrMissingOAuth2Code)
|
||||
}
|
||||
|
||||
platform := ""
|
||||
clientSessionId := ""
|
||||
|
||||
stateParts := strings.Split(oauth2CallbackReq.State, "|")
|
||||
|
||||
if len(stateParts) == 3 {
|
||||
platform = stateParts[0]
|
||||
clientSessionId = stateParts[1]
|
||||
} else {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
|
||||
}
|
||||
|
||||
if platform != "mobile" && platform != "desktop" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2LoginRequest)
|
||||
}
|
||||
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, clientSessionId)
|
||||
|
||||
if !found {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] cannot find oauth 2.0 state in duplicate checker for client session id \"%s\"", clientSessionId)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2Callback)
|
||||
}
|
||||
|
||||
remarkParts := strings.Split(remark, "|")
|
||||
|
||||
if len(remarkParts) != 4 || remarkParts[0] != platform || remarkParts[1] != clientSessionId {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 state \"%s\" in duplicate checker for client session id \"%s\"", remark, clientSessionId)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
|
||||
}
|
||||
|
||||
uid, err := utils.StringToInt64(remarkParts[2])
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid uid \"%s\" in oauth 2.0 state \"%s\"", remarkParts[2], remark)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
|
||||
}
|
||||
|
||||
verifier := remarkParts[3]
|
||||
expectedRemark := fmt.Sprintf("%s|%s|%d|%s", platform, clientSessionId, uid, verifier)
|
||||
expectedState := fmt.Sprintf("%s|%s|%s", platform, clientSessionId, utils.MD5EncodeToString([]byte(expectedRemark)))
|
||||
|
||||
if oauth2CallbackReq.State != expectedState {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] mismatched random string in oauth 2.0 state, expected \"%s\", got \"%s\"", expectedState, oauth2CallbackReq.State)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
|
||||
}
|
||||
|
||||
a.RemoveSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, clientSessionId)
|
||||
|
||||
oauth2Token, err := oauth2.GetOAuth2Token(c, oauth2CallbackReq.Code, verifier)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 token, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrCannotRetrieveOAuth2Token))
|
||||
}
|
||||
|
||||
oauth2UserInfo, err := oauth2.GetOAuth2UserInfo(c, oauth2Token)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 user info, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrInvalidOAuth2Token))
|
||||
}
|
||||
|
||||
if oauth2UserInfo == nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 user info, because user info is nil")
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrCannotRetrieveUserInfo)
|
||||
}
|
||||
|
||||
log.Infof(c, "[oauth2_authentications.CallbackHandler] oauth 2.0 user info, userName: %s, email: %s", oauth2UserInfo.UserName, oauth2UserInfo.Email)
|
||||
|
||||
if oauth2UserInfo.UserName == "" && oauth2UserInfo.Email == "" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2UserNameAndEmailEmpty)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail && oauth2UserInfo.Email == "" {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 user info, email is empty")
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2EmailEmpty)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername && oauth2UserInfo.UserName == "" {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 user info, userName is empty")
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2UserNameEmpty)
|
||||
}
|
||||
|
||||
userExternalAuthType := oauth2.GetExternalUserAuthType()
|
||||
var userExternalAuth *models.UserExternalAuth
|
||||
|
||||
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail {
|
||||
userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalEmail(c, oauth2UserInfo.Email, userExternalAuthType)
|
||||
} else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername {
|
||||
userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalUserName(c, oauth2UserInfo.UserName, userExternalAuthType)
|
||||
} else {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrNotSupported)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrUserExternalAuthNotFound) {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user external auth, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
if uid != 0 && userExternalAuth != nil && userExternalAuth.Uid != uid {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] oauth 2.0 external auth has been bound to another user \"uid:%d\", current user \"uid:%d\"", userExternalAuth.Uid, uid)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2UserAlreadyBoundToAnotherUser)
|
||||
}
|
||||
|
||||
var user *models.User
|
||||
|
||||
if err == nil { // user already bound to external auth, redirect to success page
|
||||
user, err = a.users.GetUserById(c, userExternalAuth.Uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user by id %d, because %s", userExternalAuth.Uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
} else { // errors.Is(err, errs.ErrUserExternalAuthNotFound) // user not bound to external auth, try to bind or register new user
|
||||
if uid != 0 {
|
||||
user, err = a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrUserNotFound) {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user by id %d, because %s", uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
} else {
|
||||
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail {
|
||||
user, err = a.users.GetUserByEmail(c, oauth2UserInfo.Email)
|
||||
} else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername {
|
||||
user, err = a.users.GetUserByUsername(c, oauth2UserInfo.UserName)
|
||||
} else {
|
||||
err = errs.ErrNotSupported
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrUserNotFound) {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil && a.CurrentConfig().EnableUserRegister && a.CurrentConfig().OAuth2AutoRegister {
|
||||
if oauth2UserInfo.UserName == "" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2UserNameEmptyCannotRegister)
|
||||
}
|
||||
|
||||
if oauth2UserInfo.Email == "" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2EmailEmptyCannotRegister)
|
||||
}
|
||||
|
||||
userName := strings.TrimSpace(oauth2UserInfo.UserName)
|
||||
email := strings.TrimSpace(oauth2UserInfo.Email)
|
||||
nickName := strings.TrimSpace(oauth2UserInfo.NickName)
|
||||
languageCode := ""
|
||||
currencyCode := "USD"
|
||||
|
||||
if nickName == "" {
|
||||
nickName = userName
|
||||
}
|
||||
|
||||
if !utils.IsValidUsername(userName) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrUserNameIsInvalid)
|
||||
}
|
||||
|
||||
if !utils.IsValidEmail(email) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrEmailIsInvalid)
|
||||
}
|
||||
|
||||
if !utils.IsValidNickName(nickName) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrNickNameIsInvalid)
|
||||
}
|
||||
|
||||
if _, exists := locales.AllLanguages[oauth2UserInfo.LanguageCode]; exists {
|
||||
languageCode = oauth2UserInfo.LanguageCode
|
||||
}
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[oauth2UserInfo.CurrencyCode]; exists {
|
||||
currencyCode = oauth2UserInfo.CurrencyCode
|
||||
}
|
||||
|
||||
user = &models.User{
|
||||
Username: userName,
|
||||
Email: email,
|
||||
Nickname: nickName,
|
||||
Language: languageCode,
|
||||
DefaultCurrency: currencyCode,
|
||||
FirstDayOfWeek: oauth2UserInfo.FirstDayOfWeek,
|
||||
FiscalYearStart: core.FISCAL_YEAR_START_DEFAULT,
|
||||
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
|
||||
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_OAUTH2_LOGIN) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrNotPermittedToPerformThisAction)
|
||||
}
|
||||
|
||||
err = a.users.CreateUser(c, user, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
log.Infof(c, "[oauth2_authentications.CallbackHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid)
|
||||
|
||||
userExternalAuth = &models.UserExternalAuth{
|
||||
Uid: user.Uid,
|
||||
ExternalAuthType: userExternalAuthType,
|
||||
ExternalUsername: oauth2UserInfo.UserName,
|
||||
ExternalEmail: oauth2UserInfo.Email,
|
||||
}
|
||||
|
||||
err = a.userExternalAuths.CreateUserExternalAuth(c, userExternalAuth)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create user external auth for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
log.Infof(c, "[oauth2_authentications.CallbackHandler] user external auth has been created for user \"uid:%d\"", user.Uid)
|
||||
} else if user == nil {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2AutoRegistrationNotEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_OAUTH2_LOGIN) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrNotPermittedToPerformThisAction)
|
||||
}
|
||||
|
||||
if userExternalAuth == nil {
|
||||
tokenContext, err := json.Marshal(&models.OAuth2CallbackTokenContext{
|
||||
ExternalAuthType: userExternalAuthType,
|
||||
ExternalUsername: oauth2UserInfo.UserName,
|
||||
ExternalEmail: oauth2UserInfo.Email,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to marshal oauth 2.0 callback verify token context, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
token, _, err := a.tokens.CreateOAuth2CallbackRequireVerifyToken(c, user, string(tokenContext))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create oauth 2.0 callback verify token, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrTokenGenerating)
|
||||
}
|
||||
|
||||
return a.redirectToVerifyCallbackPage(c, platform, userExternalAuthType, user.Username, token)
|
||||
} else {
|
||||
tokenContext, err := json.Marshal(&models.OAuth2CallbackTokenContext{
|
||||
ExternalAuthType: userExternalAuthType,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to marshal oauth 2.0 callback token context, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
token, _, err := a.tokens.CreateOAuth2CallbackToken(c, user, string(tokenContext))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create oauth 2.0 callback token, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrTokenGenerating)
|
||||
}
|
||||
|
||||
return a.redirectToSuccessCallbackPage(c, platform, userExternalAuthType, token)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *OAuth2AuthenticationApi) redirectToSuccessCallbackPage(c *core.WebContext, platform string, externalAuthType core.UserExternalAuthType, token string) (string, *errs.Error) {
|
||||
return fmt.Sprintf(oauth2CallbackPageUrlSuccessFormat, a.CurrentConfig().RootUrl, platform, externalAuthType, url.QueryEscape(token)), nil
|
||||
}
|
||||
|
||||
func (a *OAuth2AuthenticationApi) redirectToVerifyCallbackPage(c *core.WebContext, platform string, externalAuthType core.UserExternalAuthType, userName string, token string) (string, *errs.Error) {
|
||||
return fmt.Sprintf(oauth2CallbackPageUrlNeedVerifyFormat, a.CurrentConfig().RootUrl, platform, externalAuthType, userName, url.QueryEscape(token)), nil
|
||||
}
|
||||
|
||||
func (a *OAuth2AuthenticationApi) redirectToFailedCallbackPage(c *core.WebContext, err *errs.Error) (string, *errs.Error) {
|
||||
return fmt.Sprintf(oauth2CallbackPageUrlFailedFormat, a.CurrentConfig().RootUrl, err.Code(), url.QueryEscape(utils.GetDisplayErrorMessage(err))), nil
|
||||
}
|
||||
|
||||
func (a *OAuth2AuthenticationApi) redirectToErrorMessageCallbackPage(c *core.WebContext, message string) (string, *errs.Error) {
|
||||
return fmt.Sprintf(oauth2CallbackPageUrlErrorMessageFormat, a.CurrentConfig().RootUrl, url.QueryEscape(message)), nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/png"
|
||||
|
||||
"github.com/boombuler/barcode"
|
||||
"github.com/boombuler/barcode/qr"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
qrCodeDefaultWidth int = 320
|
||||
qrCodeDefaultHeight int = 320
|
||||
)
|
||||
|
||||
// QrCodesApi represents qrcode generator api
|
||||
type QrCodesApi struct {
|
||||
ApiUsingConfig
|
||||
}
|
||||
|
||||
// Initialize a qrcode generator api singleton instance
|
||||
var (
|
||||
QrCodes = &QrCodesApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// MobileUrlQrCodeHandler returns a mobile url qr code image
|
||||
func (a *QrCodesApi) MobileUrlQrCodeHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
|
||||
fullUrl := a.CurrentConfig().RootUrl + "mobile"
|
||||
data, err := a.generateUrlQrCode(c, fullUrl)
|
||||
|
||||
if err != nil {
|
||||
return nil, "", errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
return data, "image/png", nil
|
||||
}
|
||||
|
||||
func (a *QrCodesApi) generateUrlQrCode(c *core.WebContext, url string) ([]byte, *errs.Error) {
|
||||
qrCodeImg, _ := qr.Encode(url, qr.M, qr.Auto)
|
||||
qrCodeImg, _ = barcode.Scale(qrCodeImg, qrCodeDefaultWidth, qrCodeDefaultHeight)
|
||||
imgData := &bytes.Buffer{}
|
||||
|
||||
if err := png.Encode(imgData, qrCodeImg); err != nil {
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
return imgData.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const ezbookkeepingServerSettingsGlobalVariableName = "EZBOOKKEEPING_SERVER_SETTINGS"
|
||||
const ezbookkeepingServerSettingsGlobalVariableFullName = "window." + ezbookkeepingServerSettingsGlobalVariableName
|
||||
const ezbookkeepingServerSettingsJavascriptFileHeader = ezbookkeepingServerSettingsGlobalVariableFullName +
|
||||
"=" + ezbookkeepingServerSettingsGlobalVariableFullName + "||{};\n"
|
||||
|
||||
// ServerSettingsApi represents server settings api
|
||||
type ServerSettingsApi struct {
|
||||
ApiUsingConfig
|
||||
}
|
||||
|
||||
// Initialize a server settings api singleton instance
|
||||
var (
|
||||
ServerSettings = &ServerSettingsApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// ServerSettingsJavascriptHandler returns the javascript contains server settings
|
||||
func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
|
||||
config := a.CurrentConfig()
|
||||
builder := &strings.Builder{}
|
||||
builder.WriteString(ezbookkeepingServerSettingsJavascriptFileHeader)
|
||||
|
||||
a.appendBooleanSetting(builder, "a", config.EnableInternalAuth)
|
||||
a.appendBooleanSetting(builder, "o", config.EnableOAuth2Login)
|
||||
a.appendBooleanSetting(builder, "r", config.EnableInternalAuth && config.EnableUserRegister)
|
||||
a.appendBooleanSetting(builder, "f", config.EnableInternalAuth && config.EnableUserForgetPassword)
|
||||
a.appendBooleanSetting(builder, "t", config.EnableAPIToken)
|
||||
a.appendBooleanSetting(builder, "v", config.EnableUserVerifyEmail)
|
||||
a.appendBooleanSetting(builder, "p", config.EnableTransactionPictures)
|
||||
a.appendBooleanSetting(builder, "s", config.EnableScheduledTransaction)
|
||||
a.appendBooleanSetting(builder, "e", config.EnableDataExport)
|
||||
a.appendBooleanSetting(builder, "i", config.EnableDataImport)
|
||||
|
||||
a.appendStringSetting(builder, "op", config.OAuth2Provider)
|
||||
|
||||
if config.OAuth2Provider == settings.OAuth2ProviderOIDC && config.OAuth2OIDCCustomDisplayNameConfig.Enabled {
|
||||
a.appendMultiLanguageTipSetting(builder, "ocn", config.OAuth2OIDCCustomDisplayNameConfig)
|
||||
}
|
||||
|
||||
if config.EnableMCPServer {
|
||||
a.appendBooleanSetting(builder, "mcp", config.EnableMCPServer)
|
||||
}
|
||||
|
||||
if config.ReceiptImageRecognitionLLMConfig != nil && config.ReceiptImageRecognitionLLMConfig.LLMProvider != "" {
|
||||
if config.TransactionFromAIImageRecognition {
|
||||
a.appendBooleanSetting(builder, "llmt", config.TransactionFromAIImageRecognition)
|
||||
}
|
||||
}
|
||||
|
||||
if config.LoginPageTips.Enabled {
|
||||
a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips)
|
||||
}
|
||||
|
||||
a.appendStringSetting(builder, "m", config.MapProvider)
|
||||
|
||||
if config.EnableMapDataFetchProxy &&
|
||||
(config.MapProvider == settings.OpenStreetMapProvider ||
|
||||
config.MapProvider == settings.OpenStreetMapHumanitarianStyleProvider ||
|
||||
config.MapProvider == settings.OpenTopoMapProvider ||
|
||||
config.MapProvider == settings.OPNVKarteMapProvider ||
|
||||
config.MapProvider == settings.CyclOSMMapProvider ||
|
||||
config.MapProvider == settings.CartoDBMapProvider ||
|
||||
config.MapProvider == settings.TomTomMapProvider ||
|
||||
config.MapProvider == settings.TianDiTuProvider ||
|
||||
config.MapProvider == settings.CustomProvider) {
|
||||
a.appendBooleanSetting(builder, "mp", config.EnableMapDataFetchProxy)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.CustomProvider {
|
||||
a.appendStringSetting(builder, "cmzl", fmt.Sprintf("%d-%d-%d", config.CustomMapTileServerMinZoomLevel, config.CustomMapTileServerMaxZoomLevel, config.CustomMapTileServerDefaultZoomLevel))
|
||||
|
||||
if !config.EnableMapDataFetchProxy {
|
||||
a.appendStringSetting(builder, "cmsu", config.CustomMapTileServerTileLayerUrl)
|
||||
|
||||
if config.CustomMapTileServerAnnotationLayerUrl != "" {
|
||||
a.appendStringSetting(builder, "cmau", config.CustomMapTileServerAnnotationLayerUrl)
|
||||
}
|
||||
} else {
|
||||
if config.CustomMapTileServerAnnotationLayerUrl != "" {
|
||||
a.appendBooleanSetting(builder, "cmap", config.EnableMapDataFetchProxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.TomTomMapProvider && config.TomTomMapAPIKey != "" && !config.EnableMapDataFetchProxy {
|
||||
a.appendStringSetting(builder, "tmak", config.TomTomMapAPIKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.TianDiTuProvider && config.TianDiTuAPIKey != "" && !config.EnableMapDataFetchProxy {
|
||||
a.appendStringSetting(builder, "tdak", config.TianDiTuAPIKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.GoogleMapProvider && config.GoogleMapAPIKey != "" {
|
||||
a.appendStringSetting(builder, "gmak", config.GoogleMapAPIKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.BaiduMapProvider && config.BaiduMapAK != "" {
|
||||
a.appendStringSetting(builder, "bmak", config.BaiduMapAK)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.AmapProvider && config.AmapApplicationKey != "" {
|
||||
a.appendStringSetting(builder, "amak", config.AmapApplicationKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod != "" {
|
||||
a.appendStringSetting(builder, "amsv", config.AmapSecurityVerificationMethod)
|
||||
|
||||
if config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationExternalProxyMethod {
|
||||
a.appendStringSetting(builder, "amep", config.AmapApiExternalProxyUrl)
|
||||
}
|
||||
|
||||
if config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationPlainTextMethod {
|
||||
a.appendStringSetting(builder, "amas", config.AmapApplicationSecret)
|
||||
}
|
||||
}
|
||||
|
||||
if config.ExchangeRatesRequestTimeoutExceedDefaultValue {
|
||||
a.appendIntegerSetting(builder, "errt", int(config.ExchangeRatesRequestTimeout))
|
||||
}
|
||||
|
||||
return []byte(builder.String()), "", nil
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendStringSetting(builder *strings.Builder, key string, value string) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]=")
|
||||
|
||||
a.appendEncodedString(builder, value)
|
||||
|
||||
builder.WriteString(";\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.MultiLanguageContentConfig) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]={\n")
|
||||
|
||||
builder.WriteString("'default'")
|
||||
builder.WriteRune(':')
|
||||
a.appendEncodedString(builder, value.DefaultContent)
|
||||
|
||||
for languageTag, content := range value.MultiLanguageContent {
|
||||
builder.WriteString(",\n")
|
||||
a.appendEncodedString(builder, languageTag)
|
||||
builder.WriteRune(':')
|
||||
a.appendEncodedString(builder, content)
|
||||
}
|
||||
|
||||
builder.WriteString("\n};\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendBooleanSetting(builder *strings.Builder, key string, value bool) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]=")
|
||||
|
||||
if value {
|
||||
builder.WriteRune('1')
|
||||
} else {
|
||||
builder.WriteRune('0')
|
||||
}
|
||||
|
||||
builder.WriteString(";\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendIntegerSetting(builder *strings.Builder, key string, value int) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]=")
|
||||
builder.WriteString(utils.IntToString(value))
|
||||
builder.WriteString(";\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendEncodedString(builder *strings.Builder, content string) {
|
||||
builder.WriteRune('\'')
|
||||
runes := []rune(content)
|
||||
|
||||
for i := 0; i < len(runes); i++ {
|
||||
switch runes[i] {
|
||||
case '\\':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('\\')
|
||||
case '\'':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('\'')
|
||||
case '\n':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('n')
|
||||
case '\r':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('r')
|
||||
case '\t':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('t')
|
||||
case '\f':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('f')
|
||||
case '\b':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('b')
|
||||
default:
|
||||
builder.WriteRune(runes[i])
|
||||
}
|
||||
}
|
||||
|
||||
builder.WriteRune('\'')
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
)
|
||||
|
||||
// SystemsApi represents system api
|
||||
type SystemsApi struct{}
|
||||
|
||||
// Initialize a system api singleton instance
|
||||
var (
|
||||
Systems = &SystemsApi{}
|
||||
)
|
||||
|
||||
// VersionHandler returns the server version and commit hash
|
||||
func (a *SystemsApi) VersionHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
result := make(map[string]string)
|
||||
|
||||
result["version"] = core.Version
|
||||
result["commitHash"] = core.CommitHash
|
||||
|
||||
if core.BuildTime != "" {
|
||||
result["buildTime"] = core.BuildTime
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
+252
-46
@@ -2,37 +2,55 @@ package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// TokensApi represents token api
|
||||
type TokensApi struct {
|
||||
tokens *services.TokenService
|
||||
users *services.UserService
|
||||
ApiUsingConfig
|
||||
ApiWithUserInfo
|
||||
tokens *services.TokenService
|
||||
users *services.UserService
|
||||
userAppCloudSettings *services.UserApplicationCloudSettingsService
|
||||
}
|
||||
|
||||
// Initialize a token api singleton instance
|
||||
var (
|
||||
Tokens = &TokensApi{
|
||||
tokens: services.Tokens,
|
||||
users: services.Users,
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiWithUserInfo: ApiWithUserInfo{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingAvatarProvider: ApiUsingAvatarProvider{
|
||||
container: avatars.Container,
|
||||
},
|
||||
},
|
||||
tokens: services.Tokens,
|
||||
users: services.Users,
|
||||
userAppCloudSettings: services.UserApplicationCloudSettings,
|
||||
}
|
||||
)
|
||||
|
||||
// TokenListHandler returns available token list of current user
|
||||
func (a *TokensApi) TokenListHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
tokens, err := a.tokens.GetAllUnexpiredNormalTokensByUid(uid)
|
||||
tokens, err := a.tokens.GetAllUnexpiredNormalAndMCPTokensByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[tokens.TokenListHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[tokens.TokenListHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
tokenResps := make(models.TokenInfoResponseSlice, len(tokens))
|
||||
@@ -44,14 +62,19 @@ func (a *TokensApi) TokenListHandler(c *core.Context) (interface{}, *errs.Error)
|
||||
TokenId: a.tokens.GenerateTokenId(token),
|
||||
TokenType: token.TokenType,
|
||||
UserAgent: token.UserAgent,
|
||||
CreatedAt: token.CreatedUnixTime,
|
||||
ExpiredAt: token.ExpiredUnixTime,
|
||||
LastSeen: token.LastSeenUnixTime,
|
||||
}
|
||||
|
||||
if utils.Int64ToString(token.Uid) == claims.Id && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt {
|
||||
if token.Uid == claims.Uid && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt {
|
||||
tokenResp.IsCurrent = true
|
||||
}
|
||||
|
||||
if token.TokenType == core.USER_TOKEN_TYPE_API && token.UserAgent != core.TokenUserAgentCreatedViaCli {
|
||||
tokenResp.UserAgent = core.TokenUserAgentForAPI
|
||||
} else if token.TokenType == core.USER_TOKEN_TYPE_MCP && token.UserAgent != core.TokenUserAgentCreatedViaCli {
|
||||
tokenResp.UserAgent = core.TokenUserAgentForMCP
|
||||
}
|
||||
|
||||
tokenResps[i] = tokenResp
|
||||
}
|
||||
|
||||
@@ -60,53 +83,146 @@ func (a *TokensApi) TokenListHandler(c *core.Context) (interface{}, *errs.Error)
|
||||
return tokenResps, nil
|
||||
}
|
||||
|
||||
// TokenRevokeCurrentHandler revokes current token of current user
|
||||
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
_, claims, err := a.tokens.ParseToken(c)
|
||||
// TokenGenerateAPIHandler generates a new API token for current user
|
||||
func (a *TokensApi) TokenGenerateAPIHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableAPIToken {
|
||||
return nil, errs.ErrAPITokenNotEnabled
|
||||
}
|
||||
|
||||
var generateAPITokenReq models.TokenGenerateAPIRequest
|
||||
err := c.ShouldBindJSON(&generateAPITokenReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[tokens.TokenGenerateAPIHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid, err := utils.StringToInt64(claims.Id)
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[tokens.TokenRevokeCurrentHandler] parse user id failed, because %s", err.Error())
|
||||
log.Warnf(c, "[tokens.TokenGenerateAPIHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_GENERATE_API_TOKEN) {
|
||||
return false, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(generateAPITokenReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateAPIToken(c, user, generateAPITokenReq.ExpiredInSeconds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[tokens.TokenGenerateAPIHandler] failed to create api token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrTokenGenerating)
|
||||
}
|
||||
|
||||
log.Infof(c, "[tokens.TokenGenerateAPIHandler] user \"uid:%d\" has generated api token, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
generateAPITokenResp := &models.TokenGenerateAPIResponse{
|
||||
Token: token,
|
||||
APIBaseUrl: a.CurrentConfig().RootUrl + "api",
|
||||
}
|
||||
|
||||
return generateAPITokenResp, nil
|
||||
}
|
||||
|
||||
// TokenGenerateMCPHandler generates a new MCP token for current user
|
||||
func (a *TokensApi) TokenGenerateMCPHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableMCPServer {
|
||||
return nil, errs.ErrMCPServerNotEnabled
|
||||
}
|
||||
|
||||
var generateMCPTokenReq models.TokenGenerateMCPRequest
|
||||
err := c.ShouldBindJSON(&generateMCPTokenReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[tokens.TokenGenerateMCPHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[tokens.TokenGenerateMCPHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||
return false, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(generateMCPTokenReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateMCPToken(c, user, generateMCPTokenReq.ExpiredInSeconds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[tokens.TokenGenerateMCPHandler] failed to create mcp token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrTokenGenerating)
|
||||
}
|
||||
|
||||
log.Infof(c, "[tokens.TokenGenerateMCPHandler] user \"uid:%d\" has generated mcp token, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
generateMCPTokenResp := &models.TokenGenerateMCPResponse{
|
||||
Token: token,
|
||||
MCPUrl: a.CurrentConfig().RootUrl + "mcp",
|
||||
}
|
||||
|
||||
return generateMCPTokenResp, nil
|
||||
}
|
||||
|
||||
// TokenRevokeCurrentHandler revokes current token of current user
|
||||
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
tokenString := c.GetTokenStringFromHeader()
|
||||
|
||||
if tokenString == "" {
|
||||
return false, errs.ErrTokenIsEmpty
|
||||
}
|
||||
|
||||
_, claims, _, err := a.tokens.ParseToken(c, tokenString)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.NewIncompleteOrIncorrectSubmissionError(err))
|
||||
}
|
||||
|
||||
userTokenId, err := utils.StringToInt64(claims.UserTokenId)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[tokens.TokenRevokeCurrentHandler] parse user token id failed, because %s", err.Error())
|
||||
log.Warnf(c, "[tokens.TokenRevokeCurrentHandler] parse user token id failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
tokenRecord := &models.TokenRecord{
|
||||
Uid: uid,
|
||||
Uid: claims.Uid,
|
||||
UserTokenId: userTokenId,
|
||||
CreatedUnixTime: claims.IssuedAt,
|
||||
}
|
||||
|
||||
tokenId := a.tokens.GenerateTokenId(tokenRecord)
|
||||
err = a.tokens.DeleteToken(tokenRecord)
|
||||
err = a.tokens.DeleteToken(c, tokenRecord)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[token.TokenRevokeCurrentHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
|
||||
log.Errorf(c, "[tokens.TokenRevokeCurrentHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, claims.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[token.TokenRevokeCurrentHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenId)
|
||||
log.Infof(c, "[tokens.TokenRevokeCurrentHandler] user \"uid:%d\" has revoked token \"id:%s\"", claims.Uid, tokenId)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TokenRevokeHandler revokes specific token of current user
|
||||
func (a *TokensApi) TokenRevokeHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tokenRevokeReq models.TokenRevokeRequest
|
||||
err := c.ShouldBindJSON(&tokenRevokeReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[tokens.TokenRevokeHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[tokens.TokenRevokeHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
@@ -114,7 +230,7 @@ func (a *TokensApi) TokenRevokeHandler(c *core.Context) (interface{}, *errs.Erro
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.ErrorfWithRequestId(c, "[token.TokenRevokeHandler] failed to parse token \"id:%s\", because %s", tokenRevokeReq.TokenId, err.Error())
|
||||
log.Errorf(c, "[tokens.TokenRevokeHandler] failed to parse token \"id:%s\", because %s", tokenRevokeReq.TokenId, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.Or(err, errs.ErrInvalidTokenId)
|
||||
@@ -123,29 +239,45 @@ func (a *TokensApi) TokenRevokeHandler(c *core.Context) (interface{}, *errs.Erro
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
if tokenRecord.Uid != uid {
|
||||
log.WarnfWithRequestId(c, "[token.TokenRevokeHandler] token \"id:%s\" is not owned by user \"uid:%d\"", tokenRevokeReq.TokenId, uid)
|
||||
log.Warnf(c, "[tokens.TokenRevokeHandler] token \"id:%s\" is not owned by user \"uid:%d\"", tokenRevokeReq.TokenId, uid)
|
||||
return nil, errs.ErrInvalidTokenId
|
||||
}
|
||||
|
||||
err = a.tokens.DeleteToken(tokenRecord)
|
||||
if utils.Int64ToString(tokenRecord.UserTokenId) != c.GetTokenClaims().UserTokenId || tokenRecord.CreatedUnixTime != c.GetTokenClaims().IssuedAt {
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[tokens.TokenRevokeHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
}
|
||||
|
||||
err = a.tokens.DeleteToken(c, tokenRecord)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[token.TokenRevokeHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenRevokeReq.TokenId, uid, err.Error())
|
||||
log.Errorf(c, "[tokens.TokenRevokeHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenRevokeReq.TokenId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[token.TokenRevokeHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenRevokeReq.TokenId)
|
||||
log.Infof(c, "[tokens.TokenRevokeHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenRevokeReq.TokenId)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TokenRevokeAllHandler revokes all tokens of current user except current token
|
||||
func (a *TokensApi) TokenRevokeAllHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TokensApi) TokenRevokeAllHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
tokens, err := a.tokens.GetAllTokensByUid(uid)
|
||||
tokens, err := a.tokens.GetAllTokensByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[tokens.TokenRevokeAllHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[tokens.TokenRevokeAllHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
claims := c.GetTokenClaims()
|
||||
@@ -154,7 +286,7 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.Context) (interface{}, *errs.E
|
||||
for i := 0; i < len(tokens); i++ {
|
||||
token := tokens[i]
|
||||
|
||||
if utils.Int64ToString(token.Uid) == claims.Id && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt {
|
||||
if token.Uid == claims.Uid && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt {
|
||||
currentTokenIndex = i
|
||||
break
|
||||
}
|
||||
@@ -162,35 +294,96 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.Context) (interface{}, *errs.E
|
||||
|
||||
tokens = append(tokens[:currentTokenIndex], tokens[currentTokenIndex+1:]...)
|
||||
|
||||
err = a.tokens.DeleteTokens(uid, tokens)
|
||||
if len(tokens) < 1 {
|
||||
return nil, errs.ErrTokenRecordNotFound
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[token.TokenRevokeAllHandler] failed to revoke all tokens for user \"uid:%d\", because %s", uid, err.Error())
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[tokens.TokenRevokeAllHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
err = a.tokens.DeleteTokens(c, uid, tokens)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[tokens.TokenRevokeAllHandler] failed to revoke all tokens for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[token.TokenRevokeAllHandler] user \"uid:%d\" has revoked all tokens", uid)
|
||||
log.Infof(c, "[tokens.TokenRevokeAllHandler] user \"uid:%d\" has revoked all tokens", uid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TokenRefreshHandler refresh current token of current user
|
||||
func (a *TokensApi) TokenRefreshHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(uid)
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[token.TokenRefreshHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateToken(user, c)
|
||||
now := time.Now().Unix()
|
||||
oldTokenClaims := c.GetTokenClaims()
|
||||
|
||||
if now-oldTokenClaims.IssuedAt < int64(a.CurrentConfig().TokenMinRefreshInterval) {
|
||||
log.Infof(c, "[tokens.TokenRefreshHandler] token of user \"uid:%d\" does not need to be refreshed", uid)
|
||||
|
||||
userTokenId, err := utils.StringToInt64(oldTokenClaims.UserTokenId)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[tokens.TokenRefreshHandler] parse user token id failed, because %s", err.Error())
|
||||
} else {
|
||||
tokenRecord := &models.TokenRecord{
|
||||
Uid: oldTokenClaims.Uid,
|
||||
UserTokenId: userTokenId,
|
||||
CreatedUnixTime: oldTokenClaims.IssuedAt,
|
||||
}
|
||||
|
||||
tokenId := a.tokens.GenerateTokenId(tokenRecord)
|
||||
|
||||
err = a.tokens.UpdateTokenLastSeen(c, tokenRecord)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||
}
|
||||
|
||||
refreshResp := &models.TokenRefreshResponse{
|
||||
User: a.GetUserBasicInfo(user),
|
||||
ApplicationCloudSettings: applicationCloudSettingSlice,
|
||||
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
|
||||
}
|
||||
|
||||
return refreshResp, nil
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[token.TokenRefreshHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Errorf(c, "[tokens.TokenRefreshHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrTokenGenerating)
|
||||
}
|
||||
|
||||
oldTokenClaims := c.GetTokenClaims()
|
||||
oldUserTokenId, _ := utils.StringToInt64(oldTokenClaims.UserTokenId)
|
||||
oldTokenRecord := &models.TokenRecord{
|
||||
Uid: uid,
|
||||
@@ -198,14 +391,27 @@ func (a *TokensApi) TokenRefreshHandler(c *core.Context) (interface{}, *errs.Err
|
||||
CreatedUnixTime: oldTokenClaims.IssuedAt,
|
||||
}
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.InfofWithRequestId(c, "[token.TokenRefreshHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||
}
|
||||
|
||||
log.Infof(c, "[tokens.TokenRefreshHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
refreshResp := &models.TokenRefreshResponse{
|
||||
NewToken: token,
|
||||
OldTokenId: a.tokens.GenerateTokenId(oldTokenRecord),
|
||||
User: user.ToUserBasicInfo(),
|
||||
NewToken: token,
|
||||
OldTokenId: a.tokens.GenerateTokenId(oldTokenRecord),
|
||||
User: a.GetUserBasicInfo(user),
|
||||
ApplicationCloudSettings: applicationCloudSettingSlice,
|
||||
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
|
||||
}
|
||||
|
||||
return refreshResp, nil
|
||||
|
||||
+198
-106
@@ -3,62 +3,78 @@ package api
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// TransactionCategoriesApi represents transaction category api
|
||||
type TransactionCategoriesApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
categories *services.TransactionCategoryService
|
||||
}
|
||||
|
||||
// Initialize a transaction category api singleton instance
|
||||
var (
|
||||
TransactionCategories = &TransactionCategoriesApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
categories: services.TransactionCategories,
|
||||
}
|
||||
)
|
||||
|
||||
// CategoryListHandler returns transaction category list of current user
|
||||
func (a *TransactionCategoriesApi) CategoryListHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionCategoriesApi) CategoryListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var categoryListReq models.TransactionCategoryListRequest
|
||||
err := c.ShouldBindQuery(&categoryListReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_categories.CategoryListHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_categories.CategoryListHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
categories, err := a.categories.GetAllCategoriesByUid(uid, categoryListReq.Type, categoryListReq.ParentId)
|
||||
categories, err := a.categories.GetAllCategoriesByUid(c, uid, categoryListReq.Type, categoryListReq.ParentId)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryListHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[transaction_categories.CategoryListHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return a.getTransactionCategoryListByTypeResponse(categories, categoryListReq.ParentId)
|
||||
}
|
||||
|
||||
// CategoryGetHandler returns one specific transaction category of current user
|
||||
func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var categoryGetReq models.TransactionCategoryGetRequest
|
||||
err := c.ShouldBindQuery(&categoryGetReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_categories.CategoryGetHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_categories.CategoryGetHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
category, err := a.categories.GetCategoryByCategoryId(uid, categoryGetReq.Id)
|
||||
category, err := a.categories.GetCategoryByCategoryId(c, uid, categoryGetReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryGetHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryGetReq.Id, uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[transaction_categories.CategoryGetHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryGetReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
categoryResp := category.ToTransactionCategoryInfoResponse()
|
||||
@@ -67,161 +83,146 @@ func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.Context) (interfac
|
||||
}
|
||||
|
||||
// CategoryCreateHandler saves a new transaction category by request parameters for current user
|
||||
func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var categoryCreateReq models.TransactionCategoryCreateRequest
|
||||
err := c.ShouldBindJSON(&categoryCreateReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
if categoryCreateReq.Type < models.CATEGORY_TYPE_INCOME || categoryCreateReq.Type > models.CATEGORY_TYPE_TRANSFER {
|
||||
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] category type invalid, type is %d", categoryCreateReq.Type)
|
||||
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] category type invalid, type is %d", categoryCreateReq.Type)
|
||||
return nil, errs.ErrTransactionCategoryTypeInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
if categoryCreateReq.ParentId > 0 {
|
||||
parentCategory, err := a.categories.GetCategoryByCategoryId(uid, categoryCreateReq.ParentId)
|
||||
parentCategory, err := a.categories.GetCategoryByCategoryId(c, uid, categoryCreateReq.ParentId)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to get parent category \"id:%d\" for user \"uid:%d\", because %s", categoryCreateReq.ParentId, uid, err.Error())
|
||||
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to get parent category \"id:%d\" for user \"uid:%d\", because %s", categoryCreateReq.ParentId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if parentCategory == nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" does not exist for user \"uid:%d\"", categoryCreateReq.ParentId, uid)
|
||||
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" does not exist for user \"uid:%d\"", categoryCreateReq.ParentId, uid)
|
||||
return nil, errs.ErrParentTransactionCategoryNotFound
|
||||
}
|
||||
|
||||
if parentCategory.ParentCategoryId > 0 {
|
||||
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" has another parent category \"id:%d\" for user \"uid:%d\"", parentCategory.CategoryId, parentCategory.ParentCategoryId, uid)
|
||||
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" has another parent category \"id:%d\" for user \"uid:%d\"", parentCategory.CategoryId, parentCategory.ParentCategoryId, uid)
|
||||
return nil, errs.ErrCannotAddToSecondaryTransactionCategory
|
||||
}
|
||||
}
|
||||
|
||||
var maxOrderId int
|
||||
var maxOrderId int32
|
||||
|
||||
if categoryCreateReq.ParentId <= 0 {
|
||||
maxOrderId, err = a.categories.GetMaxDisplayOrder(uid, categoryCreateReq.Type)
|
||||
maxOrderId, err = a.categories.GetMaxDisplayOrder(c, uid, categoryCreateReq.Type)
|
||||
} else {
|
||||
maxOrderId, err = a.categories.GetMaxSubCategoryDisplayOrder(uid, categoryCreateReq.Type, categoryCreateReq.ParentId)
|
||||
maxOrderId, err = a.categories.GetMaxSubCategoryDisplayOrder(c, uid, categoryCreateReq.Type, categoryCreateReq.ParentId)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
category := a.createNewCategoryModel(uid, &categoryCreateReq, maxOrderId+1)
|
||||
|
||||
err = a.categories.CreateCategory(category)
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && categoryCreateReq.ClientSessionId != "" {
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId)
|
||||
|
||||
if found {
|
||||
log.Infof(c, "[transaction_categories.CategoryCreateHandler] another category \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
|
||||
categoryId, err := utils.StringToInt64(remark)
|
||||
|
||||
if err == nil {
|
||||
category, err = a.categories.GetCategoryByCategoryId(c, uid, categoryId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to get existed category \"id:%d\" for user \"uid:%d\", because %s", categoryId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
categoryResp := category.ToTransactionCategoryInfoResponse()
|
||||
|
||||
return categoryResp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = a.categories.CreateCategory(c, category)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to create category \"id:%d\" for user \"uid:%d\", because %s", category.CategoryId, uid, err.Error())
|
||||
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to create category \"id:%d\" for user \"uid:%d\", because %s", category.CategoryId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_categories.CategoryCreateHandler] user \"uid:%d\" has created a new category \"id:%d\" successfully", uid, category.CategoryId)
|
||||
log.Infof(c, "[transaction_categories.CategoryCreateHandler] user \"uid:%d\" has created a new category \"id:%d\" successfully", uid, category.CategoryId)
|
||||
|
||||
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId, utils.Int64ToString(category.CategoryId))
|
||||
categoryResp := category.ToTransactionCategoryInfoResponse()
|
||||
|
||||
return categoryResp, nil
|
||||
}
|
||||
|
||||
// CategoryCreateBatchHandler saves some new transaction category by request parameters for current user
|
||||
func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var categoryCreateBatchReq models.TransactionCategoryCreateBatchRequest
|
||||
err := c.ShouldBindJSON(&categoryCreateBatchReq)
|
||||
err := c.ShouldBindBodyWith(&categoryCreateBatchReq, binding.JSON)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_categories.CategoryCreateBatchHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
categoryTypeMaxOrderMap := make(map[models.TransactionCategoryType]int)
|
||||
categoriesMap := make(map[*models.TransactionCategory][]*models.TransactionCategory)
|
||||
categoriesMap[nil] = make([]*models.TransactionCategory, len(categoryCreateBatchReq.Categories))
|
||||
totalCount := 0
|
||||
|
||||
for i := 0; i < len(categoryCreateBatchReq.Categories); i++ {
|
||||
categoryCreateReq := categoryCreateBatchReq.Categories[i]
|
||||
var maxOrderId, exists = categoryTypeMaxOrderMap[categoryCreateReq.Type]
|
||||
|
||||
if !exists {
|
||||
maxOrderId, err = a.categories.GetMaxDisplayOrder(uid, categoryCreateReq.Type)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
}
|
||||
|
||||
category := a.createNewCategoryModel(uid, &models.TransactionCategoryCreateRequest{
|
||||
Name: categoryCreateReq.Name,
|
||||
Type: categoryCreateReq.Type,
|
||||
Icon: categoryCreateReq.Icon,
|
||||
Color: categoryCreateReq.Color,
|
||||
}, maxOrderId+1)
|
||||
|
||||
categoriesMap[category] = make([]*models.TransactionCategory, len(categoryCreateReq.SubCategories))
|
||||
|
||||
for j := 0; j < len(categoryCreateReq.SubCategories); j++ {
|
||||
subCategory := a.createNewCategoryModel(uid, categoryCreateReq.SubCategories[j], j+1)
|
||||
categoriesMap[category][j] = subCategory
|
||||
totalCount++
|
||||
}
|
||||
|
||||
categoriesMap[nil][i] = category
|
||||
categoryTypeMaxOrderMap[categoryCreateReq.Type] = maxOrderId + 1
|
||||
totalCount++
|
||||
}
|
||||
|
||||
categories, err := a.categories.CreateCategories(uid, categoriesMap)
|
||||
categories, err := a.createBatchCategories(c, uid, &categoryCreateBatchReq)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] failed to create categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] user \"uid:%d\" has created categoroies successfully", uid)
|
||||
|
||||
return a.getTransactionCategoryListByTypeResponse(categories, 0)
|
||||
}
|
||||
|
||||
// CategoryModifyHandler saves an existed transaction category by request parameters for current user
|
||||
func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var categoryModifyReq models.TransactionCategoryModifyRequest
|
||||
err := c.ShouldBindJSON(&categoryModifyReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_categories.CategoryModifyHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_categories.CategoryModifyHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
category, err := a.categories.GetCategoryByCategoryId(uid, categoryModifyReq.Id)
|
||||
category, err := a.categories.GetCategoryByCategoryId(c, uid, categoryModifyReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryModifyHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
newCategory := &models.TransactionCategory{
|
||||
CategoryId: category.CategoryId,
|
||||
Uid: uid,
|
||||
Name: categoryModifyReq.Name,
|
||||
Icon: categoryModifyReq.Icon,
|
||||
Color: categoryModifyReq.Color,
|
||||
Comment: categoryModifyReq.Comment,
|
||||
Hidden: categoryModifyReq.Hidden,
|
||||
CategoryId: category.CategoryId,
|
||||
Uid: uid,
|
||||
ParentCategoryId: categoryModifyReq.ParentId,
|
||||
Name: categoryModifyReq.Name,
|
||||
DisplayOrder: category.DisplayOrder,
|
||||
Icon: categoryModifyReq.Icon,
|
||||
Color: categoryModifyReq.Color,
|
||||
Comment: categoryModifyReq.Comment,
|
||||
Hidden: categoryModifyReq.Hidden,
|
||||
}
|
||||
|
||||
if newCategory.Name == category.Name &&
|
||||
if newCategory.ParentCategoryId == category.ParentCategoryId &&
|
||||
newCategory.Name == category.Name &&
|
||||
newCategory.Icon == category.Icon &&
|
||||
newCategory.Color == category.Color &&
|
||||
newCategory.Comment == category.Comment &&
|
||||
@@ -229,52 +230,91 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.Context) (inter
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
err = a.categories.ModifyCategory(newCategory)
|
||||
if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId && newCategory.ParentCategoryId != models.LevelOneTransactionCategoryParentId {
|
||||
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionCategoryToSecondary)
|
||||
}
|
||||
|
||||
if category.ParentCategoryId != models.LevelOneTransactionCategoryParentId && newCategory.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
|
||||
return nil, errs.Or(err, errs.ErrNotAllowChangeSecondaryTransactionCategoryToPrimary)
|
||||
}
|
||||
|
||||
if newCategory.ParentCategoryId != category.ParentCategoryId {
|
||||
fromPrimaryCategory, err := a.categories.GetCategoryByCategoryId(c, uid, category.ParentCategoryId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to get old primary category \"id:%d\" of category \"id:%d\" for user \"uid:%d\", because %s", category.ParentCategoryId, categoryModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
toPrimaryCategory, err := a.categories.GetCategoryByCategoryId(c, uid, newCategory.ParentCategoryId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to get new primary category \"id:%d\" of category \"id:%d\" for user \"uid:%d\", because %s", newCategory.ParentCategoryId, categoryModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if fromPrimaryCategory.Type != toPrimaryCategory.Type {
|
||||
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionType)
|
||||
}
|
||||
|
||||
if toPrimaryCategory.ParentCategoryId != models.LevelOneTransactionCategoryParentId {
|
||||
return nil, errs.Or(err, errs.ErrNotAllowUseSecondaryTransactionAsPrimaryCategory)
|
||||
}
|
||||
|
||||
maxOrderId, err := a.categories.GetMaxSubCategoryDisplayOrder(c, uid, category.Type, newCategory.ParentCategoryId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
newCategory.DisplayOrder = maxOrderId + 1
|
||||
}
|
||||
|
||||
err = a.categories.ModifyCategory(c, newCategory)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryModifyHandler] failed to update category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
|
||||
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to update category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_categories.CategoryModifyHandler] user \"uid:%d\" has updated category \"id:%d\" successfully", uid, categoryModifyReq.Id)
|
||||
log.Infof(c, "[transaction_categories.CategoryModifyHandler] user \"uid:%d\" has updated category \"id:%d\" successfully", uid, categoryModifyReq.Id)
|
||||
|
||||
newCategory.Type = category.Type
|
||||
newCategory.ParentCategoryId = category.ParentCategoryId
|
||||
newCategory.DisplayOrder = category.DisplayOrder
|
||||
categoryResp := newCategory.ToTransactionCategoryInfoResponse()
|
||||
|
||||
return categoryResp, nil
|
||||
}
|
||||
|
||||
// CategoryHideHandler hides an existed transaction category by request parameters for current user
|
||||
func (a *TransactionCategoriesApi) CategoryHideHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionCategoriesApi) CategoryHideHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var categoryHideReq models.TransactionCategoryHideRequest
|
||||
err := c.ShouldBindJSON(&categoryHideReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_categories.CategoryHideHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_categories.CategoryHideHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.categories.HideCategory(uid, []int64{categoryHideReq.Id}, categoryHideReq.Hidden)
|
||||
err = a.categories.HideCategory(c, uid, []int64{categoryHideReq.Id}, categoryHideReq.Hidden)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryHideHandler] failed to hide category \"id:%d\" for user \"uid:%d\", because %s", categoryHideReq.Id, uid, err.Error())
|
||||
log.Errorf(c, "[transaction_categories.CategoryHideHandler] failed to hide category \"id:%d\" for user \"uid:%d\", because %s", categoryHideReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_categories.CategoryHideHandler] user \"uid:%d\" has hidden category \"id:%d\"", uid, categoryHideReq.Id)
|
||||
log.Infof(c, "[transaction_categories.CategoryHideHandler] user \"uid:%d\" has hidden category \"id:%d\"", uid, categoryHideReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CategoryMoveHandler moves display order of existed transaction categories by request parameters for current user
|
||||
func (a *TransactionCategoriesApi) CategoryMoveHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionCategoriesApi) CategoryMoveHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var categoryMoveReq models.TransactionCategoryMoveRequest
|
||||
err := c.ShouldBindJSON(&categoryMoveReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_categories.CategoryMoveHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_categories.CategoryMoveHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
@@ -292,40 +332,92 @@ func (a *TransactionCategoriesApi) CategoryMoveHandler(c *core.Context) (interfa
|
||||
categories[i] = category
|
||||
}
|
||||
|
||||
err = a.categories.ModifyCategoryDisplayOrders(uid, categories)
|
||||
err = a.categories.ModifyCategoryDisplayOrders(c, uid, categories)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryMoveHandler] failed to move categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[transaction_categories.CategoryMoveHandler] failed to move categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_categories.CategoryMoveHandler] user \"uid:%d\" has moved categories", uid)
|
||||
log.Infof(c, "[transaction_categories.CategoryMoveHandler] user \"uid:%d\" has moved categories", uid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CategoryDeleteHandler deletes an existed transaction category by request parameters for current user
|
||||
func (a *TransactionCategoriesApi) CategoryDeleteHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionCategoriesApi) CategoryDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var categoryDeleteReq models.TransactionCategoryDeleteRequest
|
||||
err := c.ShouldBindJSON(&categoryDeleteReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_categories.CategoryDeleteHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_categories.CategoryDeleteHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.categories.DeleteCategory(uid, categoryDeleteReq.Id)
|
||||
err = a.categories.DeleteCategory(c, uid, categoryDeleteReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryDeleteHandler] failed to delete category \"id:%d\" for user \"uid:%d\", because %s", categoryDeleteReq.Id, uid, err.Error())
|
||||
log.Errorf(c, "[transaction_categories.CategoryDeleteHandler] failed to delete category \"id:%d\" for user \"uid:%d\", because %s", categoryDeleteReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_categories.CategoryDeleteHandler] user \"uid:%d\" has deleted category \"id:%d\"", uid, categoryDeleteReq.Id)
|
||||
log.Infof(c, "[transaction_categories.CategoryDeleteHandler] user \"uid:%d\" has deleted category \"id:%d\"", uid, categoryDeleteReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *TransactionCategoriesApi) createNewCategoryModel(uid int64, categoryCreateReq *models.TransactionCategoryCreateRequest, order int) *models.TransactionCategory {
|
||||
func (a *TransactionCategoriesApi) createBatchCategories(c *core.WebContext, uid int64, categoryCreateBatchReq *models.TransactionCategoryCreateBatchRequest) ([]*models.TransactionCategory, error) {
|
||||
var err error
|
||||
categoryTypeMaxOrderMap := make(map[models.TransactionCategoryType]int32)
|
||||
categoriesMap := make(map[*models.TransactionCategory][]*models.TransactionCategory)
|
||||
categoriesMap[nil] = make([]*models.TransactionCategory, len(categoryCreateBatchReq.Categories))
|
||||
totalCount := 0
|
||||
|
||||
for i := 0; i < len(categoryCreateBatchReq.Categories); i++ {
|
||||
categoryCreateReq := categoryCreateBatchReq.Categories[i]
|
||||
var maxOrderId, exists = categoryTypeMaxOrderMap[categoryCreateReq.Type]
|
||||
|
||||
if !exists {
|
||||
maxOrderId, err = a.categories.GetMaxDisplayOrder(c, uid, categoryCreateReq.Type)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_categories.CategoryCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
category := a.createNewCategoryModel(uid, &models.TransactionCategoryCreateRequest{
|
||||
Name: categoryCreateReq.Name,
|
||||
Type: categoryCreateReq.Type,
|
||||
Icon: categoryCreateReq.Icon,
|
||||
Color: categoryCreateReq.Color,
|
||||
}, maxOrderId+1)
|
||||
|
||||
categoriesMap[category] = make([]*models.TransactionCategory, len(categoryCreateReq.SubCategories))
|
||||
|
||||
for j := int32(0); j < int32(len(categoryCreateReq.SubCategories)); j++ {
|
||||
subCategory := a.createNewCategoryModel(uid, categoryCreateReq.SubCategories[j], j+1)
|
||||
categoriesMap[category][j] = subCategory
|
||||
totalCount++
|
||||
}
|
||||
|
||||
categoriesMap[nil][i] = category
|
||||
categoryTypeMaxOrderMap[categoryCreateReq.Type] = maxOrderId + 1
|
||||
totalCount++
|
||||
}
|
||||
|
||||
categories, err := a.categories.CreateCategories(c, uid, categoriesMap)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_categories.createBatchCategories] failed to create categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_categories.createBatchCategories] user \"uid:%d\" has created categories successfully", uid)
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (a *TransactionCategoriesApi) createNewCategoryModel(uid int64, categoryCreateReq *models.TransactionCategoryCreateRequest, order int32) *models.TransactionCategory {
|
||||
return &models.TransactionCategory{
|
||||
Uid: uid,
|
||||
Name: categoryCreateReq.Name,
|
||||
@@ -350,7 +442,7 @@ func (a *TransactionCategoriesApi) getTransactionCategoryListByTypeResponse(cate
|
||||
for i := 0; i < len(categoryResps); i++ {
|
||||
categoryResp := categoryResps[i]
|
||||
|
||||
if categoryResp.ParentId <= models.LevelOneTransactionParentId {
|
||||
if categoryResp.ParentId <= models.LevelOneTransactionCategoryParentId {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -366,7 +458,7 @@ func (a *TransactionCategoriesApi) getTransactionCategoryListByTypeResponse(cate
|
||||
finalCategoryResps := make(models.TransactionCategoryInfoResponseSlice, 0)
|
||||
|
||||
for i := 0; i < len(categoryResps); i++ {
|
||||
if parentId <= 0 && categoryResps[i].ParentId == models.LevelOneTransactionParentId {
|
||||
if parentId <= 0 && categoryResps[i].ParentId == models.LevelOneTransactionCategoryParentId {
|
||||
sort.Sort(categoryResps[i].SubCategories)
|
||||
finalCategoryResps = append(finalCategoryResps, categoryResps[i])
|
||||
} else if parentId > 0 && categoryResps[i].ParentId == parentId {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// TransactionPicturesApi represents transaction pictures api
|
||||
type TransactionPicturesApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
users *services.UserService
|
||||
pictures *services.TransactionPictureService
|
||||
}
|
||||
|
||||
// Initialize a transaction api singleton instance
|
||||
var (
|
||||
TransactionPictures = &TransactionPicturesApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
users: services.Users,
|
||||
pictures: services.TransactionPictures,
|
||||
}
|
||||
)
|
||||
|
||||
// TransactionPictureUploadHandler saves transaction picture by request parameters for current user
|
||||
func (a *TransactionPicturesApi) TransactionPictureUploadHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
form, err := c.MultipartForm()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrParameterInvalid
|
||||
}
|
||||
|
||||
pictureFiles := form.File["picture"]
|
||||
|
||||
if len(pictureFiles) < 1 {
|
||||
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] there is no transaction picture in request for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrNoTransactionPicture
|
||||
}
|
||||
|
||||
if pictureFiles[0].Size < 1 {
|
||||
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] the size of transaction picture in request is zero for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrTransactionPictureIsEmpty
|
||||
}
|
||||
|
||||
if pictureFiles[0].Size > int64(a.CurrentConfig().MaxTransactionPictureFileSize) {
|
||||
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of transaction picture for user \"uid:%d\"", pictureFiles[0].Size, a.CurrentConfig().MaxTransactionPictureFileSize, uid)
|
||||
return nil, errs.ErrExceedMaxTransactionPictureFileSize
|
||||
}
|
||||
|
||||
fileExtension := utils.GetFileNameExtension(pictureFiles[0].Filename)
|
||||
|
||||
if utils.GetImageContentType(fileExtension) == "" {
|
||||
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] the file extension \"%s\" of transaction picture in request is not supported for user \"uid:%d\"", fileExtension, uid)
|
||||
return nil, errs.ErrImageTypeNotSupported
|
||||
}
|
||||
|
||||
pictureFile, err := pictureFiles[0].Open()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get transaction picture file from request for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
pictureInfo := a.createNewPictureInfoModel(uid, fileExtension, c.ClientIP())
|
||||
|
||||
clientSessionIds := form.Value["clientSessionId"]
|
||||
clientSessionId := ""
|
||||
|
||||
if len(clientSessionIds) > 0 {
|
||||
clientSessionId = clientSessionIds[0]
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && clientSessionId != "" {
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_PICTURE, uid, clientSessionId)
|
||||
|
||||
if found {
|
||||
log.Infof(c, "[transaction_pictures.TransactionPictureUploadHandler] another transaction picture \"id:%s\" has been uploaded for user \"uid:%d\"", remark, uid)
|
||||
pictureId, err := utils.StringToInt64(remark)
|
||||
|
||||
if err == nil {
|
||||
pictureInfo, err = a.pictures.GetPictureInfoByPictureId(c, uid, pictureId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get existed transaction picture \"id:%d\" for user \"uid:%d\", because %s", pictureId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
pictureInfoResp := a.GetTransactionPictureInfoResponse(pictureInfo)
|
||||
|
||||
return pictureInfoResp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = a.pictures.UploadPicture(c, pictureInfo, pictureFile)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to update transaction picture for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_PICTURE, uid, clientSessionId, utils.Int64ToString(pictureInfo.PictureId))
|
||||
pictureInfoResp := a.GetTransactionPictureInfoResponse(pictureInfo)
|
||||
|
||||
return pictureInfoResp, nil
|
||||
}
|
||||
|
||||
// TransactionPictureGetHandler returns transaction picture data for current user
|
||||
func (a *TransactionPicturesApi) TransactionPictureGetHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
|
||||
fileName := c.Param("fileName")
|
||||
fileExtension := utils.GetFileNameExtension(fileName)
|
||||
contentType := utils.GetImageContentType(fileExtension)
|
||||
|
||||
if contentType == "" {
|
||||
return nil, "", errs.ErrImageTypeNotSupported
|
||||
}
|
||||
|
||||
fileBaseName := utils.GetFileNameWithoutExtension(fileName)
|
||||
pictureId, err := utils.StringToInt64(fileBaseName)
|
||||
|
||||
if err != nil {
|
||||
return nil, "", errs.ErrTransactionPictureIdInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
pictureData, err := a.pictures.GetPictureByPictureId(c, uid, pictureId, fileExtension)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get transaction picture, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, "", errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return pictureData, contentType, nil
|
||||
}
|
||||
|
||||
// TransactionPictureRemoveUnusedHandler removes unused transaction picture by request parameters for current user
|
||||
func (a *TransactionPicturesApi) TransactionPictureRemoveUnusedHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var pictureDeleteReq models.TransactionPictureUnusedDeleteRequest
|
||||
err := c.ShouldBindJSON(&pictureDeleteReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_pictures.TransactionPictureRemoveUnusedHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.pictures.RemoveUnusedTransactionPicture(c, uid, pictureDeleteReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_pictures.TransactionPictureRemoveUnusedHandler] failed to remove unused transaction picture for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *TransactionPicturesApi) createNewPictureInfoModel(uid int64, fileExtension string, clientIp string) *models.TransactionPictureInfo {
|
||||
return &models.TransactionPictureInfo{
|
||||
Uid: uid,
|
||||
TransactionId: models.TransactionPictureNewPictureTransactionId,
|
||||
PictureExtension: fileExtension,
|
||||
CreatedIp: clientIp,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
)
|
||||
|
||||
// TransactionTagGroupsApi represents transaction tag group api
|
||||
type TransactionTagGroupsApi struct {
|
||||
tagGroups *services.TransactionTagGroupService
|
||||
}
|
||||
|
||||
// Initialize a transaction tag group api singleton instance
|
||||
var (
|
||||
TransactionTagGroups = &TransactionTagGroupsApi{
|
||||
tagGroups: services.TransactionTagGroups,
|
||||
}
|
||||
)
|
||||
|
||||
// TagGroupListHandler returns transaction tag group list of current user
|
||||
func (a *TransactionTagGroupsApi) TagGroupListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
tagGroups, err := a.tagGroups.GetAllTagGroupsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tag_groups.TagGroupListHandler] failed to get tag groups for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
tagGroupResps := make(models.TransactionTagGroupInfoResponseSlice, len(tagGroups))
|
||||
|
||||
for i := 0; i < len(tagGroups); i++ {
|
||||
tagGroupResps[i] = tagGroups[i].ToTransactionTagGroupInfoResponse()
|
||||
}
|
||||
|
||||
sort.Sort(tagGroupResps)
|
||||
|
||||
return tagGroupResps, nil
|
||||
}
|
||||
|
||||
// TagGroupGetHandler returns one specific transaction tag group of current user
|
||||
func (a *TransactionTagGroupsApi) TagGroupGetHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagGroupGetReq models.TransactionTagGroupGetRequest
|
||||
err := c.ShouldBindQuery(&tagGroupGetReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_tag_groups.TagGroupGetHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagGroupGetReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tag_groups.TagGroupGetHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupGetReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse()
|
||||
|
||||
return tagGroupResp, nil
|
||||
}
|
||||
|
||||
// TagGroupCreateHandler saves a new transaction tag group by request parameters for current user
|
||||
func (a *TransactionTagGroupsApi) TagGroupCreateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagGroupCreateReq models.TransactionTagGroupCreateRequest
|
||||
err := c.ShouldBindJSON(&tagGroupCreateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_tag_groups.TagGroupCreateHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
maxOrderId, err := a.tagGroups.GetMaxDisplayOrder(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tag_groups.TagGroupCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
tagGroup := a.createNewTagGroupModel(uid, &tagGroupCreateReq, maxOrderId+1)
|
||||
|
||||
err = a.tagGroups.CreateTagGroup(c, tagGroup)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tag_groups.TagGroupCreateHandler] failed to create tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroup.TagGroupId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_tag_groups.TagGroupCreateHandler] user \"uid:%d\" has created a new tag group \"id:%d\" successfully", uid, tagGroup.TagGroupId)
|
||||
|
||||
tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse()
|
||||
|
||||
return tagGroupResp, nil
|
||||
}
|
||||
|
||||
// TagGroupModifyHandler saves an existed transaction tag group by request parameters for current user
|
||||
func (a *TransactionTagGroupsApi) TagGroupModifyHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagGroupModifyReq models.TransactionTagGroupModifyRequest
|
||||
err := c.ShouldBindJSON(&tagGroupModifyReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_tag_groups.TagGroupModifyHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagGroupModifyReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tag_groups.TagGroupModifyHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
newTagGroup := &models.TransactionTagGroup{
|
||||
TagGroupId: tagGroup.TagGroupId,
|
||||
Uid: uid,
|
||||
Name: tagGroupModifyReq.Name,
|
||||
}
|
||||
|
||||
if newTagGroup.Name == tagGroup.Name {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
err = a.tagGroups.ModifyTagGroup(c, newTagGroup)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tag_groups.TagGroupModifyHandler] failed to update tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_tag_groups.TagGroupModifyHandler] user \"uid:%d\" has updated tag group \"id:%d\" successfully", uid, tagGroupModifyReq.Id)
|
||||
|
||||
tagGroup.Name = newTagGroup.Name
|
||||
tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse()
|
||||
|
||||
return tagGroupResp, nil
|
||||
}
|
||||
|
||||
// TagGroupMoveHandler moves display order of existed transaction tag groups by request parameters for current user
|
||||
func (a *TransactionTagGroupsApi) TagGroupMoveHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagGroupMoveReq models.TransactionTagGroupMoveRequest
|
||||
err := c.ShouldBindJSON(&tagGroupMoveReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_tag_groups.TagGroupMoveHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
tagGroups := make([]*models.TransactionTagGroup, len(tagGroupMoveReq.NewDisplayOrders))
|
||||
|
||||
for i := 0; i < len(tagGroupMoveReq.NewDisplayOrders); i++ {
|
||||
newDisplayOrder := tagGroupMoveReq.NewDisplayOrders[i]
|
||||
tagGroup := &models.TransactionTagGroup{
|
||||
Uid: uid,
|
||||
TagGroupId: newDisplayOrder.Id,
|
||||
DisplayOrder: newDisplayOrder.DisplayOrder,
|
||||
}
|
||||
|
||||
tagGroups[i] = tagGroup
|
||||
}
|
||||
|
||||
err = a.tagGroups.ModifyTagGroupDisplayOrders(c, uid, tagGroups)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tag_groups.TagGroupMoveHandler] failed to move tag groups for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_tag_groups.TagGroupMoveHandler] user \"uid:%d\" has moved tag groups", uid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TagGroupDeleteHandler deletes an existed transaction tag group by request parameters for current user
|
||||
func (a *TransactionTagGroupsApi) TagGroupDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagGroupDeleteReq models.TransactionTagGroupDeleteRequest
|
||||
err := c.ShouldBindJSON(&tagGroupDeleteReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_tag_groups.TagGroupDeleteHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.tagGroups.DeleteTagGroup(c, uid, tagGroupDeleteReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tag_groups.TagGroupDeleteHandler] failed to delete tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupDeleteReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_tag_groups.TagGroupDeleteHandler] user \"uid:%d\" has deleted tag group \"id:%d\"", uid, tagGroupDeleteReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *TransactionTagGroupsApi) createNewTagGroupModel(uid int64, tagGroupCreateReq *models.TransactionTagGroupCreateRequest, order int32) *models.TransactionTagGroup {
|
||||
return &models.TransactionTagGroup{
|
||||
Uid: uid,
|
||||
Name: tagGroupCreateReq.Name,
|
||||
DisplayOrder: order,
|
||||
}
|
||||
}
|
||||
+181
-58
@@ -12,24 +12,26 @@ import (
|
||||
|
||||
// TransactionTagsApi represents transaction tag api
|
||||
type TransactionTagsApi struct {
|
||||
tags *services.TransactionTagService
|
||||
tags *services.TransactionTagService
|
||||
tagGroups *services.TransactionTagGroupService
|
||||
}
|
||||
|
||||
// Initialize a transaction tag api singleton instance
|
||||
var (
|
||||
TransactionTags = &TransactionTagsApi{
|
||||
tags: services.TransactionTags,
|
||||
tags: services.TransactionTags,
|
||||
tagGroups: services.TransactionTagGroups,
|
||||
}
|
||||
)
|
||||
|
||||
// TagListHandler returns transaction tag list of current user
|
||||
func (a *TransactionTagsApi) TagListHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionTagsApi) TagListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
tags, err := a.tags.GetAllTagsByUid(uid)
|
||||
tags, err := a.tags.GetAllTagsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_tags.TagListHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[transaction_tags.TagListHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
tagResps := make(models.TransactionTagInfoResponseSlice, len(tags))
|
||||
@@ -44,21 +46,21 @@ func (a *TransactionTagsApi) TagListHandler(c *core.Context) (interface{}, *errs
|
||||
}
|
||||
|
||||
// TagGetHandler returns one specific transaction tag of current user
|
||||
func (a *TransactionTagsApi) TagGetHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionTagsApi) TagGetHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagGetReq models.TransactionTagGetRequest
|
||||
err := c.ShouldBindQuery(&tagGetReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_tags.TagGetHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_tags.TagGetHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
tag, err := a.tags.GetTagByTagId(uid, tagGetReq.Id)
|
||||
tag, err := a.tags.GetTagByTagId(c, uid, tagGetReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_tags.TagGetHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagGetReq.Id, uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[transaction_tags.TagGetHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagGetReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
tagResp := tag.ToTransactionTagInfoResponse()
|
||||
@@ -67,112 +69,219 @@ func (a *TransactionTagsApi) TagGetHandler(c *core.Context) (interface{}, *errs.
|
||||
}
|
||||
|
||||
// TagCreateHandler saves a new transaction tag by request parameters for current user
|
||||
func (a *TransactionTagsApi) TagCreateHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagCreateReq models.TransactionTagCreateRequest
|
||||
err := c.ShouldBindJSON(&tagCreateReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_tags.TagCreateHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_tags.TagCreateHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
maxOrderId, err := a.tags.GetMaxDisplayOrder(uid)
|
||||
if tagCreateReq.GroupId > 0 {
|
||||
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagCreateReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagCreateReq.GroupId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if tagGroup == nil {
|
||||
log.Warnf(c, "[transaction_tags.TagCreateHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagCreateReq.GroupId, uid)
|
||||
return nil, errs.ErrTransactionTagGroupNotFound
|
||||
}
|
||||
}
|
||||
|
||||
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_tags.TagCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
tag := a.createNewTagModel(uid, &tagCreateReq, maxOrderId+1)
|
||||
|
||||
err = a.tags.CreateTag(tag)
|
||||
err = a.tags.CreateTag(c, tag)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_tags.TagCreateHandler] failed to create tag \"id:%d\" for user \"uid:%d\", because %s", tag.TagId, uid, err.Error())
|
||||
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to create tag \"id:%d\" for user \"uid:%d\", because %s", tag.TagId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_tags.TagCreateHandler] user \"uid:%d\" has created a new tag \"id:%d\" successfully", uid, tag.TagId)
|
||||
log.Infof(c, "[transaction_tags.TagCreateHandler] user \"uid:%d\" has created a new tag \"id:%d\" successfully", uid, tag.TagId)
|
||||
|
||||
tagResp := tag.ToTransactionTagInfoResponse()
|
||||
|
||||
return tagResp, nil
|
||||
}
|
||||
|
||||
// TagCreateBatchHandler saves some new transaction tags by request parameters for current user
|
||||
func (a *TransactionTagsApi) TagCreateBatchHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagCreateBatchReq models.TransactionTagCreateBatchRequest
|
||||
err := c.ShouldBindJSON(&tagCreateBatchReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
for i := 0; i < len(tagCreateBatchReq.Tags); i++ {
|
||||
if tagCreateBatchReq.Tags[i].GroupId != tagCreateBatchReq.GroupId {
|
||||
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] the group id \"%d\" of tag#%d is inconsistent with the batch group id \"%d\"", tagCreateBatchReq.Tags[i].GroupId, i, tagCreateBatchReq.GroupId)
|
||||
return nil, errs.ErrTransactionTagGroupIdInvalid
|
||||
}
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
if tagCreateBatchReq.GroupId > 0 {
|
||||
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagCreateBatchReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagCreateBatchReq.GroupId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if tagGroup == nil {
|
||||
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagCreateBatchReq.GroupId, uid)
|
||||
return nil, errs.ErrTransactionTagGroupNotFound
|
||||
}
|
||||
}
|
||||
|
||||
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateBatchReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
tags := a.createNewTagModels(uid, &tagCreateBatchReq, maxOrderId+1)
|
||||
|
||||
err = a.tags.CreateTags(c, uid, tags, tagCreateBatchReq.SkipExists)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to create tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_tags.TagCreateBatchHandler] user \"uid:%d\" has created tags successfully", uid)
|
||||
|
||||
tagResps := make(models.TransactionTagInfoResponseSlice, len(tags))
|
||||
|
||||
for i := 0; i < len(tags); i++ {
|
||||
tagResps[i] = tags[i].ToTransactionTagInfoResponse()
|
||||
}
|
||||
|
||||
sort.Sort(tagResps)
|
||||
|
||||
return tagResps, nil
|
||||
}
|
||||
|
||||
// TagModifyHandler saves an existed transaction tag by request parameters for current user
|
||||
func (a *TransactionTagsApi) TagModifyHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagModifyReq models.TransactionTagModifyRequest
|
||||
err := c.ShouldBindJSON(&tagModifyReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_tags.TagModifyHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_tags.TagModifyHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
tag, err := a.tags.GetTagByTagId(uid, tagModifyReq.Id)
|
||||
tag, err := a.tags.GetTagByTagId(c, uid, tagModifyReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_tags.TagModifyHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
newTag := &models.TransactionTag{
|
||||
TagId: tag.TagId,
|
||||
Uid: uid,
|
||||
Name: tagModifyReq.Name,
|
||||
}
|
||||
|
||||
if newTag.Name == tag.Name {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
err = a.tags.ModifyTag(newTag)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_tags.TagModifyHandler] failed to update tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
|
||||
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_tags.TagModifyHandler] user \"uid:%d\" has updated tag \"id:%d\" successfully", uid, tagModifyReq.Id)
|
||||
if tagModifyReq.GroupId != tag.TagGroupId && tagModifyReq.GroupId > 0 {
|
||||
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagModifyReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.GroupId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if tagGroup == nil {
|
||||
log.Warnf(c, "[transaction_tags.TagModifyHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagModifyReq.GroupId, uid)
|
||||
return nil, errs.ErrTransactionTagGroupNotFound
|
||||
}
|
||||
}
|
||||
|
||||
newTag := &models.TransactionTag{
|
||||
TagId: tag.TagId,
|
||||
Uid: uid,
|
||||
Name: tagModifyReq.Name,
|
||||
TagGroupId: tagModifyReq.GroupId,
|
||||
DisplayOrder: tag.DisplayOrder,
|
||||
}
|
||||
|
||||
tagNameChanged := newTag.Name != tag.Name
|
||||
|
||||
if !tagNameChanged && newTag.TagGroupId == tag.TagGroupId {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
if newTag.TagGroupId != tag.TagGroupId {
|
||||
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, newTag.TagGroupId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
newTag.DisplayOrder = maxOrderId + 1
|
||||
}
|
||||
|
||||
err = a.tags.ModifyTag(c, newTag, tagNameChanged)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to update tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_tags.TagModifyHandler] user \"uid:%d\" has updated tag \"id:%d\" successfully", uid, tagModifyReq.Id)
|
||||
|
||||
tag.Name = newTag.Name
|
||||
tag.TagGroupId = newTag.TagGroupId
|
||||
tag.DisplayOrder = newTag.DisplayOrder
|
||||
tagResp := tag.ToTransactionTagInfoResponse()
|
||||
|
||||
return tagResp, nil
|
||||
}
|
||||
|
||||
// TagHideHandler hides an transaction tag by request parameters for current user
|
||||
func (a *TransactionTagsApi) TagHideHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
// TagHideHandler hides a transaction tag by request parameters for current user
|
||||
func (a *TransactionTagsApi) TagHideHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagHideReq models.TransactionTagHideRequest
|
||||
err := c.ShouldBindJSON(&tagHideReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_tags.CategoryHideHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_tags.TagHideHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.tags.HideTag(uid, []int64{tagHideReq.Id}, tagHideReq.Hidden)
|
||||
err = a.tags.HideTag(c, uid, []int64{tagHideReq.Id}, tagHideReq.Hidden)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_tags.CategoryHideHandler] failed to hide tag \"id:%d\" for user \"uid:%d\", because %s", tagHideReq.Id, uid, err.Error())
|
||||
log.Errorf(c, "[transaction_tags.TagHideHandler] failed to hide tag \"id:%d\" for user \"uid:%d\", because %s", tagHideReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_tags.CategoryHideHandler] user \"uid:%d\" has hidden category \"id:%d\"", uid, tagHideReq.Id)
|
||||
log.Infof(c, "[transaction_tags.TagHideHandler] user \"uid:%d\" has hidden tag \"id:%d\"", uid, tagHideReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TagMoveHandler moves display order of existed transaction tags by request parameters for current user
|
||||
func (a *TransactionTagsApi) TagMoveHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionTagsApi) TagMoveHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagMoveReq models.TransactionTagMoveRequest
|
||||
err := c.ShouldBindJSON(&tagMoveReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_tags.CategoryMoveHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_tags.TagMoveHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
@@ -190,43 +299,57 @@ func (a *TransactionTagsApi) TagMoveHandler(c *core.Context) (interface{}, *errs
|
||||
tags[i] = tag
|
||||
}
|
||||
|
||||
err = a.tags.ModifyTagDisplayOrders(uid, tags)
|
||||
err = a.tags.ModifyTagDisplayOrders(c, uid, tags)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_tags.CategoryMoveHandler] failed to move tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[transaction_tags.TagMoveHandler] failed to move tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_tags.CategoryMoveHandler] user \"uid:%d\" has moved categories", uid)
|
||||
log.Infof(c, "[transaction_tags.TagMoveHandler] user \"uid:%d\" has moved tags", uid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TagDeleteHandler deletes an existed transaction tag by request parameters for current user
|
||||
func (a *TransactionTagsApi) TagDeleteHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TransactionTagsApi) TagDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var tagDeleteReq models.TransactionTagDeleteRequest
|
||||
err := c.ShouldBindJSON(&tagDeleteReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[transaction_tags.TagDeleteHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[transaction_tags.TagDeleteHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.tags.DeleteTag(uid, tagDeleteReq.Id)
|
||||
err = a.tags.DeleteTag(c, uid, tagDeleteReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[transaction_tags.TagDeleteHandler] failed to delete tag \"id:%d\" for user \"uid:%d\", because %s", tagDeleteReq.Id, uid, err.Error())
|
||||
log.Errorf(c, "[transaction_tags.TagDeleteHandler] failed to delete tag \"id:%d\" for user \"uid:%d\", because %s", tagDeleteReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[transaction_tags.TagDeleteHandler] user \"uid:%d\" has deleted tag \"id:%d\"", uid, tagDeleteReq.Id)
|
||||
log.Infof(c, "[transaction_tags.TagDeleteHandler] user \"uid:%d\" has deleted tag \"id:%d\"", uid, tagDeleteReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *TransactionTagsApi) createNewTagModel(uid int64, tagCreateReq *models.TransactionTagCreateRequest, order int) *models.TransactionTag {
|
||||
func (a *TransactionTagsApi) createNewTagModel(uid int64, tagCreateReq *models.TransactionTagCreateRequest, order int32) *models.TransactionTag {
|
||||
return &models.TransactionTag{
|
||||
Uid: uid,
|
||||
Name: tagCreateReq.Name,
|
||||
TagGroupId: tagCreateReq.GroupId,
|
||||
DisplayOrder: order,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *TransactionTagsApi) createNewTagModels(uid int64, tagCreateBatchReq *models.TransactionTagCreateBatchRequest, order int32) []*models.TransactionTag {
|
||||
tags := make([]*models.TransactionTag, len(tagCreateBatchReq.Tags))
|
||||
|
||||
for i := 0; i < len(tagCreateBatchReq.Tags); i++ {
|
||||
tagCreateReq := tagCreateBatchReq.Tags[i]
|
||||
tag := a.createNewTagModel(uid, tagCreateReq, order+int32(i))
|
||||
tag.TagGroupId = tagCreateBatchReq.GroupId
|
||||
tags[i] = tag
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const maximumTagsCountOfTemplate = 10
|
||||
|
||||
// TransactionTemplatesApi represents transaction template api
|
||||
type TransactionTemplatesApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
templates *services.TransactionTemplateService
|
||||
}
|
||||
|
||||
// Initialize a transaction template api singleton instance
|
||||
var (
|
||||
TransactionTemplates = &TransactionTemplatesApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
templates: services.TransactionTemplates,
|
||||
}
|
||||
)
|
||||
|
||||
// TemplateListHandler returns transaction template list of current user
|
||||
func (a *TransactionTemplatesApi) TemplateListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var templateListReq models.TransactionTemplateListRequest
|
||||
err := c.ShouldBindQuery(&templateListReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_templates.TemplateListHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
if templateListReq.TemplateType < models.TRANSACTION_TEMPLATE_TYPE_NORMAL || templateListReq.TemplateType > models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
|
||||
log.Warnf(c, "[transaction_templates.TemplateListHandler] template type invalid, type is %d", templateListReq.TemplateType)
|
||||
return nil, errs.ErrTransactionTemplateTypeInvalid
|
||||
}
|
||||
|
||||
if templateListReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
|
||||
return nil, errs.ErrScheduledTransactionNotEnabled
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
templates, err := a.templates.GetAllTemplatesByUid(c, uid, templateListReq.TemplateType)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateListHandler] failed to get templates for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
templateResps := make(models.TransactionTemplateInfoResponseSlice, len(templates))
|
||||
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
|
||||
|
||||
for i := 0; i < len(templates); i++ {
|
||||
templateResps[i] = templates[i].ToTransactionTemplateInfoResponse(serverUtcOffset)
|
||||
}
|
||||
|
||||
sort.Sort(templateResps)
|
||||
|
||||
return templateResps, nil
|
||||
}
|
||||
|
||||
// TemplateGetHandler returns one specific transaction template of current user
|
||||
func (a *TransactionTemplatesApi) TemplateGetHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var templateGetReq models.TransactionTemplateGetRequest
|
||||
err := c.ShouldBindQuery(&templateGetReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_templates.TemplateGetHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateGetReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateGetHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateGetReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
|
||||
return nil, errs.ErrScheduledTransactionNotEnabled
|
||||
}
|
||||
|
||||
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
|
||||
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
|
||||
|
||||
return templateResp, nil
|
||||
}
|
||||
|
||||
// TemplateCreateHandler saves a new transaction template by request parameters for current user
|
||||
func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var templateCreateReq models.TransactionTemplateCreateRequest
|
||||
err := c.ShouldBindJSON(&templateCreateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_templates.TemplateCreateHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
if templateCreateReq.TemplateType < models.TRANSACTION_TEMPLATE_TYPE_NORMAL || templateCreateReq.TemplateType > models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
|
||||
log.Warnf(c, "[transaction_templates.TemplateCreateHandler] template type invalid, type is %d", templateCreateReq.TemplateType)
|
||||
return nil, errs.ErrTransactionTemplateTypeInvalid
|
||||
}
|
||||
|
||||
if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
|
||||
return nil, errs.ErrScheduledTransactionNotEnabled
|
||||
}
|
||||
|
||||
if templateCreateReq.Type <= models.TRANSACTION_TYPE_MODIFY_BALANCE || templateCreateReq.Type > models.TRANSACTION_TYPE_TRANSFER {
|
||||
log.Warnf(c, "[transaction_templates.TemplateCreateHandler] transaction type invalid, type is %d", templateCreateReq.Type)
|
||||
return nil, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
|
||||
if templateCreateReq.ScheduledFrequencyType == nil ||
|
||||
templateCreateReq.ScheduledFrequency == nil ||
|
||||
templateCreateReq.ScheduledTimezoneUtcOffset == nil {
|
||||
return nil, errs.ErrScheduledTransactionFrequencyInvalid
|
||||
}
|
||||
|
||||
if *templateCreateReq.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateCreateReq.ScheduledFrequency != "" {
|
||||
return nil, errs.ErrScheduledTransactionFrequencyInvalid
|
||||
} else if *templateCreateReq.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateCreateReq.ScheduledFrequency == "" {
|
||||
return nil, errs.ErrScheduledTransactionFrequencyInvalid
|
||||
}
|
||||
}
|
||||
|
||||
if len(templateCreateReq.TagIds) > maximumTagsCountOfTemplate {
|
||||
return nil, errs.ErrTransactionTemplateHasTooManyTags
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
maxOrderId, err := a.templates.GetMaxDisplayOrder(c, uid, templateCreateReq.TemplateType)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
|
||||
template, err := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to create new template for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && templateCreateReq.ClientSessionId != "" {
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId)
|
||||
|
||||
if found {
|
||||
log.Infof(c, "[transaction_templates.TemplateCreateHandler] another template \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
|
||||
templateId, err := utils.StringToInt64(remark)
|
||||
|
||||
if err == nil {
|
||||
template, err = a.templates.GetTemplateByTemplateId(c, uid, templateId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to get existed template \"id:%d\" for user \"uid:%d\", because %s", templateId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
|
||||
|
||||
return templateResp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = a.templates.CreateTemplate(c, template)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to create template \"id:%d\" for user \"uid:%d\", because %s", template.TemplateId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_templates.TemplateCreateHandler] user \"uid:%d\" has created a new template \"id:%d\" successfully", uid, template.TemplateId)
|
||||
|
||||
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId, utils.Int64ToString(template.TemplateId))
|
||||
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
|
||||
|
||||
return templateResp, nil
|
||||
}
|
||||
|
||||
// TemplateModifyHandler saves an existed transaction template by request parameters for current user
|
||||
func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var templateModifyReq models.TransactionTemplateModifyRequest
|
||||
err := c.ShouldBindJSON(&templateModifyReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_templates.TemplateModifyHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
if templateModifyReq.Type <= models.TRANSACTION_TYPE_MODIFY_BALANCE || templateModifyReq.Type > models.TRANSACTION_TYPE_TRANSFER {
|
||||
log.Warnf(c, "[transaction_templates.TemplateModifyHandler] transaction type invalid, type is %d", templateModifyReq.Type)
|
||||
return nil, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateModifyReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
|
||||
return nil, errs.ErrScheduledTransactionNotEnabled
|
||||
}
|
||||
|
||||
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
|
||||
if templateModifyReq.ScheduledFrequencyType == nil ||
|
||||
templateModifyReq.ScheduledFrequency == nil ||
|
||||
templateModifyReq.ScheduledTimezoneUtcOffset == nil {
|
||||
return nil, errs.ErrScheduledTransactionFrequencyInvalid
|
||||
}
|
||||
|
||||
if *templateModifyReq.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateModifyReq.ScheduledFrequency != "" {
|
||||
return nil, errs.ErrScheduledTransactionFrequencyInvalid
|
||||
} else if *templateModifyReq.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateModifyReq.ScheduledFrequency == "" {
|
||||
return nil, errs.ErrScheduledTransactionFrequencyInvalid
|
||||
}
|
||||
}
|
||||
|
||||
if len(templateModifyReq.TagIds) > maximumTagsCountOfTemplate {
|
||||
return nil, errs.ErrTransactionTemplateHasTooManyTags
|
||||
}
|
||||
|
||||
newTemplate := &models.TransactionTemplate{
|
||||
TemplateId: template.TemplateId,
|
||||
Uid: uid,
|
||||
Name: templateModifyReq.Name,
|
||||
Type: templateModifyReq.Type,
|
||||
CategoryId: templateModifyReq.CategoryId,
|
||||
AccountId: templateModifyReq.SourceAccountId,
|
||||
TagIds: strings.Join(templateModifyReq.TagIds, ","),
|
||||
Amount: templateModifyReq.SourceAmount,
|
||||
RelatedAccountId: templateModifyReq.DestinationAccountId,
|
||||
RelatedAccountAmount: templateModifyReq.DestinationAmount,
|
||||
HideAmount: templateModifyReq.HideAmount,
|
||||
Comment: templateModifyReq.Comment,
|
||||
}
|
||||
|
||||
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
|
||||
newTemplate.ScheduledFrequencyType = *templateModifyReq.ScheduledFrequencyType
|
||||
newTemplate.ScheduledFrequency = a.getOrderedFrequencyValues(*templateModifyReq.ScheduledFrequency)
|
||||
newTemplate.ScheduledAt = a.getUTCScheduledAt(*templateModifyReq.ScheduledTimezoneUtcOffset)
|
||||
newTemplate.ScheduledTimezoneUtcOffset = *templateModifyReq.ScheduledTimezoneUtcOffset
|
||||
|
||||
if templateModifyReq.ScheduledStartDate != nil {
|
||||
startTime, err := utils.ParseFromLongDateFirstTime(*templateModifyReq.ScheduledStartDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled start date for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
startUnixTime := startTime.Unix()
|
||||
newTemplate.ScheduledStartTime = &startUnixTime
|
||||
}
|
||||
|
||||
if templateModifyReq.ScheduledEndDate != nil {
|
||||
endTime, err := utils.ParseFromLongDateLastTime(*templateModifyReq.ScheduledEndDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled end date for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
endUnixTime := endTime.Unix()
|
||||
newTemplate.ScheduledEndTime = &endUnixTime
|
||||
}
|
||||
|
||||
if newTemplate.ScheduledStartTime != nil && newTemplate.ScheduledEndTime != nil && *newTemplate.ScheduledStartTime > *newTemplate.ScheduledEndTime {
|
||||
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
|
||||
}
|
||||
}
|
||||
|
||||
if newTemplate.Name == template.Name &&
|
||||
newTemplate.Type == template.Type &&
|
||||
newTemplate.CategoryId == template.CategoryId &&
|
||||
newTemplate.AccountId == template.AccountId &&
|
||||
newTemplate.TagIds == template.TagIds &&
|
||||
newTemplate.Amount == template.Amount &&
|
||||
newTemplate.RelatedAccountId == template.RelatedAccountId &&
|
||||
newTemplate.RelatedAccountAmount == template.RelatedAccountAmount &&
|
||||
newTemplate.HideAmount == template.HideAmount &&
|
||||
newTemplate.Comment == template.Comment {
|
||||
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_NORMAL {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
} else if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
|
||||
if newTemplate.ScheduledFrequencyType == template.ScheduledFrequencyType &&
|
||||
newTemplate.ScheduledFrequency == template.ScheduledFrequency &&
|
||||
newTemplate.ScheduledStartTime == template.ScheduledStartTime &&
|
||||
newTemplate.ScheduledEndTime == template.ScheduledEndTime &&
|
||||
newTemplate.ScheduledAt == template.ScheduledAt &&
|
||||
newTemplate.ScheduledTimezoneUtcOffset == template.ScheduledTimezoneUtcOffset {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = a.templates.ModifyTemplate(c, newTemplate)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to update template \"id:%d\" for user \"uid:%d\", because %s", templateModifyReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_templates.TemplateModifyHandler] user \"uid:%d\" has updated template \"id:%d\" successfully", uid, templateModifyReq.Id)
|
||||
|
||||
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
|
||||
newTemplate.TemplateType = template.TemplateType
|
||||
newTemplate.DisplayOrder = template.DisplayOrder
|
||||
newTemplate.Hidden = template.Hidden
|
||||
templateResp := newTemplate.ToTransactionTemplateInfoResponse(serverUtcOffset)
|
||||
|
||||
return templateResp, nil
|
||||
}
|
||||
|
||||
// TemplateHideHandler hides a transaction template by request parameters for current user
|
||||
func (a *TransactionTemplatesApi) TemplateHideHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var templateHideReq models.TransactionTemplateHideRequest
|
||||
err := c.ShouldBindJSON(&templateHideReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_templates.TemplateHideHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateHideReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateHideHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateHideReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
|
||||
return nil, errs.ErrScheduledTransactionNotEnabled
|
||||
}
|
||||
|
||||
err = a.templates.HideTemplate(c, uid, []int64{templateHideReq.Id}, templateHideReq.Hidden)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateHideHandler] failed to hide template \"id:%d\" for user \"uid:%d\", because %s", templateHideReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_templates.TemplateHideHandler] user \"uid:%d\" has hidden template \"id:%d\"", uid, templateHideReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TemplateMoveHandler moves display order of existed transaction templates by request parameters for current user
|
||||
func (a *TransactionTemplatesApi) TemplateMoveHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var templateMoveReq models.TransactionTemplateMoveRequest
|
||||
err := c.ShouldBindJSON(&templateMoveReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_templates.TemplateMoveHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
if len(templateMoveReq.NewDisplayOrders) > 0 {
|
||||
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateMoveReq.NewDisplayOrders[0].Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateMoveHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateMoveReq.NewDisplayOrders[0].Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
|
||||
return nil, errs.ErrScheduledTransactionNotEnabled
|
||||
}
|
||||
}
|
||||
|
||||
templates := make([]*models.TransactionTemplate, len(templateMoveReq.NewDisplayOrders))
|
||||
|
||||
for i := 0; i < len(templateMoveReq.NewDisplayOrders); i++ {
|
||||
newDisplayOrder := templateMoveReq.NewDisplayOrders[i]
|
||||
template := &models.TransactionTemplate{
|
||||
Uid: uid,
|
||||
TemplateId: newDisplayOrder.Id,
|
||||
DisplayOrder: newDisplayOrder.DisplayOrder,
|
||||
}
|
||||
|
||||
templates[i] = template
|
||||
}
|
||||
|
||||
err = a.templates.ModifyTemplateDisplayOrders(c, uid, templates)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateMoveHandler] failed to move templates for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_templates.TemplateMoveHandler] user \"uid:%d\" has moved templates", uid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TemplateDeleteHandler deletes an existed transaction template by request parameters for current user
|
||||
func (a *TransactionTemplatesApi) TemplateDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var templateDeleteReq models.TransactionTemplateDeleteRequest
|
||||
err := c.ShouldBindJSON(&templateDeleteReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transaction_templates.TemplateDeleteHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateDeleteReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateDeleteHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateDeleteReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
|
||||
return nil, errs.ErrScheduledTransactionNotEnabled
|
||||
}
|
||||
|
||||
err = a.templates.DeleteTemplate(c, uid, templateDeleteReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateDeleteHandler] failed to delete template \"id:%d\" for user \"uid:%d\", because %s", templateDeleteReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transaction_templates.TemplateDeleteHandler] user \"uid:%d\" has deleted template \"id:%d\"", uid, templateDeleteReq.Id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) (*models.TransactionTemplate, error) {
|
||||
template := &models.TransactionTemplate{
|
||||
Uid: uid,
|
||||
TemplateType: templateCreateReq.TemplateType,
|
||||
Name: templateCreateReq.Name,
|
||||
Type: templateCreateReq.Type,
|
||||
CategoryId: templateCreateReq.CategoryId,
|
||||
AccountId: templateCreateReq.SourceAccountId,
|
||||
TagIds: strings.Join(templateCreateReq.TagIds, ","),
|
||||
Amount: templateCreateReq.SourceAmount,
|
||||
RelatedAccountId: templateCreateReq.DestinationAccountId,
|
||||
RelatedAccountAmount: templateCreateReq.DestinationAmount,
|
||||
HideAmount: templateCreateReq.HideAmount,
|
||||
Comment: templateCreateReq.Comment,
|
||||
DisplayOrder: order,
|
||||
}
|
||||
|
||||
if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
|
||||
template.ScheduledFrequencyType = *templateCreateReq.ScheduledFrequencyType
|
||||
template.ScheduledFrequency = a.getOrderedFrequencyValues(*templateCreateReq.ScheduledFrequency)
|
||||
template.ScheduledAt = a.getUTCScheduledAt(*templateCreateReq.ScheduledTimezoneUtcOffset)
|
||||
template.ScheduledTimezoneUtcOffset = *templateCreateReq.ScheduledTimezoneUtcOffset
|
||||
|
||||
if templateCreateReq.ScheduledStartDate != nil {
|
||||
startTime, err := utils.ParseFromLongDateFirstTime(*templateCreateReq.ScheduledStartDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startUnixTime := startTime.Unix()
|
||||
template.ScheduledStartTime = &startUnixTime
|
||||
}
|
||||
|
||||
if templateCreateReq.ScheduledEndDate != nil {
|
||||
endTime, err := utils.ParseFromLongDateLastTime(*templateCreateReq.ScheduledEndDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endUnixTime := endTime.Unix()
|
||||
template.ScheduledEndTime = &endUnixTime
|
||||
}
|
||||
|
||||
if template.ScheduledStartTime != nil && template.ScheduledEndTime != nil && *template.ScheduledStartTime > *template.ScheduledEndTime {
|
||||
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
|
||||
}
|
||||
}
|
||||
|
||||
return template, nil
|
||||
}
|
||||
|
||||
func (a *TransactionTemplatesApi) getUTCScheduledAt(scheduledTimezoneUtcOffset int16) int16 {
|
||||
templateTimeZone := time.FixedZone("Template Timezone", int(scheduledTimezoneUtcOffset)*60)
|
||||
transactionTime := time.Date(2020, 1, 1, 0, 0, 0, 0, templateTimeZone)
|
||||
transactionTimeInUTC := transactionTime.In(time.UTC)
|
||||
|
||||
minutesElapsedOfDayInUtc := transactionTimeInUTC.Hour()*60 + transactionTimeInUTC.Minute()
|
||||
|
||||
return int16(minutesElapsedOfDayInUtc)
|
||||
}
|
||||
|
||||
func (a *TransactionTemplatesApi) getOrderedFrequencyValues(frequencyValue string) string {
|
||||
if frequencyValue == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
items := strings.Split(frequencyValue, ",")
|
||||
values := make([]int, 0, len(items))
|
||||
valueExistMap := make(map[int]bool)
|
||||
|
||||
for i := 0; i < len(items); i++ {
|
||||
value, err := utils.StringToInt(items[i])
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := valueExistMap[value]; !exists {
|
||||
values = append(values, value)
|
||||
valueExistMap[value] = true
|
||||
}
|
||||
}
|
||||
|
||||
sort.Ints(values)
|
||||
|
||||
var sortedFrequencyValueBuilder strings.Builder
|
||||
|
||||
for i := 0; i < len(values); i++ {
|
||||
if sortedFrequencyValueBuilder.Len() > 0 {
|
||||
sortedFrequencyValueBuilder.WriteRune(',')
|
||||
}
|
||||
|
||||
sortedFrequencyValueBuilder.WriteString(utils.IntToString(values[i]))
|
||||
}
|
||||
|
||||
return sortedFrequencyValueBuilder.String()
|
||||
}
|
||||
+1477
-297
File diff suppressed because it is too large
Load Diff
@@ -32,9 +32,9 @@ var (
|
||||
)
|
||||
|
||||
// TwoFactorStatusHandler returns 2fa status of current user
|
||||
func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(uid)
|
||||
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
|
||||
|
||||
if err == errs.ErrTwoFactorIsNotEnabled {
|
||||
statusResp := &models.TwoFactorStatusResponse{
|
||||
@@ -45,7 +45,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (in
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorStatusHandler] failed to get two factor setting, because %s", err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorStatusHandler] failed to get two-factor setting, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
@@ -58,12 +58,12 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (in
|
||||
}
|
||||
|
||||
// TwoFactorEnableRequestHandler returns a new 2fa secret and qr code for current user to set 2fa and verify passcode next
|
||||
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
enabled, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
|
||||
enabled, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to check two factor setting, because %s", err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to check two-factor setting, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
@@ -71,27 +71,31 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Conte
|
||||
return nil, errs.ErrTwoFactorAlreadyEnabled
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserById(uid)
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to get user, because %s", err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(user)
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(c, user, c.GetClientLocale())
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two factor secret, because %s", err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two-factor secret, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
img, err := key.Image(240, 240)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two factor qrcode, because %s", err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two-factor qrcode, because %s", err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
@@ -110,20 +114,20 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Conte
|
||||
}
|
||||
|
||||
// TwoFactorEnableConfirmHandler enables 2fa for current user
|
||||
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var confirmReq models.TwoFactorEnableConfirmRequest
|
||||
err := c.ShouldBindJSON(&confirmReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
exists, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
|
||||
exists, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to check two factor setting, because %s", err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to check two-factor setting, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
@@ -131,62 +135,66 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
|
||||
return nil, errs.ErrTwoFactorAlreadyEnabled
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserById(uid)
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to get user, because %s", err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
twoFactorSetting := &models.TwoFactor{
|
||||
Uid: uid,
|
||||
Secret: confirmReq.Secret,
|
||||
}
|
||||
|
||||
if !totp.Validate(confirmReq.Passcode, confirmReq.Secret) {
|
||||
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] passcode is invalid")
|
||||
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] passcode is invalid")
|
||||
return nil, errs.ErrPasscodeInvalid
|
||||
}
|
||||
|
||||
recoveryCodes, err := a.twoFactorAuthorizations.GenerateTwoFactorRecoveryCodes()
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to generate two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to generate two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(uid, recoveryCodes, user.Salt)
|
||||
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(c, uid, recoveryCodes, user.Salt)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.twoFactorAuthorizations.CreateTwoFactorSetting(twoFactorSetting)
|
||||
err = a.twoFactorAuthorizations.CreateTwoFactorSetting(c, twoFactorSetting)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two factor setting for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two-factor setting for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" has enabled two factor authorization", uid)
|
||||
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" has enabled two-factor authorization", uid)
|
||||
|
||||
now := time.Now().Unix()
|
||||
err = a.tokens.DeleteTokensBeforeTime(uid, now)
|
||||
err = a.tokens.DeleteTokensBeforeTime(c, uid, now)
|
||||
|
||||
if err == nil {
|
||||
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
|
||||
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
|
||||
} else {
|
||||
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateToken(user, c)
|
||||
token, claims, err := a.tokens.CreateToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
|
||||
confirmResp := &models.TwoFactorEnableConfirmResponse{
|
||||
RecoveryCodes: recoveryCodes,
|
||||
@@ -195,9 +203,11 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
|
||||
return confirmResp, nil
|
||||
}
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
confirmResp := &models.TwoFactorEnableConfirmResponse{
|
||||
Token: token,
|
||||
@@ -208,34 +218,38 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
|
||||
}
|
||||
|
||||
// TwoFactorDisableHandler disables 2fa for current user
|
||||
func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var disableReq models.TwoFactorDisableRequest
|
||||
err := c.ShouldBindJSON(&disableReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[twofactor_authorizations.TwoFactorDisableHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(uid)
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Warnf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(disableReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
|
||||
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to check two factor setting, because %s", err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to check two-factor setting, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
@@ -243,41 +257,41 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (i
|
||||
return nil, errs.ErrTwoFactorIsNotEnabled
|
||||
}
|
||||
|
||||
err = a.twoFactorAuthorizations.DeleteTwoFactorRecoveryCodes(uid)
|
||||
err = a.twoFactorAuthorizations.DeleteTwoFactorRecoveryCodes(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two factor recovery codes for user \"uid:%d\"", uid)
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two-factor recovery codes for user \"uid:%d\"", uid)
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.twoFactorAuthorizations.DeleteTwoFactorSetting(uid)
|
||||
err = a.twoFactorAuthorizations.DeleteTwoFactorSetting(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two factor setting for user \"uid:%d\"", uid)
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two-factor setting for user \"uid:%d\"", uid)
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] user \"uid:%d\" has disabled two factor authorization", uid)
|
||||
log.Infof(c, "[twofactor_authorizations.TwoFactorDisableHandler] user \"uid:%d\" has disabled two-factor authorization", uid)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TwoFactorRecoveryCodeRegenerateHandler returns new 2fa recovery codes and revokes old recovery codes for current user
|
||||
func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var regenerateReq models.TwoFactorRegenerateRecoveryCodeRequest
|
||||
err := c.ShouldBindJSON(®enerateReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(uid)
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Warnf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
@@ -287,10 +301,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *c
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
|
||||
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to check two factor setting, because %s", err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to check two-factor setting, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
@@ -301,14 +315,14 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *c
|
||||
recoveryCodes, err := a.twoFactorAuthorizations.GenerateTwoFactorRecoveryCodes()
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to generate two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to generate two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(uid, recoveryCodes, user.Salt)
|
||||
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(c, uid, recoveryCodes, user.Salt)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to create two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to create two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
@@ -316,7 +330,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *c
|
||||
RecoveryCodes: recoveryCodes,
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] user \"uid:%d\" has regenerated two factor recovery codes", uid)
|
||||
log.Infof(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] user \"uid:%d\" has regenerated two-factor recovery codes", uid)
|
||||
|
||||
return recoveryCodesResp, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// UserApplicationCloudSettingsApi represents user application cloud settings api
|
||||
type UserApplicationCloudSettingsApi struct {
|
||||
userAppCloudSettings *services.UserApplicationCloudSettingsService
|
||||
users *services.UserService
|
||||
}
|
||||
|
||||
// Initialize a user application cloud settings api singleton instance
|
||||
var (
|
||||
UserApplicationCloudSettings = &UserApplicationCloudSettingsApi{
|
||||
userAppCloudSettings: services.UserApplicationCloudSettings,
|
||||
users: services.Users,
|
||||
}
|
||||
)
|
||||
|
||||
// ApplicationSettingsGetHandler returns application cloud settings of current user
|
||||
func (a *UserApplicationCloudSettingsApi) ApplicationSettingsGetHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsGetHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if userApplicationCloudSettings == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
applicationCloudSettingSlice := userApplicationCloudSettings.Settings
|
||||
|
||||
if len(applicationCloudSettingSlice) < 1 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return applicationCloudSettingSlice, nil
|
||||
}
|
||||
|
||||
// ApplicationSettingsUpdateHandler updates user application cloud settings by request parameters for current user
|
||||
func (a *UserApplicationCloudSettingsApi) ApplicationSettingsUpdateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var userAppCloudSettingUpdateReq models.UserApplicationCloudSettingsUpdateRequest
|
||||
err := c.ShouldBindJSON(&userAppCloudSettingUpdateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] parse request failed, because %s", err.Error())
|
||||
return false, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return false, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS) {
|
||||
return false, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
var userApplicationCloudSettings *models.UserApplicationCloudSetting
|
||||
|
||||
// Retry up to 3 times
|
||||
for i := 0; i < 3; i++ {
|
||||
userApplicationCloudSettings, err = a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get latest user application cloud settings for user \"uid:%d\" (try count %d), because %s", uid, i+1, err.Error())
|
||||
return false, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
oldApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
||||
lastUpdateTime := int64(0)
|
||||
|
||||
if userApplicationCloudSettings != nil {
|
||||
for _, setting := range userApplicationCloudSettings.Settings {
|
||||
oldApplicationCloudSettingsMap[setting.SettingKey] = setting
|
||||
}
|
||||
|
||||
lastUpdateTime = userApplicationCloudSettings.UpdatedUnixTime
|
||||
}
|
||||
|
||||
// Check if the full update settings are the same as the existing settings
|
||||
if userAppCloudSettingUpdateReq.FullUpdate {
|
||||
if len(userAppCloudSettingUpdateReq.Settings) == len(oldApplicationCloudSettingsMap) {
|
||||
needUpdate := false
|
||||
|
||||
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||
oldSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
||||
|
||||
if !exists || oldSetting.SettingValue != setting.SettingValue {
|
||||
needUpdate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !needUpdate {
|
||||
return false, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
}
|
||||
} else { // Check if the partial update settings are the same as the existing settings or the settings to update are not set to sync
|
||||
needUpdate := true
|
||||
|
||||
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||
cloudSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
||||
|
||||
if !exists {
|
||||
needUpdate = false
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not set to sync (try count %d)", setting.SettingKey, i+1)
|
||||
} else if cloudSetting.SettingValue == setting.SettingValue {
|
||||
needUpdate = false
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" value \"%s\" is not changed, no need to update (try count %d)", setting.SettingKey, setting.SettingValue, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if !needUpdate {
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] no user application cloud settings need to update for user \"uid:%d\" (try count %d)", uid, i+1)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
newApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
||||
var newApplicationCloudSettingSlice models.ApplicationCloudSettingSlice
|
||||
|
||||
if userAppCloudSettingUpdateReq.FullUpdate {
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings force update, will overwrite all existing settings (try count %d)", uid, i+1)
|
||||
} else {
|
||||
if len(oldApplicationCloudSettingsMap) > 0 {
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings exists, try to merge it with request settings (try count %d)", uid, i+1)
|
||||
newApplicationCloudSettingsMap = oldApplicationCloudSettingsMap
|
||||
}
|
||||
}
|
||||
|
||||
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||
newApplicationCloudSettingsMap[setting.SettingKey] = setting
|
||||
}
|
||||
|
||||
for settingKey, setting := range newApplicationCloudSettingsMap {
|
||||
settingType, exists := models.ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[settingKey]
|
||||
|
||||
if !exists {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not supported to sync (try count %d)", settingKey, i+1)
|
||||
continue
|
||||
}
|
||||
|
||||
if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING {
|
||||
// Do Nothing
|
||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER {
|
||||
_, err := utils.StringToFloat64(setting.SettingValue)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid number value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
|
||||
continue
|
||||
}
|
||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN {
|
||||
if setting.SettingValue != "true" && setting.SettingValue != "false" {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid boolean value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
|
||||
continue
|
||||
}
|
||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP {
|
||||
var settingValueMap map[string]bool
|
||||
err := json.Unmarshal([]byte(setting.SettingValue), &settingValueMap)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid map value \"%s\" (try count %d), because %s", settingKey, setting.SettingValue, i+1, err.Error())
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has unknown type \"%s\" (try count %d)", settingKey, settingType, i+1)
|
||||
continue
|
||||
}
|
||||
|
||||
newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting)
|
||||
}
|
||||
|
||||
err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice, userAppCloudSettingUpdateReq.FullUpdate, lastUpdateTime)
|
||||
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond) // Wait for 100 milliseconds before retrying
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to update user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return false, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ApplicationSettingsDisableHandler disabled user application cloud settings by request parameters for current user
|
||||
func (a *UserApplicationCloudSettingsApi) ApplicationSettingsDisableHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsDisableHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return false, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS) {
|
||||
return false, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
err = a.userAppCloudSettings.ClearUserApplicationCloudSettings(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsDisableHandler] failed to clear user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return false, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
)
|
||||
|
||||
// UserExternalAuthsApi represents user external auth api
|
||||
type UserExternalAuthsApi struct {
|
||||
users *services.UserService
|
||||
userExternalAuths *services.UserExternalAuthService
|
||||
}
|
||||
|
||||
// Initialize a user external auth api singleton instance
|
||||
var (
|
||||
UserExternalAuths = &UserExternalAuthsApi{
|
||||
users: services.Users,
|
||||
userExternalAuths: services.UserExternalAuths,
|
||||
}
|
||||
)
|
||||
|
||||
// ExternalAuthListHanlder returns external authentications list of current user
|
||||
func (a *UserExternalAuthsApi) ExternalAuthListHanlder(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
userExternalAuths, err := a.userExternalAuths.GetUserAllExternalAuthsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_external_auths.ExternalAuthListHanlder] failed to get all external authentications for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
userExternalAuthResps := make(models.UserExternalAuthInfoResponsesSlice, 0, len(userExternalAuths)+1)
|
||||
currentExternalAuthType := oauth2.GetExternalUserAuthType()
|
||||
hasCurrentExternalAuth := false
|
||||
|
||||
for i := 0; i < len(userExternalAuths); i++ {
|
||||
userExternalAuth := userExternalAuths[i]
|
||||
|
||||
if userExternalAuth.ExternalAuthType == currentExternalAuthType {
|
||||
hasCurrentExternalAuth = true
|
||||
}
|
||||
|
||||
userExternalAuthResps = append(userExternalAuthResps, userExternalAuth.ToUserExternalAuthInfoResponse())
|
||||
}
|
||||
|
||||
if !hasCurrentExternalAuth {
|
||||
userExternalAuthResps = append(userExternalAuthResps, &models.UserExternalAuthInfoResponse{
|
||||
ExternalAuthCategory: currentExternalAuthType.GetCategory(),
|
||||
ExternalAuthType: currentExternalAuthType,
|
||||
Linked: false,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Sort(userExternalAuthResps)
|
||||
|
||||
return userExternalAuthResps, nil
|
||||
}
|
||||
|
||||
// UnlinkExternalAuthHandler unlinks external authentication for current user
|
||||
func (a *UserExternalAuthsApi) UnlinkExternalAuthHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var externalAuthLinkReq models.UserExternalAuthUnlinkRequest
|
||||
err := c.ShouldBindJSON(&externalAuthLinkReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[user_external_auths.UnlinkExternalAuthHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[user_external_auths.UnlinkExternalAuthHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(externalAuthLinkReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UNLINK_THIRD_PARTY_LOGIN) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
externalAuthType := core.UserExternalAuthType(externalAuthLinkReq.ExternalAuthType)
|
||||
|
||||
if !externalAuthType.IsValid() {
|
||||
return nil, errs.ErrUserExternalAuthNotFound
|
||||
}
|
||||
|
||||
err = a.userExternalAuths.DeleteUserExternalAuth(c, uid, externalAuthType)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_external_auths.UnlinkExternalAuthHandler] failed to unlink external authentication \"%s\" for user \"uid:%d\", because %s", externalAuthLinkReq.ExternalAuthType, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
+652
-43
@@ -4,45 +4,65 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/locales"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
// UsersApi represents user api
|
||||
type UsersApi struct {
|
||||
users *services.UserService
|
||||
tokens *services.TokenService
|
||||
ApiUsingConfig
|
||||
ApiWithUserInfo
|
||||
users *services.UserService
|
||||
tokens *services.TokenService
|
||||
accounts *services.AccountService
|
||||
}
|
||||
|
||||
// Initialize a user api singleton instance
|
||||
var (
|
||||
Users = &UsersApi{
|
||||
users: services.Users,
|
||||
tokens: services.Tokens,
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiWithUserInfo: ApiWithUserInfo{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingAvatarProvider: ApiUsingAvatarProvider{
|
||||
container: avatars.Container,
|
||||
},
|
||||
},
|
||||
users: services.Users,
|
||||
tokens: services.Tokens,
|
||||
accounts: services.Accounts,
|
||||
}
|
||||
)
|
||||
|
||||
// UserRegisterHandler saves a new user by request parameters
|
||||
func (a *UsersApi) UserRegisterHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
if !settings.Container.Current.EnableUserRegister {
|
||||
func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableUserRegister {
|
||||
return nil, errs.ErrUserRegistrationNotAllowed
|
||||
}
|
||||
|
||||
var userRegisterReq models.UserRegisterRequest
|
||||
err := c.ShouldBindJSON(&userRegisterReq)
|
||||
err := c.ShouldBindBodyWith(&userRegisterReq, binding.JSON)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[users.UserRegisterHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[users.UserRegisterHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
if userRegisterReq.DefaultCurrency == validators.ParentAccountCurrencyPlaceholder {
|
||||
log.WarnfWithRequestId(c, "[users.UserRegisterHandler] user default currency is invalid")
|
||||
log.Warnf(c, "[users.UserRegisterHandler] user default currency is invalid")
|
||||
return nil, errs.ErrUserDefaultCurrencyIsInvalid
|
||||
}
|
||||
|
||||
@@ -55,73 +75,178 @@ func (a *UsersApi) UserRegisterHandler(c *core.Context) (interface{}, *errs.Erro
|
||||
Email: userRegisterReq.Email,
|
||||
Nickname: userRegisterReq.Nickname,
|
||||
Password: userRegisterReq.Password,
|
||||
Language: userRegisterReq.Language,
|
||||
DefaultCurrency: userRegisterReq.DefaultCurrency,
|
||||
FirstDayOfWeek: userRegisterReq.FirstDayOfWeek,
|
||||
FiscalYearStart: core.FISCAL_YEAR_START_DEFAULT,
|
||||
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
|
||||
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
|
||||
}
|
||||
|
||||
err = a.users.CreateUser(user)
|
||||
err = a.users.CreateUser(c, user, false)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[users.UserRegisterHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
|
||||
log.Errorf(c, "[users.UserRegisterHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[users.UserRegisterHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid)
|
||||
log.Infof(c, "[users.UserRegisterHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid)
|
||||
|
||||
authResp := &models.AuthResponse{
|
||||
Need2FA: false,
|
||||
User: user.ToUserBasicInfo(),
|
||||
presetCategoriesSaved := false
|
||||
|
||||
if len(userRegisterReq.Categories) > 0 {
|
||||
_, err = TransactionCategories.createBatchCategories(c, user.Uid, &userRegisterReq.TransactionCategoryCreateBatchRequest)
|
||||
|
||||
if err == nil {
|
||||
presetCategoriesSaved = true
|
||||
}
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateToken(user, c)
|
||||
authResp := &models.RegisterResponse{
|
||||
AuthResponse: models.AuthResponse{
|
||||
Need2FA: false,
|
||||
User: a.GetUserBasicInfo(user),
|
||||
NotificationContent: a.GetAfterRegisterNotificationContent(user.Language, c.GetClientLocale()),
|
||||
},
|
||||
NeedVerifyEmail: a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableUserForceVerifyEmail,
|
||||
PresetCategoriesSaved: presetCategoriesSaved,
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableSMTP {
|
||||
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserRegisterHandler] failed to create email verify token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
} else {
|
||||
go func() {
|
||||
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[users.UserRegisterHandler] cannot send verify email to \"%s\", because %s", user.Email, err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableUserForceVerifyEmail {
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[users.UserRegisterHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Warnf(c, "[users.UserRegisterHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
authResp.Token = token
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.InfofWithRequestId(c, "[users.UserRegisterHandler] user \"uid:%d\" has logined, token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
log.Infof(c, "[users.UserRegisterHandler] user \"uid:%d\" has logged in, token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
// UserProfileHandler returns user profile of current user
|
||||
func (a *UsersApi) UserProfileHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
// UserEmailVerifyHandler sets user email address verified
|
||||
func (a *UsersApi) UserEmailVerifyHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var userVerifyEmailReq models.UserVerifyEmailRequest
|
||||
err := c.ShouldBindJSON(&userVerifyEmailReq)
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(uid)
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.ErrorfWithRequestId(c, "[users.UserRegisterHandler] failed to get user, because %s", err.Error())
|
||||
log.Errorf(c, "[users.UserEmailVerifyHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
userResp := user.ToUserProfileResponse()
|
||||
if user.Disabled {
|
||||
log.Warnf(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" is disabled", user.Uid)
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
log.Warnf(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" email has been verified", user.Uid)
|
||||
return nil, errs.ErrEmailIsVerified
|
||||
}
|
||||
|
||||
err = a.users.SetUserEmailVerified(c, user.Username)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserEmailVerifyHandler] failed to update user \"uid:%d\" email address verified, because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.tokens.DeleteTokensByType(c, uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY)
|
||||
|
||||
if err == nil {
|
||||
log.Infof(c, "[users.UserEmailVerifyHandler] revoke old email verify tokens for user \"uid:%d\"", user.Uid)
|
||||
} else {
|
||||
log.Warnf(c, "[users.UserEmailVerifyHandler] failed to revoke old email verify tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
}
|
||||
|
||||
resp := &models.UserVerifyEmailResponse{}
|
||||
|
||||
if userVerifyEmailReq.RequestNewToken {
|
||||
token, claims, err := a.tokens.CreateToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[users.UserEmailVerifyHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.NewToken = token
|
||||
resp.User = a.GetUserBasicInfo(user)
|
||||
resp.NotificationContent = a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale())
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.Infof(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" token created, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// UserProfileHandler returns user profile of current user
|
||||
func (a *UsersApi) UserProfileHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[users.UserRegisterHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
userResp := a.getUserProfileResponse(user)
|
||||
return userResp, nil
|
||||
}
|
||||
|
||||
// UserUpdateProfileHandler saves user profile by request parameters for current user
|
||||
func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs.Error) {
|
||||
func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var userUpdateReq models.UserProfileUpdateRequest
|
||||
err := c.ShouldBindJSON(&userUpdateReq)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[users.UserUpdateProfileHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(uid)
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.ErrorfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to get user, because %s", err.Error())
|
||||
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
@@ -130,6 +255,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs
|
||||
userUpdateReq.Email = strings.TrimSpace(userUpdateReq.Email)
|
||||
userUpdateReq.Nickname = strings.TrimSpace(userUpdateReq.Nickname)
|
||||
|
||||
modifyProfileBasicInfo := false
|
||||
anythingUpdate := false
|
||||
userNew := &models.User{
|
||||
Uid: user.Uid,
|
||||
@@ -137,13 +263,21 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs
|
||||
}
|
||||
|
||||
if userUpdateReq.Email != "" && userUpdateReq.Email != user.Email {
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
user.Email = userUpdateReq.Email
|
||||
userNew.Email = userUpdateReq.Email
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.Password != "" {
|
||||
if !a.users.IsPasswordEqualsUserPassword(userUpdateReq.OldPassword, user) {
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if user.Password != "" && !a.users.IsPasswordEqualsUserPassword(userUpdateReq.OldPassword, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
@@ -156,72 +290,547 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs
|
||||
if userUpdateReq.Nickname != "" && userUpdateReq.Nickname != user.Nickname {
|
||||
user.Nickname = userUpdateReq.Nickname
|
||||
userNew.Nickname = userUpdateReq.Nickname
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.DefaultAccountId > 0 && userUpdateReq.DefaultAccountId != user.DefaultAccountId {
|
||||
accountMap, err := a.accounts.GetAccountsByAccountIds(c, uid, []int64{userUpdateReq.DefaultAccountId})
|
||||
|
||||
if err != nil || len(accountMap) < 1 {
|
||||
return nil, errs.Or(err, errs.ErrUserDefaultAccountIsInvalid)
|
||||
}
|
||||
|
||||
if _, exists := accountMap[userUpdateReq.DefaultAccountId]; !exists {
|
||||
log.Warnf(c, "[users.UserUpdateProfileHandler] account \"id:%d\" does not exist for user \"uid:%d\"", userUpdateReq.DefaultAccountId, uid)
|
||||
return nil, errs.ErrUserDefaultAccountIsInvalid
|
||||
}
|
||||
|
||||
if accountMap[userUpdateReq.DefaultAccountId].Hidden {
|
||||
log.Warnf(c, "[users.UserUpdateProfileHandler] account \"id:%d\" is hidden of user \"uid:%d\"", userUpdateReq.DefaultAccountId, uid)
|
||||
return nil, errs.ErrUserDefaultAccountIsHidden
|
||||
}
|
||||
|
||||
user.DefaultAccountId = userUpdateReq.DefaultAccountId
|
||||
userNew.DefaultAccountId = userUpdateReq.DefaultAccountId
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.TransactionEditScope != nil && *userUpdateReq.TransactionEditScope != user.TransactionEditScope {
|
||||
user.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
userNew.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.TransactionEditScope = models.TRANSACTION_EDIT_SCOPE_INVALID
|
||||
}
|
||||
|
||||
modifyUserLanguage := false
|
||||
|
||||
if userUpdateReq.Language != user.Language {
|
||||
user.Language = userUpdateReq.Language
|
||||
userNew.Language = userUpdateReq.Language
|
||||
modifyUserLanguage = true
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.DefaultCurrency != "" && userUpdateReq.DefaultCurrency != user.DefaultCurrency {
|
||||
user.DefaultCurrency = userUpdateReq.DefaultCurrency
|
||||
userNew.DefaultCurrency = userUpdateReq.DefaultCurrency
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.FirstDayOfWeek != nil && *userUpdateReq.FirstDayOfWeek != user.FirstDayOfWeek {
|
||||
user.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
|
||||
userNew.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.FirstDayOfWeek = models.WEEKDAY_INVALID
|
||||
userNew.FirstDayOfWeek = core.WEEKDAY_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.TransactionEditScope != nil && *userUpdateReq.TransactionEditScope != user.TransactionEditScope {
|
||||
user.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
userNew.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
if userUpdateReq.FiscalYearStart != nil && *userUpdateReq.FiscalYearStart != user.FiscalYearStart {
|
||||
user.FiscalYearStart = *userUpdateReq.FiscalYearStart
|
||||
userNew.FiscalYearStart = *userUpdateReq.FiscalYearStart
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.TransactionEditScope = models.TRANSACTION_EDIT_SCOPE_INVALID
|
||||
userNew.FiscalYearStart = core.FISCAL_YEAR_START_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.CalendarDisplayType != nil && *userUpdateReq.CalendarDisplayType != user.CalendarDisplayType {
|
||||
user.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
|
||||
userNew.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.CalendarDisplayType = core.CALENDAR_DISPLAY_TYPE_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.DateDisplayType != nil && *userUpdateReq.DateDisplayType != user.DateDisplayType {
|
||||
user.DateDisplayType = *userUpdateReq.DateDisplayType
|
||||
userNew.DateDisplayType = *userUpdateReq.DateDisplayType
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.DateDisplayType = core.DATE_DISPLAY_TYPE_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
|
||||
user.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.LongDateFormat = core.LONG_DATE_FORMAT_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.ShortDateFormat != nil && *userUpdateReq.ShortDateFormat != user.ShortDateFormat {
|
||||
user.ShortDateFormat = *userUpdateReq.ShortDateFormat
|
||||
userNew.ShortDateFormat = *userUpdateReq.ShortDateFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.ShortDateFormat = core.SHORT_DATE_FORMAT_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.LongTimeFormat != nil && *userUpdateReq.LongTimeFormat != user.LongTimeFormat {
|
||||
user.LongTimeFormat = *userUpdateReq.LongTimeFormat
|
||||
userNew.LongTimeFormat = *userUpdateReq.LongTimeFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.LongTimeFormat = core.LONG_TIME_FORMAT_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.ShortTimeFormat != nil && *userUpdateReq.ShortTimeFormat != user.ShortTimeFormat {
|
||||
user.ShortTimeFormat = *userUpdateReq.ShortTimeFormat
|
||||
userNew.ShortTimeFormat = *userUpdateReq.ShortTimeFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.ShortTimeFormat = core.SHORT_TIME_FORMAT_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.FiscalYearFormat != nil && *userUpdateReq.FiscalYearFormat != user.FiscalYearFormat {
|
||||
user.FiscalYearFormat = *userUpdateReq.FiscalYearFormat
|
||||
userNew.FiscalYearFormat = *userUpdateReq.FiscalYearFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.FiscalYearFormat = core.FISCAL_YEAR_FORMAT_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
|
||||
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.NumeralSystem != nil && *userUpdateReq.NumeralSystem != user.NumeralSystem {
|
||||
user.NumeralSystem = *userUpdateReq.NumeralSystem
|
||||
userNew.NumeralSystem = *userUpdateReq.NumeralSystem
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.NumeralSystem = core.NUMERAL_SYSTEM_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
|
||||
user.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.DecimalSeparator = core.DECIMAL_SEPARATOR_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.DigitGroupingSymbol != nil && *userUpdateReq.DigitGroupingSymbol != user.DigitGroupingSymbol {
|
||||
user.DigitGroupingSymbol = *userUpdateReq.DigitGroupingSymbol
|
||||
userNew.DigitGroupingSymbol = *userUpdateReq.DigitGroupingSymbol
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.DigitGroupingSymbol = core.DIGIT_GROUPING_SYMBOL_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.DigitGrouping != nil && *userUpdateReq.DigitGrouping != user.DigitGrouping {
|
||||
user.DigitGrouping = *userUpdateReq.DigitGrouping
|
||||
userNew.DigitGrouping = *userUpdateReq.DigitGrouping
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.CoordinateDisplayType != nil && *userUpdateReq.CoordinateDisplayType != user.CoordinateDisplayType {
|
||||
user.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
|
||||
userNew.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.CoordinateDisplayType = core.COORDINATE_DISPLAY_TYPE_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.ExpenseAmountColor != nil && *userUpdateReq.ExpenseAmountColor != user.ExpenseAmountColor {
|
||||
user.ExpenseAmountColor = *userUpdateReq.ExpenseAmountColor
|
||||
userNew.ExpenseAmountColor = *userUpdateReq.ExpenseAmountColor
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.ExpenseAmountColor = models.AMOUNT_COLOR_TYPE_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.IncomeAmountColor != nil && *userUpdateReq.IncomeAmountColor != user.IncomeAmountColor {
|
||||
user.IncomeAmountColor = *userUpdateReq.IncomeAmountColor
|
||||
userNew.IncomeAmountColor = *userUpdateReq.IncomeAmountColor
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.IncomeAmountColor = models.AMOUNT_COLOR_TYPE_INVALID
|
||||
}
|
||||
|
||||
if modifyProfileBasicInfo && user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if modifyUserLanguage || userNew.DecimalSeparator != core.DECIMAL_SEPARATOR_INVALID || userNew.DigitGroupingSymbol != core.DIGIT_GROUPING_SYMBOL_INVALID {
|
||||
decimalSeparator := userNew.DecimalSeparator
|
||||
digitGroupingSymbol := userNew.DigitGroupingSymbol
|
||||
|
||||
if userNew.DecimalSeparator == core.DECIMAL_SEPARATOR_INVALID {
|
||||
decimalSeparator = user.DecimalSeparator
|
||||
}
|
||||
|
||||
if userNew.DigitGroupingSymbol == core.DIGIT_GROUPING_SYMBOL_INVALID {
|
||||
digitGroupingSymbol = user.DigitGroupingSymbol
|
||||
}
|
||||
|
||||
locale := user.Language
|
||||
|
||||
if modifyUserLanguage {
|
||||
locale = userNew.Language
|
||||
}
|
||||
|
||||
if locale == "" {
|
||||
locale = c.GetClientLocale()
|
||||
}
|
||||
|
||||
if locales.IsDecimalSeparatorEqualsDigitGroupingSymbol(decimalSeparator, digitGroupingSymbol, locale) {
|
||||
return nil, errs.ErrDecimalSeparatorAndDigitGroupingSymbolCannotBeEqual
|
||||
}
|
||||
}
|
||||
|
||||
if !anythingUpdate {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
keyProfileUpdated, err := a.users.UpdateUser(userNew)
|
||||
keyProfileUpdated, emailSetToUnverified, err := a.users.UpdateUser(c, userNew, modifyUserLanguage)
|
||||
|
||||
if err != nil {
|
||||
log.ErrorfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" has updated successfully", user.Uid)
|
||||
if emailSetToUnverified {
|
||||
user.EmailVerified = false
|
||||
}
|
||||
|
||||
log.Infof(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" has updated successfully", user.Uid)
|
||||
|
||||
resp := &models.UserProfileUpdateResponse{
|
||||
User: user.ToUserBasicInfo(),
|
||||
User: a.GetUserBasicInfo(user),
|
||||
}
|
||||
|
||||
if emailSetToUnverified && a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableSMTP {
|
||||
err = a.tokens.DeleteTokensByType(c, uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to revoke old email verify tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
} else {
|
||||
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to create email verify token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
} else {
|
||||
go func() {
|
||||
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[users.UserUpdateProfileHandler] cannot send verify email to \"%s\", because %s", user.Email, err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if keyProfileUpdated {
|
||||
now := time.Now().Unix()
|
||||
err = a.tokens.DeleteTokensBeforeTime(uid, now)
|
||||
err = a.tokens.DeleteTokensBeforeTime(c, uid, now)
|
||||
|
||||
if err == nil {
|
||||
log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
|
||||
log.Infof(c, "[users.UserUpdateProfileHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
|
||||
} else {
|
||||
log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Warnf(c, "[users.UserUpdateProfileHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateToken(user, c)
|
||||
token, claims, err := a.tokens.CreateToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
log.Warnf(c, "[users.UserUpdateProfileHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.NewToken = token
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
log.Infof(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// UserUpdateAvatarHandler saves user avatar by request parameters for current user
|
||||
func (a *UsersApi) UserUpdateAvatarHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
form, err := c.MultipartForm()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to get multi-part form data for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.ErrParameterInvalid
|
||||
}
|
||||
|
||||
avatarFiles := form.File["avatar"]
|
||||
|
||||
if len(avatarFiles) < 1 {
|
||||
log.Warnf(c, "[users.UserUpdateAvatarHandler] there is no user avatar in request for user \"uid:%d\"", user.Uid)
|
||||
return nil, errs.ErrNoUserAvatar
|
||||
}
|
||||
|
||||
if avatarFiles[0].Size < 1 {
|
||||
log.Warnf(c, "[users.UserUpdateAvatarHandler] the size of user avatar in request is zero for user \"uid:%d\"", user.Uid)
|
||||
return nil, errs.ErrUserAvatarIsEmpty
|
||||
}
|
||||
|
||||
if avatarFiles[0].Size > int64(a.CurrentConfig().MaxAvatarFileSize) {
|
||||
log.Warnf(c, "[users.UserUpdateAvatarHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of user avatar for user \"uid:%d\"", avatarFiles[0].Size, a.CurrentConfig().MaxAvatarFileSize, uid)
|
||||
return nil, errs.ErrExceedMaxUserAvatarFileSize
|
||||
}
|
||||
|
||||
fileExtension := utils.GetFileNameExtension(avatarFiles[0].Filename)
|
||||
|
||||
if utils.GetImageContentType(fileExtension) == "" {
|
||||
log.Warnf(c, "[users.UserUpdateAvatarHandler] the file extension \"%s\" of user avatar in request is not supported for user \"uid:%d\"", fileExtension, user.Uid)
|
||||
return nil, errs.ErrImageTypeNotSupported
|
||||
}
|
||||
|
||||
avatarFile, err := avatarFiles[0].Open()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to get avatar file from request for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
err = a.users.UpdateUserAvatar(c, user.Uid, avatarFile, fileExtension, user.CustomAvatarType)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to update avatar for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
user.CustomAvatarType = fileExtension
|
||||
userResp := a.getUserProfileResponse(user)
|
||||
return userResp, nil
|
||||
}
|
||||
|
||||
// UserRemoveAvatarHandler removes user avatar by request parameters for current user
|
||||
func (a *UsersApi) UserRemoveAvatarHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[users.UserRemoveAvatarHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if user.CustomAvatarType == "" {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
err = a.users.RemoveUserAvatar(c, user.Uid, user.CustomAvatarType)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserRemoveAvatarHandler] failed to remove avatar for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
user.CustomAvatarType = ""
|
||||
userResp := a.getUserProfileResponse(user)
|
||||
return userResp, nil
|
||||
}
|
||||
|
||||
// UserSendVerifyEmailByUnloginUserHandler sends unlogin user verify email
|
||||
func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableUserVerifyEmail {
|
||||
return nil, errs.ErrEmailValidationNotAllowed
|
||||
}
|
||||
|
||||
var userResendVerifyEmailReq models.UserResendVerifyEmailRequest
|
||||
err := c.ShouldBindJSON(&userResendVerifyEmailReq)
|
||||
|
||||
user, err := a.users.GetUserByEmail(c, userResendVerifyEmailReq.Email)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(userResendVerifyEmailReq.Password, user) {
|
||||
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] request password not equals to the user password")
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" is disabled", user.Uid)
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" email has been verified", user.Uid)
|
||||
return nil, errs.ErrEmailIsVerified
|
||||
}
|
||||
|
||||
if !a.CurrentConfig().EnableSMTP {
|
||||
return nil, errs.ErrSMTPServerNotEnabled
|
||||
}
|
||||
|
||||
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.ErrTokenGenerating
|
||||
}
|
||||
|
||||
go func() {
|
||||
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// UserSendVerifyEmailByLoginedUserHandler sends logined user verify email
|
||||
func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableUserVerifyEmail {
|
||||
return nil, errs.ErrEmailValidationNotAllowed
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
log.Warnf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] user \"uid:%d\" email has been verified", user.Uid)
|
||||
return nil, errs.ErrEmailIsVerified
|
||||
}
|
||||
|
||||
if !a.CurrentConfig().EnableSMTP {
|
||||
return nil, errs.ErrSMTPServerNotEnabled
|
||||
}
|
||||
|
||||
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.ErrTokenGenerating
|
||||
}
|
||||
|
||||
go func() {
|
||||
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// UserGetAvatarHandler returns user avatar data for current user
|
||||
func (a *UsersApi) UserGetAvatarHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
|
||||
fileName := c.Param("fileName")
|
||||
fileExtension := utils.GetFileNameExtension(fileName)
|
||||
contentType := utils.GetImageContentType(fileExtension)
|
||||
|
||||
if contentType == "" {
|
||||
return nil, "", errs.ErrImageTypeNotSupported
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
fileBaseName := utils.GetFileNameWithoutExtension(fileName)
|
||||
|
||||
if utils.Int64ToString(uid) != fileBaseName {
|
||||
log.Warnf(c, "[users.UserGetAvatarHandler] cannot get other user avatar \"uid:%s\" for user \"uid:%d\"", fileBaseName, uid)
|
||||
return nil, "", errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
avatarData, err := a.users.GetUserAvatar(c, uid, fileExtension)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[users.UserGetAvatarHandler] failed to get user avatar, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, "", errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return avatarData, contentType, nil
|
||||
}
|
||||
|
||||
func (a *UsersApi) getUserProfileResponse(user *models.User) *models.UserProfileResponse {
|
||||
return user.ToUserProfileResponse(a.GetUserBasicInfo(user))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package data
|
||||
|
||||
import "github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
|
||||
// OAuth2UserInfo represents the user info retrieved from OAuth 2.0 provider
|
||||
type OAuth2UserInfo struct {
|
||||
UserName string
|
||||
Email string
|
||||
NickName string
|
||||
LanguageCode string
|
||||
CurrencyCode string
|
||||
FirstDayOfWeek core.WeekDay
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/gitea"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/github"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/nextcloud"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/oidc"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/httpclient"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// OAuth2Container contains the current OAuth 2.0 authentication provider
|
||||
type OAuth2Container struct {
|
||||
current provider.OAuth2Provider
|
||||
usePKCE bool
|
||||
oauth2HttpClient *http.Client
|
||||
externalUserAuthType core.UserExternalAuthType
|
||||
}
|
||||
|
||||
// Initialize a OAuth 2.0 container singleton instance
|
||||
var (
|
||||
Container = &OAuth2Container{}
|
||||
)
|
||||
|
||||
// InitializeOAuth2Provider initializes the current OAuth 2.0 provider according to the config
|
||||
func InitializeOAuth2Provider(config *settings.Config) error {
|
||||
if !config.EnableOAuth2Login {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.OAuth2ClientID == "" || config.OAuth2ClientSecret == "" || config.OAuth2UserIdentifier == "" || config.OAuth2Provider == "" {
|
||||
return errs.ErrInvalidOAuth2Config
|
||||
}
|
||||
|
||||
var err error
|
||||
var oauth2Provider provider.OAuth2Provider
|
||||
var externalUserAuthType core.UserExternalAuthType
|
||||
redirectUrl := config.RootUrl + "oauth2/callback"
|
||||
|
||||
if config.OAuth2Provider == settings.OAuth2ProviderOIDC {
|
||||
oauth2Provider, err = oidc.NewOIDCProvider(config, redirectUrl)
|
||||
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_OIDC
|
||||
} else if config.OAuth2Provider == settings.OAuth2ProviderNextcloud {
|
||||
oauth2Provider, err = nextcloud.NewNextcloudOAuth2Provider(config, redirectUrl)
|
||||
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD
|
||||
} else if config.OAuth2Provider == settings.OAuth2ProviderGitea {
|
||||
oauth2Provider, err = gitea.NewGiteaOAuth2Provider(config, redirectUrl)
|
||||
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITEA
|
||||
} else if config.OAuth2Provider == settings.OAuth2ProviderGithub {
|
||||
oauth2Provider, err = github.NewGithubOAuth2Provider(config, redirectUrl)
|
||||
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITHUB
|
||||
} else {
|
||||
return errs.ErrInvalidOAuth2Provider
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Container.current = oauth2Provider
|
||||
Container.usePKCE = config.OAuth2UsePKCE
|
||||
Container.oauth2HttpClient = httpclient.NewHttpClient(config.OAuth2RequestTimeout, config.OAuth2Proxy, config.OAuth2SkipTLSVerify, core.GetOutgoingUserAgent(), config.EnableDebugLog)
|
||||
Container.externalUserAuthType = externalUserAuthType
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOAuth2AuthUrl returns the OAuth 2.0 authentication url
|
||||
func GetOAuth2AuthUrl(c core.Context, state string, verifier string) (string, error) {
|
||||
if Container.current == nil {
|
||||
return "", errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
var opts []oauth2.AuthCodeOption
|
||||
|
||||
if Container.usePKCE {
|
||||
opts = append(opts, oauth2.S256ChallengeOption(verifier))
|
||||
}
|
||||
|
||||
return Container.current.GetOAuth2AuthUrl(wrapOAuth2Context(c, Container.oauth2HttpClient), state, opts...)
|
||||
}
|
||||
|
||||
// GetOAuth2Token exchanges the authorization code for an OAuth 2.0 token
|
||||
func GetOAuth2Token(c core.Context, code string, verifier string) (*oauth2.Token, error) {
|
||||
if Container.current == nil || Container.oauth2HttpClient == nil {
|
||||
return nil, errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
var opts []oauth2.AuthCodeOption
|
||||
|
||||
if Container.usePKCE {
|
||||
opts = append(opts, oauth2.VerifierOption(verifier))
|
||||
}
|
||||
|
||||
return Container.current.GetOAuth2Token(wrapOAuth2Context(c, Container.oauth2HttpClient), code, opts...)
|
||||
}
|
||||
|
||||
// GetOAuth2UserInfo retrieves the OAuth 2.0 user info using the provided OAuth 2.0 token
|
||||
func GetOAuth2UserInfo(c core.Context, token *oauth2.Token) (*data.OAuth2UserInfo, error) {
|
||||
if Container.current == nil || Container.oauth2HttpClient == nil {
|
||||
return nil, errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
return nil, errs.ErrInvalidOAuth2Token
|
||||
}
|
||||
|
||||
return Container.current.GetUserInfo(wrapOAuth2Context(c, Container.oauth2HttpClient), token)
|
||||
}
|
||||
|
||||
// GetExternalUserAuthType returns the external user auth type of the current OAuth 2.0 provider
|
||||
func GetExternalUserAuthType() core.UserExternalAuthType {
|
||||
return Container.externalUserAuthType
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
// OAuth2Context represents the context for OAuth 2.0 operations
|
||||
type OAuth2Context struct {
|
||||
core.Context
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Value returns the value associated with key
|
||||
func (c *OAuth2Context) Value(key any) any {
|
||||
if key == oauth2.HTTPClient {
|
||||
return c.httpClient
|
||||
}
|
||||
|
||||
return c.Context.Value(key)
|
||||
}
|
||||
|
||||
func wrapOAuth2Context(ctx core.Context, httpClient *http.Client) core.Context {
|
||||
return &OAuth2Context{
|
||||
Context: ctx,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/httpclient"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// CommonOAuth2Provider represents common OAuth 2.0 provider
|
||||
type CommonOAuth2Provider struct {
|
||||
provider.OAuth2Provider
|
||||
oauth2Config *oauth2.Config
|
||||
dataSource CommonOAuth2DataSource
|
||||
}
|
||||
|
||||
// CommonOAuth2DataSource defines the structure of OAuth 2.0 data source
|
||||
type CommonOAuth2DataSource interface {
|
||||
// GetAuthUrl returns the authentication url of the data source
|
||||
GetAuthUrl() string
|
||||
|
||||
// GetTokenUrl returns the token url of the data source
|
||||
GetTokenUrl() string
|
||||
|
||||
// GetUserInfoRequest returns the user info request of the data source
|
||||
GetUserInfoRequest() (*http.Request, error)
|
||||
|
||||
// GetScopes returns the scopes required by the data source
|
||||
GetScopes() []string
|
||||
|
||||
// ParseUserInfo returns the user info by parsing the response body
|
||||
ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error)
|
||||
}
|
||||
|
||||
// GetOAuth2AuthUrl returns the authentication url of the common OAuth 2.0 provider
|
||||
func (p *CommonOAuth2Provider) GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error) {
|
||||
return p.oauth2Config.AuthCodeURL(state, opts...), nil
|
||||
}
|
||||
|
||||
// GetOAuth2Token returns the OAuth 2.0 token of the common OAuth 2.0 provider
|
||||
func (p *CommonOAuth2Provider) GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
return p.oauth2Config.Exchange(c, code, opts...)
|
||||
}
|
||||
|
||||
// GetUserInfo returns the user info by the common OAuth 2.0 provider
|
||||
func (p *CommonOAuth2Provider) GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error) {
|
||||
req, err := p.dataSource.GetUserInfoRequest()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info request, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
oauth2Client := oauth2.NewClient(c, oauth2.StaticTokenSource(oauth2Token))
|
||||
|
||||
req = req.WithContext(httpclient.CustomHttpResponseLog(c, func(data []byte) {
|
||||
log.Debugf(c, "[common_oauth2_provider.GetUserInfo] response is %s", data)
|
||||
}))
|
||||
|
||||
resp, err := oauth2Client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info response, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return p.dataSource.ParseUserInfo(c, body)
|
||||
}
|
||||
|
||||
// GetDataSource returns the data source of the common OAuth 2.0 provider
|
||||
func (p *CommonOAuth2Provider) GetDataSource() CommonOAuth2DataSource {
|
||||
return p.dataSource
|
||||
}
|
||||
|
||||
// NewCommonOAuth2Provider returns a new common OAuth 2.0 provider
|
||||
func NewCommonOAuth2Provider(config *settings.Config, redirectUrl string, dataSource CommonOAuth2DataSource) *CommonOAuth2Provider {
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: config.OAuth2ClientID,
|
||||
ClientSecret: config.OAuth2ClientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: dataSource.GetAuthUrl(),
|
||||
TokenURL: dataSource.GetTokenUrl(),
|
||||
},
|
||||
RedirectURL: redirectUrl,
|
||||
Scopes: dataSource.GetScopes(),
|
||||
}
|
||||
|
||||
return &CommonOAuth2Provider{
|
||||
oauth2Config: oauth2Config,
|
||||
dataSource: dataSource,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
type giteaUserInfoResponse struct {
|
||||
Login string `json:"login"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// GiteaOAuth2DataSource represents Gitea OAuth 2.0 data source
|
||||
type GiteaOAuth2DataSource struct {
|
||||
common.CommonOAuth2DataSource
|
||||
baseUrl string
|
||||
}
|
||||
|
||||
// GetAuthUrl returns the authentication url of the Gitea data source
|
||||
func (s *GiteaOAuth2DataSource) GetAuthUrl() string {
|
||||
// Reference: https://docs.gitea.com/development/oauth2-provider
|
||||
return s.baseUrl + "login/oauth/authorize"
|
||||
}
|
||||
|
||||
// GetTokenUrl returns the token url of the Gitea data source
|
||||
func (s *GiteaOAuth2DataSource) GetTokenUrl() string {
|
||||
// Reference: https://docs.gitea.com/development/oauth2-provider
|
||||
return s.baseUrl + "login/oauth/access_token"
|
||||
}
|
||||
|
||||
// GetUserInfoRequest returns the user info request of the Gitea data source
|
||||
func (s *GiteaOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) {
|
||||
// Reference: https://gitea.com/api/swagger#/user/userGetCurrent
|
||||
req, err := http.NewRequest("GET", s.baseUrl+"api/v1/user", nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetScopes returns the scopes required by the Gitea provider
|
||||
func (s *GiteaOAuth2DataSource) GetScopes() []string {
|
||||
return []string{"read:user"}
|
||||
}
|
||||
|
||||
// ParseUserInfo returns the user info by parsing the response body
|
||||
func (s *GiteaOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) {
|
||||
userInfoResp := &giteaUserInfoResponse{}
|
||||
err := json.Unmarshal(body, &userInfoResp)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[gitea_oauth2_datasource.ParseUserInfo] failed to parse user profile response body, because %s", err.Error())
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userInfoResp.Login == "" {
|
||||
log.Warnf(c, "[gitea_oauth2_datasource.ParseUserInfo] invalid user profile response body")
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
return &data.OAuth2UserInfo{
|
||||
UserName: userInfoResp.Login,
|
||||
Email: userInfoResp.Email,
|
||||
NickName: userInfoResp.FullName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewGiteaOAuth2Provider creates a new Gitea OAuth 2.0 provider instance
|
||||
func NewGiteaOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) {
|
||||
if len(config.OAuth2GiteaBaseUrl) < 1 {
|
||||
return nil, errs.ErrInvalidOAuth2Config
|
||||
}
|
||||
|
||||
baseUrl := config.OAuth2GiteaBaseUrl
|
||||
|
||||
if baseUrl[len(baseUrl)-1] != '/' {
|
||||
baseUrl += "/"
|
||||
}
|
||||
|
||||
return common.NewCommonOAuth2Provider(config, redirectUrl, &GiteaOAuth2DataSource{
|
||||
baseUrl: baseUrl,
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
func TestNewGiteaOAuth2Provider(t *testing.T) {
|
||||
provider, err := NewGiteaOAuth2Provider(&settings.Config{
|
||||
OAuth2GiteaBaseUrl: "https://example.com/",
|
||||
}, "")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "https://example.com/login/oauth/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
|
||||
assert.Equal(t, "https://example.com/login/oauth/access_token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
|
||||
|
||||
provider, err = NewGiteaOAuth2Provider(&settings.Config{
|
||||
OAuth2GiteaBaseUrl: "https://example.com",
|
||||
}, "")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "https://example.com/login/oauth/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
|
||||
assert.Equal(t, "https://example.com/login/oauth/access_token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
|
||||
|
||||
provider, err = NewGiteaOAuth2Provider(&settings.Config{}, "")
|
||||
assert.Equal(t, errs.ErrInvalidOAuth2Config, err)
|
||||
}
|
||||
|
||||
func TestGiteaOAuth2Datasource_GetUserInfoRequest(t *testing.T) {
|
||||
datasource := &GiteaOAuth2DataSource{baseUrl: "https://example.com/"}
|
||||
req, err := datasource.GetUserInfoRequest()
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "GET", req.Method)
|
||||
assert.Equal(t, "https://example.com/api/v1/user", req.URL.String())
|
||||
assert.Equal(t, "application/json", req.Header.Get("Accept"))
|
||||
}
|
||||
|
||||
func TestGiteaOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
|
||||
datasource := &GiteaOAuth2DataSource{}
|
||||
responseContent := `{
|
||||
"login": "user1",
|
||||
"full_name": "User",
|
||||
"email": "user1@example.com"
|
||||
}`
|
||||
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "user1", info.UserName)
|
||||
assert.Equal(t, "user1@example.com", info.Email)
|
||||
assert.Equal(t, "User", info.NickName)
|
||||
}
|
||||
|
||||
func TestGiteaOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
|
||||
datasource := &GiteaOAuth2DataSource{}
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
|
||||
func TestGiteaOAuth2Datasource_ParseUserInfo_EmptyLogin(t *testing.T) {
|
||||
datasource := &GiteaOAuth2DataSource{}
|
||||
responseContent := `{"login": ""}`
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/httpclient"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const githubOAuth2AuthUrl = "https://github.com/login/oauth/authorize" // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
|
||||
const githubOAuth2TokenUrl = "https://github.com/login/oauth/access_token" // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
|
||||
const githubUserProfileApiUrl = "https://api.github.com/user" // Reference: https://docs.github.com/en/rest/users/users
|
||||
const githubUserEmailApiUrl = "https://api.github.com/user/emails" // Reference: https://docs.github.com/en/rest/users/emails
|
||||
|
||||
var githubOAuth2Scopes = []string{"user:email"}
|
||||
|
||||
type githubUserProfileResponse struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type githubUserEmailsResponse struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// GithubOAuth2Provider represents Github OAuth 2.0 provider
|
||||
type GithubOAuth2Provider struct {
|
||||
provider.OAuth2Provider
|
||||
oauth2Config *oauth2.Config
|
||||
}
|
||||
|
||||
// GetOAuth2AuthUrl returns the authentication url of the GitHub OAuth 2.0 provider
|
||||
func (p *GithubOAuth2Provider) GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error) {
|
||||
return p.oauth2Config.AuthCodeURL(state, opts...), nil
|
||||
}
|
||||
|
||||
// GetOAuth2Token returns the OAuth 2.0 token of the GitHub OAuth 2.0 provider
|
||||
func (p *GithubOAuth2Provider) GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
return p.oauth2Config.Exchange(c, code, opts...)
|
||||
}
|
||||
|
||||
// GetUserInfo returns the user info by the Github OAuth 2.0 provider
|
||||
func (p *GithubOAuth2Provider) GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error) {
|
||||
// first get user name and nick name from user profile
|
||||
req, err := p.buildAPIRequest(githubUserProfileApiUrl)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user info request, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
oauth2Client := oauth2.NewClient(c, oauth2.StaticTokenSource(oauth2Token))
|
||||
|
||||
req = req.WithContext(httpclient.CustomHttpResponseLog(c, func(data []byte) {
|
||||
log.Debugf(c, "[github_oauth2_provider.GetUserInfo] user profile response is %s", data)
|
||||
}))
|
||||
|
||||
resp, err := oauth2Client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user info response, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
userProfileResp, err := p.parseUserProfile(c, body)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// then get user primary email
|
||||
req, err = p.buildAPIRequest(githubUserEmailApiUrl)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user emails request, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
req = req.WithContext(httpclient.CustomHttpResponseLog(c, func(data []byte) {
|
||||
log.Debugf(c, "[github_oauth2_provider.GetUserInfo] user emails response is %s", data)
|
||||
}))
|
||||
|
||||
resp, err = oauth2Client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user emails response, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user emails response, because response code is %d", resp.StatusCode)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
email, err := p.parsePrimaryEmail(c, body)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &data.OAuth2UserInfo{
|
||||
UserName: userProfileResp.Login,
|
||||
Email: email,
|
||||
NickName: userProfileResp.Name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *GithubOAuth2Provider) parseUserProfile(c core.Context, body []byte) (*githubUserProfileResponse, error) {
|
||||
userProfileResp := &githubUserProfileResponse{}
|
||||
err := json.Unmarshal(body, &userProfileResp)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[github_oauth2_provider.parseUserProfile] failed to parse user profile response body, because %s", err.Error())
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userProfileResp.Login == "" {
|
||||
log.Warnf(c, "[github_oauth2_provider.parseUserProfile] invalid user profile response body")
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
return userProfileResp, nil
|
||||
}
|
||||
|
||||
func (p *GithubOAuth2Provider) parsePrimaryEmail(c core.Context, body []byte) (string, error) {
|
||||
emailsResp := make([]githubUserEmailsResponse, 0)
|
||||
err := json.Unmarshal(body, &emailsResp)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[github_oauth2_provider.parsePrimaryEmail] failed to parse user emails response body, because %s", err.Error())
|
||||
return "", errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
for _, emailEntry := range emailsResp {
|
||||
if emailEntry.Primary && emailEntry.Verified {
|
||||
return emailEntry.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (p *GithubOAuth2Provider) buildAPIRequest(url string) (*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewGithubOAuth2Provider creates a new Github OAuth 2.0 provider instance
|
||||
func NewGithubOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) {
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: config.OAuth2ClientID,
|
||||
ClientSecret: config.OAuth2ClientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: githubOAuth2AuthUrl,
|
||||
TokenURL: githubOAuth2TokenUrl,
|
||||
},
|
||||
RedirectURL: redirectUrl,
|
||||
Scopes: githubOAuth2Scopes,
|
||||
}
|
||||
|
||||
return &GithubOAuth2Provider{
|
||||
oauth2Config: oauth2Config,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
)
|
||||
|
||||
func TestGithubOAuth2Datasource_ParseUserProfile_Success(t *testing.T) {
|
||||
datasource := &GithubOAuth2Provider{}
|
||||
responseContent := `{
|
||||
"login": "octocat",
|
||||
"id": 1,
|
||||
"node_id": "MDQ6VXNlcjE=",
|
||||
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/octocat",
|
||||
"html_url": "https://github.com/octocat",
|
||||
"followers_url": "https://api.github.com/users/octocat/followers",
|
||||
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/octocat/orgs",
|
||||
"repos_url": "https://api.github.com/users/octocat/repos",
|
||||
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/octocat/received_events",
|
||||
"type": "User",
|
||||
"site_admin": false,
|
||||
"name": "monalisa octocat",
|
||||
"company": "GitHub",
|
||||
"blog": "https://github.com/blog",
|
||||
"location": "San Francisco",
|
||||
"email": "octocat@github.com",
|
||||
"hireable": false,
|
||||
"bio": "There once was...",
|
||||
"twitter_username": "monatheoctocat",
|
||||
"public_repos": 2,
|
||||
"public_gists": 1,
|
||||
"followers": 20,
|
||||
"following": 0,
|
||||
"created_at": "2008-01-14T04:33:35Z",
|
||||
"updated_at": "2008-01-14T04:33:35Z",
|
||||
"private_gists": 81,
|
||||
"total_private_repos": 100,
|
||||
"owned_private_repos": 100,
|
||||
"disk_usage": 10000,
|
||||
"collaborators": 8,
|
||||
"two_factor_authentication": true,
|
||||
"plan": {
|
||||
"name": "Medium",
|
||||
"space": 400,
|
||||
"private_repos": 20,
|
||||
"collaborators": 0
|
||||
}
|
||||
}`
|
||||
info, err := datasource.parseUserProfile(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "octocat", info.Login)
|
||||
assert.Equal(t, "monalisa octocat", info.Name)
|
||||
}
|
||||
|
||||
func TestGithubOAuth2Datasource_ParseUserProfile_EmptyLogin(t *testing.T) {
|
||||
datasource := &GithubOAuth2Provider{}
|
||||
responseContent := `{"login": ""}`
|
||||
_, err := datasource.parseUserProfile(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
|
||||
func TestGithubOAuth2Datasource_ParsePrimaryEmail(t *testing.T) {
|
||||
datasource := &GithubOAuth2Provider{}
|
||||
responseContent := `[
|
||||
{
|
||||
"email": "foo@bar.com",
|
||||
"primary": false,
|
||||
"verified": true,
|
||||
"visibility": null
|
||||
},
|
||||
{
|
||||
"email": "octocat@github.com",
|
||||
"primary": true,
|
||||
"verified": true,
|
||||
"visibility": "public"
|
||||
}
|
||||
]`
|
||||
email, err := datasource.parsePrimaryEmail(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "octocat@github.com", email)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package nextcloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
type nextcloudUserInfoResponse struct {
|
||||
OCS *struct {
|
||||
Meta *struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
} `json:"meta"`
|
||||
Data *struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display-name"`
|
||||
} `json:"data"`
|
||||
} `json:"ocs"`
|
||||
}
|
||||
|
||||
// NextcloudOAuth2DataSource represents Nextcloud OAuth 2.0 data source
|
||||
type NextcloudOAuth2DataSource struct {
|
||||
common.CommonOAuth2DataSource
|
||||
baseUrl string
|
||||
}
|
||||
|
||||
// GetAuthUrl returns the authentication url of the Nextcloud data source
|
||||
func (s *NextcloudOAuth2DataSource) GetAuthUrl() string {
|
||||
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/oauth2-login_redirector-authorize
|
||||
return s.baseUrl + "apps/oauth2/authorize"
|
||||
}
|
||||
|
||||
// GetTokenUrl returns the token url of the Nextcloud data source
|
||||
func (s *NextcloudOAuth2DataSource) GetTokenUrl() string {
|
||||
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/oauth2-oauth_api-get-token
|
||||
return s.baseUrl + "apps/oauth2/api/v1/token"
|
||||
}
|
||||
|
||||
// GetUserInfoRequest returns the user info request of the Nextcloud data source
|
||||
func (s *NextcloudOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) {
|
||||
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/provisioning_api-users-get-current-user
|
||||
req, err := http.NewRequest("GET", s.baseUrl+"ocs/v2.php/cloud/user", nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetScopes returns the scopes required by the Nextcloud provider
|
||||
func (s *NextcloudOAuth2DataSource) GetScopes() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// ParseUserInfo returns the user info by parsing the response body
|
||||
func (s *NextcloudOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) {
|
||||
userInfoResp := &nextcloudUserInfoResponse{}
|
||||
err := json.Unmarshal(body, &userInfoResp)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] failed to parse user info response body, because %s", err.Error())
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userInfoResp.OCS == nil || userInfoResp.OCS.Meta == nil || userInfoResp.OCS.Data == nil {
|
||||
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] invalid user info response body")
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userInfoResp.OCS.Meta.StatusCode != 200 {
|
||||
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] user info response status code is %d", userInfoResp.OCS.Meta.StatusCode)
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userInfoResp.OCS.Data.ID == "" {
|
||||
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] user info id is empty")
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
return &data.OAuth2UserInfo{
|
||||
UserName: userInfoResp.OCS.Data.ID,
|
||||
Email: userInfoResp.OCS.Data.Email,
|
||||
NickName: userInfoResp.OCS.Data.DisplayName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewNextcloudOAuth2Provider creates a new Nextcloud OAuth 2.0 provider instance
|
||||
func NewNextcloudOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) {
|
||||
if len(config.OAuth2NextcloudBaseUrl) < 1 {
|
||||
return nil, errs.ErrInvalidOAuth2Config
|
||||
}
|
||||
|
||||
baseUrl := config.OAuth2NextcloudBaseUrl
|
||||
|
||||
if baseUrl[len(baseUrl)-1] != '/' {
|
||||
baseUrl += "/"
|
||||
}
|
||||
|
||||
return common.NewCommonOAuth2Provider(config, redirectUrl, &NextcloudOAuth2DataSource{
|
||||
baseUrl: baseUrl,
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package nextcloud
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
func TestNewNextcloudOAuth2Provider(t *testing.T) {
|
||||
provider, err := NewNextcloudOAuth2Provider(&settings.Config{
|
||||
OAuth2NextcloudBaseUrl: "https://example.com/",
|
||||
}, "")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "https://example.com/apps/oauth2/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
|
||||
assert.Equal(t, "https://example.com/apps/oauth2/api/v1/token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
|
||||
|
||||
provider, err = NewNextcloudOAuth2Provider(&settings.Config{
|
||||
OAuth2NextcloudBaseUrl: "https://example.com/index.php",
|
||||
}, "")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "https://example.com/index.php/apps/oauth2/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
|
||||
assert.Equal(t, "https://example.com/index.php/apps/oauth2/api/v1/token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
|
||||
|
||||
provider, err = NewNextcloudOAuth2Provider(&settings.Config{}, "")
|
||||
assert.Equal(t, errs.ErrInvalidOAuth2Config, err)
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_GetUserInfoRequest(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{baseUrl: "https://example.com/"}
|
||||
req, err := datasource.GetUserInfoRequest()
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "GET", req.Method)
|
||||
assert.Equal(t, "https://example.com/ocs/v2.php/cloud/user", req.URL.String())
|
||||
assert.Equal(t, "application/json", req.Header.Get("Accept"))
|
||||
assert.Equal(t, "true", req.Header.Get("OCS-APIRequest"))
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{}
|
||||
responseContent := `{
|
||||
"ocs": {
|
||||
"meta": {
|
||||
"status": "ok",
|
||||
"statuscode": 200
|
||||
},
|
||||
"data": {
|
||||
"id": "user1",
|
||||
"email": "user1@example.com",
|
||||
"display-name": "User"
|
||||
}
|
||||
}
|
||||
}`
|
||||
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "user1", info.UserName)
|
||||
assert.Equal(t, "user1@example.com", info.Email)
|
||||
assert.Equal(t, "User", info.NickName)
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{}
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_ParseUserInfo_MissingFields(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{}
|
||||
responseContent := `{"ocs": {}}`
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_ParseUserInfo_Non200StatusCode(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{}
|
||||
responseContent := `{
|
||||
"ocs": {
|
||||
"meta": {
|
||||
"status": "error",
|
||||
"statuscode": 400
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}`
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_ParseUserInfo_EmptyID(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{}
|
||||
responseContent := `{
|
||||
"ocs": {
|
||||
"meta": {
|
||||
"status": "ok",
|
||||
"statuscode": 200
|
||||
},
|
||||
"data": {
|
||||
"id": "",
|
||||
"email": "user1@example.com",
|
||||
"display-name": "User One"
|
||||
}
|
||||
}
|
||||
}`
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
// OAuth2Provider defines the structure of OAuth 2.0 provider
|
||||
type OAuth2Provider interface {
|
||||
// GetOAuth2AuthUrl returns the authentication url of the provider
|
||||
GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error)
|
||||
|
||||
// GetOAuth2Token returns the OAuth 2.0 token of the provider
|
||||
GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
|
||||
|
||||
// GetUserInfo returns the user info
|
||||
GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/httpclient"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// OIDCClaims represents OIDC claims
|
||||
type OIDCClaims struct {
|
||||
PreferredUserName string `json:"preferred_username"`
|
||||
UserName string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// OIDCProvider represents OIDC provider
|
||||
type OIDCProvider struct {
|
||||
provider.OAuth2Provider
|
||||
oidcIssuerURL string
|
||||
oidcCheckIssuerURL bool
|
||||
redirectUrl string
|
||||
oauth2ClientID string
|
||||
oauth2ClientSecret string
|
||||
oauth2Config *oauth2.Config
|
||||
oidcProvider *oidc.Provider
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// GetOAuth2AuthUrl returns the authentication url of the OIDC provider
|
||||
func (p *OIDCProvider) GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error) {
|
||||
oauth2Config, err := p.getOAuth2Config(c)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return oauth2Config.AuthCodeURL(state, opts...), nil
|
||||
}
|
||||
|
||||
// GetOAuth2Token returns the OAuth 2.0 token of the OIDC provider
|
||||
func (p *OIDCProvider) GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
oauth2Config, err := p.getOAuth2Config(c)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return oauth2Config.Exchange(c, code, opts...)
|
||||
}
|
||||
|
||||
// GetUserInfo returns the user info by the OIDC provider
|
||||
func (p *OIDCProvider) GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error) {
|
||||
_, err := p.getOAuth2Config(c)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
|
||||
if !ok {
|
||||
log.Errorf(c, "[oidc_provider.GetUserInfo] missing \"id_token\" field in oauth 2.0 token")
|
||||
return nil, errs.ErrInvalidOAuth2Token
|
||||
}
|
||||
|
||||
idToken, err := p.oidcVerifier.Verify(c, rawIDToken)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to verify \"id_token\" field in oauth 2.0 token, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidOAuth2Token
|
||||
}
|
||||
|
||||
var claims OIDCClaims
|
||||
err = idToken.Claims(&claims)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to parse claims in oauth 2.0 token, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidOAuth2Token
|
||||
}
|
||||
|
||||
userName := claims.PreferredUserName
|
||||
email := claims.Email
|
||||
nickName := claims.Name
|
||||
|
||||
if userName == "" || email == "" || nickName == "" {
|
||||
userInfo, err := p.oidcProvider.UserInfo(httpclient.CustomHttpResponseLog(c, func(data []byte) {
|
||||
log.Debugf(c, "[oidc_provider.GetUserInfo] response is %s", data)
|
||||
}), oauth2.StaticTokenSource(oauth2Token))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to get user info, because %s", err.Error())
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
err = userInfo.Claims(&claims)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to parse user info, because %s", err.Error())
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userName == "" {
|
||||
userName = claims.PreferredUserName
|
||||
}
|
||||
|
||||
if userName == "" {
|
||||
userName = claims.UserName
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
email = claims.Email
|
||||
}
|
||||
|
||||
if nickName == "" {
|
||||
nickName = claims.Name
|
||||
}
|
||||
}
|
||||
|
||||
return &data.OAuth2UserInfo{
|
||||
UserName: userName,
|
||||
Email: email,
|
||||
NickName: nickName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) getOAuth2Config(c core.Context) (*oauth2.Config, error) {
|
||||
if p.oauth2Config != nil {
|
||||
return p.oauth2Config, nil
|
||||
}
|
||||
|
||||
var ctx context.Context = c
|
||||
|
||||
if !p.oidcCheckIssuerURL {
|
||||
ctx = oidc.InsecureIssuerURLContext(c, p.oidcIssuerURL)
|
||||
}
|
||||
|
||||
oidcProvider, err := oidc.NewProvider(ctx, p.oidcIssuerURL)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oidc_provider.getOAuth2Config] failed to create oidc provider, because %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oidcVerifier := oidcProvider.Verifier(&oidc.Config{
|
||||
ClientID: p.oauth2ClientID,
|
||||
SkipIssuerCheck: !p.oidcCheckIssuerURL,
|
||||
})
|
||||
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: p.oauth2ClientID,
|
||||
ClientSecret: p.oauth2ClientSecret,
|
||||
Endpoint: oidcProvider.Endpoint(),
|
||||
RedirectURL: p.redirectUrl,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
|
||||
p.oauth2Config = oauth2Config
|
||||
p.oidcProvider = oidcProvider
|
||||
p.oidcVerifier = oidcVerifier
|
||||
return oauth2Config, nil
|
||||
}
|
||||
|
||||
// NewOIDCProvider returns a new OIDC provider
|
||||
func NewOIDCProvider(config *settings.Config, redirectUrl string) (*OIDCProvider, error) {
|
||||
if len(config.OAuth2OIDCProviderIssuerURL) < 1 {
|
||||
return nil, errs.ErrInvalidOAuth2Config
|
||||
}
|
||||
|
||||
return &OIDCProvider{
|
||||
oidcIssuerURL: config.OAuth2OIDCProviderIssuerURL,
|
||||
oidcCheckIssuerURL: config.OAuth2OIDCProviderCheckIssuerURL,
|
||||
redirectUrl: redirectUrl,
|
||||
oauth2ClientID: config.OAuth2ClientID,
|
||||
oauth2ClientSecret: config.OAuth2ClientSecret,
|
||||
oauth2Config: nil,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package avatars
|
||||
|
||||
import "github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
|
||||
// AvatarProvider is user avatar provider interface
|
||||
type AvatarProvider interface {
|
||||
GetAvatarUrl(user *models.User) string
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package avatars
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// AvatarProviderContainer contains the current user avatar provider
|
||||
type AvatarProviderContainer struct {
|
||||
current AvatarProvider
|
||||
}
|
||||
|
||||
// Initialize a user avatar provider container singleton instance
|
||||
var (
|
||||
Container = &AvatarProviderContainer{}
|
||||
)
|
||||
|
||||
// InitializeAvatarProvider initializes the current user avatar provider according to the config
|
||||
func InitializeAvatarProvider(config *settings.Config) error {
|
||||
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
|
||||
Container.current = NewInternalStorageAvatarProvider(config)
|
||||
return nil
|
||||
} else if config.AvatarProvider == core.USER_AVATAR_PROVIDER_GRAVATAR {
|
||||
Container.current = NewGravatarAvatarProvider()
|
||||
return nil
|
||||
} else if config.AvatarProvider == "" {
|
||||
Container.current = NewNullAvatarProvider()
|
||||
return nil
|
||||
}
|
||||
|
||||
return errs.ErrInvalidAvatarProvider
|
||||
}
|
||||
|
||||
// GetAvatarUrl returns the avatar url by the current user avatar provider
|
||||
func (p *AvatarProviderContainer) GetAvatarUrl(user *models.User) string {
|
||||
if p.current == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return p.current.GetAvatarUrl(user)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package avatars
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// Reference: https://en.gravatar.com/site/implement/hash/
|
||||
const gravatarUrlFormat = "https://www.gravatar.com/avatar/%s"
|
||||
|
||||
// GravatarAvatarProvider represents the gravatar avatar provider
|
||||
type GravatarAvatarProvider struct {
|
||||
}
|
||||
|
||||
// NewGravatarAvatarProvider returns a new gravatar avatar provider
|
||||
func NewGravatarAvatarProvider() *GravatarAvatarProvider {
|
||||
return &GravatarAvatarProvider{}
|
||||
}
|
||||
|
||||
// GetAvatarUrl returns the gravatar url
|
||||
func (p *GravatarAvatarProvider) GetAvatarUrl(user *models.User) string {
|
||||
email := user.Email
|
||||
email = strings.TrimSpace(email)
|
||||
email = strings.ToLower(email)
|
||||
emailMd5 := utils.MD5EncodeToString([]byte(email))
|
||||
|
||||
return fmt.Sprintf(gravatarUrlFormat, emailMd5)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package avatars
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
func TestGravatarAvatarProvider_GetGravatarUrl(t *testing.T) {
|
||||
avatarProvider := NewGravatarAvatarProvider()
|
||||
|
||||
expectedValue := "https://www.gravatar.com/avatar/0bc83cb571cd1c50ba6f3e8a78ef1346"
|
||||
actualValue := avatarProvider.GetAvatarUrl(&models.User{
|
||||
Email: "MyEmailAddress@example.com",
|
||||
})
|
||||
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package avatars
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const internalAvatarUrlFormat = "%savatar/%d.%s"
|
||||
|
||||
// InternalStorageAvatarProvider represents the internal storage avatar provider
|
||||
type InternalStorageAvatarProvider struct {
|
||||
webRootUrl string
|
||||
}
|
||||
|
||||
// NewInternalStorageAvatarProvider returns a new internal storage avatar provider
|
||||
func NewInternalStorageAvatarProvider(config *settings.Config) *InternalStorageAvatarProvider {
|
||||
return &InternalStorageAvatarProvider{
|
||||
webRootUrl: config.RootUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAvatarUrl returns the built-in avatar url
|
||||
func (p *InternalStorageAvatarProvider) GetAvatarUrl(user *models.User) string {
|
||||
if user.CustomAvatarType == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(internalAvatarUrlFormat, p.webRootUrl, user.Uid, user.CustomAvatarType)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package avatars
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
func TestInternalStorageAvatarProvider_GetAvatarUrl(t *testing.T) {
|
||||
avatarProvider := NewInternalStorageAvatarProvider(&settings.Config{
|
||||
RootUrl: "https://foo.bar/",
|
||||
})
|
||||
|
||||
expectedValue := "https://foo.bar/avatar/1234567890.jpg"
|
||||
actualValue := avatarProvider.GetAvatarUrl(&models.User{
|
||||
Uid: 1234567890,
|
||||
CustomAvatarType: "jpg",
|
||||
})
|
||||
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestInternalStorageAvatarProvider_GetAvatarUrl_EmptyCustomAvatarType(t *testing.T) {
|
||||
avatarProvider := NewInternalStorageAvatarProvider(&settings.Config{
|
||||
RootUrl: "https://foo.bar/",
|
||||
})
|
||||
|
||||
expectedValue := ""
|
||||
actualValue := avatarProvider.GetAvatarUrl(&models.User{
|
||||
Uid: 1234567890,
|
||||
CustomAvatarType: "",
|
||||
})
|
||||
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package avatars
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
// NullAvatarProvider represents the null avatar provider
|
||||
type NullAvatarProvider struct {
|
||||
}
|
||||
|
||||
// NewNullAvatarProvider returns a new null avatar provider
|
||||
func NewNullAvatarProvider() *NullAvatarProvider {
|
||||
return &NullAvatarProvider{}
|
||||
}
|
||||
|
||||
// GetAvatarUrl returns an empty url
|
||||
func (p *NullAvatarProvider) GetAvatarUrl(user *models.User) string {
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package avatars
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
func TestNullAvatarProvider_GetGravatarUrl(t *testing.T) {
|
||||
avatarProvider := NewNullAvatarProvider()
|
||||
|
||||
expectedValue := ""
|
||||
actualValue := avatarProvider.GetAvatarUrl(&models.User{
|
||||
Email: "MyEmailAddress@example.com",
|
||||
})
|
||||
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package cli
|
||||
|
||||
import "github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
|
||||
// CliUsingConfig represents a cli that need to use config
|
||||
type CliUsingConfig struct {
|
||||
container *settings.ConfigContainer
|
||||
}
|
||||
|
||||
// CurrentConfig returns the current config
|
||||
func (l *CliUsingConfig) CurrentConfig() *settings.Config {
|
||||
return l.container.GetCurrentConfig()
|
||||
}
|
||||
+619
-129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
package alipay
|
||||
|
||||
// alipayAppTransactionDataCsvFileImporter defines the structure of alipay app csv importer for transaction data
|
||||
type alipayAppTransactionDataCsvFileImporter struct {
|
||||
alipayTransactionDataCsvFileImporter
|
||||
}
|
||||
|
||||
// Initialize a alipay app transaction data csv file importer singleton instance
|
||||
var (
|
||||
AlipayAppTransactionDataCsvFileImporter = &alipayAppTransactionDataCsvFileImporter{
|
||||
alipayTransactionDataCsvFileImporter{
|
||||
fileHeaderLine: "------------------------------------------------------------------------------------",
|
||||
dataHeaderStartContent: []string{"支付宝(中国)网络技术有限公司 电子客户回单", "支付宝支付科技有限公司 电子客户回单"},
|
||||
originalColumnNames: alipayTransactionColumnNames{
|
||||
timeColumnName: "交易时间",
|
||||
categoryColumnName: "交易分类",
|
||||
targetNameColumnName: "交易对方",
|
||||
productNameColumnName: "商品说明",
|
||||
amountColumnName: "金额",
|
||||
typeColumnName: "收/支",
|
||||
relatedAccountColumnName: "收/付款方式",
|
||||
statusColumnName: "交易状态",
|
||||
descriptionColumnName: "备注",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
package alipay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
var alipayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
|
||||
}
|
||||
|
||||
var alipayTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
models.TRANSACTION_TYPE_INCOME: "收入",
|
||||
models.TRANSACTION_TYPE_EXPENSE: "支出",
|
||||
models.TRANSACTION_TYPE_TRANSFER: "不计收支",
|
||||
}
|
||||
|
||||
// alipayTransactionColumnNames defines the structure of alipay transaction plain text header names
|
||||
type alipayTransactionColumnNames struct {
|
||||
timeColumnName string
|
||||
categoryColumnName string
|
||||
targetNameColumnName string
|
||||
productNameColumnName string
|
||||
amountColumnName string
|
||||
typeColumnName string
|
||||
relatedAccountColumnName string
|
||||
statusColumnName string
|
||||
descriptionColumnName string
|
||||
}
|
||||
|
||||
// alipayTransactionDataCsvFileImporter defines the structure of alipay csv importer for transaction data
|
||||
type alipayTransactionDataCsvFileImporter struct {
|
||||
fileHeaderLine string
|
||||
dataHeaderStartContent []string
|
||||
dataBottomEndLineRune rune
|
||||
originalColumnNames alipayTransactionColumnNames
|
||||
}
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the alipay transaction csv data
|
||||
func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||
enc := simplifiedchinese.GB18030
|
||||
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
|
||||
|
||||
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
dataTable, err := createNewAlipayTransactionBasicDataTable(ctx, csvDataTable, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
|
||||
|
||||
if !commonDataTable.HasColumn(c.originalColumnNames.timeColumnName) ||
|
||||
!commonDataTable.HasColumn(c.originalColumnNames.amountColumnName) ||
|
||||
!commonDataTable.HasColumn(c.originalColumnNames.typeColumnName) ||
|
||||
!commonDataTable.HasColumn(c.originalColumnNames.statusColumnName) {
|
||||
log.Errorf(ctx, "[alipay_transaction_data_csv_file_importer.ParseImportedData] cannot parse alipay csv data, because missing essential columns in header row")
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
transactionRowParser := createAlipayTransactionDataRowParser(c.originalColumnNames, dataTable.HeaderColumnNames())
|
||||
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, alipayTransactionSupportedColumns, transactionRowParser)
|
||||
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(alipayTransactionTypeNameMapping)
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
package alipay
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,交易成功 ,\n" +
|
||||
"2024-09-01 12:34:56 ,xxxx ,123.45 ,支出 ,交易成功 ,\n" +
|
||||
"2024-09-01 23:59:59 ,充值-普通充值 ,0.05 ,不计收支 ,交易成功 ,\n" +
|
||||
"2024-09-02 23:59:59 ,提现-普通提现 ,0.03 ,不计收支 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 4, len(allNewTransactions))
|
||||
assert.Equal(t, 2, len(allNewAccounts))
|
||||
assert.Equal(t, 1, len(allNewSubExpenseCategories))
|
||||
assert.Equal(t, 1, len(allNewSubIncomeCategories))
|
||||
assert.Equal(t, 1, len(allNewSubTransferCategories))
|
||||
assert.Equal(t, 0, len(allNewTags))
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
||||
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(12), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
|
||||
assert.Equal(t, "2024-09-01 12:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(12345), allNewTransactions[1].Amount)
|
||||
assert.Equal(t, "", allNewTransactions[1].OriginalSourceAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[2].Type)
|
||||
assert.Equal(t, "2024-09-01 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(5), allNewTransactions[2].Amount)
|
||||
assert.Equal(t, "", allNewTransactions[2].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Alipay", allNewTransactions[2].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[2].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
|
||||
assert.Equal(t, "2024-09-02 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(3), allNewTransactions[3].Amount)
|
||||
assert.Equal(t, "Alipay", allNewTransactions[3].OriginalSourceAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[3].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||
assert.Equal(t, "Alipay", allNewAccounts[0].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||
assert.Equal(t, "", allNewAccounts[1].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
|
||||
assert.Equal(t, "", allNewSubExpenseCategories[0].Name)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
|
||||
assert.Equal(t, "", allNewSubIncomeCategories[0].Name)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
|
||||
assert.Equal(t, "", allNewSubTransferCategories[0].Name)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
// refund
|
||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 01:23:45 ,0.12 ,不计收支 ,退款成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
|
||||
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(-12), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
||||
|
||||
// tax refund
|
||||
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 01:23:45 ,0.12 ,收入 ,退税成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data2), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
|
||||
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(-12), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_ParseInvestmentRefundTransaction(t *testing.T) {
|
||||
importer := AlipayAppTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("------------------------------------------------------------------------------------\n" +
|
||||
"导出信息:\n" +
|
||||
"姓名:xxx\n" +
|
||||
"支付宝账户:xxx@xxx.xxx\n" +
|
||||
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
|
||||
"导出交易类型:[全部]\n" +
|
||||
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
|
||||
"交易时间,交易对方,商品说明,收/支,金额,收/付款方式,交易状态,\n" +
|
||||
"2024-09-01 01:00:00,Test Account2,xxx-买入,不计收支,0.01,Test Account,退款成功,\n" +
|
||||
"2024-09-01 02:00:00,Test Account2,xxx-买入退款,不计收支,0.01,Test Account,退款成功,\n")
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 2, len(allNewTransactions))
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||
assert.Equal(t, "2024-09-01 01:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
|
||||
assert.Equal(t, "2024-09-01 02:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(1), allNewTransactions[1].Amount)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalDestinationAccountName)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01T12:34:56 ,0.12 ,收入 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||
|
||||
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"09/01/2024 12:34:56 ,0.12 ,收入 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data2), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 12:34:56 ,0.12 , ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
// income to alipay wallet
|
||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,交易对方 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 12:34:56 ,test ,0.12 ,收入 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalSourceAccountName)
|
||||
|
||||
// refund to other account
|
||||
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,交易对方 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 12:34:56 ,test ,0.12 ,不计收支 ,退款成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data2), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
|
||||
|
||||
// transfer to alipay wallet
|
||||
data3, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 12:34:56 ,test ,充值-普通充值 ,0.12 ,不计收支 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data3), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
|
||||
// transfer from alipay wallet
|
||||
data4, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 12:34:56 ,test ,提现-实时提现 ,0.12 ,不计收支 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data4), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
|
||||
// transfer in
|
||||
data5, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 12:34:56 ,test ,xx-转入 ,0.12 ,不计收支 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data5), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
|
||||
// transfer out
|
||||
data6, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 12:34:56 ,test ,xx-转出 ,0.12 ,不计收支 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data6), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
|
||||
// repayment
|
||||
data7, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 12:34:56 ,test ,xx还款 ,0.12 ,不计收支 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data7), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_ParseCategory(t *testing.T) {
|
||||
importer := AlipayAppTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("------------------------------------------------------------------------------------\n" +
|
||||
"导出信息:\n" +
|
||||
"姓名:xxx\n" +
|
||||
"支付宝账户:xxx@xxx.xxx\n" +
|
||||
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
|
||||
"导出交易类型:[全部]\n" +
|
||||
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
|
||||
"交易时间,交易分类,商品说明,收/支,金额,交易状态,\n" +
|
||||
"2024-09-01 01:23:45,Test Category,xxxx,收入,0.12,交易成功,\n" +
|
||||
"2024-09-01 12:34:56,Test Category2,xxxx,支出,123.45,交易成功,\n" +
|
||||
"2024-09-01 23:59:59,Test Category3,充值-普通充值,不计收支,0.05,交易成功,\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 3, len(allNewTransactions))
|
||||
assert.Equal(t, 1, len(allNewSubExpenseCategories))
|
||||
assert.Equal(t, 1, len(allNewSubIncomeCategories))
|
||||
assert.Equal(t, 1, len(allNewSubTransferCategories))
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
|
||||
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[0].Name)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
|
||||
assert.Equal(t, "Test Category", allNewSubIncomeCategories[0].Name)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
|
||||
assert.Equal(t, "Test Category3", allNewSubTransferCategories[0].Name)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_ParseRelatedAccount(t *testing.T) {
|
||||
importer := AlipayAppTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("------------------------------------------------------------------------------------\n" +
|
||||
"导出信息:\n" +
|
||||
"姓名:xxx\n" +
|
||||
"支付宝账户:xxx@xxx.xxx\n" +
|
||||
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
|
||||
"导出交易类型:[全部]\n" +
|
||||
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
|
||||
"交易时间,交易对方,商品说明,收/支,金额,收/付款方式,交易状态,备注,\n" +
|
||||
"2024-09-01 00:00:00,xxx,xxx-收益发放,不计收支,0.01,Test Account,交易成功,earning,\n" +
|
||||
"2024-09-01 01:00:00,Test Account2,xxx-买入,不计收支,0.01,Test Account,交易成功,purchase investment,\n" +
|
||||
"2024-09-01 02:00:00,Test Account2,xxx-卖出至xxx,不计收支,0.01,Test Account,交易成功,sell investment,\n" +
|
||||
"2024-09-01 03:00:00,xxx,充值-普通充值,不计收支,0.01,Test Account,交易成功,transfer to alipay wallet,\n" +
|
||||
"2024-09-01 04:00:00,Test Account3,提现-实时提现,不计收支,0.01,Test Account,交易成功,transfer from alipay wallet,\n" +
|
||||
"2024-09-01 05:00:00,Test Account3,xxx-单次转入,不计收支,0.01,Test Account,交易成功,transfer in,\n" +
|
||||
"2024-09-01 06:00:00,Test Account3,xxx-转出到银行卡,不计收支,0.01,Test Account,交易成功,transfer out,\n" +
|
||||
"2024-09-01 07:00:00,Test Account3,转账xxx,不计收支,0.01,Test Account,交易成功,transfer,\n" +
|
||||
"2024-09-01 08:00:00,Test Account4,信用卡还款,不计收支,0.01,Test Account,还款成功,repayment,\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, allNewAccounts, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 9, len(allNewTransactions))
|
||||
assert.Equal(t, 6, len(allNewAccounts))
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "earning", allNewTransactions[0].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[1].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "purchase investment", allNewTransactions[1].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[2].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[2].Amount)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[2].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[2].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "sell investment", allNewTransactions[2].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[3].Amount)
|
||||
assert.Equal(t, "", allNewTransactions[3].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Alipay", allNewTransactions[3].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "transfer to alipay wallet", allNewTransactions[3].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[4].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[4].Amount)
|
||||
assert.Equal(t, "Alipay", allNewTransactions[4].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[4].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "transfer from alipay wallet", allNewTransactions[4].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[5].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[5].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[5].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[5].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "transfer in", allNewTransactions[5].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[6].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[6].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[6].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[6].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[6].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "transfer out", allNewTransactions[6].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[7].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[7].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[7].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[7].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[7].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "transfer", allNewTransactions[7].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[8].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[8].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[8].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[8].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account4", allNewTransactions[8].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "repayment", allNewTransactions[8].Comment)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
|
||||
assert.Equal(t, "", allNewAccounts[2].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[3].Uid)
|
||||
assert.Equal(t, "Alipay", allNewAccounts[3].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[3].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[4].Uid)
|
||||
assert.Equal(t, "Test Account3", allNewAccounts[4].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[4].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[5].Uid)
|
||||
assert.Equal(t, "Test Account4", allNewAccounts[5].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[5].Currency)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,备注 ,\n" +
|
||||
"2024-09-01 12:34:56 ,test ,0.12 ,收入 ,交易成功 ,test2 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "test2", allNewTransactions[0].Comment)
|
||||
|
||||
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,备注 ,\n" +
|
||||
"2024-09-01 12:34:56 ,test ,0.12 ,收入 ,交易成功 , ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data2), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "test", allNewTransactions[0].Comment)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_SkipClosedIncomeOrTransferTransaction(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,交易关闭 ,\n" +
|
||||
"2024-09-01 23:59:59 ,充值-普通充值 ,0.05 ,不计收支 ,交易关闭 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownProductTransferTransaction(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 23:59:59 ,xxxx ,0.05 ,不计收支 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownStatusTransaction(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,xxxx ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data, err := simplifiedchinese.GB18030.NewEncoder().String(
|
||||
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 12:34:56 ,0.12 ,收入 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
|
||||
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(""), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
// Missing Time Column
|
||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"金额(元),收/支 ,交易状态 ,\n" +
|
||||
"0.12 ,收入 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Amount Column
|
||||
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,收/支 ,交易状态 ,\n" +
|
||||
"2024-09-01 12:34:56 ,收入 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data2), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Status Column
|
||||
data3, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,金额(元),收/支 ,\n" +
|
||||
"2024-09-01 12:34:56 ,0.12 ,收入 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data3), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Type Column
|
||||
data4, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,金额(元),交易状态 ,\n" +
|
||||
"2024-09-01 12:34:56 ,0.12 ,交易成功 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data4), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_NoTransactionData(t *testing.T) {
|
||||
importer := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
|
||||
"------------------------------------------------------------------------------------\n")
|
||||
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package alipay
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
func createNewAlipayTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable, fileHeaderLine string, dataHeaderStartContent []string, dataBottomEndLineRune rune) (datatable.BasicDataTable, error) {
|
||||
iterator := originalDataTable.DataRowIterator()
|
||||
allOriginalLines := make([][]string, 0)
|
||||
hasFileHeader := false
|
||||
foundContentBeforeDataHeaderLine := false
|
||||
|
||||
for iterator.HasNext() {
|
||||
row := iterator.Next()
|
||||
|
||||
if !hasFileHeader {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
} else if strings.Index(row.GetData(0), fileHeaderLine) == 0 {
|
||||
hasFileHeader = true
|
||||
continue
|
||||
} else {
|
||||
log.Warnf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !foundContentBeforeDataHeaderLine {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
} else if utils.ContainsAnyString(row.GetData(0), dataHeaderStartContent) {
|
||||
foundContentBeforeDataHeaderLine = true
|
||||
continue
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if foundContentBeforeDataHeaderLine {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
} else if row.ColumnCount() == 1 && dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(row.GetData(0), dataBottomEndLineRune) {
|
||||
break
|
||||
}
|
||||
|
||||
items := make([]string, row.ColumnCount())
|
||||
|
||||
for i := 0; i < row.ColumnCount(); i++ {
|
||||
items[i] = strings.Trim(row.GetData(i), " ")
|
||||
}
|
||||
|
||||
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
|
||||
log.Errorf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", iterator.CurrentRowId(), len(items), len(allOriginalLines[0]))
|
||||
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
||||
}
|
||||
|
||||
allOriginalLines = append(allOriginalLines, items)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
|
||||
return nil, errs.ErrInvalidFileHeader
|
||||
}
|
||||
|
||||
if len(allOriginalLines) < 2 {
|
||||
log.Errorf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
|
||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user