Compare commits
	
		
			713 Commits
		
	
	
		
			afdbde951c
			...
			master
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						3e838cfdb5
	
				 | 
					
					
						|||
| 
						
						
							
						
						e0e00d023f
	
				 | 
					
					
						|||
| 
						
						
							
						
						433230b495
	
				 | 
					
					
						|||
| 
						
						
							
						
						b8fa5f5f24
	
				 | 
					
					
						|||
| 
						
						
							
						
						091fbd857e
	
				 | 
					
					
						|||
| 
						
						
							
						
						bfa9bedeea
	
				 | 
					
					
						|||
| 
						
						
							
						
						74f8221be4
	
				 | 
					
					
						|||
| 
						
						
							
						
						6817ab6b56
	
				 | 
					
					
						|||
| 
						
						
							
						
						c74ab20236
	
				 | 
					
					
						|||
| 
						
						
							
						
						b9edf51f05
	
				 | 
					
					
						|||
| 
						
						
							
						
						74a9ca98ad
	
				 | 
					
					
						|||
| 
						
						
							
						
						4bd59f107b
	
				 | 
					
					
						|||
| 
						
						
							
						
						08f924f647
	
				 | 
					
					
						|||
| 
						
						
							
						
						5445df3b61
	
				 | 
					
					
						|||
| 
						
						
							
						
						a377ca2072
	
				 | 
					
					
						|||
| 
						
						
							
						
						623e7a5771
	
				 | 
					
					
						|||
| 
						
						
							
						
						0351a2b4fa
	
				 | 
					
					
						|||
| 
						
						
							
						
						322dee4453
	
				 | 
					
					
						|||
| 
						
						
							
						
						5e5f4528b9
	
				 | 
					
					
						|||
| 
						
						
							
						
						70fdc247e7
	
				 | 
					
					
						|||
| 
						
						
							
						
						8f5f1efa24
	
				 | 
					
					
						|||
| 
						
						
							
						
						0f15510ac6
	
				 | 
					
					
						|||
| 
						
						
							
						
						3ce457e9f9
	
				 | 
					
					
						|||
| 
						
						
							
						
						a9168dcdc5
	
				 | 
					
					
						|||
| 
						
						
							
						
						4ad63577ba
	
				 | 
					
					
						|||
| 
						
						
							
						
						47722cfd57
	
				 | 
					
					
						|||
| 
						
						
							
						
						b46a010e73
	
				 | 
					
					
						|||
| 
						
						
							
						
						ccd9dbcdbf
	
				 | 
					
					
						|||
| 
						
						
							
						
						0b65bf8dd7
	
				 | 
					
					
						|||
| 
						
						
							
						
						ab23f87a66
	
				 | 
					
					
						|||
| 
						
						
							
						
						8f1047ff5d
	
				 | 
					
					
						|||
| 
						
						
							
						
						43e50a00ce
	
				 | 
					
					
						|||
| 
						
						
							
						
						50133684c7
	
				 | 
					
					
						|||
| 
						
						
							
						
						befde25266
	
				 | 
					
					
						|||
| 
						
						
							
						
						437f49fb20
	
				 | 
					
					
						|||
| 
						
						
							
						
						c3b6358f33
	
				 | 
					
					
						|||
| 
						
						
							
						
						4347281fcd
	
				 | 
					
					
						|||
| 
						
						
							
						
						92cd6b5f7e
	
				 | 
					
					
						|||
| 
						
						
							
						
						cf6e534d02
	
				 | 
					
					
						|||
| 
						
						
							
						
						29c5971554
	
				 | 
					
					
						|||
| 
						
						
							
						
						cdfc3f6571
	
				 | 
					
					
						|||
| 
						
						
							
						
						f65a7360e2
	
				 | 
					
					
						|||
| 
						
						
							
						
						85e706335a
	
				 | 
					
					
						|||
| 
						
						
							
						
						fe74060df9
	
				 | 
					
					
						|||
| 
						
						
							
						
						e8d5f22395
	
				 | 
					
					
						|||
| 
						
						
							
						
						83fa2568aa
	
				 | 
					
					
						|||
| 
						
						
							
						
						bf1c8e0a85
	
				 | 
					
					
						|||
| 
						
						
							
						
						323fa8ee15
	
				 | 
					
					
						|||
| 
						
						
							
						
						e7a46e96ed
	
				 | 
					
					
						|||
| 
						
						
							
						
						3a0dee11a6
	
				 | 
					
					
						|||
| 
						
						
							
						
						43be47d526
	
				 | 
					
					
						|||
| 
						
						
							
						
						48067af034
	
				 | 
					
					
						|||
| 
						
						
							
						
						7e7e90ad24
	
				 | 
					
					
						|||
| 
						
						
							
						
						3af4069581
	
				 | 
					
					
						|||
| 
						
						
							
						
						609b130b4e
	
				 | 
					
					
						|||
| 
						
						
							
						
						93f7dfd379
	
				 | 
					
					
						|||
| 
						
						
							
						
						40325c6df5
	
				 | 
					
					
						|||
| 
						
						
							
						
						bbcaa27ac5
	
				 | 
					
					
						|||
| 
						
						
							
						
						19d833a522
	
				 | 
					
					
						|||
| 
						
						
							
						
						a94102e136
	
				 | 
					
					
						|||
| 
						
						
							
						
						fc693793fe
	
				 | 
					
					
						|||
| 
						
						
							
						
						8cfdabbae4
	
				 | 
					
					
						|||
| 
						
						
							
						
						985ff41c72
	
				 | 
					
					
						|||
| 
						
						
							
						
						a79ea4ac49
	
				 | 
					
					
						|||
| 
						
						
							
						
						7385caff9a
	
				 | 
					
					
						|||
| 
						
						
							
						
						15954dbfe2
	
				 | 
					
					
						|||
| 
						
						
							
						
						4ba6206c9d
	
				 | 
					
					
						|||
| 
						
						
							
						
						266b9e36e2
	
				 | 
					
					
						|||
| 
						
						
							
						
						e6aa61b03b
	
				 | 
					
					
						|||
| 
						
						
							
						
						0c09ef25ec
	
				 | 
					
					
						|||
| 
						
						
							
						
						dd5929c691
	
				 | 
					
					
						|||
| 
						
						
							
						
						cf87fdfb49
	
				 | 
					
					
						|||
| 
						
						
							
						
						ff03584518
	
				 | 
					
					
						|||
| 
						
						
							
						
						d6c37784e1
	
				 | 
					
					
						|||
| 
						
						
							
						
						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 | |||
| b6d416a3a8 | |||
| 2a8cbbfa24 | |||
| 29d752bdd9 | |||
| b12e3315fe | |||
| ce3958d397 | |||
| 26ea2503a4 | |||
| d6ce068490 | |||
| da4ee81c95 | |||
| bec294365f | |||
| 51a8b684fd | |||
| 7b026eeae1 | |||
| 4dd4542c37 | 
							
								
								
									
										4
									
								
								.aspire/settings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.aspire/settings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
  "appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
**/.dockerignore
 | 
			
		||||
**/.env
 | 
			
		||||
**/.git
 | 
			
		||||
**/.gitignore
 | 
			
		||||
**/.project
 | 
			
		||||
**/.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
 | 
			
		||||
							
								
								
									
										90
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										90
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,4 +1,4 @@
 | 
			
		||||
name: Build and Push Dyson Sphere
 | 
			
		||||
