Compare commits
	
		
			759 Commits
		
	
	
		
			5f30b56ef8
			...
			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 | |||
| 387246a95c | |||
| 007da589bf | |||
| cde55eb237 | |||
| 03e26ef93c | |||
| afdbde951c | |||
| e66abe2e0c | |||
| 4a7f2e18b3 | |||
| e1b47bc7d1 | |||
| b6d416a3a8 | |||
| 2a8cbbfa24 | |||
| 33f56c4ef5 | |||
| 0318364bcf | |||
| ba49d1c7a7 | |||
| 29d752bdd9 | |||
| b12e3315fe | |||
| ce3958d397 | |||
| 26ea2503a4 | |||
| d6ce068490 | |||
| da4ee81c95 | |||
| bec294365f | |||
| 51a8b684fd | |||
| 7b026eeae1 | |||
| e76c80eead | |||
| 4dd4542c37 | |||
| 2a3918134f | |||
| 734e5ca4a0 | |||
| ff0789904d | |||
| 17330fc104 | |||
| 7c0ad46deb | |||
| b8fcd0d94f | |||
| fc6edd7378 | |||
| 1f2cdb146d | |||
| be236a27c6 | |||
| 99c36ae548 | |||
| ed2961a5d5 | |||
| 08b5ffa02f | |||
| 837a123c3b | |||
| ad1166190f | |||
| 8e8c938132 | |||
| 8e5b6ace45 | |||
| 5757526ea5 | |||
| 6a9cd0905d | |||
| 082a096470 | |||
| 3a72347432 | |||
| 19b1e957dd | |||
| 6449926334 | |||
| fb885e138d | |||
| 5bdc21ebc5 | |||
| f177377fe3 | |||
| 0df4864888 | |||
| 29b0ad184e | |||
| ad730832db | |||
| 71fcc26534 | |||
| fb8fc69920 | |||
| 05bf2cd055 | |||
| ccb8a4e3f4 | |||
| ca5be5a01c | |||
| c4ea15097e | |||
| cdeed3c318 | |||
| a53fcb10dd | |||
| c0879d30d4 | |||
| 0226bf8fa3 | |||
| 217b434cc4 | |||
| f8295c6a18 | |||
| d4fa08d320 | |||
| 8bd0ea0fa1 | |||
| 9ab31d79ce | |||
| ee5d6ef821 | |||
| d7b443e678 | |||
| 98b2eeb13d | |||
| ec3961d546 | |||
| a5dae37525 | |||
| 933d762f24 | |||
| 8251a9ec7d | |||
| 38243f9eba | |||
| b0b7afd6b3 | |||
| 6237fd6140 | |||
| 2e8d6a3667 | |||
| ac496777ed | |||
| 19ddc1b363 | |||
| 661b612537 | |||
| 8432436fcf | |||
| 2a28948418 | |||
| 5dd138949e | |||
| f540544a47 | |||
| 9f8eec792b | |||
| 0bdd429d87 | |||
| b2203fb464 | |||
| c5bbd58f5c | |||
| 35a9dcffbc | |||
| 1d50f225c1 | |||
| b7263b9804 | |||
| c63d6e0fbc | |||
| cebd1bd65a | |||
| da58e10d88 | |||
| d492c9ce1f | |||
| f170793928 | |||
| 1a137fbb6a | |||
| 21cf212d8f | |||
| c6cb2a0dc3 | |||
| d9747daab9 | |||
| d91b705b9a | |||
| 5ce3598cc9 | |||
| 1b45f07419 | |||
| 6bec0a672e | |||
| c338512c16 | |||
| 9444913b72 | |||
| 50bfec59ee | |||
| a97bf15362 | |||
| feb612afcd | |||
| 049a5c9b6f | |||
| 694bc77921 | |||
| be0b48cfd9 | |||
| a23338c263 | |||
| c5ef9b065b | |||
| 5990b17b4c | |||
| de7a2cea09 | |||
| 698442ad13 | |||
| 9fd6016308 | |||
| 516090a5f8 | |||
| 6b0e5f919d | |||
| c6450757be | |||
| 38abe16ba6 | |||
| bf40b51c41 | |||
| f50894a3d1 | |||
| d1fb0b9b55 | |||
| f1a47fd079 | |||
| 546b65f4c6 | |||
| 1baa3109bc | |||
| eadf25f389 | |||
| d385abbf57 | |||
| a431fbbd51 | |||
| d83c69620f | |||
| cb8e720af1 | 
							
								
								
									
										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
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,7 +1,9 @@
 | 
				
			|||||||
bin/
 | 
					bin/
 | 
				
			||||||
obj/
 | 
					obj/
 | 
				
			||||||
/packages/
 | 
					/packages/
 | 
				
			||||||
 | 
					/Certificates/
 | 
				
			||||||
