Compare commits
	
		
			637 Commits
		
	
	
		
			387246a95c
			...
			refactor/w
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 28d60c722a | |||
| 4626529eb5 | |||
| 46ebd92dc1 | |||
| 7f8521bb40 | |||
| f01226d91a | |||
| 6cb6dee6be | |||
| 0e9caf67ff | |||
| ca70bb5487 | |||
| 59ed135f20 | |||
| 6077f91529 | |||
| 5c485bb1c3 | |||
| 27d979d77b | |||
| 15687a0c32 | |||
| 37ea882ef7 | |||
| e624c2bb3e | |||
| 9631cd3edd | |||
| f4a659fce5 | |||
| 1ded811b36 | |||
| 32977d9580 | |||
| aaf29e7228 | |||
| 658ef3bddf | |||
| fc0bc936ce | |||
| 3850ae6a8e | |||
| 21c99567b4 | |||
| 1315c7f4d4 | |||
| 630a532d98 | |||
| b9bb180113 | |||
| 04d74d0d70 | |||
| 6a8a0ed491 | |||
| 0f835845bf | |||
| c5d8a8d07f | |||
| 95e2ba1136 | |||
| 1176fde8b4 | |||
| e634968e00 | |||
| 282a1dbddc | |||
| c64adace24 | |||
| 8ac0b28c66 | |||
| 8f71d7f9e5 | |||
| c435e63917 | |||
| 243159e4cc | |||
| 42dad7095a | |||
| d1efcdede8 | |||
| 47680475b3 | |||
| 6632d43f32 | |||
| 29c4dcd71c | |||
| e7aa887715 | |||
| 0f05633996 | |||
| 966af08a33 | |||
| b25b90a074 | |||
| dcbefeaaab | |||
| eb83a0392a | |||
| 85fefcf724 | |||
| d17c26a228 | |||
| 2e5ef8ff94 | |||
| 7a5f410e36 | |||
| 0b4e8a9777 | |||
| 30fd912281 | |||
| 5bf58f0194 | |||
| 8e3e3f09df | |||
| fa24f14c05 | |||
| a93b633e84 | |||
| 97a7b876db | |||
| 909fe173c2 | |||
| 58a44e8af4 | |||
| 1075177511 | |||
| 78f8a9e638 | |||
| 9ce31c4dd8 | |||
| e70d8371f8 | |||
| 51b6f7309e | |||
| d75876a772 | |||
| 4910c3296b | |||
| 7b924fa075 | |||
| d69c9f9623 | |||
| a88d828e21 | |||
| 14c93d372e | |||
| adf371a72e | |||
| c03f2472fa | |||
| 50efe62bac | |||
| 7bc94a9646 | |||
| d9fe1273b5 | |||
| ff9d490869 | |||
| 266312e97e | |||
| 7087736e31 | |||
| 82bf1608fd | |||
| 3b3287db0b | |||
| 4573d9395f | |||
| a8c99b3128 | |||
| fdd7bd3c9d | |||
| b785d0098b | |||
| 5b31357fe9 | |||
| d5a5721402 | |||
| 204640a759 | |||
| e3657386cd | |||
| f81e3dc9f4 | |||
| b2a0d25ffa | |||
| e1459951c4 | |||
| a88843a4c2 | |||
| 4d83c2de31 | |||
| f63c934cee | |||
| 001da9ae40 | |||
| 4efbfa948a | |||
| 3458e85a8b | |||
| 3710169f8c | |||
| 9e4a58a8a0 | |||
| dc93991de2 | |||
| b0154e1a63 | |||
| 66e14ffedb | |||
| b152edb848 | |||
| 2ace444dbb | |||
| 634958ffc5 | |||
| 1e374a73c7 | |||
| cc59e046bd | |||
| f3dcff2e4a | |||
| 1a5723c880 | |||
| 96559a2c26 | |||
| 366edfc14f | |||
| f6f0703cb3 | |||
| 3d47b4e44e | |||
| 71fe2a30e7 | |||
| d8f57161ae | |||
| 3caa79b9a7 | |||
| 49beb17925 | |||
| bd8e13f25d | |||
| 1128c9a0ba | |||
| 8dfe201afe | |||
| c1016e496a | |||
| 091097a858 | |||
| 5c97733b3e | |||
| 4ee387ab76 | |||
| 19bf17200d | |||
| be6d97ec85 | |||
| 9d282b26f3 | |||
| dbc2c54ab0 | |||
| aa062932cf | |||
| 812dd03e85 | |||
| 06d639a114 | |||
| 74f51036b1 | |||
| 8308325b73 | |||
| fa7010db3d | |||
| 89320fc540 | |||
| 5ec8d89563 | |||
| 0eeafb5352 | |||
| ab2bdcc7ca | |||
| c2b49e6642 | |||
| 1a89c48790 | |||
| 8dddfe77cd | |||
| 8e8b011fdd | |||
| abd346bb97 | |||
| 6386ec8caa | |||
| ad062828ff | |||
| 92e4988114 | |||
| f9269d7558 | |||
| fa01b7027a | |||
| eaa3a9c297 | |||
| 6cedda9307 | |||
| 942ca73f8d | |||
| da3f58f2ec | |||
| 4a8521d59d | |||
| d7ad84e199 | |||
| 52430c19a5 | |||
| 9492b6cac6 | |||
| 5f324a2348 | |||
| 7452b14817 | |||
| 4a27794ccc | |||
| d2f5ba36ab | |||
| 0117fdf084 | |||
| 02680d224a | |||
| 68bfdebcbd | |||
| 54907eede1 | |||
| a21d19c3ef | |||
| df732616d5 | |||
| 79a31ae060 | |||
| 6eacfcd8f2 | |||
| 5e328509bd | |||
| 9c078db564 | |||
| ddd109c77c | |||
| 3ee04d0b24 | |||
| 7f110313e9 | |||
| bc2e87c56f | |||
| d7271a2d11 | |||
| c57d65db67 | |||
| edf3aab173 | |||
| 352746a141 | |||
| 216c72ea36 | |||
| d0723b366b | |||
| fb6721cb1b | |||
| 9fcb169c94 | |||
| 572874431d | |||
| f595ac8001 | |||
| 18674e0e1d | |||
| da4c4d3a84 | |||
| aec01b117d | |||
| d299c32e35 | |||
| 344007af66 | |||
| d4de5aeac2 | |||
| 8ce5ba50f4 | |||
| 5a44952b27 | |||
| c30946daf6 | |||
| 0221d7b294 | |||
| c44b0b64c3 | |||
| 442ee3bcfd | |||
| 081815c512 | |||
| eab2a388ae | |||
| 5f7ab49abb | |||
| 4ff89173b2 | |||
| f2052410c7 | |||
| 83a49be725 | |||
| 9b205a73fd | |||
| d5157eb7e3 | |||
| 75c92c51db | |||
| 915054fce0 | |||
| 63653680ba | |||
| 84c4df6620 | |||
| 8c748fd57a | |||
| 4684550ebf | |||
| 51db08f374 | |||
| 9f38a288b9 | |||
| 75a975049c | |||
| f8c35c0350 | |||
| d9a5fed77f | |||
| 7cb14940d9 | |||
| 953bf5d4de | |||
| d9620fd6a4 | |||
| 541e2dd14c | |||
| c7925d98c8 | |||
| f759b19bcb | |||
| 5d7429a416 | |||
| fb7e52d6f3 | |||
| 50e888b075 | |||
| 76c8bbf307 | |||
| 8f3825e92c | |||
| d1c3610ec8 | |||
| 4b958a3c31 | |||
| 1f9021d459 | |||
| 7ad9deaf70 | |||
| c1c17b5f4e | |||
| d92220b4bc | |||
| 4d1972bc99 | |||
| 83c052ec4e | |||
| 57a75fe9e6 | |||
| 379bc37aff | |||
| 0217fbb13b | |||
| 4e9943e6a2 | |||
| b3cc623168 | |||
| 3ee5e5367d | |||
| 85fef30c7f | |||
| e8d8dcbb2d | |||
| 3b679d6134 | |||
| ec44b51ab6 | |||
| 2e52a13c30 | |||
| 1e8e2e9ea7 | |||
| 9e8363c004 | |||
| 56c40ee001 | |||
| e3dfccfee3 | |||
| d555fcaf17 | |||
| 2fdefae718 | |||
| e78858b7b4 | |||
| 636b674229 | |||
| fc6cee17d7 | |||
| 7f7b47fb1c | |||
| bf181b88ec | |||
| c056938b6e | |||
| 66eadf96b0 | |||
| 665595b8b4 | |||
| 29550401fd | |||
| 1bb0012c40 | |||
| 2cea391ebf | |||
| 32e91da0b2 | |||
| 69b56b9658 | |||
| 83e3d77f79 | |||
| 38a8eecd50 | |||
| bd77137714 | |||
| 201126e5d0 | |||
| d4a2e5ef5b | |||
| 2761abf405 | |||
| add16ffdad | |||
| b49cd1c382 | |||
| aa9ae5c11e | |||
| 8e8965eb3d | |||
| a0fe8fd0f0 | |||
| 855031a4fe | |||
| adc2b20aeb | |||
| c860f10cf9 | |||
| d441eff2d2 | |||
| d31f36d3dc | |||
| 4fc7bd47f9 | |||
| a66037d947 | |||
| bb4e04df0b | |||
| d3752caf1d | |||
| 614c77d7ce | |||
| 5d13f08d47 | |||
| 07ba148d9b | |||
| 917e2d5393 | |||
| e384763faf | |||
| 7fb199b187 | |||
| 924e31aad5 | |||
| 48f776e6ff | |||
| a27bda4720 | |||
| a7e0e1e369 | |||
| 5bb5018cc0 | |||
| a9aab6b7e5 | |||
| 651c06caac | |||
| e0d58085f3 | |||
| cb420c2262 | |||
| 6211f546b1 | |||
| 9070fe7fa3 | |||
| c86d7275ec | |||
| 9e1178b7a1 | |||
| cd76cedb7b | |||
| f273445451 | |||
| 740d9a33cf | |||
| 792d703b6f | |||
| f09832404d | |||
| 134b11e7f0 | |||
| 8c01ec364c | |||
| 27e6dde7c4 | |||
| b04b17c8ae | |||
| b037ecad79 | |||
| 7ec3f25d43 | |||
| 1778ab112d | |||
| 5f70d53c94 | |||
| 4b66e97bda | |||
| f8d8e485f1 | |||
| e21bf531e1 | |||
| 76fdf14e79 | |||
| 96cceafe77 | |||
| 58e34b20e1 | |||
|  | e420b183ce | ||
|  | a08f058806 | ||
| 616491e6d8 | |||
| 05c6d67c03 | |||
| e66130e893 | |||
| 5bb9bbac73 | |||
| 8474fc7160 | |||
| ea8158cb50 | |||
| 65398c5fec | |||
| 5181897463 | |||
| 96c7927632 | |||
| 0eb3ffcdbe | |||
|  | 736db75cfd | ||
| 0b44c4547c | |||
|  | 728ac9c166 | ||
| 360b58885e | |||
| 09d412053f | |||
| e0107f189d | |||
| 42af09034c | |||
| 963470b693 | |||
| da57936d92 | |||
| 78cec27ef0 | |||
| c3f5ed881f | |||
| 1c52b4d661 | |||
| 765be4f214 | |||
| 91de6797c5 | |||
| 4bceb119ea | |||
| 14a5c01a6d | |||
| 83df727f8f | |||
| 3444e27a96 | |||
| 865505f883 | |||
| 0ed47be689 | |||
| d8c1c63e56 | |||
| 2934225a6c | |||
|  | d1e5058dae | ||
|  | cbd58d3e72 | ||
|  | 735268fe46 | ||
| 7ddb904335 | |||
| c514adfbbf | |||
| a32c06552f | |||
|  | aefc1aeb4f | ||
|  | 7fc36b5d22 | ||
| 5fd52e7b9e | |||
| e7d14d4687 | |||
| a57ae840ff | |||
| 009621a456 | |||
| 36ed0dc893 | |||
| 8a1c490907 | |||
| 32054705d0 | |||
| 5859483654 | |||
| d0ca8db162 | |||
| a3e138cc2d | |||
| 1fab398778 | |||
| 77ccc9aeb5 | |||
| a6dfe8712c | |||
| 973b2f81ea | |||
| 554f73b550 | |||
| ee8e9df12e | |||
| 00cdd1bc5d | |||
| f1ea7c1c5a | |||
| d13e18534f | |||
| 1dc33c5bd4 | |||
| e09922c8df | |||
| e85af628bf | |||
| 4f2e18ca27 | |||
| 1105d6f11e | |||
| f2bba64ee5 | |||
| ebbe14f293 | |||
| 681934a0dc | |||
| a52b09b787 | |||
| b0af3af059 | |||
| 6bc5bcfd1a | |||
| 999ba52003 | |||
| e0ebed7c09 | |||
| e50ce2f515 | |||
| 5bb9ed5f04 | |||
| 4a36557714 | |||
| 1a93cdad46 | |||
| 2bbef9b9d1 | |||
| 22101c8280 | |||
| 256c6469a6 | |||
| 7367f372c0 | |||
| 822a339532 | |||
| 5d2ad2479b | |||
| 795ca04d7c | |||
| 111701a2c4 | |||
| a793a03a20 | |||
| d231b5f27e | |||
| 709dc44d57 | |||
| d7a39ab574 | |||
| 18882c08d9 | |||
| ce6f9a174f | |||
| f5c8b75122 | |||
| 165d2e4d93 | |||
| 9e9d0dc563 | |||
| a9a5082e1a | |||
| eca9601a89 | |||
| 6bfe784b3f | |||
| 6524a56eeb | |||
| b7f853d84f | |||
| 473155b68d | |||
| 608b93fb61 | |||
| 4a36b30d6b | |||
| 72b26c6a2c | |||
| 7fc86441d1 | |||
| 1a05f16299 | |||
| db5d631049 | |||
| 2d7dd26882 | |||
| b0834f48d4 | |||
| 7d3236550c | |||
| adf62fb42b | |||
| 14c6913af7 | |||
| 192ea0fcdd | |||
| 189abd4982 | |||
| 3df66dabd9 | |||
| f46f70b33c | |||
| e689d15688 | |||
| 3d236c35c9 | |||
| 665538bdd3 | |||
| be7d7536fc | |||
| a932108c87 | |||
| 71eccbb466 | |||
| 700803f7a6 | |||
| 1f38d827c5 | |||
| 8d73c0f289 | |||
| f9884e32fb | |||
| 27b6f2022f | |||
| 98b5808b09 | |||
| f4df8c0c3b | |||
| 882c14df06 | |||
| b3ed98322b | |||
| 4cfd4387b6 | |||
| 89406870bd | |||
| c747d03aff | |||
| 77df275ac0 | |||
| d7dcb7221f | |||
| 92a8709df0 | |||
| e3499ff283 | |||
| 0306b54a0f | |||
| 3afbeacffb | |||
| 3e7376c1f7 | |||
| fd81e8389c | |||
| 00dda8faf9 | |||
| 6b1dda41bc | |||
| fd1c47196d | |||
| 7383a5cff8 | |||
| 49fe70b0aa | |||
| 8e6e3e6289 | |||
| cb681681e1 | |||
| 1e25982c08 | |||
| e243b0f47a | |||
| 6f0a42820b | |||
| c1fc6837db | |||
| 51697c31cb | |||
| 409c83b030 | |||
| acb293ec8f | |||
| 162967e68b | |||
| 11266ac69a | |||
| 03b4b7f3b9 | |||
| 2649aeeee8 | |||
| 3e76ef62b3 | |||
| 284cb23d4d | |||
| 24f0d8f151 | |||
| 9d63a3b81c | |||
| f1b594bdf2 | |||
| 1f7b19938b | |||
| 05c6410550 | |||
| 4246fea03f | |||
| 83059374e9 | |||
| 28f6893c68 | |||
| d881a75e48 | |||
| fe5a455b68 | |||
| 0d4473da69 | |||
| f1b62d354f | |||
| 6ef1533abf | |||
| 32f7b0221d | |||
| 8b1bb7fcfd | |||
| e31a5ea017 | |||
| 7442b8416f | |||
| c875c82bdc | |||
| 4a0117906a | |||
| f74b1cf46a | |||
| 52addc91df | |||
| e1ebd44ea8 | |||
| e428e04435 | |||
| b405a46005 | |||
| 4c0e0b5ee9 | |||
| e7e6c258e2 | |||
| 05284760a7 | |||
| 4c0d381be2 | |||
| 42b300fefb | |||
| 0c08bfed5b | |||
| 57c72bdfbf | |||
| 1fd3b39c75 | |||
| f80cabfa75 | |||
| 2d728e4b07 | |||
| 7ff9605460 | |||
| d3bf9739b5 | |||
| 4e68ab4ef0 | |||
| 71accd725e | |||
| 46612b28aa | |||
| 02af78ca99 | |||
| f40d1dc1b2 | |||
| b0683576b9 | |||
| eaf0b366d3 | |||
| cf9903e500 | |||
| 186e9c00aa | |||
| f1867e7916 | |||
| 0486c0d0e5 | |||
| 081f3f609e | |||
| 123dce564c | |||
| d13fb8b0e4 | |||
| a4b84f0717 | |||
| 29b7aa641d | |||
| f3ab4c4de1 | |||
| d7acf4fedf | |||
| d5fb00a8a9 | |||
| f2f6b192d6 | |||
| 7910696b27 | |||
| 67af3c45ce | |||
| be3d2e237c | |||
| 832d6a2ef0 | |||
| 460f321bd1 | |||
| 5a24c31d43 | |||
| 31ac45026e | |||
| 91ae34d415 | |||
| 777e6da142 | |||
| 50944376fc | |||
| 29403b09d2 | |||
| 3f2dfe6076 | |||
| 8e6e9aadf7 | |||
| 362713873b | |||
| d95ea249fb | |||
| 8bcb2f2247 | |||
| 925ddd9e8b | |||
| 8e61a8b43d | |||
| b4c8096c41 | |||
| c316a099f8 | |||
| be589aed1d | |||
| 5f64236b59 | |||
| da66ce63af | |||
| 11fd0c011b | |||
| 44ec076e59 | |||
| f0e16837d6 | |||
| 9ecd43ada8 | |||
| 3a9867bf52 | |||
| ee3197f210 | |||
| 7a0aeccd9a | |||
| b298465d70 | |||
| 608414bfda | |||
| 4557631153 | |||
| f499e7d31a | |||
| 226bc004f5 | |||
| a814eb3d67 | |||
| 62b3d2d73d | |||
| 3a26527b5a | |||
| 7261b15038 | |||
| 631eed0ea5 | |||
| 8f9e201637 | |||
| bb6d8e317d | |||
| bedb9f81f1 | |||
| 7ce41e06a7 | |||
| 6c0343960f | |||
| f8ee75a50e | |||
| a565e4fb7c | |||
| 7657cc61b7 | |||
| f70ef0bf97 | |||
| 4a4e7a302b | |||
| f1a6d4ab90 | |||
| 609e30b67b | |||
| d22394230b | |||
| fc63a76eb2 | |||
| a37ca3c772 | |||
| 7b9150bd88 | |||
| 3380c8f688 | |||
| da5b3ac261 | |||
| 921a10f7ab | |||
| 4398984551 | |||
| e0e1eb76cd | |||
| 57f85ec341 | |||
| 086a12f971 | |||
| 651820e384 | |||
| 4e2a7ebbce | |||
| b14af43996 | |||
| 022f89c36e | |||
| e4dcf2517a | |||
| cd4af2e26f | |||
| 5549051ec5 | |||
| 3310487aba | |||
| 21b42b5b21 | |||
| 8fbc81cab9 | |||
| 3c11c4f3be | |||
| a03b8d1cac | |||
| cbfdb4aa60 | |||
| ef9175d27d | |||
| 06f1cc3ca1 | |||
| 92ab7a1a2a | |||
| 28067d18f6 | |||
| b6d416a3a8 | |||
| 2a8cbbfa24 | |||
| 29d752bdd9 | |||
| b12e3315fe | |||
| ce3958d397 | |||
| 26ea2503a4 | |||
| d6ce068490 | |||
| da4ee81c95 | |||
| bec294365f | |||
| 51a8b684fd | |||
| 7b026eeae1 | |||
| 4dd4542c37 | 
							
								
								
									
										4
									
								
								.aspire/settings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.aspire/settings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | { | ||||||
|  |   "appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj" | ||||||
|  | } | ||||||
|  |  | ||||||
| @@ -1,6 +1,5 @@ | |||||||
| **/.dockerignore | **/.dockerignore | ||||||
| **/.env | **/.env | ||||||
| **/.git |  | ||||||
| **/.gitignore | **/.gitignore | ||||||
| **/.project | **/.project | ||||||
| **/.settings | **/.settings | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | root = true | ||||||
|  |  | ||||||
|  | [*] | ||||||
|  | indent_style = space | ||||||
|  | indent_size = 4 | ||||||
							
								
								
									
										38
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | # Default container port for ring | ||||||
|  | RING_PORT=8080 | ||||||
|  |  | ||||||
|  | # Default container port for pass | ||||||
|  | PASS_PORT=8080 | ||||||
|  |  | ||||||
|  | # Default container port for drive | ||||||
|  | DRIVE_PORT=8080 | ||||||
|  |  | ||||||
|  | # Default container port for sphere | ||||||
|  | SPHERE_PORT=8080 | ||||||
|  |  | ||||||
|  | # Default container port for develop | ||||||
|  | DEVELOP_PORT=8080 | ||||||
|  |  | ||||||
|  | # Parameter cache-password | ||||||
|  | CACHE_PASSWORD=KS3jSPaU9e | ||||||
|  |  | ||||||
|  | # Parameter queue-password | ||||||
|  | QUEUE_PASSWORD=8xEECa4ckz | ||||||
|  |  | ||||||
|  | # Container image name for ring | ||||||
|  | RING_IMAGE=ring:latest | ||||||
|  |  | ||||||
|  | # Container image name for pass | ||||||
|  | PASS_IMAGE=pass:latest | ||||||
|  |  | ||||||
|  | # Container image name for drive | ||||||
|  | DRIVE_IMAGE=drive:latest | ||||||
|  |  | ||||||
|  | # Container image name for sphere | ||||||
|  | SPHERE_IMAGE=sphere:latest | ||||||
|  |  | ||||||
|  | # Container image name for develop | ||||||
|  | DEVELOP_IMAGE=develop:latest | ||||||
|  |  | ||||||
|  | # Container image name for gateway | ||||||
|  | GATEWAY_IMAGE=gateway:latest | ||||||
							
								
								
									
										48
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										48
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,4 +1,4 @@ | |||||||
| name: Build and Push Dyson Sphere | name: Build and Push Microservices | ||||||
|  |  | ||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
| @@ -7,27 +7,55 @@ on: | |||||||
|   workflow_dispatch: |   workflow_dispatch: | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   build: |   build-and-push: | ||||||
|     runs-on: ubuntu-latest # x86_64 (default), avoids arm64 native module issues |     runs-on: ubuntu-latest | ||||||
|  |     permissions: | ||||||
|  |       contents: read | ||||||
|  |       packages: write | ||||||
|  |  | ||||||
|  |     strategy: | ||||||
|  |       matrix: | ||||||
|  |         include: | ||||||
|  |           - service: Sphere | ||||||
|  |             image: sphere | ||||||
|  |           - service: Pass | ||||||
|  |             image: pass | ||||||
|  |           - service: Ring | ||||||
|  |             image: ring | ||||||
|  |           - service: Drive | ||||||
|  |             image: drive | ||||||
|  |           - service: Develop | ||||||
|  |             image: develop | ||||||
|  |           - service: Gateway | ||||||
|  |             image: gateway | ||||||
|  |  | ||||||
|     steps: |     steps: | ||||||
|       - name: Checkout repository |       - name: Checkout repository | ||||||
|         uses: actions/checkout@v3 |         uses: actions/checkout@v4 | ||||||
|  |         with: | ||||||
|  |           fetch-depth: 0 | ||||||
|  |  | ||||||
|  |       - name: Setup NBGV | ||||||
|  |         uses: dotnet/nbgv@master | ||||||
|  |         id: nbgv | ||||||
|  |  | ||||||
|       - name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
|         uses: docker/setup-buildx-action@v3 |         uses: docker/setup-buildx-action@v3 | ||||||
|  |  | ||||||
|       - name: Log in to DockerHub |       - name: Log in to GitHub Container Registry | ||||||
|         uses: docker/login-action@v3 |         uses: docker/login-action@v3 | ||||||
|         with: |         with: | ||||||
|           password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} |           registry: ghcr.io | ||||||
|           username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} |           username: ${{ github.actor }} | ||||||
|  |           password: ${{ secrets.GITHUB_TOKEN }} | ||||||
|  |  | ||||||
|       - name: Build and push Docker image |       - name: Build and push Docker image for ${{ matrix.service }} | ||||||
|         uses: docker/build-push-action@v6 |         uses: docker/build-push-action@v6 | ||||||
|         with: |         with: | ||||||
|           file: DysonNetwork.Sphere/Dockerfile |  | ||||||
|           context: . |           context: . | ||||||
|  |           file: DysonNetwork.${{ matrix.service }}/Dockerfile | ||||||
|           push: true |           push: true | ||||||
|           tags: xsheep2010/dyson-sphere:latest |           tags: | | ||||||
|  |             ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-${{ matrix.image }}:${{ steps.nbgv.outputs.SimpleVersion }} | ||||||
|  |             ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-${{ matrix.image }}:latest | ||||||
|           platforms: linux/amd64 |           platforms: linux/amd64 | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -6,3 +6,4 @@ riderModule.iml | |||||||
| /_ReSharper.Caches/ | /_ReSharper.Caches/ | ||||||
| .idea | .idea | ||||||
| .DS_Store | .DS_Store | ||||||
|  | /Keys/ | ||||||
|   | |||||||
							
								
								
									
										613
									
								
								API_WALLET_FUNDS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										613
									
								
								API_WALLET_FUNDS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,613 @@ | |||||||
|  | # Wallet Funds API Documentation | ||||||
|  |  | ||||||
|  | ## Overview | ||||||
|  |  | ||||||
|  | The Wallet Funds API provides red packet functionality for the DysonNetwork platform, allowing users to create and distribute funds among multiple recipients with expiration and claiming mechanisms. | ||||||
|  |  | ||||||
|  | ## Authentication | ||||||
|  |  | ||||||
|  | All endpoints require Bearer token authentication: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | Authorization: Bearer {jwt_token} | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Data Types | ||||||
|  |  | ||||||
|  | ### Enums | ||||||
|  |  | ||||||
|  | #### FundSplitType | ||||||
|  | ```typescript | ||||||
|  | enum FundSplitType { | ||||||
|  |   Even = 0,    // Equal distribution | ||||||
|  |   Random = 1   // Lucky draw distribution | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### FundStatus | ||||||
|  | ```typescript | ||||||
|  | enum FundStatus { | ||||||
|  |   Created = 0,           // Fund created, waiting for claims | ||||||
|  |   PartiallyReceived = 1, // Some recipients claimed | ||||||
|  |   FullyReceived = 2,     // All recipients claimed | ||||||
|  |   Expired = 3,           // Fund expired, unclaimed amounts refunded | ||||||
|  |   Refunded = 4           // Legacy status | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Request/Response Models | ||||||
|  |  | ||||||
|  | #### CreateFundRequest | ||||||
|  | ```typescript | ||||||
|  | interface CreateFundRequest { | ||||||
|  |   recipientAccountIds: string[];  // UUIDs of recipients | ||||||
|  |   currency: string;               // e.g., "points", "golds" | ||||||
|  |   totalAmount: number;            // Total amount to distribute | ||||||
|  |   splitType: FundSplitType;       // Even or Random | ||||||
|  |   message?: string;               // Optional message | ||||||
|  |   expirationHours?: number;       // Optional: hours until expiration (default: 24) | ||||||
|  |   pinCode: string;                // Required: 6-digit PIN code for security | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### SnWalletFund | ||||||
|  | ```typescript | ||||||
|  | interface SnWalletFund { | ||||||
|  |   id: string;                     // UUID | ||||||
|  |   currency: string; | ||||||
|  |   totalAmount: number; | ||||||
|  |   splitType: FundSplitType; | ||||||
|  |   status: FundStatus; | ||||||
|  |   message?: string; | ||||||
|  |   creatorAccountId: string;       // UUID | ||||||
|  |   creatorAccount: SnAccount;      // Creator account details (includes profile) | ||||||
|  |   recipients: SnWalletFundRecipient[]; | ||||||
|  |   expiredAt: string;              // ISO 8601 timestamp | ||||||
|  |   createdAt: string;              // ISO 8601 timestamp | ||||||
|  |   updatedAt: string;              // ISO 8601 timestamp | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### SnWalletFundRecipient | ||||||
|  | ```typescript | ||||||
|  | interface SnWalletFundRecipient { | ||||||
|  |   id: string;                     // UUID | ||||||
|  |   fundId: string;                 // UUID | ||||||
|  |   recipientAccountId: string;     // UUID | ||||||
|  |   recipientAccount: SnAccount;    // Recipient account details (includes profile) | ||||||
|  |   amount: number;                 // Allocated amount | ||||||
|  |   isReceived: boolean; | ||||||
|  |   receivedAt?: string;            // ISO 8601 timestamp (if claimed) | ||||||
|  |   createdAt: string;              // ISO 8601 timestamp | ||||||
|  |   updatedAt: string;              // ISO 8601 timestamp | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### SnWalletTransaction | ||||||
|  | ```typescript | ||||||
|  | interface SnWalletTransaction { | ||||||
|  |   id: string;                     // UUID | ||||||
|  |   payerWalletId?: string;         // UUID (null for system transfers) | ||||||
|  |   payeeWalletId?: string;         // UUID (null for system transfers) | ||||||
|  |   currency: string; | ||||||
|  |   amount: number; | ||||||
|  |   remarks?: string; | ||||||
|  |   type: TransactionType; | ||||||
|  |   createdAt: string;              // ISO 8601 timestamp | ||||||
|  |   updatedAt: string;              // ISO 8601 timestamp | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### Error Response | ||||||
|  | ```typescript | ||||||
|  | interface ErrorResponse { | ||||||
|  |   type: string;                   // Error type | ||||||
|  |   title: string;                  // Error title | ||||||
|  |   status: number;                 // HTTP status code | ||||||
|  |   detail: string;                 // Error details | ||||||
|  |   instance?: string;              // Request instance | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## API Endpoints | ||||||
|  |  | ||||||
|  | ### 1. Create Fund | ||||||
|  |  | ||||||
|  | Creates a new fund (red packet) for distribution among recipients. | ||||||
|  |  | ||||||
|  | **Endpoint:** `POST /api/wallets/funds` | ||||||
|  |  | ||||||
|  | **Request Body:** `CreateFundRequest` | ||||||
|  |  | ||||||
|  | **Response:** `SnWalletFund` (201 Created) | ||||||
|  |  | ||||||
|  | **Example Request:** | ||||||
|  | ```bash | ||||||
|  | curl -X POST "/api/wallets/funds" \ | ||||||
|  |   -H "Authorization: Bearer {token}" \ | ||||||
|  |   -H "Content-Type: application/json" \ | ||||||
|  |   -d '{ | ||||||
|  |     "recipientAccountIds": [ | ||||||
|  |       "550e8400-e29b-41d4-a716-446655440000", | ||||||
|  |       "550e8400-e29b-41d4-a716-446655440001", | ||||||
|  |       "550e8400-e29b-41d4-a716-446655440002" | ||||||
|  |     ], | ||||||
|  |     "currency": "points", | ||||||
|  |     "totalAmount": 100.00, | ||||||
|  |     "splitType": "Even", | ||||||
|  |     "message": "Happy New Year! 🎉", | ||||||
|  |     "expirationHours": 48, | ||||||
|  |     "pinCode": "123456" | ||||||
|  |   }' | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Example Response:** | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "id": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |   "currency": "points", | ||||||
|  |   "totalAmount": 100.00, | ||||||
|  |   "splitType": 0, | ||||||
|  |   "status": 0, | ||||||
|  |   "message": "Happy New Year! 🎉", | ||||||
|  |   "creatorAccountId": "550e8400-e29b-41d4-a716-446655440004", | ||||||
|  |   "creatorAccount": { | ||||||
|  |     "id": "550e8400-e29b-41d4-a716-446655440004", | ||||||
|  |     "username": "creator_user" | ||||||
|  |   }, | ||||||
|  |   "recipients": [ | ||||||
|  |     { | ||||||
|  |       "id": "550e8400-e29b-41d4-a716-446655440005", | ||||||
|  |       "fundId": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |       "recipientAccountId": "550e8400-e29b-41d4-a716-446655440000", | ||||||
|  |       "amount": 33.34, | ||||||
|  |       "isReceived": false, | ||||||
|  |       "createdAt": "2025-10-03T22:00:00Z", | ||||||
|  |       "updatedAt": "2025-10-03T22:00:00Z" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "id": "550e8400-e29b-41d4-a716-446655440006", | ||||||
|  |       "fundId": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |       "recipientAccountId": "550e8400-e29b-41d4-a716-446655440001", | ||||||
|  |       "amount": 33.33, | ||||||
|  |       "isReceived": false, | ||||||
|  |       "createdAt": "2025-10-03T22:00:00Z", | ||||||
|  |       "updatedAt": "2025-10-03T22:00:00Z" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "id": "550e8400-e29b-41d4-a716-446655440007", | ||||||
|  |       "fundId": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |       "recipientAccountId": "550e8400-e29b-41d4-a716-446655440002", | ||||||
|  |       "amount": 33.33, | ||||||
|  |       "isReceived": false, | ||||||
|  |       "createdAt": "2025-10-03T22:00:00Z", | ||||||
|  |       "updatedAt": "2025-10-03T22:00:00Z" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "expiredAt": "2025-10-05T22:00:00Z", | ||||||
|  |   "createdAt": "2025-10-03T22:00:00Z", | ||||||
|  |   "updatedAt": "2025-10-03T22:00:00Z" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Error Responses:** | ||||||
|  | - `400 Bad Request`: Invalid parameters, insufficient funds, invalid recipients | ||||||
|  | - `401 Unauthorized`: Missing or invalid authentication | ||||||
|  | - `403 Forbidden`: Invalid PIN code | ||||||
|  | - `422 Unprocessable Entity`: Business logic violations | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ### 2. Get Funds | ||||||
|  |  | ||||||
|  | Retrieves funds that the authenticated user is involved in (as creator or recipient). | ||||||
|  |  | ||||||
|  | **Endpoint:** `GET /api/wallets/funds` | ||||||
|  |  | ||||||
|  | **Query Parameters:** | ||||||
|  | - `offset` (number, optional): Pagination offset (default: 0) | ||||||
|  | - `take` (number, optional): Number of items to return (default: 20, max: 100) | ||||||
|  | - `status` (FundStatus, optional): Filter by fund status | ||||||
|  |  | ||||||
|  | **Response:** `SnWalletFund[]` (200 OK) | ||||||
|  |  | ||||||
|  | **Headers:** | ||||||
|  | - `X-Total`: Total number of funds matching the criteria | ||||||
|  |  | ||||||
|  | **Example Request:** | ||||||
|  | ```bash | ||||||
|  | curl -X GET "/api/wallets/funds?offset=0&take=10&status=0" \ | ||||||
|  |   -H "Authorization: Bearer {token}" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Example Response:** | ||||||
|  | ```json | ||||||
|  | [ | ||||||
|  |   { | ||||||
|  |     "id": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |     "currency": "points", | ||||||
|  |     "totalAmount": 100.00, | ||||||
|  |     "splitType": 0, | ||||||
|  |     "status": 0, | ||||||
|  |     "message": "Happy New Year! 🎉", | ||||||
|  |     "creatorAccountId": "550e8400-e29b-41d4-a716-446655440004", | ||||||
|  |     "creatorAccount": { | ||||||
|  |       "id": "550e8400-e29b-41d4-a716-446655440004", | ||||||
|  |       "username": "creator_user" | ||||||
|  |     }, | ||||||
|  |     "recipients": [ | ||||||
|  |       { | ||||||
|  |         "id": "550e8400-e29b-41d4-a716-446655440005", | ||||||
|  |         "fundId": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |         "recipientAccountId": "550e8400-e29b-41d4-a716-446655440000", | ||||||
|  |         "amount": 33.34, | ||||||
|  |         "isReceived": false | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "expiredAt": "2025-10-05T22:00:00Z", | ||||||
|  |     "createdAt": "2025-10-03T22:00:00Z", | ||||||
|  |     "updatedAt": "2025-10-03T22:00:00Z" | ||||||
|  |   } | ||||||
|  | ] | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Error Responses:** | ||||||
|  | - `401 Unauthorized`: Missing or invalid authentication | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ### 3. Get Fund | ||||||
|  |  | ||||||
|  | Retrieves details of a specific fund. | ||||||
|  |  | ||||||
|  | **Endpoint:** `GET /api/wallets/funds/{id}` | ||||||
|  |  | ||||||
|  | **Path Parameters:** | ||||||
|  | - `id` (string): Fund UUID | ||||||
|  |  | ||||||
|  | **Response:** `SnWalletFund` (200 OK) | ||||||
|  |  | ||||||
|  | **Example Request:** | ||||||
|  | ```bash | ||||||
|  | curl -X GET "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003" \ | ||||||
|  |   -H "Authorization: Bearer {token}" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Example Response:** (Same as create fund response) | ||||||
|  |  | ||||||
|  | **Error Responses:** | ||||||
|  | - `401 Unauthorized`: Missing or invalid authentication | ||||||
|  | - `403 Forbidden`: User doesn't have permission to view this fund | ||||||
|  | - `404 Not Found`: Fund not found | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ### 4. Receive Fund | ||||||
|  |  | ||||||
|  | Claims the authenticated user's portion of a fund. | ||||||
|  |  | ||||||
|  | **Endpoint:** `POST /api/wallets/funds/{id}/receive` | ||||||
|  |  | ||||||
|  | **Path Parameters:** | ||||||
|  | - `id` (string): Fund UUID | ||||||
|  |  | ||||||
|  | **Response:** `SnWalletTransaction` (200 OK) | ||||||
|  |  | ||||||
|  | **Example Request:** | ||||||
|  | ```bash | ||||||
|  | curl -X POST "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" \ | ||||||
|  |   -H "Authorization: Bearer {token}" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Example Response:** | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "id": "550e8400-e29b-41d4-a716-446655440008", | ||||||
|  |   "payerWalletId": null, | ||||||
|  |   "payeeWalletId": "550e8400-e29b-41d4-a716-446655440009", | ||||||
|  |   "currency": "points", | ||||||
|  |   "amount": 33.34, | ||||||
|  |   "remarks": "Received fund portion from 550e8400-e29b-41d4-a716-446655440004", | ||||||
|  |   "type": 1, | ||||||
|  |   "createdAt": "2025-10-03T22:05:00Z", | ||||||
|  |   "updatedAt": "2025-10-03T22:05:00Z" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Error Responses:** | ||||||
|  | - `400 Bad Request`: Fund expired, already claimed, not a recipient | ||||||
|  | - `401 Unauthorized`: Missing or invalid authentication | ||||||
|  | - `404 Not Found`: Fund not found | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ### 5. Get Wallet Overview | ||||||
|  |  | ||||||
|  | Retrieves a summarized overview of wallet transactions grouped by type for graphing/charting purposes. | ||||||
|  |  | ||||||
|  | **Endpoint:** `GET /api/wallets/overview` | ||||||
|  |  | ||||||
|  | **Query Parameters:** | ||||||
|  | - `startDate` (string, optional): Start date in ISO 8601 format (e.g., "2025-01-01T00:00:00Z") | ||||||
|  | - `endDate` (string, optional): End date in ISO 8601 format (e.g., "2025-12-31T23:59:59Z") | ||||||
|  |  | ||||||
|  | **Response:** `WalletOverview` (200 OK) | ||||||
|  |  | ||||||
|  | **Example Request:** | ||||||
|  | ```bash | ||||||
|  | curl -X GET "/api/wallets/overview?startDate=2025-01-01T00:00:00Z&endDate=2025-12-31T23:59:59Z" \ | ||||||
|  |   -H "Authorization: Bearer {token}" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Example Response:** | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "accountId": "550e8400-e29b-41d4-a716-446655440000", | ||||||
|  |   "startDate": "2025-01-01T00:00:00.0000000Z", | ||||||
|  |   "endDate": "2025-12-31T23:59:59.0000000Z", | ||||||
|  |   "summary": { | ||||||
|  |     "System": { | ||||||
|  |       "type": "System", | ||||||
|  |       "currencies": { | ||||||
|  |         "points": { | ||||||
|  |           "currency": "points", | ||||||
|  |           "income": 150.00, | ||||||
|  |           "spending": 0.00, | ||||||
|  |           "net": 150.00 | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "Transfer": { | ||||||
|  |       "type": "Transfer", | ||||||
|  |       "currencies": { | ||||||
|  |         "points": { | ||||||
|  |           "currency": "points", | ||||||
|  |           "income": 25.00, | ||||||
|  |           "spending": 75.00, | ||||||
|  |           "net": -50.00 | ||||||
|  |         }, | ||||||
|  |         "golds": { | ||||||
|  |           "currency": "golds", | ||||||
|  |           "income": 0.00, | ||||||
|  |           "spending": 10.00, | ||||||
|  |           "net": -10.00 | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "Order": { | ||||||
|  |       "type": "Order", | ||||||
|  |       "currencies": { | ||||||
|  |         "points": { | ||||||
|  |           "currency": "points", | ||||||
|  |           "income": 0.00, | ||||||
|  |           "spending": 200.00, | ||||||
|  |           "net": -200.00 | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "totalIncome": 175.00, | ||||||
|  |   "totalSpending": 285.00, | ||||||
|  |   "netTotal": -110.00 | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Response Fields:** | ||||||
|  | - `accountId`: User's account UUID | ||||||
|  | - `startDate`/`endDate`: Date range applied (ISO 8601 format) | ||||||
|  | - `summary`: Object keyed by transaction type | ||||||
|  |   - `type`: Transaction type name | ||||||
|  |   - `currencies`: Object keyed by currency code | ||||||
|  |     - `currency`: Currency name | ||||||
|  |     - `income`: Total money received | ||||||
|  |     - `spending`: Total money spent | ||||||
|  |     - `net`: Income minus spending | ||||||
|  | - `totalIncome`: Sum of all income across all types/currencies | ||||||
|  | - `totalSpending`: Sum of all spending across all types/currencies | ||||||
|  | - `netTotal`: Overall net (totalIncome - totalSpending) | ||||||
|  |  | ||||||
|  | **Error Responses:** | ||||||
|  | - `401 Unauthorized`: Missing or invalid authentication | ||||||
|  |  | ||||||
|  | ## Error Codes | ||||||
|  |  | ||||||
|  | ### Common Error Types | ||||||
|  |  | ||||||
|  | #### Validation Errors | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", | ||||||
|  |   "title": "Bad Request", | ||||||
|  |   "status": 400, | ||||||
|  |   "detail": "At least one recipient is required", | ||||||
|  |   "instance": "/api/wallets/funds" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### Insufficient Funds | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", | ||||||
|  |   "title": "Bad Request", | ||||||
|  |   "status": 400, | ||||||
|  |   "detail": "Insufficient funds", | ||||||
|  |   "instance": "/api/wallets/funds" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### Fund Not Available | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", | ||||||
|  |   "title": "Bad Request", | ||||||
|  |   "status": 400, | ||||||
|  |   "detail": "Fund is no longer available", | ||||||
|  |   "instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### Already Claimed | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", | ||||||
|  |   "title": "Bad Request", | ||||||
|  |   "status": 400, | ||||||
|  |   "detail": "You have already received this fund", | ||||||
|  |   "instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Rate Limiting | ||||||
|  |  | ||||||
|  | - **Create Fund**: 10 requests per minute per user | ||||||
|  | - **Get Funds**: 60 requests per minute per user | ||||||
|  | - **Get Fund**: 60 requests per minute per user | ||||||
|  | - **Receive Fund**: 30 requests per minute per user | ||||||
|  |  | ||||||
|  | ## Webhooks/Notifications | ||||||
|  |  | ||||||
|  | The system integrates with the platform's notification system: | ||||||
|  |  | ||||||
|  | - **Fund Created**: Creator receives confirmation | ||||||
|  | - **Fund Claimed**: Creator receives notification when someone claims | ||||||
|  | - **Fund Expired**: Creator receives refund notification | ||||||
|  |  | ||||||
|  | ## SDK Examples | ||||||
|  |  | ||||||
|  | ### JavaScript/TypeScript | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // Create a fund | ||||||
|  | const createFund = async (fundData: CreateFundRequest): Promise<SnWalletFund> => { | ||||||
|  |   const response = await fetch('/api/wallets/funds', { | ||||||
|  |     method: 'POST', | ||||||
|  |     headers: { | ||||||
|  |       'Authorization': `Bearer ${token}`, | ||||||
|  |       'Content-Type': 'application/json' | ||||||
|  |     }, | ||||||
|  |     body: JSON.stringify(fundData) | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (!response.ok) { | ||||||
|  |     throw new Error(`HTTP error! status: ${response.status}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return response.json(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Get user's funds | ||||||
|  | const getFunds = async (params?: { | ||||||
|  |   offset?: number; | ||||||
|  |   take?: number; | ||||||
|  |   status?: FundStatus; | ||||||
|  | }): Promise<SnWalletFund[]> => { | ||||||
|  |   const queryParams = new URLSearchParams(); | ||||||
|  |   if (params?.offset) queryParams.set('offset', params.offset.toString()); | ||||||
|  |   if (params?.take) queryParams.set('take', params.take.toString()); | ||||||
|  |   if (params?.status !== undefined) queryParams.set('status', params.status.toString()); | ||||||
|  |  | ||||||
|  |   const response = await fetch(`/api/wallets/funds?${queryParams}`, { | ||||||
|  |     headers: { | ||||||
|  |       'Authorization': `Bearer ${token}` | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (!response.ok) { | ||||||
|  |     throw new Error(`HTTP error! status: ${response.status}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return response.json(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Claim a fund | ||||||
|  | const receiveFund = async (fundId: string): Promise<SnWalletTransaction> => { | ||||||
|  |   const response = await fetch(`/api/wallets/funds/${fundId}/receive`, { | ||||||
|  |     method: 'POST', | ||||||
|  |     headers: { | ||||||
|  |       'Authorization': `Bearer ${token}` | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (!response.ok) { | ||||||
|  |     throw new Error(`HTTP error! status: ${response.status}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return response.json(); | ||||||
|  | }; | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Python | ||||||
|  |  | ||||||
|  | ```python | ||||||
|  | import requests | ||||||
|  | from typing import List, Optional | ||||||
|  | from enum import Enum | ||||||
|  |  | ||||||
|  | class FundSplitType(Enum): | ||||||
|  |     EVEN = 0 | ||||||
|  |     RANDOM = 1 | ||||||
|  |  | ||||||
|  | class FundStatus(Enum): | ||||||
|  |     CREATED = 0 | ||||||
|  |     PARTIALLY_RECEIVED = 1 | ||||||
|  |     FULLY_RECEIVED = 2 | ||||||
|  |     EXPIRED = 3 | ||||||
|  |     REFUNDED = 4 | ||||||
|  |  | ||||||
|  | def create_fund(token: str, fund_data: dict) -> dict: | ||||||
|  |     """Create a new fund""" | ||||||
|  |     response = requests.post( | ||||||
|  |         '/api/wallets/funds', | ||||||
|  |         json=fund_data, | ||||||
|  |         headers={ | ||||||
|  |             'Authorization': f'Bearer {token}', | ||||||
|  |             'Content-Type': 'application/json' | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     response.raise_for_status() | ||||||
|  |     return response.json() | ||||||
|  |  | ||||||
|  | def get_funds( | ||||||
|  |     token: str, | ||||||
|  |     offset: int = 0, | ||||||
|  |     take: int = 20, | ||||||
|  |     status: Optional[FundStatus] = None | ||||||
|  | ) -> List[dict]: | ||||||
|  |     """Get user's funds""" | ||||||
|  |     params = {'offset': offset, 'take': take} | ||||||
|  |     if status is not None: | ||||||
|  |         params['status'] = status.value | ||||||
|  |  | ||||||
|  |     response = requests.get( | ||||||
|  |         '/api/wallets/funds', | ||||||
|  |         params=params, | ||||||
|  |         headers={'Authorization': f'Bearer {token}'} | ||||||
|  |     ) | ||||||
|  |     response.raise_for_status() | ||||||
|  |     return response.json() | ||||||
|  |  | ||||||
|  | def receive_fund(token: str, fund_id: str) -> dict: | ||||||
|  |     """Claim a fund portion""" | ||||||
|  |     response = requests.post( | ||||||
|  |         f'/api/wallets/funds/{fund_id}/receive', | ||||||
|  |         headers={'Authorization': f'Bearer {token}'} | ||||||
|  |     ) | ||||||
|  |     response.raise_for_status() | ||||||
|  |     return response.json() | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Changelog | ||||||
|  |  | ||||||
|  | ### Version 1.0.0 | ||||||
|  | - Initial release with basic red packet functionality | ||||||
|  | - Support for even and random split types | ||||||
|  | - 24-hour expiration with automatic refunds | ||||||
|  | - RESTful API endpoints | ||||||
|  | - Comprehensive error handling | ||||||
|  |  | ||||||
|  | ## Support | ||||||
|  |  | ||||||
|  | For API support or questions: | ||||||
|  | - Check the main documentation at `README_WALLET_FUNDS.md` | ||||||
|  | - Review error messages for specific guidance | ||||||
|  | - Contact the development team for technical issues | ||||||
							
								
								
									
										66
									
								
								DysonNetwork.Control/AppHost.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								DysonNetwork.Control/AppHost.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | |||||||
|  | using Microsoft.Extensions.Hosting; | ||||||
|  |  | ||||||
|  | var builder = DistributedApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
|  | var isDev = builder.Environment.IsDevelopment(); | ||||||
|  |  | ||||||
|  | var cache = builder.AddRedis("cache"); | ||||||
|  | var queue = builder.AddNats("queue").WithJetStream(); | ||||||
|  |  | ||||||
|  | var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring"); | ||||||
|  | var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass") | ||||||
|  |     .WithReference(ringService); | ||||||
|  | var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive") | ||||||
|  |     .WithReference(passService) | ||||||
|  |     .WithReference(ringService); | ||||||
|  | var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere") | ||||||
|  |     .WithReference(passService) | ||||||
|  |     .WithReference(ringService) | ||||||
|  |     .WithReference(driveService); | ||||||
|  | var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop") | ||||||
|  |     .WithReference(passService) | ||||||
|  |     .WithReference(ringService) | ||||||
|  |     .WithReference(sphereService); | ||||||
|  |  | ||||||
|  | passService.WithReference(developService).WithReference(driveService); | ||||||
|  |  | ||||||
|  | List<IResourceBuilder<ProjectResource>> services = | ||||||
|  |     [ringService, passService, driveService, sphereService, developService]; | ||||||
|  |  | ||||||
|  | for (var idx = 0; idx < services.Count; idx++) | ||||||
|  | { | ||||||
|  |     var service = services[idx]; | ||||||
|  |  | ||||||
|  |     service.WithReference(cache).WithReference(queue); | ||||||
|  |  | ||||||
|  |     var grpcPort = 7002 + idx; | ||||||
|  |  | ||||||
|  |     if (isDev) | ||||||
|  |     { | ||||||
|  |         service.WithEnvironment("GRPC_PORT", grpcPort.ToString()); | ||||||
|  |  | ||||||
|  |         var httpPort = 8001 + idx; | ||||||
|  |         service.WithEnvironment("HTTP_PORTS", httpPort.ToString()); | ||||||
|  |         service.WithHttpEndpoint(httpPort, targetPort: null, isProxied: false, name: "http"); | ||||||
|  |     } | ||||||
|  |     else | ||||||
|  |     { | ||||||
|  |         service.WithHttpEndpoint(8080, targetPort: null, isProxied: false, name: "http"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     service.WithEndpoint(isDev ? grpcPort : 7001, isDev ? null : 7001, "https", name: "grpc", isProxied: false); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Extra double-ended references | ||||||
|  | ringService.WithReference(passService); | ||||||
|  |  | ||||||
|  | var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway") | ||||||
|  |     .WithEnvironment("HTTP_PORTS", "5001") | ||||||
|  |     .WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http"); | ||||||
|  |  | ||||||
|  | foreach (var service in services) | ||||||
|  |     gateway.WithReference(service); | ||||||
|  |  | ||||||
|  | builder.AddDockerComposeEnvironment("docker-compose"); | ||||||
|  |  | ||||||
|  | builder.Build().Run(); | ||||||
							
								
								
									
										25
									
								
								DysonNetwork.Control/DysonNetwork.Control.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								DysonNetwork.Control/DysonNetwork.Control.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | |||||||
|  | <Project Sdk="Microsoft.NET.Sdk"> | ||||||
|  |   <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" /> | ||||||
|  |   <PropertyGroup> | ||||||
|  |     <OutputType>Exe</OutputType> | ||||||
|  |     <TargetFramework>net9.0</TargetFramework> | ||||||
|  |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|  |     <Nullable>enable</Nullable> | ||||||
|  |     <UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId> | ||||||
|  |     <RootNamespace>DysonNetwork.Control</RootNamespace> | ||||||
|  |   </PropertyGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.1" /> | ||||||
|  |     <PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" /> | ||||||
|  |     <PackageReference Include="Aspire.Hosting.Nats" Version="9.5.1" /> | ||||||
|  |     <PackageReference Include="Aspire.Hosting.Redis" Version="9.5.1" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" /> | ||||||
|  |     <ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" /> | ||||||
|  |     <ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" /> | ||||||
|  |     <ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" /> | ||||||
|  |     <ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" /> | ||||||
|  |     <ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" /> | ||||||
|  |   </ItemGroup> | ||||||
|  | </Project> | ||||||
							
								
								
									
										32
									
								
								DysonNetwork.Control/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								DysonNetwork.Control/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | { | ||||||
|  |   "$schema": "https://json.schemastore.org/launchsettings.json", | ||||||
|  |   "profiles": { | ||||||
|  |     "https": { | ||||||
|  |       "commandName": "Project", | ||||||
|  |       "dotnetRunMessages": true, | ||||||
|  |       "launchBrowser": true, | ||||||
|  |       "applicationUrl": "https://localhost:17025;http://localhost:15057", | ||||||
|  |       "environmentVariables": { | ||||||
|  |         "ASPNETCORE_ENVIRONMENT": "Development", | ||||||
|  |         "DOTNET_ENVIRONMENT": "Development", | ||||||
|  |         "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175", | ||||||
|  |         "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189", | ||||||
|  |         "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21260", | ||||||
|  |         "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22052" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "http": { | ||||||
|  |       "commandName": "Project", | ||||||
|  |       "dotnetRunMessages": true, | ||||||
|  |       "launchBrowser": true, | ||||||
|  |       "applicationUrl": "http://localhost:15057", | ||||||
|  |       "environmentVariables": { | ||||||
|  |         "ASPNETCORE_ENVIRONMENT": "Development", | ||||||
|  |         "DOTNET_ENVIRONMENT": "Development", | ||||||
|  |         "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163", | ||||||
|  |         "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185", | ||||||
|  |         "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:22108" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								DysonNetwork.Control/appsettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								DysonNetwork.Control/appsettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | { | ||||||
|  |   "Logging": { | ||||||
|  |     "LogLevel": { | ||||||
|  |       "Default": "Information", | ||||||
|  |       "Microsoft.AspNetCore": "Warning" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "ConnectionStrings": { | ||||||
|  |     "cache": "localhost:6379" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										51
									
								
								DysonNetwork.Develop/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								DysonNetwork.Develop/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Design; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop; | ||||||
|  |  | ||||||
|  | public class AppDatabase( | ||||||
|  |     DbContextOptions<AppDatabase> options, | ||||||
|  |     IConfiguration configuration | ||||||
|  | ) : DbContext(options) | ||||||
|  | { | ||||||
|  |     public DbSet<SnDeveloper> Developers { get; set; } = null!; | ||||||
|  |  | ||||||
|  |     public DbSet<SnDevProject> DevProjects { get; set; } = null!; | ||||||
|  |      | ||||||
|  |     public DbSet<SnCustomApp> CustomApps { get; set; } = null!; | ||||||
|  |     public DbSet<SnCustomAppSecret> CustomAppSecrets { get; set; } = null!; | ||||||
|  |     public DbSet<SnBotAccount> BotAccounts { get; set; } = null!; | ||||||
|  |  | ||||||
|  |     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | ||||||
|  |     { | ||||||
|  |         optionsBuilder.UseNpgsql( | ||||||
|  |             configuration.GetConnectionString("App"), | ||||||
|  |             opt => opt | ||||||
|  |                 .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) | ||||||
|  |                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) | ||||||
|  |                 .UseNodaTime() | ||||||
|  |         ).UseSnakeCaseNamingConvention(); | ||||||
|  |  | ||||||
|  |         base.OnConfiguring(optionsBuilder); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     protected override void OnModelCreating(ModelBuilder modelBuilder) | ||||||
|  |     { | ||||||
|  |         base.OnModelCreating(modelBuilder); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase> | ||||||
|  | { | ||||||
|  |     public AppDatabase CreateDbContext(string[] args) | ||||||
|  |     { | ||||||
|  |         var configuration = new ConfigurationBuilder() | ||||||
|  |             .SetBasePath(Directory.GetCurrentDirectory()) | ||||||
|  |             .AddJsonFile("appsettings.json") | ||||||
|  |             .Build(); | ||||||
|  |  | ||||||
|  |         var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>(); | ||||||
|  |         return new AppDatabase(optionsBuilder.Options, configuration); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								DysonNetwork.Develop/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								DysonNetwork.Develop/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base | ||||||
|  | USER $APP_UID | ||||||
|  | WORKDIR /app | ||||||
|  | EXPOSE 8080 | ||||||
|  | EXPOSE 8081 | ||||||
|  |  | ||||||
|  | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||||
|  | ARG BUILD_CONFIGURATION=Release | ||||||
|  | WORKDIR /src | ||||||
|  | COPY ["DysonNetwork.Develop/DysonNetwork.Develop.csproj", "DysonNetwork.Develop/"] | ||||||
|  | RUN dotnet restore "DysonNetwork.Develop/DysonNetwork.Develop.csproj" | ||||||
|  | COPY . . | ||||||
|  | WORKDIR "/src/DysonNetwork.Develop" | ||||||
|  | RUN dotnet build "./DysonNetwork.Develop.csproj" -c $BUILD_CONFIGURATION -o /app/build | ||||||
|  |  | ||||||
|  | FROM build AS publish | ||||||
|  | ARG BUILD_CONFIGURATION=Release | ||||||
|  | RUN dotnet publish "./DysonNetwork.Develop.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false | ||||||
|  |  | ||||||
|  | FROM base AS final | ||||||
|  | WORKDIR /app | ||||||
|  | COPY --from=publish /app/publish . | ||||||
|  | ENTRYPOINT ["dotnet", "DysonNetwork.Develop.dll"] | ||||||
							
								
								
									
										37
									
								
								DysonNetwork.Develop/DysonNetwork.Develop.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								DysonNetwork.Develop/DysonNetwork.Develop.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||||
|  |  | ||||||
|  |     <PropertyGroup> | ||||||
|  |         <TargetFramework>net9.0</TargetFramework> | ||||||
|  |         <Nullable>enable</Nullable> | ||||||
|  |         <ImplicitUsings>enable</ImplicitUsings> | ||||||
|  |         <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> | ||||||
|  |     </PropertyGroup> | ||||||
|  |  | ||||||
|  |     <ItemGroup> | ||||||
|  |         <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> | ||||||
|  |         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/> | ||||||
|  |         <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> | ||||||
|  |             <PrivateAssets>all</PrivateAssets> | ||||||
|  |             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|  |         </PackageReference> | ||||||
|  |         <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> | ||||||
|  |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/> | ||||||
|  |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> | ||||||
|  |         <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/> | ||||||
|  |         <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> | ||||||
|  |         <PackageReference Include="NodaTime" Version="3.2.2"/> | ||||||
|  |         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> | ||||||
|  |         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/> | ||||||
|  |   </ItemGroup> | ||||||
|  |   | ||||||
|  |   <ItemGroup> | ||||||
|  |       <Content Include="..\.dockerignore"> | ||||||
|  |         <Link>.dockerignore</Link> | ||||||
|  |       </Content> | ||||||
|  |     </ItemGroup> | ||||||
|  |  | ||||||
|  |   <ItemGroup> | ||||||
|  |     <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |   | ||||||
|  | </Project> | ||||||
							
								
								
									
										460
									
								
								DysonNetwork.Develop/Identity/BotAccountController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										460
									
								
								DysonNetwork.Develop/Identity/BotAccountController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,460 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  | using DysonNetwork.Develop.Project; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using DysonNetwork.Shared.Registry; | ||||||
|  | using Grpc.Core; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using NodaTime; | ||||||
|  | using NodaTime.Serialization.Protobuf; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Identity; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/developers/{pubName}/projects/{projectId:guid}/bots")] | ||||||
|  | [Authorize] | ||||||
|  | public class BotAccountController( | ||||||
|  |     BotAccountService botService, | ||||||
|  |     DeveloperService ds, | ||||||
|  |     DevProjectService projectService, | ||||||
|  |     ILogger<BotAccountController> logger, | ||||||
|  |     AccountClientHelper accounts, | ||||||
|  |     BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver | ||||||
|  | ) | ||||||
|  |     : ControllerBase | ||||||
|  | { | ||||||
|  |     public class CommonBotRequest | ||||||
|  |     { | ||||||
|  |         [MaxLength(256)] public string? FirstName { get; set; } | ||||||
|  |         [MaxLength(256)] public string? MiddleName { get; set; } | ||||||
|  |         [MaxLength(256)] public string? LastName { get; set; } | ||||||
|  |         [MaxLength(1024)] public string? Gender { get; set; } | ||||||
|  |         [MaxLength(1024)] public string? Pronouns { get; set; } | ||||||
|  |         [MaxLength(1024)] public string? TimeZone { get; set; } | ||||||
|  |         [MaxLength(1024)] public string? Location { get; set; } | ||||||
|  |         [MaxLength(4096)] public string? Bio { get; set; } | ||||||
|  |         public Instant? Birthday { get; set; } | ||||||
|  |  | ||||||
|  |         [MaxLength(32)] public string? PictureId { get; set; } | ||||||
|  |         [MaxLength(32)] public string? BackgroundId { get; set; } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public class BotCreateRequest : CommonBotRequest | ||||||
|  |     { | ||||||
|  |         [Required] | ||||||
|  |         [MinLength(2)] | ||||||
|  |         [MaxLength(256)] | ||||||
|  |         [RegularExpression(@"^[A-Za-z0-9_-]+$", | ||||||
|  |             ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.") | ||||||
|  |         ] | ||||||
|  |         public string Name { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|  |         [Required][MaxLength(256)] public string Nick { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|  |         [Required][MaxLength(1024)] public string Slug { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|  |         [MaxLength(128)] public string Language { get; set; } = "en-us"; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public class UpdateBotRequest : CommonBotRequest | ||||||
|  |     { | ||||||
|  |         [MinLength(2)] | ||||||
|  |         [MaxLength(256)] | ||||||
|  |         [RegularExpression(@"^[A-Za-z0-9_-]+$", | ||||||
|  |             ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.") | ||||||
|  |         ] | ||||||
|  |         public string? Name { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|  |         [MaxLength(256)] public string? Nick { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|  |         [Required][MaxLength(1024)] public string? Slug { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|  |         [MaxLength(128)] public string? Language { get; set; } | ||||||
|  |  | ||||||
|  |         public bool? IsActive { get; set; } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet] | ||||||
|  |     public async Task<IActionResult> ListBots( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||||
|  |                 Shared.Proto.PublisherMemberRole.Viewer)) | ||||||
|  |             return StatusCode(403, "You must be an viewer of the developer to list bots"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var bots = await botService.GetBotsByProjectAsync(projectId); | ||||||
|  |         return Ok(await botService.LoadBotsAccountAsync(bots)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{botId:guid}")] | ||||||
|  |     public async Task<IActionResult> GetBot( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid botId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||||
|  |                 Shared.Proto.PublisherMemberRole.Viewer)) | ||||||
|  |             return StatusCode(403, "You must be an viewer of the developer to view bot details"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var bot = await botService.GetBotByIdAsync(botId); | ||||||
|  |         if (bot is null || bot.ProjectId != projectId) | ||||||
|  |             return NotFound("Bot not found"); | ||||||
|  |  | ||||||
|  |         return Ok(await botService.LoadBotAccountAsync(bot)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost] | ||||||
|  |     public async Task<IActionResult> CreateBot( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromBody] BotCreateRequest createRequest | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||||
|  |                 Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to create a bot"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |         var accountId = Guid.NewGuid(); | ||||||
|  |         var account = new Account() | ||||||
|  |         { | ||||||
|  |             Id = accountId.ToString(), | ||||||
|  |             Name = createRequest.Name, | ||||||
|  |             Nick = createRequest.Nick, | ||||||
|  |             Language = createRequest.Language, | ||||||
|  |             Profile = new AccountProfile() | ||||||
|  |             { | ||||||
|  |                 Id = Guid.NewGuid().ToString(), | ||||||
|  |                 Bio = createRequest.Bio, | ||||||
|  |                 Gender = createRequest.Gender, | ||||||
|  |                 FirstName = createRequest.FirstName, | ||||||
|  |                 MiddleName = createRequest.MiddleName, | ||||||
|  |                 LastName = createRequest.LastName, | ||||||
|  |                 TimeZone = createRequest.TimeZone, | ||||||
|  |                 Pronouns = createRequest.Pronouns, | ||||||
|  |                 Location = createRequest.Location, | ||||||
|  |                 Birthday = createRequest.Birthday?.ToTimestamp(), | ||||||
|  |                 AccountId = accountId.ToString(), | ||||||
|  |                 CreatedAt = now.ToTimestamp(), | ||||||
|  |                 UpdatedAt = now.ToTimestamp() | ||||||
|  |             }, | ||||||
|  |             CreatedAt = now.ToTimestamp(), | ||||||
|  |             UpdatedAt = now.ToTimestamp() | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var bot = await botService.CreateBotAsync( | ||||||
|  |                 project, | ||||||
|  |                 createRequest.Slug, | ||||||
|  |                 account, | ||||||
|  |                 createRequest.PictureId, | ||||||
|  |                 createRequest.BackgroundId | ||||||
|  |             ); | ||||||
|  |             return Ok(bot); | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             logger.LogError(ex, "Error creating bot account"); | ||||||
|  |             return StatusCode(500, "An error occurred while creating the bot account"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPatch("{botId:guid}")] | ||||||
|  |     public async Task<IActionResult> UpdateBot( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid botId, | ||||||
|  |         [FromBody] UpdateBotRequest request | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||||
|  |                 Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to update a bot"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var bot = await botService.GetBotByIdAsync(botId); | ||||||
|  |         if (bot is null || bot.ProjectId != projectId) | ||||||
|  |             return NotFound("Bot not found"); | ||||||
|  |  | ||||||
|  |         var botAccount = await accounts.GetBotAccount(bot.Id); | ||||||
|  |  | ||||||
|  |         if (request.Name is not null) botAccount.Name = request.Name; | ||||||
|  |         if (request.Nick is not null) botAccount.Nick = request.Nick; | ||||||
|  |         if (request.Language is not null) botAccount.Language = request.Language; | ||||||
|  |         if (request.Bio is not null) botAccount.Profile.Bio = request.Bio; | ||||||
|  |         if (request.Gender is not null) botAccount.Profile.Gender = request.Gender; | ||||||
|  |         if (request.FirstName is not null) botAccount.Profile.FirstName = request.FirstName; | ||||||
|  |         if (request.MiddleName is not null) botAccount.Profile.MiddleName = request.MiddleName; | ||||||
|  |         if (request.LastName is not null) botAccount.Profile.LastName = request.LastName; | ||||||
|  |         if (request.TimeZone is not null) botAccount.Profile.TimeZone = request.TimeZone; | ||||||
|  |         if (request.Pronouns is not null) botAccount.Profile.Pronouns = request.Pronouns; | ||||||
|  |         if (request.Location is not null) botAccount.Profile.Location = request.Location; | ||||||
|  |         if (request.Birthday is not null) botAccount.Profile.Birthday = request.Birthday?.ToTimestamp(); | ||||||
|  |  | ||||||
|  |         if (request.Slug is not null) bot.Slug = request.Slug; | ||||||
|  |         if (request.IsActive is not null) bot.IsActive = request.IsActive.Value; | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var updatedBot = await botService.UpdateBotAsync( | ||||||
|  |                 bot, | ||||||
|  |                 botAccount, | ||||||
|  |                 request.PictureId, | ||||||
|  |                 request.BackgroundId | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             return Ok(updatedBot); | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             logger.LogError(ex, "Error updating bot account {BotId}", botId); | ||||||
|  |             return StatusCode(500, "An error occurred while updating the bot account"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpDelete("{botId:guid}")] | ||||||
|  |     public async Task<IActionResult> DeleteBot( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid botId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||||
|  |                 Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to delete a bot"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var bot = await botService.GetBotByIdAsync(botId); | ||||||
|  |         if (bot is null || bot.ProjectId != projectId) | ||||||
|  |             return NotFound("Bot not found"); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             await botService.DeleteBotAsync(bot); | ||||||
|  |             return NoContent(); | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             logger.LogError(ex, "Error deleting bot {BotId}", botId); | ||||||
|  |             return StatusCode(500, "An error occurred while deleting the bot account"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{botId:guid}/keys")] | ||||||
|  |     public async Task<ActionResult<List<SnApiKey>>> ListBotKeys( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid botId | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Viewer); | ||||||
|  |         if (developer == null) return NotFound("Developer not found"); | ||||||
|  |         if (project == null) return NotFound("Project not found or you don't have access"); | ||||||
|  |         if (bot == null) return NotFound("Bot not found"); | ||||||
|  |  | ||||||
|  |         var keys = await accountsReceiver.ListApiKeyAsync(new ListApiKeyRequest | ||||||
|  |         { | ||||||
|  |             AutomatedId = bot.Id.ToString() | ||||||
|  |         }); | ||||||
|  |         var data = keys.Data.Select(SnApiKey.FromProtoValue).ToList(); | ||||||
|  |  | ||||||
|  |         return Ok(data); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{botId:guid}/keys/{keyId:guid}")] | ||||||
|  |     public async Task<ActionResult<SnApiKey>> GetBotKey( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid botId, | ||||||
|  |         [FromRoute] Guid keyId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Viewer); | ||||||
|  |         if (developer == null) return NotFound("Developer not found"); | ||||||
|  |         if (project == null) return NotFound("Project not found or you don't have access"); | ||||||
|  |         if (bot == null) return NotFound("Bot not found"); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); | ||||||
|  |             if (key == null) return NotFound("API key not found"); | ||||||
|  |             return Ok(SnApiKey.FromProtoValue(key)); | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) | ||||||
|  |         { | ||||||
|  |             return NotFound("API key not found"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public class CreateApiKeyRequest | ||||||
|  |     { | ||||||
|  |         [Required, MaxLength(1024)] | ||||||
|  |         public string Label { get; set; } = null!; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost("{botId:guid}/keys")] | ||||||
|  |     public async Task<ActionResult<SnApiKey>> CreateBotKey( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid botId, | ||||||
|  |         [FromBody] CreateApiKeyRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Editor); | ||||||
|  |         if (developer == null) return NotFound("Developer not found"); | ||||||
|  |         if (project == null) return NotFound("Project not found or you don't have access"); | ||||||
|  |         if (bot == null) return NotFound("Bot not found"); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var newKey = new ApiKey | ||||||
|  |             { | ||||||
|  |                 AccountId = bot.Id.ToString(), | ||||||
|  |                 Label = request.Label | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey); | ||||||
|  |             return Ok(SnApiKey.FromProtoValue(createdKey)); | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) | ||||||
|  |         { | ||||||
|  |             return BadRequest(ex.Status.Detail); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")] | ||||||
|  |     public async Task<ActionResult<SnApiKey>> RotateBotKey( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid botId, | ||||||
|  |         [FromRoute] Guid keyId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Editor); | ||||||
|  |         if (developer == null) return NotFound("Developer not found"); | ||||||
|  |         if (project == null) return NotFound("Project not found or you don't have access"); | ||||||
|  |         if (bot == null) return NotFound("Bot not found"); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); | ||||||
|  |             return Ok(SnApiKey.FromProtoValue(rotatedKey)); | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) | ||||||
|  |         { | ||||||
|  |             return NotFound("API key not found"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpDelete("{botId:guid}/keys/{keyId:guid}")] | ||||||
|  |     public async Task<IActionResult> DeleteBotKey( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid botId, | ||||||
|  |         [FromRoute] Guid keyId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Editor); | ||||||
|  |         if (developer == null) return NotFound("Developer not found"); | ||||||
|  |         if (project == null) return NotFound("Project not found or you don't have access"); | ||||||
|  |         if (bot == null) return NotFound("Bot not found"); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             await accountsReceiver.DeleteApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); | ||||||
|  |             return NoContent(); | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) | ||||||
|  |         { | ||||||
|  |             return NotFound("API key not found"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async Task<(SnDeveloper?, SnDevProject?, SnBotAccount?)> ValidateBotAccess( | ||||||
|  |         string pubName, | ||||||
|  |         Guid projectId, | ||||||
|  |         Guid botId, | ||||||
|  |         Account currentUser, | ||||||
|  |         Shared.Proto.PublisherMemberRole requiredRole) | ||||||
|  |     { | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer == null) return (null, null, null); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole)) | ||||||
|  |             return (null, null, null); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project == null) return (developer, null, null); | ||||||
|  |  | ||||||
|  |         var bot = await botService.GetBotByIdAsync(botId); | ||||||
|  |         if (bot == null || bot.ProjectId != projectId) return (developer, project, null); | ||||||
|  |  | ||||||
|  |         return (developer, project, bot); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										36
									
								
								DysonNetwork.Develop/Identity/BotAccountPublicController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								DysonNetwork.Develop/Identity/BotAccountPublicController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Identity; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("api/bots")] | ||||||
|  | public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase | ||||||
|  | { | ||||||
|  |     [HttpGet("{botId:guid}")] | ||||||
|  |     public async Task<ActionResult<SnBotAccount>> GetBotTransparentInfo([FromRoute] Guid botId) | ||||||
|  |     { | ||||||
|  |         var bot = await botService.GetBotByIdAsync(botId); | ||||||
|  |         if (bot is null) return NotFound("Bot not found"); | ||||||
|  |         bot = await botService.LoadBotAccountAsync(bot); | ||||||
|  |  | ||||||
|  |         var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId); | ||||||
|  |         if (developer is null) return NotFound("Developer not found"); | ||||||
|  |         bot.Developer = await developerService.LoadDeveloperPublisher(developer); | ||||||
|  |  | ||||||
|  |         return Ok(bot); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{botId:guid}/developer")] | ||||||
|  |     public async Task<ActionResult<SnDeveloper>> GetBotDeveloper([FromRoute] Guid botId) | ||||||
|  |     { | ||||||
|  |         var bot = await botService.GetBotByIdAsync(botId); | ||||||
|  |         if (bot is null) return NotFound("Bot not found"); | ||||||
|  |          | ||||||
|  |         var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId); | ||||||
|  |         if (developer is null) return NotFound("Developer not found"); | ||||||
|  |         developer = await developerService.LoadDeveloperPublisher(developer); | ||||||
|  |  | ||||||
|  |         return Ok(developer); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										172
									
								
								DysonNetwork.Develop/Identity/BotAccountService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								DysonNetwork.Develop/Identity/BotAccountService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using DysonNetwork.Shared.Registry; | ||||||
|  | using Grpc.Core; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NodaTime.Serialization.Protobuf; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Identity; | ||||||
|  |  | ||||||
|  | public class BotAccountService( | ||||||
|  |     AppDatabase db, | ||||||
|  |     BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver, | ||||||
|  |     AccountClientHelper accounts | ||||||
|  | ) | ||||||
|  | { | ||||||
|  |     public async Task<SnBotAccount?> GetBotByIdAsync(Guid id) | ||||||
|  |     { | ||||||
|  |         return await db.BotAccounts | ||||||
|  |             .Include(b => b.Project) | ||||||
|  |             .FirstOrDefaultAsync(b => b.Id == id); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<List<SnBotAccount>> GetBotsByProjectAsync(Guid projectId) | ||||||
|  |     { | ||||||
|  |         return await db.BotAccounts | ||||||
|  |             .Where(b => b.ProjectId == projectId) | ||||||
|  |             .ToListAsync(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnBotAccount> CreateBotAsync( | ||||||
|  |         SnDevProject project, | ||||||
|  |         string slug, | ||||||
|  |         Account account, | ||||||
|  |         string? pictureId, | ||||||
|  |         string? backgroundId | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         // First, check if a bot with this slug already exists in this project | ||||||
|  |         var existingBot = await db.BotAccounts | ||||||
|  |             .FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug); | ||||||
|  |  | ||||||
|  |         if (existingBot != null) | ||||||
|  |             throw new InvalidOperationException("A bot with this slug already exists in this project."); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var automatedId = Guid.NewGuid(); | ||||||
|  |             var createRequest = new CreateBotAccountRequest | ||||||
|  |             { | ||||||
|  |                 AutomatedId = automatedId.ToString(), | ||||||
|  |                 Account = account, | ||||||
|  |                 PictureId = pictureId, | ||||||
|  |                 BackgroundId = backgroundId | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest); | ||||||
|  |             var botAccount = createResponse.Bot; | ||||||
|  |  | ||||||
|  |             // Then create the local bot account | ||||||
|  |             var bot = new SnBotAccount | ||||||
|  |             { | ||||||
|  |                 Id = automatedId, | ||||||
|  |                 Slug = slug, | ||||||
|  |                 ProjectId = project.Id, | ||||||
|  |                 Project = project, | ||||||
|  |                 IsActive = botAccount.IsActive, | ||||||
|  |                 CreatedAt = botAccount.CreatedAt.ToInstant(), | ||||||
|  |                 UpdatedAt = botAccount.UpdatedAt.ToInstant() | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             db.BotAccounts.Add(bot); | ||||||
|  |             await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |             return bot; | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException( | ||||||
|  |                 "A bot account with this ID already exists in the authentication service.", ex); | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument) | ||||||
|  |         { | ||||||
|  |             throw new ArgumentException($"Invalid bot account data: {ex.Status.Detail}", ex); | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) | ||||||
|  |         { | ||||||
|  |             throw new Exception($"Failed to create bot account: {ex.Status.Detail}", ex); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnBotAccount> UpdateBotAsync( | ||||||
|  |         SnBotAccount bot, | ||||||
|  |         Account account, | ||||||
|  |         string? pictureId, | ||||||
|  |         string? backgroundId | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         db.Update(bot); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             // Update the bot account in the Pass service | ||||||
|  |             var updateRequest = new UpdateBotAccountRequest | ||||||
|  |             { | ||||||
|  |                 AutomatedId = bot.Id.ToString(), | ||||||
|  |                 Account = account, | ||||||
|  |                 PictureId = pictureId, | ||||||
|  |                 BackgroundId = backgroundId | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest); | ||||||
|  |             var updatedBot = updateResponse.Bot; | ||||||
|  |  | ||||||
|  |             // Update local bot account | ||||||
|  |             bot.UpdatedAt = updatedBot.UpdatedAt.ToInstant(); | ||||||
|  |             bot.IsActive = updatedBot.IsActive; | ||||||
|  |             await db.SaveChangesAsync(); | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) | ||||||
|  |         { | ||||||
|  |             throw new Exception("Bot account not found in the authentication service", ex); | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) | ||||||
|  |         { | ||||||
|  |             throw new Exception($"Failed to update bot account: {ex.Status.Detail}", ex); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return bot; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task DeleteBotAsync(SnBotAccount bot) | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             // Delete the bot account from the Pass service | ||||||
|  |             var deleteRequest = new DeleteBotAccountRequest | ||||||
|  |             { | ||||||
|  |                 AutomatedId = bot.Id.ToString(), | ||||||
|  |                 Force = false | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             await accountReceiver.DeleteBotAccountAsync(deleteRequest); | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) | ||||||
|  |         { | ||||||
|  |             // Account not found in Pass service, continue with local deletion | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Delete the local bot account | ||||||
|  |         db.BotAccounts.Remove(bot); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnBotAccount?> LoadBotAccountAsync(SnBotAccount bot) => | ||||||
|  |         (await LoadBotsAccountAsync([bot])).FirstOrDefault(); | ||||||
|  |  | ||||||
|  |     public async Task<List<SnBotAccount>> LoadBotsAccountAsync(List<SnBotAccount> bots) | ||||||
|  |     { | ||||||
|  |         var automatedIds = bots.Select(b => b.Id).ToList(); | ||||||
|  |         var data = await accounts.GetBotAccountBatch(automatedIds); | ||||||
|  |  | ||||||
|  |         foreach (var bot in bots) | ||||||
|  |         { | ||||||
|  |             bot.Account = data | ||||||
|  |                 .Select(SnAccount.FromProtoValue) | ||||||
|  |                 .FirstOrDefault(e => e.AutomatedId == bot.Id); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return bots; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										432
									
								
								DysonNetwork.Develop/Identity/CustomAppController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										432
									
								
								DysonNetwork.Develop/Identity/CustomAppController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,432 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  | using DysonNetwork.Develop.Project; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Identity; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")] | ||||||
|  | public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService) | ||||||
|  |     : ControllerBase | ||||||
|  | { | ||||||
|  |     public record CustomAppRequest( | ||||||
|  |         [MaxLength(1024)] string? Slug, | ||||||
|  |         [MaxLength(1024)] string? Name, | ||||||
|  |         [MaxLength(4096)] string? Description, | ||||||
|  |         string? PictureId, | ||||||
|  |         string? BackgroundId, | ||||||
|  |         Shared.Models.CustomAppStatus? Status, | ||||||
|  |         SnCustomAppLinks? Links, | ||||||
|  |         SnCustomAppOauthConfig? OauthConfig | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     public record CreateSecretRequest( | ||||||
|  |         [MaxLength(4096)] string? Description, | ||||||
|  |         TimeSpan? ExpiresIn = null, | ||||||
|  |         bool IsOidc = false | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     public record SecretResponse( | ||||||
|  |         string Id, | ||||||
|  |         string? Secret, | ||||||
|  |         string? Description, | ||||||
|  |         Instant? ExpiresAt, | ||||||
|  |         bool IsOidc, | ||||||
|  |         Instant CreatedAt, | ||||||
|  |         Instant UpdatedAt | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     [HttpGet] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer)) | ||||||
|  |             return StatusCode(403, "You must be a viewer of the developer to list custom apps"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         var apps = await customApps.GetAppsByProjectAsync(projectId); | ||||||
|  |         return Ok(apps); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{appId:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid appId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |          | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) return NotFound(); | ||||||
|  |          | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer)) | ||||||
|  |             return StatusCode(403, "You must be a viewer of the developer to list custom apps"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         var app = await customApps.GetAppAsync(appId, projectId); | ||||||
|  |         if (app == null) | ||||||
|  |             return NotFound(); | ||||||
|  |  | ||||||
|  |         return Ok(app); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> CreateApp( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromBody] CustomAppRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to create a custom app"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug)) | ||||||
|  |             return BadRequest("Name and slug are required"); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var app = await customApps.CreateAppAsync(projectId, request); | ||||||
|  |             if (app == null) | ||||||
|  |                 return BadRequest("Failed to create app"); | ||||||
|  |  | ||||||
|  |             return CreatedAtAction( | ||||||
|  |                 nameof(GetApp), | ||||||
|  |                 new { pubName, projectId, appId = app.Id }, | ||||||
|  |                 app | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |         catch (InvalidOperationException ex) | ||||||
|  |         { | ||||||
|  |             return BadRequest(ex.Message); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPatch("{appId:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> UpdateApp( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid appId, | ||||||
|  |         [FromBody] CustomAppRequest request | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to update a custom app"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var app = await customApps.GetAppAsync(appId, projectId); | ||||||
|  |         if (app == null) | ||||||
|  |             return NotFound(); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             app = await customApps.UpdateAppAsync(app, request); | ||||||
|  |             return Ok(app); | ||||||
|  |         } | ||||||
|  |         catch (InvalidOperationException ex) | ||||||
|  |         { | ||||||
|  |             return BadRequest(ex.Message); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpDelete("{appId:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> DeleteApp( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid appId | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to delete a custom app"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var app = await customApps.GetAppAsync(appId, projectId); | ||||||
|  |         if (app == null) | ||||||
|  |             return NotFound(); | ||||||
|  |  | ||||||
|  |         var result = await customApps.DeleteAppAsync(appId); | ||||||
|  |         if (!result) | ||||||
|  |             return NotFound(); | ||||||
|  |  | ||||||
|  |         return NoContent(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{appId:guid}/secrets")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> ListSecrets( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid appId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to view app secrets"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var app = await customApps.GetAppAsync(appId, projectId); | ||||||
|  |         if (app == null) | ||||||
|  |             return NotFound("App not found"); | ||||||
|  |  | ||||||
|  |         var secrets = await customApps.GetAppSecretsAsync(appId); | ||||||
|  |         return Ok(secrets.Select(s => new SecretResponse( | ||||||
|  |             s.Id.ToString(), | ||||||
|  |             null, | ||||||
|  |             s.Description, | ||||||
|  |             s.ExpiredAt, | ||||||
|  |             s.IsOidc, | ||||||
|  |             s.CreatedAt, | ||||||
|  |             s.UpdatedAt | ||||||
|  |         ))); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost("{appId:guid}/secrets")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> CreateSecret( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid appId, | ||||||
|  |         [FromBody] CreateSecretRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to create app secrets"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var app = await customApps.GetAppAsync(appId, projectId); | ||||||
|  |         if (app == null) | ||||||
|  |             return NotFound("App not found"); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var secret = await customApps.CreateAppSecretAsync(new SnCustomAppSecret | ||||||
|  |             { | ||||||
|  |                 AppId = appId, | ||||||
|  |                 Description = request.Description, | ||||||
|  |                 ExpiredAt = request.ExpiresIn.HasValue | ||||||
|  |                     ? NodaTime.SystemClock.Instance.GetCurrentInstant() | ||||||
|  |                         .Plus(Duration.FromTimeSpan(request.ExpiresIn.Value)) | ||||||
|  |                     : (NodaTime.Instant?)null, | ||||||
|  |                 IsOidc = request.IsOidc | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             return CreatedAtAction( | ||||||
|  |                 nameof(GetSecret), | ||||||
|  |                 new { pubName, projectId, appId, secretId = secret.Id }, | ||||||
|  |                 new SecretResponse( | ||||||
|  |                     secret.Id.ToString(), | ||||||
|  |                     secret.Secret, | ||||||
|  |                     secret.Description, | ||||||
|  |                     secret.ExpiredAt, | ||||||
|  |                     secret.IsOidc, | ||||||
|  |                     secret.CreatedAt, | ||||||
|  |                     secret.UpdatedAt | ||||||
|  |                 ) | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |         catch (InvalidOperationException ex) | ||||||
|  |         { | ||||||
|  |             return BadRequest(ex.Message); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{appId:guid}/secrets/{secretId:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> GetSecret( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid appId, | ||||||
|  |         [FromRoute] Guid secretId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to view app secrets"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var app = await customApps.GetAppAsync(appId, projectId); | ||||||
|  |         if (app == null) | ||||||
|  |             return NotFound("App not found"); | ||||||
|  |  | ||||||
|  |         var secret = await customApps.GetAppSecretAsync(secretId, appId); | ||||||
|  |         if (secret == null) | ||||||
|  |             return NotFound("Secret not found"); | ||||||
|  |  | ||||||
|  |         return Ok(new SecretResponse( | ||||||
|  |             secret.Id.ToString(), | ||||||
|  |             null, | ||||||
|  |             secret.Description, | ||||||
|  |             secret.ExpiredAt, | ||||||
|  |             secret.IsOidc, | ||||||
|  |             secret.CreatedAt, | ||||||
|  |             secret.UpdatedAt | ||||||
|  |         )); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpDelete("{appId:guid}/secrets/{secretId:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> DeleteSecret( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid appId, | ||||||
|  |         [FromRoute] Guid secretId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to delete app secrets"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var app = await customApps.GetAppAsync(appId, projectId); | ||||||
|  |         if (app == null) | ||||||
|  |             return NotFound("App not found"); | ||||||
|  |  | ||||||
|  |         var secret = await customApps.GetAppSecretAsync(secretId, appId); | ||||||
|  |         if (secret == null) | ||||||
|  |             return NotFound("Secret not found"); | ||||||
|  |  | ||||||
|  |         var result = await customApps.DeleteAppSecretAsync(secretId, appId); | ||||||
|  |         if (!result) | ||||||
|  |             return NotFound("Failed to delete secret"); | ||||||
|  |  | ||||||
|  |         return NoContent(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost("{appId:guid}/secrets/{secretId:guid}/rotate")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> RotateSecret( | ||||||
|  |         [FromRoute] string pubName, | ||||||
|  |         [FromRoute] Guid projectId, | ||||||
|  |         [FromRoute] Guid appId, | ||||||
|  |         [FromRoute] Guid secretId, | ||||||
|  |         [FromBody] CreateSecretRequest? request = null) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|  |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to rotate app secrets"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound("Project not found or you don't have access"); | ||||||
|  |  | ||||||
|  |         var app = await customApps.GetAppAsync(appId, projectId); | ||||||
|  |         if (app == null) | ||||||
|  |             return NotFound("App not found"); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var secret = await customApps.RotateAppSecretAsync(new SnCustomAppSecret | ||||||
|  |             { | ||||||
|  |                 Id = secretId, | ||||||
|  |                 AppId = appId, | ||||||
|  |                 Description = request?.Description, | ||||||
|  |                 ExpiredAt = request?.ExpiresIn.HasValue == true | ||||||
|  |                     ? NodaTime.SystemClock.Instance.GetCurrentInstant() | ||||||
|  |                         .Plus(Duration.FromTimeSpan(request.ExpiresIn.Value)) | ||||||
|  |                     : (NodaTime.Instant?)null, | ||||||
|  |                 IsOidc = request?.IsOidc ?? false | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             return Ok(new SecretResponse( | ||||||
|  |                 secret.Id.ToString(), | ||||||
|  |                 secret.Secret, | ||||||
|  |                 secret.Description, | ||||||
|  |                 secret.ExpiredAt, | ||||||
|  |                 secret.IsOidc, | ||||||
|  |                 secret.CreatedAt, | ||||||
|  |                 secret.UpdatedAt | ||||||
|  |             )); | ||||||
|  |         } | ||||||
|  |         catch (InvalidOperationException ex) | ||||||
|  |         { | ||||||
|  |             return BadRequest(ex.Message); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										268
									
								
								DysonNetwork.Develop/Identity/CustomAppService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										268
									
								
								DysonNetwork.Develop/Identity/CustomAppService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,268 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using System.Security.Cryptography; | ||||||
|  | using System.Text; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Identity; | ||||||
|  |  | ||||||
|  | public class CustomAppService( | ||||||
|  |     AppDatabase db, | ||||||
|  |     FileReferenceService.FileReferenceServiceClient fileRefs, | ||||||
|  |     FileService.FileServiceClient files | ||||||
|  | ) | ||||||
|  | { | ||||||
|  |     public async Task<SnCustomApp?> CreateAppAsync( | ||||||
|  |         Guid projectId, | ||||||
|  |         CustomAppController.CustomAppRequest request | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var project = await db.DevProjects | ||||||
|  |             .Include(p => p.Developer) | ||||||
|  |             .FirstOrDefaultAsync(p => p.Id == projectId); | ||||||
|  |              | ||||||
|  |         if (project == null) | ||||||
|  |             return null; | ||||||
|  |              | ||||||
|  |         var app = new SnCustomApp | ||||||
|  |         { | ||||||
|  |             Slug = request.Slug!, | ||||||
|  |             Name = request.Name!, | ||||||
|  |             Description = request.Description, | ||||||
|  |             Status = request.Status ?? Shared.Models.CustomAppStatus.Developing, | ||||||
|  |             Links = request.Links, | ||||||
|  |             OauthConfig = request.OauthConfig, | ||||||
|  |             ProjectId = projectId | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         if (request.PictureId is not null) | ||||||
|  |         { | ||||||
|  |             var picture = await files.GetFileAsync( | ||||||
|  |                 new GetFileRequest | ||||||
|  |                 { | ||||||
|  |                     Id = request.PictureId | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |             if (picture is null) | ||||||
|  |                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); | ||||||
|  |             app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture); | ||||||
|  |  | ||||||
|  |             // Create a new reference | ||||||
|  |             await fileRefs.CreateReferenceAsync( | ||||||
|  |                 new CreateReferenceRequest | ||||||
|  |                 { | ||||||
|  |                     FileId = picture.Id, | ||||||
|  |                     Usage = "custom-apps.picture", | ||||||
|  |                     ResourceId = app.ResourceIdentifier | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |         if (request.BackgroundId is not null) | ||||||
|  |         { | ||||||
|  |             var background = await files.GetFileAsync( | ||||||
|  |                 new GetFileRequest { Id = request.BackgroundId } | ||||||
|  |             ); | ||||||
|  |             if (background is null) | ||||||
|  |                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); | ||||||
|  |             app.Background = SnCloudFileReferenceObject.FromProtoValue(background); | ||||||
|  |  | ||||||
|  |             // Create a new reference | ||||||
|  |             await fileRefs.CreateReferenceAsync( | ||||||
|  |                 new CreateReferenceRequest | ||||||
|  |                 { | ||||||
|  |                     FileId = background.Id, | ||||||
|  |                     Usage = "custom-apps.background", | ||||||
|  |                     ResourceId = app.ResourceIdentifier | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         db.CustomApps.Add(app); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         return app; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnCustomApp?> GetAppAsync(Guid id, Guid? projectId = null) | ||||||
|  |     { | ||||||
|  |         var query = db.CustomApps.AsQueryable(); | ||||||
|  |          | ||||||
|  |         if (projectId.HasValue) | ||||||
|  |         { | ||||||
|  |             query = query.Where(a => a.ProjectId == projectId.Value); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return await query.FirstOrDefaultAsync(a => a.Id == id); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<List<SnCustomAppSecret>> GetAppSecretsAsync(Guid appId) | ||||||
|  |     { | ||||||
|  |         return await db.CustomAppSecrets | ||||||
|  |             .Where(s => s.AppId == appId) | ||||||
|  |             .OrderByDescending(s => s.CreatedAt) | ||||||
|  |             .ToListAsync(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnCustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId) | ||||||
|  |     { | ||||||
|  |         return await db.CustomAppSecrets | ||||||
|  |             .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnCustomAppSecret> CreateAppSecretAsync(SnCustomAppSecret secret) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(secret.Secret)) | ||||||
|  |         { | ||||||
|  |             // Generate a new random secret if not provided | ||||||
|  |             secret.Secret = GenerateRandomSecret(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         secret.Id = Guid.NewGuid(); | ||||||
|  |         secret.CreatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |         secret.UpdatedAt = secret.CreatedAt; | ||||||
|  |  | ||||||
|  |         db.CustomAppSecrets.Add(secret); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         return secret; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<bool> DeleteAppSecretAsync(Guid secretId, Guid appId) | ||||||
|  |     { | ||||||
|  |         var secret = await db.CustomAppSecrets | ||||||
|  |             .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId); | ||||||
|  |  | ||||||
|  |         if (secret == null) | ||||||
|  |             return false; | ||||||
|  |  | ||||||
|  |         db.CustomAppSecrets.Remove(secret); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnCustomAppSecret> RotateAppSecretAsync(SnCustomAppSecret secretUpdate) | ||||||
|  |     { | ||||||
|  |         var existingSecret = await db.CustomAppSecrets | ||||||
|  |             .FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId); | ||||||
|  |  | ||||||
|  |         if (existingSecret == null) | ||||||
|  |             throw new InvalidOperationException("Secret not found"); | ||||||
|  |  | ||||||
|  |         // Update the existing secret with new values | ||||||
|  |         existingSecret.Secret = GenerateRandomSecret(); | ||||||
|  |         existingSecret.Description = secretUpdate.Description ?? existingSecret.Description; | ||||||
|  |         existingSecret.ExpiredAt = secretUpdate.ExpiredAt ?? existingSecret.ExpiredAt; | ||||||
|  |         existingSecret.IsOidc = secretUpdate.IsOidc; | ||||||
|  |         existingSecret.UpdatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |  | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |         return existingSecret; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string GenerateRandomSecret(int length = 64) | ||||||
|  |     { | ||||||
|  |         const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-._~+"; | ||||||
|  |         var res = new StringBuilder(); | ||||||
|  |         using (var rng = RandomNumberGenerator.Create()) | ||||||
|  |         { | ||||||
|  |             var uintBuffer = new byte[sizeof(uint)]; | ||||||
|  |             while (length-- > 0) | ||||||
|  |             { | ||||||
|  |                 rng.GetBytes(uintBuffer); | ||||||
|  |                 var num = BitConverter.ToUInt32(uintBuffer, 0); | ||||||
|  |                 res.Append(valid[(int)(num % (uint)valid.Length)]); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return res.ToString(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<List<SnCustomApp>> GetAppsByProjectAsync(Guid projectId) | ||||||
|  |     { | ||||||
|  |         return await db.CustomApps | ||||||
|  |             .Where(a => a.ProjectId == projectId) | ||||||
|  |             .ToListAsync(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnCustomApp?> UpdateAppAsync(SnCustomApp app, CustomAppController.CustomAppRequest request) | ||||||
|  |     { | ||||||
|  |         if (request.Slug is not null) | ||||||
|  |             app.Slug = request.Slug; | ||||||
|  |         if (request.Name is not null) | ||||||
|  |             app.Name = request.Name; | ||||||
|  |         if (request.Description is not null) | ||||||
|  |             app.Description = request.Description; | ||||||
|  |         if (request.Status is not null) | ||||||
|  |             app.Status = request.Status.Value; | ||||||
|  |         if (request.Links is not null) | ||||||
|  |             app.Links = request.Links; | ||||||
|  |         if (request.OauthConfig is not null) | ||||||
|  |             app.OauthConfig = request.OauthConfig; | ||||||
|  |  | ||||||
|  |         if (request.PictureId is not null) | ||||||
|  |         { | ||||||
|  |             var picture = await files.GetFileAsync( | ||||||
|  |                 new GetFileRequest | ||||||
|  |                 { | ||||||
|  |                     Id = request.PictureId | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |             if (picture is null) | ||||||
|  |                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); | ||||||
|  |             app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture); | ||||||
|  |  | ||||||
|  |             // Create a new reference | ||||||
|  |             await fileRefs.CreateReferenceAsync( | ||||||
|  |                 new CreateReferenceRequest | ||||||
|  |                 { | ||||||
|  |                     FileId = picture.Id, | ||||||
|  |                     Usage = "custom-apps.picture", | ||||||
|  |                     ResourceId = app.ResourceIdentifier | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |         if (request.BackgroundId is not null) | ||||||
|  |         { | ||||||
|  |             var background = await files.GetFileAsync( | ||||||
|  |                 new GetFileRequest { Id = request.BackgroundId } | ||||||
|  |             ); | ||||||
|  |             if (background is null) | ||||||
|  |                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); | ||||||
|  |             app.Background = SnCloudFileReferenceObject.FromProtoValue(background); | ||||||
|  |  | ||||||
|  |             // Create a new reference | ||||||
|  |             await fileRefs.CreateReferenceAsync( | ||||||
|  |                 new CreateReferenceRequest | ||||||
|  |                 { | ||||||
|  |                     FileId = background.Id, | ||||||
|  |                     Usage = "custom-apps.background", | ||||||
|  |                     ResourceId = app.ResourceIdentifier | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         db.Update(app); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         return app; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<bool> DeleteAppAsync(Guid id) | ||||||
|  |     { | ||||||
|  |         var app = await db.CustomApps.FindAsync(id); | ||||||
|  |         if (app == null) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         db.CustomApps.Remove(app); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest | ||||||
|  |             { | ||||||
|  |                 ResourceId = app.ResourceIdentifier | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										69
									
								
								DysonNetwork.Develop/Identity/CustomAppServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								DysonNetwork.Develop/Identity/CustomAppServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Grpc.Core; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Identity; | ||||||
|  |  | ||||||
|  | public class CustomAppServiceGrpc(AppDatabase db) : Shared.Proto.CustomAppService.CustomAppServiceBase | ||||||
|  | { | ||||||
|  |     public override async Task<GetCustomAppResponse> GetCustomApp(GetCustomAppRequest request, ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var q = db.CustomApps.AsQueryable(); | ||||||
|  |         switch (request.QueryCase) | ||||||
|  |         { | ||||||
|  |             case GetCustomAppRequest.QueryOneofCase.Id when !string.IsNullOrWhiteSpace(request.Id): | ||||||
|  |             { | ||||||
|  |                 if (!Guid.TryParse(request.Id, out var id)) | ||||||
|  |                     throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid id")); | ||||||
|  |                 var appById = await q.FirstOrDefaultAsync(a => a.Id == id); | ||||||
|  |                 if (appById is null) | ||||||
|  |                     throw new RpcException(new Status(StatusCode.NotFound, "app not found")); | ||||||
|  |                 return new GetCustomAppResponse { App = appById.ToProto() }; | ||||||
|  |             } | ||||||
|  |             case GetCustomAppRequest.QueryOneofCase.Slug when !string.IsNullOrWhiteSpace(request.Slug): | ||||||
|  |             { | ||||||
|  |                 var appBySlug = await q.FirstOrDefaultAsync(a => a.Slug == request.Slug); | ||||||
|  |                 if (appBySlug is null) | ||||||
|  |                     throw new RpcException(new Status(StatusCode.NotFound, "app not found")); | ||||||
|  |                 return new GetCustomAppResponse { App = appBySlug.ToProto() }; | ||||||
|  |             } | ||||||
|  |             default: | ||||||
|  |                 throw new RpcException(new Status(StatusCode.InvalidArgument, "id or slug required")); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<CheckCustomAppSecretResponse> CheckCustomAppSecret(CheckCustomAppSecretRequest request, ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrEmpty(request.Secret)) | ||||||
|  |             throw new RpcException(new Status(StatusCode.InvalidArgument, "secret required")); | ||||||
|  |  | ||||||
|  |         IQueryable<SnCustomAppSecret> q = db.CustomAppSecrets; | ||||||
|  |         switch (request.SecretIdentifierCase) | ||||||
|  |         { | ||||||
|  |             case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.SecretId: | ||||||
|  |             { | ||||||
|  |                 if (!Guid.TryParse(request.SecretId, out var sid)) | ||||||
|  |                     throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid secret_id")); | ||||||
|  |                 q = q.Where(s => s.Id == sid); | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |             case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.AppId: | ||||||
|  |             { | ||||||
|  |                 if (!Guid.TryParse(request.AppId, out var aid)) | ||||||
|  |                     throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid app_id")); | ||||||
|  |                 q = q.Where(s => s.AppId == aid); | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |             default: | ||||||
|  |                 throw new RpcException(new Status(StatusCode.InvalidArgument, "secret_id or app_id required")); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (request.HasIsOidc) | ||||||
|  |             q = q.Where(s => s.IsOidc == request.IsOidc); | ||||||
|  |  | ||||||
|  |         var now = NodaTime.SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |         var exists = await q.AnyAsync(s => s.Secret == request.Secret && (s.ExpiredAt == null || s.ExpiredAt > now)); | ||||||
|  |         return new CheckCustomAppSecretResponse { Valid = exists }; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										129
									
								
								DysonNetwork.Develop/Identity/DeveloperController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								DysonNetwork.Develop/Identity/DeveloperController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,129 @@ | |||||||
|  | using DysonNetwork.Shared.Auth; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Grpc.Core; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Identity; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/developers")] | ||||||
|  | public class DeveloperController( | ||||||
|  |     AppDatabase db, | ||||||
|  |     PublisherService.PublisherServiceClient ps, | ||||||
|  |     ActionLogService.ActionLogServiceClient als, | ||||||
|  |     DeveloperService ds | ||||||
|  | ) | ||||||
|  |     : ControllerBase | ||||||
|  | { | ||||||
|  |     [HttpGet("{name}")] | ||||||
|  |     public async Task<ActionResult<SnDeveloper>> GetDeveloper(string name) | ||||||
|  |     { | ||||||
|  |         var developer = await ds.GetDeveloperByName(name); | ||||||
|  |         if (developer is null) return NotFound(); | ||||||
|  |         return Ok(await ds.LoadDeveloperPublisher(developer)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{name}/stats")] | ||||||
|  |     public async Task<ActionResult<DeveloperStats>> GetDeveloperStats(string name) | ||||||
|  |     { | ||||||
|  |         var developer = await ds.GetDeveloperByName(name); | ||||||
|  |         if (developer is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         // Get custom apps count | ||||||
|  |         var customAppsCount = await db.CustomApps | ||||||
|  |             .Include(a => a.Project) | ||||||
|  |             .Where(a => a.Project.DeveloperId == developer.Id) | ||||||
|  |             .CountAsync(); | ||||||
|  |  | ||||||
|  |         var stats = new DeveloperStats | ||||||
|  |         { | ||||||
|  |             TotalCustomApps = customAppsCount | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         return Ok(stats); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<List<SnDeveloper>>> ListJoinedDevelopers() | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |          | ||||||
|  |         var pubResponse = await ps.ListPublishersAsync(new ListPublishersRequest { AccountId = currentUser.Id }); | ||||||
|  |         var pubIds = pubResponse.Publishers.Select(p => p.Id).Select(Guid.Parse).ToList(); | ||||||
|  |  | ||||||
|  |         var developerQuery = db.Developers | ||||||
|  |             .Where(d => pubIds.Contains(d.PublisherId)) | ||||||
|  |             .AsQueryable(); | ||||||
|  |          | ||||||
|  |         var totalCount = await developerQuery.CountAsync();  | ||||||
|  |         Response.Headers.Append("X-Total", totalCount.ToString()); | ||||||
|  |          | ||||||
|  |         var developers = await developerQuery.ToListAsync(); | ||||||
|  |  | ||||||
|  |         return Ok(await ds.LoadDeveloperPublisher(developers)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost("{name}/enroll")] | ||||||
|  |     [Authorize] | ||||||
|  |     [RequiredPermission("global", "developers.create")] | ||||||
|  |     public async Task<ActionResult<SnDeveloper>> EnrollDeveloperProgram(string name) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         SnPublisher? pub; | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name }); | ||||||
|  |             pub = SnPublisher.FromProto(pubResponse.Publisher); | ||||||
|  |         } catch (RpcException ex) | ||||||
|  |         { | ||||||
|  |             return NotFound(ex.Status.Detail); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check if the user is an owner of the publisher | ||||||
|  |         var permResponse = await ps.IsPublisherMemberAsync(new IsPublisherMemberRequest | ||||||
|  |         { | ||||||
|  |             PublisherId = pub.Id.ToString(), | ||||||
|  |             AccountId = currentUser.Id, | ||||||
|  |             Role = Shared.Proto.PublisherMemberRole.Owner | ||||||
|  |         }); | ||||||
|  |         if (!permResponse.Valid) return StatusCode(403, "You must be the owner of the publisher to join the developer program"); | ||||||
|  |  | ||||||
|  |         var hasDeveloper = await db.Developers.AnyAsync(d => d.PublisherId == pub.Id); | ||||||
|  |         if (hasDeveloper) return BadRequest("Publisher is already in the developer program"); | ||||||
|  |          | ||||||
|  |         var developer = new SnDeveloper | ||||||
|  |         { | ||||||
|  |             Id = Guid.NewGuid(), | ||||||
|  |             PublisherId = pub.Id | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         db.Developers.Add(developer); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         _ = als.CreateActionLogAsync(new CreateActionLogRequest | ||||||
|  |         { | ||||||
|  |             Action = "developers.enroll", | ||||||
|  |             Meta =  | ||||||
|  |             {  | ||||||
|  |                 { "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(pub.Id.ToString()) }, | ||||||
|  |                 { "publisher_name", Google.Protobuf.WellKnownTypes.Value.ForString(pub.Name) } | ||||||
|  |             }, | ||||||
|  |             AccountId = currentUser.Id, | ||||||
|  |             UserAgent = Request.Headers.UserAgent, | ||||||
|  |             IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         return Ok(await ds.LoadDeveloperPublisher(developer)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public class DeveloperStats | ||||||
|  |     { | ||||||
|  |         public int TotalCustomApps { get; set; } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										76
									
								
								DysonNetwork.Develop/Identity/DeveloperService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								DysonNetwork.Develop/Identity/DeveloperService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Grpc.Core; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Identity; | ||||||
|  |  | ||||||
|  | public class DeveloperService( | ||||||
|  |     AppDatabase db, | ||||||
|  |     PublisherService.PublisherServiceClient ps, | ||||||
|  |     ILogger<DeveloperService> logger) | ||||||
|  | { | ||||||
|  |     public async Task<SnDeveloper> LoadDeveloperPublisher(SnDeveloper developer) | ||||||
|  |     { | ||||||
|  |         var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Id = developer.PublisherId.ToString() }); | ||||||
|  |         developer.Publisher = SnPublisher.FromProto(pubResponse.Publisher); | ||||||
|  |         return developer; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     public async Task<IEnumerable<SnDeveloper>> LoadDeveloperPublisher(IEnumerable<SnDeveloper> developers) | ||||||
|  |     { | ||||||
|  |         var enumerable = developers.ToList(); | ||||||
|  |         var pubIds = enumerable.Select(d => d.PublisherId).ToList(); | ||||||
|  |         var pubRequest = new GetPublisherBatchRequest(); | ||||||
|  |         pubIds.ForEach(x => pubRequest.Ids.Add(x.ToString())); | ||||||
|  |         var pubResponse = await ps.GetPublisherBatchAsync(pubRequest); | ||||||
|  |         var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), SnPublisher.FromProto); | ||||||
|  |  | ||||||
|  |         return enumerable.Select(d => | ||||||
|  |         { | ||||||
|  |             d.Publisher = pubs[d.PublisherId]; | ||||||
|  |             return d; | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnDeveloper?> GetDeveloperByName(string name) | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name }); | ||||||
|  |             var pubId = Guid.Parse(pubResponse.Publisher.Id); | ||||||
|  |  | ||||||
|  |             var developer = await db.Developers.FirstOrDefaultAsync(d => d.PublisherId == pubId); | ||||||
|  |             return developer; | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) | ||||||
|  |         { | ||||||
|  |             logger.LogError(ex, "Developer {name} not found", name); | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnDeveloper?> GetDeveloperById(Guid id) | ||||||
|  |     { | ||||||
|  |         return await db.Developers.FirstOrDefaultAsync(d => d.Id == id); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, Shared.Proto.PublisherMemberRole role) | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var permResponse = await ps.IsPublisherMemberAsync(new IsPublisherMemberRequest | ||||||
|  |             { | ||||||
|  |                 PublisherId = pubId.ToString(), | ||||||
|  |                 AccountId = accountId.ToString(), | ||||||
|  |                 Role = role | ||||||
|  |             }); | ||||||
|  |             return permResponse.Valid; | ||||||
|  |         } | ||||||
|  |         catch (RpcException) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										202
									
								
								DysonNetwork.Develop/Migrations/20250807133702_InitialMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								DysonNetwork.Develop/Migrations/20250807133702_InitialMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,202 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using DysonNetwork.Develop; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250807133702_InitialMigration")] | ||||||
|  |     partial class InitialMigration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCloudFileReferenceObject>("Background") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("background"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("DeveloperId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("developer_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCustomAppLinks>("Links") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("links"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCustomAppOauthConfig>("OauthConfig") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("oauth_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCloudFileReferenceObject>("Picture") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("picture"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<int>("Status") | ||||||
|  |                         .HasColumnType("integer") | ||||||
|  |                         .HasColumnName("status"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnVerificationMark>("Verification") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("verification"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_custom_apps"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("DeveloperId") | ||||||
|  |                         .HasDatabaseName("ix_custom_apps_developer_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("custom_apps", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AppId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("app_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsOidc") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_oidc"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Secret") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("secret"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_custom_app_secrets"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("AppId") | ||||||
|  |                         .HasDatabaseName("ix_custom_app_secrets_app_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("custom_app_secrets", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("PublisherId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("publisher_id"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_developers"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("developers", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("DeveloperId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_custom_apps_developers_developer_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Developer"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App") | ||||||
|  |                         .WithMany("Secrets") | ||||||
|  |                         .HasForeignKey("AppId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("App"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Secrets"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,106 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class InitialMigration : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "developers", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     publisher_id = table.Column<Guid>(type: "uuid", nullable: false) | ||||||
|  |                 }, | ||||||
|  |                 constraints: table => | ||||||
|  |                 { | ||||||
|  |                     table.PrimaryKey("pk_developers", x => x.id); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "custom_apps", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||||
|  |                     name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||||
|  |                     description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true), | ||||||
|  |                     status = table.Column<int>(type: "integer", nullable: false), | ||||||
|  |                     picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true), | ||||||
|  |                     background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true), | ||||||
|  |                     verification = table.Column<SnVerificationMark>(type: "jsonb", nullable: true), | ||||||
|  |                     oauth_config = table.Column<SnCustomAppOauthConfig>(type: "jsonb", nullable: true), | ||||||
|  |                     links = table.Column<SnCustomAppLinks>(type: "jsonb", nullable: true), | ||||||
|  |                     developer_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||||
|  |                 }, | ||||||
|  |                 constraints: table => | ||||||
|  |                 { | ||||||
|  |                     table.PrimaryKey("pk_custom_apps", x => x.id); | ||||||
|  |                     table.ForeignKey( | ||||||
|  |                         name: "fk_custom_apps_developers_developer_id", | ||||||
|  |                         column: x => x.developer_id, | ||||||
|  |                         principalTable: "developers", | ||||||
|  |                         principalColumn: "id", | ||||||
|  |                         onDelete: ReferentialAction.Cascade); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "custom_app_secrets", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     secret = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||||
|  |                     description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true), | ||||||
|  |                     expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true), | ||||||
|  |                     is_oidc = table.Column<bool>(type: "boolean", nullable: false), | ||||||
|  |                     app_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||||
|  |                 }, | ||||||
|  |                 constraints: table => | ||||||
|  |                 { | ||||||
|  |                     table.PrimaryKey("pk_custom_app_secrets", x => x.id); | ||||||
|  |                     table.ForeignKey( | ||||||
|  |                         name: "fk_custom_app_secrets_custom_apps_app_id", | ||||||
|  |                         column: x => x.app_id, | ||||||
|  |                         principalTable: "custom_apps", | ||||||
|  |                         principalColumn: "id", | ||||||
|  |                         onDelete: ReferentialAction.Cascade); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateIndex( | ||||||
|  |                 name: "ix_custom_app_secrets_app_id", | ||||||
|  |                 table: "custom_app_secrets", | ||||||
|  |                 column: "app_id"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateIndex( | ||||||
|  |                 name: "ix_custom_apps_developer_id", | ||||||
|  |                 table: "custom_apps", | ||||||
|  |                 column: "developer_id"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropTable( | ||||||
|  |                 name: "custom_app_secrets"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropTable( | ||||||
|  |                 name: "custom_apps"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropTable( | ||||||
|  |                 name: "developers"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										269
									
								
								DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,269 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using DysonNetwork.Develop; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250818124844_AddDevProject")] | ||||||
|  |     partial class AddDevProject | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCloudFileReferenceObject>("Background") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("background"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCustomAppLinks>("Links") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("links"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCustomAppOauthConfig>("OauthConfig") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("oauth_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCloudFileReferenceObject>("Picture") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("picture"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("ProjectId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("project_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<int>("Status") | ||||||
|  |                         .HasColumnType("integer") | ||||||
|  |                         .HasColumnName("status"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnVerificationMark>("Verification") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("verification"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_custom_apps"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("ProjectId") | ||||||
|  |                         .HasDatabaseName("ix_custom_apps_project_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("custom_apps", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AppId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("app_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsOidc") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_oidc"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Secret") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("secret"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_custom_app_secrets"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("AppId") | ||||||
|  |                         .HasDatabaseName("ix_custom_app_secrets_app_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("custom_app_secrets", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("PublisherId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("publisher_id"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_developers"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("developers", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("DeveloperId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("developer_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_dev_projects"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("DeveloperId") | ||||||
|  |                         .HasDatabaseName("ix_dev_projects_developer_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("dev_projects", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("ProjectId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_custom_apps_dev_projects_project_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Project"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App") | ||||||
|  |                         .WithMany("Secrets") | ||||||
|  |                         .HasForeignKey("AppId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("App"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") | ||||||
|  |                         .WithMany("Projects") | ||||||
|  |                         .HasForeignKey("DeveloperId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_dev_projects_developers_developer_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Developer"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Secrets"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Projects"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,95 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class AddDevProject : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropForeignKey( | ||||||
|  |                 name: "fk_custom_apps_developers_developer_id", | ||||||
|  |                 table: "custom_apps"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.RenameColumn( | ||||||
|  |                 name: "developer_id", | ||||||
|  |                 table: "custom_apps", | ||||||
|  |                 newName: "project_id"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.RenameIndex( | ||||||
|  |                 name: "ix_custom_apps_developer_id", | ||||||
|  |                 table: "custom_apps", | ||||||
|  |                 newName: "ix_custom_apps_project_id"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "dev_projects", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||||
|  |                     name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||||
|  |                     description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false), | ||||||
|  |                     developer_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||||
|  |                 }, | ||||||
|  |                 constraints: table => | ||||||
|  |                 { | ||||||
|  |                     table.PrimaryKey("pk_dev_projects", x => x.id); | ||||||
|  |                     table.ForeignKey( | ||||||
|  |                         name: "fk_dev_projects_developers_developer_id", | ||||||
|  |                         column: x => x.developer_id, | ||||||
|  |                         principalTable: "developers", | ||||||
|  |                         principalColumn: "id", | ||||||
|  |                         onDelete: ReferentialAction.Cascade); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateIndex( | ||||||
|  |                 name: "ix_dev_projects_developer_id", | ||||||
|  |                 table: "dev_projects", | ||||||
|  |                 column: "developer_id"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.AddForeignKey( | ||||||
|  |                 name: "fk_custom_apps_dev_projects_project_id", | ||||||
|  |                 table: "custom_apps", | ||||||
|  |                 column: "project_id", | ||||||
|  |                 principalTable: "dev_projects", | ||||||
|  |                 principalColumn: "id", | ||||||
|  |                 onDelete: ReferentialAction.Cascade); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropForeignKey( | ||||||
|  |                 name: "fk_custom_apps_dev_projects_project_id", | ||||||
|  |                 table: "custom_apps"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropTable( | ||||||
|  |                 name: "dev_projects"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.RenameColumn( | ||||||
|  |                 name: "project_id", | ||||||
|  |                 table: "custom_apps", | ||||||
|  |                 newName: "developer_id"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.RenameIndex( | ||||||
|  |                 name: "ix_custom_apps_project_id", | ||||||
|  |                 table: "custom_apps", | ||||||
|  |                 newName: "ix_custom_apps_developer_id"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.AddForeignKey( | ||||||
|  |                 name: "fk_custom_apps_developers_developer_id", | ||||||
|  |                 table: "custom_apps", | ||||||
|  |                 column: "developer_id", | ||||||
|  |                 principalTable: "developers", | ||||||
|  |                 principalColumn: "id", | ||||||
|  |                 onDelete: ReferentialAction.Cascade); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										323
									
								
								DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										323
									
								
								DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,323 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using DysonNetwork.Develop; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250819163227_AddBotAccount")] | ||||||
|  |     partial class AddBotAccount | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsActive") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_active"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("ProjectId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("project_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_bot_accounts"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("ProjectId") | ||||||
|  |                         .HasDatabaseName("ix_bot_accounts_project_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("bot_accounts", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCloudFileReferenceObject>("Background") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("background"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCustomAppLinks>("Links") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("links"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCustomAppOauthConfig>("OauthConfig") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("oauth_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCloudFileReferenceObject>("Picture") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("picture"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("ProjectId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("project_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<int>("Status") | ||||||
|  |                         .HasColumnType("integer") | ||||||
|  |                         .HasColumnName("status"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnVerificationMark>("Verification") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("verification"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_custom_apps"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("ProjectId") | ||||||
|  |                         .HasDatabaseName("ix_custom_apps_project_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("custom_apps", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AppId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("app_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsOidc") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_oidc"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Secret") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("secret"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_custom_app_secrets"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("AppId") | ||||||
|  |                         .HasDatabaseName("ix_custom_app_secrets_app_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("custom_app_secrets", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("PublisherId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("publisher_id"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_developers"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("developers", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("DeveloperId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("developer_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_dev_projects"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("DeveloperId") | ||||||
|  |                         .HasDatabaseName("ix_dev_projects_developer_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("dev_projects", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("ProjectId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_bot_accounts_dev_projects_project_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Project"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("ProjectId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_custom_apps_dev_projects_project_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Project"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App") | ||||||
|  |                         .WithMany("Secrets") | ||||||
|  |                         .HasForeignKey("AppId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("App"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") | ||||||
|  |                         .WithMany("Projects") | ||||||
|  |                         .HasForeignKey("DeveloperId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_dev_projects_developers_developer_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Developer"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Secrets"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Projects"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,50 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class AddBotAccount : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "bot_accounts", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||||
|  |                     is_active = table.Column<bool>(type: "boolean", nullable: false), | ||||||
|  |                     project_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||||
|  |                 }, | ||||||
|  |                 constraints: table => | ||||||
|  |                 { | ||||||
|  |                     table.PrimaryKey("pk_bot_accounts", x => x.id); | ||||||
|  |                     table.ForeignKey( | ||||||
|  |                         name: "fk_bot_accounts_dev_projects_project_id", | ||||||
|  |                         column: x => x.project_id, | ||||||
|  |                         principalTable: "dev_projects", | ||||||
|  |                         principalColumn: "id", | ||||||
|  |                         onDelete: ReferentialAction.Cascade); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateIndex( | ||||||
|  |                 name: "ix_bot_accounts_project_id", | ||||||
|  |                 table: "bot_accounts", | ||||||
|  |                 column: "project_id"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropTable( | ||||||
|  |                 name: "bot_accounts"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										320
									
								
								DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										320
									
								
								DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,320 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using DysonNetwork.Develop; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     partial class AppDatabaseModelSnapshot : ModelSnapshot | ||||||
|  |     { | ||||||
|  |         protected override void BuildModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsActive") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_active"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("ProjectId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("project_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_bot_accounts"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("ProjectId") | ||||||
|  |                         .HasDatabaseName("ix_bot_accounts_project_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("bot_accounts", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCloudFileReferenceObject>("Background") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("background"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCustomAppLinks>("Links") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("links"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCustomAppOauthConfig>("OauthConfig") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("oauth_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnCloudFileReferenceObject>("Picture") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("picture"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("ProjectId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("project_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<int>("Status") | ||||||
|  |                         .HasColumnType("integer") | ||||||
|  |                         .HasColumnName("status"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<SnVerificationMark>("Verification") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("verification"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_custom_apps"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("ProjectId") | ||||||
|  |                         .HasDatabaseName("ix_custom_apps_project_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("custom_apps", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AppId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("app_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsOidc") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_oidc"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Secret") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("secret"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_custom_app_secrets"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("AppId") | ||||||
|  |                         .HasDatabaseName("ix_custom_app_secrets_app_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("custom_app_secrets", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("PublisherId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("publisher_id"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_developers"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("developers", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("DeveloperId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("developer_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_dev_projects"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("DeveloperId") | ||||||
|  |                         .HasDatabaseName("ix_dev_projects_developer_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("dev_projects", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("ProjectId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_bot_accounts_dev_projects_project_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Project"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("ProjectId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_custom_apps_dev_projects_project_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Project"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App") | ||||||
|  |                         .WithMany("Secrets") | ||||||
|  |                         .HasForeignKey("AppId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("App"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") | ||||||
|  |                         .WithMany("Projects") | ||||||
|  |                         .HasForeignKey("DeveloperId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_dev_projects_developers_developer_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Developer"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Secrets"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Projects"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										40
									
								
								DysonNetwork.Develop/Program.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								DysonNetwork.Develop/Program.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | |||||||
|  | using DysonNetwork.Develop; | ||||||
|  | using DysonNetwork.Shared.Auth; | ||||||
|  | using DysonNetwork.Shared.Http; | ||||||
|  | using DysonNetwork.Develop.Startup; | ||||||
|  | using DysonNetwork.Shared.Registry; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  |  | ||||||
|  | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
|  | builder.AddServiceDefaults(); | ||||||
|  |  | ||||||
|  | builder.ConfigureAppKestrel(builder.Configuration); | ||||||
|  |  | ||||||
|  | builder.Services.AddAppServices(builder.Configuration); | ||||||
|  | builder.Services.AddAppAuthentication(); | ||||||
|  | builder.Services.AddDysonAuth(); | ||||||
|  | builder.Services.AddPublisherService(); | ||||||
|  | builder.Services.AddAccountService(); | ||||||
|  | builder.Services.AddDriveService(); | ||||||
|  |  | ||||||
|  | builder.AddSwaggerManifest( | ||||||
|  |     "DysonNetwork.Develop", | ||||||
|  |     "The developer portal in the Solar Network." | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | var app = builder.Build(); | ||||||
|  |  | ||||||
|  | app.MapDefaultEndpoints(); | ||||||
|  |  | ||||||
|  | using (var scope = app.Services.CreateScope()) | ||||||
|  | { | ||||||
|  |     var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||||
|  |     await db.Database.MigrateAsync(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | app.ConfigureAppMiddleware(builder.Configuration); | ||||||
|  |  | ||||||
|  | app.UseSwaggerManifest(); | ||||||
|  |  | ||||||
|  | app.Run(); | ||||||
							
								
								
									
										107
									
								
								DysonNetwork.Develop/Project/DevProjectController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								DysonNetwork.Develop/Project/DevProjectController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  | using DysonNetwork.Develop.Identity; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Project; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/developers/{pubName}/projects")] | ||||||
|  | public class DevProjectController(DevProjectService projectService, DeveloperService developerService) : ControllerBase | ||||||
|  | { | ||||||
|  |     public record DevProjectRequest( | ||||||
|  |         [MaxLength(1024)] string? Slug, | ||||||
|  |         [MaxLength(1024)] string? Name, | ||||||
|  |         [MaxLength(4096)] string? Description | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     [HttpGet] | ||||||
|  |     public async Task<IActionResult> ListProjects([FromRoute] string pubName) | ||||||
|  |     { | ||||||
|  |         var developer = await developerService.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) return NotFound(); | ||||||
|  |          | ||||||
|  |         var projects = await projectService.GetProjectsByDeveloperAsync(developer.Id); | ||||||
|  |         return Ok(projects); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{id:guid}")] | ||||||
|  |     public async Task<IActionResult> GetProject([FromRoute] string pubName, Guid id) | ||||||
|  |     { | ||||||
|  |         var developer = await developerService.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         var project = await projectService.GetProjectAsync(id, developer.Id); | ||||||
|  |         if (project is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         return Ok(project); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> CreateProject([FromRoute] string pubName, [FromBody] DevProjectRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser)  | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await developerService.GetDeveloperByName(pubName); | ||||||
|  |         if (developer is null) | ||||||
|  |             return NotFound("Developer not found"); | ||||||
|  |              | ||||||
|  |         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) | ||||||
|  |             return StatusCode(403, "You must be an editor of the developer to create a project"); | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrWhiteSpace(request.Slug) || string.IsNullOrWhiteSpace(request.Name)) | ||||||
|  |             return BadRequest("Slug and Name are required"); | ||||||
|  |  | ||||||
|  |         var project = await projectService.CreateProjectAsync(developer, request); | ||||||
|  |         return CreatedAtAction( | ||||||
|  |             nameof(GetProject),  | ||||||
|  |             new { pubName, id = project.Id }, | ||||||
|  |             project | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPut("{id:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> UpdateProject( | ||||||
|  |         [FromRoute] string pubName,  | ||||||
|  |         [FromRoute] Guid id, | ||||||
|  |         [FromBody] DevProjectRequest request | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser)  | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await developerService.GetDeveloperByName(pubName); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |         if (developer is null || developer.Id != accountId) | ||||||
|  |             return Forbid(); | ||||||
|  |  | ||||||
|  |         var project = await projectService.UpdateProjectAsync(id, developer.Id, request); | ||||||
|  |         if (project is null) | ||||||
|  |             return NotFound(); | ||||||
|  |  | ||||||
|  |         return Ok(project); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpDelete("{id:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<IActionResult> DeleteProject([FromRoute] string pubName, [FromRoute] Guid id) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser)  | ||||||
|  |             return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var developer = await developerService.GetDeveloperByName(pubName); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |         if (developer is null || developer.Id != accountId) | ||||||
|  |             return Forbid(); | ||||||
|  |  | ||||||
|  |         var success = await projectService.DeleteProjectAsync(id, developer.Id); | ||||||
|  |         if (!success) | ||||||
|  |             return NotFound(); | ||||||
|  |  | ||||||
|  |         return NoContent(); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										77
									
								
								DysonNetwork.Develop/Project/DevProjectService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								DysonNetwork.Develop/Project/DevProjectService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Project; | ||||||
|  |  | ||||||
|  | public class DevProjectService( | ||||||
|  |     AppDatabase db, | ||||||
|  |     FileReferenceService.FileReferenceServiceClient fileRefs, | ||||||
|  |     FileService.FileServiceClient files | ||||||
|  | ) | ||||||
|  | { | ||||||
|  |     public async Task<SnDevProject> CreateProjectAsync( | ||||||
|  |         SnDeveloper developer, | ||||||
|  |         DevProjectController.DevProjectRequest request | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var project = new SnDevProject | ||||||
|  |         { | ||||||
|  |             Slug = request.Slug!, | ||||||
|  |             Name = request.Name!, | ||||||
|  |             Description = request.Description ?? string.Empty, | ||||||
|  |             DeveloperId = developer.Id | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         db.DevProjects.Add(project); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |          | ||||||
|  |         return project; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnDevProject?> GetProjectAsync(Guid id, Guid? developerId = null) | ||||||
|  |     { | ||||||
|  |         var query = db.DevProjects.AsQueryable(); | ||||||
|  |          | ||||||
|  |         if (developerId.HasValue) | ||||||
|  |         { | ||||||
|  |             query = query.Where(p => p.DeveloperId == developerId.Value); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return await query.FirstOrDefaultAsync(p => p.Id == id); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<List<SnDevProject>> GetProjectsByDeveloperAsync(Guid developerId) | ||||||
|  |     { | ||||||
|  |         return await db.DevProjects | ||||||
|  |             .Where(p => p.DeveloperId == developerId) | ||||||
|  |             .ToListAsync(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnDevProject?> UpdateProjectAsync( | ||||||
|  |         Guid id, | ||||||
|  |         Guid developerId, | ||||||
|  |         DevProjectController.DevProjectRequest request | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var project = await GetProjectAsync(id, developerId); | ||||||
|  |         if (project == null) return null; | ||||||
|  |  | ||||||
|  |         if (request.Slug != null) project.Slug = request.Slug; | ||||||
|  |         if (request.Name != null) project.Name = request.Name; | ||||||
|  |         if (request.Description != null) project.Description = request.Description; | ||||||
|  |  | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |         return project; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<bool> DeleteProjectAsync(Guid id, Guid developerId) | ||||||
|  |     { | ||||||
|  |         var project = await GetProjectAsync(id, developerId); | ||||||
|  |         if (project == null) return false; | ||||||
|  |  | ||||||
|  |         db.DevProjects.Remove(project); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -5,7 +5,6 @@ | |||||||
|       "commandName": "Project", |       "commandName": "Project", | ||||||
|       "dotnetRunMessages": true, |       "dotnetRunMessages": true, | ||||||
|       "launchBrowser": false, |       "launchBrowser": false, | ||||||
|       "applicationUrl": "http://localhost:5212", |  | ||||||
|       "environmentVariables": { |       "environmentVariables": { | ||||||
|         "ASPNETCORE_ENVIRONMENT": "Development" |         "ASPNETCORE_ENVIRONMENT": "Development" | ||||||
|       } |       } | ||||||
| @@ -14,7 +13,6 @@ | |||||||
|       "commandName": "Project", |       "commandName": "Project", | ||||||
|       "dotnetRunMessages": true, |       "dotnetRunMessages": true, | ||||||
|       "launchBrowser": false, |       "launchBrowser": false, | ||||||
|       "applicationUrl": "https://localhost:7259;http://localhost:5212", |  | ||||||
|       "environmentVariables": { |       "environmentVariables": { | ||||||
|         "ASPNETCORE_ENVIRONMENT": "Development" |         "ASPNETCORE_ENVIRONMENT": "Development" | ||||||
|       } |       } | ||||||
							
								
								
									
										29
									
								
								DysonNetwork.Develop/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								DysonNetwork.Develop/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | using DysonNetwork.Develop.Identity; | ||||||
|  | using DysonNetwork.Shared.Auth; | ||||||
|  | using DysonNetwork.Shared.Http; | ||||||
|  | using Prometheus; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Startup; | ||||||
|  |  | ||||||
|  | public static class ApplicationConfiguration | ||||||
|  | { | ||||||
|  |     public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration) | ||||||
|  |     { | ||||||
|  |         app.MapMetrics(); | ||||||
|  |         app.MapOpenApi(); | ||||||
|  |  | ||||||
|  |         app.UseRequestLocalization(); | ||||||
|  |  | ||||||
|  |         app.ConfigureForwardedHeaders(configuration); | ||||||
|  |  | ||||||
|  |         app.UseAuthentication(); | ||||||
|  |         app.UseAuthorization(); | ||||||
|  |         app.UseMiddleware<PermissionMiddleware>(); | ||||||
|  |  | ||||||
|  |         app.MapControllers(); | ||||||
|  |          | ||||||
|  |         app.MapGrpcService<CustomAppServiceGrpc>(); | ||||||
|  |  | ||||||
|  |         return app; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										61
									
								
								DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | |||||||
|  | using System.Globalization; | ||||||
|  | using NodaTime; | ||||||
|  | using NodaTime.Serialization.SystemTextJson; | ||||||
|  | using System.Text.Json; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
|  | using DysonNetwork.Develop.Identity; | ||||||
|  | using DysonNetwork.Develop.Project; | ||||||
|  | using DysonNetwork.Shared.Cache; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Develop.Startup; | ||||||
|  |  | ||||||
|  | public static class ServiceCollectionExtensions | ||||||
|  | { | ||||||
|  |     public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) | ||||||
|  |     { | ||||||
|  |         services.AddLocalization(); | ||||||
|  |  | ||||||
|  |         services.AddDbContext<AppDatabase>(); | ||||||
|  |         services.AddSingleton<IClock>(SystemClock.Instance); | ||||||
|  |         services.AddHttpContextAccessor(); | ||||||
|  |         services.AddSingleton<ICacheService, CacheServiceRedis>(); | ||||||
|  |  | ||||||
|  |         services.AddHttpClient(); | ||||||
|  |  | ||||||
|  |         services.AddControllers().AddJsonOptions(options => | ||||||
|  |         { | ||||||
|  |             options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; | ||||||
|  |             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|  |             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|  |              | ||||||
|  |             options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         services.AddGrpc(options => { options.EnableDetailedErrors = true; }); | ||||||
|  |  | ||||||
|  |         services.Configure<RequestLocalizationOptions>(options => | ||||||
|  |         { | ||||||
|  |             var supportedCultures = new[] | ||||||
|  |             { | ||||||
|  |                 new CultureInfo("en-US"), | ||||||
|  |                 new CultureInfo("zh-Hans"), | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             options.SupportedCultures = supportedCultures; | ||||||
|  |             options.SupportedUICultures = supportedCultures; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         services.AddScoped<DeveloperService>(); | ||||||
|  |         services.AddScoped<CustomAppService>(); | ||||||
|  |         services.AddScoped<DevProjectService>(); | ||||||
|  |         services.AddScoped<BotAccountService>(); | ||||||
|  |  | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static IServiceCollection AddAppAuthentication(this IServiceCollection services) | ||||||
|  |     { | ||||||
|  |         services.AddAuthorization(); | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										26
									
								
								DysonNetwork.Develop/appsettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								DysonNetwork.Develop/appsettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | { | ||||||
|  |   "Debug": true, | ||||||
|  |   "BaseUrl": "http://localhost:5071", | ||||||
|  |   "SiteUrl": "https://solian.app", | ||||||
|  |   "Logging": { | ||||||
|  |     "LogLevel": { | ||||||
|  |       "Default": "Information", | ||||||
|  |       "Microsoft.AspNetCore": "Warning" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "AllowedHosts": "*", | ||||||
|  |   "ConnectionStrings": { | ||||||
|  |     "App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" | ||||||
|  |   }, | ||||||
|  |   "KnownProxies": ["127.0.0.1", "::1"], | ||||||
|  |   "Swagger": { | ||||||
|  |     "PublicBasePath": "/develop" | ||||||
|  |   }, | ||||||
|  |   "Etcd": { | ||||||
|  |     "Insecure": true | ||||||
|  |   }, | ||||||
|  |   "Service": { | ||||||
|  |     "Name": "DysonNetwork.Develop", | ||||||
|  |     "Url": "https://localhost:7192" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										3
									
								
								DysonNetwork.Drive/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								DysonNetwork.Drive/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | /Uploads/ | ||||||
|  | /Client/node_modules/ | ||||||
|  | /wwwroot/dist | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| using System.Linq.Expressions; | using System.Linq.Expressions; | ||||||
| using System.Reflection; | using System.Reflection; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Drive.Billing; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Models; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Design; | using Microsoft.EntityFrameworkCore.Design; | ||||||
| using Microsoft.EntityFrameworkCore.Query; | using Microsoft.EntityFrameworkCore.Query; | ||||||
| @@ -15,7 +15,12 @@ public class AppDatabase( | |||||||
|     IConfiguration configuration |     IConfiguration configuration | ||||||
| ) : DbContext(options) | ) : DbContext(options) | ||||||
| { | { | ||||||
|     public DbSet<CloudFile> Files { get; set; } = null!; |     public DbSet<FilePool> Pools { get; set; } = null!; | ||||||
|  |     public DbSet<SnFileBundle> Bundles { get; set; } = null!; | ||||||
|  |      | ||||||
|  |     public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!; | ||||||
|  |      | ||||||
|  |     public DbSet<SnCloudFile> Files { get; set; } = null!; | ||||||
|     public DbSet<CloudFileReference> FileReferences { get; set; } = null!; |     public DbSet<CloudFileReference> FileReferences { get; set; } = null!; | ||||||
|      |      | ||||||
|     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) |     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | ||||||
| @@ -25,7 +30,6 @@ public class AppDatabase( | |||||||
|             opt => opt |             opt => opt | ||||||
|                 .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) |                 .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) | ||||||
|                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) |                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) | ||||||
|                 .UseNetTopologySuite() |  | ||||||
|                 .UseNodaTime() |                 .UseNodaTime() | ||||||
|         ).UseSnakeCaseNamingConvention(); |         ).UseSnakeCaseNamingConvention(); | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										28
									
								
								DysonNetwork.Drive/Billing/Quota.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								DysonNetwork.Drive/Billing/Quota.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Billing; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// The quota record stands for the extra quota that a user has. | ||||||
|  | /// For normal users, the quota is 1GiB. | ||||||
|  | /// For stellar program t1 users, the quota is 5GiB | ||||||
|  | /// For stellar program t2 users, the quota is 10GiB | ||||||
|  | /// For stellar program t3 users, the quota is 15GiB | ||||||
|  | /// | ||||||
|  | /// If users want to increase the quota, they need to pay for it. | ||||||
|  | /// Each 1NSD they paid for one GiB. | ||||||
|  | /// | ||||||
|  | /// But the quota record unit is MiB, the minimal billable unit. | ||||||
|  | /// </summary> | ||||||
|  | public class QuotaRecord : ModelBase | ||||||
|  | { | ||||||
|  |     public Guid Id { get; set; } = Guid.NewGuid(); | ||||||
|  |     public Guid AccountId { get; set; } | ||||||
|  |     public string Name { get; set; } = string.Empty; | ||||||
|  |     public string Description { get; set; } = string.Empty; | ||||||
|  |      | ||||||
|  |     public long Quota { get; set; } | ||||||
|  |      | ||||||
|  |     public Instant? ExpiredAt { get; set; } | ||||||
|  | } | ||||||
							
								
								
									
										66
									
								
								DysonNetwork.Drive/Billing/QuotaController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								DysonNetwork.Drive/Billing/QuotaController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | |||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Billing; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/billing/quota")] | ||||||
|  | public class QuotaController(AppDatabase db, QuotaService quota) : ControllerBase | ||||||
|  | { | ||||||
|  |     public class QuotaDetails | ||||||
|  |     { | ||||||
|  |         public long BasedQuota { get; set; } | ||||||
|  |         public long ExtraQuota { get; set; } | ||||||
|  |         public long TotalQuota { get; set; } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     [HttpGet] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<QuotaDetails>> GetQuota() | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |          | ||||||
|  |         var (based, extra) = await quota.GetQuotaVerbose(accountId); | ||||||
|  |         return Ok(new QuotaDetails | ||||||
|  |         { | ||||||
|  |             BasedQuota = based, | ||||||
|  |             ExtraQuota = extra, | ||||||
|  |             TotalQuota = based + extra | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     [HttpGet("records")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<List<QuotaRecord>>> GetQuotaRecords( | ||||||
|  |         [FromQuery] bool expired = false, | ||||||
|  |         [FromQuery] int offset = 0, | ||||||
|  |         [FromQuery] int take = 20 | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |         var query = db.QuotaRecords | ||||||
|  |             .Where(r => r.AccountId == accountId) | ||||||
|  |             .AsQueryable(); | ||||||
|  |         if (!expired) | ||||||
|  |             query = query | ||||||
|  |                 .Where(r => !r.ExpiredAt.HasValue || r.ExpiredAt > now); | ||||||
|  |  | ||||||
|  |         var total = await query.CountAsync(); | ||||||
|  |         Response.Headers.Append("X-Total", total.ToString()); | ||||||
|  |  | ||||||
|  |         var records = await query | ||||||
|  |             .OrderByDescending(r => r.CreatedAt) | ||||||
|  |             .Skip(offset) | ||||||
|  |             .Take(take) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|  |         return Ok(records); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										69
									
								
								DysonNetwork.Drive/Billing/QuotaService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								DysonNetwork.Drive/Billing/QuotaService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | using DysonNetwork.Shared.Auth; | ||||||
|  | using DysonNetwork.Shared.Cache; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Billing; | ||||||
|  |  | ||||||
|  | public class QuotaService( | ||||||
|  |     AppDatabase db, | ||||||
|  |     UsageService usage, | ||||||
|  |     AccountService.AccountServiceClient accounts, | ||||||
|  |     ICacheService cache | ||||||
|  | ) | ||||||
|  | { | ||||||
|  |     public async Task<(bool ok, long billable, long quota)> IsFileAcceptable(Guid accountId, double costMultiplier, long newFileSize) | ||||||
|  |     { | ||||||
|  |         // The billable unit is MiB | ||||||
|  |         var billableUnit = (long)Math.Ceiling(newFileSize / 1024.0 / 1024.0 * costMultiplier); | ||||||
|  |         var totalBillableUsage = await usage.GetTotalBillableUsage(accountId); | ||||||
|  |         var quota = await GetQuota(accountId); | ||||||
|  |         return (totalBillableUsage + billableUnit <= quota, billableUnit, quota); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<long> GetQuota(Guid accountId) | ||||||
|  |     { | ||||||
|  |         var cacheKey = $"file:quota:{accountId}"; | ||||||
|  |         var cachedResult = await cache.GetAsync<long?>(cacheKey); | ||||||
|  |         if (cachedResult.HasValue) return cachedResult.Value; | ||||||
|  |          | ||||||
|  |         var (based, extra) = await GetQuotaVerbose(accountId); | ||||||
|  |         var quota = based + extra; | ||||||
|  |         await cache.SetAsync(cacheKey, quota, expiry: TimeSpan.FromMinutes(30)); | ||||||
|  |         return quota; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     public async Task<(long based, long extra)> GetQuotaVerbose(Guid accountId) | ||||||
|  |     { | ||||||
|  |          | ||||||
|  |  | ||||||
|  |         var response = await accounts.GetAccountAsync(new GetAccountRequest { Id = accountId.ToString() }); | ||||||
|  |         var perkSubscription = response.PerkSubscription; | ||||||
|  |  | ||||||
|  |         // The base quota is 1GiB, T1 is 5GiB, T2 is 10GiB, T3 is 15GiB | ||||||
|  |         var basedQuota = 1L; | ||||||
|  |         if (perkSubscription != null) | ||||||
|  |         { | ||||||
|  |             var privilege = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(perkSubscription.Identifier); | ||||||
|  |             basedQuota = privilege switch | ||||||
|  |             { | ||||||
|  |                 1 => 5L, | ||||||
|  |                 2 => 10L, | ||||||
|  |                 3 => 15L, | ||||||
|  |                 _ => basedQuota | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // The based quota is in GiB, we need to convert it to MiB | ||||||
|  |         basedQuota *= 1024L; | ||||||
|  |          | ||||||
|  |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |         var extraQuota = await db.QuotaRecords | ||||||
|  |             .Where(e => e.AccountId == accountId) | ||||||
|  |             .Where(e => !e.ExpiredAt.HasValue || e.ExpiredAt > now) | ||||||
|  |             .SumAsync(e => e.Quota); | ||||||
|  |          | ||||||
|  |         return (basedQuota, extraQuota); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										49
									
								
								DysonNetwork.Drive/Billing/UsageController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								DysonNetwork.Drive/Billing/UsageController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | using DysonNetwork.Shared.Cache; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Billing; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("api/billing/usage")] | ||||||
|  | public class UsageController(UsageService usage, QuotaService quota, ICacheService cache) : ControllerBase | ||||||
|  | { | ||||||
|  |     [HttpGet] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<TotalUsageDetails>> GetTotalUsage() | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |          | ||||||
|  |         var cacheKey = $"file:usage:{accountId}"; | ||||||
|  |          | ||||||
|  |         // Try to get from cache first | ||||||
|  |         var (found, cachedResult) = await cache.GetAsyncWithStatus<TotalUsageDetails>(cacheKey); | ||||||
|  |         if (found && cachedResult != null) | ||||||
|  |             return Ok(cachedResult); | ||||||
|  |  | ||||||
|  |         // If not in cache, get from services | ||||||
|  |         var result = await usage.GetTotalUsage(accountId); | ||||||
|  |         var totalQuota = await quota.GetQuota(accountId); | ||||||
|  |         result.TotalQuota = totalQuota; | ||||||
|  |  | ||||||
|  |         // Cache the result for 5 minutes | ||||||
|  |         await cache.SetAsync(cacheKey, result, TimeSpan.FromMinutes(5)); | ||||||
|  |  | ||||||
|  |         return Ok(result); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Authorize] | ||||||
|  |     [HttpGet("{poolId:guid}")] | ||||||
|  |     public async Task<ActionResult<UsageDetails>> GetPoolUsage(Guid poolId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |          | ||||||
|  |         var usageDetails = await usage.GetPoolUsage(poolId, accountId); | ||||||
|  |         if (usageDetails == null) | ||||||
|  |             return NotFound(); | ||||||
|  |         return usageDetails; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										121
									
								
								DysonNetwork.Drive/Billing/UsageService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								DysonNetwork.Drive/Billing/UsageService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Billing; | ||||||
|  |  | ||||||
|  | public class UsageDetails | ||||||
|  | { | ||||||
|  |     public required Guid PoolId { get; set; } | ||||||
|  |     public required string PoolName { get; set; } | ||||||
|  |     public required long UsageBytes { get; set; } | ||||||
|  |     public required double Cost { get; set; } | ||||||
|  |     public required long FileCount { get; set; } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public class TotalUsageDetails | ||||||
|  | { | ||||||
|  |     public required List<UsageDetails> PoolUsages { get; set; } | ||||||
|  |     public required long TotalUsageBytes { get; set; } | ||||||
|  |     public required long TotalFileCount { get; set; } | ||||||
|  |      | ||||||
|  |     // Quota, cannot be loaded in the service, cause circular dependency | ||||||
|  |     // Let the controller do the calculation | ||||||
|  |     public long? TotalQuota { get; set; } | ||||||
|  |     public long? UsedQuota { get; set; } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public class UsageService(AppDatabase db) | ||||||
|  | { | ||||||
|  |     public async Task<TotalUsageDetails> GetTotalUsage(Guid accountId) | ||||||
|  |     { | ||||||
|  |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |         var fileQuery = db.Files | ||||||
|  |             .Where(f => !f.IsMarkedRecycle) | ||||||
|  |             .Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now) | ||||||
|  |             .Where(f => f.AccountId == accountId) | ||||||
|  |             .AsQueryable(); | ||||||
|  |          | ||||||
|  |         var poolUsages = await db.Pools | ||||||
|  |             .Select(p => new UsageDetails | ||||||
|  |             { | ||||||
|  |                 PoolId = p.Id, | ||||||
|  |                 PoolName = p.Name, | ||||||
|  |                 UsageBytes = fileQuery | ||||||
|  |                     .Where(f => f.PoolId == p.Id) | ||||||
|  |                     .Sum(f => f.Size), | ||||||
|  |                 Cost = fileQuery | ||||||
|  |                            .Where(f => f.PoolId == p.Id) | ||||||
|  |                            .Sum(f => f.Size) / 1024.0 / 1024.0 * | ||||||
|  |                        (p.BillingConfig.CostMultiplier ?? 1.0), | ||||||
|  |                 FileCount = fileQuery | ||||||
|  |                     .Count(f => f.PoolId == p.Id) | ||||||
|  |             }) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|  |         var totalUsage = poolUsages.Sum(p => p.UsageBytes); | ||||||
|  |         var totalFileCount = poolUsages.Sum(p => p.FileCount); | ||||||
|  |  | ||||||
|  |         return new TotalUsageDetails | ||||||
|  |         { | ||||||
|  |             PoolUsages = poolUsages, | ||||||
|  |             TotalUsageBytes = totalUsage, | ||||||
|  |             TotalFileCount = totalFileCount, | ||||||
|  |             UsedQuota = await GetTotalBillableUsage(accountId) | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<UsageDetails?> GetPoolUsage(Guid poolId, Guid accountId) | ||||||
|  |     { | ||||||
|  |         var pool = await db.Pools.FindAsync(poolId); | ||||||
|  |         if (pool == null) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |         var fileQuery = db.Files | ||||||
|  |             .Where(f => !f.IsMarkedRecycle) | ||||||
|  |             .Where(f => f.ExpiredAt.HasValue && f.ExpiredAt > now) | ||||||
|  |             .Where(f => f.AccountId == accountId) | ||||||
|  |             .AsQueryable(); | ||||||
|  |  | ||||||
|  |         var usageBytes = await fileQuery | ||||||
|  |             .SumAsync(f => f.Size); | ||||||
|  |  | ||||||
|  |         var fileCount = await fileQuery | ||||||
|  |             .CountAsync(); | ||||||
|  |  | ||||||
|  |         var cost = usageBytes / 1024.0 / 1024.0 * | ||||||
|  |                    (pool.BillingConfig.CostMultiplier ?? 1.0); | ||||||
|  |  | ||||||
|  |         return new UsageDetails | ||||||
|  |         { | ||||||
|  |             PoolId = pool.Id, | ||||||
|  |             PoolName = pool.Name, | ||||||
|  |             UsageBytes = usageBytes, | ||||||
|  |             Cost = cost, | ||||||
|  |             FileCount = fileCount | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<long> GetTotalBillableUsage(Guid accountId) | ||||||
|  |     { | ||||||
|  |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |         var files = await db.Files | ||||||
|  |             .Where(f => f.AccountId == accountId) | ||||||
|  |             .Where(f => f.PoolId.HasValue) | ||||||
|  |             .Where(f => !f.IsMarkedRecycle) | ||||||
|  |             .Include(f => f.Pool) | ||||||
|  |             .Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now) | ||||||
|  |             .Select(f => new | ||||||
|  |             { | ||||||
|  |                 f.Size, | ||||||
|  |                 Multiplier = f.Pool!.BillingConfig.CostMultiplier ?? 1.0 | ||||||
|  |             }) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|  |         var totalCost = files.Sum(f => f.Size * f.Multiplier) / 1024.0 / 1024.0; | ||||||
|  |  | ||||||
|  |         return (long)Math.Ceiling(totalCost); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,17 +1,36 @@ | |||||||
| FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base | ||||||
| USER $APP_UID |  | ||||||
| WORKDIR /app | WORKDIR /app | ||||||
| EXPOSE 8080 | EXPOSE 8080 | ||||||
| EXPOSE 8081 | EXPOSE 8081 | ||||||
|  |  | ||||||
|  | # Stage 1: Install runtime dependencies | ||||||
|  |  | ||||||
|  | # Install only necessary dependencies | ||||||
|  | RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||||
|  |   libfontconfig1 \ | ||||||
|  |   libfreetype6 \ | ||||||
|  |   libpng-dev \ | ||||||
|  |   libharfbuzz0b \ | ||||||
|  |   libgif7 \ | ||||||
|  |   libvips \ | ||||||
|  |   ffmpeg \ | ||||||
|  |   && apt-get clean \ | ||||||
|  |   && rm -rf /var/lib/apt/lists/* \ | ||||||
|  |      | ||||||
|  | USER $APP_UID | ||||||
|  |  | ||||||
|  | # Stage 2: Build .NET application | ||||||
| FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||||
| ARG BUILD_CONFIGURATION=Release | ARG BUILD_CONFIGURATION=Release | ||||||
| WORKDIR /src | WORKDIR /src | ||||||
| COPY ["DysonNetwork.Drive/DysonNetwork.Drive.csproj", "DysonNetwork.Drive/"] | COPY ["DysonNetwork.Drive/DysonNetwork.Drive.csproj", "DysonNetwork.Drive/"] | ||||||
| RUN dotnet restore "DysonNetwork.Drive/DysonNetwork.Drive.csproj" | RUN dotnet restore "DysonNetwork.Drive/DysonNetwork.Drive.csproj" | ||||||
| COPY . . | COPY . . | ||||||
|  |  | ||||||
| WORKDIR "/src/DysonNetwork.Drive" | WORKDIR "/src/DysonNetwork.Drive" | ||||||
| RUN dotnet build "./DysonNetwork.Drive.csproj" -c $BUILD_CONFIGURATION -o /app/build | RUN dotnet build "./DysonNetwork.Drive.csproj" -c $BUILD_CONFIGURATION -o /app/build \ | ||||||
|  |     -p:TypeScriptCompileBlocked=true \ | ||||||
|  |     -p:UseRazorBuildServer=false | ||||||
|  |  | ||||||
| FROM build AS publish | FROM build AS publish | ||||||
| ARG BUILD_CONFIGURATION=Release | ARG BUILD_CONFIGURATION=Release | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ | |||||||
|     </PropertyGroup> |     </PropertyGroup> | ||||||
|  |  | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|  |         <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> | ||||||
|         <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> |         <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> | ||||||
|         <PackageReference Include="FFMpegCore" Version="5.2.0" /> |         <PackageReference Include="FFMpegCore" Version="5.2.0" /> | ||||||
|         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> |         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> | ||||||
| @@ -16,11 +17,17 @@ | |||||||
|           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|           <PrivateAssets>all</PrivateAssets> |           <PrivateAssets>all</PrivateAssets> | ||||||
|         </PackageReference> |         </PackageReference> | ||||||
|  |         <PackageReference Include="MimeKit" Version="4.13.0" /> | ||||||
|         <PackageReference Include="MimeTypes" Version="2.5.2"> |         <PackageReference Include="MimeTypes" Version="2.5.2"> | ||||||
|           <PrivateAssets>all</PrivateAssets> |           <PrivateAssets>all</PrivateAssets> | ||||||
|           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|         </PackageReference> |         </PackageReference> | ||||||
|         <PackageReference Include="Minio" Version="6.0.5" /> |         <PackageReference Include="Minio" Version="6.0.5" /> | ||||||
|  |         <PackageReference Include="Nanoid" Version="3.1.0" /> | ||||||
|  |         <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115"> | ||||||
|  |           <PrivateAssets>all</PrivateAssets> | ||||||
|  |           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|  |         </PackageReference> | ||||||
|         <PackageReference Include="NetVips" Version="3.1.0" /> |         <PackageReference Include="NetVips" Version="3.1.0" /> | ||||||
|         <PackageReference Include="NetVips.Native.linux-x64" Version="8.17.1" /> |         <PackageReference Include="NetVips.Native.linux-x64" Version="8.17.1" /> | ||||||
|         <PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.1" /> |         <PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.1" /> | ||||||
| @@ -30,7 +37,6 @@ | |||||||
|         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> |         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" /> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" /> | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" /> |  | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> | ||||||
|         <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" /> |         <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" /> | ||||||
|         <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> |         <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> | ||||||
| @@ -48,12 +54,10 @@ | |||||||
|         <PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" /> |         <PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" /> | ||||||
|         <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" /> |         <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" /> | ||||||
|         <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> |         <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> | ||||||
|         <PackageReference Include="SkiaSharp" Version="3.119.0" /> |         <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" /> | ||||||
|         <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.0" /> |         <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" /> | ||||||
|         <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.0" /> |         <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> | ||||||
|         <PackageReference Include="SkiaSharp.NativeAssets.macOS" Version="3.119.0" /> |         <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" /> | ||||||
|         <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" /> |  | ||||||
|         <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" /> |  | ||||||
|         <PackageReference Include="tusdotnet" Version="2.10.0" /> |         <PackageReference Include="tusdotnet" Version="2.10.0" /> | ||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
| @@ -66,9 +70,4 @@ | |||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> |       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
|     <ItemGroup> |  | ||||||
|       <Compile Remove="Storage\FileReferenceServiceGrpc.cs" /> |  | ||||||
|     </ItemGroup> |  | ||||||
|  |  | ||||||
| </Project> | </Project> | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ using System; | |||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Drive.Storage; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -1,6 +1,4 @@ | |||||||
| using System; | using DysonNetwork.Shared.Models; | ||||||
| using System.Collections.Generic; |  | ||||||
| using DysonNetwork.Drive.Storage; |  | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										190
									
								
								DysonNetwork.Drive/Migrations/20250715080004_ReinitalMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								DysonNetwork.Drive/Migrations/20250715080004_ReinitalMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,190 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Drive; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250715080004_ReinitalMigration")] | ||||||
|  |     partial class ReinitalMigration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasCompression") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Hash") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_marked_recycle"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("MimeType") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("mime_type"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("sensitive_marks"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Size") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("size"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageId") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("storage_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageUrl") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("storage_url"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("UploadedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("UploadedTo") | ||||||
|  |                         .HasMaxLength(128) | ||||||
|  |                         .HasColumnType("character varying(128)") | ||||||
|  |                         .HasColumnName("uploaded_to"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("user_meta"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("files", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("FileId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("file_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ResourceId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("resource_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Usage") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("usage"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_file_references"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("FileId") | ||||||
|  |                         .HasDatabaseName("ix_file_references_file_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("file_references", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("FileId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_file_references_files_file_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("File"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,35 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class ReinitalMigration : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AlterColumn<Dictionary<string, object>>( | ||||||
|  |                 name: "file_meta", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "jsonb", | ||||||
|  |                 nullable: false, | ||||||
|  |                 oldClrType: typeof(Dictionary<string, object>), | ||||||
|  |                 oldType: "jsonb", | ||||||
|  |                 oldNullable: true); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AlterColumn<Dictionary<string, object>>( | ||||||
|  |                 name: "file_meta", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "jsonb", | ||||||
|  |                 nullable: true, | ||||||
|  |                 oldClrType: typeof(Dictionary<string, object>), | ||||||
|  |                 oldType: "jsonb"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										264
									
								
								DysonNetwork.Drive/Migrations/20250726103203_AddCloudFilePool.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										264
									
								
								DysonNetwork.Drive/Migrations/20250726103203_AddCloudFilePool.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,264 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Drive; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250726103203_AddCloudFilePool")] | ||||||
|  |     partial class AddCloudFilePool | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasCompression") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasThumbnail") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_thumbnail"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Hash") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsEncrypted") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_encrypted"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_marked_recycle"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("MimeType") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("mime_type"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("PoolId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("sensitive_marks"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Size") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("size"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageId") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("storage_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageUrl") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("storage_url"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("UploadedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("UploadedTo") | ||||||
|  |                         .HasMaxLength(128) | ||||||
|  |                         .HasColumnType("character varying(128)") | ||||||
|  |                         .HasColumnName("uploaded_to"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("user_meta"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("PoolId") | ||||||
|  |                         .HasDatabaseName("ix_files_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("files", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("FileId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("file_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ResourceId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("resource_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Usage") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("usage"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_file_references"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("FileId") | ||||||
|  |                         .HasDatabaseName("ix_file_references_file_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("file_references", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<BillingConfig>("BillingConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("billing_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<PolicyConfig>("PolicyConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("policy_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("storage_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_pools"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("pools", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("PoolId") | ||||||
|  |                         .HasConstraintName("fk_files_pools_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Pool"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("FileId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_file_references_files_file_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("File"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										111
									
								
								DysonNetwork.Drive/Migrations/20250726103203_AddCloudFilePool.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								DysonNetwork.Drive/Migrations/20250726103203_AddCloudFilePool.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class AddCloudFilePool : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AlterColumn<Dictionary<string, object>>( | ||||||
|  |                 name: "file_meta", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "jsonb", | ||||||
|  |                 nullable: true, | ||||||
|  |                 oldClrType: typeof(Dictionary<string, object>), | ||||||
|  |                 oldType: "jsonb"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.AddColumn<bool>( | ||||||
|  |                 name: "has_thumbnail", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "boolean", | ||||||
|  |                 nullable: false, | ||||||
|  |                 defaultValue: false); | ||||||
|  |  | ||||||
|  |             migrationBuilder.AddColumn<bool>( | ||||||
|  |                 name: "is_encrypted", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "boolean", | ||||||
|  |                 nullable: false, | ||||||
|  |                 defaultValue: false); | ||||||
|  |  | ||||||
|  |             migrationBuilder.AddColumn<Guid>( | ||||||
|  |                 name: "pool_id", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "uuid", | ||||||
|  |                 nullable: true); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "pools", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||||
|  |                     storage_config = table.Column<RemoteStorageConfig>(type: "jsonb", nullable: false), | ||||||
|  |                     billing_config = table.Column<BillingConfig>(type: "jsonb", nullable: false), | ||||||
|  |                     policy_config = table.Column<PolicyConfig>(type: "jsonb", nullable: false), | ||||||
|  |                     account_id = table.Column<Guid>(type: "uuid", nullable: true), | ||||||
|  |                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||||
|  |                 }, | ||||||
|  |                 constraints: table => | ||||||
|  |                 { | ||||||
|  |                     table.PrimaryKey("pk_pools", x => x.id); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateIndex( | ||||||
|  |                 name: "ix_files_pool_id", | ||||||
|  |                 table: "files", | ||||||
|  |                 column: "pool_id"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.AddForeignKey( | ||||||
|  |                 name: "fk_files_pools_pool_id", | ||||||
|  |                 table: "files", | ||||||
|  |                 column: "pool_id", | ||||||
|  |                 principalTable: "pools", | ||||||
|  |                 principalColumn: "id"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropForeignKey( | ||||||
|  |                 name: "fk_files_pools_pool_id", | ||||||
|  |                 table: "files"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropTable( | ||||||
|  |                 name: "pools"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropIndex( | ||||||
|  |                 name: "ix_files_pool_id", | ||||||
|  |                 table: "files"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropColumn( | ||||||
|  |                 name: "has_thumbnail", | ||||||
|  |                 table: "files"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropColumn( | ||||||
|  |                 name: "is_encrypted", | ||||||
|  |                 table: "files"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropColumn( | ||||||
|  |                 name: "pool_id", | ||||||
|  |                 table: "files"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.AlterColumn<Dictionary<string, object>>( | ||||||
|  |                 name: "file_meta", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "jsonb", | ||||||
|  |                 nullable: false, | ||||||
|  |                 oldClrType: typeof(Dictionary<string, object>), | ||||||
|  |                 oldType: "jsonb", | ||||||
|  |                 oldNullable: true); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										270
									
								
								DysonNetwork.Drive/Migrations/20250726120323_AddFilePoolDescription.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										270
									
								
								DysonNetwork.Drive/Migrations/20250726120323_AddFilePoolDescription.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,270 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Drive; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250726120323_AddFilePoolDescription")] | ||||||
|  |     partial class AddFilePoolDescription | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasCompression") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasThumbnail") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_thumbnail"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Hash") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsEncrypted") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_encrypted"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_marked_recycle"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("MimeType") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("mime_type"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("PoolId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("sensitive_marks"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Size") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("size"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageId") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("storage_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageUrl") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("storage_url"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("UploadedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("UploadedTo") | ||||||
|  |                         .HasMaxLength(128) | ||||||
|  |                         .HasColumnType("character varying(128)") | ||||||
|  |                         .HasColumnName("uploaded_to"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("user_meta"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("PoolId") | ||||||
|  |                         .HasDatabaseName("ix_files_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("files", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("FileId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("file_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ResourceId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("resource_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Usage") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("usage"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_file_references"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("FileId") | ||||||
|  |                         .HasDatabaseName("ix_file_references_file_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("file_references", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<BillingConfig>("BillingConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("billing_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<PolicyConfig>("PolicyConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("policy_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("storage_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_pools"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("pools", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("PoolId") | ||||||
|  |                         .HasConstraintName("fk_files_pools_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Pool"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("FileId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_file_references_files_file_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("File"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,30 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class AddFilePoolDescription : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AddColumn<string>( | ||||||
|  |                 name: "description", | ||||||
|  |                 table: "pools", | ||||||
|  |                 type: "character varying(8192)", | ||||||
|  |                 maxLength: 8192, | ||||||
|  |                 nullable: false, | ||||||
|  |                 defaultValue: ""); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropColumn( | ||||||
|  |                 name: "description", | ||||||
|  |                 table: "pools"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										274
									
								
								DysonNetwork.Drive/Migrations/20250726172039_AddCloudFileExpiration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										274
									
								
								DysonNetwork.Drive/Migrations/20250726172039_AddCloudFileExpiration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,274 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Drive; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250726172039_AddCloudFileExpiration")] | ||||||
|  |     partial class AddCloudFileExpiration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasCompression") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasThumbnail") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_thumbnail"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Hash") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsEncrypted") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_encrypted"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_marked_recycle"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("MimeType") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("mime_type"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("PoolId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("sensitive_marks"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Size") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("size"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageId") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("storage_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageUrl") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("storage_url"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("UploadedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("UploadedTo") | ||||||
|  |                         .HasMaxLength(128) | ||||||
|  |                         .HasColumnType("character varying(128)") | ||||||
|  |                         .HasColumnName("uploaded_to"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("user_meta"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("PoolId") | ||||||
|  |                         .HasDatabaseName("ix_files_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("files", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("FileId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("file_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ResourceId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("resource_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Usage") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("usage"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_file_references"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("FileId") | ||||||
|  |                         .HasDatabaseName("ix_file_references_file_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("file_references", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<BillingConfig>("BillingConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("billing_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<PolicyConfig>("PolicyConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("policy_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("storage_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_pools"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("pools", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("PoolId") | ||||||
|  |                         .HasConstraintName("fk_files_pools_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Pool"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("FileId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_file_references_files_file_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("File"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,29 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class AddCloudFileExpiration : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AddColumn<Instant>( | ||||||
|  |                 name: "expired_at", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "timestamp with time zone", | ||||||
|  |                 nullable: true); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropColumn( | ||||||
|  |                 name: "expired_at", | ||||||
|  |                 table: "files"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										321
									
								
								DysonNetwork.Drive/Migrations/20250727092028_AddQuotaRecord.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										321
									
								
								DysonNetwork.Drive/Migrations/20250727092028_AddQuotaRecord.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,321 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Drive; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250727092028_AddQuotaRecord")] | ||||||
|  |     partial class AddQuotaRecord | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Quota") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("quota"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_quota_records"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("quota_records", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasCompression") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasThumbnail") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_thumbnail"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Hash") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsEncrypted") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_encrypted"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_marked_recycle"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("MimeType") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("mime_type"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("PoolId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("sensitive_marks"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Size") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("size"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageId") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("storage_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageUrl") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("storage_url"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("UploadedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("UploadedTo") | ||||||
|  |                         .HasMaxLength(128) | ||||||
|  |                         .HasColumnType("character varying(128)") | ||||||
|  |                         .HasColumnName("uploaded_to"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("user_meta"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("PoolId") | ||||||
|  |                         .HasDatabaseName("ix_files_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("files", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("FileId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("file_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ResourceId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("resource_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Usage") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("usage"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_file_references"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("FileId") | ||||||
|  |                         .HasDatabaseName("ix_file_references_file_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("file_references", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<BillingConfig>("BillingConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("billing_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<PolicyConfig>("PolicyConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("policy_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("storage_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_pools"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("pools", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("PoolId") | ||||||
|  |                         .HasConstraintName("fk_files_pools_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Pool"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("FileId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_file_references_files_file_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("File"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,41 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class AddQuotaRecord : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "quota_records", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     account_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     name = table.Column<string>(type: "text", nullable: false), | ||||||
|  |                     description = table.Column<string>(type: "text", nullable: false), | ||||||
|  |                     quota = table.Column<long>(type: "bigint", nullable: false), | ||||||
|  |                     expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true), | ||||||
|  |                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||||
|  |                 }, | ||||||
|  |                 constraints: table => | ||||||
|  |                 { | ||||||
|  |                     table.PrimaryKey("pk_quota_records", x => x.id); | ||||||
|  |                 }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropTable( | ||||||
|  |                 name: "quota_records"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										399
									
								
								DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										399
									
								
								DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,399 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Drive; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250727130951_AddFileBundle")] | ||||||
|  |     partial class AddFileBundle | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Quota") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("quota"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_quota_records"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("quota_records", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("BundleId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasCompression") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasThumbnail") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_thumbnail"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Hash") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsEncrypted") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_encrypted"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_marked_recycle"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("MimeType") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("mime_type"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("PoolId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("sensitive_marks"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Size") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("size"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageId") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("storage_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageUrl") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("storage_url"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("UploadedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("UploadedTo") | ||||||
|  |                         .HasMaxLength(128) | ||||||
|  |                         .HasColumnType("character varying(128)") | ||||||
|  |                         .HasColumnName("uploaded_to"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("user_meta"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("BundleId") | ||||||
|  |                         .HasDatabaseName("ix_files_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("PoolId") | ||||||
|  |                         .HasDatabaseName("ix_files_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("files", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("FileId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("file_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ResourceId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("resource_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Usage") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("usage"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_file_references"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("FileId") | ||||||
|  |                         .HasDatabaseName("ix_file_references_file_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("file_references", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Passcode") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("passcode"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_bundles"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("Slug") | ||||||
|  |                         .IsUnique() | ||||||
|  |                         .HasDatabaseName("ix_bundles_slug"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("bundles", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<BillingConfig>("BillingConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("billing_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<PolicyConfig>("PolicyConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("policy_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("storage_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_pools"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("pools", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle") | ||||||
|  |                         .WithMany("Files") | ||||||
|  |                         .HasForeignKey("BundleId") | ||||||
|  |                         .HasConstraintName("fk_files_bundles_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("PoolId") | ||||||
|  |                         .HasConstraintName("fk_files_pools_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Bundle"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Pool"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("FileId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_file_references_files_file_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("File"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Files"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,78 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class AddFileBundle : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AddColumn<Guid>( | ||||||
|  |                 name: "bundle_id", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "uuid", | ||||||
|  |                 nullable: true); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "bundles", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||||
|  |                     name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||||
|  |                     description = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: true), | ||||||
|  |                     passcode = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), | ||||||
|  |                     expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true), | ||||||
|  |                     account_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||||
|  |                 }, | ||||||
|  |                 constraints: table => | ||||||
|  |                 { | ||||||
|  |                     table.PrimaryKey("pk_bundles", x => x.id); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateIndex( | ||||||
|  |                 name: "ix_files_bundle_id", | ||||||
|  |                 table: "files", | ||||||
|  |                 column: "bundle_id"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateIndex( | ||||||
|  |                 name: "ix_bundles_slug", | ||||||
|  |                 table: "bundles", | ||||||
|  |                 column: "slug", | ||||||
|  |                 unique: true); | ||||||
|  |  | ||||||
|  |             migrationBuilder.AddForeignKey( | ||||||
|  |                 name: "fk_files_bundles_bundle_id", | ||||||
|  |                 table: "files", | ||||||
|  |                 column: "bundle_id", | ||||||
|  |                 principalTable: "bundles", | ||||||
|  |                 principalColumn: "id"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropForeignKey( | ||||||
|  |                 name: "fk_files_bundles_bundle_id", | ||||||
|  |                 table: "files"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropTable( | ||||||
|  |                 name: "bundles"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropIndex( | ||||||
|  |                 name: "ix_files_bundle_id", | ||||||
|  |                 table: "files"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropColumn( | ||||||
|  |                 name: "bundle_id", | ||||||
|  |                 table: "files"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										403
									
								
								DysonNetwork.Drive/Migrations/20250808170904_AddHiddenPool.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										403
									
								
								DysonNetwork.Drive/Migrations/20250808170904_AddHiddenPool.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,403 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Drive; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250808170904_AddHiddenPool")] | ||||||
|  |     partial class AddHiddenPool | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Quota") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("quota"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_quota_records"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("quota_records", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("BundleId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasCompression") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasThumbnail") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_thumbnail"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Hash") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsEncrypted") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_encrypted"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_marked_recycle"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("MimeType") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("mime_type"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("PoolId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("sensitive_marks"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Size") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("size"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageId") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("storage_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageUrl") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("storage_url"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("UploadedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("UploadedTo") | ||||||
|  |                         .HasMaxLength(128) | ||||||
|  |                         .HasColumnType("character varying(128)") | ||||||
|  |                         .HasColumnName("uploaded_to"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("user_meta"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("BundleId") | ||||||
|  |                         .HasDatabaseName("ix_files_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("PoolId") | ||||||
|  |                         .HasDatabaseName("ix_files_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("files", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("FileId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("file_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ResourceId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("resource_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Usage") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("usage"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_file_references"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("FileId") | ||||||
|  |                         .HasDatabaseName("ix_file_references_file_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("file_references", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Passcode") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("passcode"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_bundles"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("Slug") | ||||||
|  |                         .IsUnique() | ||||||
|  |                         .HasDatabaseName("ix_bundles_slug"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("bundles", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<BillingConfig>("BillingConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("billing_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsHidden") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_hidden"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<PolicyConfig>("PolicyConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("policy_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("storage_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_pools"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("pools", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle") | ||||||
|  |                         .WithMany("Files") | ||||||
|  |                         .HasForeignKey("BundleId") | ||||||
|  |                         .HasConstraintName("fk_files_bundles_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("PoolId") | ||||||
|  |                         .HasConstraintName("fk_files_pools_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Bundle"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Pool"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("FileId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_file_references_files_file_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("File"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Files"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,29 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class AddHiddenPool : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AddColumn<bool>( | ||||||
|  |                 name: "is_hidden", | ||||||
|  |                 table: "pools", | ||||||
|  |                 type: "boolean", | ||||||
|  |                 nullable: false, | ||||||
|  |                 defaultValue: false); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropColumn( | ||||||
|  |                 name: "is_hidden", | ||||||
|  |                 table: "pools"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										403
									
								
								DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										403
									
								
								DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,403 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Drive; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250819164302_RemoveUploadedTo")] | ||||||
|  |     partial class RemoveUploadedTo | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Quota") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("quota"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_quota_records"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("quota_records", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("BundleId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasCompression") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasThumbnail") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_thumbnail"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Hash") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsEncrypted") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_encrypted"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_marked_recycle"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("MimeType") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("mime_type"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("PoolId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("sensitive_marks"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Size") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("size"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageId") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("storage_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageUrl") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("storage_url"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("UploadedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("user_meta"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("BundleId") | ||||||
|  |                         .HasDatabaseName("ix_files_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("PoolId") | ||||||
|  |                         .HasDatabaseName("ix_files_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("files", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("FileId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("file_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ResourceId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("resource_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Usage") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("usage"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_file_references"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("FileId") | ||||||
|  |                         .HasDatabaseName("ix_file_references_file_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("file_references", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Passcode") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("passcode"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_bundles"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("Slug") | ||||||
|  |                         .IsUnique() | ||||||
|  |                         .HasDatabaseName("ix_bundles_slug"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("bundles", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<BillingConfig>("BillingConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("billing_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsHidden") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_hidden"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<PolicyConfig>("PolicyConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("policy_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("storage_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_pools"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("pools", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle") | ||||||
|  |                         .WithMany("Files") | ||||||
|  |                         .HasForeignKey("BundleId") | ||||||
|  |                         .HasConstraintName("fk_files_bundles_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("PoolId") | ||||||
|  |                         .HasConstraintName("fk_files_pools_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Bundle"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Pool"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|  |                         .WithMany("References") | ||||||
|  |                         .HasForeignKey("FileId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_file_references_files_file_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("File"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("References"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Files"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -2,28 +2,28 @@ | |||||||
| 
 | 
 | ||||||
| #nullable disable | #nullable disable | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Sphere.Migrations | namespace DysonNetwork.Drive.Migrations | ||||||
| { | { | ||||||
|     /// <inheritdoc /> |     /// <inheritdoc /> | ||||||
|     public partial class AddCloudFileUsage : Migration |     public partial class RemoveUploadedTo : Migration | ||||||
|     { |     { | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|         protected override void Up(MigrationBuilder migrationBuilder) |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|         { |         { | ||||||
|             migrationBuilder.AddColumn<string>( |             migrationBuilder.DropColumn( | ||||||
|                 name: "usage", |                 name: "uploaded_to", | ||||||
|                 table: "files", |                 table: "files"); | ||||||
|                 type: "character varying(1024)", |  | ||||||
|                 maxLength: 1024, |  | ||||||
|                 nullable: true); |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|         protected override void Down(MigrationBuilder migrationBuilder) |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|         { |         { | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.AddColumn<string>( | ||||||
|                 name: "usage", |                 name: "uploaded_to", | ||||||
|                 table: "files"); |                 table: "files", | ||||||
|  |                 type: "character varying(128)", | ||||||
|  |                 maxLength: 128, | ||||||
|  |                 nullable: true); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
							
								
								
									
										402
									
								
								DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										402
									
								
								DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,402 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Drive; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250907070034_RemoveNetTopo")] | ||||||
|  |     partial class RemoveNetTopo | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Quota") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("quota"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_quota_records"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("quota_records", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("BundleId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasCompression") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasThumbnail") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_thumbnail"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Hash") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsEncrypted") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_encrypted"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_marked_recycle"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("MimeType") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("mime_type"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("PoolId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("sensitive_marks"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Size") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("size"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageId") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("storage_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageUrl") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("storage_url"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("UploadedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("user_meta"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("BundleId") | ||||||
|  |                         .HasDatabaseName("ix_files_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("PoolId") | ||||||
|  |                         .HasDatabaseName("ix_files_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("files", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("FileId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("file_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ResourceId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("resource_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Usage") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("usage"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_file_references"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("FileId") | ||||||
|  |                         .HasDatabaseName("ix_file_references_file_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("file_references", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Passcode") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("passcode"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_bundles"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("Slug") | ||||||
|  |                         .IsUnique() | ||||||
|  |                         .HasDatabaseName("ix_bundles_slug"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("bundles", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<BillingConfig>("BillingConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("billing_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsHidden") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_hidden"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<PolicyConfig>("PolicyConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("policy_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("storage_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_pools"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("pools", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle") | ||||||
|  |                         .WithMany("Files") | ||||||
|  |                         .HasForeignKey("BundleId") | ||||||
|  |                         .HasConstraintName("fk_files_bundles_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("PoolId") | ||||||
|  |                         .HasConstraintName("fk_files_pools_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Bundle"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Pool"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|  |                         .WithMany("References") | ||||||
|  |                         .HasForeignKey("FileId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_file_references_files_file_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("File"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("References"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Files"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,24 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class RemoveNetTopo : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AlterDatabase() | ||||||
|  |                 .OldAnnotation("Npgsql:PostgresExtension:postgis", ",,"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AlterDatabase() | ||||||
|  |                 .Annotation("Npgsql:PostgresExtension:postgis", ",,"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -2,7 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
| @@ -23,14 +23,13 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                 .HasAnnotation("ProductVersion", "9.0.7") |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); |  | ||||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => |             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => | ||||||
|                 { |                 { | ||||||
|                     b.Property<string>("Id") |                     b.Property<Guid>("Id") | ||||||
|                         .HasMaxLength(32) |                         .ValueGeneratedOnAdd() | ||||||
|                         .HasColumnType("character varying(32)") |                         .HasColumnType("uuid") | ||||||
|                         .HasColumnName("id"); |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|                     b.Property<Guid>("AccountId") |                     b.Property<Guid>("AccountId") | ||||||
| @@ -45,11 +44,66 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                         .HasColumnType("timestamp with time zone") |                         .HasColumnType("timestamp with time zone") | ||||||
|                         .HasColumnName("deleted_at"); |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Quota") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("quota"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_quota_records"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("quota_records", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("BundleId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|                     b.Property<string>("Description") |                     b.Property<string>("Description") | ||||||
|                         .HasMaxLength(4096) |                         .HasMaxLength(4096) | ||||||
|                         .HasColumnType("character varying(4096)") |                         .HasColumnType("character varying(4096)") | ||||||
|                         .HasColumnName("description"); |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|                     b.Property<Dictionary<string, object>>("FileMeta") |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("file_meta"); |                         .HasColumnName("file_meta"); | ||||||
| @@ -58,11 +112,19 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                         .HasColumnType("boolean") |                         .HasColumnType("boolean") | ||||||
|                         .HasColumnName("has_compression"); |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasThumbnail") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_thumbnail"); | ||||||
|  |  | ||||||
|                     b.Property<string>("Hash") |                     b.Property<string>("Hash") | ||||||
|                         .HasMaxLength(256) |                         .HasMaxLength(256) | ||||||
|                         .HasColumnType("character varying(256)") |                         .HasColumnType("character varying(256)") | ||||||
|                         .HasColumnName("hash"); |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsEncrypted") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_encrypted"); | ||||||
|  |  | ||||||
|                     b.Property<bool>("IsMarkedRecycle") |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|                         .HasColumnType("boolean") |                         .HasColumnType("boolean") | ||||||
|                         .HasColumnName("is_marked_recycle"); |                         .HasColumnName("is_marked_recycle"); | ||||||
| @@ -78,6 +140,10 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                         .HasColumnType("character varying(1024)") |                         .HasColumnType("character varying(1024)") | ||||||
|                         .HasColumnName("name"); |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("PoolId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("pool_id"); | ||||||
|  |  | ||||||
|                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("sensitive_marks"); |                         .HasColumnName("sensitive_marks"); | ||||||
| @@ -104,11 +170,6 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                         .HasColumnType("timestamp with time zone") |                         .HasColumnType("timestamp with time zone") | ||||||
|                         .HasColumnName("uploaded_at"); |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|                     b.Property<string>("UploadedTo") |  | ||||||
|                         .HasMaxLength(128) |  | ||||||
|                         .HasColumnType("character varying(128)") |  | ||||||
|                         .HasColumnName("uploaded_to"); |  | ||||||
|  |  | ||||||
|                     b.Property<Dictionary<string, object>>("UserMeta") |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("user_meta"); |                         .HasColumnName("user_meta"); | ||||||
| @@ -116,6 +177,12 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                     b.HasKey("Id") |                     b.HasKey("Id") | ||||||
|                         .HasName("pk_files"); |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("BundleId") | ||||||
|  |                         .HasDatabaseName("ix_files_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("PoolId") | ||||||
|  |                         .HasDatabaseName("ix_files_pool_id"); | ||||||
|  |  | ||||||
|                     b.ToTable("files", (string)null); |                     b.ToTable("files", (string)null); | ||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
| @@ -169,10 +236,146 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                     b.ToTable("file_references", (string)null); |                     b.ToTable("file_references", (string)null); | ||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Passcode") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("passcode"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_bundles"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("Slug") | ||||||
|  |                         .IsUnique() | ||||||
|  |                         .HasDatabaseName("ix_bundles_slug"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("bundles", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<BillingConfig>("BillingConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("billing_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsHidden") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_hidden"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<PolicyConfig>("PolicyConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("policy_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("storage_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_pools"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("pools", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle") | ||||||
|  |                         .WithMany("Files") | ||||||
|  |                         .HasForeignKey("BundleId") | ||||||
|  |                         .HasConstraintName("fk_files_bundles_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("PoolId") | ||||||
|  |                         .HasConstraintName("fk_files_pools_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Bundle"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Pool"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|                 { |                 { | ||||||
|                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|                         .WithMany() |                         .WithMany("References") | ||||||
|                         .HasForeignKey("FileId") |                         .HasForeignKey("FileId") | ||||||
|                         .OnDelete(DeleteBehavior.Cascade) |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|                         .IsRequired() |                         .IsRequired() | ||||||
| @@ -180,6 +383,16 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|  |  | ||||||
|                     b.Navigation("File"); |                     b.Navigation("File"); | ||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("References"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Files"); | ||||||
|  |                 }); | ||||||
| #pragma warning restore 612, 618 | #pragma warning restore 612, 618 | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -4,31 +4,38 @@ using DysonNetwork.Shared.Auth; | |||||||
| using DysonNetwork.Shared.Http; | using DysonNetwork.Shared.Http; | ||||||
| using DysonNetwork.Shared.Registry; | using DysonNetwork.Shared.Registry; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
|  | using tusdotnet.Stores; | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
|  | builder.AddServiceDefaults(); | ||||||
|  |  | ||||||
| // Configure Kestrel and server options | // Configure Kestrel and server options | ||||||
| builder.ConfigureAppKestrel(); | builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue); | ||||||
|  |  | ||||||
| // Add application services | // Add application services | ||||||
| builder.Services.AddRegistryService(builder.Configuration); |  | ||||||
| builder.Services.AddAppServices(builder.Configuration); | builder.Services.AddAppServices(builder.Configuration); | ||||||
| builder.Services.AddAppRateLimiting(); | builder.Services.AddAppRateLimiting(); | ||||||
| builder.Services.AddAppAuthentication(); | builder.Services.AddAppAuthentication(); | ||||||
| builder.Services.AddAppSwagger(); | builder.Services.AddDysonAuth(); | ||||||
| builder.Services.AddDysonAuth(builder.Configuration); | builder.Services.AddAccountService(); | ||||||
|  |  | ||||||
|  | builder.Services.AddAppFileStorage(builder.Configuration); | ||||||
|  |  | ||||||
| // Add flush handlers and websocket handlers |  | ||||||
| builder.Services.AddAppFlushHandlers(); | builder.Services.AddAppFlushHandlers(); | ||||||
|  |  | ||||||
| // Add business services |  | ||||||
| builder.Services.AddAppBusinessServices(); | builder.Services.AddAppBusinessServices(); | ||||||
|  |  | ||||||
| // Add scheduled jobs |  | ||||||
| builder.Services.AddAppScheduledJobs(); | builder.Services.AddAppScheduledJobs(); | ||||||
|  |  | ||||||
|  | builder.AddSwaggerManifest( | ||||||
|  |     "DysonNetwork.Drive", | ||||||
|  |     "The file upload and storage service in the Solar Network." | ||||||
|  | ); | ||||||
|  |  | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
|  |  | ||||||
|  | app.MapDefaultEndpoints(); | ||||||
|  |  | ||||||
| // Run database migrations | // Run database migrations | ||||||
| using (var scope = app.Services.CreateScope()) | using (var scope = app.Services.CreateScope()) | ||||||
| { | { | ||||||
| @@ -36,10 +43,12 @@ using (var scope = app.Services.CreateScope()) | |||||||
|     await db.Database.MigrateAsync(); |     await db.Database.MigrateAsync(); | ||||||
| } | } | ||||||
|  |  | ||||||
| // Configure application middleware pipeline | var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>(); | ||||||
| app.ConfigureAppMiddleware(builder.Configuration); | app.ConfigureAppMiddleware(tusDiskStore); | ||||||
|  |  | ||||||
| // Configure gRPC | // Configure gRPC | ||||||
| app.ConfigureGrpcServices(); | app.ConfigureGrpcServices(); | ||||||
|  |  | ||||||
| app.Run(); | app.UseSwaggerManifest(); | ||||||
|  |  | ||||||
|  | app.Run(); | ||||||
|   | |||||||
| @@ -5,7 +5,6 @@ | |||||||
|       "commandName": "Project", |       "commandName": "Project", | ||||||
|       "dotnetRunMessages": true, |       "dotnetRunMessages": true, | ||||||
|       "launchBrowser": false, |       "launchBrowser": false, | ||||||
|       "applicationUrl": "http://localhost:5090", |  | ||||||
|       "environmentVariables": { |       "environmentVariables": { | ||||||
|         "ASPNETCORE_ENVIRONMENT": "Development" |         "ASPNETCORE_ENVIRONMENT": "Development" | ||||||
|       } |       } | ||||||
| @@ -14,7 +13,6 @@ | |||||||
|       "commandName": "Project", |       "commandName": "Project", | ||||||
|       "dotnetRunMessages": true, |       "dotnetRunMessages": true, | ||||||
|       "launchBrowser": false, |       "launchBrowser": false, | ||||||
|       "applicationUrl": "https://localhost:7092;http://localhost:5090", |  | ||||||
|       "environmentVariables": { |       "environmentVariables": { | ||||||
|         "ASPNETCORE_ENVIRONMENT": "Development" |         "ASPNETCORE_ENVIRONMENT": "Development" | ||||||
|       } |       } | ||||||
|   | |||||||
| @@ -1,27 +1,26 @@ | |||||||
|  | using DysonNetwork.Drive.Storage; | ||||||
|  | using tusdotnet; | ||||||
|  | using tusdotnet.Interfaces; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Startup; | namespace DysonNetwork.Drive.Startup; | ||||||
|  |  | ||||||
| public static class ApplicationBuilderExtensions | public static class ApplicationBuilderExtensions | ||||||
| { | { | ||||||
|     public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration) |     public static WebApplication ConfigureAppMiddleware(this WebApplication app, ITusStore tusStore) | ||||||
|     { |     { | ||||||
|         // Configure the HTTP request pipeline. |  | ||||||
|         if (app.Environment.IsDevelopment()) |  | ||||||
|         { |  | ||||||
|             app.UseSwagger(); |  | ||||||
|             app.UseSwaggerUI(); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         app.UseHttpsRedirection(); |  | ||||||
|         app.UseAuthorization(); |         app.UseAuthorization(); | ||||||
|         app.MapControllers(); |         app.MapControllers(); | ||||||
|  |  | ||||||
|  |         app.MapTus("/api/tus", _ => Task.FromResult(TusService.BuildConfiguration(tusStore, app.Configuration))); | ||||||
|  |  | ||||||
|         return app; |         return app; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public static WebApplication ConfigureGrpcServices(this WebApplication app) |     public static WebApplication ConfigureGrpcServices(this WebApplication app) | ||||||
|     { |     { | ||||||
|         // Map your gRPC services here |         // Map your gRPC services here | ||||||
|         // Example: app.MapGrpcService<MyGrpcService>(); |         app.MapGrpcService<FileServiceGrpc>(); | ||||||
|  |         app.MapGrpcService<FileReferenceServiceGrpc>(); | ||||||
|  |  | ||||||
|         return app; |         return app; | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										297
									
								
								DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										297
									
								
								DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,297 @@ | |||||||
|  | using System.Text.Json; | ||||||
|  | using DysonNetwork.Drive.Storage.Model; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using DysonNetwork.Shared.Stream; | ||||||
|  | using FFMpegCore; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NATS.Client.Core; | ||||||
|  | using NATS.Client.JetStream; | ||||||
|  | using NATS.Client.JetStream.Models; | ||||||
|  | using NATS.Net; | ||||||
|  | using NetVips; | ||||||
|  | using NodaTime; | ||||||
|  | using FileService = DysonNetwork.Drive.Storage.FileService; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Startup; | ||||||
|  |  | ||||||
|  | public class BroadcastEventHandler( | ||||||
|  |     INatsConnection nats, | ||||||
|  |     ILogger<BroadcastEventHandler> logger, | ||||||
|  |     IServiceProvider serviceProvider | ||||||
|  | ) : BackgroundService | ||||||
|  | { | ||||||
|  |     private const string TempFileSuffix = "dypart"; | ||||||
|  |  | ||||||
|  |     private static readonly string[] AnimatedImageTypes = | ||||||
|  |         ["image/gif", "image/apng", "image/avif"]; | ||||||
|  |  | ||||||
|  |     private static readonly string[] AnimatedImageExtensions = | ||||||
|  |         [".gif", ".apng", ".avif"]; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||||
|  |     { | ||||||
|  |         var js = nats.CreateJetStreamContext(); | ||||||
|  |  | ||||||
|  |         await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]); | ||||||
|  |          | ||||||
|  |         var accountEventConsumer = await js.CreateOrUpdateConsumerAsync("account_events", | ||||||
|  |             new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken); | ||||||
|  |          | ||||||
|  |         await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]); | ||||||
|  |         var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events", | ||||||
|  |             new ConsumerConfig("drive_file_uploaded_handler") { MaxDeliver = 3 }, cancellationToken: stoppingToken); | ||||||
|  |  | ||||||
|  |         var accountDeletedTask = HandleAccountDeleted(accountEventConsumer, stoppingToken); | ||||||
|  |         var fileUploadedTask = HandleFileUploaded(fileUploadedConsumer, stoppingToken); | ||||||
|  |  | ||||||
|  |         await Task.WhenAll(accountDeletedTask, fileUploadedTask); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async Task HandleFileUploaded(INatsJSConsumer consumer, CancellationToken stoppingToken) | ||||||
|  |     { | ||||||
|  |         await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken)) | ||||||
|  |         { | ||||||
|  |             var payload = JsonSerializer.Deserialize<FileUploadedEventPayload>(msg.Data, GrpcTypeHelper.SerializerOptions); | ||||||
|  |             if (payload == null) | ||||||
|  |             { | ||||||
|  |                 await msg.AckAsync(cancellationToken: stoppingToken); | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 await ProcessAndUploadInBackgroundAsync( | ||||||
|  |                     payload.FileId, | ||||||
|  |                     payload.RemoteId, | ||||||
|  |                     payload.StorageId, | ||||||
|  |                     payload.ContentType, | ||||||
|  |                     payload.ProcessingFilePath, | ||||||
|  |                     payload.IsTempFile | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|  |                 await msg.AckAsync(cancellationToken: stoppingToken); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload.FileId); | ||||||
|  |                 await msg.NakAsync(cancellationToken: stoppingToken, delay: TimeSpan.FromSeconds(60)); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async Task HandleAccountDeleted(INatsJSConsumer consumer, CancellationToken stoppingToken) | ||||||
|  |     { | ||||||
|  |         await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken)) | ||||||
|  |         { | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data, GrpcTypeHelper.SerializerOptions); | ||||||
|  |                 if (evt == null) | ||||||
|  |                 { | ||||||
|  |                     await msg.AckAsync(cancellationToken: stoppingToken); | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 logger.LogInformation("Account deleted: {AccountId}", evt.AccountId); | ||||||
|  |  | ||||||
|  |                 using var scope = serviceProvider.CreateScope(); | ||||||
|  |                 var fs = scope.ServiceProvider.GetRequiredService<FileService>(); | ||||||
|  |                 var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||||
|  |  | ||||||
|  |                 await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken); | ||||||
|  |                 try | ||||||
|  |                 { | ||||||
|  |                     var files = await db.Files | ||||||
|  |                         .Where(p => p.AccountId == evt.AccountId) | ||||||
|  |                         .ToListAsync(cancellationToken: stoppingToken); | ||||||
|  |  | ||||||
|  |                     await fs.DeleteFileDataBatchAsync(files); | ||||||
|  |                     await db.Files | ||||||
|  |                         .Where(p => p.AccountId == evt.AccountId) | ||||||
|  |                         .ExecuteDeleteAsync(cancellationToken: stoppingToken); | ||||||
|  |  | ||||||
|  |                     await transaction.CommitAsync(cancellationToken: stoppingToken); | ||||||
|  |                 } | ||||||
|  |                 catch (Exception) | ||||||
|  |                 { | ||||||
|  |                     await transaction.RollbackAsync(cancellationToken: stoppingToken); | ||||||
|  |                     throw; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 await msg.AckAsync(cancellationToken: stoppingToken); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 logger.LogError(ex, "Error processing AccountDeleted"); | ||||||
|  |                 await msg.NakAsync(cancellationToken: stoppingToken); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |      private async Task ProcessAndUploadInBackgroundAsync( | ||||||
|  |         string fileId, | ||||||
|  |         Guid remoteId, | ||||||
|  |         string storageId, | ||||||
|  |         string contentType, | ||||||
|  |         string processingFilePath, | ||||||
|  |         bool isTempFile | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         using var scope = serviceProvider.CreateScope(); | ||||||
|  |         var fs = scope.ServiceProvider.GetRequiredService<FileService>(); | ||||||
|  |         var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||||
|  |  | ||||||
|  |         var pool = await fs.GetPoolAsync(remoteId); | ||||||
|  |         if (pool is null) return; | ||||||
|  |  | ||||||
|  |         var uploads = new List<(string FilePath, string Suffix, string ContentType, bool SelfDestruct)>(); | ||||||
|  |         var newMimeType = contentType; | ||||||
|  |         var hasCompression = false; | ||||||
|  |         var hasThumbnail = false; | ||||||
|  |  | ||||||
|  |         logger.LogInformation("Processing file {FileId} in background...", fileId); | ||||||
|  |  | ||||||
|  |         var fileToUpdate = await scopedDb.Files.AsNoTracking().FirstAsync(f => f.Id == fileId); | ||||||
|  |  | ||||||
|  |         if (fileToUpdate.IsEncrypted) | ||||||
|  |         { | ||||||
|  |             uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |         } | ||||||
|  |         else if (!pool.PolicyConfig.NoOptimization) | ||||||
|  |         { | ||||||
|  |             var fileExtension = Path.GetExtension(processingFilePath); | ||||||
|  |             switch (contentType.Split('/')[0]) | ||||||
|  |             { | ||||||
|  |                 case "image": | ||||||
|  |                     if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension)) | ||||||
|  |                     { | ||||||
|  |                         logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId); | ||||||
|  |                         uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |                         break; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     try | ||||||
|  |                     { | ||||||
|  |                         newMimeType = "image/webp"; | ||||||
|  |                         using var vipsImage = Image.NewFromFile(processingFilePath); | ||||||
|  |                         var imageToWrite = vipsImage; | ||||||
|  |  | ||||||
|  |                         if (vipsImage.Interpretation is Enums.Interpretation.Scrgb or Enums.Interpretation.Xyz) | ||||||
|  |                         { | ||||||
|  |                             imageToWrite = vipsImage.Colourspace(Enums.Interpretation.Srgb); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         var webpPath = Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.webp"); | ||||||
|  |                         imageToWrite.Autorot().WriteToFile(webpPath, | ||||||
|  |                             new VOption { { "lossless", true }, { "strip", true } }); | ||||||
|  |                         uploads.Add((webpPath, string.Empty, newMimeType, true)); | ||||||
|  |  | ||||||
|  |                         if (imageToWrite.Width * imageToWrite.Height >= 1024 * 1024) | ||||||
|  |                         { | ||||||
|  |                             var scale = 1024.0 / Math.Max(imageToWrite.Width, imageToWrite.Height); | ||||||
|  |                             var compressedPath = | ||||||
|  |                                 Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.compressed.webp"); | ||||||
|  |                             using var compressedImage = imageToWrite.Resize(scale); | ||||||
|  |                             compressedImage.Autorot().WriteToFile(compressedPath, | ||||||
|  |                                 new VOption { { "Q", 80 }, { "strip", true } }); | ||||||
|  |                             uploads.Add((compressedPath, ".compressed", newMimeType, true)); | ||||||
|  |                             hasCompression = true; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if (!ReferenceEquals(imageToWrite, vipsImage)) | ||||||
|  |                         { | ||||||
|  |                             imageToWrite.Dispose(); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     catch (Exception ex) | ||||||
|  |                     { | ||||||
|  |                         logger.LogError(ex, "Failed to optimize image {FileId}, uploading original", fileId); | ||||||
|  |                         uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |                         newMimeType = contentType; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     break; | ||||||
|  |  | ||||||
|  |                 case "video": | ||||||
|  |                     uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |  | ||||||
|  |                     var thumbnailPath = Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.thumbnail.jpg"); | ||||||
|  |                     try | ||||||
|  |                     { | ||||||
|  |                         await FFMpegArguments | ||||||
|  |                             .FromFileInput(processingFilePath, verifyExists: true) | ||||||
|  |                             .OutputToFile(thumbnailPath, overwrite: true, options => options | ||||||
|  |                                 .Seek(TimeSpan.FromSeconds(0)) | ||||||
|  |                                 .WithFrameOutputCount(1) | ||||||
|  |                                 .WithCustomArgument("-q:v 2") | ||||||
|  |                             ) | ||||||
|  |                             .NotifyOnOutput(line => logger.LogInformation("[FFmpeg] {Line}", line)) | ||||||
|  |                             .NotifyOnError(line => logger.LogWarning("[FFmpeg] {Line}", line)) | ||||||
|  |                             .ProcessAsynchronously(); | ||||||
|  |  | ||||||
|  |                         if (File.Exists(thumbnailPath)) | ||||||
|  |                         { | ||||||
|  |                             uploads.Add((thumbnailPath, ".thumbnail", "image/jpeg", true)); | ||||||
|  |                             hasThumbnail = true; | ||||||
|  |                         } | ||||||
|  |                         else | ||||||
|  |                         { | ||||||
|  |                             logger.LogWarning("FFMpeg did not produce thumbnail for video {FileId}", fileId); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     catch (Exception ex) | ||||||
|  |                     { | ||||||
|  |                         logger.LogError(ex, "Failed to generate thumbnail for video {FileId}", fileId); | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     break; | ||||||
|  |  | ||||||
|  |                 default: | ||||||
|  |                     uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |                     break; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         else | ||||||
|  |         { | ||||||
|  |             uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         logger.LogInformation("Optimized file {FileId}, now uploading...", fileId); | ||||||
|  |  | ||||||
|  |         if (uploads.Count > 0) | ||||||
|  |         { | ||||||
|  |             var destPool = remoteId; | ||||||
|  |             var uploadTasks = uploads.Select(item => | ||||||
|  |                 fs.UploadFileToRemoteAsync( | ||||||
|  |                     storageId, | ||||||
|  |                     destPool, | ||||||
|  |                     item.FilePath, | ||||||
|  |                     item.Suffix, | ||||||
|  |                     item.ContentType, | ||||||
|  |                     item.SelfDestruct | ||||||
|  |                 ) | ||||||
|  |             ).ToList(); | ||||||
|  |  | ||||||
|  |             await Task.WhenAll(uploadTasks); | ||||||
|  |  | ||||||
|  |             logger.LogInformation("Uploaded file {FileId} done!", fileId); | ||||||
|  |  | ||||||
|  |             var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |             await scopedDb.Files.Where(f => f.Id == fileId).ExecuteUpdateAsync(setter => setter | ||||||
|  |                 .SetProperty(f => f.UploadedAt, now) | ||||||
|  |                 .SetProperty(f => f.PoolId, destPool) | ||||||
|  |                 .SetProperty(f => f.MimeType, newMimeType) | ||||||
|  |                 .SetProperty(f => f.HasCompression, hasCompression) | ||||||
|  |                 .SetProperty(f => f.HasThumbnail, hasThumbnail) | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             // Only delete temp file after successful upload and db update | ||||||
|  |             if (isTempFile) | ||||||
|  |                 File.Delete(processingFilePath); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         await fs._PurgeCacheAsync(fileId); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | using DysonNetwork.Drive.Storage; | ||||||
| using Quartz; | using Quartz; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Startup; | namespace DysonNetwork.Drive.Startup; | ||||||
| @@ -14,6 +15,13 @@ public static class ScheduledJobsConfiguration | |||||||
|                 .ForJob(appDatabaseRecyclingJob) |                 .ForJob(appDatabaseRecyclingJob) | ||||||
|                 .WithIdentity("AppDatabaseRecyclingTrigger") |                 .WithIdentity("AppDatabaseRecyclingTrigger") | ||||||
|                 .WithCronSchedule("0 0 0 * * ?")); |                 .WithCronSchedule("0 0 0 * * ?")); | ||||||
|  |              | ||||||
|  |             var cloudFileUnusedRecyclingJob = new JobKey("CloudFileUnusedRecycling"); | ||||||
|  |             q.AddJob<CloudFileUnusedRecyclingJob>(opts => opts.WithIdentity(cloudFileUnusedRecyclingJob)); | ||||||
|  |             q.AddTrigger(opts => opts | ||||||
|  |                 .ForJob(cloudFileUnusedRecyclingJob) | ||||||
|  |                 .WithIdentity("CloudFileUnusedRecyclingTrigger") | ||||||
|  |                 .WithCronSchedule("0 0 0 * * ?")); | ||||||
|         }); |         }); | ||||||
|         services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); |         services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,13 +1,11 @@ | |||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
| using System.Threading.RateLimiting; | using System.Threading.RateLimiting; | ||||||
| using dotnet_etcd.interfaces; |  | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using Microsoft.AspNetCore.RateLimiting; | using Microsoft.AspNetCore.RateLimiting; | ||||||
| using Microsoft.OpenApi.Models; |  | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using NodaTime.Serialization.SystemTextJson; | using NodaTime.Serialization.SystemTextJson; | ||||||
| using StackExchange.Redis; | using tusdotnet.Stores; | ||||||
| using DysonNetwork.Shared.Proto; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Startup; | namespace DysonNetwork.Drive.Startup; | ||||||
|  |  | ||||||
| @@ -16,11 +14,6 @@ public static class ServiceCollectionExtensions | |||||||
|     public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) |     public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) | ||||||
|     { |     { | ||||||
|         services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase |         services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase | ||||||
|         services.AddSingleton<IConnectionMultiplexer>(_ => |  | ||||||
|         { |  | ||||||
|             var connection = configuration.GetConnectionString("FastRetrieve")!; |  | ||||||
|             return ConnectionMultiplexer.Connect(connection); |  | ||||||
|         }); |  | ||||||
|         services.AddSingleton<IClock>(SystemClock.Instance); |         services.AddSingleton<IClock>(SystemClock.Instance); | ||||||
|         services.AddHttpContextAccessor(); |         services.AddHttpContextAccessor(); | ||||||
|         services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis |         services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis | ||||||
| @@ -40,6 +33,7 @@ public static class ServiceCollectionExtensions | |||||||
|  |  | ||||||
|         services.AddControllers().AddJsonOptions(options => |         services.AddControllers().AddJsonOptions(options => | ||||||
|         { |         { | ||||||
|  |             options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; | ||||||
|             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; |             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; |             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|  |  | ||||||
| @@ -64,9 +58,7 @@ public static class ServiceCollectionExtensions | |||||||
|  |  | ||||||
|     public static IServiceCollection AddAppAuthentication(this IServiceCollection services) |     public static IServiceCollection AddAppAuthentication(this IServiceCollection services) | ||||||
|     { |     { | ||||||
|         services.AddCors(); |  | ||||||
|         services.AddAuthorization(); |         services.AddAuthorization(); | ||||||
|  |  | ||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -77,55 +69,26 @@ public static class ServiceCollectionExtensions | |||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public static IServiceCollection AddAppSwagger(this IServiceCollection services) |     public static IServiceCollection AddAppFileStorage(this IServiceCollection services, IConfiguration configuration) | ||||||
|     { |     { | ||||||
|         services.AddEndpointsApiExplorer(); |         var tusStorePath = configuration.GetSection("Tus").GetValue<string>("StorePath")!; | ||||||
|         services.AddSwaggerGen(options => |         Directory.CreateDirectory(tusStorePath); | ||||||
|         { |         var tusDiskStore = new TusDiskStore(tusStorePath); | ||||||
|             options.SwaggerDoc("v1", new OpenApiInfo |  | ||||||
|             { |         services.AddSingleton(tusDiskStore); | ||||||
|                 Version = "v1", |  | ||||||
|                 Title = "Dyson Drive", |  | ||||||
|                 Description = |  | ||||||
|                     "The file service of the Dyson Network. Mainly handling file storage and sharing. Also provide image processing and media analysis. Powered the Solar Network Drive as well.", |  | ||||||
|                 TermsOfService = new Uri("https://solsynth.dev/terms"), // Update with actual terms |  | ||||||
|                 License = new OpenApiLicense |  | ||||||
|                 { |  | ||||||
|                     Name = "APGLv3", // Update with actual license |  | ||||||
|                     Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html") |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
|             options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme |  | ||||||
|             { |  | ||||||
|                 In = ParameterLocation.Header, |  | ||||||
|                 Description = "Please enter a valid token", |  | ||||||
|                 Name = "Authorization", |  | ||||||
|                 Type = SecuritySchemeType.Http, |  | ||||||
|                 BearerFormat = "JWT", |  | ||||||
|                 Scheme = "Bearer" |  | ||||||
|             }); |  | ||||||
|             options.AddSecurityRequirement(new OpenApiSecurityRequirement |  | ||||||
|             { |  | ||||||
|                 { |  | ||||||
|                     new OpenApiSecurityScheme |  | ||||||
|                     { |  | ||||||
|                         Reference = new OpenApiReference |  | ||||||
|                         { |  | ||||||
|                             Type = ReferenceType.SecurityScheme, |  | ||||||
|                             Id = "Bearer" |  | ||||||
|                         } |  | ||||||
|                     }, |  | ||||||
|                     [] |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public static IServiceCollection AddAppBusinessServices(this IServiceCollection services) |     public static IServiceCollection AddAppBusinessServices(this IServiceCollection services) | ||||||
|     { |     { | ||||||
|         // Add your business services here |         services.AddScoped<Storage.FileService>(); | ||||||
|  |         services.AddScoped<Storage.FileReferenceService>(); | ||||||
|  |         services.AddScoped<Billing.UsageService>(); | ||||||
|  |         services.AddScoped<Billing.QuotaService>(); | ||||||
|  |  | ||||||
|  |         services.AddHostedService<BroadcastEventHandler>(); | ||||||
|  |          | ||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
| } | } | ||||||
							
								
								
									
										154
									
								
								DysonNetwork.Drive/Storage/BundleController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								DysonNetwork.Drive/Storage/BundleController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Storage; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/bundles")] | ||||||
|  | public class BundleController(AppDatabase db) : ControllerBase | ||||||
|  | { | ||||||
|  |     public class BundleRequest | ||||||
|  |     { | ||||||
|  |         [MaxLength(1024)] public string? Slug { get; set; } | ||||||
|  |         [MaxLength(1024)] public string? Name { get; set; } | ||||||
|  |         [MaxLength(8192)] public string? Description { get; set; } | ||||||
|  |         [MaxLength(256)] public string? Passcode { get; set; } | ||||||
|  |  | ||||||
|  |         public Instant? ExpiredAt { get; set; } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{id:guid}")] | ||||||
|  |     public async Task<ActionResult<SnFileBundle>> GetBundle([FromRoute] Guid id, [FromQuery] string? passcode) | ||||||
|  |     { | ||||||
|  |         var bundle = await db.Bundles | ||||||
|  |             .Where(e => e.Id == id) | ||||||
|  |             .Include(e => e.Files) | ||||||
|  |             .FirstOrDefaultAsync(); | ||||||
|  |         if (bundle is null) return NotFound(); | ||||||
|  |         if (!bundle.VerifyPasscode(passcode)) return Forbid(); | ||||||
|  |  | ||||||
|  |         return Ok(bundle); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("me")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<List<SnFileBundle>>> ListBundles( | ||||||
|  |         [FromQuery] string? term, | ||||||
|  |         [FromQuery] int offset = 0, | ||||||
|  |         [FromQuery] int take = 20 | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var query = db.Bundles | ||||||
|  |             .Where(e => e.AccountId == accountId) | ||||||
|  |             .OrderByDescending(e => e.CreatedAt) | ||||||
|  |             .AsQueryable(); | ||||||
|  |         if (!string.IsNullOrEmpty(term)) | ||||||
|  |             query = query.Where(e => EF.Functions.ILike(e.Name, $"%{term}%")); | ||||||
|  |  | ||||||
|  |         var total = await query.CountAsync(); | ||||||
|  |         Response.Headers.Append("X-Total", total.ToString()); | ||||||
|  |  | ||||||
|  |         var bundles = await query | ||||||
|  |             .Skip(offset) | ||||||
|  |             .Take(take) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|  |         return Ok(bundles); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<SnFileBundle>> CreateBundle([FromBody] BundleRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         if (currentUser.PerkSubscription is null && !string.IsNullOrEmpty(request.Slug)) | ||||||
|  |             return StatusCode(403, "You must have a subscription to create a bundle with a custom slug"); | ||||||
|  |         if (string.IsNullOrEmpty(request.Slug)) | ||||||
|  |             request.Slug = Guid.NewGuid().ToString("N")[..6]; | ||||||
|  |         if (string.IsNullOrEmpty(request.Name)) | ||||||
|  |             request.Name = "Unnamed Bundle"; | ||||||
|  |  | ||||||
|  |         var bundle = new SnFileBundle | ||||||
|  |         { | ||||||
|  |             Slug = request.Slug, | ||||||
|  |             Name = request.Name, | ||||||
|  |             Description = request.Description, | ||||||
|  |             Passcode = request.Passcode, | ||||||
|  |             ExpiredAt = request.ExpiredAt, | ||||||
|  |             AccountId = accountId | ||||||
|  |         }.HashPasscode(); | ||||||
|  |  | ||||||
|  |         db.Bundles.Add(bundle); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         return Ok(bundle); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPut("{id:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<SnFileBundle>> UpdateBundle([FromRoute] Guid id, [FromBody] BundleRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var bundle = await db.Bundles | ||||||
|  |             .Where(e => e.Id == id) | ||||||
|  |             .Where(e => e.AccountId == accountId) | ||||||
|  |             .FirstOrDefaultAsync(); | ||||||
|  |         if (bundle is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         if (request.Slug != null && request.Slug != bundle.Slug) | ||||||
|  |         { | ||||||
|  |             if (currentUser.PerkSubscription is null) | ||||||
|  |                 return StatusCode(403, "You must have a subscription to change the slug of a bundle"); | ||||||
|  |             bundle.Slug = request.Slug; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (request.Name != null) bundle.Name = request.Name; | ||||||
|  |         if (request.Description != null) bundle.Description = request.Description; | ||||||
|  |         if (request.ExpiredAt != null) bundle.ExpiredAt = request.ExpiredAt; | ||||||
|  |  | ||||||
|  |         if (request.Passcode != null) | ||||||
|  |         { | ||||||
|  |             bundle.Passcode = request.Passcode; | ||||||
|  |             bundle = bundle.HashPasscode(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         return Ok(bundle); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpDelete("{id:guid}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult> DeleteBundle([FromRoute] Guid id) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var bundle = await db.Bundles | ||||||
|  |             .Where(e => e.Id == id) | ||||||
|  |             .Where(e => e.AccountId == accountId) | ||||||
|  |             .FirstOrDefaultAsync(); | ||||||
|  |         if (bundle is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         db.Bundles.Remove(bundle); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         await db.Files | ||||||
|  |             .Where(e => e.BundleId == id) | ||||||
|  |             .ExecuteUpdateAsync(s => s.SetProperty(e => e.IsMarkedRecycle, true)); | ||||||
|  |  | ||||||
|  |         return NoContent(); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -7,19 +7,42 @@ namespace DysonNetwork.Drive.Storage; | |||||||
| public class CloudFileUnusedRecyclingJob( | public class CloudFileUnusedRecyclingJob( | ||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|     FileReferenceService fileRefService, |     FileReferenceService fileRefService, | ||||||
|     ILogger<CloudFileUnusedRecyclingJob> logger |     ILogger<CloudFileUnusedRecyclingJob> logger, | ||||||
|  |     IConfiguration configuration | ||||||
| ) | ) | ||||||
|     : IJob |     : IJob | ||||||
| { | { | ||||||
|     public async Task Execute(IJobExecutionContext context) |     public async Task Execute(IJobExecutionContext context) | ||||||
|     { |     { | ||||||
|  |         logger.LogInformation("Cleaning tus cloud files..."); | ||||||
|  |         var storePath = configuration["Tus:StorePath"]; | ||||||
|  |         if (Directory.Exists(storePath)) | ||||||
|  |         { | ||||||
|  |             var oneHourAgo = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1); | ||||||
|  |             var files = Directory.GetFiles(storePath); | ||||||
|  |             foreach (var file in files) | ||||||
|  |             { | ||||||
|  |                 var creationTime = File.GetCreationTime(file).ToUniversalTime(); | ||||||
|  |                 if (creationTime < oneHourAgo.ToDateTimeUtc()) | ||||||
|  |                     File.Delete(file); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|         logger.LogInformation("Marking unused cloud files..."); |         logger.LogInformation("Marking unused cloud files..."); | ||||||
|  |  | ||||||
|  |         var recyclablePools = await db.Pools | ||||||
|  |             .Where(p => p.PolicyConfig.EnableRecycle) | ||||||
|  |             .Select(p => p.Id) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|         const int batchSize = 1000; // Process larger batches for efficiency |         const int batchSize = 1000; // Process larger batches for efficiency | ||||||
|         var processedCount = 0; |         var processedCount = 0; | ||||||
|         var markedCount = 0; |         var markedCount = 0; | ||||||
|         var totalFiles = await db.Files.Where(f => !f.IsMarkedRecycle).CountAsync(); |         var totalFiles = await db.Files | ||||||
|  |             .Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value)) | ||||||
|  |             .Where(f => !f.IsMarkedRecycle) | ||||||
|  |             .CountAsync(); | ||||||
|  |  | ||||||
|         logger.LogInformation("Found {TotalFiles} files to check for unused status", totalFiles); |         logger.LogInformation("Found {TotalFiles} files to check for unused status", totalFiles); | ||||||
|  |  | ||||||
| @@ -35,13 +58,12 @@ public class CloudFileUnusedRecyclingJob( | |||||||
|         { |         { | ||||||
|             // Query for the next batch of files using keyset pagination |             // Query for the next batch of files using keyset pagination | ||||||
|             var filesQuery = db.Files |             var filesQuery = db.Files | ||||||
|  |                 .Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value)) | ||||||
|                 .Where(f => !f.IsMarkedRecycle) |                 .Where(f => !f.IsMarkedRecycle) | ||||||
|                 .Where(f => f.CreatedAt <= ageThreshold); // Only process older files first |                 .Where(f => f.CreatedAt <= ageThreshold); // Only process older files first | ||||||
|  |  | ||||||
|             if (lastProcessedId != null) |             if (lastProcessedId != null) | ||||||
|             { |  | ||||||
|                 filesQuery = filesQuery.Where(f => string.Compare(f.Id, lastProcessedId) > 0); |                 filesQuery = filesQuery.Where(f => string.Compare(f.Id, lastProcessedId) > 0); | ||||||
|             } |  | ||||||
|  |  | ||||||
|             var fileBatch = await filesQuery |             var fileBatch = await filesQuery | ||||||
|                 .OrderBy(f => f.Id) // Ensure consistent ordering for pagination |                 .OrderBy(f => f.Id) // Ensure consistent ordering for pagination | ||||||
| @@ -84,9 +106,17 @@ public class CloudFileUnusedRecyclingJob( | |||||||
|             { |             { | ||||||
|                 logger.LogInformation( |                 logger.LogInformation( | ||||||
|                     "Progress: processed {ProcessedCount}/{TotalFiles} files, marked {MarkedCount} for recycling", |                     "Progress: processed {ProcessedCount}/{TotalFiles} files, marked {MarkedCount} for recycling", | ||||||
|                     processedCount, totalFiles, markedCount); |                     processedCount, | ||||||
|  |                     totalFiles, | ||||||
|  |                     markedCount | ||||||
|  |                 ); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |          | ||||||
|  |         var expiredCount = await db.Files | ||||||
|  |             .Where(f => f.ExpiredAt.HasValue && f.ExpiredAt.Value <= now) | ||||||
|  |             .ExecuteUpdateAsync(s => s.SetProperty(f => f.IsMarkedRecycle, true)); | ||||||
|  |         markedCount += expiredCount; | ||||||
|  |  | ||||||
|         logger.LogInformation("Completed marking {MarkedCount} files for recycling", markedCount); |         logger.LogInformation("Completed marking {MarkedCount} files for recycling", markedCount); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,3 +1,6 @@ | |||||||
|  | using DysonNetwork.Drive.Billing; | ||||||
|  | using DysonNetwork.Shared.Auth; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| @@ -11,6 +14,7 @@ namespace DysonNetwork.Drive.Storage; | |||||||
| public class FileController( | public class FileController( | ||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|     FileService fs, |     FileService fs, | ||||||
|  |     QuotaService qs, | ||||||
|     IConfiguration configuration, |     IConfiguration configuration, | ||||||
|     IWebHostEnvironment env |     IWebHostEnvironment env | ||||||
| ) : ControllerBase | ) : ControllerBase | ||||||
| @@ -20,7 +24,9 @@ public class FileController( | |||||||
|         string id, |         string id, | ||||||
|         [FromQuery] bool download = false, |         [FromQuery] bool download = false, | ||||||
|         [FromQuery] bool original = false, |         [FromQuery] bool original = false, | ||||||
|         [FromQuery] string? overrideMimeType = null |         [FromQuery] bool thumbnail = false, | ||||||
|  |         [FromQuery] string? overrideMimeType = null, | ||||||
|  |         [FromQuery] string? passcode = null | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         // Support the file extension for client side data recognize |         // Support the file extension for client side data recognize | ||||||
| @@ -33,21 +39,64 @@ public class FileController( | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         var file = await fs.GetFileAsync(id); |         var file = await fs.GetFileAsync(id); | ||||||
|         if (file is null) return NotFound(); |         if (file is null) return NotFound("File not found."); | ||||||
|  |  | ||||||
|  |         if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode)) | ||||||
|  |             return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect."); | ||||||
|  |  | ||||||
|         if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl); |         if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl); | ||||||
|  |  | ||||||
|         if (file.UploadedTo is null) |         if (file.UploadedAt is null) | ||||||
|         { |         { | ||||||
|             var tusStorePath = configuration.GetValue<string>("Tus:StorePath")!; |             // File is not yet uploaded to remote storage. Try to serve from local temp storage. | ||||||
|             var filePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id); |             var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id); | ||||||
|             if (!System.IO.File.Exists(filePath)) return new NotFoundResult(); |             if (System.IO.File.Exists(tempFilePath)) | ||||||
|             return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name); |             { | ||||||
|  |                 if (file.IsEncrypted) | ||||||
|  |                 { | ||||||
|  |                     return StatusCode(StatusCodes.Status403Forbidden, "Encrypted files cannot be accessed before they are processed and stored."); | ||||||
|  |                 } | ||||||
|  |                 return PhysicalFile(tempFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true); | ||||||
|  |             } | ||||||
|  |          | ||||||
|  |             // Fallback for tus uploads that are not processed yet. | ||||||
|  |             var tusStorePath = configuration.GetValue<string>("Tus:StorePath"); | ||||||
|  |             if (!string.IsNullOrEmpty(tusStorePath)) | ||||||
|  |             { | ||||||
|  |                 var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id); | ||||||
|  |                 if (System.IO.File.Exists(tusFilePath)) | ||||||
|  |                 { | ||||||
|  |                     return PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return StatusCode(StatusCodes.Status400BadRequest, "File is being processed. Please try again later."); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var dest = fs.GetRemoteStorageConfig(file.UploadedTo); |         if (!file.PoolId.HasValue) | ||||||
|  |             return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID."); | ||||||
|  |  | ||||||
|  |         var pool = await fs.GetPoolAsync(file.PoolId.Value); | ||||||
|  |         if (pool is null) | ||||||
|  |             return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible."); | ||||||
|  |         var dest = pool.StorageConfig; | ||||||
|  |  | ||||||
|  |         if (!pool.PolicyConfig.AllowAnonymous) | ||||||
|  |             if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |                 return Unauthorized(); | ||||||
|  |         // TODO: Provide ability to add access log | ||||||
|  |  | ||||||
|         var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId; |         var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId; | ||||||
|  |  | ||||||
|  |         switch (thumbnail) | ||||||
|  |         { | ||||||
|  |             case true when file.HasThumbnail: | ||||||
|  |                 fileName += ".thumbnail"; | ||||||
|  |                 break; | ||||||
|  |             case true when !file.HasThumbnail: | ||||||
|  |                 return NotFound(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         if (!original && file.HasCompression) |         if (!original && file.HasCompression) | ||||||
|             fileName += ".compressed"; |             fileName += ".compressed"; | ||||||
|  |  | ||||||
| @@ -72,7 +121,8 @@ public class FileController( | |||||||
|             var client = fs.CreateMinioClient(dest); |             var client = fs.CreateMinioClient(dest); | ||||||
|             if (client is null) |             if (client is null) | ||||||
|                 return BadRequest( |                 return BadRequest( | ||||||
|                     "Failed to configure client for remote destination, file got an invalid storage remote."); |                     "Failed to configure client for remote destination, file got an invalid storage remote." | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|             var headers = new Dictionary<string, string>(); |             var headers = new Dictionary<string, string>(); | ||||||
|             if (fileExtension is not null) |             if (fileExtension is not null) | ||||||
| @@ -113,14 +163,93 @@ public class FileController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("{id}/info")] |     [HttpGet("{id}/info")] | ||||||
|     public async Task<ActionResult<CloudFile>> GetFileInfo(string id) |     public async Task<ActionResult<SnCloudFile>> GetFileInfo(string id) | ||||||
|     { |     { | ||||||
|         var file = await db.Files.FindAsync(id); |         var file = await fs.GetFileAsync(id); | ||||||
|         if (file is null) return NotFound(); |         if (file is null) return NotFound("File not found."); | ||||||
|  |  | ||||||
|         return file; |         return file; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     [Authorize] | ||||||
|  |     [HttpPatch("{id}/name")] | ||||||
|  |     public async Task<ActionResult<SnCloudFile>> UpdateFileName(string id, [FromBody] string name) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |         var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId); | ||||||
|  |         if (file is null) return NotFound(); | ||||||
|  |         file.Name = name; | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |         await fs._PurgeCacheAsync(file.Id); | ||||||
|  |         return file; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public class MarkFileRequest | ||||||
|  |     { | ||||||
|  |         public List<ContentSensitiveMark>? SensitiveMarks { get; set; } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Authorize] | ||||||
|  |     [HttpPut("{id}/marks")] | ||||||
|  |     public async Task<ActionResult<SnCloudFile>> MarkFile(string id, [FromBody] MarkFileRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |         var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId); | ||||||
|  |         if (file is null) return NotFound(); | ||||||
|  |         file.SensitiveMarks = request.SensitiveMarks; | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |         await fs._PurgeCacheAsync(file.Id); | ||||||
|  |         return file; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Authorize] | ||||||
|  |     [HttpPut("{id}/meta")] | ||||||
|  |     public async Task<ActionResult<SnCloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |         var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId); | ||||||
|  |         if (file is null) return NotFound(); | ||||||
|  |         file.UserMeta = meta; | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |         await fs._PurgeCacheAsync(file.Id); | ||||||
|  |         return file; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Authorize] | ||||||
|  |     [HttpGet("me")] | ||||||
|  |     public async Task<ActionResult<List<SnCloudFile>>> GetMyFiles( | ||||||
|  |         [FromQuery] Guid? pool, | ||||||
|  |         [FromQuery] bool recycled = false, | ||||||
|  |         [FromQuery] int offset = 0, | ||||||
|  |         [FromQuery] int take = 20 | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var query = db.Files | ||||||
|  |             .Where(e => e.IsMarkedRecycle == recycled) | ||||||
|  |             .Where(e => e.AccountId == accountId) | ||||||
|  |             .Include(e => e.Pool) | ||||||
|  |             .OrderByDescending(e => e.CreatedAt) | ||||||
|  |             .AsQueryable(); | ||||||
|  |  | ||||||
|  |         if (pool.HasValue) query = query.Where(e => e.PoolId == pool); | ||||||
|  |  | ||||||
|  |         var total = await query.CountAsync(); | ||||||
|  |         Response.Headers.Append("X-Total", total.ToString()); | ||||||
|  |  | ||||||
|  |         var files = await query | ||||||
|  |             .Skip(offset) | ||||||
|  |             .Take(take) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|  |         return Ok(files); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     [HttpDelete("{id}")] |     [HttpDelete("{id}")] | ||||||
|     public async Task<ActionResult> DeleteFile(string id) |     public async Task<ActionResult> DeleteFile(string id) | ||||||
| @@ -134,11 +263,135 @@ public class FileController( | |||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
|         if (file is null) return NotFound(); |         if (file is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         await fs.DeleteFileDataAsync(file, force: true); | ||||||
|         await fs.DeleteFileAsync(file); |         await fs.DeleteFileAsync(file); | ||||||
|  |  | ||||||
|         db.Files.Remove(file); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return NoContent(); |         return NoContent(); | ||||||
|     } |     } | ||||||
| } |  | ||||||
|  |     [Authorize] | ||||||
|  |     [HttpDelete("me/recycle")] | ||||||
|  |     public async Task<ActionResult> DeleteMyRecycledFiles() | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var count = await fs.DeleteAccountRecycledFilesAsync(accountId); | ||||||
|  |         return Ok(new { Count = count }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Authorize] | ||||||
|  |     [HttpDelete("recycle")] | ||||||
|  |     [RequiredPermission("maintenance", "files.delete.recycle")] | ||||||
|  |     public async Task<ActionResult> DeleteAllRecycledFiles() | ||||||
|  |     { | ||||||
|  |         var count = await fs.DeleteAllRecycledFilesAsync(); | ||||||
|  |         return Ok(new { Count = count }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public class CreateFastFileRequest | ||||||
|  |     { | ||||||
|  |         public string Name { get; set; } = null!; | ||||||
|  |         public long Size { get; set; } | ||||||
|  |         public string Hash { get; set; } = null!; | ||||||
|  |         public string? MimeType { get; set; } | ||||||
|  |         public string? Description { get; set; } | ||||||
|  |         public Dictionary<string, object?>? UserMeta { get; set; } | ||||||
|  |         public Dictionary<string, object?>? FileMeta { get; set; } | ||||||
|  |         public List<ContentSensitiveMark>? SensitiveMarks { get; set; } | ||||||
|  |         public Guid PoolId { get; set; } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Authorize] | ||||||
|  |     [HttpPost("fast")] | ||||||
|  |     [RequiredPermission("global", "files.create")] | ||||||
|  |     public async Task<ActionResult<SnCloudFile>> CreateFastFile([FromBody] CreateFastFileRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var pool = await db.Pools.FirstOrDefaultAsync(p => p.Id == request.PoolId); | ||||||
|  |         if (pool is null) return BadRequest(); | ||||||
|  |         if (!currentUser.IsSuperuser && pool.AccountId != accountId) | ||||||
|  |             return StatusCode(403, "You don't have permission to create files in this pool."); | ||||||
|  |  | ||||||
|  |         if (!pool.PolicyConfig.EnableFastUpload) | ||||||
|  |             return StatusCode( | ||||||
|  |                 403, | ||||||
|  |                 "This pool does not allow fast upload" | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |         if (pool.PolicyConfig.RequirePrivilege > 0) | ||||||
|  |         { | ||||||
|  |             if (currentUser.PerkSubscription is null) | ||||||
|  |             { | ||||||
|  |                 return StatusCode( | ||||||
|  |                     403, | ||||||
|  |                     $"You need to have join the Stellar Program to use this pool" | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var privilege = | ||||||
|  |                 PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier); | ||||||
|  |             if (privilege < pool.PolicyConfig.RequirePrivilege) | ||||||
|  |             { | ||||||
|  |                 return StatusCode( | ||||||
|  |                     403, | ||||||
|  |                     $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}" | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (request.Size > pool.PolicyConfig.MaxFileSize) | ||||||
|  |         { | ||||||
|  |             return StatusCode( | ||||||
|  |                 403, | ||||||
|  |                 $"File size {request.Size} is larger than the pool's maximum file size {pool.PolicyConfig.MaxFileSize}" | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var (ok, billableUnit, quota) = await qs.IsFileAcceptable( | ||||||
|  |             accountId, | ||||||
|  |             pool.BillingConfig.CostMultiplier ?? 1.0, | ||||||
|  |             request.Size | ||||||
|  |         ); | ||||||
|  |         if (!ok) | ||||||
|  |         { | ||||||
|  |             return StatusCode( | ||||||
|  |                 403, | ||||||
|  |                 $"File size {billableUnit} is larger than the user's quota {quota}" | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         await using var transaction = await db.Database.BeginTransactionAsync(); | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var file = new SnCloudFile | ||||||
|  |             { | ||||||
|  |                 Name = request.Name, | ||||||
|  |                 Size = request.Size, | ||||||
|  |                 Hash = request.Hash, | ||||||
|  |                 MimeType = request.MimeType, | ||||||
|  |                 Description = request.Description, | ||||||
|  |                 AccountId = accountId, | ||||||
|  |                 UserMeta = request.UserMeta, | ||||||
|  |                 FileMeta = request.FileMeta, | ||||||
|  |                 SensitiveMarks = request.SensitiveMarks, | ||||||
|  |                 PoolId = request.PoolId | ||||||
|  |             }; | ||||||
|  |             db.Files.Add(file); | ||||||
|  |             await db.SaveChangesAsync(); | ||||||
|  |             await fs._PurgeCacheAsync(file.Id); | ||||||
|  |             await transaction.CommitAsync(); | ||||||
|  |  | ||||||
|  |             file.FastUploadLink = await fs.CreateFastUploadLinkAsync(file); | ||||||
|  |  | ||||||
|  |             return file; | ||||||
|  |         } | ||||||
|  |         catch (Exception) | ||||||
|  |         { | ||||||
|  |             await transaction.RollbackAsync(); | ||||||
|  |             throw; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										60
									
								
								DysonNetwork.Drive/Storage/FileEncryptor.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								DysonNetwork.Drive/Storage/FileEncryptor.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | |||||||
|  | using System.Security.Cryptography; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Storage; | ||||||
|  |  | ||||||
|  | public static class FileEncryptor | ||||||
|  | { | ||||||
|  |     public static void EncryptFile(string inputPath, string outputPath, string password) | ||||||
|  |     { | ||||||
|  |         var salt = RandomNumberGenerator.GetBytes(16); | ||||||
|  |         var key = DeriveKey(password, salt, 32); | ||||||
|  |         var nonce = RandomNumberGenerator.GetBytes(12); // For AES-GCM | ||||||
|  |  | ||||||
|  |         using var aes = new AesGcm(key, 16); // Specify 16-byte tag size explicitly | ||||||
|  |         var plaintext = File.ReadAllBytes(inputPath); | ||||||
|  |         var magic = "DYSON1"u8.ToArray(); | ||||||
|  |         var contentWithMagic = new byte[magic.Length + plaintext.Length]; | ||||||
|  |         Buffer.BlockCopy(magic, 0, contentWithMagic, 0, magic.Length); | ||||||
|  |         Buffer.BlockCopy(plaintext, 0, contentWithMagic, magic.Length, plaintext.Length); | ||||||
|  |  | ||||||
|  |         var ciphertext = new byte[contentWithMagic.Length]; | ||||||
|  |         var tag = new byte[16]; | ||||||
|  |         aes.Encrypt(nonce, contentWithMagic, ciphertext, tag); | ||||||
|  |  | ||||||
|  |         // Save as: [salt (16)][nonce (12)][tag (16)][ciphertext] | ||||||
|  |         using var fs = new FileStream(outputPath, FileMode.Create, FileAccess.Write); | ||||||
|  |         fs.Write(salt); | ||||||
|  |         fs.Write(nonce); | ||||||
|  |         fs.Write(tag); | ||||||
|  |         fs.Write(ciphertext); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static void DecryptFile(string inputPath, string outputPath, string password) | ||||||
|  |     { | ||||||
|  |         var input = File.ReadAllBytes(inputPath); | ||||||
|  |  | ||||||
|  |         var salt = input[..16]; | ||||||
|  |         var nonce = input[16..28]; | ||||||
|  |         var tag = input[28..44]; | ||||||
|  |         var ciphertext = input[44..]; | ||||||
|  |  | ||||||
|  |         var key = DeriveKey(password, salt, 32); | ||||||
|  |         var decrypted = new byte[ciphertext.Length]; | ||||||
|  |  | ||||||
|  |         using var aes = new AesGcm(key, 16); // Specify 16-byte tag size explicitly | ||||||
|  |         aes.Decrypt(nonce, ciphertext, tag, decrypted); | ||||||
|  |  | ||||||
|  |         var magic = "DYSON1"u8.ToArray(); | ||||||
|  |         if (magic.Where((t, i) => decrypted[i] != t).Any()) | ||||||
|  |             throw new CryptographicException("Incorrect password or corrupted file."); | ||||||
|  |  | ||||||
|  |         var plaintext = decrypted[magic.Length..]; | ||||||
|  |         File.WriteAllBytes(outputPath, plaintext); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static byte[] DeriveKey(string password, byte[] salt, int keyBytes) | ||||||
|  |     { | ||||||
|  |         using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, 100_000, HashAlgorithmName.SHA256); | ||||||
|  |         return pbkdf2.GetBytes(keyBytes); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -48,11 +48,9 @@ public class FileExpirationJob(AppDatabase db, FileService fileService, ILogger< | |||||||
|             if (remainingReferences == 0) |             if (remainingReferences == 0) | ||||||
|             { |             { | ||||||
|                 var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId); |                 var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId); | ||||||
|                 if (file != null) |                 if (file == null) continue; | ||||||
|                 { |                 logger.LogInformation("Deleting file {fileId} as all references have expired", fileId); | ||||||
|                     logger.LogInformation("Deleting file {fileId} as all references have expired", fileId); |                 await fileService.DeleteFileAsync(file); | ||||||
|                     await fileService.DeleteFileAsync(file); |  | ||||||
|                 } |  | ||||||
|             } |             } | ||||||
|             else |             else | ||||||
|             { |             { | ||||||
|   | |||||||
							
								
								
									
										49
									
								
								DysonNetwork.Drive/Storage/FilePoolController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								DysonNetwork.Drive/Storage/FilePoolController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Storage; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/pools")] | ||||||
|  | public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase | ||||||
|  | { | ||||||
|  |     [HttpGet] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<List<FilePool>>> ListUsablePools() | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |         var pools = await db.Pools | ||||||
|  |             .Where(p => p.PolicyConfig.PublicUsable || p.AccountId == accountId) | ||||||
|  |             .Where(p => !p.IsHidden || p.AccountId == accountId) | ||||||
|  |             .OrderBy(p => p.CreatedAt) | ||||||
|  |             .ToListAsync(); | ||||||
|  |         pools = pools.Select(p => | ||||||
|  |         { | ||||||
|  |             p.StorageConfig.SecretId = string.Empty; | ||||||
|  |             p.StorageConfig.SecretKey = string.Empty; | ||||||
|  |             return p; | ||||||
|  |         }).ToList(); | ||||||
|  |  | ||||||
|  |         return Ok(pools); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Authorize] | ||||||
|  |     [HttpDelete("{id:guid}/recycle")] | ||||||
|  |     public async Task<ActionResult> DeleteFilePoolRecycledFiles(Guid id) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var pool = await fs.GetPoolAsync(id); | ||||||
|  |         if (pool is null) return NotFound(); | ||||||
|  |         if (!currentUser.IsSuperuser && pool.AccountId != accountId) return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var count = await fs.DeletePoolRecycledFilesAsync(id); | ||||||
|  |         return Ok(new { Count = count }); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,4 +1,6 @@ | |||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using EFCore.BulkExtensions; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
| @@ -6,7 +8,7 @@ namespace DysonNetwork.Drive.Storage; | |||||||
|  |  | ||||||
| public class FileReferenceService(AppDatabase db, FileService fileService, ICacheService cache) | public class FileReferenceService(AppDatabase db, FileService fileService, ICacheService cache) | ||||||
| { | { | ||||||
|     private const string CacheKeyPrefix = "fileref:"; |     private const string CacheKeyPrefix = "file:ref:"; | ||||||
|     private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); |     private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
| @@ -19,11 +21,12 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | |||||||
|     /// <param name="duration">Optional duration after which the file expires (alternative to expiredAt)</param> |     /// <param name="duration">Optional duration after which the file expires (alternative to expiredAt)</param> | ||||||
|     /// <returns>The created file reference</returns> |     /// <returns>The created file reference</returns> | ||||||
|     public async Task<CloudFileReference> CreateReferenceAsync( |     public async Task<CloudFileReference> CreateReferenceAsync( | ||||||
|         string fileId,  |         string fileId, | ||||||
|         string usage,  |         string usage, | ||||||
|         string resourceId,  |         string resourceId, | ||||||
|         Instant? expiredAt = null,  |         Instant? expiredAt = null, | ||||||
|         Duration? duration = null) |         Duration? duration = null | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         // Calculate expiration time if needed |         // Calculate expiration time if needed | ||||||
|         var finalExpiration = expiredAt; |         var finalExpiration = expiredAt; | ||||||
| @@ -46,6 +49,25 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | |||||||
|         return reference; |         return reference; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public async Task<List<CloudFileReference>> CreateReferencesAsync( | ||||||
|  |         List<string> fileId, | ||||||
|  |         string usage, | ||||||
|  |         string resourceId, | ||||||
|  |         Instant? expiredAt = null, | ||||||
|  |         Duration? duration = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var data = fileId.Select(id => new CloudFileReference | ||||||
|  |         { | ||||||
|  |             FileId = id, | ||||||
|  |             Usage = usage, | ||||||
|  |             ResourceId = resourceId, | ||||||
|  |             ExpiredAt = expiredAt ?? SystemClock.Instance.GetCurrentInstant() + duration | ||||||
|  |         }).ToList(); | ||||||
|  |         await db.BulkInsertAsync(data); | ||||||
|  |         return data; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Gets all references to a file |     /// Gets all references to a file | ||||||
|     /// </summary> |     /// </summary> | ||||||
| @@ -169,10 +191,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | |||||||
|             .Where(r => r.ResourceId == resourceId && r.Usage == usage) |             .Where(r => r.ResourceId == resourceId && r.Usage == usage) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|  |  | ||||||
|         if (!references.Any()) |         if (references.Count == 0) | ||||||
|         { |  | ||||||
|             return 0; |             return 0; | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var fileIds = references.Select(r => r.FileId).Distinct().ToList(); |         var fileIds = references.Select(r => r.FileId).Distinct().ToList(); | ||||||
|  |  | ||||||
| @@ -186,6 +206,28 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | |||||||
|  |  | ||||||
|         return deletedCount; |         return deletedCount; | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     public async Task<int> DeleteResourceReferencesBatchAsync(IEnumerable<string> resourceIds, string? usage = null) | ||||||
|  |     { | ||||||
|  |         var references = await db.FileReferences | ||||||
|  |             .Where(r => resourceIds.Contains(r.ResourceId)) | ||||||
|  |             .If(usage != null, q => q.Where(q => q.Usage == usage)) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|  |         if (references.Count == 0) | ||||||
|  |             return 0; | ||||||
|  |  | ||||||
|  |         var fileIds = references.Select(r => r.FileId).Distinct().ToList(); | ||||||
|  |  | ||||||
|  |         db.FileReferences.RemoveRange(references); | ||||||
|  |         var deletedCount = await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         // Purge caches | ||||||
|  |         var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList(); | ||||||
|  |         await Task.WhenAll(tasks); | ||||||
|  |  | ||||||
|  |         return deletedCount; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Deletes a specific file reference |     /// Deletes a specific file reference | ||||||
| @@ -274,8 +316,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | |||||||
|  |  | ||||||
|             // Update newly added references with the expiration time |             // Update newly added references with the expiration time | ||||||
|             var referenceIds = await db.FileReferences |             var referenceIds = await db.FileReferences | ||||||
|                 .Where(r => toAdd.Select(a => a.FileId).Contains(r.FileId) &&  |                 .Where(r => toAdd.Select(a => a.FileId).Contains(r.FileId) && | ||||||
|                             r.ResourceId == resourceId &&  |                             r.ResourceId == resourceId && | ||||||
|                             r.Usage == usage) |                             r.Usage == usage) | ||||||
|                 .Select(r => r.Id) |                 .Select(r => r.Id) | ||||||
|                 .ToListAsync(); |                 .ToListAsync(); | ||||||
| @@ -306,7 +348,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | |||||||
|     /// <param name="resourceId">The ID of the resource</param> |     /// <param name="resourceId">The ID of the resource</param> | ||||||
|     /// <param name="usage">Optional filter by usage context</param> |     /// <param name="usage">Optional filter by usage context</param> | ||||||
|     /// <returns>A list of files referenced by the resource</returns> |     /// <returns>A list of files referenced by the resource</returns> | ||||||
|     public async Task<List<CloudFile>> GetResourceFilesAsync(string resourceId, string? usage = null) |     public async Task<List<SnCloudFile>> GetResourceFilesAsync(string resourceId, string? usage = null) | ||||||
|     { |     { | ||||||
|         var query = db.FileReferences.Where(r => r.ResourceId == resourceId); |         var query = db.FileReferences.Where(r => r.ResourceId == resourceId); | ||||||
|  |  | ||||||
| @@ -431,4 +473,4 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | |||||||
|  |  | ||||||
|         return await SetReferenceExpirationAsync(referenceId, expiredAt); |         return await SetReferenceExpirationAsync(referenceId, expiredAt); | ||||||
|     } |     } | ||||||
| } | } | ||||||
							
								
								
									
										174
									
								
								DysonNetwork.Drive/Storage/FileReferenceServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								DysonNetwork.Drive/Storage/FileReferenceServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,174 @@ | |||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Grpc.Core; | ||||||
|  | using NodaTime; | ||||||
|  | using Duration = NodaTime.Duration; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Storage; | ||||||
|  |  | ||||||
|  | public class FileReferenceServiceGrpc(FileReferenceService fileReferenceService) | ||||||
|  |     : Shared.Proto.FileReferenceService.FileReferenceServiceBase | ||||||
|  | { | ||||||
|  |     public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         Instant? expiredAt = null; | ||||||
|  |         if (request.ExpiredAt != null) | ||||||
|  |             expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds); | ||||||
|  |         else if (request.Duration != null) | ||||||
|  |             expiredAt = SystemClock.Instance.GetCurrentInstant() + | ||||||
|  |                         Duration.FromTimeSpan(request.Duration.ToTimeSpan()); | ||||||
|  |  | ||||||
|  |         var reference = await fileReferenceService.CreateReferenceAsync( | ||||||
|  |             request.FileId, | ||||||
|  |             request.Usage, | ||||||
|  |             request.ResourceId, | ||||||
|  |             expiredAt | ||||||
|  |         ); | ||||||
|  |         return reference.ToProtoValue(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<CreateReferenceBatchResponse> CreateReferenceBatch(CreateReferenceBatchRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         Instant? expiredAt = null; | ||||||
|  |         if (request.ExpiredAt != null) | ||||||
|  |             expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds); | ||||||
|  |         else if (request.Duration != null) | ||||||
|  |             expiredAt = SystemClock.Instance.GetCurrentInstant() + | ||||||
|  |                         Duration.FromTimeSpan(request.Duration.ToTimeSpan()); | ||||||
|  |  | ||||||
|  |         var references = await fileReferenceService.CreateReferencesAsync( | ||||||
|  |             request.FilesId.ToList(), | ||||||
|  |             request.Usage, | ||||||
|  |             request.ResourceId, | ||||||
|  |             expiredAt | ||||||
|  |         ); | ||||||
|  |         var response = new CreateReferenceBatchResponse(); | ||||||
|  |         response.References.AddRange(references.Select(r => r.ToProtoValue())); | ||||||
|  |         return response; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var references = await fileReferenceService.GetReferencesAsync(request.FileId); | ||||||
|  |         var response = new GetReferencesResponse(); | ||||||
|  |         response.References.AddRange(references.Select(r => r.ToProtoValue())); | ||||||
|  |         return response; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<GetReferenceCountResponse> GetReferenceCount(GetReferenceCountRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var count = await fileReferenceService.GetReferenceCountAsync(request.FileId); | ||||||
|  |         return new GetReferenceCountResponse { Count = count }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<GetReferencesResponse> GetResourceReferences(GetResourceReferencesRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var references = await fileReferenceService.GetResourceReferencesAsync(request.ResourceId, request.Usage); | ||||||
|  |         var response = new GetReferencesResponse(); | ||||||
|  |         response.References.AddRange(references.Select(r => r.ToProtoValue())); | ||||||
|  |         return response; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<GetResourceFilesResponse> GetResourceFiles(GetResourceFilesRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var files = await fileReferenceService.GetResourceFilesAsync(request.ResourceId, request.Usage); | ||||||
|  |         var response = new GetResourceFilesResponse(); | ||||||
|  |         response.Files.AddRange(files.Select(f => f.ToProtoValue())); | ||||||
|  |         return response; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences( | ||||||
|  |         DeleteResourceReferencesRequest request, ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         int deletedCount; | ||||||
|  |         if (request.Usage is null) | ||||||
|  |             deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId); | ||||||
|  |         else | ||||||
|  |             deletedCount = | ||||||
|  |                 await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!); | ||||||
|  |         return new DeleteResourceReferencesResponse { DeletedCount = deletedCount }; | ||||||
|  |     } | ||||||
|  |          | ||||||
|  |     public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var resourceIds = request.ResourceIds.ToList(); | ||||||
|  |         int deletedCount; | ||||||
|  |         if (request.Usage is null) | ||||||
|  |             deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds); | ||||||
|  |         else | ||||||
|  |             deletedCount = | ||||||
|  |                 await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!); | ||||||
|  |         return new DeleteResourceReferencesResponse { DeletedCount = deletedCount }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var success = await fileReferenceService.DeleteReferenceAsync(Guid.Parse(request.ReferenceId)); | ||||||
|  |         return new DeleteReferenceResponse { Success = success }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<UpdateResourceFilesResponse> UpdateResourceFiles(UpdateResourceFilesRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         Instant? expiredAt = null; | ||||||
|  |         if (request.ExpiredAt != null) | ||||||
|  |         { | ||||||
|  |             expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds); | ||||||
|  |         } | ||||||
|  |         else if (request.Duration != null) | ||||||
|  |         { | ||||||
|  |             expiredAt = SystemClock.Instance.GetCurrentInstant() + | ||||||
|  |                         Duration.FromTimeSpan(request.Duration.ToTimeSpan()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var references = await fileReferenceService.UpdateResourceFilesAsync( | ||||||
|  |             request.ResourceId, | ||||||
|  |             request.FileIds, | ||||||
|  |             request.Usage, | ||||||
|  |             expiredAt | ||||||
|  |         ); | ||||||
|  |         var response = new UpdateResourceFilesResponse(); | ||||||
|  |         response.References.AddRange(references.Select(r => r.ToProtoValue())); | ||||||
|  |         return response; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<SetReferenceExpirationResponse> SetReferenceExpiration( | ||||||
|  |         SetReferenceExpirationRequest request, ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         Instant? expiredAt = null; | ||||||
|  |         if (request.ExpiredAt != null) | ||||||
|  |         { | ||||||
|  |             expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds); | ||||||
|  |         } | ||||||
|  |         else if (request.Duration != null) | ||||||
|  |         { | ||||||
|  |             expiredAt = SystemClock.Instance.GetCurrentInstant() + | ||||||
|  |                         Duration.FromTimeSpan(request.Duration.ToTimeSpan()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var success = | ||||||
|  |             await fileReferenceService.SetReferenceExpirationAsync(Guid.Parse(request.ReferenceId), expiredAt); | ||||||
|  |         return new SetReferenceExpirationResponse { Success = success }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<SetFileReferencesExpirationResponse> SetFileReferencesExpiration( | ||||||
|  |         SetFileReferencesExpirationRequest request, ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds); | ||||||
|  |         var updatedCount = await fileReferenceService.SetFileReferencesExpirationAsync(request.FileId, expiredAt); | ||||||
|  |         return new SetFileReferencesExpirationResponse { UpdatedCount = updatedCount }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<HasFileReferencesResponse> HasFileReferences(HasFileReferencesRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var hasReferences = await fileReferenceService.HasFileReferencesAsync(request.FileId); | ||||||
|  |         return new HasFileReferencesResponse { HasReferences = hasReferences }; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,47 +1,45 @@ | |||||||
| using System.Globalization; | using System.Globalization; | ||||||
| using FFMpegCore; | using FFMpegCore; | ||||||
| using System.Security.Cryptography; | using System.Security.Cryptography; | ||||||
|  | using DysonNetwork.Drive.Storage.Model; | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
|  | using Google.Protobuf.WellKnownTypes; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Minio; | using Minio; | ||||||
| using Minio.DataModel.Args; | using Minio.DataModel.Args; | ||||||
|  | using NATS.Client.Core; | ||||||
| using NetVips; | using NetVips; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using tusdotnet.Stores; | using System.Linq.Expressions; | ||||||
|  | using Microsoft.EntityFrameworkCore.Query; | ||||||
|  | using NATS.Net; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Storage; | namespace DysonNetwork.Drive.Storage; | ||||||
|  |  | ||||||
| public class FileService( | public class FileService( | ||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|     IConfiguration configuration, |  | ||||||
|     TusDiskStore store, |  | ||||||
|     ILogger<FileService> logger, |     ILogger<FileService> logger, | ||||||
|     IServiceScopeFactory scopeFactory, |     ICacheService cache, | ||||||
|     ICacheService cache |     INatsConnection nats | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     private const string CacheKeyPrefix = "file:"; |     private const string CacheKeyPrefix = "file:"; | ||||||
|     private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); |     private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); | ||||||
|  |  | ||||||
|     /// <summary> |     public async Task<SnCloudFile?> GetFileAsync(string fileId) | ||||||
|     /// The api for getting file meta with cache, |  | ||||||
|     /// the best use case is for accessing the file data. |  | ||||||
|     /// |  | ||||||
|     /// <b>This function won't load uploader's information, only keep minimal file meta</b> |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="fileId">The id of the cloud file requested</param> |  | ||||||
|     /// <returns>The minimal file meta</returns> |  | ||||||
|     public async Task<CloudFile?> GetFileAsync(string fileId) |  | ||||||
|     { |     { | ||||||
|         var cacheKey = $"{CacheKeyPrefix}{fileId}"; |         var cacheKey = $"{CacheKeyPrefix}{fileId}"; | ||||||
|  |  | ||||||
|         var cachedFile = await cache.GetAsync<CloudFile>(cacheKey); |         var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey); | ||||||
|         if (cachedFile is not null) |         if (cachedFile is not null) | ||||||
|             return cachedFile; |             return cachedFile; | ||||||
|  |  | ||||||
|         var file = await db.Files |         var file = await db.Files | ||||||
|             .Where(f => f.Id == fileId) |             .Where(f => f.Id == fileId) | ||||||
|  |             .Include(f => f.Pool) | ||||||
|  |             .Include(f => f.Bundle) | ||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
|  |  | ||||||
|         if (file != null) |         if (file != null) | ||||||
| @@ -50,246 +48,273 @@ public class FileService( | |||||||
|         return file; |         return file; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static readonly string TempFilePrefix = "dyn-cloudfile"; |     public async Task<List<SnCloudFile>> GetFilesAsync(List<string> fileIds) | ||||||
|  |     { | ||||||
|  |         var cachedFiles = new Dictionary<string, SnCloudFile>(); | ||||||
|  |         var uncachedIds = new List<string>(); | ||||||
|  |  | ||||||
|     private static readonly string[] AnimatedImageTypes = |         foreach (var fileId in fileIds) | ||||||
|         ["image/gif", "image/apng", "image/webp", "image/avif"]; |         { | ||||||
|  |             var cacheKey = $"{CacheKeyPrefix}{fileId}"; | ||||||
|  |             var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey); | ||||||
|  |  | ||||||
|     // The analysis file method no longer will remove the GPS EXIF data |             if (cachedFile != null) | ||||||
|     // It should be handled on the client side, and for some specific cases it should be keep |                 cachedFiles[fileId] = cachedFile; | ||||||
|     public async Task<CloudFile> ProcessNewFileAsync( |             else | ||||||
|  |                 uncachedIds.Add(fileId); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (uncachedIds.Count > 0) | ||||||
|  |         { | ||||||
|  |             var dbFiles = await db.Files | ||||||
|  |                 .Where(f => uncachedIds.Contains(f.Id)) | ||||||
|  |                 .Include(f => f.Pool) | ||||||
|  |                 .ToListAsync(); | ||||||
|  |  | ||||||
|  |             foreach (var file in dbFiles) | ||||||
|  |             { | ||||||
|  |                 var cacheKey = $"{CacheKeyPrefix}{file.Id}"; | ||||||
|  |                 await cache.SetAsync(cacheKey, file, CacheDuration); | ||||||
|  |                 cachedFiles[file.Id] = file; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return fileIds | ||||||
|  |             .Select(f => cachedFiles.GetValueOrDefault(f)) | ||||||
|  |             .Where(f => f != null) | ||||||
|  |             .Cast<SnCloudFile>() | ||||||
|  |             .ToList(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnCloudFile> ProcessNewFileAsync( | ||||||
|         Account account, |         Account account, | ||||||
|         string fileId, |         string fileId, | ||||||
|         Stream stream, |         string filePool, | ||||||
|  |         string? fileBundleId, | ||||||
|  |         string filePath, | ||||||
|         string fileName, |         string fileName, | ||||||
|         string? contentType |         string? contentType, | ||||||
|  |         string? encryptPassword, | ||||||
|  |         Instant? expiredAt | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         var result = new List<(string filePath, string suffix)>(); |         var accountId = Guid.Parse(account.Id); | ||||||
|  |  | ||||||
|         var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId)); |         var pool = await GetPoolAsync(Guid.Parse(filePool)); | ||||||
|         var fileSize = stream.Length; |         if (pool is null) throw new InvalidOperationException("Pool not found"); | ||||||
|         var hash = await HashFileAsync(stream, fileSize: fileSize); |  | ||||||
|         contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName); |  | ||||||
|  |  | ||||||
|         var file = new CloudFile |         if (pool.StorageConfig.Expiration is not null && expiredAt.HasValue) | ||||||
|  |         { | ||||||
|  |             var expectedExpiration = SystemClock.Instance.GetCurrentInstant() - expiredAt.Value; | ||||||
|  |             var effectiveExpiration = pool.StorageConfig.Expiration < expectedExpiration | ||||||
|  |                 ? pool.StorageConfig.Expiration | ||||||
|  |                 : expectedExpiration; | ||||||
|  |             expiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var bundle = fileBundleId is not null | ||||||
|  |             ? await GetBundleAsync(Guid.Parse(fileBundleId), accountId) | ||||||
|  |             : null; | ||||||
|  |         if (fileBundleId is not null && bundle is null) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException("Bundle not found"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (bundle?.ExpiredAt != null) | ||||||
|  |             expiredAt = bundle.ExpiredAt.Value; | ||||||
|  |  | ||||||
|  |         var managedTempPath = Path.Combine(Path.GetTempPath(), fileId); | ||||||
|  |         File.Copy(filePath, managedTempPath, true); | ||||||
|  |  | ||||||
|  |         var fileInfo = new FileInfo(managedTempPath); | ||||||
|  |         var fileSize = fileInfo.Length; | ||||||
|  |         var finalContentType = contentType ?? | ||||||
|  |                                (!fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName)); | ||||||
|  |  | ||||||
|  |         var file = new SnCloudFile | ||||||
|         { |         { | ||||||
|             Id = fileId, |             Id = fileId, | ||||||
|             Name = fileName, |             Name = fileName, | ||||||
|             MimeType = contentType, |             MimeType = finalContentType, | ||||||
|             Size = fileSize, |             Size = fileSize, | ||||||
|             Hash = hash, |             ExpiredAt = expiredAt, | ||||||
|             AccountId = Guid.Parse(account.Id) |             BundleId = bundle?.Id, | ||||||
|  |             AccountId = Guid.Parse(account.Id), | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == hash); |         if (!pool.PolicyConfig.NoMetadata) | ||||||
|         file.StorageId = existingFile is not null ? existingFile.StorageId : file.Id; |  | ||||||
|  |  | ||||||
|         if (existingFile is not null) |  | ||||||
|         { |         { | ||||||
|             file.FileMeta = existingFile.FileMeta; |             await ExtractMetadataAsync(file, managedTempPath); | ||||||
|             file.HasCompression = existingFile.HasCompression; |  | ||||||
|             file.SensitiveMarks = existingFile.SensitiveMarks; |  | ||||||
|  |  | ||||||
|             db.Files.Add(file); |  | ||||||
|             await db.SaveChangesAsync(); |  | ||||||
|             return file; |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         switch (contentType.Split('/')[0]) |         string processingPath = managedTempPath; | ||||||
|  |         bool isTempFile = true; | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(encryptPassword)) | ||||||
|  |         { | ||||||
|  |             if (!pool.PolicyConfig.AllowEncryption) | ||||||
|  |                 throw new InvalidOperationException("Encryption is not allowed in this pool"); | ||||||
|  |  | ||||||
|  |             var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted"); | ||||||
|  |             FileEncryptor.EncryptFile(managedTempPath, encryptedPath, encryptPassword); | ||||||
|  |  | ||||||
|  |             File.Delete(managedTempPath); | ||||||
|  |  | ||||||
|  |             processingPath = encryptedPath; | ||||||
|  |  | ||||||
|  |             file.IsEncrypted = true; | ||||||
|  |             file.MimeType = "application/octet-stream"; | ||||||
|  |             file.Size = new FileInfo(processingPath).Length; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         file.Hash = await HashFileAsync(processingPath); | ||||||
|  |  | ||||||
|  |         db.Files.Add(file); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         file.StorageId ??= file.Id; | ||||||
|  |  | ||||||
|  |         var js = nats.CreateJetStreamContext(); | ||||||
|  |         await js.PublishAsync( | ||||||
|  |             FileUploadedEvent.Type, | ||||||
|  |             GrpcTypeHelper.ConvertObjectToByteString(new FileUploadedEventPayload( | ||||||
|  |                 file.Id, | ||||||
|  |                 pool.Id, | ||||||
|  |                 file.StorageId, | ||||||
|  |                 file.MimeType, | ||||||
|  |                 processingPath, | ||||||
|  |                 isTempFile) | ||||||
|  |             ).ToByteArray() | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         return file; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async Task ExtractMetadataAsync(SnCloudFile file, string filePath) | ||||||
|  |     { | ||||||
|  |         switch (file.MimeType?.Split('/')[0]) | ||||||
|         { |         { | ||||||
|             case "image": |             case "image": | ||||||
|                 var blurhash = |                 try | ||||||
|                     BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(xComponent: 3, yComponent: 3, filename: ogFilePath); |  | ||||||
|  |  | ||||||
|                 // Rewind stream |  | ||||||
|                 stream.Position = 0; |  | ||||||
|  |  | ||||||
|                 // Use NetVips for the rest |  | ||||||
|                 using (var vipsImage = NetVips.Image.NewFromStream(stream)) |  | ||||||
|                 { |                 { | ||||||
|  |                     var blurhash = BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(3, 3, filePath); | ||||||
|  |                     await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||||
|  |                     stream.Position = 0; | ||||||
|  |  | ||||||
|  |                     using var vipsImage = Image.NewFromStream(stream); | ||||||
|                     var width = vipsImage.Width; |                     var width = vipsImage.Width; | ||||||
|                     var height = vipsImage.Height; |                     var height = vipsImage.Height; | ||||||
|                     var format = vipsImage.Get("vips-loader") ?? "unknown"; |  | ||||||
|  |  | ||||||
|                     // Try to get orientation from exif data |  | ||||||
|                     var orientation = 1; |                     var orientation = 1; | ||||||
|                     var meta = new Dictionary<string, object> |                     try | ||||||
|  |                     { | ||||||
|  |                         orientation = vipsImage.Get("orientation") as int? ?? 1; | ||||||
|  |                     } | ||||||
|  |                     catch | ||||||
|  |                     { | ||||||
|  |                         // ignored | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     var meta = new Dictionary<string, object?> | ||||||
|                     { |                     { | ||||||
|                         ["blur"] = blurhash, |                         ["blur"] = blurhash, | ||||||
|                         ["format"] = format, |                         ["format"] = vipsImage.Get("vips-loader") ?? "unknown", | ||||||
|                         ["width"] = width, |                         ["width"] = width, | ||||||
|                         ["height"] = height, |                         ["height"] = height, | ||||||
|                         ["orientation"] = orientation, |                         ["orientation"] = orientation, | ||||||
|                     }; |                     }; | ||||||
|                     Dictionary<string, object> exif = []; |                     var exif = new Dictionary<string, object>(); | ||||||
|  |  | ||||||
|                     foreach (var field in vipsImage.GetFields()) |                     foreach (var field in vipsImage.GetFields()) | ||||||
|                     { |                     { | ||||||
|  |                         if (IsIgnoredField(field)) continue; | ||||||
|                         var value = vipsImage.Get(field); |                         var value = vipsImage.Get(field); | ||||||
|  |                         if (field.StartsWith("exif-")) | ||||||
|                         // Skip GPS-related EXIF fields to remove location data |                             exif[field.Replace("exif-", "")] = value; | ||||||
|                         if (IsIgnoredField(field)) |                         else | ||||||
|                             continue; |                             meta[field] = value; | ||||||
|  |  | ||||||
|                         if (field.StartsWith("exif-")) exif[field.Replace("exif-", "")] = value; |  | ||||||
|                         else meta[field] = value; |  | ||||||
|  |  | ||||||
|                         if (field == "orientation") orientation = (int)value; |  | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|                     if (orientation is 6 or 8) |                     if (orientation is 6 or 8) (width, height) = (height, width); | ||||||
|                         (width, height) = (height, width); |  | ||||||
|  |  | ||||||
|                     var aspectRatio = height != 0 ? (double)width / height : 0; |  | ||||||
|  |  | ||||||
|                     meta["exif"] = exif; |                     meta["exif"] = exif; | ||||||
|                     meta["ratio"] = aspectRatio; |                     meta["ratio"] = height != 0 ? (double)width / height : 0; | ||||||
|                     file.FileMeta = meta; |                     file.FileMeta = meta; | ||||||
|                 } |                 } | ||||||
|  |                 catch (Exception ex) | ||||||
|  |                 { | ||||||
|  |                     file.FileMeta = new Dictionary<string, object?>(); | ||||||
|  |                     logger.LogError(ex, "Failed to analyze image file {FileId}", file.Id); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 break; |                 break; | ||||||
|  |  | ||||||
|             case "video": |             case "video": | ||||||
|             case "audio": |             case "audio": | ||||||
|                 try |                 try | ||||||
|                 { |                 { | ||||||
|                     var mediaInfo = await FFProbe.AnalyseAsync(ogFilePath); |                     var mediaInfo = await FFProbe.AnalyseAsync(filePath); | ||||||
|                     file.FileMeta = new Dictionary<string, object> |                     file.FileMeta = new Dictionary<string, object?> | ||||||
|                     { |                     { | ||||||
|  |                         ["width"] = mediaInfo.PrimaryVideoStream?.Width, | ||||||
|  |                         ["height"] = mediaInfo.PrimaryVideoStream?.Height, | ||||||
|                         ["duration"] = mediaInfo.Duration.TotalSeconds, |                         ["duration"] = mediaInfo.Duration.TotalSeconds, | ||||||
|                         ["format_name"] = mediaInfo.Format.FormatName, |                         ["format_name"] = mediaInfo.Format.FormatName, | ||||||
|                         ["format_long_name"] = mediaInfo.Format.FormatLongName, |                         ["format_long_name"] = mediaInfo.Format.FormatLongName, | ||||||
|                         ["start_time"] = mediaInfo.Format.StartTime.ToString(), |                         ["start_time"] = mediaInfo.Format.StartTime.ToString(), | ||||||
|                         ["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture), |                         ["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture), | ||||||
|                         ["tags"] = mediaInfo.Format.Tags ?? [], |                         ["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(), | ||||||
|                         ["chapters"] = mediaInfo.Chapters, |                         ["chapters"] = mediaInfo.Chapters, | ||||||
|  |                         ["video_streams"] = mediaInfo.VideoStreams.Select(s => new | ||||||
|  |                         { | ||||||
|  |                             s.AvgFrameRate, | ||||||
|  |                             s.BitRate, | ||||||
|  |                             s.CodecName, | ||||||
|  |                             s.Duration, | ||||||
|  |                             s.Height, | ||||||
|  |                             s.Width, | ||||||
|  |                             s.Language, | ||||||
|  |                             s.PixelFormat, | ||||||
|  |                             s.Rotation | ||||||
|  |                         }).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(), | ||||||
|  |                         ["audio_streams"] = mediaInfo.AudioStreams.Select(s => new | ||||||
|  |                             { | ||||||
|  |                                 s.BitRate, | ||||||
|  |                                 s.Channels, | ||||||
|  |                                 s.ChannelLayout, | ||||||
|  |                                 s.CodecName, | ||||||
|  |                                 s.Duration, | ||||||
|  |                                 s.Language, | ||||||
|  |                                 s.SampleRateHz | ||||||
|  |                             }) | ||||||
|  |                             .ToList(), | ||||||
|                     }; |                     }; | ||||||
|                     if (mediaInfo.PrimaryVideoStream is not null) |                     if (mediaInfo.PrimaryVideoStream is not null) | ||||||
|                         file.FileMeta["ratio"] = |                         file.FileMeta["ratio"] = (double)mediaInfo.PrimaryVideoStream.Width / | ||||||
|                             mediaInfo.PrimaryVideoStream.Width / mediaInfo.PrimaryVideoStream.Height; |                                                  mediaInfo.PrimaryVideoStream.Height; | ||||||
|                 } |                 } | ||||||
|                 catch (Exception ex) |                 catch (Exception ex) | ||||||
|                 { |                 { | ||||||
|                     logger.LogError("File analyzed failed, unable collect video / audio information: {Message}", |                     logger.LogError(ex, "Failed to analyze media file {FileId}", file.Id); | ||||||
|                         ex.Message); |  | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 break; |                 break; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         db.Files.Add(file); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         _ = Task.Run(async () => |  | ||||||
|         { |  | ||||||
|             using var scope = scopeFactory.CreateScope(); |  | ||||||
|             var nfs = scope.ServiceProvider.GetRequiredService<FileService>(); |  | ||||||
|  |  | ||||||
|             try |  | ||||||
|             { |  | ||||||
|                 logger.LogInformation("Processed file {fileId}, now trying optimizing if possible...", fileId); |  | ||||||
|  |  | ||||||
|                 if (contentType.Split('/')[0] == "image") |  | ||||||
|                 { |  | ||||||
|                     // Skip compression for animated image types |  | ||||||
|                     var animatedMimeTypes = AnimatedImageTypes; |  | ||||||
|                     if (Enumerable.Contains(animatedMimeTypes, contentType)) |  | ||||||
|                     { |  | ||||||
|                         logger.LogInformation( |  | ||||||
|                             "File {fileId} is an animated image (MIME: {mime}), skipping WebP conversion.", fileId, |  | ||||||
|                             contentType |  | ||||||
|                         ); |  | ||||||
|                         var tempFilePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}"); |  | ||||||
|                         result.Add((tempFilePath, string.Empty)); |  | ||||||
|                         return; |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     file.MimeType = "image/webp"; |  | ||||||
|  |  | ||||||
|                     using var vipsImage = Image.NewFromFile(ogFilePath); |  | ||||||
|                     var imagePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}"); |  | ||||||
|                     vipsImage.Autorot().WriteToFile(imagePath + ".webp", |  | ||||||
|                         new VOption { { "lossless", true }, { "strip", true } }); |  | ||||||
|                     result.Add((imagePath + ".webp", string.Empty)); |  | ||||||
|  |  | ||||||
|                     if (vipsImage.Width * vipsImage.Height >= 1024 * 1024) |  | ||||||
|                     { |  | ||||||
|                         var scale = 1024.0 / Math.Max(vipsImage.Width, vipsImage.Height); |  | ||||||
|                         var imageCompressedPath = |  | ||||||
|                             Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}-compressed"); |  | ||||||
|  |  | ||||||
|                         // Create and save image within the same synchronous block to avoid disposal issues |  | ||||||
|                         using var compressedImage = vipsImage.Resize(scale); |  | ||||||
|                         compressedImage.Autorot().WriteToFile(imageCompressedPath + ".webp", |  | ||||||
|                             new VOption { { "Q", 80 }, { "strip", true } }); |  | ||||||
|  |  | ||||||
|                         result.Add((imageCompressedPath + ".webp", ".compressed")); |  | ||||||
|                         file.HasCompression = true; |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                 else |  | ||||||
|                 { |  | ||||||
|                     // No extra process for video, add it to the upload queue. |  | ||||||
|                     result.Add((ogFilePath, string.Empty)); |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 logger.LogInformation("Optimized file {fileId}, now uploading...", fileId); |  | ||||||
|  |  | ||||||
|                 if (result.Count > 0) |  | ||||||
|                 { |  | ||||||
|                     List<Task<CloudFile>> tasks = []; |  | ||||||
|                     tasks.AddRange(result.Select(item => |  | ||||||
|                         nfs.UploadFileToRemoteAsync(file, item.filePath, null, item.suffix, true)) |  | ||||||
|                     ); |  | ||||||
|  |  | ||||||
|                     await Task.WhenAll(tasks); |  | ||||||
|                     file = await tasks.First(); |  | ||||||
|                 } |  | ||||||
|                 else |  | ||||||
|                 { |  | ||||||
|                     file = await nfs.UploadFileToRemoteAsync(file, stream, null); |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 logger.LogInformation("Uploaded file {fileId} done!", fileId); |  | ||||||
|  |  | ||||||
|                 var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>(); |  | ||||||
|                 await scopedDb.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(setter => setter |  | ||||||
|                     .SetProperty(f => f.UploadedAt, file.UploadedAt) |  | ||||||
|                     .SetProperty(f => f.UploadedTo, file.UploadedTo) |  | ||||||
|                     .SetProperty(f => f.MimeType, file.MimeType) |  | ||||||
|                     .SetProperty(f => f.HasCompression, file.HasCompression) |  | ||||||
|                 ); |  | ||||||
|             } |  | ||||||
|             catch (Exception err) |  | ||||||
|             { |  | ||||||
|                 logger.LogError(err, "Failed to process {fileId}", fileId); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             await stream.DisposeAsync(); |  | ||||||
|             await store.DeleteFileAsync(file.Id, CancellationToken.None); |  | ||||||
|             await nfs._PurgeCacheAsync(file.Id); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         return file; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static async Task<string> HashFileAsync(Stream stream, int chunkSize = 1024 * 1024, long? fileSize = null) |     private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024) | ||||||
|     { |     { | ||||||
|         fileSize ??= stream.Length; |         var fileInfo = new FileInfo(filePath); | ||||||
|         if (fileSize > chunkSize * 1024 * 5) |         if (fileInfo.Length > chunkSize * 1024 * 5) | ||||||
|             return await HashFastApproximateAsync(stream, chunkSize); |             return await HashFastApproximateAsync(filePath, chunkSize); | ||||||
|  |  | ||||||
|  |         await using var stream = File.OpenRead(filePath); | ||||||
|         using var md5 = MD5.Create(); |         using var md5 = MD5.Create(); | ||||||
|         var hashBytes = await md5.ComputeHashAsync(stream); |         var hashBytes = await md5.ComputeHashAsync(stream); | ||||||
|         return Convert.ToHexString(hashBytes).ToLowerInvariant(); |         return Convert.ToHexString(hashBytes).ToLowerInvariant(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static async Task<string> HashFastApproximateAsync(Stream stream, int chunkSize = 1024 * 1024) |     private static async Task<string> HashFastApproximateAsync(string filePath, int chunkSize = 1024 * 1024) | ||||||
|     { |     { | ||||||
|         // Scale the chunk size to kB level |         await using var stream = File.OpenRead(filePath); | ||||||
|         chunkSize *= 1024; |  | ||||||
|  |  | ||||||
|         using var md5 = MD5.Create(); |  | ||||||
|  |  | ||||||
|         var buffer = new byte[chunkSize * 2]; |         var buffer = new byte[chunkSize * 2]; | ||||||
|         var fileLength = stream.Length; |         var fileLength = stream.Length; | ||||||
| @@ -302,89 +327,127 @@ public class FileService( | |||||||
|             bytesRead += await stream.ReadAsync(buffer.AsMemory(chunkSize, chunkSize)); |             bytesRead += await stream.ReadAsync(buffer.AsMemory(chunkSize, chunkSize)); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var hash = md5.ComputeHash(buffer, 0, bytesRead); |         var hash = MD5.HashData(buffer.AsSpan(0, bytesRead)); | ||||||
|  |         stream.Position = 0; | ||||||
|         return Convert.ToHexString(hash).ToLowerInvariant(); |         return Convert.ToHexString(hash).ToLowerInvariant(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<CloudFile> UploadFileToRemoteAsync(CloudFile file, string filePath, string? targetRemote, |     public async Task UploadFileToRemoteAsync( | ||||||
|         string? suffix = null, bool selfDestruct = false) |         string storageId, | ||||||
|  |         Guid targetRemote, | ||||||
|  |         string filePath, | ||||||
|  |         string? suffix = null, | ||||||
|  |         string? contentType = null, | ||||||
|  |         bool selfDestruct = false | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var fileStream = File.OpenRead(filePath); |         await using var fileStream = File.OpenRead(filePath); | ||||||
|         var result = await UploadFileToRemoteAsync(file, fileStream, targetRemote, suffix); |         await UploadFileToRemoteAsync(storageId, targetRemote, fileStream, suffix, contentType); | ||||||
|         if (selfDestruct) File.Delete(filePath); |         if (selfDestruct) File.Delete(filePath); | ||||||
|         return result; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<CloudFile> UploadFileToRemoteAsync(CloudFile file, Stream stream, string? targetRemote, |     private async Task UploadFileToRemoteAsync( | ||||||
|         string? suffix = null) |         string storageId, | ||||||
|  |         Guid targetRemote, | ||||||
|  |         Stream stream, | ||||||
|  |         string? suffix = null, | ||||||
|  |         string? contentType = null | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         if (file.UploadedAt.HasValue) return file; |         var dest = await GetRemoteStorageConfig(targetRemote); | ||||||
|  |         if (dest is null) | ||||||
|         file.UploadedTo = targetRemote ?? configuration.GetValue<string>("Storage:PreferredRemote")!; |  | ||||||
|  |  | ||||||
|         var dest = GetRemoteStorageConfig(file.UploadedTo); |  | ||||||
|         var client = CreateMinioClient(dest); |  | ||||||
|         if (client is null) |  | ||||||
|             throw new InvalidOperationException( |             throw new InvalidOperationException( | ||||||
|                 $"Failed to configure client for remote destination '{file.UploadedTo}'" |                 $"Failed to configure client for remote destination '{targetRemote}'" | ||||||
|             ); |             ); | ||||||
|  |         var client = CreateMinioClient(dest); | ||||||
|  |  | ||||||
|         var bucket = dest.Bucket; |         var bucket = dest.Bucket; | ||||||
|         var contentType = file.MimeType ?? "application/octet-stream"; |         contentType ??= "application/octet-stream"; | ||||||
|  |  | ||||||
|         await client.PutObjectAsync(new PutObjectArgs() |         await client!.PutObjectAsync(new PutObjectArgs() | ||||||
|             .WithBucket(bucket) |             .WithBucket(bucket) | ||||||
|             .WithObject(string.IsNullOrWhiteSpace(suffix) ? file.Id : file.Id + suffix) |             .WithObject(string.IsNullOrWhiteSpace(suffix) ? storageId : storageId + suffix) | ||||||
|             .WithStreamData(stream) // Fix this disposed |             .WithStreamData(stream) | ||||||
|             .WithObjectSize(stream.Length) |             .WithObjectSize(stream.Length) | ||||||
|             .WithContentType(contentType) |             .WithContentType(contentType) | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
|         file.UploadedAt = Instant.FromDateTimeUtc(DateTime.UtcNow); |  | ||||||
|         return file; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task DeleteFileAsync(CloudFile file) |     public async Task<SnCloudFile> UpdateFileAsync(SnCloudFile file, FieldMask updateMask) | ||||||
|     { |     { | ||||||
|         await DeleteFileDataAsync(file); |         var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Id == file.Id); | ||||||
|  |         if (existingFile == null) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException($"File with ID {file.Id} not found."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var updatable = new UpdatableCloudFile(existingFile); | ||||||
|  |  | ||||||
|  |         foreach (var path in updateMask.Paths) | ||||||
|  |         { | ||||||
|  |             switch (path) | ||||||
|  |             { | ||||||
|  |                 case "name": | ||||||
|  |                     updatable.Name = file.Name; | ||||||
|  |                     break; | ||||||
|  |                 case "description": | ||||||
|  |                     updatable.Description = file.Description; | ||||||
|  |                     break; | ||||||
|  |                 case "file_meta": | ||||||
|  |                     updatable.FileMeta = file.FileMeta; | ||||||
|  |                     break; | ||||||
|  |                 case "user_meta": | ||||||
|  |                     updatable.UserMeta = file.UserMeta; | ||||||
|  |                     break; | ||||||
|  |                 case "is_marked_recycle": | ||||||
|  |                     updatable.IsMarkedRecycle = file.IsMarkedRecycle; | ||||||
|  |                     break; | ||||||
|  |                 default: | ||||||
|  |                     logger.LogWarning("Attempted to update unmodifiable field: {Field}", path); | ||||||
|  |                     break; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls()); | ||||||
|  |  | ||||||
|  |         await _PurgeCacheAsync(file.Id); | ||||||
|  |         return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task DeleteFileAsync(SnCloudFile file) | ||||||
|  |     { | ||||||
|         db.Remove(file); |         db.Remove(file); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|         await _PurgeCacheAsync(file.Id); |         await _PurgeCacheAsync(file.Id); | ||||||
|  |  | ||||||
|  |         await DeleteFileDataAsync(file); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task DeleteFileDataAsync(CloudFile file) |     public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false) | ||||||
|     { |     { | ||||||
|         if (file.StorageId is null) return; |         if (!file.PoolId.HasValue) return; | ||||||
|         if (file.UploadedTo is null) return; |  | ||||||
|  |  | ||||||
|         // Check if any other file with the same storage ID is referenced |         if (!force) | ||||||
|         var otherFilesWithSameStorageId = await db.Files |  | ||||||
|             .Where(f => f.StorageId == file.StorageId && f.Id != file.Id) |  | ||||||
|             .Select(f => f.Id) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         // Check if any of these files are referenced |  | ||||||
|         var anyReferenced = false; |  | ||||||
|         if (otherFilesWithSameStorageId.Any()) |  | ||||||
|         { |         { | ||||||
|             anyReferenced = await db.FileReferences |             var sameOriginFiles = await db.Files | ||||||
|                 .Where(r => otherFilesWithSameStorageId.Contains(r.FileId)) |                 .Where(f => f.StorageId == file.StorageId && f.Id != file.Id) | ||||||
|                 .AnyAsync(); |                 .Select(f => f.Id) | ||||||
|  |                 .ToListAsync(); | ||||||
|  |  | ||||||
|  |             if (sameOriginFiles.Count != 0) | ||||||
|  |                 return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // If any other file with the same storage ID is referenced, don't delete the actual file data |         var dest = await GetRemoteStorageConfig(file.PoolId.Value); | ||||||
|         if (anyReferenced) return; |         if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}"); | ||||||
|  |  | ||||||
|         var dest = GetRemoteStorageConfig(file.UploadedTo); |  | ||||||
|         var client = CreateMinioClient(dest); |         var client = CreateMinioClient(dest); | ||||||
|         if (client is null) |         if (client is null) | ||||||
|             throw new InvalidOperationException( |             throw new InvalidOperationException( | ||||||
|                 $"Failed to configure client for remote destination '{file.UploadedTo}'" |                 $"Failed to configure client for remote destination '{file.PoolId}'" | ||||||
|             ); |             ); | ||||||
|  |  | ||||||
|         var bucket = dest.Bucket; |         var bucket = dest.Bucket; | ||||||
|         var objectId = file.StorageId ?? file.Id; // Use StorageId if available, otherwise fall back to Id |         var objectId = file.StorageId ?? file.Id; | ||||||
|  |  | ||||||
|         await client.RemoveObjectAsync( |         await client.RemoveObjectAsync( | ||||||
|             new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId) |             new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId) | ||||||
| @@ -392,7 +455,6 @@ public class FileService( | |||||||
|  |  | ||||||
|         if (file.HasCompression) |         if (file.HasCompression) | ||||||
|         { |         { | ||||||
|             // Also remove the compressed version if it exists |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 await client.RemoveObjectAsync( |                 await client.RemoveObjectAsync( | ||||||
| @@ -401,18 +463,87 @@ public class FileService( | |||||||
|             } |             } | ||||||
|             catch |             catch | ||||||
|             { |             { | ||||||
|                 // Ignore errors when deleting compressed version |  | ||||||
|                 logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id); |                 logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         if (file.HasThumbnail) | ||||||
|  |         { | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 await client.RemoveObjectAsync( | ||||||
|  |                     new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId + ".thumbnail") | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |             catch | ||||||
|  |             { | ||||||
|  |                 logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public RemoteStorageConfig GetRemoteStorageConfig(string destination) |     public async Task DeleteFileDataBatchAsync(List<SnCloudFile> files) | ||||||
|     { |     { | ||||||
|         var destinations = configuration.GetSection("Storage:Remote").Get<List<RemoteStorageConfig>>()!; |         files = files.Where(f => f.PoolId.HasValue).ToList(); | ||||||
|         var dest = destinations.FirstOrDefault(d => d.Id == destination); |  | ||||||
|         if (dest is null) throw new InvalidOperationException($"Remote destination '{destination}' not found"); |         foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value)) | ||||||
|         return dest; |         { | ||||||
|  |             var dest = await GetRemoteStorageConfig(fileGroup.Key); | ||||||
|  |             if (dest is null) | ||||||
|  |                 throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}"); | ||||||
|  |             var client = CreateMinioClient(dest); | ||||||
|  |             if (client is null) | ||||||
|  |                 throw new InvalidOperationException( | ||||||
|  |                     $"Failed to configure client for remote destination '{fileGroup.Key}'" | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|  |             List<string> objectsToDelete = []; | ||||||
|  |  | ||||||
|  |             foreach (var file in fileGroup) | ||||||
|  |             { | ||||||
|  |                 objectsToDelete.Add(file.StorageId ?? file.Id); | ||||||
|  |                 if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed"); | ||||||
|  |                 if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail"); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             await client.RemoveObjectsAsync( | ||||||
|  |                 new RemoveObjectsArgs().WithBucket(dest.Bucket).WithObjects(objectsToDelete) | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async Task<SnFileBundle?> GetBundleAsync(Guid id, Guid accountId) | ||||||
|  |     { | ||||||
|  |         var bundle = await db.Bundles | ||||||
|  |             .Where(e => e.Id == id) | ||||||
|  |             .Where(e => e.AccountId == accountId) | ||||||
|  |             .FirstOrDefaultAsync(); | ||||||
|  |         return bundle; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<FilePool?> GetPoolAsync(Guid destination) | ||||||
|  |     { | ||||||
|  |         var cacheKey = $"file:pool:{destination}"; | ||||||
|  |         var cachedResult = await cache.GetAsync<FilePool?>(cacheKey); | ||||||
|  |         if (cachedResult != null) return cachedResult; | ||||||
|  |  | ||||||
|  |         var pool = await db.Pools.FirstOrDefaultAsync(p => p.Id == destination); | ||||||
|  |         if (pool != null) | ||||||
|  |             await cache.SetAsync(cacheKey, pool); | ||||||
|  |  | ||||||
|  |         return pool; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<RemoteStorageConfig?> GetRemoteStorageConfig(Guid destination) | ||||||
|  |     { | ||||||
|  |         var pool = await GetPoolAsync(destination); | ||||||
|  |         return pool?.StorageConfig; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<RemoteStorageConfig?> GetRemoteStorageConfig(string destination) | ||||||
|  |     { | ||||||
|  |         var id = Guid.Parse(destination); | ||||||
|  |         return await GetRemoteStorageConfig(id); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public IMinioClient? CreateMinioClient(RemoteStorageConfig dest) |     public IMinioClient? CreateMinioClient(RemoteStorageConfig dest) | ||||||
| @@ -426,31 +557,27 @@ public class FileService( | |||||||
|         return client.Build(); |         return client.Build(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Helper method to purge the cache for a specific file |  | ||||||
|     // Made internal to allow FileReferenceService to use it |  | ||||||
|     internal async Task _PurgeCacheAsync(string fileId) |     internal async Task _PurgeCacheAsync(string fileId) | ||||||
|     { |     { | ||||||
|         var cacheKey = $"{CacheKeyPrefix}{fileId}"; |         var cacheKey = $"{CacheKeyPrefix}{fileId}"; | ||||||
|         await cache.RemoveAsync(cacheKey); |         await cache.RemoveAsync(cacheKey); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Helper method to purge cache for multiple files |  | ||||||
|     internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds) |     internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds) | ||||||
|     { |     { | ||||||
|         var tasks = fileIds.Select(_PurgeCacheAsync); |         var tasks = fileIds.Select(_PurgeCacheAsync); | ||||||
|         await Task.WhenAll(tasks); |         await Task.WhenAll(tasks); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<List<CloudFile?>> LoadFromReference(List<CloudFileReferenceObject> references) |     public async Task<List<SnCloudFile?>> LoadFromReference(List<SnCloudFileReferenceObject> references) | ||||||
|     { |     { | ||||||
|         var cachedFiles = new Dictionary<string, CloudFile>(); |         var cachedFiles = new Dictionary<string, SnCloudFile>(); | ||||||
|         var uncachedIds = new List<string>(); |         var uncachedIds = new List<string>(); | ||||||
|  |  | ||||||
|         // Check cache first |  | ||||||
|         foreach (var reference in references) |         foreach (var reference in references) | ||||||
|         { |         { | ||||||
|             var cacheKey = $"{CacheKeyPrefix}{reference.Id}"; |             var cacheKey = $"{CacheKeyPrefix}{reference.Id}"; | ||||||
|             var cachedFile = await cache.GetAsync<CloudFile>(cacheKey); |             var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey); | ||||||
|  |  | ||||||
|             if (cachedFile != null) |             if (cachedFile != null) | ||||||
|             { |             { | ||||||
| @@ -462,14 +589,12 @@ public class FileService( | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Load uncached files from database |  | ||||||
|         if (uncachedIds.Count > 0) |         if (uncachedIds.Count > 0) | ||||||
|         { |         { | ||||||
|             var dbFiles = await db.Files |             var dbFiles = await db.Files | ||||||
|                 .Where(f => uncachedIds.Contains(f.Id)) |                 .Where(f => uncachedIds.Contains(f.Id)) | ||||||
|                 .ToListAsync(); |                 .ToListAsync(); | ||||||
|  |  | ||||||
|             // Add to cache |  | ||||||
|             foreach (var file in dbFiles) |             foreach (var file in dbFiles) | ||||||
|             { |             { | ||||||
|                 var cacheKey = $"{CacheKeyPrefix}{file.Id}"; |                 var cacheKey = $"{CacheKeyPrefix}{file.Id}"; | ||||||
| @@ -478,18 +603,11 @@ public class FileService( | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Preserve original order |         return [.. references | ||||||
|         return references |  | ||||||
|             .Select(r => cachedFiles.GetValueOrDefault(r.Id)) |             .Select(r => cachedFiles.GetValueOrDefault(r.Id)) | ||||||
|             .Where(f => f != null) |             .Where(f => f != null)]; | ||||||
|             .ToList(); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Gets the number of references to a file based on CloudFileReference records |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="fileId">The ID of the file</param> |  | ||||||
|     /// <returns>The number of references to the file</returns> |  | ||||||
|     public async Task<int> GetReferenceCountAsync(string fileId) |     public async Task<int> GetReferenceCountAsync(string fileId) | ||||||
|     { |     { | ||||||
|         return await db.FileReferences |         return await db.FileReferences | ||||||
| @@ -497,11 +615,6 @@ public class FileService( | |||||||
|             .CountAsync(); |             .CountAsync(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Checks if a file is referenced by any resource |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="fileId">The ID of the file to check</param> |  | ||||||
|     /// <returns>True if the file is referenced, false otherwise</returns> |  | ||||||
|     public async Task<bool> IsReferencedAsync(string fileId) |     public async Task<bool> IsReferencedAsync(string fileId) | ||||||
|     { |     { | ||||||
|         return await db.FileReferences |         return await db.FileReferences | ||||||
| @@ -509,47 +622,106 @@ public class FileService( | |||||||
|             .AnyAsync(); |             .AnyAsync(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |     private static bool IsIgnoredField(string fieldName) | ||||||
|     /// Checks if an EXIF field contains GPS location data |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="fieldName">The EXIF field name</param> |  | ||||||
|     /// <returns>True if the field contains GPS data, false otherwise</returns> |  | ||||||
|     private static bool IsGpsExifField(string fieldName) |  | ||||||
|     { |     { | ||||||
|         // Common GPS EXIF field names |  | ||||||
|         var gpsFields = new[] |         var gpsFields = new[] | ||||||
|         { |         { | ||||||
|             "gps-latitude", |             "gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "gps-longitude-ref", | ||||||
|             "gps-longitude", |             "gps-altitude-ref", "gps-timestamp", "gps-datestamp", "gps-speed", "gps-speed-ref", "gps-track", | ||||||
|             "gps-altitude", |             "gps-track-ref", "gps-img-direction", "gps-img-direction-ref", "gps-dest-latitude", | ||||||
|             "gps-latitude-ref", |             "gps-dest-longitude", "gps-dest-latitude-ref", "gps-dest-longitude-ref", "gps-processing-method", | ||||||
|             "gps-longitude-ref", |  | ||||||
|             "gps-altitude-ref", |  | ||||||
|             "gps-timestamp", |  | ||||||
|             "gps-datestamp", |  | ||||||
|             "gps-speed", |  | ||||||
|             "gps-speed-ref", |  | ||||||
|             "gps-track", |  | ||||||
|             "gps-track-ref", |  | ||||||
|             "gps-img-direction", |  | ||||||
|             "gps-img-direction-ref", |  | ||||||
|             "gps-dest-latitude", |  | ||||||
|             "gps-dest-longitude", |  | ||||||
|             "gps-dest-latitude-ref", |  | ||||||
|             "gps-dest-longitude-ref", |  | ||||||
|             "gps-processing-method", |  | ||||||
|             "gps-area-information" |             "gps-area-information" | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         return gpsFields.Any(gpsField => |         if (fieldName.StartsWith("exif-GPS")) return true; | ||||||
|             fieldName.Equals(gpsField, StringComparison.OrdinalIgnoreCase) || |         if (fieldName.StartsWith("ifd3-GPS")) return true; | ||||||
|             fieldName.StartsWith("gps", StringComparison.OrdinalIgnoreCase)); |         if (fieldName.EndsWith("-data")) return true; | ||||||
|  |         return gpsFields.Any(gpsField => fieldName.StartsWith(gpsField, StringComparison.OrdinalIgnoreCase)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static bool IsIgnoredField(string fieldName) |     public async Task<int> DeleteAccountRecycledFilesAsync(Guid accountId) | ||||||
|     { |     { | ||||||
|         if (IsGpsExifField(fieldName)) return true; |         var files = await db.Files | ||||||
|         if (fieldName.EndsWith("-data")) return true; |             .Where(f => f.AccountId == accountId && f.IsMarkedRecycle) | ||||||
|         return false; |             .ToListAsync(); | ||||||
|  |         var count = files.Count; | ||||||
|  |         var tasks = files.Select(f => DeleteFileDataAsync(f, true)); | ||||||
|  |         await Task.WhenAll(tasks); | ||||||
|  |         var fileIds = files.Select(f => f.Id).ToList(); | ||||||
|  |         await _PurgeCacheRangeAsync(fileIds); | ||||||
|  |         db.RemoveRange(files); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |         return count; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<int> DeletePoolRecycledFilesAsync(Guid poolId) | ||||||
|  |     { | ||||||
|  |         var files = await db.Files | ||||||
|  |             .Where(f => f.PoolId == poolId && f.IsMarkedRecycle) | ||||||
|  |             .ToListAsync(); | ||||||
|  |         var count = files.Count; | ||||||
|  |         var tasks = files.Select(f => DeleteFileDataAsync(f, true)); | ||||||
|  |         await Task.WhenAll(tasks); | ||||||
|  |         var fileIds = files.Select(f => f.Id).ToList(); | ||||||
|  |         await _PurgeCacheRangeAsync(fileIds); | ||||||
|  |         db.RemoveRange(files); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |         return count; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<int> DeleteAllRecycledFilesAsync() | ||||||
|  |     { | ||||||
|  |         var files = await db.Files | ||||||
|  |             .Where(f => f.IsMarkedRecycle) | ||||||
|  |             .ToListAsync(); | ||||||
|  |         var count = files.Count; | ||||||
|  |         var tasks = files.Select(f => DeleteFileDataAsync(f, true)); | ||||||
|  |         await Task.WhenAll(tasks); | ||||||
|  |         var fileIds = files.Select(f => f.Id).ToList(); | ||||||
|  |         await _PurgeCacheRangeAsync(fileIds); | ||||||
|  |         db.RemoveRange(files); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |         return count; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<string> CreateFastUploadLinkAsync(SnCloudFile file) | ||||||
|  |     { | ||||||
|  |         if (file.PoolId is null) throw new InvalidOperationException("Pool ID is null"); | ||||||
|  |  | ||||||
|  |         var dest = await GetRemoteStorageConfig(file.PoolId.Value); | ||||||
|  |         if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}"); | ||||||
|  |         var client = CreateMinioClient(dest); | ||||||
|  |         if (client is null) | ||||||
|  |             throw new InvalidOperationException( | ||||||
|  |                 $"Failed to configure client for remote destination '{file.PoolId}'" | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |         var url = await client.PresignedPutObjectAsync( | ||||||
|  |             new PresignedPutObjectArgs() | ||||||
|  |                 .WithBucket(dest.Bucket) | ||||||
|  |                 .WithObject(file.Id) | ||||||
|  |                 .WithExpiry(60 * 60 * 24) | ||||||
|  |         ); | ||||||
|  |         return url; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | file class UpdatableCloudFile(SnCloudFile file) | ||||||
|  | { | ||||||
|  |     public string Name { get; set; } = file.Name; | ||||||
|  |     public string? Description { get; set; } = file.Description; | ||||||
|  |     public Dictionary<string, object?>? FileMeta { get; set; } = file.FileMeta; | ||||||
|  |     public Dictionary<string, object?>? UserMeta { get; set; } = file.UserMeta; | ||||||
|  |     public bool IsMarkedRecycle { get; set; } = file.IsMarkedRecycle; | ||||||
|  |  | ||||||
|  |     public Expression<Func<SetPropertyCalls<SnCloudFile>, SetPropertyCalls<SnCloudFile>>> ToSetPropertyCalls() | ||||||
|  |     { | ||||||
|  |         var userMeta = UserMeta ?? []; | ||||||
|  |         return setter => setter | ||||||
|  |             .SetProperty(f => f.Name, Name) | ||||||
|  |             .SetProperty(f => f.Description, Description) | ||||||
|  |             .SetProperty(f => f.FileMeta, FileMeta) | ||||||
|  |             .SetProperty(f => f.UserMeta, userMeta) | ||||||
|  |             .SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle); | ||||||
|     } |     } | ||||||
| } | } | ||||||
							
								
								
									
										71
									
								
								DysonNetwork.Drive/Storage/FileServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								DysonNetwork.Drive/Storage/FileServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Google.Protobuf.WellKnownTypes; | ||||||
|  | using Grpc.Core; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Storage | ||||||
|  | { | ||||||
|  |     public class FileServiceGrpc(FileService fileService) : Shared.Proto.FileService.FileServiceBase | ||||||
|  |     { | ||||||
|  |         public override async Task<Shared.Proto.CloudFile> GetFile(GetFileRequest request, ServerCallContext context) | ||||||
|  |         { | ||||||
|  |             var file = await fileService.GetFileAsync(request.Id); | ||||||
|  |             return file?.ToProtoValue() ?? throw new RpcException(new Status(StatusCode.NotFound, "File not found")); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public override async Task<GetFileBatchResponse> GetFileBatch(GetFileBatchRequest request, ServerCallContext context) | ||||||
|  |         { | ||||||
|  |             var files = await fileService.GetFilesAsync(request.Ids.ToList()); | ||||||
|  |             return new GetFileBatchResponse { Files = { files.Select(f => f.ToProtoValue()) } }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public override async Task<Shared.Proto.CloudFile> UpdateFile(UpdateFileRequest request, | ||||||
|  |             ServerCallContext context) | ||||||
|  |         { | ||||||
|  |             var file = await fileService.GetFileAsync(request.File.Id); | ||||||
|  |             if (file == null) | ||||||
|  |                 throw new RpcException(new Status(StatusCode.NotFound, "File not found")); | ||||||
|  |             var updatedFile = await fileService.UpdateFileAsync(file, request.UpdateMask); | ||||||
|  |             return updatedFile.ToProtoValue(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public override async Task<Empty> DeleteFile(DeleteFileRequest request, ServerCallContext context) | ||||||
|  |         { | ||||||
|  |             var file = await fileService.GetFileAsync(request.Id); | ||||||
|  |             if (file == null) | ||||||
|  |             { | ||||||
|  |                 throw new RpcException(new Status(StatusCode.NotFound, "File not found")); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             await fileService.DeleteFileAsync(file); | ||||||
|  |             return new Empty(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public override async Task<LoadFromReferenceResponse> LoadFromReference( | ||||||
|  |             LoadFromReferenceRequest request, | ||||||
|  |             ServerCallContext context | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             // Assuming CloudFileReferenceObject is a simple class/struct that holds an ID | ||||||
|  |             // You might need to define this or adjust the LoadFromReference method in FileService | ||||||
|  |             var references = request.ReferenceIds.Select(id => new SnCloudFileReferenceObject { Id = id }).ToList(); | ||||||
|  |             var files = await fileService.LoadFromReference(references); | ||||||
|  |             var response = new LoadFromReferenceResponse(); | ||||||
|  |             response.Files.AddRange(files.Where(f => f != null).Select(f => f!.ToProtoValue())); | ||||||
|  |             return response; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public override async Task<IsReferencedResponse> IsReferenced(IsReferencedRequest request, | ||||||
|  |             ServerCallContext context) | ||||||
|  |         { | ||||||
|  |             var isReferenced = await fileService.IsReferencedAsync(request.FileId); | ||||||
|  |             return new IsReferencedResponse { IsReferenced = isReferenced }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public override async Task<Empty> PurgeCache(PurgeCacheRequest request, ServerCallContext context) | ||||||
|  |         { | ||||||
|  |             await fileService._PurgeCacheAsync(request.FileId); | ||||||
|  |             return new Empty(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										278
									
								
								DysonNetwork.Drive/Storage/FileUploadController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										278
									
								
								DysonNetwork.Drive/Storage/FileUploadController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,278 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  | using System.Text.Json; | ||||||
|  | using DysonNetwork.Drive.Billing; | ||||||
|  | using DysonNetwork.Drive.Storage.Model; | ||||||
|  | using DysonNetwork.Shared.Auth; | ||||||
|  | using DysonNetwork.Shared.Http; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NanoidDotNet; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Storage; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/files/upload")] | ||||||
|  | [Authorize] | ||||||
|  | public class FileUploadController( | ||||||
|  |     IConfiguration configuration, | ||||||
|  |     FileService fileService, | ||||||
|  |     AppDatabase db, | ||||||
|  |     PermissionService.PermissionServiceClient permission, | ||||||
|  |     QuotaService quotaService | ||||||
|  | ) | ||||||
|  |     : ControllerBase | ||||||
|  | { | ||||||
|  |     private readonly string _tempPath = | ||||||
|  |         configuration.GetValue<string>("Storage:Uploads") ?? Path.Combine(Path.GetTempPath(), "multipart-uploads"); | ||||||
|  |  | ||||||
|  |     private const long DefaultChunkSize = 1024 * 1024 * 5; // 5MB | ||||||
|  |  | ||||||
|  |     [HttpPost("create")] | ||||||
|  |     public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!currentUser.IsSuperuser) | ||||||
|  |         { | ||||||
|  |             var allowed = await permission.HasPermissionAsync(new HasPermissionRequest | ||||||
|  |             { Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" }); | ||||||
|  |             if (!allowed.HasPermission) | ||||||
|  |             { | ||||||
|  |                 return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 }; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         request.PoolId ??= Guid.Parse(configuration["Storage:PreferredRemote"]!); | ||||||
|  |  | ||||||
|  |         var pool = await fileService.GetPoolAsync(request.PoolId.Value); | ||||||
|  |         if (pool is null) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult(ApiError.NotFound("Pool")) { StatusCode = 404 }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (pool.PolicyConfig.RequirePrivilege is > 0) | ||||||
|  |         { | ||||||
|  |             var privilege = | ||||||
|  |                 currentUser.PerkSubscription is null ? 0 : | ||||||
|  |                 PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier); | ||||||
|  |             if (privilege < pool.PolicyConfig.RequirePrivilege) | ||||||
|  |             { | ||||||
|  |                 return new ObjectResult(ApiError.Unauthorized( | ||||||
|  |                     $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use pool {pool.Name}, you are tier {privilege}", | ||||||
|  |                     forbidden: true)) | ||||||
|  |                 { | ||||||
|  |                     StatusCode = 403 | ||||||
|  |                 }; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var policy = pool.PolicyConfig; | ||||||
|  |         if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword)) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult(ApiError.Unauthorized("File encryption is not allowed in this pool", true)) | ||||||
|  |             { StatusCode = 403 }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (policy.AcceptTypes is { Count: > 0 }) | ||||||
|  |         { | ||||||
|  |             if (string.IsNullOrEmpty(request.ContentType)) | ||||||
|  |             { | ||||||
|  |                 return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]> | ||||||
|  |                 { | ||||||
|  |                     { "contentType", new[] { "Content type is required by the pool's policy" } } | ||||||
|  |                 })) | ||||||
|  |                 { StatusCode = 400 }; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var foundMatch = policy.AcceptTypes.Any(acceptType => | ||||||
|  |             { | ||||||
|  |                 if (acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |                 { | ||||||
|  |                     var type = acceptType[..^2]; | ||||||
|  |                     return request.ContentType.StartsWith($"{type}/", StringComparison.OrdinalIgnoreCase); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 return acceptType.Equals(request.ContentType, StringComparison.OrdinalIgnoreCase); | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             if (!foundMatch) | ||||||
|  |             { | ||||||
|  |                 return new ObjectResult( | ||||||
|  |                     ApiError.Unauthorized($"Content type {request.ContentType} is not allowed by the pool's policy", | ||||||
|  |                         true)) | ||||||
|  |                 { StatusCode = 403 }; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult(ApiError.Unauthorized( | ||||||
|  |                 $"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}", | ||||||
|  |                 true)) | ||||||
|  |             { | ||||||
|  |                 StatusCode = 403 | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable( | ||||||
|  |             Guid.Parse(currentUser.Id), | ||||||
|  |             pool.BillingConfig.CostMultiplier ?? 1.0, | ||||||
|  |             request.FileSize | ||||||
|  |         ); | ||||||
|  |         if (!ok) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult( | ||||||
|  |                 ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB", | ||||||
|  |                     true)) | ||||||
|  |             { StatusCode = 403 }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!Directory.Exists(_tempPath)) | ||||||
|  |         { | ||||||
|  |             Directory.CreateDirectory(_tempPath); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check if a file with the same hash already exists | ||||||
|  |         var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == request.Hash); | ||||||
|  |         if (existingFile != null) | ||||||
|  |         { | ||||||
|  |             return Ok(new CreateUploadTaskResponse | ||||||
|  |             { | ||||||
|  |                 FileExists = true, | ||||||
|  |                 File = existingFile | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var taskId = await Nanoid.GenerateAsync(); | ||||||
|  |         var taskPath = Path.Combine(_tempPath, taskId); | ||||||
|  |         Directory.CreateDirectory(taskPath); | ||||||
|  |  | ||||||
|  |         var chunkSize = request.ChunkSize ?? DefaultChunkSize; | ||||||
|  |         var chunksCount = (int)Math.Ceiling((double)request.FileSize / chunkSize); | ||||||
|  |  | ||||||
|  |         var task = new UploadTask | ||||||
|  |         { | ||||||
|  |             TaskId = taskId, | ||||||
|  |             FileName = request.FileName, | ||||||
|  |             FileSize = request.FileSize, | ||||||
|  |             ContentType = request.ContentType, | ||||||
|  |             ChunkSize = chunkSize, | ||||||
|  |             ChunksCount = chunksCount, | ||||||
|  |             PoolId = request.PoolId.Value, | ||||||
|  |             BundleId = request.BundleId, | ||||||
|  |             EncryptPassword = request.EncryptPassword, | ||||||
|  |             ExpiredAt = request.ExpiredAt, | ||||||
|  |             Hash = request.Hash, | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         await System.IO.File.WriteAllTextAsync(Path.Combine(taskPath, "task.json"), JsonSerializer.Serialize(task)); | ||||||
|  |  | ||||||
|  |         return Ok(new CreateUploadTaskResponse | ||||||
|  |         { | ||||||
|  |             FileExists = false, | ||||||
|  |             TaskId = taskId, | ||||||
|  |             ChunkSize = chunkSize, | ||||||
|  |             ChunksCount = chunksCount | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public class UploadChunkRequest | ||||||
|  |     { | ||||||
|  |         [Required] | ||||||
|  |         public IFormFile Chunk { get; set; } = null!; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost("chunk/{taskId}/{chunkIndex}")] | ||||||
|  |     [RequestSizeLimit(DefaultChunkSize + 1024 * 1024)] // 6MB to be safe | ||||||
|  |     [RequestFormLimits(MultipartBodyLengthLimit = DefaultChunkSize + 1024 * 1024)] | ||||||
|  |     public async Task<IActionResult> UploadChunk(string taskId, int chunkIndex, [FromForm] UploadChunkRequest request) | ||||||
|  |     { | ||||||
|  |         var chunk = request.Chunk; | ||||||
|  |         var taskPath = Path.Combine(_tempPath, taskId); | ||||||
|  |         if (!Directory.Exists(taskPath)) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk"); | ||||||
|  |         await using var stream = new FileStream(chunkPath, FileMode.Create); | ||||||
|  |         await chunk.CopyToAsync(stream); | ||||||
|  |  | ||||||
|  |         return Ok(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost("complete/{taskId}")] | ||||||
|  |     public async Task<IActionResult> CompleteUpload(string taskId) | ||||||
|  |     { | ||||||
|  |         var taskPath = Path.Combine(_tempPath, taskId); | ||||||
|  |         if (!Directory.Exists(taskPath)) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var taskJsonPath = Path.Combine(taskPath, "task.json"); | ||||||
|  |         if (!System.IO.File.Exists(taskJsonPath)) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult(ApiError.NotFound("Upload task metadata")) { StatusCode = 404 }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var task = JsonSerializer.Deserialize<UploadTask>(await System.IO.File.ReadAllTextAsync(taskJsonPath)); | ||||||
|  |         if (task == null) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult(new ApiError { Code = "BAD_REQUEST", Message = "Invalid task metadata.", Status = 400 }) | ||||||
|  |             { StatusCode = 400 }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp"); | ||||||
|  |         await using (var mergedStream = new FileStream(mergedFilePath, FileMode.Create)) | ||||||
|  |         { | ||||||
|  |             for (var i = 0; i < task.ChunksCount; i++) | ||||||
|  |             { | ||||||
|  |                 var chunkPath = Path.Combine(taskPath, $"{i}.chunk"); | ||||||
|  |                 if (!System.IO.File.Exists(chunkPath)) | ||||||
|  |                 { | ||||||
|  |                     // Clean up partially uploaded file | ||||||
|  |                     mergedStream.Close(); | ||||||
|  |                     System.IO.File.Delete(mergedFilePath); | ||||||
|  |                     Directory.Delete(taskPath, true); | ||||||
|  |                     return new ObjectResult(new ApiError | ||||||
|  |                     { Code = "CHUNK_MISSING", Message = $"Chunk {i} is missing.", Status = 400 }) | ||||||
|  |                     { StatusCode = 400 }; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 await using var chunkStream = new FileStream(chunkPath, FileMode.Open); | ||||||
|  |                 await chunkStream.CopyToAsync(mergedStream); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var fileId = await Nanoid.GenerateAsync(); | ||||||
|  |  | ||||||
|  |         var cloudFile = await fileService.ProcessNewFileAsync( | ||||||
|  |             currentUser, | ||||||
|  |             fileId, | ||||||
|  |             task.PoolId.ToString(), | ||||||
|  |             task.BundleId?.ToString(), | ||||||
|  |             mergedFilePath, | ||||||
|  |             task.FileName, | ||||||
|  |             task.ContentType, | ||||||
|  |             task.EncryptPassword, | ||||||
|  |             task.ExpiredAt | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         // Clean up | ||||||
|  |         Directory.Delete(taskPath, true); | ||||||
|  |         System.IO.File.Delete(mergedFilePath); | ||||||
|  |  | ||||||
|  |         return Ok(cloudFile); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								DysonNetwork.Drive/Storage/Model/Events.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								DysonNetwork.Drive/Storage/Model/Events.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | namespace DysonNetwork.Drive.Storage.Model; | ||||||
|  |  | ||||||
|  | public static class FileUploadedEvent | ||||||
|  | { | ||||||
|  |     public const string Type = "file_uploaded"; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public record FileUploadedEventPayload( | ||||||
|  |     string FileId, | ||||||
|  |     Guid RemoteId, | ||||||
|  |     string StorageId, | ||||||
|  |     string ContentType, | ||||||
|  |     string ProcessingFilePath, | ||||||
|  |     bool IsTempFile | ||||||
|  | ); | ||||||
							
								
								
									
										42
									
								
								DysonNetwork.Drive/Storage/Model/FileUploadModels.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								DysonNetwork.Drive/Storage/Model/FileUploadModels.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Storage.Model | ||||||
|  | { | ||||||
|  |     public class CreateUploadTaskRequest | ||||||
|  |     { | ||||||
|  |         public string Hash { get; set; } = null!; | ||||||
|  |         public string FileName { get; set; } = null!; | ||||||
|  |         public long FileSize { get; set; } | ||||||
|  |         public string ContentType { get; set; } = null!; | ||||||
|  |         public Guid? PoolId { get; set; } = null!; | ||||||
|  |         public Guid? BundleId { get; set; } | ||||||
|  |         public string? EncryptPassword { get; set; } | ||||||
|  |         public Instant? ExpiredAt { get; set; } | ||||||
|  |         public long? ChunkSize { get; set; } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public class CreateUploadTaskResponse | ||||||
|  |     { | ||||||
|  |         public bool FileExists { get; set; } | ||||||
|  |         public SnCloudFile? File { get; set; } | ||||||
|  |         public string? TaskId { get; set; } | ||||||
|  |         public long? ChunkSize { get; set; } | ||||||
|  |         public int? ChunksCount { get; set; } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     internal class UploadTask | ||||||
|  |     { | ||||||
|  |         public string TaskId { get; set; } = null!; | ||||||
|  |         public string FileName { get; set; } = null!; | ||||||
|  |         public long FileSize { get; set; } | ||||||
|  |         public string ContentType { get; set; } = null!; | ||||||
|  |         public long ChunkSize { get; set; } | ||||||
|  |         public int ChunksCount { get; set; } | ||||||
|  |         public Guid PoolId { get; set; } | ||||||
|  |         public Guid? BundleId { get; set; } | ||||||
|  |         public string? EncryptPassword { get; set; } | ||||||
|  |         public Instant? ExpiredAt { get; set; } | ||||||
|  |         public string Hash { get; set; } = null!; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										94
									
								
								DysonNetwork.Drive/Storage/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								DysonNetwork.Drive/Storage/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | |||||||
|  | # Multi-part File Upload API | ||||||
|  |  | ||||||
|  | This document outlines the process for uploading large files in chunks using the multi-part upload API. | ||||||
|  |  | ||||||
|  | ## 1. Create an Upload Task | ||||||
|  |  | ||||||
|  | To begin a file upload, you first need to create an upload task. This is done by sending a `POST` request to the `/api/files/upload/create` endpoint. | ||||||
|  |  | ||||||
|  | **Endpoint:** `POST /api/files/upload/create` | ||||||
|  |  | ||||||
|  | **Request Body:** | ||||||
|  |  | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "hash": "string (file hash, e.g., MD5 or SHA256)", | ||||||
|  |   "file_name": "string", | ||||||
|  |   "file_size": "long (in bytes)", | ||||||
|  |   "content_type": "string (e.g., 'image/jpeg')", | ||||||
|  |   "pool_id": "string (GUID, optional)", | ||||||
|  |   "bundle_id": "string (GUID, optional)", | ||||||
|  |   "encrypt_password": "string (optional)", | ||||||
|  |   "expired_at": "string (ISO 8601 format, optional)", | ||||||
|  |   "chunk_size": "long (in bytes, optional, defaults to 5MB)" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Response:** | ||||||
|  |  | ||||||
|  | If a file with the same hash already exists, the server will return a `200 OK` with the following body: | ||||||
|  |  | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "file_exists": true, | ||||||
|  |   "file": { ... (CloudFile object in snake_case) ... } | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | If the file does not exist, the server will return a `200 OK` with a task ID and chunk information: | ||||||
|  |  | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "file_exists": false, | ||||||
|  |   "task_id": "string", | ||||||
|  |   "chunk_size": "long", | ||||||
|  |   "chunks_count": "int" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | You will need the `task_id`, `chunk_size`, and `chunks_count` for the next steps. | ||||||
|  |  | ||||||
|  | ## 2. Upload File Chunks | ||||||
|  |  | ||||||
|  | Once you have a `task_id`, you can start uploading the file in chunks. Each chunk is sent as a `POST` request with `multipart/form-data`. | ||||||
|  |  | ||||||
|  | **Endpoint:** `POST /api/files/upload/chunk/{taskId}/{chunkIndex}` | ||||||
|  |  | ||||||
|  | -   `taskId`: The ID of the upload task from the previous step. | ||||||
|  | -   `chunkIndex`: The 0-based index of the chunk you are uploading. | ||||||
|  |  | ||||||
|  | **Request Body:** | ||||||
|  |  | ||||||
|  | The body of the request should be `multipart/form-data` with a single form field named `chunk` containing the binary data for that chunk. | ||||||
|  |  | ||||||
|  | The size of each chunk should be equal to the `chunk_size` returned in the "Create Upload Task" step, except for the last chunk, which may be smaller. | ||||||
|  |  | ||||||
|  | **Response:** | ||||||
|  |  | ||||||
|  | A successful chunk upload will return a `200 OK` with an empty body. | ||||||
|  |  | ||||||
|  | You should upload all chunks from `0` to `chunks_count - 1`. | ||||||
|  |  | ||||||
|  | ## 3. Complete the Upload | ||||||
|  |  | ||||||
|  | After all chunks have been successfully uploaded, you must send a final request to complete the upload process. This will merge all the chunks into a single file and process it. | ||||||
|  |  | ||||||
|  | **Endpoint:** `POST /api/files/upload/complete/{taskId}` | ||||||
|  |  | ||||||
|  | -   `taskId`: The ID of the upload task. | ||||||
|  |  | ||||||
|  | **Request Body:** | ||||||
|  |  | ||||||
|  | The request body should be empty. | ||||||
|  |  | ||||||
|  | **Response:** | ||||||
|  |  | ||||||
|  | A successful request will return a `200 OK` with the `CloudFile` object for the newly uploaded file. | ||||||
|  |  | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   ... (CloudFile object) ... | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | If any chunks are missing or an error occurs during the merge process, the server will return a `400 Bad Request` with an error message. | ||||||
| @@ -1,9 +1,12 @@ | |||||||
| using System.Net; | using System.Net; | ||||||
| using System.Text; | using System.Text; | ||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
|  | using DysonNetwork.Drive.Billing; | ||||||
|  | using DysonNetwork.Shared.Auth; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using Microsoft.Extensions.Options; | using Microsoft.Extensions.Options; | ||||||
|  | using NodaTime; | ||||||
| using tusdotnet.Interfaces; | using tusdotnet.Interfaces; | ||||||
| using tusdotnet.Models; | using tusdotnet.Models; | ||||||
| using tusdotnet.Models.Configuration; | using tusdotnet.Models.Configuration; | ||||||
| @@ -12,7 +15,10 @@ namespace DysonNetwork.Drive.Storage; | |||||||
|  |  | ||||||
| public abstract class TusService | public abstract class TusService | ||||||
| { | { | ||||||
|     public static DefaultTusConfiguration BuildConfiguration(ITusStore store) => new() |     public static DefaultTusConfiguration BuildConfiguration( | ||||||
|  |         ITusStore store, | ||||||
|  |         IConfiguration configuration | ||||||
|  |     ) => new() | ||||||
|     { |     { | ||||||
|         Store = store, |         Store = store, | ||||||
|         Events = new Events |         Events = new Events | ||||||
| @@ -29,21 +35,68 @@ public abstract class TusService | |||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 var httpContext = eventContext.HttpContext; |                 var httpContext = eventContext.HttpContext; | ||||||
|                 if (httpContext.Items["CurrentUser"] is not Account user) |                 if (httpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|                 { |                 { | ||||||
|                     eventContext.FailRequest(HttpStatusCode.Unauthorized); |                     eventContext.FailRequest(HttpStatusCode.Unauthorized); | ||||||
|                     return; |                     return; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 if (!user.IsSuperuser) |                 if (eventContext.Intent != IntentType.CreateFile) return; | ||||||
|  |  | ||||||
|  |                 using var scope = httpContext.RequestServices.CreateScope(); | ||||||
|  |  | ||||||
|  |                 if (!currentUser.IsSuperuser) | ||||||
|                 { |                 { | ||||||
|                     using var scope = httpContext.RequestServices.CreateScope(); |  | ||||||
|                     var pm = scope.ServiceProvider.GetRequiredService<PermissionService.PermissionServiceClient>(); |                     var pm = scope.ServiceProvider.GetRequiredService<PermissionService.PermissionServiceClient>(); | ||||||
|                     var allowed = await pm.HasPermissionAsync(new HasPermissionRequest |                     var allowed = await pm.HasPermissionAsync(new HasPermissionRequest | ||||||
|                         { Actor = $"user:{user.Id}", Area = "global", Key = "files.create" }); |                         { Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" }); | ||||||
|                     if (!allowed.HasPermission) |                     if (!allowed.HasPermission) | ||||||
|                         eventContext.FailRequest(HttpStatusCode.Forbidden); |                         eventContext.FailRequest(HttpStatusCode.Forbidden); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  |                 var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault(); | ||||||
|  |                 if (string.IsNullOrEmpty(filePool)) filePool = configuration["Storage:PreferredRemote"]; | ||||||
|  |                 if (!Guid.TryParse(filePool, out _)) | ||||||
|  |                 { | ||||||
|  |                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id"); | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var fs = scope.ServiceProvider.GetRequiredService<FileService>(); | ||||||
|  |                 var pool = await fs.GetPoolAsync(Guid.Parse(filePool!)); | ||||||
|  |                 if (pool is null) | ||||||
|  |                 { | ||||||
|  |                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found"); | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (pool.PolicyConfig.RequirePrivilege > 0) | ||||||
|  |                 { | ||||||
|  |                     if (currentUser.PerkSubscription is null) | ||||||
|  |                     { | ||||||
|  |                         eventContext.FailRequest( | ||||||
|  |                             HttpStatusCode.Forbidden, | ||||||
|  |                             $"You need to have join the Stellar Program to use this pool" | ||||||
|  |                         ); | ||||||
|  |                         return; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     var privilege = | ||||||
|  |                         PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier); | ||||||
|  |                     if (privilege < pool.PolicyConfig.RequirePrivilege) | ||||||
|  |                     { | ||||||
|  |                         eventContext.FailRequest( | ||||||
|  |                             HttpStatusCode.Forbidden, | ||||||
|  |                             $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}" | ||||||
|  |                         ); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault(); | ||||||
|  |                 if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _)) | ||||||
|  |                 { | ||||||
|  |                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id"); | ||||||
|  |                 } | ||||||
|             }, |             }, | ||||||
|             OnFileCompleteAsync = async eventContext => |             OnFileCompleteAsync = async eventContext => | ||||||
|             { |             { | ||||||
| @@ -60,20 +113,189 @@ public abstract class TusService | |||||||
|                     : "uploaded_file"; |                     : "uploaded_file"; | ||||||
|                 var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null; |                 var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null; | ||||||
|  |  | ||||||
|                 var fileStream = await file.GetContentAsync(eventContext.CancellationToken); |                 var filePath = Path.Combine(configuration.GetValue<string>("Tus:StorePath")!, file.Id); | ||||||
|  |  | ||||||
|                 var fileService = services.GetRequiredService<FileService>(); |                 var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault(); | ||||||
|                 var info = await fileService.ProcessNewFileAsync(user, file.Id, fileStream, fileName, contentType); |                 var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault(); | ||||||
|  |                 var encryptPassword = httpContext.Request.Headers["X-FilePass"].FirstOrDefault(); | ||||||
|  |  | ||||||
|                 using var finalScope = eventContext.HttpContext.RequestServices.CreateScope(); |                 if (string.IsNullOrEmpty(filePool)) | ||||||
|                 var jsonOptions = finalScope.ServiceProvider.GetRequiredService<IOptions<JsonOptions>>().Value |                     filePool = configuration["Storage:PreferredRemote"]; | ||||||
|                     .JsonSerializerOptions; |  | ||||||
|                 var infoJson = JsonSerializer.Serialize(info, jsonOptions); |  | ||||||
|                 eventContext.HttpContext.Response.Headers.Append("X-FileInfo", infoJson); |  | ||||||
|  |  | ||||||
|                 // Dispose the stream after all processing is complete |                 Instant? expiredAt = null; | ||||||
|                 await fileStream.DisposeAsync(); |                 var expiredString = httpContext.Request.Headers["X-FileExpire"].FirstOrDefault(); | ||||||
|             } |                 if (!string.IsNullOrEmpty(expiredString) && int.TryParse(expiredString, out var expired)) | ||||||
|  |                     expiredAt = Instant.FromUnixTimeSeconds(expired); | ||||||
|  |                  | ||||||
|  |                 try | ||||||
|  |                 { | ||||||
|  |                     var fileService = services.GetRequiredService<FileService>(); | ||||||
|  |                     var info = await fileService.ProcessNewFileAsync( | ||||||
|  |                         user, | ||||||
|  |                         file.Id, | ||||||
|  |                         filePool!, | ||||||
|  |                         bundleId, | ||||||
|  |                         filePath, | ||||||
|  |                         fileName, | ||||||
|  |                         contentType, | ||||||
|  |                         encryptPassword, | ||||||
|  |                         expiredAt | ||||||
|  |                     ); | ||||||
|  |  | ||||||
|  |                     using var finalScope = eventContext.HttpContext.RequestServices.CreateScope(); | ||||||
|  |                     var jsonOptions = finalScope.ServiceProvider.GetRequiredService<IOptions<JsonOptions>>().Value | ||||||
|  |                         .JsonSerializerOptions; | ||||||
|  |                     var infoJson = JsonSerializer.Serialize(info, jsonOptions); | ||||||
|  |                     eventContext.HttpContext.Response.Headers.Append("X-FileInfo", infoJson); | ||||||
|  |                 } | ||||||
|  |                 catch (Exception ex) | ||||||
|  |                 { | ||||||
|  |                     var logger = services.GetRequiredService<ILogger<TusService>>(); | ||||||
|  |                     eventContext.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; | ||||||
|  |                     await eventContext.HttpContext.Response.WriteAsync(ex.Message); | ||||||
|  |                     logger.LogError(ex, "Error handling file upload..."); | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             OnBeforeCreateAsync = async eventContext => | ||||||
|  |             { | ||||||
|  |                 var httpContext = eventContext.HttpContext; | ||||||
|  |                 if (httpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |                 { | ||||||
|  |                     eventContext.FailRequest(HttpStatusCode.Unauthorized); | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  |                 var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |                 var poolId = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault(); | ||||||
|  |                 if (string.IsNullOrEmpty(poolId)) poolId = configuration["Storage:PreferredRemote"]; | ||||||
|  |                 if (!Guid.TryParse(poolId, out _)) | ||||||
|  |                 { | ||||||
|  |                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id"); | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault(); | ||||||
|  |                 if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _)) | ||||||
|  |                 { | ||||||
|  |                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id"); | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var metadata = eventContext.Metadata; | ||||||
|  |                 var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null; | ||||||
|  |  | ||||||
|  |                 var scope = eventContext.HttpContext.RequestServices.CreateScope(); | ||||||
|  |  | ||||||
|  |                 var rejected = false; | ||||||
|  |  | ||||||
|  |                 var fs = scope.ServiceProvider.GetRequiredService<FileService>(); | ||||||
|  |                 var pool = await fs.GetPoolAsync(Guid.Parse(poolId!)); | ||||||
|  |                 if (pool is null) | ||||||
|  |                 { | ||||||
|  |                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found"); | ||||||
|  |                     rejected = true; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var logger = scope.ServiceProvider.GetRequiredService<ILogger<TusService>>(); | ||||||
|  |  | ||||||
|  |                 // Do the policy check | ||||||
|  |                 var policy = pool!.PolicyConfig; | ||||||
|  |                 if (!rejected && !pool.PolicyConfig.AllowEncryption) | ||||||
|  |                 { | ||||||
|  |                     var encryptPassword = eventContext.HttpContext.Request.Headers["X-FilePass"].FirstOrDefault(); | ||||||
|  |                     if (!string.IsNullOrEmpty(encryptPassword)) | ||||||
|  |                     { | ||||||
|  |                         eventContext.FailRequest( | ||||||
|  |                             HttpStatusCode.Forbidden, | ||||||
|  |                             "File encryption is not allowed in this pool" | ||||||
|  |                         ); | ||||||
|  |                         rejected = true; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (!rejected && policy.AcceptTypes is not null) | ||||||
|  |                 { | ||||||
|  |                     if (string.IsNullOrEmpty(contentType)) | ||||||
|  |                     { | ||||||
|  |                         eventContext.FailRequest( | ||||||
|  |                             HttpStatusCode.BadRequest, | ||||||
|  |                             "Content type is required by the pool's policy" | ||||||
|  |                         ); | ||||||
|  |                         rejected = true; | ||||||
|  |                     } | ||||||
|  |                     else | ||||||
|  |                     { | ||||||
|  |                         var foundMatch = false; | ||||||
|  |                         foreach (var acceptType in policy.AcceptTypes) | ||||||
|  |                         { | ||||||
|  |                             if (acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |                             { | ||||||
|  |                                 var type = acceptType[..^2]; | ||||||
|  |                                 if (!contentType.StartsWith($"{type}/", StringComparison.OrdinalIgnoreCase)) continue; | ||||||
|  |                                 foundMatch = true; | ||||||
|  |                                 break; | ||||||
|  |                             } | ||||||
|  |                             else if (acceptType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) | ||||||
|  |                             { | ||||||
|  |                                 foundMatch = true; | ||||||
|  |                                 break; | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if (!foundMatch) | ||||||
|  |                         { | ||||||
|  |                             eventContext.FailRequest( | ||||||
|  |                                 HttpStatusCode.Forbidden, | ||||||
|  |                                 $"Content type {contentType} is not allowed by the pool's policy" | ||||||
|  |                             ); | ||||||
|  |                             rejected = true; | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (!rejected && policy.MaxFileSize is not null) | ||||||
|  |                 { | ||||||
|  |                     if (eventContext.UploadLength > policy.MaxFileSize) | ||||||
|  |                     { | ||||||
|  |                         eventContext.FailRequest( | ||||||
|  |                             HttpStatusCode.Forbidden, | ||||||
|  |                             $"File size {eventContext.UploadLength} is larger than the pool's maximum file size {policy.MaxFileSize}" | ||||||
|  |                         ); | ||||||
|  |                         rejected = true; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (!rejected) | ||||||
|  |                 { | ||||||
|  |                     var quotaService = scope.ServiceProvider.GetRequiredService<QuotaService>(); | ||||||
|  |                     var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable( | ||||||
|  |                         accountId, | ||||||
|  |                         pool.BillingConfig.CostMultiplier ?? 1.0, | ||||||
|  |                         eventContext.UploadLength | ||||||
|  |                     ); | ||||||
|  |                     if (!ok) | ||||||
|  |                     { | ||||||
|  |                         eventContext.FailRequest( | ||||||
|  |                             HttpStatusCode.Forbidden, | ||||||
|  |                             $"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB" | ||||||
|  |                         ); | ||||||
|  |                         rejected = true; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (rejected) | ||||||
|  |                     logger.LogInformation("File rejected #{FileId}", eventContext.FileId); | ||||||
|  |             }, | ||||||
|  |             OnCreateCompleteAsync = eventContext => | ||||||
|  |             { | ||||||
|  |                 var directUpload = eventContext.HttpContext.Request.Headers["X-DirectUpload"].FirstOrDefault(); | ||||||
|  |                 if (!string.IsNullOrEmpty(directUpload)) return Task.CompletedTask; | ||||||
|  |  | ||||||
|  |                 var gatewayUrl = configuration["GatewayUrl"]; | ||||||
|  |                 if (gatewayUrl is not null) | ||||||
|  |                     eventContext.SetUploadUrl(new Uri(gatewayUrl + "/drive/tus/" + eventContext.FileId)); | ||||||
|  |                 return Task.CompletedTask; | ||||||
|  |             }, | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
							
								
								
									
										20
									
								
								DysonNetwork.Drive/VersionController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								DysonNetwork.Drive/VersionController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | using DysonNetwork.Shared.Data; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/version")] | ||||||
|  | public class VersionController : ControllerBase | ||||||
|  | { | ||||||
|  |     [HttpGet] | ||||||
|  |     public IActionResult Get() | ||||||
|  |     { | ||||||
|  |         return Ok(new AppVersion | ||||||
|  |         { | ||||||
|  |             Version = ThisAssembly.AssemblyVersion, | ||||||
|  |             Commit = ThisAssembly.GitCommitId, | ||||||
|  |             UpdateDate = ThisAssembly.GitCommitDate | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| { | { | ||||||
|   "Debug": true, |   "Debug": true, | ||||||
|   "BaseUrl": "http://localhost:5071", |   "BaseUrl": "http://localhost:5090", | ||||||
|  |   "GatewayUrl": "http://localhost:5094", | ||||||
|   "Logging": { |   "Logging": { | ||||||
|     "LogLevel": { |     "LogLevel": { | ||||||
|       "Default": "Information", |       "Default": "Information", | ||||||
| @@ -9,9 +10,7 @@ | |||||||
|   }, |   }, | ||||||
|   "AllowedHosts": "*", |   "AllowedHosts": "*", | ||||||
|   "ConnectionStrings": { |   "ConnectionStrings": { | ||||||
|     "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", |     "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" | ||||||
|     "FastRetrieve": "localhost:6379", |  | ||||||
|     "Etcd": "etcd.orb.local:2379" |  | ||||||
|   }, |   }, | ||||||
|   "Authentication": { |   "Authentication": { | ||||||
|     "Schemes": { |     "Schemes": { | ||||||
| @@ -28,20 +27,12 @@ | |||||||
|     "PublicKeyPath": "Keys/PublicKey.pem", |     "PublicKeyPath": "Keys/PublicKey.pem", | ||||||
|     "PrivateKeyPath": "Keys/PrivateKey.pem" |     "PrivateKeyPath": "Keys/PrivateKey.pem" | ||||||
|   }, |   }, | ||||||
|   "OidcProvider": { |  | ||||||
|     "IssuerUri": "https://nt.solian.app", |  | ||||||
|     "PublicKeyPath": "Keys/PublicKey.pem", |  | ||||||
|     "PrivateKeyPath": "Keys/PrivateKey.pem", |  | ||||||
|     "AccessTokenLifetime": "01:00:00", |  | ||||||
|     "RefreshTokenLifetime": "30.00:00:00", |  | ||||||
|     "AuthorizationCodeLifetime": "00:30:00", |  | ||||||
|     "RequireHttpsMetadata": true |  | ||||||
|   }, |  | ||||||
|   "Tus": { |   "Tus": { | ||||||
|     "StorePath": "Uploads" |     "StorePath": "Uploads" | ||||||
|   }, |   }, | ||||||
|   "Storage": { |   "Storage": { | ||||||
|     "PreferredRemote": "minio", |     "Uploads": "Uploads", | ||||||
|  |     "PreferredRemote": "2adceae3-981a-4564-9b8d-5d71a211c873", | ||||||
|     "Remote": [ |     "Remote": [ | ||||||
|       { |       { | ||||||
|         "Id": "minio", |         "Id": "minio", | ||||||
| @@ -129,8 +120,6 @@ | |||||||
|   ], |   ], | ||||||
|   "Service": { |   "Service": { | ||||||
|     "Name": "DysonNetwork.Drive", |     "Name": "DysonNetwork.Drive", | ||||||
|     "Url": "https://localhost:7092", |     "Url": "https://localhost:7092" | ||||||
|     "ClientCert": "../Certificates/client.crt", |  | ||||||
|     "ClientKey": "../Certificates/client.key" |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								DysonNetwork.Drive/version.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								DysonNetwork.Drive/version.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | { | ||||||
|  |   "version": "1.0", | ||||||
|  |   "publicReleaseRefSpec": ["^refs/heads/main$"], | ||||||
|  |   "cloudBuild": { | ||||||
|  |     "setVersionVariables": true | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,12 @@ | |||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("config")] | ||||||
|  | public class ConfigurationController(IConfiguration configuration) : ControllerBase | ||||||
|  | { | ||||||
|  |     [HttpGet] | ||||||
|  |     public IActionResult Get() => Ok(configuration.GetSection("Client").Get<Dictionary<string, object>>()); | ||||||
|  |  | ||||||
|  |     [HttpGet("site")] | ||||||
|  |     public IActionResult GetSiteUrl() => Ok(configuration["SiteUrl"]); | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								DysonNetwork.Gateway/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								DysonNetwork.Gateway/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base | ||||||
|  | USER $APP_UID | ||||||
|  | WORKDIR /app | ||||||
|  | EXPOSE 8080 | ||||||
|  | EXPOSE 8081 | ||||||
|  |  | ||||||
|  | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||||
|  | ARG BUILD_CONFIGURATION=Release | ||||||
|  | WORKDIR /src | ||||||
|  | COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"] | ||||||
|  | RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj" | ||||||
|  | COPY . . | ||||||
|  | WORKDIR "/src/DysonNetwork.Gateway" | ||||||
|  | RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build | ||||||
|  |  | ||||||
|  | FROM build AS publish | ||||||
|  | ARG BUILD_CONFIGURATION=Release | ||||||
|  | RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false | ||||||
|  |  | ||||||
|  | FROM base AS final | ||||||
|  | WORKDIR /app | ||||||
|  | COPY --from=publish /app/publish . | ||||||
|  | ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"] | ||||||
							
								
								
									
										18
									
								
								DysonNetwork.Gateway/DysonNetwork.Gateway.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								DysonNetwork.Gateway/DysonNetwork.Gateway.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||||
|  |  | ||||||
|  |   <PropertyGroup> | ||||||
|  |     <TargetFramework>net9.0</TargetFramework> | ||||||
|  |     <Nullable>enable</Nullable> | ||||||
|  |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|  |   </PropertyGroup> | ||||||
|  |  | ||||||
|  |   <ItemGroup> | ||||||
|  |     <PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="9.4.2" /> | ||||||
|  |     <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |  | ||||||
|  |   <ItemGroup> | ||||||
|  |       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |  | ||||||
|  | </Project> | ||||||
							
								
								
									
										171
									
								
								DysonNetwork.Gateway/Program.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								DysonNetwork.Gateway/Program.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,171 @@ | |||||||
|  | using System.Threading.RateLimiting; | ||||||
|  | using DysonNetwork.Shared.Http; | ||||||
|  | using Yarp.ReverseProxy.Configuration; | ||||||
|  | using Microsoft.AspNetCore.HttpOverrides; | ||||||
|  |  | ||||||
|  | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
|  | builder.AddServiceDefaults(); | ||||||
|  |  | ||||||
|  | builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue, enableGrpc: false); | ||||||
|  |  | ||||||
|  | builder.Services.AddCors(options => | ||||||
|  | { | ||||||
|  |     options.AddDefaultPolicy( | ||||||
|  |         policy => | ||||||
|  |         { | ||||||
|  |             policy.SetIsOriginAllowed(origin => true) | ||||||
|  |                 .AllowAnyMethod() | ||||||
|  |                 .AllowAnyHeader() | ||||||
|  |                 .AllowCredentials() | ||||||
|  |                 .WithExposedHeaders("X-Total"); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | builder.Services.AddRateLimiter(options => | ||||||
|  | { | ||||||
|  |     options.AddPolicy("fixed", context => | ||||||
|  |     { | ||||||
|  |         var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; | ||||||
|  |  | ||||||
|  |         return RateLimitPartition.GetFixedWindowLimiter( | ||||||
|  |             partitionKey: ip, | ||||||
|  |             factory: _ => new FixedWindowRateLimiterOptions | ||||||
|  |             { | ||||||
|  |                 PermitLimit = 120, // 120 requests... | ||||||
|  |                 Window = TimeSpan.FromMinutes(1), // ...per minute per IP | ||||||
|  |                 QueueProcessingOrder = QueueProcessingOrder.OldestFirst, | ||||||
|  |                 QueueLimit = 10 // allow short bursts instead of instant 503s | ||||||
|  |             }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     options.OnRejected = async (context, token) => | ||||||
|  |         { | ||||||
|  |             // Log the rejected IP | ||||||
|  |             var logger = context.HttpContext.RequestServices | ||||||
|  |                 .GetRequiredService<ILoggerFactory>() | ||||||
|  |                 .CreateLogger("RateLimiter"); | ||||||
|  |  | ||||||
|  |             var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; | ||||||
|  |             logger.LogWarning("Rate limit exceeded for IP: {IP}", ip); | ||||||
|  |  | ||||||
|  |             // Respond to the client | ||||||
|  |             context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; | ||||||
|  |             await context.HttpContext.Response.WriteAsync( | ||||||
|  |                 "Rate limit exceeded. Try again later.", token); | ||||||
|  |         }; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop" }; | ||||||
|  |  | ||||||
|  | var specialRoutes = new[] | ||||||
|  | { | ||||||
|  |     new RouteConfig | ||||||
|  |     { | ||||||
|  |         RouteId = "ring-ws", | ||||||
|  |         ClusterId = "ring", | ||||||
|  |         Match = new RouteMatch { Path = "/ws" } | ||||||
|  |     }, | ||||||
|  |     new RouteConfig | ||||||
|  |     { | ||||||
|  |         RouteId = "pass-openid", | ||||||
|  |         ClusterId = "pass", | ||||||
|  |         Match = new RouteMatch { Path = "/.well-known/openid-configuration" } | ||||||
|  |     }, | ||||||
|  |     new RouteConfig | ||||||
|  |     { | ||||||
|  |         RouteId = "pass-jwks", | ||||||
|  |         ClusterId = "pass", | ||||||
|  |         Match = new RouteMatch { Path = "/.well-known/jwks" } | ||||||
|  |     }, | ||||||
|  |     new RouteConfig | ||||||
|  |     { | ||||||
|  |         RouteId = "drive-tus", | ||||||
|  |         ClusterId = "drive", | ||||||
|  |         Match = new RouteMatch { Path = "/api/tus" } | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | var apiRoutes = serviceNames.Select(serviceName => | ||||||
|  | { | ||||||
|  |     var apiPath = serviceName switch | ||||||
|  |     { | ||||||
|  |         "pass" => "/id", | ||||||
|  |         _ => $"/{serviceName}" | ||||||
|  |     }; | ||||||
|  |     return new RouteConfig | ||||||
|  |     { | ||||||
|  |         RouteId = $"{serviceName}-api", | ||||||
|  |         ClusterId = serviceName, | ||||||
|  |         Match = new RouteMatch { Path = $"{apiPath}/{{**catch-all}}" }, | ||||||
|  |         Transforms = | ||||||
|  |         [ | ||||||
|  |             new Dictionary<string, string> { { "PathRemovePrefix", apiPath } }, | ||||||
|  |             new Dictionary<string, string> { { "PathPrefix", "/api" } } | ||||||
|  |         ] | ||||||
|  |     }; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig | ||||||
|  | { | ||||||
|  |     RouteId = $"{serviceName}-swagger", | ||||||
|  |     ClusterId = serviceName, | ||||||
|  |     Match = new RouteMatch { Path = $"/swagger/{serviceName}/{{**catch-all}}" }, | ||||||
|  |     Transforms = | ||||||
|  |     [ | ||||||
|  |         new Dictionary<string, string> { { "PathRemovePrefix", $"/swagger/{serviceName}" } }, | ||||||
|  |         new Dictionary<string, string> { { "PathPrefix", "/swagger" } } | ||||||
|  |     ] | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray(); | ||||||
|  |  | ||||||
|  | var clusters = serviceNames.Select(serviceName => new ClusterConfig | ||||||
|  | { | ||||||
|  |     ClusterId = serviceName, | ||||||
|  |     HealthCheck = new() | ||||||
|  |     { | ||||||
|  |         Active = new() | ||||||
|  |         { | ||||||
|  |             Enabled = true, | ||||||
|  |             Interval = TimeSpan.FromSeconds(10), | ||||||
|  |             Timeout = TimeSpan.FromSeconds(5), | ||||||
|  |             Path = "/health" | ||||||
|  |         }, | ||||||
|  |         Passive = new() | ||||||
|  |         { | ||||||
|  |             Enabled = true | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |     Destinations = new Dictionary<string, DestinationConfig> | ||||||
|  |     { | ||||||
|  |         { "destination1", new DestinationConfig { Address = $"http://{serviceName}" } } | ||||||
|  |     } | ||||||
|  | }).ToArray(); | ||||||
|  |  | ||||||
|  | builder.Services | ||||||
|  |     .AddReverseProxy() | ||||||
|  |     .LoadFromMemory(routes, clusters) | ||||||
|  |     .AddServiceDiscoveryDestinationResolver(); | ||||||
|  |  | ||||||
|  | builder.Services.AddControllers(); | ||||||
|  |  | ||||||
|  | var app = builder.Build(); | ||||||
|  |  | ||||||
|  | var forwardedHeadersOptions = new ForwardedHeadersOptions | ||||||
|  | { | ||||||
|  |     ForwardedHeaders = ForwardedHeaders.All | ||||||
|  | }; | ||||||
|  | forwardedHeadersOptions.KnownNetworks.Clear(); | ||||||
|  | forwardedHeadersOptions.KnownProxies.Clear(); | ||||||
|  | app.UseForwardedHeaders(forwardedHeadersOptions); | ||||||
|  |  | ||||||
|  | app.UseCors(); | ||||||
|  |  | ||||||
|  | app.UseRateLimiter(); | ||||||
|  |  | ||||||
|  | app.MapReverseProxy().RequireRateLimiting("fixed"); | ||||||
|  |  | ||||||
|  | app.MapControllers(); | ||||||
|  |  | ||||||
|  | app.Run(); | ||||||
							
								
								
									
										21
									
								
								DysonNetwork.Gateway/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								DysonNetwork.Gateway/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "$schema": "https://json.schemastore.org/launchsettings.json", | ||||||
|  |   "profiles": { | ||||||
|  |     "http": { | ||||||
|  |       "commandName": "Project", | ||||||
|  |       "dotnetRunMessages": true, | ||||||
|  |       "launchBrowser": true, | ||||||
|  |       "environmentVariables": { | ||||||
|  |         "ASPNETCORE_ENVIRONMENT": "Development" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "https": { | ||||||
|  |       "commandName": "Project", | ||||||
|  |       "dotnetRunMessages": true, | ||||||
|  |       "launchBrowser": true, | ||||||
|  |       "environmentVariables": { | ||||||
|  |         "ASPNETCORE_ENVIRONMENT": "Development" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								DysonNetwork.Gateway/appsettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								DysonNetwork.Gateway/appsettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | { | ||||||
|  |   "Logging": { | ||||||
|  |     "LogLevel": { | ||||||
|  |       "Default": "Information", | ||||||
|  |       "Microsoft.AspNetCore": "Warning" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "AllowedHosts": "*", | ||||||
|  |   "SiteUrl": "http://localhost:3000", | ||||||
|  |   "Client": { | ||||||
|  |     "SomeSetting": "SomeValue" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										4
									
								
								DysonNetwork.Pass/.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								DysonNetwork.Pass/.dockerignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | /wwwroot/dist/ | ||||||
|  | **/bin/ | ||||||
|  | **/obj/ | ||||||
|  | **/node_modules/ | ||||||
							
								
								
									
										2
									
								
								DysonNetwork.Pass/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								DysonNetwork.Pass/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | /Keys | ||||||
|  | /wwwroot/dist | ||||||
| @@ -1,31 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Account; |  | ||||||
|  |  | ||||||
| public enum AbuseReportType |  | ||||||
| { |  | ||||||
|     Copyright, |  | ||||||
|     Harassment, |  | ||||||
|     Impersonation, |  | ||||||
|     OffensiveContent, |  | ||||||
|     Spam, |  | ||||||
|     PrivacyViolation, |  | ||||||
|     IllegalContent, |  | ||||||
|     Other |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AbuseReport : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |  | ||||||
|     [MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!; |  | ||||||
|     public AbuseReportType Type { get; set; } |  | ||||||
|     [MaxLength(8192)] public string Reason { get; set; } = null!; |  | ||||||
|  |  | ||||||
|     public Instant? ResolvedAt { get; set; } |  | ||||||
|     [MaxLength(8192)] public string? Resolution { get; set; } |  | ||||||
|      |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     public Account Account { get; set; } = null!; |  | ||||||
| } |  | ||||||
| @@ -1,267 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using System.ComponentModel.DataAnnotations.Schema; |  | ||||||
| using System.Text.Json.Serialization; |  | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
| using NodaTime.Serialization.Protobuf; |  | ||||||
| using OtpNet; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Account; |  | ||||||
|  |  | ||||||
| [Index(nameof(Name), IsUnique = true)] |  | ||||||
| public class Account : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } |  | ||||||
|     [MaxLength(256)] public string Name { get; set; } = string.Empty; |  | ||||||
|     [MaxLength(256)] public string Nick { get; set; } = string.Empty; |  | ||||||
|     [MaxLength(32)] public string Language { get; set; } = string.Empty; |  | ||||||
|     public Instant? ActivatedAt { get; set; } |  | ||||||
|     public bool IsSuperuser { get; set; } = false; |  | ||||||
|  |  | ||||||
|     public AccountProfile Profile { get; set; } = null!; |  | ||||||
|     public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>(); |  | ||||||
|     public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>(); |  | ||||||
|  |  | ||||||
|     [JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>(); |  | ||||||
|     [JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>(); |  | ||||||
|     [JsonIgnore] public ICollection<Auth.AuthSession> Sessions { get; set; } = new List<Auth.AuthSession>(); |  | ||||||
|     [JsonIgnore] public ICollection<Auth.AuthChallenge> Challenges { get; set; } = new List<Auth.AuthChallenge>(); |  | ||||||
|  |  | ||||||
|     [JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>(); |  | ||||||
|     [JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>(); |  | ||||||
|  |  | ||||||
|     public Shared.Proto.Account ToProtoValue() |  | ||||||
|     { |  | ||||||
|         var proto = new Shared.Proto.Account |  | ||||||
|         { |  | ||||||
|             Id = Id.ToString(), |  | ||||||
|             Name = Name, |  | ||||||
|             Nick = Nick, |  | ||||||
|             Language = Language, |  | ||||||
|             ActivatedAt = ActivatedAt?.ToTimestamp(), |  | ||||||
|             IsSuperuser = IsSuperuser, |  | ||||||
|             Profile = Profile.ToProtoValue() |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         // Add contacts |  | ||||||
|         foreach (var contact in Contacts) |  | ||||||
|             proto.Contacts.Add(contact.ToProtoValue()); |  | ||||||
|  |  | ||||||
|         // Add badges |  | ||||||
|         foreach (var badge in Badges) |  | ||||||
|             proto.Badges.Add(badge.ToProtoValue()); |  | ||||||
|  |  | ||||||
|         return proto; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public abstract class Leveling |  | ||||||
| { |  | ||||||
|     public static readonly List<int> ExperiencePerLevel = |  | ||||||
|     [ |  | ||||||
|         0, // Level 0 |  | ||||||
|         100, // Level 1 |  | ||||||
|         250, // Level 2 |  | ||||||
|         500, // Level 3 |  | ||||||
|         1000, // Level 4 |  | ||||||
|         2000, // Level 5 |  | ||||||
|         4000, // Level 6 |  | ||||||
|         8000, // Level 7 |  | ||||||
|         16000, // Level 8 |  | ||||||
|         32000, // Level 9 |  | ||||||
|         64000, // Level 10 |  | ||||||
|         128000, // Level 11 |  | ||||||
|         256000, // Level 12 |  | ||||||
|         512000, // Level 13 |  | ||||||
|         1024000 // Level 14 |  | ||||||
|     ]; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AccountProfile : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } |  | ||||||
|     [MaxLength(256)] public string? FirstName { get; set; } |  | ||||||
|     [MaxLength(256)] public string? MiddleName { get; set; } |  | ||||||
|     [MaxLength(256)] public string? LastName { get; set; } |  | ||||||
|     [MaxLength(4096)] public string? Bio { get; set; } |  | ||||||
|     [MaxLength(1024)] public string? Gender { get; set; } |  | ||||||
|     [MaxLength(1024)] public string? Pronouns { get; set; } |  | ||||||
|     [MaxLength(1024)] public string? TimeZone { get; set; } |  | ||||||
|     [MaxLength(1024)] public string? Location { get; set; } |  | ||||||
|     public Instant? Birthday { get; set; } |  | ||||||
|     public Instant? LastSeenAt { get; set; } |  | ||||||
|  |  | ||||||
|     [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; } |  | ||||||
|     [Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; } |  | ||||||
|  |  | ||||||
|     public int Experience { get; set; } = 0; |  | ||||||
|     [NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1; |  | ||||||
|  |  | ||||||
|     [NotMapped] |  | ||||||
|     public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1 |  | ||||||
|         ? 100 |  | ||||||
|         : (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 / |  | ||||||
|           (Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]); |  | ||||||
|  |  | ||||||
|     // Outdated fields, for backward compability |  | ||||||
|     [MaxLength(32)] public string? PictureId { get; set; } |  | ||||||
|     [MaxLength(32)] public string? BackgroundId { get; set; } |  | ||||||
|  |  | ||||||
|     [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; } |  | ||||||
|     [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; } |  | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     [JsonIgnore] public Account Account { get; set; } = null!; |  | ||||||
|  |  | ||||||
|     public Shared.Proto.AccountProfile ToProtoValue() |  | ||||||
|     { |  | ||||||
|         var proto = new Shared.Proto.AccountProfile |  | ||||||
|         { |  | ||||||
|             Id = Id.ToString(), |  | ||||||
|             FirstName = FirstName ?? string.Empty, |  | ||||||
|             MiddleName = MiddleName ?? string.Empty, |  | ||||||
|             LastName = LastName ?? string.Empty, |  | ||||||
|             Bio = Bio ?? string.Empty, |  | ||||||
|             Gender = Gender ?? string.Empty, |  | ||||||
|             Pronouns = Pronouns ?? string.Empty, |  | ||||||
|             TimeZone = TimeZone ?? string.Empty, |  | ||||||
|             Location = Location ?? string.Empty, |  | ||||||
|             Birthday = Birthday?.ToTimestamp(), |  | ||||||
|             LastSeenAt = LastSeenAt?.ToTimestamp(), |  | ||||||
|             Experience = Experience, |  | ||||||
|             Level = Level, |  | ||||||
|             LevelingProgress = LevelingProgress, |  | ||||||
|             PictureId = PictureId ?? string.Empty, |  | ||||||
|             BackgroundId = BackgroundId ?? string.Empty, |  | ||||||
|             Picture = Picture?.ToProtoValue(), |  | ||||||
|             Background = Background?.ToProtoValue(), |  | ||||||
|             AccountId = AccountId.ToString(), |  | ||||||
|             Verification = Verification?.ToProtoValue(), |  | ||||||
|             ActiveBadge = ActiveBadge?.ToProtoValue() |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         return proto; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AccountContact : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } |  | ||||||
|     public AccountContactType Type { get; set; } |  | ||||||
|     public Instant? VerifiedAt { get; set; } |  | ||||||
|     public bool IsPrimary { get; set; } = false; |  | ||||||
|     [MaxLength(1024)] public string Content { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     [JsonIgnore] public Account Account { get; set; } = null!; |  | ||||||
|  |  | ||||||
|     public Shared.Proto.AccountContact ToProtoValue() |  | ||||||
|     { |  | ||||||
|         var proto = new Shared.Proto.AccountContact |  | ||||||
|         { |  | ||||||
|             Id = Id.ToString(), |  | ||||||
|             Type = Type switch |  | ||||||
|             { |  | ||||||
|                 AccountContactType.Email => Shared.Proto.AccountContactType.Email, |  | ||||||
|                 AccountContactType.PhoneNumber => Shared.Proto.AccountContactType.PhoneNumber, |  | ||||||
|                 AccountContactType.Address => Shared.Proto.AccountContactType.Address, |  | ||||||
|                 _ => Shared.Proto.AccountContactType.Unspecified |  | ||||||
|             }, |  | ||||||
|             Content = Content, |  | ||||||
|             IsPrimary = IsPrimary, |  | ||||||
|             VerifiedAt = VerifiedAt?.ToTimestamp(), |  | ||||||
|             AccountId = AccountId.ToString() |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         return proto; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public enum AccountContactType |  | ||||||
| { |  | ||||||
|     Email, |  | ||||||
|     PhoneNumber, |  | ||||||
|     Address |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AccountAuthFactor : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } |  | ||||||
|     public AccountAuthFactorType Type { get; set; } |  | ||||||
|     [JsonIgnore] [MaxLength(8196)] public string? Secret { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonIgnore] |  | ||||||
|     [Column(TypeName = "jsonb")] |  | ||||||
|     public Dictionary<string, object>? Config { get; set; } = new(); |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// The trustworthy stands for how safe is this auth factor. |  | ||||||
|     /// Basically, it affects how many steps it can complete in authentication. |  | ||||||
|     /// Besides, users may need to use some high-trustworthy level auth factors when confirming some dangerous operations. |  | ||||||
|     /// </summary> |  | ||||||
|     public int Trustworthy { get; set; } = 1; |  | ||||||
|  |  | ||||||
|     public Instant? EnabledAt { get; set; } |  | ||||||
|     public Instant? ExpiredAt { get; set; } |  | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     [JsonIgnore] public Account Account { get; set; } = null!; |  | ||||||
|  |  | ||||||
|     public AccountAuthFactor HashSecret(int cost = 12) |  | ||||||
|     { |  | ||||||
|         if (Secret == null) return this; |  | ||||||
|         Secret = BCrypt.Net.BCrypt.HashPassword(Secret, workFactor: cost); |  | ||||||
|         return this; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public bool VerifyPassword(string password) |  | ||||||
|     { |  | ||||||
|         if (Secret == null) |  | ||||||
|             throw new InvalidOperationException("Auth factor with no secret cannot be verified with password."); |  | ||||||
|         switch (Type) |  | ||||||
|         { |  | ||||||
|             case AccountAuthFactorType.Password: |  | ||||||
|             case AccountAuthFactorType.PinCode: |  | ||||||
|                 return BCrypt.Net.BCrypt.Verify(password, Secret); |  | ||||||
|             case AccountAuthFactorType.TimedCode: |  | ||||||
|                 var otp = new Totp(Base32Encoding.ToBytes(Secret)); |  | ||||||
|                 return otp.VerifyTotp(DateTime.UtcNow, password, out _, new VerificationWindow(previous: 5, future: 5)); |  | ||||||
|             case AccountAuthFactorType.EmailCode: |  | ||||||
|             case AccountAuthFactorType.InAppCode: |  | ||||||
|             default: |  | ||||||
|                 throw new InvalidOperationException("Unsupported verification type, use CheckDeliveredCode instead."); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// This dictionary will be returned to the client and should only be set when it just created. |  | ||||||
|     /// Useful for passing the client some data to finishing setup and recovery code. |  | ||||||
|     /// </summary> |  | ||||||
|     [NotMapped] |  | ||||||
|     public Dictionary<string, object>? CreatedResponse { get; set; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public enum AccountAuthFactorType |  | ||||||
| { |  | ||||||
|     Password, |  | ||||||
|     EmailCode, |  | ||||||
|     InAppCode, |  | ||||||
|     TimedCode, |  | ||||||
|     PinCode, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AccountConnection : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |  | ||||||
|     [MaxLength(4096)] public string Provider { get; set; } = null!; |  | ||||||
|     [MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!; |  | ||||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new(); |  | ||||||
|  |  | ||||||
|     [JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; } |  | ||||||
|     [JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; } |  | ||||||
|     public Instant? LastUsedAt { get; set; } |  | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     public Account Account { get; set; } = null!; |  | ||||||
| } |  | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user