name: Build and Push Microservices
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
@@ -7,27 +7,97 @@ on:
 | 
			
		||||
  workflow_dispatch:
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  build:
 | 
			
		||||
    runs-on: ubuntu-latest # x86_64 (default), avoids arm64 native module issues
 | 
			
		||||
  determine-changes:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    outputs:
 | 
			
		||||
      matrix: ${{ steps.changes.outputs.matrix }}
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout repository
 | 
			
		||||
        uses: actions/checkout@v4
 | 
			
		||||
        with:
 | 
			
		||||
          fetch-depth: 0
 | 
			
		||||
 | 
			
		||||
      - name: Get changed files
 | 
			
		||||
        id: changed-files
 | 
			
		||||
        run: |
 | 
			
		||||
          echo "files=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} | xargs)" >> $GITHUB_OUTPUT
 | 
			
		||||
 | 
			
		||||
      - name: Determine changed services
 | 
			
		||||
        id: changes
 | 
			
		||||
        run: |
 | 
			
		||||
          files="${{ steps.changed-files.outputs.files }}"
 | 
			
		||||
          matrix="{\"include\":[]}"
 | 
			
		||||
          services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight")
 | 
			
		||||
          images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight")
 | 
			
		||||
          changed_services=()
 | 
			
		||||
 | 
			
		||||
          for file in $files; do
 | 
			
		||||
            if [[ "$file" == DysonNetwork.Shared/* ]]; then
 | 
			
		||||
              changed_services=("${services[@]}")
 | 
			
		||||
              break
 | 
			
		||||
            fi
 | 
			
		||||
            for i in "${!services[@]}"; do
 | 
			
		||||
              if [[ "$file" == DysonNetwork.${services[$i]}/* ]]; then
 | 
			
		||||
                # check if service is already in changed_services
 | 
			
		||||
                if [[ ! " ${changed_services[@]} " =~ " ${services[$i]} " ]]; then
 | 
			
		||||
                  changed_services+=("${services[$i]}")
 | 
			
		||||
                fi
 | 
			
		||||
              fi
 | 
			
		||||
            done
 | 
			
		||||
          done
 | 
			
		||||
 | 
			
		||||
          if [ ${#changed_services[@]} -gt 0 ]; then
 | 
			
		||||
            json_objects=""
 | 
			
		||||
            for service in "${changed_services[@]}"; do
 | 
			
		||||
              for i in "${!services[@]}"; do
 | 
			
		||||
                if [[ "${services[$i]}" == "$service" ]]; then
 | 
			
		||||
                  image="${images[$i]}"
 | 
			
		||||
                  break
 | 
			
		||||
                fi
 | 
			
		||||
              done
 | 
			
		||||
              json_objects+="{\"service\":\"$service\",\"image\":\"$image\"},"
 | 
			
		||||
            done
 | 
			
		||||
            matrix="{\"include\":[${json_objects%,}]}"
 | 
			
		||||
          fi
 | 
			
		||||
          echo "matrix=$matrix" >> $GITHUB_OUTPUT
 | 
			
		||||
 | 
			
		||||
  build-and-push:
 | 
			
		||||
    needs: determine-changes
 | 
			
		||||
    if: ${{ needs.determine-changes.outputs.matrix != '{"include":[]}' }}
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    permissions:
 | 
			
		||||
      contents: read
 | 
			
		||||
      packages: write
 | 
			
		||||
    strategy:
 | 
			
		||||
      matrix: ${{ fromJson(needs.determine-changes.outputs.matrix) }}
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - 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
 | 
			
		||||
        uses: docker/setup-buildx-action@v3
 | 
			
		||||
 | 
			
		||||
      - name: Log in to DockerHub
 | 
			
		||||
      - name: Log in to GitHub Container Registry
 | 
			
		||||
        uses: docker/login-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
 | 
			
		||||
          username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          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
 | 
			
		||||
        with:
 | 
			
		||||
          file: DysonNetwork.Sphere/Dockerfile
 | 
			
		||||
          context: .
 | 
			
		||||
          file: DysonNetwork.${{ matrix.service }}/Dockerfile
 | 
			
		||||
          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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -6,3 +6,4 @@ riderModule.iml
 | 
			
		||||
/_ReSharper.Caches/
 | 
			
		||||
.idea
 | 
			
		||||
.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
 | 
			
		||||
							
								
								
									
										71
									
								
								DysonNetwork.Control/AppHost.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								DysonNetwork.Control/AppHost.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
			
		||||
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);
 | 
			
		||||
var insightService = builder.AddProject<Projects.DysonNetwork_Insight>("insight")
 | 
			
		||||
    .WithReference(passService)
 | 
			
		||||
    .WithReference(ringService)
 | 
			
		||||
    .WithReference(sphereService)
 | 
			
		||||
    .WithReference(developService);
 | 
			
		||||
 | 
			
		||||
passService.WithReference(developService).WithReference(driveService);
 | 
			
		||||
 | 
			
		||||
List<IResourceBuilder<ProjectResource>> services =
 | 
			
		||||
    [ringService, passService, driveService, sphereService, developService, insightService];
 | 
			
		||||
 | 
			
		||||
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();
 | 
			
		||||
							
								
								
									
										28
									
								
								DysonNetwork.Control/DysonNetwork.Control.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								DysonNetwork.Control/DysonNetwork.Control.csproj
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
  <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.2" />
 | 
			
		||||
    
 | 
			
		||||
  <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.2" />
 | 
			
		||||
    <PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
 | 
			
		||||
    <PackageReference Include="Aspire.Hosting.Nats" Version="9.5.2" />
 | 
			
		||||
    <PackageReference Include="Aspire.Hosting.Redis" Version="9.5.2" />
 | 
			
		||||
  </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" />
 | 
			
		||||
    <ProjectReference Include="..\DysonNetwork.Insight\DysonNetwork.Insight.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"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										81
									
								
								DysonNetwork.Develop/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								DysonNetwork.Develop/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
			
		||||
using DysonNetwork.Shared.Models;
 | 
			
		||||
using Microsoft.EntityFrameworkCore;
 | 
			
		||||
using Microsoft.EntityFrameworkCore.Design;
 | 
			
		||||
using NodaTime;
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    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);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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"]
 | 
			
		||||
							
								
								
									
										36
									
								
								DysonNetwork.Develop/DysonNetwork.Develop.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								DysonNetwork.Develop/DysonNetwork.Develop.csproj
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
<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.10" />
 | 
			
		||||
        <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
 | 
			
		||||
            <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="Swashbuckle.AspNetCore" Version="9.0.6" />
 | 
			
		||||
        <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,
 | 
			
		||||
    RemoteAccountService remoteAccounts,
 | 
			
		||||
    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 remoteAccounts.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,
 | 
			
		||||
    RemoteAccountService remoteAccounts
 | 
			
		||||
)
 | 
			
		||||
{
 | 
			
		||||
    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 remoteAccounts.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.FromProtoValue(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.FromProtoValue(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.FromProtoValue);
 | 
			
		||||
 | 
			
		||||
        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.AddSphereService();
 | 
			
		||||
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("DysonNetwork.Develop");
 | 
			
		||||
 | 
			
		||||
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();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										73
									
								
								DysonNetwork.Develop/Project/DevProjectService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								DysonNetwork.Develop/Project/DevProjectService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,73 @@
 | 
			
		||||
using Microsoft.EntityFrameworkCore;
 | 
			
		||||
using DysonNetwork.Shared.Proto;
 | 
			
		||||
using DysonNetwork.Shared.Models;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Develop.Project;
 | 
			
		||||
 | 
			
		||||
public class DevProjectService(AppDatabase db )
 | 
			
		||||
{
 | 
			
		||||
    public async Task<SnDevProject> CreateProjectAsync(
 | 
			
		||||
        SnDeveloper developer,
 | 
			
		||||
        DevProjectController.DevProjectRequest request
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        var project = new SnDevProject
 | 
			
		||||
        {
 | 
			
		||||
            Slug = request.Slug!,
 | 
			
		||||
            Name = request.Name!,
 | 
			
		||||
            Description = request.Description ?? string.Empty,
 | 
			
		||||
            DeveloperId = developer.Id
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        db.DevProjects.Add(project);
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
 | 
			
		||||
        return project;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<SnDevProject?> GetProjectAsync(Guid id, Guid? developerId = null)
 | 
			
		||||
    {
 | 
			
		||||
        var query = db.DevProjects.AsQueryable();
 | 
			
		||||
 | 
			
		||||
        if (developerId.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            query = query.Where(p => p.DeveloperId == developerId.Value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return await query.FirstOrDefaultAsync(p => p.Id == id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<List<SnDevProject>> GetProjectsByDeveloperAsync(Guid developerId)
 | 
			
		||||
    {
 | 
			
		||||
        return await db.DevProjects
 | 
			
		||||
            .Where(p => p.DeveloperId == developerId)
 | 
			
		||||
            .ToListAsync();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<SnDevProject?> UpdateProjectAsync(
 | 
			
		||||
        Guid id,
 | 
			
		||||
        Guid developerId,
 | 
			
		||||
        DevProjectController.DevProjectRequest request
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        var project = await GetProjectAsync(id, developerId);
 | 
			
		||||
        if (project == null) return null;
 | 
			
		||||
 | 
			
		||||
        if (request.Slug != null) project.Slug = request.Slug;
 | 
			
		||||
        if (request.Name != null) project.Name = request.Name;
 | 
			
		||||
        if (request.Description != null) project.Description = request.Description;
 | 
			
		||||
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
        return project;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<bool> DeleteProjectAsync(Guid id, Guid developerId)
 | 
			
		||||
    {
 | 
			
		||||
        var project = await GetProjectAsync(id, developerId);
 | 
			
		||||
        if (project == null) return false;
 | 
			
		||||
 | 
			
		||||
        db.DevProjects.Remove(project);
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -5,7 +5,6 @@
 | 
			
		||||
      "commandName": "Project",
 | 
			
		||||
      "dotnetRunMessages": true,
 | 
			
		||||
      "launchBrowser": false,
 | 
			
		||||
      "applicationUrl": "http://localhost:5212",
 | 
			
		||||
      "environmentVariables": {
 | 
			
		||||
        "ASPNETCORE_ENVIRONMENT": "Development"
 | 
			
		||||
      }
 | 
			
		||||
@@ -14,7 +13,6 @@
 | 
			
		||||
      "commandName": "Project",
 | 
			
		||||
      "dotnetRunMessages": true,
 | 
			
		||||
      "launchBrowser": false,
 | 
			
		||||
      "applicationUrl": "https://localhost:7259;http://localhost:5212",
 | 
			
		||||
      "environmentVariables": {
 | 
			
		||||
        "ASPNETCORE_ENVIRONMENT": "Development"
 | 
			
		||||
      }
 | 
			
		||||
							
								
								
									
										28
									
								
								DysonNetwork.Develop/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								DysonNetwork.Develop/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
using DysonNetwork.Develop.Identity;
 | 
			
		||||
using DysonNetwork.Shared.Auth;
 | 
			
		||||
using DysonNetwork.Shared.Http;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Develop.Startup;
 | 
			
		||||
 | 
			
		||||
public static class ApplicationConfiguration
 | 
			
		||||
{
 | 
			
		||||
    public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
 | 
			
		||||
    {
 | 
			
		||||
        app.MapOpenApi();
 | 
			
		||||
 | 
			
		||||
        app.UseRequestLocalization();
 | 
			
		||||
 | 
			
		||||
        app.ConfigureForwardedHeaders(configuration);
 | 
			
		||||
 | 
			
		||||
        app.UseAuthentication();
 | 
			
		||||
        app.UseAuthorization();
 | 
			
		||||
        app.UseMiddleware<PermissionMiddleware>();
 | 
			
		||||
 | 
			
		||||
        app.MapControllers();
 | 
			
		||||
        
 | 
			
		||||
        app.MapGrpcService<CustomAppServiceGrpc>();
 | 
			
		||||
        app.MapGrpcReflectionService();
 | 
			
		||||
 | 
			
		||||
        return app;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										62
									
								
								DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
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.AddGrpcReflection();
 | 
			
		||||
 | 
			
		||||
        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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								DysonNetwork.Develop/appsettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								DysonNetwork.Develop/appsettings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
{
 | 
			
		||||
  "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
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										3
									
								
								DysonNetwork.Drive/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								DysonNetwork.Drive/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
/Uploads/
 | 
			
		||||
/Client/node_modules/
 | 
			
		||||
/wwwroot/dist
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
using System.Linq.Expressions;
 | 
			
		||||
using System.Reflection;
 | 
			
		||||
using DysonNetwork.Drive.Storage;
 | 
			
		||||
using DysonNetwork.Shared.Data;
 | 
			
		||||
using DysonNetwork.Drive.Billing;
 | 
			
		||||
using DysonNetwork.Shared.Models;
 | 
			
		||||
using Microsoft.EntityFrameworkCore;
 | 
			
		||||
using Microsoft.EntityFrameworkCore.Design;
 | 
			
		||||
using Microsoft.EntityFrameworkCore.Query;
 | 
			
		||||
@@ -15,7 +15,12 @@ public class AppDatabase(
 | 
			
		||||
    IConfiguration configuration
 | 
			
		||||
) : DbContext(options)
 | 
			
		||||
{
 | 
			
		||||
    public DbSet<CloudFile> Files { get; set; } = null!;
 | 
			
		||||
    public DbSet<FilePool> Pools { get; set; } = null!;
 | 
			
		||||
    public DbSet<SnFileBundle> Bundles { get; set; } = null!;
 | 
			
		||||
    
 | 
			
		||||
    public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!;
 | 
			
		||||
    
 | 
			
		||||
    public DbSet<SnCloudFile> Files { get; set; } = null!;
 | 
			
		||||
    public DbSet<CloudFileReference> FileReferences { get; set; } = null!;
 | 
			
		||||
    
 | 
			
		||||
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
 | 
			
		||||
@@ -25,7 +30,6 @@ public class AppDatabase(
 | 
			
		||||
            opt => opt
 | 
			
		||||
                .ConfigureDataSource(optSource => optSource.EnableDynamicJson())
 | 
			
		||||
                .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
 | 
			
		||||
                .UseNetTopologySuite()
 | 
			
		||||
                .UseNodaTime()
 | 
			
		||||
        ).UseSnakeCaseNamingConvention();
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										28
									
								
								DysonNetwork.Drive/Billing/Quota.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								DysonNetwork.Drive/Billing/Quota.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
using DysonNetwork.Shared.Models;
 | 
			
		||||
using NodaTime;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Billing;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// The quota record stands for the extra quota that a user has.
 | 
			
		||||
/// For normal users, the quota is 1GiB.
 | 
			
		||||
/// For stellar program t1 users, the quota is 5GiB
 | 
			
		||||
/// For stellar program t2 users, the quota is 10GiB
 | 
			
		||||
/// For stellar program t3 users, the quota is 15GiB
 | 
			
		||||
///
 | 
			
		||||
/// If users want to increase the quota, they need to pay for it.
 | 
			
		||||
/// Each 1NSD they paid for one GiB.
 | 
			
		||||
///
 | 
			
		||||
/// But the quota record unit is MiB, the minimal billable unit.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public class QuotaRecord : ModelBase
 | 
			
		||||
{
 | 
			
		||||
    public Guid Id { get; set; } = Guid.NewGuid();
 | 
			
		||||
    public Guid AccountId { get; set; }
 | 
			
		||||
    public string Name { get; set; } = string.Empty;
 | 
			
		||||
    public string Description { get; set; } = string.Empty;
 | 
			
		||||
    
 | 
			
		||||
    public long Quota { get; set; }
 | 
			
		||||
    
 | 
			
		||||
    public Instant? ExpiredAt { get; set; }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										66
									
								
								DysonNetwork.Drive/Billing/QuotaController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								DysonNetwork.Drive/Billing/QuotaController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,66 @@
 | 
			
		||||
using DysonNetwork.Shared.Proto;
 | 
			
		||||
using Microsoft.AspNetCore.Authorization;
 | 
			
		||||
using Microsoft.AspNetCore.Mvc;
 | 
			
		||||
using Microsoft.EntityFrameworkCore;
 | 
			
		||||
using NodaTime;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Billing;
 | 
			
		||||
 | 
			
		||||
[ApiController]
 | 
			
		||||
[Route("/api/billing/quota")]
 | 
			
		||||
public class QuotaController(AppDatabase db, QuotaService quota) : ControllerBase
 | 
			
		||||
{
 | 
			
		||||
    public class QuotaDetails
 | 
			
		||||
    {
 | 
			
		||||
        public long BasedQuota { get; set; }
 | 
			
		||||
        public long ExtraQuota { get; set; }
 | 
			
		||||
        public long TotalQuota { get; set; }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    [HttpGet]
 | 
			
		||||
    [Authorize]
 | 
			
		||||
    public async Task<ActionResult<QuotaDetails>> GetQuota()
 | 
			
		||||
    {
 | 
			
		||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
			
		||||
        var accountId = Guid.Parse(currentUser.Id);
 | 
			
		||||
        
 | 
			
		||||
        var (based, extra) = await quota.GetQuotaVerbose(accountId);
 | 
			
		||||
        return Ok(new QuotaDetails
 | 
			
		||||
        {
 | 
			
		||||
            BasedQuota = based,
 | 
			
		||||
            ExtraQuota = extra,
 | 
			
		||||
            TotalQuota = based + extra
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    [HttpGet("records")]
 | 
			
		||||
    [Authorize]
 | 
			
		||||
    public async Task<ActionResult<List<QuotaRecord>>> GetQuotaRecords(
 | 
			
		||||
        [FromQuery] bool expired = false,
 | 
			
		||||
        [FromQuery] int offset = 0,
 | 
			
		||||
        [FromQuery] int take = 20
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
			
		||||
        var accountId = Guid.Parse(currentUser.Id);
 | 
			
		||||
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        var query = db.QuotaRecords
 | 
			
		||||
            .Where(r => r.AccountId == accountId)
 | 
			
		||||
            .AsQueryable();
 | 
			
		||||
        if (!expired)
 | 
			
		||||
            query = query
 | 
			
		||||
                .Where(r => !r.ExpiredAt.HasValue || r.ExpiredAt > now);
 | 
			
		||||
 | 
			
		||||
        var total = await query.CountAsync();
 | 
			
		||||
        Response.Headers.Append("X-Total", total.ToString());
 | 
			
		||||
 | 
			
		||||
        var records = await query
 | 
			
		||||
            .OrderByDescending(r => r.CreatedAt)
 | 
			
		||||
            .Skip(offset)
 | 
			
		||||
            .Take(take)
 | 
			
		||||
            .ToListAsync();
 | 
			
		||||
 | 
			
		||||
        return Ok(records);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										69
									
								
								DysonNetwork.Drive/Billing/QuotaService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								DysonNetwork.Drive/Billing/QuotaService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,69 @@
 | 
			
		||||
using DysonNetwork.Shared.Auth;
 | 
			
		||||
using DysonNetwork.Shared.Cache;
 | 
			
		||||
using DysonNetwork.Shared.Proto;
 | 
			
		||||
using Microsoft.EntityFrameworkCore;
 | 
			
		||||
using NodaTime;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Billing;
 | 
			
		||||
 | 
			
		||||
public class QuotaService(
 | 
			
		||||
    AppDatabase db,
 | 
			
		||||
    UsageService usage,
 | 
			
		||||
    AccountService.AccountServiceClient accounts,
 | 
			
		||||
    ICacheService cache
 | 
			
		||||
)
 | 
			
		||||
{
 | 
			
		||||
    public async Task<(bool ok, long billable, long quota)> IsFileAcceptable(Guid accountId, double costMultiplier, long newFileSize)
 | 
			
		||||
    {
 | 
			
		||||
        // The billable unit is MiB
 | 
			
		||||
        var billableUnit = (long)Math.Ceiling(newFileSize / 1024.0 / 1024.0 * costMultiplier);
 | 
			
		||||
        var totalBillableUsage = await usage.GetTotalBillableUsage(accountId);
 | 
			
		||||
        var quota = await GetQuota(accountId);
 | 
			
		||||
        return (totalBillableUsage + billableUnit <= quota, billableUnit, quota);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<long> GetQuota(Guid accountId)
 | 
			
		||||
    {
 | 
			
		||||
        var cacheKey = $"file:quota:{accountId}";
 | 
			
		||||
        var cachedResult = await cache.GetAsync<long?>(cacheKey);
 | 
			
		||||
        if (cachedResult.HasValue) return cachedResult.Value;
 | 
			
		||||
        
 | 
			
		||||
        var (based, extra) = await GetQuotaVerbose(accountId);
 | 
			
		||||
        var quota = based + extra;
 | 
			
		||||
        await cache.SetAsync(cacheKey, quota, expiry: TimeSpan.FromMinutes(30));
 | 
			
		||||
        return quota;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    public async Task<(long based, long extra)> GetQuotaVerbose(Guid accountId)
 | 
			
		||||
    {
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        var response = await accounts.GetAccountAsync(new GetAccountRequest { Id = accountId.ToString() });
 | 
			
		||||
        var perkSubscription = response.PerkSubscription;
 | 
			
		||||
 | 
			
		||||
        // The base quota is 1GiB, T1 is 5GiB, T2 is 10GiB, T3 is 15GiB
 | 
			
		||||
        var basedQuota = 1L;
 | 
			
		||||
        if (perkSubscription != null)
 | 
			
		||||
        {
 | 
			
		||||
            var privilege = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(perkSubscription.Identifier);
 | 
			
		||||
            basedQuota = privilege switch
 | 
			
		||||
            {
 | 
			
		||||
                1 => 5L,
 | 
			
		||||
                2 => 10L,
 | 
			
		||||
                3 => 15L,
 | 
			
		||||
                _ => basedQuota
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // The based quota is in GiB, we need to convert it to MiB
 | 
			
		||||
        basedQuota *= 1024L;
 | 
			
		||||
        
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        var extraQuota = await db.QuotaRecords
 | 
			
		||||
            .Where(e => e.AccountId == accountId)
 | 
			
		||||
            .Where(e => !e.ExpiredAt.HasValue || e.ExpiredAt > now)
 | 
			
		||||
            .SumAsync(e => e.Quota);
 | 
			
		||||
        
 | 
			
		||||
        return (basedQuota, extraQuota);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										49
									
								
								DysonNetwork.Drive/Billing/UsageController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								DysonNetwork.Drive/Billing/UsageController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
			
		||||
using DysonNetwork.Shared.Cache;
 | 
			
		||||
using DysonNetwork.Shared.Proto;
 | 
			
		||||
using Microsoft.AspNetCore.Authorization;
 | 
			
		||||
using Microsoft.AspNetCore.Mvc;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Billing;
 | 
			
		||||
 | 
			
		||||
[ApiController]
 | 
			
		||||
[Route("api/billing/usage")]
 | 
			
		||||
public class UsageController(UsageService usage, QuotaService quota, ICacheService cache) : ControllerBase
 | 
			
		||||
{
 | 
			
		||||
    [HttpGet]
 | 
			
		||||
    [Authorize]
 | 
			
		||||
    public async Task<ActionResult<TotalUsageDetails>> GetTotalUsage()
 | 
			
		||||
    {
 | 
			
		||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
			
		||||
        var accountId = Guid.Parse(currentUser.Id);
 | 
			
		||||
        
 | 
			
		||||
        var cacheKey = $"file:usage:{accountId}";
 | 
			
		||||
        
 | 
			
		||||
        // Try to get from cache first
 | 
			
		||||
        var (found, cachedResult) = await cache.GetAsyncWithStatus<TotalUsageDetails>(cacheKey);
 | 
			
		||||
        if (found && cachedResult != null)
 | 
			
		||||
            return Ok(cachedResult);
 | 
			
		||||
 | 
			
		||||
        // If not in cache, get from services
 | 
			
		||||
        var result = await usage.GetTotalUsage(accountId);
 | 
			
		||||
        var totalQuota = await quota.GetQuota(accountId);
 | 
			
		||||
        result.TotalQuota = totalQuota;
 | 
			
		||||
 | 
			
		||||
        // Cache the result for 5 minutes
 | 
			
		||||
        await cache.SetAsync(cacheKey, result, TimeSpan.FromMinutes(5));
 | 
			
		||||
 | 
			
		||||
        return Ok(result);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Authorize]
 | 
			
		||||
    [HttpGet("{poolId:guid}")]
 | 
			
		||||
    public async Task<ActionResult<UsageDetails>> GetPoolUsage(Guid poolId)
 | 
			
		||||
    {
 | 
			
		||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
			
		||||
        var accountId = Guid.Parse(currentUser.Id);
 | 
			
		||||
        
 | 
			
		||||
        var usageDetails = await usage.GetPoolUsage(poolId, accountId);
 | 
			
		||||
        if (usageDetails == null)
 | 
			
		||||
            return NotFound();
 | 
			
		||||
        return usageDetails;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										121
									
								
								DysonNetwork.Drive/Billing/UsageService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								DysonNetwork.Drive/Billing/UsageService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,121 @@
 | 
			
		||||
using Microsoft.EntityFrameworkCore;
 | 
			
		||||
using NodaTime;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Billing;
 | 
			
		||||
 | 
			
		||||
public class UsageDetails
 | 
			
		||||
{
 | 
			
		||||
    public required Guid PoolId { get; set; }
 | 
			
		||||
    public required string PoolName { get; set; }
 | 
			
		||||
    public required long UsageBytes { get; set; }
 | 
			
		||||
    public required double Cost { get; set; }
 | 
			
		||||
    public required long FileCount { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public class TotalUsageDetails
 | 
			
		||||
{
 | 
			
		||||
    public required List<UsageDetails> PoolUsages { get; set; }
 | 
			
		||||
    public required long TotalUsageBytes { get; set; }
 | 
			
		||||
    public required long TotalFileCount { get; set; }
 | 
			
		||||
    
 | 
			
		||||
    // Quota, cannot be loaded in the service, cause circular dependency
 | 
			
		||||
    // Let the controller do the calculation
 | 
			
		||||
    public long? TotalQuota { get; set; }
 | 
			
		||||
    public long? UsedQuota { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public class UsageService(AppDatabase db)
 | 
			
		||||
{
 | 
			
		||||
    public async Task<TotalUsageDetails> GetTotalUsage(Guid accountId)
 | 
			
		||||
    {
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        var fileQuery = db.Files
 | 
			
		||||
            .Where(f => !f.IsMarkedRecycle)
 | 
			
		||||
            .Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now)
 | 
			
		||||
            .Where(f => f.AccountId == accountId)
 | 
			
		||||
            .AsQueryable();
 | 
			
		||||
        
 | 
			
		||||
        var poolUsages = await db.Pools
 | 
			
		||||
            .Select(p => new UsageDetails
 | 
			
		||||
            {
 | 
			
		||||
                PoolId = p.Id,
 | 
			
		||||
                PoolName = p.Name,
 | 
			
		||||
                UsageBytes = fileQuery
 | 
			
		||||
                    .Where(f => f.PoolId == p.Id)
 | 
			
		||||
                    .Sum(f => f.Size),
 | 
			
		||||
                Cost = fileQuery
 | 
			
		||||
                           .Where(f => f.PoolId == p.Id)
 | 
			
		||||
                           .Sum(f => f.Size) / 1024.0 / 1024.0 *
 | 
			
		||||
                       (p.BillingConfig.CostMultiplier ?? 1.0),
 | 
			
		||||
                FileCount = fileQuery
 | 
			
		||||
                    .Count(f => f.PoolId == p.Id)
 | 
			
		||||
            })
 | 
			
		||||
            .ToListAsync();
 | 
			
		||||
 | 
			
		||||
        var totalUsage = poolUsages.Sum(p => p.UsageBytes);
 | 
			
		||||
        var totalFileCount = poolUsages.Sum(p => p.FileCount);
 | 
			
		||||
 | 
			
		||||
        return new TotalUsageDetails
 | 
			
		||||
        {
 | 
			
		||||
            PoolUsages = poolUsages,
 | 
			
		||||
            TotalUsageBytes = totalUsage,
 | 
			
		||||
            TotalFileCount = totalFileCount,
 | 
			
		||||
            UsedQuota = await GetTotalBillableUsage(accountId)
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<UsageDetails?> GetPoolUsage(Guid poolId, Guid accountId)
 | 
			
		||||
    {
 | 
			
		||||
        var pool = await db.Pools.FindAsync(poolId);
 | 
			
		||||
        if (pool == null)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        var fileQuery = db.Files
 | 
			
		||||
            .Where(f => !f.IsMarkedRecycle)
 | 
			
		||||
            .Where(f => f.ExpiredAt.HasValue && f.ExpiredAt > now)
 | 
			
		||||
            .Where(f => f.AccountId == accountId)
 | 
			
		||||
            .AsQueryable();
 | 
			
		||||
 | 
			
		||||
        var usageBytes = await fileQuery
 | 
			
		||||
            .SumAsync(f => f.Size);
 | 
			
		||||
 | 
			
		||||
        var fileCount = await fileQuery
 | 
			
		||||
            .CountAsync();
 | 
			
		||||
 | 
			
		||||
        var cost = usageBytes / 1024.0 / 1024.0 *
 | 
			
		||||
                   (pool.BillingConfig.CostMultiplier ?? 1.0);
 | 
			
		||||
 | 
			
		||||
        return new UsageDetails
 | 
			
		||||
        {
 | 
			
		||||
            PoolId = pool.Id,
 | 
			
		||||
            PoolName = pool.Name,
 | 
			
		||||
            UsageBytes = usageBytes,
 | 
			
		||||
            Cost = cost,
 | 
			
		||||
            FileCount = fileCount
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<long> GetTotalBillableUsage(Guid accountId)
 | 
			
		||||
    {
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        var files = await db.Files
 | 
			
		||||
            .Where(f => f.AccountId == accountId)
 | 
			
		||||
            .Where(f => f.PoolId.HasValue)
 | 
			
		||||
            .Where(f => !f.IsMarkedRecycle)
 | 
			
		||||
            .Include(f => f.Pool)
 | 
			
		||||
            .Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now)
 | 
			
		||||
            .Select(f => new
 | 
			
		||||
            {
 | 
			
		||||
                f.Size,
 | 
			
		||||
                Multiplier = f.Pool!.BillingConfig.CostMultiplier ?? 1.0
 | 
			
		||||
            })
 | 
			
		||||
            .ToListAsync();
 | 
			
		||||
 | 
			
		||||
        var totalCost = files.Sum(f => f.Size * f.Multiplier) / 1024.0 / 1024.0;
 | 
			
		||||
 | 
			
		||||
        return (long)Math.Ceiling(totalCost);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,17 +1,36 @@
 | 
			
		||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
 | 
			
		||||
USER $APP_UID
 | 
			
		||||
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
 | 
			
		||||
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
 | 
			
		||||
 
 | 
			
		||||
@@ -8,49 +8,53 @@
 | 
			
		||||
    </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="FFMpegCore" Version="5.4.0" />
 | 
			
		||||
        <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
 | 
			
		||||
        <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
 | 
			
		||||
        <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
 | 
			
		||||
        <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
 | 
			
		||||
          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
 | 
			
		||||
          <PrivateAssets>all</PrivateAssets>
 | 
			
		||||
        </PackageReference>
 | 
			
		||||
        <PackageReference Include="MimeKit" Version="4.14.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.8.118">
 | 
			
		||||
          <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="NetVips.Native.linux-x64" Version="8.17.3" />
 | 
			
		||||
        <PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.3" />
 | 
			
		||||
        <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.NetTopologySuite" Version="9.0.4"/>
 | 
			
		||||
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/>
 | 
			
		||||
        <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
 | 
			
		||||
        <PackageReference Include="OpenTelemetry.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" Version="3.119.0" />
 | 
			
		||||
        <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.0" />
 | 
			
		||||
        <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.0" />
 | 
			
		||||
        <PackageReference Include="SkiaSharp.NativeAssets.macOS" Version="3.119.0" />
 | 
			
		||||
        <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
 | 
			
		||||
        <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" />
 | 
			
		||||
        <PackageReference Include="tusdotnet" Version="2.10.0" />
 | 
			
		||||
        <PackageReference Include="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.13.1" />
 | 
			
		||||
        <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
 | 
			
		||||
        <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
 | 
			
		||||
        <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
 | 
			
		||||
        <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
 | 
			
		||||
        <PackageReference Include="Quartz" Version="3.15.1" />
 | 
			
		||||
        <PackageReference Include="Quartz.AspNetCore" Version="3.15.1" />
 | 
			
		||||
        <PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
 | 
			
		||||
        <PackageReference Include="EFCore.BulkExtensions" Version="9.0.2" />
 | 
			
		||||
        <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.2" />
 | 
			
		||||
        <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
 | 
			
		||||
        <!-- Pin the SkiaSharp version at the 2.88.9 due to the BlurHash need this specific version -->
 | 
			
		||||
        <PackageReference Include="SkiaSharp" Version="2.88.9" />
 | 
			
		||||
        <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.6" />
 | 
			
		||||
        <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
 | 
			
		||||
    <ItemGroup>
 | 
			
		||||
@@ -62,5 +66,4 @@
 | 
			
		||||
    <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
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Sphere.Migrations
 | 
			
		||||
namespace DysonNetwork.Drive.Migrations
 | 
			
		||||
{
 | 
			
		||||
    /// <inheritdoc />
 | 
			
		||||
    public partial class AddCloudFileUsage : Migration
 | 
			
		||||
    public partial class RemoveUploadedTo : Migration
 | 
			
		||||
    {
 | 
			
		||||
        /// <inheritdoc />
 | 
			
		||||
        protected override void Up(MigrationBuilder migrationBuilder)
 | 
			
		||||
        {
 | 
			
		||||
            migrationBuilder.AddColumn<string>(
 | 
			
		||||
                name: "usage",
 | 
			
		||||
                table: "files",
 | 
			
		||||
                type: "character varying(1024)",
 | 
			
		||||
                maxLength: 1024,
 | 
			
		||||
                nullable: true);
 | 
			
		||||
            migrationBuilder.DropColumn(
 | 
			
		||||
                name: "uploaded_to",
 | 
			
		||||
                table: "files");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /// <inheritdoc />
 | 
			
		||||
        protected override void Down(MigrationBuilder migrationBuilder)
 | 
			
		||||
        {
 | 
			
		||||
            migrationBuilder.DropColumn(
 | 
			
		||||
                name: "usage",
 | 
			
		||||
                table: "files");
 | 
			
		||||
            migrationBuilder.AddColumn<string>(
 | 
			
		||||
                name: "uploaded_to",
 | 
			
		||||
                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
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,34 +1,37 @@
 | 
			
		||||
using DysonNetwork.Drive;
 | 
			
		||||
using DysonNetwork.Drive.Startup;
 | 
			
		||||
using DysonNetwork.Pusher.Startup;
 | 
			
		||||
using DysonNetwork.Shared.Auth;
 | 
			
		||||
using DysonNetwork.Shared.Http;
 | 
			
		||||
using DysonNetwork.Shared.Registry;
 | 
			
		||||
using Microsoft.EntityFrameworkCore;
 | 
			
		||||
 | 
			
		||||
var builder = WebApplication.CreateBuilder(args);
 | 
			
		||||
 | 
			
		||||
builder.AddServiceDefaults();
 | 
			
		||||
 | 
			
		||||
// Configure Kestrel and server options
 | 
			
		||||
builder.ConfigureAppKestrel();
 | 
			
		||||
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
 | 
			
		||||
 | 
			
		||||
// Add application services
 | 
			
		||||
builder.Services.AddRegistryService(builder.Configuration);
 | 
			
		||||
 | 
			
		||||
builder.Services.AddAppServices(builder.Configuration);
 | 
			
		||||
builder.Services.AddAppRateLimiting();
 | 
			
		||||
builder.Services.AddAppAuthentication();
 | 
			
		||||
builder.Services.AddAppSwagger();
 | 
			
		||||
builder.Services.AddDysonAuth(builder.Configuration);
 | 
			
		||||
builder.Services.AddDysonAuth();
 | 
			
		||||
builder.Services.AddAccountService();
 | 
			
		||||
 | 
			
		||||
// Add flush handlers and websocket handlers
 | 
			
		||||
builder.Services.AddAppFlushHandlers();
 | 
			
		||||
 | 
			
		||||
// Add business services
 | 
			
		||||
builder.Services.AddAppBusinessServices();
 | 
			
		||||
 | 
			
		||||
// Add scheduled jobs
 | 
			
		||||
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())
 | 
			
		||||
{
 | 
			
		||||
@@ -36,10 +39,11 @@ using (var scope = app.Services.CreateScope())
 | 
			
		||||
    await db.Database.MigrateAsync();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Configure application middleware pipeline
 | 
			
		||||
app.ConfigureAppMiddleware(builder.Configuration);
 | 
			
		||||
app.ConfigureAppMiddleware();
 | 
			
		||||
 | 
			
		||||
// Configure gRPC
 | 
			
		||||
app.ConfigureGrpcServices();
 | 
			
		||||
 | 
			
		||||
app.UseSwaggerManifest("DysonNetwork.Drive");
 | 
			
		||||
 | 
			
		||||
app.Run();
 | 
			
		||||
@@ -5,7 +5,6 @@
 | 
			
		||||
      "commandName": "Project",
 | 
			
		||||
      "dotnetRunMessages": true,
 | 
			
		||||
      "launchBrowser": false,
 | 
			
		||||
      "applicationUrl": "http://localhost:5090",
 | 
			
		||||
      "environmentVariables": {
 | 
			
		||||
        "ASPNETCORE_ENVIRONMENT": "Development"
 | 
			
		||||
      }
 | 
			
		||||
@@ -14,7 +13,6 @@
 | 
			
		||||
      "commandName": "Project",
 | 
			
		||||
      "dotnetRunMessages": true,
 | 
			
		||||
      "launchBrowser": false,
 | 
			
		||||
      "applicationUrl": "https://localhost:7092;http://localhost:5090",
 | 
			
		||||
      "environmentVariables": {
 | 
			
		||||
        "ASPNETCORE_ENVIRONMENT": "Development"
 | 
			
		||||
      }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,11 @@
 | 
			
		||||
using DysonNetwork.Drive.Storage;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Startup;
 | 
			
		||||
 | 
			
		||||
public static class ApplicationBuilderExtensions
 | 
			
		||||
{
 | 
			
		||||
    public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
 | 
			
		||||
    public static WebApplication ConfigureAppMiddleware(this WebApplication app)
 | 
			
		||||
    {
 | 
			
		||||
        // Configure the HTTP request pipeline.
 | 
			
		||||
        if (app.Environment.IsDevelopment())
 | 
			
		||||
        {
 | 
			
		||||
            app.UseSwagger();
 | 
			
		||||
            app.UseSwaggerUI();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        app.UseHttpsRedirection();
 | 
			
		||||
        app.UseAuthorization();
 | 
			
		||||
        app.MapControllers();
 | 
			
		||||
 | 
			
		||||
@@ -21,7 +15,9 @@ public static class ApplicationBuilderExtensions
 | 
			
		||||
    public static WebApplication ConfigureGrpcServices(this WebApplication app)
 | 
			
		||||
    {
 | 
			
		||||
        // Map your gRPC services here
 | 
			
		||||
        // Example: app.MapGrpcService<MyGrpcService>();
 | 
			
		||||
        app.MapGrpcService<FileServiceGrpc>();
 | 
			
		||||
        app.MapGrpcService<FileReferenceServiceGrpc>();
 | 
			
		||||
        app.MapGrpcReflectionService();
 | 
			
		||||
 | 
			
		||||
        return app;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										297
									
								
								DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										297
									
								
								DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,297 @@
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using DysonNetwork.Drive.Storage.Model;
 | 
			
		||||
using DysonNetwork.Shared.Proto;
 | 
			
		||||
using DysonNetwork.Shared.Stream;
 | 
			
		||||
using FFMpegCore;
 | 
			
		||||
using Microsoft.EntityFrameworkCore;
 | 
			
		||||
using NATS.Client.Core;
 | 
			
		||||
using NATS.Client.JetStream;
 | 
			
		||||
using NATS.Client.JetStream.Models;
 | 
			
		||||
using NATS.Net;
 | 
			
		||||
using NetVips;
 | 
			
		||||
using NodaTime;
 | 
			
		||||
using FileService = DysonNetwork.Drive.Storage.FileService;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Startup;
 | 
			
		||||
 | 
			
		||||
public class BroadcastEventHandler(
 | 
			
		||||
    INatsConnection nats,
 | 
			
		||||
    ILogger<BroadcastEventHandler> logger,
 | 
			
		||||
    IServiceProvider serviceProvider
 | 
			
		||||
) : BackgroundService
 | 
			
		||||
{
 | 
			
		||||
    private const string TempFileSuffix = "dypart";
 | 
			
		||||
 | 
			
		||||
    private static readonly string[] AnimatedImageTypes =
 | 
			
		||||
        ["image/gif", "image/apng", "image/avif"];
 | 
			
		||||
 | 
			
		||||
    private static readonly string[] AnimatedImageExtensions =
 | 
			
		||||
        [".gif", ".apng", ".avif"];
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 | 
			
		||||
    {
 | 
			
		||||
        var js = nats.CreateJetStreamContext();
 | 
			
		||||
 | 
			
		||||
        await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]);
 | 
			
		||||
        
 | 
			
		||||
        var accountEventConsumer = await js.CreateOrUpdateConsumerAsync("account_events",
 | 
			
		||||
            new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken);
 | 
			
		||||
        
 | 
			
		||||
        await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]);
 | 
			
		||||
        var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events",
 | 
			
		||||
            new ConsumerConfig("drive_file_uploaded_handler") { MaxDeliver = 3 }, cancellationToken: stoppingToken);
 | 
			
		||||
 | 
			
		||||
        var accountDeletedTask = HandleAccountDeleted(accountEventConsumer, stoppingToken);
 | 
			
		||||
        var fileUploadedTask = HandleFileUploaded(fileUploadedConsumer, stoppingToken);
 | 
			
		||||
 | 
			
		||||
        await Task.WhenAll(accountDeletedTask, fileUploadedTask);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task HandleFileUploaded(INatsJSConsumer consumer, CancellationToken stoppingToken)
 | 
			
		||||
    {
 | 
			
		||||
        await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
 | 
			
		||||
        {
 | 
			
		||||
            var payload = JsonSerializer.Deserialize<FileUploadedEventPayload>(msg.Data, GrpcTypeHelper.SerializerOptions);
 | 
			
		||||
            if (payload == null)
 | 
			
		||||
            {
 | 
			
		||||
                await msg.AckAsync(cancellationToken: stoppingToken);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                await ProcessAndUploadInBackgroundAsync(
 | 
			
		||||
                    payload.FileId,
 | 
			
		||||
                    payload.RemoteId,
 | 
			
		||||
                    payload.StorageId,
 | 
			
		||||
                    payload.ContentType,
 | 
			
		||||
                    payload.ProcessingFilePath,
 | 
			
		||||
                    payload.IsTempFile
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                await msg.AckAsync(cancellationToken: stoppingToken);
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload.FileId);
 | 
			
		||||
                await msg.NakAsync(cancellationToken: stoppingToken, delay: TimeSpan.FromSeconds(60));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task HandleAccountDeleted(INatsJSConsumer consumer, CancellationToken stoppingToken)
 | 
			
		||||
    {
 | 
			
		||||
        await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
 | 
			
		||||
        {
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
 | 
			
		||||
                if (evt == null)
 | 
			
		||||
                {
 | 
			
		||||
                    await msg.AckAsync(cancellationToken: stoppingToken);
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                logger.LogInformation("Account deleted: {AccountId}", evt.AccountId);
 | 
			
		||||
 | 
			
		||||
                using var scope = serviceProvider.CreateScope();
 | 
			
		||||
                var fs = scope.ServiceProvider.GetRequiredService<FileService>();
 | 
			
		||||
                var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
 | 
			
		||||
 | 
			
		||||
                await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken);
 | 
			
		||||
                try
 | 
			
		||||
                {
 | 
			
		||||
                    var files = await db.Files
 | 
			
		||||
                        .Where(p => p.AccountId == evt.AccountId)
 | 
			
		||||
                        .ToListAsync(cancellationToken: stoppingToken);
 | 
			
		||||
 | 
			
		||||
                    await fs.DeleteFileDataBatchAsync(files);
 | 
			
		||||
                    await db.Files
 | 
			
		||||
                        .Where(p => p.AccountId == evt.AccountId)
 | 
			
		||||
                        .ExecuteDeleteAsync(cancellationToken: stoppingToken);
 | 
			
		||||
 | 
			
		||||
                    await transaction.CommitAsync(cancellationToken: stoppingToken);
 | 
			
		||||
                }
 | 
			
		||||
                catch (Exception)
 | 
			
		||||
                {
 | 
			
		||||
                    await transaction.RollbackAsync(cancellationToken: stoppingToken);
 | 
			
		||||
                    throw;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                await msg.AckAsync(cancellationToken: stoppingToken);
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogError(ex, "Error processing AccountDeleted");
 | 
			
		||||
                await msg.NakAsync(cancellationToken: stoppingToken);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
     private async Task ProcessAndUploadInBackgroundAsync(
 | 
			
		||||
        string fileId,
 | 
			
		||||
        Guid remoteId,
 | 
			
		||||
        string storageId,
 | 
			
		||||
        string contentType,
 | 
			
		||||
        string processingFilePath,
 | 
			
		||||
        bool isTempFile
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        using var scope = serviceProvider.CreateScope();
 | 
			
		||||
        var fs = scope.ServiceProvider.GetRequiredService<FileService>();
 | 
			
		||||
        var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>();
 | 
			
		||||
 | 
			
		||||
        var pool = await fs.GetPoolAsync(remoteId);
 | 
			
		||||
        if (pool is null) return;
 | 
			
		||||
 | 
			
		||||
        var uploads = new List<(string FilePath, string Suffix, string ContentType, bool SelfDestruct)>();
 | 
			
		||||
        var newMimeType = contentType;
 | 
			
		||||
        var hasCompression = false;
 | 
			
		||||
        var hasThumbnail = false;
 | 
			
		||||
 | 
			
		||||
        logger.LogInformation("Processing file {FileId} in background...", fileId);
 | 
			
		||||
 | 
			
		||||
        var fileToUpdate = await scopedDb.Files.AsNoTracking().FirstAsync(f => f.Id == fileId);
 | 
			
		||||
 | 
			
		||||
        if (fileToUpdate.IsEncrypted)
 | 
			
		||||
        {
 | 
			
		||||
            uploads.Add((processingFilePath, string.Empty, contentType, false));
 | 
			
		||||
        }
 | 
			
		||||
        else if (!pool.PolicyConfig.NoOptimization)
 | 
			
		||||
        {
 | 
			
		||||
            var fileExtension = Path.GetExtension(processingFilePath);
 | 
			
		||||
            switch (contentType.Split('/')[0])
 | 
			
		||||
            {
 | 
			
		||||
                case "image":
 | 
			
		||||
                    if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
 | 
			
		||||
                        uploads.Add((processingFilePath, string.Empty, contentType, false));
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    try
 | 
			
		||||
                    {
 | 
			
		||||
                        newMimeType = "image/webp";
 | 
			
		||||
                        using var vipsImage = Image.NewFromFile(processingFilePath);
 | 
			
		||||
                        var imageToWrite = vipsImage;
 | 
			
		||||
 | 
			
		||||
                        if (vipsImage.Interpretation is Enums.Interpretation.Scrgb or Enums.Interpretation.Xyz)
 | 
			
		||||
                        {
 | 
			
		||||
                            imageToWrite = vipsImage.Colourspace(Enums.Interpretation.Srgb);
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        var webpPath = Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.webp");
 | 
			
		||||
                        imageToWrite.Autorot().WriteToFile(webpPath,
 | 
			
		||||
                            new VOption { { "lossless", true }, { "strip", true } });
 | 
			
		||||
                        uploads.Add((webpPath, string.Empty, newMimeType, true));
 | 
			
		||||
 | 
			
		||||
                        if (imageToWrite.Width * imageToWrite.Height >= 1024 * 1024)
 | 
			
		||||
                        {
 | 
			
		||||
                            var scale = 1024.0 / Math.Max(imageToWrite.Width, imageToWrite.Height);
 | 
			
		||||
                            var compressedPath =
 | 
			
		||||
                                Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.compressed.webp");
 | 
			
		||||
                            using var compressedImage = imageToWrite.Resize(scale);
 | 
			
		||||
                            compressedImage.Autorot().WriteToFile(compressedPath,
 | 
			
		||||
                                new VOption { { "Q", 80 }, { "strip", true } });
 | 
			
		||||
                            uploads.Add((compressedPath, ".compressed", newMimeType, true));
 | 
			
		||||
                            hasCompression = true;
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        if (!ReferenceEquals(imageToWrite, vipsImage))
 | 
			
		||||
                        {
 | 
			
		||||
                            imageToWrite.Dispose();
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    catch (Exception ex)
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogError(ex, "Failed to optimize image {FileId}, uploading original", fileId);
 | 
			
		||||
                        uploads.Add((processingFilePath, string.Empty, contentType, false));
 | 
			
		||||
                        newMimeType = contentType;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    break;
 | 
			
		||||
 | 
			
		||||
                case "video":
 | 
			
		||||
                    uploads.Add((processingFilePath, string.Empty, contentType, false));
 | 
			
		||||
 | 
			
		||||
                    var thumbnailPath = Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.thumbnail.jpg");
 | 
			
		||||
                    try
 | 
			
		||||
                    {
 | 
			
		||||
                        await FFMpegArguments
 | 
			
		||||
                            .FromFileInput(processingFilePath, verifyExists: true)
 | 
			
		||||
                            .OutputToFile(thumbnailPath, overwrite: true, options => options
 | 
			
		||||
                                .Seek(TimeSpan.FromSeconds(0))
 | 
			
		||||
                                .WithFrameOutputCount(1)
 | 
			
		||||
                                .WithCustomArgument("-q:v 2")
 | 
			
		||||
                            )
 | 
			
		||||
                            .NotifyOnOutput(line => logger.LogInformation("[FFmpeg] {Line}", line))
 | 
			
		||||
                            .NotifyOnError(line => logger.LogWarning("[FFmpeg] {Line}", line))
 | 
			
		||||
                            .ProcessAsynchronously();
 | 
			
		||||
 | 
			
		||||
                        if (File.Exists(thumbnailPath))
 | 
			
		||||
                        {
 | 
			
		||||
                            uploads.Add((thumbnailPath, ".thumbnail", "image/jpeg", true));
 | 
			
		||||
                            hasThumbnail = true;
 | 
			
		||||
                        }
 | 
			
		||||
                        else
 | 
			
		||||
                        {
 | 
			
		||||
                            logger.LogWarning("FFMpeg did not produce thumbnail for video {FileId}", fileId);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    catch (Exception ex)
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogError(ex, "Failed to generate thumbnail for video {FileId}", fileId);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    break;
 | 
			
		||||
 | 
			
		||||
                default:
 | 
			
		||||
                    uploads.Add((processingFilePath, string.Empty, contentType, false));
 | 
			
		||||
                    break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            uploads.Add((processingFilePath, string.Empty, contentType, false));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        logger.LogInformation("Optimized file {FileId}, now uploading...", fileId);
 | 
			
		||||
 | 
			
		||||
        if (uploads.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            var destPool = remoteId;
 | 
			
		||||
            var uploadTasks = uploads.Select(item =>
 | 
			
		||||
                fs.UploadFileToRemoteAsync(
 | 
			
		||||
                    storageId,
 | 
			
		||||
                    destPool,
 | 
			
		||||
                    item.FilePath,
 | 
			
		||||
                    item.Suffix,
 | 
			
		||||
                    item.ContentType,
 | 
			
		||||
                    item.SelfDestruct
 | 
			
		||||
                )
 | 
			
		||||
            ).ToList();
 | 
			
		||||
 | 
			
		||||
            await Task.WhenAll(uploadTasks);
 | 
			
		||||
 | 
			
		||||
            logger.LogInformation("Uploaded file {FileId} done!", fileId);
 | 
			
		||||
 | 
			
		||||
            var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
            await scopedDb.Files.Where(f => f.Id == fileId).ExecuteUpdateAsync(setter => setter
 | 
			
		||||
                .SetProperty(f => f.UploadedAt, now)
 | 
			
		||||
                .SetProperty(f => f.PoolId, destPool)
 | 
			
		||||
                .SetProperty(f => f.MimeType, newMimeType)
 | 
			
		||||
                .SetProperty(f => f.HasCompression, hasCompression)
 | 
			
		||||
                .SetProperty(f => f.HasThumbnail, hasThumbnail)
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            // Only delete temp file after successful upload and db update
 | 
			
		||||
            if (isTempFile)
 | 
			
		||||
                File.Delete(processingFilePath);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await fs._PurgeCacheAsync(fileId);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,17 +0,0 @@
 | 
			
		||||
namespace DysonNetwork.Pusher.Startup;
 | 
			
		||||
 | 
			
		||||
public static class KestrelConfiguration
 | 
			
		||||
{
 | 
			
		||||
    public static WebApplicationBuilder ConfigureAppKestrel(this WebApplicationBuilder builder)
 | 
			
		||||
    {
 | 
			
		||||
        builder.Host.UseContentRoot(Directory.GetCurrentDirectory());
 | 
			
		||||
        builder.WebHost.ConfigureKestrel(options =>
 | 
			
		||||
        {
 | 
			
		||||
            options.Limits.MaxRequestBodySize = 50 * 1024 * 1024;
 | 
			
		||||
            options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
 | 
			
		||||
            options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return builder;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
using DysonNetwork.Drive.Storage;
 | 
			
		||||
using Quartz;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Startup;
 | 
			
		||||
@@ -14,6 +15,13 @@ public static class ScheduledJobsConfiguration
 | 
			
		||||
                .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);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,8 @@
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Threading.RateLimiting;
 | 
			
		||||
using dotnet_etcd.interfaces;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
using DysonNetwork.Shared.Cache;
 | 
			
		||||
using Microsoft.AspNetCore.RateLimiting;
 | 
			
		||||
using Microsoft.OpenApi.Models;
 | 
			
		||||
using NodaTime;
 | 
			
		||||
using NodaTime.Serialization.SystemTextJson;
 | 
			
		||||
using StackExchange.Redis;
 | 
			
		||||
using DysonNetwork.Shared.Proto;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Startup;
 | 
			
		||||
 | 
			
		||||
@@ -16,11 +11,6 @@ public static class ServiceCollectionExtensions
 | 
			
		||||
    public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
 | 
			
		||||
    {
 | 
			
		||||
        services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
 | 
			
		||||
        services.AddSingleton<IConnectionMultiplexer>(_ =>
 | 
			
		||||
        {
 | 
			
		||||
            var connection = configuration.GetConnectionString("FastRetrieve")!;
 | 
			
		||||
            return ConnectionMultiplexer.Connect(connection);
 | 
			
		||||
        });
 | 
			
		||||
        services.AddSingleton<IClock>(SystemClock.Instance);
 | 
			
		||||
        services.AddHttpContextAccessor();
 | 
			
		||||
        services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
 | 
			
		||||
@@ -34,12 +24,11 @@ public static class ServiceCollectionExtensions
 | 
			
		||||
            options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
 | 
			
		||||
            options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        // Register gRPC reflection for service discovery
 | 
			
		||||
        services.AddGrpc();
 | 
			
		||||
        services.AddGrpcReflection();
 | 
			
		||||
 | 
			
		||||
        services.AddControllers().AddJsonOptions(options =>
 | 
			
		||||
        {
 | 
			
		||||
            options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
 | 
			
		||||
            options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
 | 
			
		||||
            options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
 | 
			
		||||
 | 
			
		||||
@@ -49,24 +38,9 @@ public static class ServiceCollectionExtensions
 | 
			
		||||
        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.AddCors();
 | 
			
		||||
        services.AddAuthorization();
 | 
			
		||||
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -77,54 +51,15 @@ public static class ServiceCollectionExtensions
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static IServiceCollection AddAppSwagger(this IServiceCollection services)
 | 
			
		||||
    {
 | 
			
		||||
        services.AddEndpointsApiExplorer();
 | 
			
		||||
        services.AddSwaggerGen(options =>
 | 
			
		||||
        {
 | 
			
		||||
            options.SwaggerDoc("v1", new OpenApiInfo
 | 
			
		||||
            {
 | 
			
		||||
                Version = "v1",
 | 
			
		||||
                Title = "DysonNetwork.Drive API",
 | 
			
		||||
                Description = "DysonNetwork Drive Service",
 | 
			
		||||
                TermsOfService = new Uri("https://example.com/terms"), // Update with actual terms
 | 
			
		||||
                License = new OpenApiLicense
 | 
			
		||||
                {
 | 
			
		||||
                    Name = "APGLv3", // Update with actual license
 | 
			
		||||
                    Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html")
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
            options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
 | 
			
		||||
            {
 | 
			
		||||
                In = ParameterLocation.Header,
 | 
			
		||||
                Description = "Please enter a valid token",
 | 
			
		||||
                Name = "Authorization",
 | 
			
		||||
                Type = SecuritySchemeType.Http,
 | 
			
		||||
                BearerFormat = "JWT",
 | 
			
		||||
                Scheme = "Bearer"
 | 
			
		||||
            });
 | 
			
		||||
            options.AddSecurityRequirement(new OpenApiSecurityRequirement
 | 
			
		||||
            {
 | 
			
		||||
                {
 | 
			
		||||
                    new OpenApiSecurityScheme
 | 
			
		||||
                    {
 | 
			
		||||
                        Reference = new OpenApiReference
 | 
			
		||||
                        {
 | 
			
		||||
                            Type = ReferenceType.SecurityScheme,
 | 
			
		||||
                            Id = "Bearer"
 | 
			
		||||
                        }
 | 
			
		||||
                    },
 | 
			
		||||
                    []
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
 | 
			
		||||
    {
 | 
			
		||||
        // Add your business services here
 | 
			
		||||
        services.AddScoped<Storage.FileService>();
 | 
			
		||||
        services.AddScoped<Storage.FileReferenceService>();
 | 
			
		||||
        services.AddScoped<Billing.UsageService>();
 | 
			
		||||
        services.AddScoped<Billing.QuotaService>();
 | 
			
		||||
 | 
			
		||||
        services.AddHostedService<BroadcastEventHandler>();
 | 
			
		||||
        
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										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();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,131 +0,0 @@
 | 
			
		||||
using System.ComponentModel.DataAnnotations;
 | 
			
		||||
using System.ComponentModel.DataAnnotations.Schema;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
using DysonNetwork.Shared.Data;
 | 
			
		||||
using DysonNetwork.Shared.Proto;
 | 
			
		||||
using NodaTime;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Storage;
 | 
			
		||||
 | 
			
		||||
public class RemoteStorageConfig
 | 
			
		||||
{
 | 
			
		||||
    public string Id { get; set; } = string.Empty;
 | 
			
		||||
    public string Label { get; set; } = string.Empty;
 | 
			
		||||
    public string Region { get; set; } = string.Empty;
 | 
			
		||||
    public string Bucket { get; set; } = string.Empty;
 | 
			
		||||
    public string Endpoint { get; set; } = string.Empty;
 | 
			
		||||
    public string SecretId { get; set; } = string.Empty;
 | 
			
		||||
    public string SecretKey { get; set; } = string.Empty;
 | 
			
		||||
    public bool EnableSigned { get; set; }
 | 
			
		||||
    public bool EnableSsl { get; set; }
 | 
			
		||||
    public string? ImageProxy { get; set; }
 | 
			
		||||
    public string? AccessProxy { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// The class that used in jsonb columns which referenced the cloud file.
 | 
			
		||||
/// The aim of this class is to store some properties that won't change to a file to reduce the database load.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public class CloudFileReferenceObject : ModelBase, ICloudFile
 | 
			
		||||
{
 | 
			
		||||
    public string Id { get; set; } = null!;
 | 
			
		||||
    public string Name { get; set; } = string.Empty;
 | 
			
		||||
    public Dictionary<string, object>? FileMeta { get; set; } = null!;
 | 
			
		||||
    public Dictionary<string, object>? UserMeta { get; set; } = null!;
 | 
			
		||||
    public string? MimeType { get; set; }
 | 
			
		||||
    public string? Hash { get; set; }
 | 
			
		||||
    public long Size { get; set; }
 | 
			
		||||
    public bool HasCompression { get; set; } = false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
 | 
			
		||||
{
 | 
			
		||||
    /// The id generated by TuS, basically just UUID remove the dash lines
 | 
			
		||||
    [MaxLength(32)]
 | 
			
		||||
    public string Id { get; set; } = Guid.NewGuid().ToString();
 | 
			
		||||
 | 
			
		||||
    [MaxLength(1024)] public string Name { get; set; } = string.Empty;
 | 
			
		||||
    [MaxLength(4096)] public string? Description { get; set; }
 | 
			
		||||
    [Column(TypeName = "jsonb")] public Dictionary<string, object>? FileMeta { get; set; } = null!;
 | 
			
		||||
    [Column(TypeName = "jsonb")] public Dictionary<string, object>? UserMeta { get; set; } = null!;
 | 
			
		||||
    [Column(TypeName = "jsonb")] public List<ContentSensitiveMark>? SensitiveMarks { get; set; } = [];
 | 
			
		||||
    [MaxLength(256)] public string? MimeType { get; set; }
 | 
			
		||||
    [MaxLength(256)] public string? Hash { get; set; }
 | 
			
		||||
    public long Size { get; set; }
 | 
			
		||||
    public Instant? UploadedAt { get; set; }
 | 
			
		||||
    [MaxLength(128)] public string? UploadedTo { get; set; }
 | 
			
		||||
    public bool HasCompression { get; set; } = false;
 | 
			
		||||
    
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// The field is set to true if the recycling job plans to delete the file.
 | 
			
		||||
    /// Due to the unstable of the recycling job, this doesn't really delete the file until a human verifies it.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public bool IsMarkedRecycle { get; set; } = false;
 | 
			
		||||
 | 
			
		||||
    /// The object name which stored remotely,
 | 
			
		||||
    /// multiple cloud file may have same storage id to indicate they are the same file
 | 
			
		||||
    /// 
 | 
			
		||||
    /// If the storage id was null and the uploaded at is not null, means it is an embedding file,
 | 
			
		||||
    /// The embedding file means the file is store on another site,
 | 
			
		||||
    /// or it is a webpage (based on mimetype)
 | 
			
		||||
    [MaxLength(32)]
 | 
			
		||||
    public string? StorageId { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// This field should be null when the storage id is filled
 | 
			
		||||
    /// Indicates the off-site accessible url of the file
 | 
			
		||||
    [MaxLength(4096)]
 | 
			
		||||
    public string? StorageUrl { get; set; }
 | 
			
		||||
 | 
			
		||||
    public Guid AccountId { get; set; }
 | 
			
		||||
 | 
			
		||||
    public CloudFileReferenceObject ToReferenceObject()
 | 
			
		||||
    {
 | 
			
		||||
        return new CloudFileReferenceObject
 | 
			
		||||
        {
 | 
			
		||||
            CreatedAt = CreatedAt,
 | 
			
		||||
            UpdatedAt = UpdatedAt,
 | 
			
		||||
            DeletedAt = DeletedAt,
 | 
			
		||||
            Id = Id,
 | 
			
		||||
            Name = Name,
 | 
			
		||||
            FileMeta = FileMeta,
 | 
			
		||||
            UserMeta = UserMeta,
 | 
			
		||||
            MimeType = MimeType,
 | 
			
		||||
            Hash = Hash,
 | 
			
		||||
            Size = Size,
 | 
			
		||||
            HasCompression = HasCompression
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public string ResourceIdentifier => $"file/{Id}";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public enum ContentSensitiveMark
 | 
			
		||||
{
 | 
			
		||||
    Language,
 | 
			
		||||
    SexualContent,
 | 
			
		||||
    Violence,
 | 
			
		||||
    Profanity,
 | 
			
		||||
    HateSpeech,
 | 
			
		||||
    Racism,
 | 
			
		||||
    AdultContent,
 | 
			
		||||
    DrugAbuse,
 | 
			
		||||
    AlcoholAbuse,
 | 
			
		||||
    Gambling,
 | 
			
		||||
    SelfHarm,
 | 
			
		||||
    ChildAbuse,
 | 
			
		||||
    Other
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public class CloudFileReference : ModelBase
 | 
			
		||||
{
 | 
			
		||||
    public Guid Id { get; set; } = Guid.NewGuid();
 | 
			
		||||
    [MaxLength(32)] public string FileId { get; set; } = null!;
 | 
			
		||||
    public CloudFile File { get; set; } = null!;
 | 
			
		||||
    [MaxLength(1024)] public string Usage { get; set; } = null!;
 | 
			
		||||
    [MaxLength(1024)] public string ResourceId { get; set; } = null!;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Optional expiration date for the file reference
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public Instant? ExpiredAt { get; set; }
 | 
			
		||||
}
 | 
			
		||||
@@ -7,19 +7,42 @@ namespace DysonNetwork.Drive.Storage;
 | 
			
		||||
public class CloudFileUnusedRecyclingJob(
 | 
			
		||||
    AppDatabase db,
 | 
			
		||||
    FileReferenceService fileRefService,
 | 
			
		||||
    ILogger<CloudFileUnusedRecyclingJob> logger
 | 
			
		||||
    ILogger<CloudFileUnusedRecyclingJob> logger,
 | 
			
		||||
    IConfiguration configuration
 | 
			
		||||
)
 | 
			
		||||
    : IJob
 | 
			
		||||
{
 | 
			
		||||
    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...");
 | 
			
		||||
 | 
			
		||||
        var recyclablePools = await db.Pools
 | 
			
		||||
            .Where(p => p.PolicyConfig.EnableRecycle)
 | 
			
		||||
            .Select(p => p.Id)
 | 
			
		||||
            .ToListAsync();
 | 
			
		||||
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        const int batchSize = 1000; // Process larger batches for efficiency
 | 
			
		||||
        var processedCount = 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);
 | 
			
		||||
 | 
			
		||||
@@ -35,13 +58,12 @@ public class CloudFileUnusedRecyclingJob(
 | 
			
		||||
        {
 | 
			
		||||
            // Query for the next batch of files using keyset pagination
 | 
			
		||||
            var filesQuery = db.Files
 | 
			
		||||
                .Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value))
 | 
			
		||||
                .Where(f => !f.IsMarkedRecycle)
 | 
			
		||||
                .Where(f => f.CreatedAt <= ageThreshold); // Only process older files first
 | 
			
		||||
 | 
			
		||||
            if (lastProcessedId != null)
 | 
			
		||||
            {
 | 
			
		||||
                filesQuery = filesQuery.Where(f => string.Compare(f.Id, lastProcessedId) > 0);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var fileBatch = await filesQuery
 | 
			
		||||
                .OrderBy(f => f.Id) // Ensure consistent ordering for pagination
 | 
			
		||||
@@ -84,10 +106,18 @@ public class CloudFileUnusedRecyclingJob(
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation(
 | 
			
		||||
                    "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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,3 +1,6 @@
 | 
			
		||||
using DysonNetwork.Drive.Billing;
 | 
			
		||||
using DysonNetwork.Shared.Auth;
 | 
			
		||||
using DysonNetwork.Shared.Models;
 | 
			
		||||
using DysonNetwork.Shared.Proto;
 | 
			
		||||
using Microsoft.AspNetCore.Authorization;
 | 
			
		||||
using Microsoft.AspNetCore.Mvc;
 | 
			
		||||
@@ -11,6 +14,7 @@ namespace DysonNetwork.Drive.Storage;
 | 
			
		||||
public class FileController(
 | 
			
		||||
    AppDatabase db,
 | 
			
		||||
    FileService fs,
 | 
			
		||||
    QuotaService qs,
 | 
			
		||||
    IConfiguration configuration,
 | 
			
		||||
    IWebHostEnvironment env
 | 
			
		||||
) : ControllerBase
 | 
			
		||||
@@ -20,7 +24,9 @@ public class FileController(
 | 
			
		||||
        string id,
 | 
			
		||||
        [FromQuery] bool download = false,
 | 
			
		||||
        [FromQuery] bool original = false,
 | 
			
		||||
        [FromQuery] string? overrideMimeType = null
 | 
			
		||||
        [FromQuery] bool thumbnail = false,
 | 
			
		||||
        [FromQuery] string? overrideMimeType = null,
 | 
			
		||||
        [FromQuery] string? passcode = null
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        // Support the file extension for client side data recognize
 | 
			
		||||
@@ -33,21 +39,64 @@ public class FileController(
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var file = await fs.GetFileAsync(id);
 | 
			
		||||
        if (file is null) return NotFound();
 | 
			
		||||
        if (file is null) return NotFound("File not found.");
 | 
			
		||||
 | 
			
		||||
        if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode))
 | 
			
		||||
            return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect.");
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
 | 
			
		||||
 | 
			
		||||
        if (file.UploadedTo is null)
 | 
			
		||||
        if (file.UploadedAt is null)
 | 
			
		||||
        {
 | 
			
		||||
            var tusStorePath = configuration.GetValue<string>("Tus:StorePath")!;
 | 
			
		||||
            var filePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id);
 | 
			
		||||
            if (!System.IO.File.Exists(filePath)) return new NotFoundResult();
 | 
			
		||||
            return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name);
 | 
			
		||||
            // 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);
 | 
			
		||||
            }
 | 
			
		||||
        
 | 
			
		||||
        var dest = fs.GetRemoteStorageConfig(file.UploadedTo);
 | 
			
		||||
            // 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";
 | 
			
		||||
 | 
			
		||||
@@ -72,7 +121,8 @@ public class FileController(
 | 
			
		||||
            var client = fs.CreateMinioClient(dest);
 | 
			
		||||
            if (client is null)
 | 
			
		||||
                return BadRequest(
 | 
			
		||||
                    "Failed to configure client for remote destination, file got an invalid storage remote.");
 | 
			
		||||
                    "Failed to configure client for remote destination, file got an invalid storage remote."
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
            var headers = new Dictionary<string, string>();
 | 
			
		||||
            if (fileExtension is not null)
 | 
			
		||||
@@ -113,14 +163,93 @@ public class FileController(
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [HttpGet("{id}/info")]
 | 
			
		||||
    public async Task<ActionResult<CloudFile>> GetFileInfo(string id)
 | 
			
		||||
    public async Task<ActionResult<SnCloudFile>> GetFileInfo(string id)
 | 
			
		||||
    {
 | 
			
		||||
        var file = await db.Files.FindAsync(id);
 | 
			
		||||
        if (file is null) return NotFound();
 | 
			
		||||
        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<Shared.Models.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)
 | 
			
		||||
@@ -134,11 +263,135 @@ public class FileController(
 | 
			
		||||
            .FirstOrDefaultAsync();
 | 
			
		||||
        if (file is null) return NotFound();
 | 
			
		||||
 | 
			
		||||
        await fs.DeleteFileDataAsync(file, force: true);
 | 
			
		||||
        await fs.DeleteFileAsync(file);
 | 
			
		||||
 | 
			
		||||
        db.Files.Remove(file);
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
 | 
			
		||||
        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<Shared.Models.ContentSensitiveMark>? SensitiveMarks { get; set; }
 | 
			
		||||
        public Guid PoolId { get; set; }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Authorize]
 | 
			
		||||
    [HttpPost("fast")]
 | 
			
		||||
    [RequiredPermission("global", "files.create")]
 | 
			
		||||
    public async Task<ActionResult<SnCloudFile>> CreateFastFile([FromBody] CreateFastFileRequest request)
 | 
			
		||||
    {
 | 
			
		||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
			
		||||
        var accountId = Guid.Parse(currentUser.Id);
 | 
			
		||||
 | 
			
		||||
        var pool = await db.Pools.FirstOrDefaultAsync(p => p.Id == request.PoolId);
 | 
			
		||||
        if (pool is null) return BadRequest();
 | 
			
		||||
        if (!currentUser.IsSuperuser && pool.AccountId != accountId)
 | 
			
		||||
            return StatusCode(403, "You don't have permission to create files in this pool.");
 | 
			
		||||
 | 
			
		||||
        if (!pool.PolicyConfig.EnableFastUpload)
 | 
			
		||||
            return StatusCode(
 | 
			
		||||
                403,
 | 
			
		||||
                "This pool does not allow fast upload"
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
        if (pool.PolicyConfig.RequirePrivilege > 0)
 | 
			
		||||
        {
 | 
			
		||||
            if (currentUser.PerkSubscription is null)
 | 
			
		||||
            {
 | 
			
		||||
                return StatusCode(
 | 
			
		||||
                    403,
 | 
			
		||||
                    $"You need to have join the Stellar Program to use this pool"
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var privilege =
 | 
			
		||||
                PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
 | 
			
		||||
            if (privilege < pool.PolicyConfig.RequirePrivilege)
 | 
			
		||||
            {
 | 
			
		||||
                return StatusCode(
 | 
			
		||||
                    403,
 | 
			
		||||
                    $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}"
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (request.Size > pool.PolicyConfig.MaxFileSize)
 | 
			
		||||
        {
 | 
			
		||||
            return StatusCode(
 | 
			
		||||
                403,
 | 
			
		||||
                $"File size {request.Size} is larger than the pool's maximum file size {pool.PolicyConfig.MaxFileSize}"
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var (ok, billableUnit, quota) = await qs.IsFileAcceptable(
 | 
			
		||||
            accountId,
 | 
			
		||||
            pool.BillingConfig.CostMultiplier ?? 1.0,
 | 
			
		||||
            request.Size
 | 
			
		||||
        );
 | 
			
		||||
        if (!ok)
 | 
			
		||||
        {
 | 
			
		||||
            return StatusCode(
 | 
			
		||||
                403,
 | 
			
		||||
                $"File size {billableUnit} is larger than the user's quota {quota}"
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await using var transaction = await db.Database.BeginTransactionAsync();
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var file = new SnCloudFile
 | 
			
		||||
            {
 | 
			
		||||
                Name = request.Name,
 | 
			
		||||
                Size = request.Size,
 | 
			
		||||
                Hash = request.Hash,
 | 
			
		||||
                MimeType = request.MimeType,
 | 
			
		||||
                Description = request.Description,
 | 
			
		||||
                AccountId = accountId,
 | 
			
		||||
                UserMeta = request.UserMeta,
 | 
			
		||||
                FileMeta = request.FileMeta,
 | 
			
		||||
                SensitiveMarks = request.SensitiveMarks,
 | 
			
		||||
                PoolId = request.PoolId
 | 
			
		||||
            };
 | 
			
		||||
            db.Files.Add(file);
 | 
			
		||||
            await db.SaveChangesAsync();
 | 
			
		||||
            await fs._PurgeCacheAsync(file.Id);
 | 
			
		||||
            await transaction.CommitAsync();
 | 
			
		||||
 | 
			
		||||
            file.FastUploadLink = await fs.CreateFastUploadLinkAsync(file);
 | 
			
		||||
 | 
			
		||||
            return file;
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception)
 | 
			
		||||
        {
 | 
			
		||||
            await transaction.RollbackAsync();
 | 
			
		||||
            throw;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										60
									
								
								DysonNetwork.Drive/Storage/FileEncryptor.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								DysonNetwork.Drive/Storage/FileEncryptor.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Storage;
 | 
			
		||||
 | 
			
		||||
public static class FileEncryptor
 | 
			
		||||
{
 | 
			
		||||
    public static void EncryptFile(string inputPath, string outputPath, string password)
 | 
			
		||||
    {
 | 
			
		||||
        var salt = RandomNumberGenerator.GetBytes(16);
 | 
			
		||||
        var key = DeriveKey(password, salt, 32);
 | 
			
		||||
        var nonce = RandomNumberGenerator.GetBytes(12); // For AES-GCM
 | 
			
		||||
 | 
			
		||||
        using var aes = new AesGcm(key, 16); // Specify 16-byte tag size explicitly
 | 
			
		||||
        var plaintext = File.ReadAllBytes(inputPath);
 | 
			
		||||
        var magic = "DYSON1"u8.ToArray();
 | 
			
		||||
        var contentWithMagic = new byte[magic.Length + plaintext.Length];
 | 
			
		||||
        Buffer.BlockCopy(magic, 0, contentWithMagic, 0, magic.Length);
 | 
			
		||||
        Buffer.BlockCopy(plaintext, 0, contentWithMagic, magic.Length, plaintext.Length);
 | 
			
		||||
 | 
			
		||||
        var ciphertext = new byte[contentWithMagic.Length];
 | 
			
		||||
        var tag = new byte[16];
 | 
			
		||||
        aes.Encrypt(nonce, contentWithMagic, ciphertext, tag);
 | 
			
		||||
 | 
			
		||||
        // Save as: [salt (16)][nonce (12)][tag (16)][ciphertext]
 | 
			
		||||
        using var fs = new FileStream(outputPath, FileMode.Create, FileAccess.Write);
 | 
			
		||||
        fs.Write(salt);
 | 
			
		||||
        fs.Write(nonce);
 | 
			
		||||
        fs.Write(tag);
 | 
			
		||||
        fs.Write(ciphertext);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void DecryptFile(string inputPath, string outputPath, string password)
 | 
			
		||||
    {
 | 
			
		||||
        var input = File.ReadAllBytes(inputPath);
 | 
			
		||||
 | 
			
		||||
        var salt = input[..16];
 | 
			
		||||
        var nonce = input[16..28];
 | 
			
		||||
        var tag = input[28..44];
 | 
			
		||||
        var ciphertext = input[44..];
 | 
			
		||||
 | 
			
		||||
        var key = DeriveKey(password, salt, 32);
 | 
			
		||||
        var decrypted = new byte[ciphertext.Length];
 | 
			
		||||
 | 
			
		||||
        using var aes = new AesGcm(key, 16); // Specify 16-byte tag size explicitly
 | 
			
		||||
        aes.Decrypt(nonce, ciphertext, tag, decrypted);
 | 
			
		||||
 | 
			
		||||
        var magic = "DYSON1"u8.ToArray();
 | 
			
		||||
        if (magic.Where((t, i) => decrypted[i] != t).Any())
 | 
			
		||||
            throw new CryptographicException("Incorrect password or corrupted file.");
 | 
			
		||||
 | 
			
		||||
        var plaintext = decrypted[magic.Length..];
 | 
			
		||||
        File.WriteAllBytes(outputPath, plaintext);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static byte[] DeriveKey(string password, byte[] salt, int keyBytes)
 | 
			
		||||
    {
 | 
			
		||||
        using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, 100_000, HashAlgorithmName.SHA256);
 | 
			
		||||
        return pbkdf2.GetBytes(keyBytes);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -48,12 +48,10 @@ public class FileExpirationJob(AppDatabase db, FileService fileService, ILogger<
 | 
			
		||||
            if (remainingReferences == 0)
 | 
			
		||||
            {
 | 
			
		||||
                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);
 | 
			
		||||
                await fileService.DeleteFileAsync(file);
 | 
			
		||||
            }
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                // 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,4 +1,6 @@
 | 
			
		||||
using DysonNetwork.Shared.Cache;
 | 
			
		||||
using DysonNetwork.Shared.Models;
 | 
			
		||||
using EFCore.BulkExtensions;
 | 
			
		||||
using Microsoft.EntityFrameworkCore;
 | 
			
		||||
using NodaTime;
 | 
			
		||||
 | 
			
		||||
@@ -6,7 +8,7 @@ namespace DysonNetwork.Drive.Storage;
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
@@ -23,7 +25,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
 | 
			
		||||
        string usage,
 | 
			
		||||
        string resourceId,
 | 
			
		||||
        Instant? expiredAt = null,
 | 
			
		||||
        Duration? duration = null)
 | 
			
		||||
        Duration? duration = null
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        // Calculate expiration time if needed
 | 
			
		||||
        var finalExpiration = expiredAt;
 | 
			
		||||
@@ -46,6 +49,25 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
 | 
			
		||||
        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>
 | 
			
		||||
    /// Gets all references to a file
 | 
			
		||||
    /// </summary>
 | 
			
		||||
@@ -169,10 +191,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
 | 
			
		||||
            .Where(r => r.ResourceId == resourceId && r.Usage == usage)
 | 
			
		||||
            .ToListAsync();
 | 
			
		||||
 | 
			
		||||
        if (!references.Any())
 | 
			
		||||
        {
 | 
			
		||||
        if (references.Count == 0)
 | 
			
		||||
            return 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var fileIds = references.Select(r => r.FileId).Distinct().ToList();
 | 
			
		||||
 | 
			
		||||
@@ -187,6 +207,28 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
 | 
			
		||||
        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>
 | 
			
		||||
    /// Deletes a specific file reference
 | 
			
		||||
    /// </summary>
 | 
			
		||||
@@ -306,7 +348,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
 | 
			
		||||
    /// <param name="resourceId">The ID of the resource</param>
 | 
			
		||||
    /// <param name="usage">Optional filter by usage context</param>
 | 
			
		||||
    /// <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);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										174
									
								
								DysonNetwork.Drive/Storage/FileReferenceServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								DysonNetwork.Drive/Storage/FileReferenceServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,174 @@
 | 
			
		||||
using DysonNetwork.Shared.Proto;
 | 
			
		||||
using Grpc.Core;
 | 
			
		||||
using NodaTime;
 | 
			
		||||
using Duration = NodaTime.Duration;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Storage;
 | 
			
		||||
 | 
			
		||||
public class FileReferenceServiceGrpc(FileReferenceService fileReferenceService)
 | 
			
		||||
    : Shared.Proto.FileReferenceService.FileReferenceServiceBase
 | 
			
		||||
{
 | 
			
		||||
    public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request,
 | 
			
		||||
        ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        Instant? expiredAt = null;
 | 
			
		||||
        if (request.ExpiredAt != null)
 | 
			
		||||
            expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
 | 
			
		||||
        else if (request.Duration != null)
 | 
			
		||||
            expiredAt = SystemClock.Instance.GetCurrentInstant() +
 | 
			
		||||
                        Duration.FromTimeSpan(request.Duration.ToTimeSpan());
 | 
			
		||||
 | 
			
		||||
        var reference = await fileReferenceService.CreateReferenceAsync(
 | 
			
		||||
            request.FileId,
 | 
			
		||||
            request.Usage,
 | 
			
		||||
            request.ResourceId,
 | 
			
		||||
            expiredAt
 | 
			
		||||
        );
 | 
			
		||||
        return reference.ToProtoValue();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<CreateReferenceBatchResponse> CreateReferenceBatch(CreateReferenceBatchRequest request,
 | 
			
		||||
        ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        Instant? expiredAt = null;
 | 
			
		||||
        if (request.ExpiredAt != null)
 | 
			
		||||
            expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
 | 
			
		||||
        else if (request.Duration != null)
 | 
			
		||||
            expiredAt = SystemClock.Instance.GetCurrentInstant() +
 | 
			
		||||
                        Duration.FromTimeSpan(request.Duration.ToTimeSpan());
 | 
			
		||||
 | 
			
		||||
        var references = await fileReferenceService.CreateReferencesAsync(
 | 
			
		||||
            request.FilesId.ToList(),
 | 
			
		||||
            request.Usage,
 | 
			
		||||
            request.ResourceId,
 | 
			
		||||
            expiredAt
 | 
			
		||||
        );
 | 
			
		||||
        var response = new CreateReferenceBatchResponse();
 | 
			
		||||
        response.References.AddRange(references.Select(r => r.ToProtoValue()));
 | 
			
		||||
        return response;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request,
 | 
			
		||||
        ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        var references = await fileReferenceService.GetReferencesAsync(request.FileId);
 | 
			
		||||
        var response = new GetReferencesResponse();
 | 
			
		||||
        response.References.AddRange(references.Select(r => r.ToProtoValue()));
 | 
			
		||||
        return response;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<GetReferenceCountResponse> GetReferenceCount(GetReferenceCountRequest request,
 | 
			
		||||
        ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        var count = await fileReferenceService.GetReferenceCountAsync(request.FileId);
 | 
			
		||||
        return new GetReferenceCountResponse { Count = count };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<GetReferencesResponse> GetResourceReferences(GetResourceReferencesRequest request,
 | 
			
		||||
        ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        var references = await fileReferenceService.GetResourceReferencesAsync(request.ResourceId, request.Usage);
 | 
			
		||||
        var response = new GetReferencesResponse();
 | 
			
		||||
        response.References.AddRange(references.Select(r => r.ToProtoValue()));
 | 
			
		||||
        return response;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<GetResourceFilesResponse> GetResourceFiles(GetResourceFilesRequest request,
 | 
			
		||||
        ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        var files = await fileReferenceService.GetResourceFilesAsync(request.ResourceId, request.Usage);
 | 
			
		||||
        var response = new GetResourceFilesResponse();
 | 
			
		||||
        response.Files.AddRange(files.Select(f => f.ToProtoValue()));
 | 
			
		||||
        return response;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
 | 
			
		||||
        DeleteResourceReferencesRequest request, ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        int deletedCount;
 | 
			
		||||
        if (request.Usage is null)
 | 
			
		||||
            deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
 | 
			
		||||
        else
 | 
			
		||||
            deletedCount =
 | 
			
		||||
                await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
 | 
			
		||||
        return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
 | 
			
		||||
    }
 | 
			
		||||
        
 | 
			
		||||
    public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        var resourceIds = request.ResourceIds.ToList();
 | 
			
		||||
        int deletedCount;
 | 
			
		||||
        if (request.Usage is null)
 | 
			
		||||
            deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds);
 | 
			
		||||
        else
 | 
			
		||||
            deletedCount =
 | 
			
		||||
                await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!);
 | 
			
		||||
        return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
 | 
			
		||||
        ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        var success = await fileReferenceService.DeleteReferenceAsync(Guid.Parse(request.ReferenceId));
 | 
			
		||||
        return new DeleteReferenceResponse { Success = success };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<UpdateResourceFilesResponse> UpdateResourceFiles(UpdateResourceFilesRequest request,
 | 
			
		||||
        ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        Instant? expiredAt = null;
 | 
			
		||||
        if (request.ExpiredAt != null)
 | 
			
		||||
        {
 | 
			
		||||
            expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
 | 
			
		||||
        }
 | 
			
		||||
        else if (request.Duration != null)
 | 
			
		||||
        {
 | 
			
		||||
            expiredAt = SystemClock.Instance.GetCurrentInstant() +
 | 
			
		||||
                        Duration.FromTimeSpan(request.Duration.ToTimeSpan());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var references = await fileReferenceService.UpdateResourceFilesAsync(
 | 
			
		||||
            request.ResourceId,
 | 
			
		||||
            request.FileIds,
 | 
			
		||||
            request.Usage,
 | 
			
		||||
            expiredAt
 | 
			
		||||
        );
 | 
			
		||||
        var response = new UpdateResourceFilesResponse();
 | 
			
		||||
        response.References.AddRange(references.Select(r => r.ToProtoValue()));
 | 
			
		||||
        return response;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<SetReferenceExpirationResponse> SetReferenceExpiration(
 | 
			
		||||
        SetReferenceExpirationRequest request, ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        Instant? expiredAt = null;
 | 
			
		||||
        if (request.ExpiredAt != null)
 | 
			
		||||
        {
 | 
			
		||||
            expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
 | 
			
		||||
        }
 | 
			
		||||
        else if (request.Duration != null)
 | 
			
		||||
        {
 | 
			
		||||
            expiredAt = SystemClock.Instance.GetCurrentInstant() +
 | 
			
		||||
                        Duration.FromTimeSpan(request.Duration.ToTimeSpan());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var success =
 | 
			
		||||
            await fileReferenceService.SetReferenceExpirationAsync(Guid.Parse(request.ReferenceId), expiredAt);
 | 
			
		||||
        return new SetReferenceExpirationResponse { Success = success };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<SetFileReferencesExpirationResponse> SetFileReferencesExpiration(
 | 
			
		||||
        SetFileReferencesExpirationRequest request, ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        var expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
 | 
			
		||||
        var updatedCount = await fileReferenceService.SetFileReferencesExpirationAsync(request.FileId, expiredAt);
 | 
			
		||||
        return new SetFileReferencesExpirationResponse { UpdatedCount = updatedCount };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<HasFileReferencesResponse> HasFileReferences(HasFileReferencesRequest request,
 | 
			
		||||
        ServerCallContext context)
 | 
			
		||||
    {
 | 
			
		||||
        var hasReferences = await fileReferenceService.HasFileReferencesAsync(request.FileId);
 | 
			
		||||
        return new HasFileReferencesResponse { HasReferences = hasReferences };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,47 +1,45 @@
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using 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 tusdotnet.Stores;
 | 
			
		||||
using System.Linq.Expressions;
 | 
			
		||||
using Microsoft.EntityFrameworkCore.Query;
 | 
			
		||||
using NATS.Net;
 | 
			
		||||
using DysonNetwork.Shared.Models;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Storage;
 | 
			
		||||
 | 
			
		||||
public class FileService(
 | 
			
		||||
    AppDatabase db,
 | 
			
		||||
    IConfiguration configuration,
 | 
			
		||||
    TusDiskStore store,
 | 
			
		||||
    ILogger<FileService> logger,
 | 
			
		||||
    IServiceScopeFactory scopeFactory,
 | 
			
		||||
    ICacheService cache
 | 
			
		||||
    ICacheService cache,
 | 
			
		||||
    INatsConnection nats
 | 
			
		||||
)
 | 
			
		||||
{
 | 
			
		||||
    private const string CacheKeyPrefix = "file:";
 | 
			
		||||
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// The api for getting file meta with cache,
 | 
			
		||||
    /// the best use case is for accessing the file data.
 | 
			
		||||
    ///
 | 
			
		||||
    /// <b>This function won't load uploader's information, only keep minimal file meta</b>
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="fileId">The id of the cloud file requested</param>
 | 
			
		||||
    /// <returns>The minimal file meta</returns>
 | 
			
		||||
    public async Task<CloudFile?> GetFileAsync(string fileId)
 | 
			
		||||
    public async Task<SnCloudFile?> GetFileAsync(string fileId)
 | 
			
		||||
    {
 | 
			
		||||
        var cacheKey = $"{CacheKeyPrefix}{fileId}";
 | 
			
		||||
 | 
			
		||||
        var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
 | 
			
		||||
        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)
 | 
			
		||||
@@ -50,246 +48,273 @@ public class FileService(
 | 
			
		||||
        return file;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static readonly string TempFilePrefix = "dyn-cloudfile";
 | 
			
		||||
    public async Task<List<SnCloudFile>> GetFilesAsync(List<string> fileIds)
 | 
			
		||||
    {
 | 
			
		||||
        var cachedFiles = new Dictionary<string, SnCloudFile>();
 | 
			
		||||
        var uncachedIds = new List<string>();
 | 
			
		||||
 | 
			
		||||
    private static readonly string[] AnimatedImageTypes =
 | 
			
		||||
        ["image/gif", "image/apng", "image/webp", "image/avif"];
 | 
			
		||||
        foreach (var fileId in fileIds)
 | 
			
		||||
        {
 | 
			
		||||
            var cacheKey = $"{CacheKeyPrefix}{fileId}";
 | 
			
		||||
            var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
 | 
			
		||||
 | 
			
		||||
    // The analysis file method no longer will remove the GPS EXIF data
 | 
			
		||||
    // It should be handled on the client side, and for some specific cases it should be keep
 | 
			
		||||
    public async Task<CloudFile> ProcessNewFileAsync(
 | 
			
		||||
            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,
 | 
			
		||||
        Stream stream,
 | 
			
		||||
        string filePool,
 | 
			
		||||
        string? fileBundleId,
 | 
			
		||||
        string filePath,
 | 
			
		||||
        string fileName,
 | 
			
		||||
        string? contentType
 | 
			
		||||
        string? contentType,
 | 
			
		||||
        string? encryptPassword,
 | 
			
		||||
        Instant? expiredAt
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        var result = new List<(string filePath, string suffix)>();
 | 
			
		||||
        var accountId = Guid.Parse(account.Id);
 | 
			
		||||
 | 
			
		||||
        var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId));
 | 
			
		||||
        var fileSize = stream.Length;
 | 
			
		||||
        var hash = await HashFileAsync(stream, fileSize: fileSize);
 | 
			
		||||
        contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName);
 | 
			
		||||
        var pool = await GetPoolAsync(Guid.Parse(filePool));
 | 
			
		||||
        if (pool is null) throw new InvalidOperationException("Pool not found");
 | 
			
		||||
 | 
			
		||||
        var file = new CloudFile
 | 
			
		||||
        if (pool.StorageConfig.Expiration is not null && expiredAt.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            var expectedExpiration = SystemClock.Instance.GetCurrentInstant() - expiredAt.Value;
 | 
			
		||||
            var effectiveExpiration = pool.StorageConfig.Expiration < expectedExpiration
 | 
			
		||||
                ? pool.StorageConfig.Expiration
 | 
			
		||||
                : expectedExpiration;
 | 
			
		||||
            expiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var bundle = fileBundleId is not null
 | 
			
		||||
            ? await GetBundleAsync(Guid.Parse(fileBundleId), accountId)
 | 
			
		||||
            : null;
 | 
			
		||||
        if (fileBundleId is not null && bundle is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Bundle not found");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (bundle?.ExpiredAt != null)
 | 
			
		||||
            expiredAt = bundle.ExpiredAt.Value;
 | 
			
		||||
 | 
			
		||||
        var managedTempPath = Path.Combine(Path.GetTempPath(), fileId);
 | 
			
		||||
        File.Copy(filePath, managedTempPath, true);
 | 
			
		||||
 | 
			
		||||
        var fileInfo = new FileInfo(managedTempPath);
 | 
			
		||||
        var fileSize = fileInfo.Length;
 | 
			
		||||
        var finalContentType = contentType ??
 | 
			
		||||
                               (!fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName));
 | 
			
		||||
 | 
			
		||||
        var file = new SnCloudFile
 | 
			
		||||
        {
 | 
			
		||||
            Id = fileId,
 | 
			
		||||
            Name = fileName,
 | 
			
		||||
            MimeType = contentType,
 | 
			
		||||
            MimeType = finalContentType,
 | 
			
		||||
            Size = fileSize,
 | 
			
		||||
            Hash = hash,
 | 
			
		||||
            AccountId = Guid.Parse(account.Id)
 | 
			
		||||
            ExpiredAt = expiredAt,
 | 
			
		||||
            BundleId = bundle?.Id,
 | 
			
		||||
            AccountId = Guid.Parse(account.Id),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == hash);
 | 
			
		||||
        file.StorageId = existingFile is not null ? existingFile.StorageId : file.Id;
 | 
			
		||||
 | 
			
		||||
        if (existingFile is not null)
 | 
			
		||||
        if (!pool.PolicyConfig.NoMetadata)
 | 
			
		||||
        {
 | 
			
		||||
            file.FileMeta = existingFile.FileMeta;
 | 
			
		||||
            file.HasCompression = existingFile.HasCompression;
 | 
			
		||||
            file.SensitiveMarks = existingFile.SensitiveMarks;
 | 
			
		||||
            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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
        switch (contentType.Split('/')[0])
 | 
			
		||||
    private async Task ExtractMetadataAsync(SnCloudFile file, string filePath)
 | 
			
		||||
    {
 | 
			
		||||
        switch (file.MimeType?.Split('/')[0])
 | 
			
		||||
        {
 | 
			
		||||
            case "image":
 | 
			
		||||
                var blurhash =
 | 
			
		||||
                    BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(xComponent: 3, yComponent: 3, filename: ogFilePath);
 | 
			
		||||
 | 
			
		||||
                // Rewind stream
 | 
			
		||||
                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;
 | 
			
		||||
 | 
			
		||||
                // Use NetVips for the rest
 | 
			
		||||
                using (var vipsImage = NetVips.Image.NewFromStream(stream))
 | 
			
		||||
                {
 | 
			
		||||
                    using var vipsImage = Image.NewFromStream(stream);
 | 
			
		||||
                    var width = vipsImage.Width;
 | 
			
		||||
                    var height = vipsImage.Height;
 | 
			
		||||
                    var format = vipsImage.Get("vips-loader") ?? "unknown";
 | 
			
		||||
 | 
			
		||||
                    // Try to get orientation from exif data
 | 
			
		||||
                    var orientation = 1;
 | 
			
		||||
                    var meta = new Dictionary<string, object>
 | 
			
		||||
                    try
 | 
			
		||||
                    {
 | 
			
		||||
                        orientation = vipsImage.Get("orientation") as int? ?? 1;
 | 
			
		||||
                    }
 | 
			
		||||
                    catch
 | 
			
		||||
                    {
 | 
			
		||||
                        // ignored
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    var meta = new Dictionary<string, object?>
 | 
			
		||||
                    {
 | 
			
		||||
                        ["blur"] = blurhash,
 | 
			
		||||
                        ["format"] = format,
 | 
			
		||||
                        ["format"] = vipsImage.Get("vips-loader") ?? "unknown",
 | 
			
		||||
                        ["width"] = width,
 | 
			
		||||
                        ["height"] = height,
 | 
			
		||||
                        ["orientation"] = orientation,
 | 
			
		||||
                    };
 | 
			
		||||
                    Dictionary<string, object> exif = [];
 | 
			
		||||
                    var exif = new Dictionary<string, object>();
 | 
			
		||||
 | 
			
		||||
                    foreach (var field in vipsImage.GetFields())
 | 
			
		||||
                    {
 | 
			
		||||
                        if (IsIgnoredField(field)) continue;
 | 
			
		||||
                        var value = vipsImage.Get(field);
 | 
			
		||||
 | 
			
		||||
                        // Skip GPS-related EXIF fields to remove location data
 | 
			
		||||
                        if (IsIgnoredField(field))
 | 
			
		||||
                            continue;
 | 
			
		||||
 | 
			
		||||
                        if (field.StartsWith("exif-")) exif[field.Replace("exif-", "")] = value;
 | 
			
		||||
                        else meta[field] = value;
 | 
			
		||||
 | 
			
		||||
                        if (field == "orientation") orientation = (int)value;
 | 
			
		||||
                        if (field.StartsWith("exif-"))
 | 
			
		||||
                            exif[field.Replace("exif-", "")] = value;
 | 
			
		||||
                        else
 | 
			
		||||
                            meta[field] = value;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (orientation is 6 or 8)
 | 
			
		||||
                        (width, height) = (height, width);
 | 
			
		||||
 | 
			
		||||
                    var aspectRatio = height != 0 ? (double)width / height : 0;
 | 
			
		||||
 | 
			
		||||
                    if (orientation is 6 or 8) (width, height) = (height, width);
 | 
			
		||||
                    meta["exif"] = exif;
 | 
			
		||||
                    meta["ratio"] = aspectRatio;
 | 
			
		||||
                    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(ogFilePath);
 | 
			
		||||
                    file.FileMeta = new Dictionary<string, object>
 | 
			
		||||
                    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 ?? [],
 | 
			
		||||
                        ["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"] =
 | 
			
		||||
                            mediaInfo.PrimaryVideoStream.Width / mediaInfo.PrimaryVideoStream.Height;
 | 
			
		||||
                        file.FileMeta["ratio"] = (double)mediaInfo.PrimaryVideoStream.Width /
 | 
			
		||||
                                                 mediaInfo.PrimaryVideoStream.Height;
 | 
			
		||||
                }
 | 
			
		||||
                catch (Exception ex)
 | 
			
		||||
                {
 | 
			
		||||
                    logger.LogError("File analyzed failed, unable collect video / audio information: {Message}",
 | 
			
		||||
                        ex.Message);
 | 
			
		||||
                    logger.LogError(ex, "Failed to analyze media file {FileId}", file.Id);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        db.Files.Add(file);
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
 | 
			
		||||
        _ = Task.Run(async () =>
 | 
			
		||||
        {
 | 
			
		||||
            using var scope = scopeFactory.CreateScope();
 | 
			
		||||
            var nfs = scope.ServiceProvider.GetRequiredService<FileService>();
 | 
			
		||||
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Processed file {fileId}, now trying optimizing if possible...", fileId);
 | 
			
		||||
 | 
			
		||||
                if (contentType.Split('/')[0] == "image")
 | 
			
		||||
                {
 | 
			
		||||
                    // Skip compression for animated image types
 | 
			
		||||
                    var animatedMimeTypes = AnimatedImageTypes;
 | 
			
		||||
                    if (Enumerable.Contains(animatedMimeTypes, contentType))
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation(
 | 
			
		||||
                            "File {fileId} is an animated image (MIME: {mime}), skipping WebP conversion.", fileId,
 | 
			
		||||
                            contentType
 | 
			
		||||
                        );
 | 
			
		||||
                        var tempFilePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
 | 
			
		||||
                        result.Add((tempFilePath, string.Empty));
 | 
			
		||||
                        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
                    file.MimeType = "image/webp";
 | 
			
		||||
 | 
			
		||||
                    using var vipsImage = Image.NewFromFile(ogFilePath);
 | 
			
		||||
                    var imagePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
 | 
			
		||||
                    vipsImage.Autorot().WriteToFile(imagePath + ".webp",
 | 
			
		||||
                        new VOption { { "lossless", true }, { "strip", true } });
 | 
			
		||||
                    result.Add((imagePath + ".webp", string.Empty));
 | 
			
		||||
 | 
			
		||||
                    if (vipsImage.Width * vipsImage.Height >= 1024 * 1024)
 | 
			
		||||
    private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024)
 | 
			
		||||
    {
 | 
			
		||||
                        var scale = 1024.0 / Math.Max(vipsImage.Width, vipsImage.Height);
 | 
			
		||||
                        var imageCompressedPath =
 | 
			
		||||
                            Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}-compressed");
 | 
			
		||||
 | 
			
		||||
                        // Create and save image within the same synchronous block to avoid disposal issues
 | 
			
		||||
                        using var compressedImage = vipsImage.Resize(scale);
 | 
			
		||||
                        compressedImage.Autorot().WriteToFile(imageCompressedPath + ".webp",
 | 
			
		||||
                            new VOption { { "Q", 80 }, { "strip", true } });
 | 
			
		||||
 | 
			
		||||
                        result.Add((imageCompressedPath + ".webp", ".compressed"));
 | 
			
		||||
                        file.HasCompression = true;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    // No extra process for video, add it to the upload queue.
 | 
			
		||||
                    result.Add((ogFilePath, string.Empty));
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                logger.LogInformation("Optimized file {fileId}, now uploading...", fileId);
 | 
			
		||||
 | 
			
		||||
                if (result.Count > 0)
 | 
			
		||||
                {
 | 
			
		||||
                    List<Task<CloudFile>> tasks = [];
 | 
			
		||||
                    tasks.AddRange(result.Select(item =>
 | 
			
		||||
                        nfs.UploadFileToRemoteAsync(file, item.filePath, null, item.suffix, true))
 | 
			
		||||
                    );
 | 
			
		||||
 | 
			
		||||
                    await Task.WhenAll(tasks);
 | 
			
		||||
                    file = await tasks.First();
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    file = await nfs.UploadFileToRemoteAsync(file, stream, null);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                logger.LogInformation("Uploaded file {fileId} done!", fileId);
 | 
			
		||||
 | 
			
		||||
                var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>();
 | 
			
		||||
                await scopedDb.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(setter => setter
 | 
			
		||||
                    .SetProperty(f => f.UploadedAt, file.UploadedAt)
 | 
			
		||||
                    .SetProperty(f => f.UploadedTo, file.UploadedTo)
 | 
			
		||||
                    .SetProperty(f => f.MimeType, file.MimeType)
 | 
			
		||||
                    .SetProperty(f => f.HasCompression, file.HasCompression)
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception err)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogError(err, "Failed to process {fileId}", fileId);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await stream.DisposeAsync();
 | 
			
		||||
            await store.DeleteFileAsync(file.Id, CancellationToken.None);
 | 
			
		||||
            await nfs._PurgeCacheAsync(file.Id);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return file;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static async Task<string> HashFileAsync(Stream stream, int chunkSize = 1024 * 1024, long? fileSize = null)
 | 
			
		||||
    {
 | 
			
		||||
        fileSize ??= stream.Length;
 | 
			
		||||
        if (fileSize > chunkSize * 1024 * 5)
 | 
			
		||||
            return await HashFastApproximateAsync(stream, chunkSize);
 | 
			
		||||
        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(Stream stream, int chunkSize = 1024 * 1024)
 | 
			
		||||
    private static async Task<string> HashFastApproximateAsync(string filePath, int chunkSize = 1024 * 1024)
 | 
			
		||||
    {
 | 
			
		||||
        // Scale the chunk size to kB level
 | 
			
		||||
        chunkSize *= 1024;
 | 
			
		||||
 | 
			
		||||
        using var md5 = MD5.Create();
 | 
			
		||||
        await using var stream = File.OpenRead(filePath);
 | 
			
		||||
 | 
			
		||||
        var buffer = new byte[chunkSize * 2];
 | 
			
		||||
        var fileLength = stream.Length;
 | 
			
		||||
@@ -302,89 +327,127 @@ public class FileService(
 | 
			
		||||
            bytesRead += await stream.ReadAsync(buffer.AsMemory(chunkSize, chunkSize));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var hash = md5.ComputeHash(buffer, 0, bytesRead);
 | 
			
		||||
        var hash = MD5.HashData(buffer.AsSpan(0, bytesRead));
 | 
			
		||||
        stream.Position = 0;
 | 
			
		||||
        return Convert.ToHexString(hash).ToLowerInvariant();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<CloudFile> UploadFileToRemoteAsync(CloudFile file, string filePath, string? targetRemote,
 | 
			
		||||
        string? suffix = null, bool selfDestruct = false)
 | 
			
		||||
    public async Task UploadFileToRemoteAsync(
 | 
			
		||||
        string storageId,
 | 
			
		||||
        Guid targetRemote,
 | 
			
		||||
        string filePath,
 | 
			
		||||
        string? suffix = null,
 | 
			
		||||
        string? contentType = null,
 | 
			
		||||
        bool selfDestruct = false
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        var fileStream = File.OpenRead(filePath);
 | 
			
		||||
        var result = await UploadFileToRemoteAsync(file, fileStream, targetRemote, suffix);
 | 
			
		||||
        await using var fileStream = File.OpenRead(filePath);
 | 
			
		||||
        await UploadFileToRemoteAsync(storageId, targetRemote, fileStream, suffix, contentType);
 | 
			
		||||
        if (selfDestruct) File.Delete(filePath);
 | 
			
		||||
        return result;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<CloudFile> UploadFileToRemoteAsync(CloudFile file, Stream stream, string? targetRemote,
 | 
			
		||||
        string? suffix = null)
 | 
			
		||||
    private async Task UploadFileToRemoteAsync(
 | 
			
		||||
        string storageId,
 | 
			
		||||
        Guid targetRemote,
 | 
			
		||||
        Stream stream,
 | 
			
		||||
        string? suffix = null,
 | 
			
		||||
        string? contentType = null
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        if (file.UploadedAt.HasValue) return file;
 | 
			
		||||
 | 
			
		||||
        file.UploadedTo = targetRemote ?? configuration.GetValue<string>("Storage:PreferredRemote")!;
 | 
			
		||||
 | 
			
		||||
        var dest = GetRemoteStorageConfig(file.UploadedTo);
 | 
			
		||||
        var client = CreateMinioClient(dest);
 | 
			
		||||
        if (client is null)
 | 
			
		||||
        var dest = await GetRemoteStorageConfig(targetRemote);
 | 
			
		||||
        if (dest is null)
 | 
			
		||||
            throw new InvalidOperationException(
 | 
			
		||||
                $"Failed to configure client for remote destination '{file.UploadedTo}'"
 | 
			
		||||
                $"Failed to configure client for remote destination '{targetRemote}'"
 | 
			
		||||
            );
 | 
			
		||||
        var client = CreateMinioClient(dest);
 | 
			
		||||
 | 
			
		||||
        var bucket = dest.Bucket;
 | 
			
		||||
        var contentType = file.MimeType ?? "application/octet-stream";
 | 
			
		||||
        contentType ??= "application/octet-stream";
 | 
			
		||||
 | 
			
		||||
        await client.PutObjectAsync(new PutObjectArgs()
 | 
			
		||||
        await client!.PutObjectAsync(new PutObjectArgs()
 | 
			
		||||
            .WithBucket(bucket)
 | 
			
		||||
            .WithObject(string.IsNullOrWhiteSpace(suffix) ? file.Id : file.Id + suffix)
 | 
			
		||||
            .WithStreamData(stream) // Fix this disposed
 | 
			
		||||
            .WithObject(string.IsNullOrWhiteSpace(suffix) ? storageId : storageId + suffix)
 | 
			
		||||
            .WithStreamData(stream)
 | 
			
		||||
            .WithObjectSize(stream.Length)
 | 
			
		||||
            .WithContentType(contentType)
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        file.UploadedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
 | 
			
		||||
        return file;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task DeleteFileAsync(CloudFile file)
 | 
			
		||||
    public async Task<SnCloudFile> UpdateFileAsync(SnCloudFile file, FieldMask updateMask)
 | 
			
		||||
    {
 | 
			
		||||
        await DeleteFileDataAsync(file);
 | 
			
		||||
        var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Id == file.Id);
 | 
			
		||||
        if (existingFile == null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"File with ID {file.Id} not found.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var updatable = new UpdatableCloudFile(existingFile);
 | 
			
		||||
 | 
			
		||||
        foreach (var path in updateMask.Paths)
 | 
			
		||||
        {
 | 
			
		||||
            switch (path)
 | 
			
		||||
            {
 | 
			
		||||
                case "name":
 | 
			
		||||
                    updatable.Name = file.Name;
 | 
			
		||||
                    break;
 | 
			
		||||
                case "description":
 | 
			
		||||
                    updatable.Description = file.Description;
 | 
			
		||||
                    break;
 | 
			
		||||
                case "file_meta":
 | 
			
		||||
                    updatable.FileMeta = file.FileMeta;
 | 
			
		||||
                    break;
 | 
			
		||||
                case "user_meta":
 | 
			
		||||
                    updatable.UserMeta = file.UserMeta;
 | 
			
		||||
                    break;
 | 
			
		||||
                case "is_marked_recycle":
 | 
			
		||||
                    updatable.IsMarkedRecycle = file.IsMarkedRecycle;
 | 
			
		||||
                    break;
 | 
			
		||||
                default:
 | 
			
		||||
                    logger.LogWarning("Attempted to update unmodifiable field: {Field}", path);
 | 
			
		||||
                    break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls());
 | 
			
		||||
 | 
			
		||||
        await _PurgeCacheAsync(file.Id);
 | 
			
		||||
        return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task DeleteFileAsync(SnCloudFile file)
 | 
			
		||||
    {
 | 
			
		||||
        db.Remove(file);
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
        await _PurgeCacheAsync(file.Id);
 | 
			
		||||
 | 
			
		||||
        await DeleteFileDataAsync(file);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task DeleteFileDataAsync(CloudFile file)
 | 
			
		||||
    public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false)
 | 
			
		||||
    {
 | 
			
		||||
        if (file.StorageId is null) return;
 | 
			
		||||
        if (file.UploadedTo is null) return;
 | 
			
		||||
        if (!file.PoolId.HasValue) return;
 | 
			
		||||
 | 
			
		||||
        // Check if any other file with the same storage ID is referenced
 | 
			
		||||
        var otherFilesWithSameStorageId = await db.Files
 | 
			
		||||
        if (!force)
 | 
			
		||||
        {
 | 
			
		||||
            var sameOriginFiles = await db.Files
 | 
			
		||||
                .Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
 | 
			
		||||
                .Select(f => f.Id)
 | 
			
		||||
                .ToListAsync();
 | 
			
		||||
 | 
			
		||||
        // Check if any of these files are referenced
 | 
			
		||||
        var anyReferenced = false;
 | 
			
		||||
        if (otherFilesWithSameStorageId.Any())
 | 
			
		||||
        {
 | 
			
		||||
            anyReferenced = await db.FileReferences
 | 
			
		||||
                .Where(r => otherFilesWithSameStorageId.Contains(r.FileId))
 | 
			
		||||
                .AnyAsync();
 | 
			
		||||
            if (sameOriginFiles.Count != 0)
 | 
			
		||||
                return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If any other file with the same storage ID is referenced, don't delete the actual file data
 | 
			
		||||
        if (anyReferenced) return;
 | 
			
		||||
 | 
			
		||||
        var dest = GetRemoteStorageConfig(file.UploadedTo);
 | 
			
		||||
        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.UploadedTo}'"
 | 
			
		||||
                $"Failed to configure client for remote destination '{file.PoolId}'"
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
        var bucket = dest.Bucket;
 | 
			
		||||
        var objectId = file.StorageId ?? file.Id; // Use StorageId if available, otherwise fall back to Id
 | 
			
		||||
        var objectId = file.StorageId ?? file.Id;
 | 
			
		||||
 | 
			
		||||
        await client.RemoveObjectAsync(
 | 
			
		||||
            new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId)
 | 
			
		||||
@@ -392,7 +455,6 @@ public class FileService(
 | 
			
		||||
 | 
			
		||||
        if (file.HasCompression)
 | 
			
		||||
        {
 | 
			
		||||
            // Also remove the compressed version if it exists
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                await client.RemoveObjectAsync(
 | 
			
		||||
@@ -401,18 +463,87 @@ public class FileService(
 | 
			
		||||
            }
 | 
			
		||||
            catch
 | 
			
		||||
            {
 | 
			
		||||
                // Ignore errors when deleting compressed version
 | 
			
		||||
                logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (file.HasThumbnail)
 | 
			
		||||
        {
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                await client.RemoveObjectAsync(
 | 
			
		||||
                    new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId + ".thumbnail")
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
            catch
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public RemoteStorageConfig GetRemoteStorageConfig(string destination)
 | 
			
		||||
    public async Task DeleteFileDataBatchAsync(List<SnCloudFile> files)
 | 
			
		||||
    {
 | 
			
		||||
        var destinations = configuration.GetSection("Storage:Remote").Get<List<RemoteStorageConfig>>()!;
 | 
			
		||||
        var dest = destinations.FirstOrDefault(d => d.Id == destination);
 | 
			
		||||
        if (dest is null) throw new InvalidOperationException($"Remote destination '{destination}' not found");
 | 
			
		||||
        return dest;
 | 
			
		||||
        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)
 | 
			
		||||
@@ -426,31 +557,27 @@ public class FileService(
 | 
			
		||||
        return client.Build();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Helper method to purge the cache for a specific file
 | 
			
		||||
    // Made internal to allow FileReferenceService to use it
 | 
			
		||||
    internal async Task _PurgeCacheAsync(string fileId)
 | 
			
		||||
    {
 | 
			
		||||
        var cacheKey = $"{CacheKeyPrefix}{fileId}";
 | 
			
		||||
        await cache.RemoveAsync(cacheKey);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Helper method to purge cache for multiple files
 | 
			
		||||
    internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds)
 | 
			
		||||
    {
 | 
			
		||||
        var tasks = fileIds.Select(_PurgeCacheAsync);
 | 
			
		||||
        await Task.WhenAll(tasks);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<List<CloudFile?>> LoadFromReference(List<CloudFileReferenceObject> references)
 | 
			
		||||
    public async Task<List<SnCloudFile?>> LoadFromReference(List<SnCloudFileReferenceObject> references)
 | 
			
		||||
    {
 | 
			
		||||
        var cachedFiles = new Dictionary<string, CloudFile>();
 | 
			
		||||
        var cachedFiles = new Dictionary<string, SnCloudFile>();
 | 
			
		||||
        var uncachedIds = new List<string>();
 | 
			
		||||
 | 
			
		||||
        // Check cache first
 | 
			
		||||
        foreach (var reference in references)
 | 
			
		||||
        {
 | 
			
		||||
            var cacheKey = $"{CacheKeyPrefix}{reference.Id}";
 | 
			
		||||
            var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
 | 
			
		||||
            var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
 | 
			
		||||
 | 
			
		||||
            if (cachedFile != null)
 | 
			
		||||
            {
 | 
			
		||||
@@ -462,14 +589,12 @@ public class FileService(
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Load uncached files from database
 | 
			
		||||
        if (uncachedIds.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            var dbFiles = await db.Files
 | 
			
		||||
                .Where(f => uncachedIds.Contains(f.Id))
 | 
			
		||||
                .ToListAsync();
 | 
			
		||||
 | 
			
		||||
            // Add to cache
 | 
			
		||||
            foreach (var file in dbFiles)
 | 
			
		||||
            {
 | 
			
		||||
                var cacheKey = $"{CacheKeyPrefix}{file.Id}";
 | 
			
		||||
@@ -478,18 +603,11 @@ public class FileService(
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Preserve original order
 | 
			
		||||
        return references
 | 
			
		||||
        return [.. references
 | 
			
		||||
            .Select(r => cachedFiles.GetValueOrDefault(r.Id))
 | 
			
		||||
            .Where(f => f != null)
 | 
			
		||||
            .ToList();
 | 
			
		||||
            .Where(f => f != null)];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Gets the number of references to a file based on CloudFileReference records
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="fileId">The ID of the file</param>
 | 
			
		||||
    /// <returns>The number of references to the file</returns>
 | 
			
		||||
    public async Task<int> GetReferenceCountAsync(string fileId)
 | 
			
		||||
    {
 | 
			
		||||
        return await db.FileReferences
 | 
			
		||||
@@ -497,11 +615,6 @@ public class FileService(
 | 
			
		||||
            .CountAsync();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Checks if a file is referenced by any resource
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="fileId">The ID of the file to check</param>
 | 
			
		||||
    /// <returns>True if the file is referenced, false otherwise</returns>
 | 
			
		||||
    public async Task<bool> IsReferencedAsync(string fileId)
 | 
			
		||||
    {
 | 
			
		||||
        return await db.FileReferences
 | 
			
		||||
@@ -509,47 +622,106 @@ public class FileService(
 | 
			
		||||
            .AnyAsync();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Checks if an EXIF field contains GPS location data
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="fieldName">The EXIF field name</param>
 | 
			
		||||
    /// <returns>True if the field contains GPS data, false otherwise</returns>
 | 
			
		||||
    private static bool IsGpsExifField(string fieldName)
 | 
			
		||||
    private static bool IsIgnoredField(string fieldName)
 | 
			
		||||
    {
 | 
			
		||||
        // Common GPS EXIF field names
 | 
			
		||||
        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-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"
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return gpsFields.Any(gpsField =>
 | 
			
		||||
            fieldName.Equals(gpsField, StringComparison.OrdinalIgnoreCase) ||
 | 
			
		||||
            fieldName.StartsWith("gps", StringComparison.OrdinalIgnoreCase));
 | 
			
		||||
        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));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static bool IsIgnoredField(string fieldName)
 | 
			
		||||
    public async Task<int> DeleteAccountRecycledFilesAsync(Guid accountId)
 | 
			
		||||
    {
 | 
			
		||||
        if (IsGpsExifField(fieldName)) return true;
 | 
			
		||||
        if (fieldName.EndsWith("-data")) return true;
 | 
			
		||||
        return false;
 | 
			
		||||
        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.
 | 
			
		||||
@@ -1,79 +0,0 @@
 | 
			
		||||
using System.Net;
 | 
			
		||||
using System.Text;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using DysonNetwork.Shared.Proto;
 | 
			
		||||
using Microsoft.AspNetCore.Mvc;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using tusdotnet.Interfaces;
 | 
			
		||||
using tusdotnet.Models;
 | 
			
		||||
using tusdotnet.Models.Configuration;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive.Storage;
 | 
			
		||||
 | 
			
		||||
public abstract class TusService
 | 
			
		||||
{
 | 
			
		||||
    public static DefaultTusConfiguration BuildConfiguration(ITusStore store) => 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 user)
 | 
			
		||||
                {
 | 
			
		||||
                    eventContext.FailRequest(HttpStatusCode.Unauthorized);
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (!user.IsSuperuser)
 | 
			
		||||
                {
 | 
			
		||||
                    using var scope = httpContext.RequestServices.CreateScope();
 | 
			
		||||
                    var pm = scope.ServiceProvider.GetRequiredService<PermissionService.PermissionServiceClient>();
 | 
			
		||||
                    var allowed = await pm.HasPermissionAsync(new HasPermissionRequest
 | 
			
		||||
                        { Actor = $"user:{user.Id}", Area = "global", Key = "files.create" });
 | 
			
		||||
                    if (!allowed.HasPermission)
 | 
			
		||||
                        eventContext.FailRequest(HttpStatusCode.Forbidden);
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            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 fileStream = await file.GetContentAsync(eventContext.CancellationToken);
 | 
			
		||||
 | 
			
		||||
                var fileService = services.GetRequiredService<FileService>();
 | 
			
		||||
                var info = await fileService.ProcessNewFileAsync(user, file.Id, fileStream, fileName, contentType);
 | 
			
		||||
 | 
			
		||||
                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);
 | 
			
		||||
 | 
			
		||||
                // Dispose the stream after all processing is complete
 | 
			
		||||
                await fileStream.DisposeAsync();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								DysonNetwork.Drive/VersionController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								DysonNetwork.Drive/VersionController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
using DysonNetwork.Shared.Data;
 | 
			
		||||
using Microsoft.AspNetCore.Mvc;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Drive;
 | 
			
		||||
 | 
			
		||||
[ApiController]
 | 
			
		||||
[Route("/api/version")]
 | 
			
		||||
public class VersionController : ControllerBase
 | 
			
		||||
{
 | 
			
		||||
    [HttpGet]
 | 
			
		||||
    public IActionResult Get()
 | 
			
		||||
    {
 | 
			
		||||
        return Ok(new AppVersion
 | 
			
		||||
        {
 | 
			
		||||
            Version = ThisAssembly.AssemblyVersion,
 | 
			
		||||
            Commit = ThisAssembly.GitCommitId,
 | 
			
		||||
            UpdateDate = ThisAssembly.GitCommitDate
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  "Debug": true,
 | 
			
		||||
  "BaseUrl": "http://localhost:5071",
 | 
			
		||||
  "BaseUrl": "http://localhost:5090",
 | 
			
		||||
  "GatewayUrl": "http://localhost:5094",
 | 
			
		||||
  "Logging": {
 | 
			
		||||
    "LogLevel": {
 | 
			
		||||
      "Default": "Information",
 | 
			
		||||
@@ -9,8 +10,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "AllowedHosts": "*",
 | 
			
		||||
  "ConnectionStrings": {
 | 
			
		||||
    "App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
 | 
			
		||||
    "FastRetrieve": "localhost:6379"
 | 
			
		||||
    "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": {
 | 
			
		||||
@@ -27,20 +27,12 @@
 | 
			
		||||
    "PublicKeyPath": "Keys/PublicKey.pem",
 | 
			
		||||
    "PrivateKeyPath": "Keys/PrivateKey.pem"
 | 
			
		||||
  },
 | 
			
		||||
  "OidcProvider": {
 | 
			
		||||
    "IssuerUri": "https://nt.solian.app",
 | 
			
		||||
    "PublicKeyPath": "Keys/PublicKey.pem",
 | 
			
		||||
    "PrivateKeyPath": "Keys/PrivateKey.pem",
 | 
			
		||||
    "AccessTokenLifetime": "01:00:00",
 | 
			
		||||
    "RefreshTokenLifetime": "30.00:00:00",
 | 
			
		||||
    "AuthorizationCodeLifetime": "00:30:00",
 | 
			
		||||
    "RequireHttpsMetadata": true
 | 
			
		||||
  },
 | 
			
		||||
  "Tus": {
 | 
			
		||||
    "StorePath": "Uploads"
 | 
			
		||||
  },
 | 
			
		||||
  "Storage": {
 | 
			
		||||
    "PreferredRemote": "minio",
 | 
			
		||||
    "Uploads": "Uploads",
 | 
			
		||||
    "PreferredRemote": "2adceae3-981a-4564-9b8d-5d71a211c873",
 | 
			
		||||
    "Remote": [
 | 
			
		||||
      {
 | 
			
		||||
        "Id": "minio",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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.5.2" />
 | 
			
		||||
    <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
      <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
 | 
			
		||||
</Project>
 | 
			
		||||
							
								
								
									
										168
									
								
								DysonNetwork.Gateway/Program.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								DysonNetwork.Gateway/Program.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,168 @@
 | 
			
		||||
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", "insight" };
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
    {
 | 
			
		||||
        _ => $"/{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 HealthCheckConfig
 | 
			
		||||
    {
 | 
			
		||||
        Active = new ActiveHealthCheckConfig
 | 
			
		||||
        {
 | 
			
		||||
            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.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"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										76
									
								
								DysonNetwork.Insight/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								DysonNetwork.Insight/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,76 @@
 | 
			
		||||
using DysonNetwork.Shared.Models;
 | 
			
		||||
using Microsoft.EntityFrameworkCore;
 | 
			
		||||
using Microsoft.EntityFrameworkCore.Design;
 | 
			
		||||
using NodaTime;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Insight;
 | 
			
		||||
 | 
			
		||||
public class AppDatabase(
 | 
			
		||||
    DbContextOptions<AppDatabase> options,
 | 
			
		||||
    IConfiguration configuration
 | 
			
		||||
) : DbContext(options)
 | 
			
		||||
{
 | 
			
		||||
    public DbSet<SnThinkingSequence> ThinkingSequences { get; set; }
 | 
			
		||||
    public DbSet<SnThinkingThought> ThinkingThoughts { get; set; }
 | 
			
		||||
    
 | 
			
		||||
    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);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    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);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								DysonNetwork.Insight/Controllers/BillingController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								DysonNetwork.Insight/Controllers/BillingController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
using DysonNetwork.Insight.Thought;
 | 
			
		||||
using DysonNetwork.Shared.Auth;
 | 
			
		||||
using Microsoft.AspNetCore.Authorization;
 | 
			
		||||
using Microsoft.AspNetCore.Mvc;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Insight.Controllers;
 | 
			
		||||
 | 
			
		||||
[ApiController]
 | 
			
		||||
[Route("/api/billing")]
 | 
			
		||||
public class BillingController(ThoughtService thoughtService, ILogger<BillingController> logger) : ControllerBase
 | 
			
		||||
{
 | 
			
		||||
    [HttpPost("settle")]
 | 
			
		||||
    [Authorize]
 | 
			
		||||
    [RequiredPermission("maintenance", "insight.billing.settle")]
 | 
			
		||||
    public async Task<IActionResult> ProcessTokenBilling()
 | 
			
		||||
    {
 | 
			
		||||
        await thoughtService.SettleThoughtBills(logger);
 | 
			
		||||
        return Ok();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user