riderModule.iml
 | 
					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;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										21
									
								
								DysonNetwork.Develop/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								DysonNetwork.Develop/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "$schema": "https://json.schemastore.org/launchsettings.json",
 | 
				
			||||||
 | 
					  "profiles": {
 | 
				
			||||||
 | 
					    "http": {
 | 
				
			||||||
 | 
					      "commandName": "Project",
 | 
				
			||||||
 | 
					      "dotnetRunMessages": true,
 | 
				
			||||||
 | 
					      "launchBrowser": false,
 | 
				
			||||||
 | 
					      "environmentVariables": {
 | 
				
			||||||
 | 
					        "ASPNETCORE_ENVIRONMENT": "Development"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "https": {
 | 
				
			||||||
 | 
					      "commandName": "Project",
 | 
				
			||||||
 | 
					      "dotnetRunMessages": true,
 | 
				
			||||||
 | 
					      "launchBrowser": false,
 | 
				
			||||||
 | 
					      "environmentVariables": {
 | 
				
			||||||
 | 
					        "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
 | 
				
			||||||
							
								
								
									
										183
									
								
								DysonNetwork.Drive/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								DysonNetwork.Drive/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,183 @@
 | 
				
			|||||||
 | 
					using System.Linq.Expressions;
 | 
				
			||||||
 | 
					using System.Reflection;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive.Billing;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Models;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Design;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Query;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using Quartz;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class AppDatabase(
 | 
				
			||||||
 | 
					    DbContextOptions<AppDatabase> options,
 | 
				
			||||||
 | 
					    IConfiguration configuration
 | 
				
			||||||
 | 
					) : DbContext(options)
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    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!;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    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);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Automatically apply soft-delete filter to all entities inheriting BaseModel
 | 
				
			||||||
 | 
					        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
 | 
				
			||||||
 | 
					            var method = typeof(AppDatabase)
 | 
				
			||||||
 | 
					                .GetMethod(nameof(SetSoftDeleteFilter),
 | 
				
			||||||
 | 
					                    BindingFlags.NonPublic | BindingFlags.Static)!
 | 
				
			||||||
 | 
					                .MakeGenericMethod(entityType.ClrType);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            method.Invoke(null, [modelBuilder]);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
 | 
				
			||||||
 | 
					        where TEntity : ModelBase
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach (var entry in ChangeTracker.Entries<ModelBase>())
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            switch (entry.State)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                case EntityState.Added:
 | 
				
			||||||
 | 
					                    entry.Entity.CreatedAt = now;
 | 
				
			||||||
 | 
					                    entry.Entity.UpdatedAt = now;
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					                case EntityState.Modified:
 | 
				
			||||||
 | 
					                    entry.Entity.UpdatedAt = now;
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					                case EntityState.Deleted:
 | 
				
			||||||
 | 
					                    entry.State = EntityState.Modified;
 | 
				
			||||||
 | 
					                    entry.Entity.DeletedAt = now;
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					                case EntityState.Detached:
 | 
				
			||||||
 | 
					                case EntityState.Unchanged:
 | 
				
			||||||
 | 
					                default:
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return await base.SaveChangesAsync(cancellationToken);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public async Task Execute(IJobExecutionContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        logger.LogInformation("Deleting soft-deleted records...");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var threshold = now - Duration.FromDays(7);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var entityTypes = db.Model.GetEntityTypes()
 | 
				
			||||||
 | 
					            .Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
 | 
				
			||||||
 | 
					            .Select(t => t.ClrType);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach (var entityType in entityTypes)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
 | 
				
			||||||
 | 
					                .MakeGenericMethod(entityType).Invoke(db, null)!;
 | 
				
			||||||
 | 
					            var parameter = Expression.Parameter(entityType, "e");
 | 
				
			||||||
 | 
					            var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
 | 
				
			||||||
 | 
					            var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
 | 
				
			||||||
 | 
					            var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
 | 
				
			||||||
 | 
					            var finalCondition = Expression.AndAlso(notNull, condition);
 | 
				
			||||||
 | 
					            var lambda = Expression.Lambda(finalCondition, parameter);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var queryable = set.Provider.CreateQuery(
 | 
				
			||||||
 | 
					                Expression.Call(
 | 
				
			||||||
 | 
					                    typeof(Queryable),
 | 
				
			||||||
 | 
					                    "Where",
 | 
				
			||||||
 | 
					                    [entityType],
 | 
				
			||||||
 | 
					                    set.Expression,
 | 
				
			||||||
 | 
					                    Expression.Quote(lambda)
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var toListAsync = typeof(EntityFrameworkQueryableExtensions)
 | 
				
			||||||
 | 
					                .GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
 | 
				
			||||||
 | 
					                .MakeGenericMethod(entityType);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
 | 
				
			||||||
 | 
					            db.RemoveRange(items);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public static class OptionalQueryExtensions
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public static IQueryable<T> If<T>(
 | 
				
			||||||
 | 
					        this IQueryable<T> source,
 | 
				
			||||||
 | 
					        bool condition,
 | 
				
			||||||
 | 
					        Func<IQueryable<T>, IQueryable<T>> transform
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return condition ? transform(source) : source;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static IQueryable<T> If<T, TP>(
 | 
				
			||||||
 | 
					        this IIncludableQueryable<T, TP> source,
 | 
				
			||||||
 | 
					        bool condition,
 | 
				
			||||||
 | 
					        Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					        where T : class
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return condition ? transform(source) : source;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static IQueryable<T> If<T, TP>(
 | 
				
			||||||
 | 
					        this IIncludableQueryable<T, IEnumerable<TP>> source,
 | 
				
			||||||
 | 
					        bool condition,
 | 
				
			||||||
 | 
					        Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					        where T : class
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return condition ? transform(source) : source;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										42
									
								
								DysonNetwork.Drive/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								DysonNetwork.Drive/Dockerfile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
 | 
				
			||||||
 | 
					WORKDIR /app
 | 
				
			||||||
 | 
					EXPOSE 8080
 | 
				
			||||||
 | 
					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
 | 
				
			||||||
 | 
					ARG BUILD_CONFIGURATION=Release
 | 
				
			||||||
 | 
					WORKDIR /src
 | 
				
			||||||
 | 
					COPY ["DysonNetwork.Drive/DysonNetwork.Drive.csproj", "DysonNetwork.Drive/"]
 | 
				
			||||||
 | 
					RUN dotnet restore "DysonNetwork.Drive/DysonNetwork.Drive.csproj"
 | 
				
			||||||
 | 
					COPY . .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					WORKDIR "/src/DysonNetwork.Drive"
 | 
				
			||||||
 | 
					RUN dotnet build "./DysonNetwork.Drive.csproj" -c $BUILD_CONFIGURATION -o /app/build \
 | 
				
			||||||
 | 
					    -p:TypeScriptCompileBlocked=true \
 | 
				
			||||||
 | 
					    -p:UseRazorBuildServer=false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					FROM build AS publish
 | 
				
			||||||
 | 
					ARG BUILD_CONFIGURATION=Release
 | 
				
			||||||
 | 
					RUN dotnet publish "./DysonNetwork.Drive.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					FROM base AS final
 | 
				
			||||||
 | 
					WORKDIR /app
 | 
				
			||||||
 | 
					COPY --from=publish /app/publish .
 | 
				
			||||||
 | 
					ENTRYPOINT ["dotnet", "DysonNetwork.Drive.dll"]
 | 
				
			||||||
							
								
								
									
										73
									
								
								DysonNetwork.Drive/DysonNetwork.Drive.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								DysonNetwork.Drive/DysonNetwork.Drive.csproj
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,73 @@
 | 
				
			|||||||
 | 
					<Project Sdk="Microsoft.NET.Sdk.Web">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <PropertyGroup>
 | 
				
			||||||
 | 
					        <TargetFramework>net9.0</TargetFramework>
 | 
				
			||||||
 | 
					        <Nullable>enable</Nullable>
 | 
				
			||||||
 | 
					        <ImplicitUsings>enable</ImplicitUsings>
 | 
				
			||||||
 | 
					        <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
 | 
				
			||||||
 | 
					    </PropertyGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <ItemGroup>
 | 
				
			||||||
 | 
					        <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
 | 
				
			||||||
 | 
					        <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
 | 
				
			||||||
 | 
					        <PackageReference Include="FFMpegCore" Version="5.2.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
 | 
				
			||||||
 | 
					          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
 | 
				
			||||||
 | 
					          <PrivateAssets>all</PrivateAssets>
 | 
				
			||||||
 | 
					        </PackageReference>
 | 
				
			||||||
 | 
					        <PackageReference Include="MimeKit" Version="4.13.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="MimeTypes" Version="2.5.2">
 | 
				
			||||||
 | 
					          <PrivateAssets>all</PrivateAssets>
 | 
				
			||||||
 | 
					          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
 | 
				
			||||||
 | 
					        </PackageReference>
 | 
				
			||||||
 | 
					        <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.Native.linux-x64" Version="8.17.1" />
 | 
				
			||||||
 | 
					        <PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.1" />
 | 
				
			||||||
 | 
					        <PackageReference Include="NodaTime" Version="3.2.2" />
 | 
				
			||||||
 | 
					        <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
 | 
				
			||||||
 | 
					        <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
 | 
				
			||||||
 | 
					        <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
 | 
				
			||||||
 | 
					        <PackageReference Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
 | 
				
			||||||
 | 
					        <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
 | 
				
			||||||
 | 
					        <PackageReference Include="prometheus-net.EntityFramework" Version="0.9.5" />
 | 
				
			||||||
 | 
					        <PackageReference Include="prometheus-net.SystemMetrics" Version="3.1.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Quartz" Version="3.14.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Quartz.AspNetCore" Version="3.14.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
 | 
				
			||||||
 | 
					        <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" />
 | 
				
			||||||
 | 
					        <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
 | 
				
			||||||
 | 
					        <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
 | 
				
			||||||
 | 
					        <PackageReference Include="tusdotnet" Version="2.10.0" />
 | 
				
			||||||
 | 
					    </ItemGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <ItemGroup>
 | 
				
			||||||
 | 
					      <Content Include="..\.dockerignore">
 | 
				
			||||||
 | 
					        <Link>.dockerignore</Link>
 | 
				
			||||||
 | 
					      </Content>
 | 
				
			||||||
 | 
					    </ItemGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <ItemGroup>
 | 
				
			||||||
 | 
					      <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
 | 
				
			||||||
 | 
					    </ItemGroup>
 | 
				
			||||||
 | 
					</Project>
 | 
				
			||||||
							
								
								
									
										190
									
								
								DysonNetwork.Drive/Migrations/20250713121317_InitialMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								DysonNetwork.Drive/Migrations/20250713121317_InitialMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							@@ -0,0 +1,190 @@
 | 
				
			|||||||
 | 
					// <auto-generated />
 | 
				
			||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using System.Collections.Generic;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					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("20250713121317_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.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<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,87 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Shared.Models;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <inheritdoc />
 | 
				
			||||||
 | 
					    public partial class InitialMigration : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.AlterDatabase()
 | 
				
			||||||
 | 
					                .Annotation("Npgsql:PostgresExtension:postgis", ",,");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "files",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<string>(type: "character varying(32)", maxLength: 32, 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),
 | 
				
			||||||
 | 
					                    file_meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
 | 
				
			||||||
 | 
					                    user_meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
 | 
				
			||||||
 | 
					                    sensitive_marks = table.Column<List<ContentSensitiveMark>>(type: "jsonb", nullable: true),
 | 
				
			||||||
 | 
					                    mime_type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
 | 
				
			||||||
 | 
					                    hash = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
 | 
				
			||||||
 | 
					                    size = table.Column<long>(type: "bigint", nullable: false),
 | 
				
			||||||
 | 
					                    uploaded_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
 | 
				
			||||||
 | 
					                    uploaded_to = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
 | 
				
			||||||
 | 
					                    has_compression = table.Column<bool>(type: "boolean", nullable: false),
 | 
				
			||||||
 | 
					                    is_marked_recycle = table.Column<bool>(type: "boolean", nullable: false),
 | 
				
			||||||
 | 
					                    storage_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
 | 
				
			||||||
 | 
					                    storage_url = table.Column<string>(type: "character varying(4096)", maxLength: 4096, 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_files", x => x.id);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "file_references",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    file_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
 | 
				
			||||||
 | 
					                    usage = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    resource_id = table.Column<string>(type: "character varying(1024)", maxLength: 1024, 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_file_references", x => x.id);
 | 
				
			||||||
 | 
					                    table.ForeignKey(
 | 
				
			||||||
 | 
					                        name: "fk_file_references_files_file_id",
 | 
				
			||||||
 | 
					                        column: x => x.file_id,
 | 
				
			||||||
 | 
					                        principalTable: "files",
 | 
				
			||||||
 | 
					                        principalColumn: "id",
 | 
				
			||||||
 | 
					                        onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateIndex(
 | 
				
			||||||
 | 
					                name: "ix_file_references_file_id",
 | 
				
			||||||
 | 
					                table: "file_references",
 | 
				
			||||||
 | 
					                column: "file_id");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "file_references");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "files");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										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", ",,");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										399
									
								
								DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										399
									
								
								DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs
									
									
									
									
									
										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.Storage.ValueConversion;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.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.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
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										54
									
								
								DysonNetwork.Drive/Program.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								DysonNetwork.Drive/Program.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Drive;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive.Startup;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Auth;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Http;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Registry;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using tusdotnet.Stores;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var builder = WebApplication.CreateBuilder(args);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.AddServiceDefaults();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Configure Kestrel and server options
 | 
				
			||||||
 | 
					builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Add application services
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.Services.AddAppServices(builder.Configuration);
 | 
				
			||||||
 | 
					builder.Services.AddAppRateLimiting();
 | 
				
			||||||
 | 
					builder.Services.AddAppAuthentication();
 | 
				
			||||||
 | 
					builder.Services.AddDysonAuth();
 | 
				
			||||||
 | 
					builder.Services.AddAccountService();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.Services.AddAppFileStorage(builder.Configuration);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.Services.AddAppFlushHandlers();
 | 
				
			||||||
 | 
					builder.Services.AddAppBusinessServices();
 | 
				
			||||||
 | 
					builder.Services.AddAppScheduledJobs();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.AddSwaggerManifest(
 | 
				
			||||||
 | 
					    "DysonNetwork.Drive",
 | 
				
			||||||
 | 
					    "The file upload and storage service in the Solar Network."
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var app = builder.Build();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.MapDefaultEndpoints();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Run database migrations
 | 
				
			||||||
 | 
					using (var scope = app.Services.CreateScope())
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
 | 
				
			||||||
 | 
					    await db.Database.MigrateAsync();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
 | 
				
			||||||
 | 
					app.ConfigureAppMiddleware(tusDiskStore);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Configure gRPC
 | 
				
			||||||
 | 
					app.ConfigureGrpcServices();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.UseSwaggerManifest();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.Run();
 | 
				
			||||||
							
								
								
									
										21
									
								
								DysonNetwork.Drive/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								DysonNetwork.Drive/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "$schema": "https://json.schemastore.org/launchsettings.json",
 | 
				
			||||||
 | 
					  "profiles": {
 | 
				
			||||||
 | 
					    "http": {
 | 
				
			||||||
 | 
					      "commandName": "Project",
 | 
				
			||||||
 | 
					      "dotnetRunMessages": true,
 | 
				
			||||||
 | 
					      "launchBrowser": false,
 | 
				
			||||||
 | 
					      "environmentVariables": {
 | 
				
			||||||
 | 
					        "ASPNETCORE_ENVIRONMENT": "Development"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "https": {
 | 
				
			||||||
 | 
					      "commandName": "Project",
 | 
				
			||||||
 | 
					      "dotnetRunMessages": true,
 | 
				
			||||||
 | 
					      "launchBrowser": false,
 | 
				
			||||||
 | 
					      "environmentVariables": {
 | 
				
			||||||
 | 
					        "ASPNETCORE_ENVIRONMENT": "Development"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										27
									
								
								DysonNetwork.Drive/Startup/ApplicationBuilderExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								DysonNetwork.Drive/Startup/ApplicationBuilderExtensions.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					using tusdotnet;
 | 
				
			||||||
 | 
					using tusdotnet.Interfaces;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Startup;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public static class ApplicationBuilderExtensions
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public static WebApplication ConfigureAppMiddleware(this WebApplication app, ITusStore tusStore)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        app.UseAuthorization();
 | 
				
			||||||
 | 
					        app.MapControllers();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        app.MapTus("/api/tus", _ => Task.FromResult(TusService.BuildConfiguration(tusStore, app.Configuration)));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return app;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static WebApplication ConfigureGrpcServices(this WebApplication app)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        // Map your gRPC services here
 | 
				
			||||||
 | 
					        app.MapGrpcService<FileServiceGrpc>();
 | 
				
			||||||
 | 
					        app.MapGrpcService<FileReferenceServiceGrpc>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										30
									
								
								DysonNetwork.Drive/Startup/ScheduledJobsConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								DysonNetwork.Drive/Startup/ScheduledJobsConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					using Quartz;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Startup;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public static class ScheduledJobsConfiguration
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        services.AddQuartz(q =>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var appDatabaseRecyclingJob = new JobKey("AppDatabaseRecycling");
 | 
				
			||||||
 | 
					            q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity(appDatabaseRecyclingJob));
 | 
				
			||||||
 | 
					            q.AddTrigger(opts => opts
 | 
				
			||||||
 | 
					                .ForJob(appDatabaseRecyclingJob)
 | 
				
			||||||
 | 
					                .WithIdentity("AppDatabaseRecyclingTrigger")
 | 
				
			||||||
 | 
					                .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);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return services;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										94
									
								
								DysonNetwork.Drive/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								DysonNetwork.Drive/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,94 @@
 | 
				
			|||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					using System.Threading.RateLimiting;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.RateLimiting;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.SystemTextJson;
 | 
				
			||||||
 | 
					using tusdotnet.Stores;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Startup;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public static class ServiceCollectionExtensions
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
 | 
				
			||||||
 | 
					        services.AddSingleton<IClock>(SystemClock.Instance);
 | 
				
			||||||
 | 
					        services.AddHttpContextAccessor();
 | 
				
			||||||
 | 
					        services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        services.AddHttpClient();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Register gRPC services
 | 
				
			||||||
 | 
					        services.AddGrpc(options =>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            options.EnableDetailedErrors = true; // Will be adjusted in Program.cs
 | 
				
			||||||
 | 
					            options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
 | 
				
			||||||
 | 
					            options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Register gRPC reflection for service discovery
 | 
				
			||||||
 | 
					        services.AddGrpc();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        services.AddControllers().AddJsonOptions(options =>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
 | 
				
			||||||
 | 
					            options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
 | 
				
			||||||
 | 
					            options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return services;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            opts.Window = TimeSpan.FromMinutes(1);
 | 
				
			||||||
 | 
					            opts.PermitLimit = 120;
 | 
				
			||||||
 | 
					            opts.QueueLimit = 2;
 | 
				
			||||||
 | 
					            opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
 | 
				
			||||||
 | 
					        }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return services;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        services.AddAuthorization();
 | 
				
			||||||
 | 
					        return services;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        services.AddSingleton<FlushBufferService>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return services;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static IServiceCollection AddAppFileStorage(this IServiceCollection services, IConfiguration configuration)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var tusStorePath = configuration.GetSection("Tus").GetValue<string>("StorePath")!;
 | 
				
			||||||
 | 
					        Directory.CreateDirectory(tusStorePath);
 | 
				
			||||||
 | 
					        var tusDiskStore = new TusDiskStore(tusStorePath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        services.AddSingleton(tusDiskStore);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return services;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        services.AddScoped<Storage.FileService>();
 | 
				
			||||||
 | 
					        services.AddScoped<Storage.FileReferenceService>();
 | 
				
			||||||
 | 
					        services.AddScoped<Billing.UsageService>();
 | 
				
			||||||
 | 
					        services.AddScoped<Billing.QuotaService>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        services.AddHostedService<BroadcastEventHandler>();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        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();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -2,25 +2,47 @@ using Microsoft.EntityFrameworkCore;
 | 
				
			|||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
using Quartz;
 | 
					using Quartz;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Sphere.Storage;
 | 
					namespace DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class CloudFileUnusedRecyclingJob(
 | 
					public class CloudFileUnusedRecyclingJob(
 | 
				
			||||||
    AppDatabase db,
 | 
					    AppDatabase db,
 | 
				
			||||||
    FileService fs,
 | 
					 | 
				
			||||||
    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);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -36,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
 | 
				
			||||||
@@ -85,10 +106,18 @@ 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);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										397
									
								
								DysonNetwork.Drive/Storage/FileController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										397
									
								
								DysonNetwork.Drive/Storage/FileController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,397 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Drive.Billing;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Auth;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Models;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Minio.DataModel.Args;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("/api/files")]
 | 
				
			||||||
 | 
					public class FileController(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    FileService fs,
 | 
				
			||||||
 | 
					    QuotaService qs,
 | 
				
			||||||
 | 
					    IConfiguration configuration,
 | 
				
			||||||
 | 
					    IWebHostEnvironment env
 | 
				
			||||||
 | 
					) : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [HttpGet("{id}")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult> OpenFile(
 | 
				
			||||||
 | 
					        string id,
 | 
				
			||||||
 | 
					        [FromQuery] bool download = false,
 | 
				
			||||||
 | 
					        [FromQuery] bool original = false,
 | 
				
			||||||
 | 
					        [FromQuery] bool thumbnail = false,
 | 
				
			||||||
 | 
					        [FromQuery] string? overrideMimeType = null,
 | 
				
			||||||
 | 
					        [FromQuery] string? passcode = null
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        // Support the file extension for client side data recognize
 | 
				
			||||||
 | 
					        string? fileExtension = null;
 | 
				
			||||||
 | 
					        if (id.Contains('.'))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var splitId = id.Split('.');
 | 
				
			||||||
 | 
					            id = splitId.First();
 | 
				
			||||||
 | 
					            fileExtension = splitId.Last();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var file = await fs.GetFileAsync(id);
 | 
				
			||||||
 | 
					        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 (file.UploadedAt is null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            // File is not yet uploaded to remote storage. Try to serve from local temp storage.
 | 
				
			||||||
 | 
					            var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id);
 | 
				
			||||||
 | 
					            if (System.IO.File.Exists(tempFilePath))
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                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.");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        switch (thumbnail)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            case true when file.HasThumbnail:
 | 
				
			||||||
 | 
					                fileName += ".thumbnail";
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					            case true when !file.HasThumbnail:
 | 
				
			||||||
 | 
					                return NotFound();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!original && file.HasCompression)
 | 
				
			||||||
 | 
					            fileName += ".compressed";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var proxyUrl = dest.ImageProxy;
 | 
				
			||||||
 | 
					            var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
 | 
				
			||||||
 | 
					            var fullUri = new Uri(baseUri, fileName);
 | 
				
			||||||
 | 
					            return Redirect(fullUri.ToString());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (dest.AccessProxy is not null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var proxyUrl = dest.AccessProxy;
 | 
				
			||||||
 | 
					            var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
 | 
				
			||||||
 | 
					            var fullUri = new Uri(baseUri, fileName);
 | 
				
			||||||
 | 
					            return Redirect(fullUri.ToString());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (dest.EnableSigned)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var client = fs.CreateMinioClient(dest);
 | 
				
			||||||
 | 
					            if (client is null)
 | 
				
			||||||
 | 
					                return BadRequest(
 | 
				
			||||||
 | 
					                    "Failed to configure client for remote destination, file got an invalid storage remote."
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var headers = new Dictionary<string, string>();
 | 
				
			||||||
 | 
					            if (fileExtension is not null)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                if (MimeTypes.TryGetMimeType(fileExtension, out var mimeType))
 | 
				
			||||||
 | 
					                    headers.Add("Response-Content-Type", mimeType);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            else if (overrideMimeType is not null)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                headers.Add("Response-Content-Type", overrideMimeType);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            else if (file.MimeType is not null && !file.MimeType!.EndsWith("unknown"))
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                headers.Add("Response-Content-Type", file.MimeType);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (download)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                headers.Add("Response-Content-Disposition", $"attachment; filename=\"{file.Name}\"");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var bucket = dest.Bucket;
 | 
				
			||||||
 | 
					            var openUrl = await client.PresignedGetObjectAsync(
 | 
				
			||||||
 | 
					                new PresignedGetObjectArgs()
 | 
				
			||||||
 | 
					                    .WithBucket(bucket)
 | 
				
			||||||
 | 
					                    .WithObject(fileName)
 | 
				
			||||||
 | 
					                    .WithExpiry(3600)
 | 
				
			||||||
 | 
					                    .WithHeaders(headers)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return Redirect(openUrl);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Fallback redirect to the S3 endpoint (public read)
 | 
				
			||||||
 | 
					        var protocol = dest.EnableSsl ? "https" : "http";
 | 
				
			||||||
 | 
					        // Use the path bucket lookup mode
 | 
				
			||||||
 | 
					        return Redirect($"{protocol}://{dest.Endpoint}/{dest.Bucket}/{fileName}");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{id}/info")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnCloudFile>> GetFileInfo(string id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var file = await fs.GetFileAsync(id);
 | 
				
			||||||
 | 
					        if (file is null) return NotFound("File not found.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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]
 | 
				
			||||||
 | 
					    [HttpDelete("{id}")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult> DeleteFile(string id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        var userId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var file = await db.Files
 | 
				
			||||||
 | 
					            .Where(e => e.Id == id)
 | 
				
			||||||
 | 
					            .Where(e => e.AccountId == userId)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (file is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await fs.DeleteFileDataAsync(file, force: true);
 | 
				
			||||||
 | 
					        await fs.DeleteFileAsync(file);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
 | 
				
			|||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
using Quartz;
 | 
					using Quartz;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Sphere.Storage;
 | 
					namespace DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// <summary>
 | 
					/// <summary>
 | 
				
			||||||
/// Job responsible for cleaning up expired file references
 | 
					/// Job responsible for cleaning up expired file references
 | 
				
			||||||
@@ -48,12 +48,10 @@ 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
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                // Just purge the cache
 | 
					                // Just purge the cache
 | 
				
			||||||
							
								
								
									
										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,11 +1,14 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Models;
 | 
				
			||||||
 | 
					using EFCore.BulkExtensions;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Sphere.Storage;
 | 
					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>
 | 
				
			||||||
@@ -22,7 +25,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
 | 
				
			|||||||
        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;
 | 
				
			||||||
@@ -45,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>
 | 
				
			||||||
@@ -156,6 +179,56 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
 | 
				
			|||||||
        return deletedCount;
 | 
					        return deletedCount;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Deletes references for a specific resource and usage
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    /// <param name="resourceId">The ID of the resource</param>
 | 
				
			||||||
 | 
					    /// <param name="usage">The usage context</param>
 | 
				
			||||||
 | 
					    /// <returns>The number of deleted references</returns>
 | 
				
			||||||
 | 
					    public async Task<int> DeleteResourceReferencesAsync(string resourceId, string usage)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var references = await db.FileReferences
 | 
				
			||||||
 | 
					            .Where(r => r.ResourceId == resourceId && r.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();
 | 
				
			||||||
 | 
					        tasks.Add(PurgeCacheForResourceAsync(resourceId));
 | 
				
			||||||
 | 
					        await Task.WhenAll(tasks);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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
 | 
				
			||||||
    /// </summary>
 | 
					    /// </summary>
 | 
				
			||||||
@@ -275,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);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										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 };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										727
									
								
								DysonNetwork.Drive/Storage/FileService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										727
									
								
								DysonNetwork.Drive/Storage/FileService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,727 @@
 | 
				
			|||||||
 | 
					using System.Globalization;
 | 
				
			||||||
 | 
					using FFMpegCore;
 | 
				
			||||||
 | 
					using System.Security.Cryptography;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive.Storage.Model;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Google.Protobuf.WellKnownTypes;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Minio;
 | 
				
			||||||
 | 
					using Minio.DataModel.Args;
 | 
				
			||||||
 | 
					using NATS.Client.Core;
 | 
				
			||||||
 | 
					using NetVips;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using System.Linq.Expressions;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Query;
 | 
				
			||||||
 | 
					using NATS.Net;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Models;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class FileService(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    ILogger<FileService> logger,
 | 
				
			||||||
 | 
					    ICacheService cache,
 | 
				
			||||||
 | 
					    INatsConnection nats
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    private const string CacheKeyPrefix = "file:";
 | 
				
			||||||
 | 
					    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<SnCloudFile?> GetFileAsync(string fileId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var cacheKey = $"{CacheKeyPrefix}{fileId}";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
 | 
				
			||||||
 | 
					        if (cachedFile is not null)
 | 
				
			||||||
 | 
					            return cachedFile;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var file = await db.Files
 | 
				
			||||||
 | 
					            .Where(f => f.Id == fileId)
 | 
				
			||||||
 | 
					            .Include(f => f.Pool)
 | 
				
			||||||
 | 
					            .Include(f => f.Bundle)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (file != null)
 | 
				
			||||||
 | 
					            await cache.SetAsync(cacheKey, file, CacheDuration);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return file;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<List<SnCloudFile>> GetFilesAsync(List<string> fileIds)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var cachedFiles = new Dictionary<string, SnCloudFile>();
 | 
				
			||||||
 | 
					        var uncachedIds = new List<string>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach (var fileId in fileIds)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var cacheKey = $"{CacheKeyPrefix}{fileId}";
 | 
				
			||||||
 | 
					            var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (cachedFile != null)
 | 
				
			||||||
 | 
					                cachedFiles[fileId] = cachedFile;
 | 
				
			||||||
 | 
					            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,
 | 
				
			||||||
 | 
					        string fileId,
 | 
				
			||||||
 | 
					        string filePool,
 | 
				
			||||||
 | 
					        string? fileBundleId,
 | 
				
			||||||
 | 
					        string filePath,
 | 
				
			||||||
 | 
					        string fileName,
 | 
				
			||||||
 | 
					        string? contentType,
 | 
				
			||||||
 | 
					        string? encryptPassword,
 | 
				
			||||||
 | 
					        Instant? expiredAt
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(account.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var pool = await GetPoolAsync(Guid.Parse(filePool));
 | 
				
			||||||
 | 
					        if (pool is null) throw new InvalidOperationException("Pool not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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,
 | 
				
			||||||
 | 
					            Name = fileName,
 | 
				
			||||||
 | 
					            MimeType = finalContentType,
 | 
				
			||||||
 | 
					            Size = fileSize,
 | 
				
			||||||
 | 
					            ExpiredAt = expiredAt,
 | 
				
			||||||
 | 
					            BundleId = bundle?.Id,
 | 
				
			||||||
 | 
					            AccountId = Guid.Parse(account.Id),
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!pool.PolicyConfig.NoMetadata)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await ExtractMetadataAsync(file, managedTempPath);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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":
 | 
				
			||||||
 | 
					                try
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    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 height = vipsImage.Height;
 | 
				
			||||||
 | 
					                    var orientation = 1;
 | 
				
			||||||
 | 
					                    try
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        orientation = vipsImage.Get("orientation") as int? ?? 1;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    catch
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        // ignored
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    var meta = new Dictionary<string, object?>
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        ["blur"] = blurhash,
 | 
				
			||||||
 | 
					                        ["format"] = vipsImage.Get("vips-loader") ?? "unknown",
 | 
				
			||||||
 | 
					                        ["width"] = width,
 | 
				
			||||||
 | 
					                        ["height"] = height,
 | 
				
			||||||
 | 
					                        ["orientation"] = orientation,
 | 
				
			||||||
 | 
					                    };
 | 
				
			||||||
 | 
					                    var exif = new Dictionary<string, object>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    foreach (var field in vipsImage.GetFields())
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        if (IsIgnoredField(field)) continue;
 | 
				
			||||||
 | 
					                        var value = vipsImage.Get(field);
 | 
				
			||||||
 | 
					                        if (field.StartsWith("exif-"))
 | 
				
			||||||
 | 
					                            exif[field.Replace("exif-", "")] = value;
 | 
				
			||||||
 | 
					                        else
 | 
				
			||||||
 | 
					                            meta[field] = value;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    if (orientation is 6 or 8) (width, height) = (height, width);
 | 
				
			||||||
 | 
					                    meta["exif"] = exif;
 | 
				
			||||||
 | 
					                    meta["ratio"] = height != 0 ? (double)width / height : 0;
 | 
				
			||||||
 | 
					                    file.FileMeta = meta;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                catch (Exception ex)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    file.FileMeta = new Dictionary<string, object?>();
 | 
				
			||||||
 | 
					                    logger.LogError(ex, "Failed to analyze image file {FileId}", file.Id);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            case "video":
 | 
				
			||||||
 | 
					            case "audio":
 | 
				
			||||||
 | 
					                try
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    var mediaInfo = await FFProbe.AnalyseAsync(filePath);
 | 
				
			||||||
 | 
					                    file.FileMeta = new Dictionary<string, object?>
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        ["width"] = mediaInfo.PrimaryVideoStream?.Width,
 | 
				
			||||||
 | 
					                        ["height"] = mediaInfo.PrimaryVideoStream?.Height,
 | 
				
			||||||
 | 
					                        ["duration"] = mediaInfo.Duration.TotalSeconds,
 | 
				
			||||||
 | 
					                        ["format_name"] = mediaInfo.Format.FormatName,
 | 
				
			||||||
 | 
					                        ["format_long_name"] = mediaInfo.Format.FormatLongName,
 | 
				
			||||||
 | 
					                        ["start_time"] = mediaInfo.Format.StartTime.ToString(),
 | 
				
			||||||
 | 
					                        ["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture),
 | 
				
			||||||
 | 
					                        ["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(),
 | 
				
			||||||
 | 
					                        ["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)
 | 
				
			||||||
 | 
					                        file.FileMeta["ratio"] = (double)mediaInfo.PrimaryVideoStream.Width /
 | 
				
			||||||
 | 
					                                                 mediaInfo.PrimaryVideoStream.Height;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                catch (Exception ex)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    logger.LogError(ex, "Failed to analyze media file {FileId}", file.Id);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var fileInfo = new FileInfo(filePath);
 | 
				
			||||||
 | 
					        if (fileInfo.Length > chunkSize * 1024 * 5)
 | 
				
			||||||
 | 
					            return await HashFastApproximateAsync(filePath, chunkSize);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await using var stream = File.OpenRead(filePath);
 | 
				
			||||||
 | 
					        using var md5 = MD5.Create();
 | 
				
			||||||
 | 
					        var hashBytes = await md5.ComputeHashAsync(stream);
 | 
				
			||||||
 | 
					        return Convert.ToHexString(hashBytes).ToLowerInvariant();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static async Task<string> HashFastApproximateAsync(string filePath, int chunkSize = 1024 * 1024)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        await using var stream = File.OpenRead(filePath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var buffer = new byte[chunkSize * 2];
 | 
				
			||||||
 | 
					        var fileLength = stream.Length;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, chunkSize));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (fileLength > chunkSize)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            stream.Seek(-chunkSize, SeekOrigin.End);
 | 
				
			||||||
 | 
					            bytesRead += await stream.ReadAsync(buffer.AsMemory(chunkSize, chunkSize));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var hash = MD5.HashData(buffer.AsSpan(0, bytesRead));
 | 
				
			||||||
 | 
					        stream.Position = 0;
 | 
				
			||||||
 | 
					        return Convert.ToHexString(hash).ToLowerInvariant();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task UploadFileToRemoteAsync(
 | 
				
			||||||
 | 
					        string storageId,
 | 
				
			||||||
 | 
					        Guid targetRemote,
 | 
				
			||||||
 | 
					        string filePath,
 | 
				
			||||||
 | 
					        string? suffix = null,
 | 
				
			||||||
 | 
					        string? contentType = null,
 | 
				
			||||||
 | 
					        bool selfDestruct = false
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        await using var fileStream = File.OpenRead(filePath);
 | 
				
			||||||
 | 
					        await UploadFileToRemoteAsync(storageId, targetRemote, fileStream, suffix, contentType);
 | 
				
			||||||
 | 
					        if (selfDestruct) File.Delete(filePath);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private async Task UploadFileToRemoteAsync(
 | 
				
			||||||
 | 
					        string storageId,
 | 
				
			||||||
 | 
					        Guid targetRemote,
 | 
				
			||||||
 | 
					        Stream stream,
 | 
				
			||||||
 | 
					        string? suffix = null,
 | 
				
			||||||
 | 
					        string? contentType = null
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var dest = await GetRemoteStorageConfig(targetRemote);
 | 
				
			||||||
 | 
					        if (dest is null)
 | 
				
			||||||
 | 
					            throw new InvalidOperationException(
 | 
				
			||||||
 | 
					                $"Failed to configure client for remote destination '{targetRemote}'"
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        var client = CreateMinioClient(dest);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var bucket = dest.Bucket;
 | 
				
			||||||
 | 
					        contentType ??= "application/octet-stream";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await client!.PutObjectAsync(new PutObjectArgs()
 | 
				
			||||||
 | 
					            .WithBucket(bucket)
 | 
				
			||||||
 | 
					            .WithObject(string.IsNullOrWhiteSpace(suffix) ? storageId : storageId + suffix)
 | 
				
			||||||
 | 
					            .WithStreamData(stream)
 | 
				
			||||||
 | 
					            .WithObjectSize(stream.Length)
 | 
				
			||||||
 | 
					            .WithContentType(contentType)
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<SnCloudFile> UpdateFileAsync(SnCloudFile file, FieldMask updateMask)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        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);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        await _PurgeCacheAsync(file.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await DeleteFileDataAsync(file);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (!file.PoolId.HasValue) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!force)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var sameOriginFiles = await db.Files
 | 
				
			||||||
 | 
					                .Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
 | 
				
			||||||
 | 
					                .Select(f => f.Id)
 | 
				
			||||||
 | 
					                .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (sameOriginFiles.Count != 0)
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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 bucket = dest.Bucket;
 | 
				
			||||||
 | 
					        var objectId = file.StorageId ?? file.Id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await client.RemoveObjectAsync(
 | 
				
			||||||
 | 
					            new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId)
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (file.HasCompression)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            try
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                await client.RemoveObjectAsync(
 | 
				
			||||||
 | 
					                    new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId + ".compressed")
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            catch
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                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 async Task DeleteFileDataBatchAsync(List<SnCloudFile> files)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        files = files.Where(f => f.PoolId.HasValue).ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            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)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var client = new MinioClient()
 | 
				
			||||||
 | 
					            .WithEndpoint(dest.Endpoint)
 | 
				
			||||||
 | 
					            .WithRegion(dest.Region)
 | 
				
			||||||
 | 
					            .WithCredentials(dest.SecretId, dest.SecretKey);
 | 
				
			||||||
 | 
					        if (dest.EnableSsl) client = client.WithSSL();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return client.Build();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    internal async Task _PurgeCacheAsync(string fileId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var cacheKey = $"{CacheKeyPrefix}{fileId}";
 | 
				
			||||||
 | 
					        await cache.RemoveAsync(cacheKey);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var tasks = fileIds.Select(_PurgeCacheAsync);
 | 
				
			||||||
 | 
					        await Task.WhenAll(tasks);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<List<SnCloudFile?>> LoadFromReference(List<SnCloudFileReferenceObject> references)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var cachedFiles = new Dictionary<string, SnCloudFile>();
 | 
				
			||||||
 | 
					        var uncachedIds = new List<string>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach (var reference in references)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var cacheKey = $"{CacheKeyPrefix}{reference.Id}";
 | 
				
			||||||
 | 
					            var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (cachedFile != null)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                cachedFiles[reference.Id] = cachedFile;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            else
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                uncachedIds.Add(reference.Id);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (uncachedIds.Count > 0)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var dbFiles = await db.Files
 | 
				
			||||||
 | 
					                .Where(f => uncachedIds.Contains(f.Id))
 | 
				
			||||||
 | 
					                .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            foreach (var file in dbFiles)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                var cacheKey = $"{CacheKeyPrefix}{file.Id}";
 | 
				
			||||||
 | 
					                await cache.SetAsync(cacheKey, file, CacheDuration);
 | 
				
			||||||
 | 
					                cachedFiles[file.Id] = file;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return [.. references
 | 
				
			||||||
 | 
					            .Select(r => cachedFiles.GetValueOrDefault(r.Id))
 | 
				
			||||||
 | 
					            .Where(f => f != null)];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<int> GetReferenceCountAsync(string fileId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.FileReferences
 | 
				
			||||||
 | 
					            .Where(r => r.FileId == fileId)
 | 
				
			||||||
 | 
					            .CountAsync();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<bool> IsReferencedAsync(string fileId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.FileReferences
 | 
				
			||||||
 | 
					            .Where(r => r.FileId == fileId)
 | 
				
			||||||
 | 
					            .AnyAsync();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static bool IsIgnoredField(string fieldName)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var gpsFields = new[]
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            "gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "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"
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (fieldName.StartsWith("exif-GPS")) return true;
 | 
				
			||||||
 | 
					        if (fieldName.StartsWith("ifd3-GPS")) return true;
 | 
				
			||||||
 | 
					        if (fieldName.EndsWith("-data")) return true;
 | 
				
			||||||
 | 
					        return gpsFields.Any(gpsField => fieldName.StartsWith(gpsField, StringComparison.OrdinalIgnoreCase));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<int> DeleteAccountRecycledFilesAsync(Guid accountId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var files = await db.Files
 | 
				
			||||||
 | 
					            .Where(f => f.AccountId == accountId && 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> 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.
 | 
				
			||||||
							
								
								
									
										301
									
								
								DysonNetwork.Drive/Storage/TusService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										301
									
								
								DysonNetwork.Drive/Storage/TusService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,301 @@
 | 
				
			|||||||
 | 
					using System.Net;
 | 
				
			||||||
 | 
					using System.Text;
 | 
				
			||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive.Billing;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Auth;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using Microsoft.Extensions.Options;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using tusdotnet.Interfaces;
 | 
				
			||||||
 | 
					using tusdotnet.Models;
 | 
				
			||||||
 | 
					using tusdotnet.Models.Configuration;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public abstract class TusService
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public static DefaultTusConfiguration BuildConfiguration(
 | 
				
			||||||
 | 
					        ITusStore store,
 | 
				
			||||||
 | 
					        IConfiguration configuration
 | 
				
			||||||
 | 
					    ) => new()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        Store = store,
 | 
				
			||||||
 | 
					        Events = new Events
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            OnAuthorizeAsync = async eventContext =>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                if (eventContext.Intent == IntentType.DeleteFile)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    eventContext.FailRequest(
 | 
				
			||||||
 | 
					                        HttpStatusCode.BadRequest,
 | 
				
			||||||
 | 
					                        "Deleting files from this endpoint was disabled, please refer to the Dyson Network File API."
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                    return;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                var httpContext = eventContext.HttpContext;
 | 
				
			||||||
 | 
					                if (httpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    eventContext.FailRequest(HttpStatusCode.Unauthorized);
 | 
				
			||||||
 | 
					                    return;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (eventContext.Intent != IntentType.CreateFile) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                using var scope = httpContext.RequestServices.CreateScope();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (!currentUser.IsSuperuser)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    var pm = scope.ServiceProvider.GetRequiredService<PermissionService.PermissionServiceClient>();
 | 
				
			||||||
 | 
					                    var allowed = await pm.HasPermissionAsync(new HasPermissionRequest
 | 
				
			||||||
 | 
					                        { Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
 | 
				
			||||||
 | 
					                    if (!allowed.HasPermission)
 | 
				
			||||||
 | 
					                        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 =>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                using var scope = eventContext.HttpContext.RequestServices.CreateScope();
 | 
				
			||||||
 | 
					                var services = scope.ServiceProvider;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                var httpContext = eventContext.HttpContext;
 | 
				
			||||||
 | 
					                if (httpContext.Items["CurrentUser"] is not Account user) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                var file = await eventContext.GetFileAsync();
 | 
				
			||||||
 | 
					                var metadata = await file.GetMetadataAsync(eventContext.CancellationToken);
 | 
				
			||||||
 | 
					                var fileName = metadata.TryGetValue("filename", out var fn)
 | 
				
			||||||
 | 
					                    ? fn.GetString(Encoding.UTF8)
 | 
				
			||||||
 | 
					                    : "uploaded_file";
 | 
				
			||||||
 | 
					                var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                var filePath = Path.Combine(configuration.GetValue<string>("Tus:StorePath")!, file.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault();
 | 
				
			||||||
 | 
					                var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
 | 
				
			||||||
 | 
					                var encryptPassword = httpContext.Request.Headers["X-FilePass"].FirstOrDefault();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (string.IsNullOrEmpty(filePool))
 | 
				
			||||||
 | 
					                    filePool = configuration["Storage:PreferredRemote"];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                Instant? expiredAt = null;
 | 
				
			||||||
 | 
					                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
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										125
									
								
								DysonNetwork.Drive/appsettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								DysonNetwork.Drive/appsettings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,125 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "Debug": true,
 | 
				
			||||||
 | 
					  "BaseUrl": "http://localhost:5090",
 | 
				
			||||||
 | 
					  "GatewayUrl": "http://localhost:5094",
 | 
				
			||||||
 | 
					  "Logging": {
 | 
				
			||||||
 | 
					    "LogLevel": {
 | 
				
			||||||
 | 
					      "Default": "Information",
 | 
				
			||||||
 | 
					      "Microsoft.AspNetCore": "Warning"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "AllowedHosts": "*",
 | 
				
			||||||
 | 
					  "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"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "Authentication": {
 | 
				
			||||||
 | 
					    "Schemes": {
 | 
				
			||||||
 | 
					      "Bearer": {
 | 
				
			||||||
 | 
					        "ValidAudiences": [
 | 
				
			||||||
 | 
					          "http://localhost:5071",
 | 
				
			||||||
 | 
					          "https://localhost:7099"
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        "ValidIssuer": "solar-network"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "AuthToken": {
 | 
				
			||||||
 | 
					    "PublicKeyPath": "Keys/PublicKey.pem",
 | 
				
			||||||
 | 
					    "PrivateKeyPath": "Keys/PrivateKey.pem"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "Tus": {
 | 
				
			||||||
 | 
					    "StorePath": "Uploads"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "Storage": {
 | 
				
			||||||
 | 
					    "Uploads": "Uploads",
 | 
				
			||||||
 | 
					    "PreferredRemote": "2adceae3-981a-4564-9b8d-5d71a211c873",
 | 
				
			||||||
 | 
					    "Remote": [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "Id": "minio",
 | 
				
			||||||
 | 
					        "Label": "Minio",
 | 
				
			||||||
 | 
					        "Region": "auto",
 | 
				
			||||||
 | 
					        "Bucket": "solar-network-development",
 | 
				
			||||||
 | 
					        "Endpoint": "localhost:9000",
 | 
				
			||||||
 | 
					        "SecretId": "littlesheep",
 | 
				
			||||||
 | 
					        "SecretKey": "password",
 | 
				
			||||||
 | 
					        "EnabledSigned": true,
 | 
				
			||||||
 | 
					        "EnableSsl": false
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "Id": "cloudflare",
 | 
				
			||||||
 | 
					        "Label": "Cloudflare R2",
 | 
				
			||||||
 | 
					        "Region": "auto",
 | 
				
			||||||
 | 
					        "Bucket": "solar-network",
 | 
				
			||||||
 | 
					        "Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
 | 
				
			||||||
 | 
					        "SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
 | 
				
			||||||
 | 
					        "SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
 | 
				
			||||||
 | 
					        "EnableSigned": true,
 | 
				
			||||||
 | 
					        "EnableSsl": true
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "Captcha": {
 | 
				
			||||||
 | 
					    "Provider": "cloudflare",
 | 
				
			||||||
 | 
					    "ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
 | 
				
			||||||
 | 
					    "ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "Notifications": {
 | 
				
			||||||
 | 
					    "Topic": "dev.solsynth.solian",
 | 
				
			||||||
 | 
					    "Endpoint": "http://localhost:8088"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "Email": {
 | 
				
			||||||
 | 
					    "Server": "smtp4dev.orb.local",
 | 
				
			||||||
 | 
					    "Port": 25,
 | 
				
			||||||
 | 
					    "UseSsl": false,
 | 
				
			||||||
 | 
					    "Username": "no-reply@mail.solsynth.dev",
 | 
				
			||||||
 | 
					    "Password": "password",
 | 
				
			||||||
 | 
					    "FromAddress": "no-reply@mail.solsynth.dev",
 | 
				
			||||||
 | 
					    "FromName": "Alphabot",
 | 
				
			||||||
 | 
					    "SubjectPrefix": "Solar Network"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "RealtimeChat": {
 | 
				
			||||||
 | 
					    "Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
 | 
				
			||||||
 | 
					    "ApiKey": "APIs6TiL8wj3A4j",
 | 
				
			||||||
 | 
					    "ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "GeoIp": {
 | 
				
			||||||
 | 
					    "DatabasePath": "./Keys/GeoLite2-City.mmdb"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "Oidc": {
 | 
				
			||||||
 | 
					    "Google": {
 | 
				
			||||||
 | 
					      "ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
 | 
				
			||||||
 | 
					      "ClientSecret": ""
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "Apple": {
 | 
				
			||||||
 | 
					      "ClientId": "dev.solsynth.solian",
 | 
				
			||||||
 | 
					      "TeamId": "W7HPZ53V6B",
 | 
				
			||||||
 | 
					      "KeyId": "B668YP4KBG",
 | 
				
			||||||
 | 
					      "PrivateKeyPath": "./Keys/Solarpass.p8"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "Microsoft": {
 | 
				
			||||||
 | 
					      "ClientId": "YOUR_MICROSOFT_CLIENT_ID",
 | 
				
			||||||
 | 
					      "ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
 | 
				
			||||||
 | 
					      "DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "Payment": {
 | 
				
			||||||
 | 
					    "Auth": {
 | 
				
			||||||
 | 
					      "Afdian": "<token here>"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "Subscriptions": {
 | 
				
			||||||
 | 
					      "Afdian": {
 | 
				
			||||||
 | 
					        "7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
 | 
				
			||||||
 | 
					        "7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
 | 
				
			||||||
 | 
					        "141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "KnownProxies": [
 | 
				
			||||||
 | 
					    "127.0.0.1",
 | 
				
			||||||
 | 
					    "::1"
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  "Service": {
 | 
				
			||||||
 | 
					    "Name": "DysonNetwork.Drive",
 | 
				
			||||||
 | 
					    "Url": "https://localhost:7092"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										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
 | 
				
			||||||
							
								
								
									
										269
									
								
								DysonNetwork.Pass/Account/AccountController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								DysonNetwork.Pass/Account/AccountController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,269 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Auth;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Credit;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Wallet;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.GeoIp;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Http;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Models;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Account;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("/api/accounts")]
 | 
				
			||||||
 | 
					public class AccountController(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    AuthService auth,
 | 
				
			||||||
 | 
					    AccountService accounts,
 | 
				
			||||||
 | 
					    SubscriptionService subscriptions,
 | 
				
			||||||
 | 
					    AccountEventService events,
 | 
				
			||||||
 | 
					    SocialCreditService socialCreditService,
 | 
				
			||||||
 | 
					    GeoIpService geo
 | 
				
			||||||
 | 
					) : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [HttpGet("{name}")]
 | 
				
			||||||
 | 
					    [ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
 | 
				
			||||||
 | 
					    [ProducesResponseType(StatusCodes.Status404NotFound)]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccount?>> GetByName(string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var account = await db.Accounts
 | 
				
			||||||
 | 
					            .Include(e => e.Badges)
 | 
				
			||||||
 | 
					            .Include(e => e.Profile)
 | 
				
			||||||
 | 
					            .Include(e => e.Contacts.Where(c => c.IsPublic))
 | 
				
			||||||
 | 
					            .Where(a => a.Name == name)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
 | 
				
			||||||
 | 
					        account.PerkSubscription = perk?.ToReference();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return account;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{name}/badges")]
 | 
				
			||||||
 | 
					    [ProducesResponseType<List<SnAccountBadge>>(StatusCodes.Status200OK)]
 | 
				
			||||||
 | 
					    [ProducesResponseType(StatusCodes.Status404NotFound)]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<SnAccountBadge>>> GetBadgesByName(string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var account = await db.Accounts
 | 
				
			||||||
 | 
					            .Include(e => e.Badges)
 | 
				
			||||||
 | 
					            .Where(a => a.Name == name)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        return account is null
 | 
				
			||||||
 | 
					            ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier))
 | 
				
			||||||
 | 
					            : account.Badges.ToList();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{name}/credits")]
 | 
				
			||||||
 | 
					    [ProducesResponseType<double>(StatusCodes.Status200OK)]
 | 
				
			||||||
 | 
					    [ProducesResponseType(StatusCodes.Status404NotFound)]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<double>> GetSocialCredits(string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var account = await db.Accounts
 | 
				
			||||||
 | 
					            .Where(a => a.Name == name)
 | 
				
			||||||
 | 
					            .Select(a => new { a.Id })
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (account is null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var credits = await socialCreditService.GetSocialCredit(account.Id);
 | 
				
			||||||
 | 
					        return credits;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class AccountCreateRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [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;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [EmailAddress]
 | 
				
			||||||
 | 
					        [RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")]
 | 
				
			||||||
 | 
					        [Required]
 | 
				
			||||||
 | 
					        [MaxLength(1024)]
 | 
				
			||||||
 | 
					        public string Email { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [Required]
 | 
				
			||||||
 | 
					        [MinLength(4)]
 | 
				
			||||||
 | 
					        [MaxLength(128)]
 | 
				
			||||||
 | 
					        public string Password { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [MaxLength(32)] public string Language { get; set; } = "en-us";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [Required] public string CaptchaToken { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost]
 | 
				
			||||||
 | 
					    [ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
 | 
				
			||||||
 | 
					    [ProducesResponseType(StatusCodes.Status400BadRequest)]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccount>> CreateAccount([FromBody] AccountCreateRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (!await auth.ValidateCaptcha(request.CaptchaToken))
 | 
				
			||||||
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(request.CaptchaToken)] = ["Invalid captcha token."]
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
 | 
				
			||||||
 | 
					        if (ip is null) return BadRequest(ApiError.NotFound(request.Name, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					        var region = geo.GetFromIp(ip)?.Country.IsoCode ?? "us";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var account = await accounts.CreateAccount(
 | 
				
			||||||
 | 
					                request.Name,
 | 
				
			||||||
 | 
					                request.Nick,
 | 
				
			||||||
 | 
					                request.Email,
 | 
				
			||||||
 | 
					                request.Password,
 | 
				
			||||||
 | 
					                request.Language,
 | 
				
			||||||
 | 
					                region
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            return Ok(account);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "BAD_REQUEST",
 | 
				
			||||||
 | 
					                Message = "Failed to create account.",
 | 
				
			||||||
 | 
					                Detail = ex.Message,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class RecoveryPasswordRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [Required] public string Account { get; set; } = null!;
 | 
				
			||||||
 | 
					        [Required] public string CaptchaToken { get; set; } = null!;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("recovery/password")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (!await auth.ValidateCaptcha(request.CaptchaToken))
 | 
				
			||||||
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(request.CaptchaToken)] = new[] { "Invalid captcha token." }
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var account = await accounts.LookupAccount(request.Account);
 | 
				
			||||||
 | 
					        if (account is null)
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "NOT_FOUND",
 | 
				
			||||||
 | 
					                Message = "Unable to find the account.",
 | 
				
			||||||
 | 
					                Detail = request.Account,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.RequestPasswordReset(account);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (InvalidOperationException)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "TOO_MANY_REQUESTS",
 | 
				
			||||||
 | 
					                Message = "You already requested password reset within 24 hours.",
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class StatusRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        public StatusAttitude Attitude { get; set; }
 | 
				
			||||||
 | 
					        public bool IsInvisible { get; set; }
 | 
				
			||||||
 | 
					        public bool IsNotDisturb { get; set; }
 | 
				
			||||||
 | 
					        public bool IsAutomated { get; set; } = false;
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? Label { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(4096)] public string? AppIdentifier { get; set; }
 | 
				
			||||||
 | 
					        public Dictionary<string, object>? Meta { get; set; }
 | 
				
			||||||
 | 
					        public Instant? ClearedAt { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{name}/statuses")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountStatus>> GetOtherStatus(string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
 | 
				
			||||||
 | 
					        if (account is null)
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "NOT_FOUND",
 | 
				
			||||||
 | 
					                Message = "Account not found.",
 | 
				
			||||||
 | 
					                Detail = name,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        var status = await events.GetStatus(account.Id);
 | 
				
			||||||
 | 
					        status.IsInvisible = false; // Keep the invisible field not available for other users
 | 
				
			||||||
 | 
					        return Ok(status);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{name}/calendar")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<DailyEventResponse>>> GetOtherEventCalendar(
 | 
				
			||||||
 | 
					        string name,
 | 
				
			||||||
 | 
					        [FromQuery] int? month,
 | 
				
			||||||
 | 
					        [FromQuery] int? year
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
 | 
				
			||||||
 | 
					        month ??= currentDate.Month;
 | 
				
			||||||
 | 
					        year ??= currentDate.Year;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (month is < 1 or > 12)
 | 
				
			||||||
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(month)] = new[] { "Month must be between 1 and 12." }
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					        if (year < 1)
 | 
				
			||||||
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(year)] = new[] { "Year must be a positive integer." }
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
 | 
				
			||||||
 | 
					        if (account is null)
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "not_found",
 | 
				
			||||||
 | 
					                Message = "Account not found.",
 | 
				
			||||||
 | 
					                Detail = name,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
 | 
				
			||||||
 | 
					        return Ok(calendar);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("search")]
 | 
				
			||||||
 | 
					    public async Task<List<SnAccount>> Search([FromQuery] string query, [FromQuery] int take = 20)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (string.IsNullOrWhiteSpace(query))
 | 
				
			||||||
 | 
					            return [];
 | 
				
			||||||
 | 
					        return await db.Accounts
 | 
				
			||||||
 | 
					            .Include(e => e.Profile)
 | 
				
			||||||
 | 
					            .Where(a => EF.Functions.ILike(a.Name, $"%{query}%") ||
 | 
				
			||||||
 | 
					                        EF.Functions.ILike(a.Nick, $"%{query}%"))
 | 
				
			||||||
 | 
					            .Take(take)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										936
									
								
								DysonNetwork.Pass/Account/AccountCurrentController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										936
									
								
								DysonNetwork.Pass/Account/AccountCurrentController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,936 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Permission;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Wallet;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Http;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Models;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using AuthService = DysonNetwork.Pass.Auth.AuthService;
 | 
				
			||||||
 | 
					using SnAuthSession = DysonNetwork.Shared.Models.SnAuthSession;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Account;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[Authorize]
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("/api/accounts/me")]
 | 
				
			||||||
 | 
					public class AccountCurrentController(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    AccountService accounts,
 | 
				
			||||||
 | 
					    SubscriptionService subscriptions,
 | 
				
			||||||
 | 
					    AccountEventService events,
 | 
				
			||||||
 | 
					    AuthService auth,
 | 
				
			||||||
 | 
					    FileService.FileServiceClient files,
 | 
				
			||||||
 | 
					    FileReferenceService.FileReferenceServiceClient fileRefs,
 | 
				
			||||||
 | 
					    Credit.SocialCreditService creditService
 | 
				
			||||||
 | 
					) : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [HttpGet]
 | 
				
			||||||
 | 
					    [ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
 | 
				
			||||||
 | 
					    [ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccount>> GetCurrentIdentity()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        var userId = currentUser.Id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var account = await db.Accounts
 | 
				
			||||||
 | 
					            .Include(e => e.Badges)
 | 
				
			||||||
 | 
					            .Include(e => e.Profile)
 | 
				
			||||||
 | 
					            .Where(e => e.Id == userId)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var perk = await subscriptions.GetPerkSubscriptionAsync(account!.Id);
 | 
				
			||||||
 | 
					        account.PerkSubscription = perk?.ToReference();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(account);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class BasicInfoRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [MaxLength(256)] public string? Nick { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(32)] public string? Language { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(32)] public string? Region { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPatch]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccount>> UpdateBasicInfo([FromBody] BasicInfoRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.Nick is not null) account.Nick = request.Nick;
 | 
				
			||||||
 | 
					        if (request.Language is not null) account.Language = request.Language;
 | 
				
			||||||
 | 
					        if (request.Region is not null) account.Region = request.Region;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        await accounts.PurgeAccountCache(currentUser);
 | 
				
			||||||
 | 
					        return currentUser;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class ProfileRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [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 Shared.Models.UsernameColor? UsernameColor { get; set; }
 | 
				
			||||||
 | 
					        public Instant? Birthday { get; set; }
 | 
				
			||||||
 | 
					        public List<ProfileLink>? Links { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [MaxLength(32)] public string? PictureId { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(32)] public string? BackgroundId { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPatch("profile")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountProfile>> UpdateProfile([FromBody] ProfileRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        var userId = currentUser.Id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var profile = await db.AccountProfiles
 | 
				
			||||||
 | 
					            .Where(p => p.Account.Id == userId)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (profile is null)
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "NOT_FOUND",
 | 
				
			||||||
 | 
					                Message = "Unable to get your account.",
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.FirstName is not null) profile.FirstName = request.FirstName;
 | 
				
			||||||
 | 
					        if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
 | 
				
			||||||
 | 
					        if (request.LastName is not null) profile.LastName = request.LastName;
 | 
				
			||||||
 | 
					        if (request.Bio is not null) profile.Bio = request.Bio;
 | 
				
			||||||
 | 
					        if (request.Gender is not null) profile.Gender = request.Gender;
 | 
				
			||||||
 | 
					        if (request.Pronouns is not null) profile.Pronouns = request.Pronouns;
 | 
				
			||||||
 | 
					        if (request.Birthday is not null) profile.Birthday = request.Birthday;
 | 
				
			||||||
 | 
					        if (request.Location is not null) profile.Location = request.Location;
 | 
				
			||||||
 | 
					        if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
 | 
				
			||||||
 | 
					        if (request.Links is not null) profile.Links = request.Links;
 | 
				
			||||||
 | 
					        if (request.UsernameColor is not null) profile.UsernameColor = request.UsernameColor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.PictureId is not null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var file = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
 | 
				
			||||||
 | 
					            if (profile.Picture is not null)
 | 
				
			||||||
 | 
					                await fileRefs.DeleteResourceReferencesAsync(
 | 
				
			||||||
 | 
					                    new DeleteResourceReferencesRequest { ResourceId = profile.ResourceIdentifier }
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            await fileRefs.CreateReferenceAsync(
 | 
				
			||||||
 | 
					                new CreateReferenceRequest
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    ResourceId = profile.ResourceIdentifier,
 | 
				
			||||||
 | 
					                    FileId = request.PictureId,
 | 
				
			||||||
 | 
					                    Usage = "profile.picture"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            profile.Picture = SnCloudFileReferenceObject.FromProtoValue(file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.BackgroundId is not null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
 | 
				
			||||||
 | 
					            if (profile.Background is not null)
 | 
				
			||||||
 | 
					                await fileRefs.DeleteResourceReferencesAsync(
 | 
				
			||||||
 | 
					                    new DeleteResourceReferencesRequest { ResourceId = profile.ResourceIdentifier }
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            await fileRefs.CreateReferenceAsync(
 | 
				
			||||||
 | 
					                new CreateReferenceRequest
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    ResourceId = profile.ResourceIdentifier,
 | 
				
			||||||
 | 
					                    FileId = request.BackgroundId,
 | 
				
			||||||
 | 
					                    Usage = "profile.background"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            profile.Background = SnCloudFileReferenceObject.FromProtoValue(file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.Update(profile);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await accounts.PurgeAccountCache(currentUser);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return profile;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete]
 | 
				
			||||||
 | 
					    public async Task<ActionResult> RequestDeleteAccount()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.RequestAccountDeletion(currentUser);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (InvalidOperationException)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "TOO_MANY_REQUESTS",
 | 
				
			||||||
 | 
					                Message = "You already requested account deletion within 24 hours.",
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("statuses")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountStatus>> GetCurrentStatus()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        var status = await events.GetStatus(currentUser.Id);
 | 
				
			||||||
 | 
					        return Ok(status);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPatch("statuses")]
 | 
				
			||||||
 | 
					    [RequiredPermission("global", "accounts.statuses.update")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountStatus>> UpdateStatus([FromBody] AccountController.StatusRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        if (request is { IsAutomated: true, AppIdentifier: not null })
 | 
				
			||||||
 | 
					            return BadRequest("Automated status cannot be updated.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        var status = await db.AccountStatuses
 | 
				
			||||||
 | 
					            .Where(e => e.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .Where(e => e.ClearedAt == null || e.ClearedAt > now)
 | 
				
			||||||
 | 
					            .OrderByDescending(e => e.CreatedAt)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (status is null) return NotFound(ApiError.NotFound("status", traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					        if (status.IsAutomated && request.AppIdentifier is null)
 | 
				
			||||||
 | 
					            return BadRequest("Automated status cannot be updated.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        status.Attitude = request.Attitude;
 | 
				
			||||||
 | 
					        status.IsInvisible = request.IsInvisible;
 | 
				
			||||||
 | 
					        status.IsNotDisturb = request.IsNotDisturb;
 | 
				
			||||||
 | 
					        status.IsAutomated = request.IsAutomated;
 | 
				
			||||||
 | 
					        status.Label = request.Label;
 | 
				
			||||||
 | 
					        status.AppIdentifier = request.AppIdentifier;
 | 
				
			||||||
 | 
					        status.Meta = request.Meta;
 | 
				
			||||||
 | 
					        status.ClearedAt = request.ClearedAt;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.Update(status);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        events.PurgeStatusCache(currentUser.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return status;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("statuses")]
 | 
				
			||||||
 | 
					    [RequiredPermission("global", "accounts.statuses.create")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountStatus>> CreateStatus([FromBody] AccountController.StatusRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request is { IsAutomated: true, AppIdentifier: not null })
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					            var existingStatus = await db.AccountStatuses
 | 
				
			||||||
 | 
					                .Where(s => s.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					                .Where(s => s.ClearedAt == null || s.ClearedAt > now)
 | 
				
			||||||
 | 
					                .OrderByDescending(s => s.CreatedAt)
 | 
				
			||||||
 | 
					                .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					            if (existingStatus is not null && existingStatus.IsAutomated)
 | 
				
			||||||
 | 
					                if (existingStatus.IsAutomated && request.AppIdentifier == existingStatus.AppIdentifier)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    existingStatus.Attitude = request.Attitude;
 | 
				
			||||||
 | 
					                    existingStatus.IsInvisible = request.IsInvisible;
 | 
				
			||||||
 | 
					                    existingStatus.IsNotDisturb = request.IsNotDisturb;
 | 
				
			||||||
 | 
					                    existingStatus.Meta = request.Meta;
 | 
				
			||||||
 | 
					                    existingStatus.Label = request.Label;
 | 
				
			||||||
 | 
					                    db.Update(existingStatus);
 | 
				
			||||||
 | 
					                    await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					                    return Ok(existingStatus);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                else
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    existingStatus.ClearedAt = now;
 | 
				
			||||||
 | 
					                    db.Update(existingStatus);
 | 
				
			||||||
 | 
					                    await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            else if (existingStatus is not null)
 | 
				
			||||||
 | 
					                return Ok(existingStatus); // Do not override manually set status with automated ones
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var status = new SnAccountStatus
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            AccountId = currentUser.Id,
 | 
				
			||||||
 | 
					            Attitude = request.Attitude,
 | 
				
			||||||
 | 
					            IsInvisible = request.IsInvisible,
 | 
				
			||||||
 | 
					            IsNotDisturb = request.IsNotDisturb,
 | 
				
			||||||
 | 
					            IsAutomated = request.IsAutomated,
 | 
				
			||||||
 | 
					            Label = request.Label,
 | 
				
			||||||
 | 
					            Meta = request.Meta,
 | 
				
			||||||
 | 
					            AppIdentifier = request.AppIdentifier,
 | 
				
			||||||
 | 
					            ClearedAt = request.ClearedAt
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return await events.CreateStatus(currentUser, status);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("statuses")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult> DeleteStatus([FromQuery] string? app)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        var queryable = db.AccountStatuses
 | 
				
			||||||
 | 
					            .Where(s => s.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .Where(s => s.ClearedAt == null || s.ClearedAt > now)
 | 
				
			||||||
 | 
					            .OrderByDescending(s => s.CreatedAt)
 | 
				
			||||||
 | 
					            .AsQueryable();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!string.IsNullOrWhiteSpace(app))
 | 
				
			||||||
 | 
					            queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var status = await queryable
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (status is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await events.ClearStatus(currentUser, status);
 | 
				
			||||||
 | 
					        return NoContent();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("check-in")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnCheckInResult>> GetCheckInResult()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        var userId = currentUser.Id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        var today = now.InUtc().Date;
 | 
				
			||||||
 | 
					        var startOfDay = today.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
 | 
				
			||||||
 | 
					        var endOfDay = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var result = await db.AccountCheckInResults
 | 
				
			||||||
 | 
					            .Where(x => x.AccountId == userId)
 | 
				
			||||||
 | 
					            .Where(x => x.CreatedAt >= startOfDay && x.CreatedAt < endOfDay)
 | 
				
			||||||
 | 
					            .OrderByDescending(x => x.CreatedAt)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return result is null
 | 
				
			||||||
 | 
					            ? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier))
 | 
				
			||||||
 | 
					            : Ok(result);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("check-in")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnCheckInResult>> DoCheckIn(
 | 
				
			||||||
 | 
					        [FromBody] string? captchaToken,
 | 
				
			||||||
 | 
					        [FromQuery] Instant? backdated = null
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (backdated is null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
 | 
				
			||||||
 | 
					            if (!isAvailable)
 | 
				
			||||||
 | 
					                return BadRequest(new ApiError
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    Code = "BAD_REQUEST",
 | 
				
			||||||
 | 
					                    Message = "Check-in is not available for today.",
 | 
				
			||||||
 | 
					                    Status = 400,
 | 
				
			||||||
 | 
					                    TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        else
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            if (currentUser.PerkSubscription is null)
 | 
				
			||||||
 | 
					                return StatusCode(403, ApiError.Unauthorized(
 | 
				
			||||||
 | 
					                    message: "You need to have a subscription to check-in backdated.",
 | 
				
			||||||
 | 
					                    forbidden: true,
 | 
				
			||||||
 | 
					                    traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					            var isAvailable = await events.CheckInBackdatedIsAvailable(currentUser, backdated.Value);
 | 
				
			||||||
 | 
					            if (!isAvailable)
 | 
				
			||||||
 | 
					                return BadRequest(new ApiError
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    Code = "BAD_REQUEST",
 | 
				
			||||||
 | 
					                    Message = "Check-in is not available for this date.",
 | 
				
			||||||
 | 
					                    Status = 400,
 | 
				
			||||||
 | 
					                    TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var needsCaptcha = await events.CheckInDailyDoAskCaptcha(currentUser);
 | 
				
			||||||
 | 
					            return needsCaptcha switch
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423,
 | 
				
			||||||
 | 
					                    new ApiError
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        Code = "CAPTCHA_REQUIRED",
 | 
				
			||||||
 | 
					                        Message = "Captcha is required for this check-in.",
 | 
				
			||||||
 | 
					                        Status = 423,
 | 
				
			||||||
 | 
					                        TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(
 | 
				
			||||||
 | 
					                    new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        ["captchaToken"] = new[] { "Invalid captcha token." }
 | 
				
			||||||
 | 
					                    }, traceId: HttpContext.TraceIdentifier)),
 | 
				
			||||||
 | 
					                _ => await events.CheckInDaily(currentUser, backdated)
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (InvalidOperationException ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "BAD_REQUEST",
 | 
				
			||||||
 | 
					                Message = "Check-in failed.",
 | 
				
			||||||
 | 
					                Detail = ex.Message,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("calendar")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month,
 | 
				
			||||||
 | 
					        [FromQuery] int? year)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
 | 
				
			||||||
 | 
					        month ??= currentDate.Month;
 | 
				
			||||||
 | 
					        year ??= currentDate.Year;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (month is < 1 or > 12)
 | 
				
			||||||
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(month)] = new[] { "Month must be between 1 and 12." }
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					        if (year < 1)
 | 
				
			||||||
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(year)] = new[] { "Year must be a positive integer." }
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value);
 | 
				
			||||||
 | 
					        return Ok(calendar);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("actions")]
 | 
				
			||||||
 | 
					    [ProducesResponseType<List<ActionLog>>(StatusCodes.Status200OK)]
 | 
				
			||||||
 | 
					    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<ActionLog>>> GetActionLogs(
 | 
				
			||||||
 | 
					        [FromQuery] int take = 20,
 | 
				
			||||||
 | 
					        [FromQuery] int offset = 0
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var query = db.ActionLogs
 | 
				
			||||||
 | 
					            .Where(log => log.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .OrderByDescending(log => log.CreatedAt);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var total = await query.CountAsync();
 | 
				
			||||||
 | 
					        Response.Headers.Append("X-Total", total.ToString());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var logs = await query
 | 
				
			||||||
 | 
					            .Skip(offset)
 | 
				
			||||||
 | 
					            .Take(take)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(logs);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("factors")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<SnAccountAuthFactor>>> GetAuthFactors()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var factors = await db.AccountAuthFactors
 | 
				
			||||||
 | 
					            .Include(f => f.Account)
 | 
				
			||||||
 | 
					            .Where(f => f.Account.Id == currentUser.Id)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(factors);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class AuthFactorRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        public Shared.Models.AccountAuthFactorType Type { get; set; }
 | 
				
			||||||
 | 
					        public string? Secret { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("factors")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "ALREADY_EXISTS",
 | 
				
			||||||
 | 
					                Message = $"Auth factor with type {request.Type} already exists.",
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
 | 
				
			||||||
 | 
					        return Ok(factor);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("factors/{id:guid}/enable")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var factor = await db.AccountAuthFactors
 | 
				
			||||||
 | 
					            .Where(f => f.AccountId == currentUser.Id && f.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (factor is null) return NotFound(ApiError.NotFound(id.ToString(), traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            factor = await accounts.EnableAuthFactor(factor, code);
 | 
				
			||||||
 | 
					            return Ok(factor);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "BAD_REQUEST",
 | 
				
			||||||
 | 
					                Message = "Failed to enable auth factor.",
 | 
				
			||||||
 | 
					                Detail = ex.Message,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("factors/{id:guid}/disable")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountAuthFactor>> DisableAuthFactor(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var factor = await db.AccountAuthFactors
 | 
				
			||||||
 | 
					            .Where(f => f.AccountId == currentUser.Id && f.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (factor is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            factor = await accounts.DisableAuthFactor(factor);
 | 
				
			||||||
 | 
					            return Ok(factor);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("factors/{id:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountAuthFactor>> DeleteAuthFactor(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var factor = await db.AccountAuthFactors
 | 
				
			||||||
 | 
					            .Where(f => f.AccountId == currentUser.Id && f.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (factor is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.DeleteAuthFactor(factor);
 | 
				
			||||||
 | 
					            return NoContent();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("devices")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<SnAuthClientWithChallenge>>> GetDevices()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
 | 
				
			||||||
 | 
					            HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var devices = await db.AuthClients
 | 
				
			||||||
 | 
					            .Where(device => device.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var challengeDevices = devices.Select(SnAuthClientWithChallenge.FromClient).ToList();
 | 
				
			||||||
 | 
					        var deviceIds = challengeDevices.Select(x => x.Id).ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var authChallenges = await db.AuthChallenges
 | 
				
			||||||
 | 
					            .Where(c => c.ClientId != null && deviceIds.Contains(c.ClientId.Value))
 | 
				
			||||||
 | 
					            .GroupBy(c => c.ClientId)
 | 
				
			||||||
 | 
					            .ToDictionaryAsync(c => c.Key!.Value, c => c.ToList());
 | 
				
			||||||
 | 
					        foreach (var challengeDevice in challengeDevices)
 | 
				
			||||||
 | 
					            if (authChallenges.TryGetValue(challengeDevice.Id, out var challenge))
 | 
				
			||||||
 | 
					                challengeDevice.Challenges = challenge;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(challengeDevices);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("sessions")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<SnAuthSession>>> GetSessions(
 | 
				
			||||||
 | 
					        [FromQuery] int take = 20,
 | 
				
			||||||
 | 
					        [FromQuery] int offset = 0
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
 | 
				
			||||||
 | 
					            HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var query = db.AuthSessions
 | 
				
			||||||
 | 
					            .Include(session => session.Account)
 | 
				
			||||||
 | 
					            .Include(session => session.Challenge)
 | 
				
			||||||
 | 
					            .Where(session => session.Account.Id == currentUser.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var total = await query.CountAsync();
 | 
				
			||||||
 | 
					        Response.Headers.Append("X-Total", total.ToString());
 | 
				
			||||||
 | 
					        Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var sessions = await query
 | 
				
			||||||
 | 
					            .OrderByDescending(x => x.LastGrantedAt)
 | 
				
			||||||
 | 
					            .Skip(offset)
 | 
				
			||||||
 | 
					            .Take(take)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(sessions);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("sessions/{id:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAuthSession>> DeleteSession(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.DeleteSession(currentUser, id);
 | 
				
			||||||
 | 
					            return NoContent();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("devices/{deviceId}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAuthSession>> DeleteDevice(string deviceId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.DeleteDevice(currentUser, deviceId);
 | 
				
			||||||
 | 
					            return NoContent();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("sessions/current")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAuthSession>> DeleteCurrentSession()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
 | 
				
			||||||
 | 
					            HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.DeleteSession(currentUser, currentSession.Id);
 | 
				
			||||||
 | 
					            return NoContent();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPatch("devices/{deviceId}/label")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAuthSession>> UpdateDeviceLabel(string deviceId, [FromBody] string label)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.UpdateDeviceName(currentUser, deviceId, label);
 | 
				
			||||||
 | 
					            return NoContent();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPatch("devices/current/label")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAuthSession>> UpdateCurrentDeviceLabel([FromBody] string label)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
 | 
				
			||||||
 | 
					            HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.Challenge.ClientId);
 | 
				
			||||||
 | 
					        if (device is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.UpdateDeviceName(currentUser, device.DeviceId, label);
 | 
				
			||||||
 | 
					            return NoContent();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("contacts")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<SnAccountContact>>> GetContacts()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var contacts = await db.AccountContacts
 | 
				
			||||||
 | 
					            .Where(c => c.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(contacts);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class AccountContactRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [Required] public Shared.Models.AccountContactType Type { get; set; }
 | 
				
			||||||
 | 
					        [Required] public string Content { get; set; } = null!;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("contacts")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountContact>> CreateContact([FromBody] AccountContactRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var contact = await accounts.CreateContactMethod(currentUser, request.Type, request.Content);
 | 
				
			||||||
 | 
					            return Ok(contact);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("contacts/{id:guid}/verify")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountContact>> VerifyContact(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var contact = await db.AccountContacts
 | 
				
			||||||
 | 
					            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (contact is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.VerifyContactMethod(currentUser, contact);
 | 
				
			||||||
 | 
					            return Ok(contact);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("contacts/{id:guid}/primary")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountContact>> SetPrimaryContact(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var contact = await db.AccountContacts
 | 
				
			||||||
 | 
					            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (contact is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            contact = await accounts.SetContactMethodPrimary(currentUser, contact);
 | 
				
			||||||
 | 
					            return Ok(contact);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("contacts/{id:guid}/public")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountContact>> SetPublicContact(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var contact = await db.AccountContacts
 | 
				
			||||||
 | 
					            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (contact is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            contact = await accounts.SetContactMethodPublic(currentUser, contact, true);
 | 
				
			||||||
 | 
					            return Ok(contact);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("contacts/{id:guid}/public")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountContact>> UnsetPublicContact(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var contact = await db.AccountContacts
 | 
				
			||||||
 | 
					            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (contact is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            contact = await accounts.SetContactMethodPublic(currentUser, contact, false);
 | 
				
			||||||
 | 
					            return Ok(contact);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("contacts/{id:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountContact>> DeleteContact(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var contact = await db.AccountContacts
 | 
				
			||||||
 | 
					            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (contact is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.DeleteContactMethod(currentUser, contact);
 | 
				
			||||||
 | 
					            return NoContent();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("badges")]
 | 
				
			||||||
 | 
					    [ProducesResponseType<List<SnAccountBadge>>(StatusCodes.Status200OK)]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<SnAccountBadge>>> GetBadges()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var badges = await db.Badges
 | 
				
			||||||
 | 
					            .Where(b => b.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					        return Ok(badges);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("badges/{id:guid}/active")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnAccountBadge>> ActivateBadge(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.ActiveBadge(currentUser, id);
 | 
				
			||||||
 | 
					            return Ok();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("leveling")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SnExperienceRecord>> GetLevelingHistory(
 | 
				
			||||||
 | 
					        [FromQuery] int take = 20,
 | 
				
			||||||
 | 
					        [FromQuery] int offset = 0
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var queryable = db.ExperienceRecords
 | 
				
			||||||
 | 
					            .Where(r => r.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .OrderByDescending(r => r.CreatedAt)
 | 
				
			||||||
 | 
					            .AsQueryable();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var totalCount = await queryable.CountAsync();
 | 
				
			||||||
 | 
					        Response.Headers["X-Total"] = totalCount.ToString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var records = await queryable
 | 
				
			||||||
 | 
					            .Skip(offset)
 | 
				
			||||||
 | 
					            .Take(take)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					        return Ok(records);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("credits")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<bool>> GetSocialCredit()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var credit = await creditService.GetSocialCredit(currentUser.Id);
 | 
				
			||||||
 | 
					        return Ok(credit);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("credits/history")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SocialCreditRecord>> GetCreditHistory(
 | 
				
			||||||
 | 
					        [FromQuery] int take = 20,
 | 
				
			||||||
 | 
					        [FromQuery] int offset = 0
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var queryable = db.SocialCreditRecords
 | 
				
			||||||
 | 
					            .Where(r => r.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .OrderByDescending(r => r.CreatedAt)
 | 
				
			||||||
 | 
					            .AsQueryable();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var totalCount = await queryable.CountAsync();
 | 
				
			||||||
 | 
					        Response.Headers["X-Total"] = totalCount.ToString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var records = await queryable
 | 
				
			||||||
 | 
					            .Skip(offset)
 | 
				
			||||||
 | 
					            .Take(take)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					        return Ok(records);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user