Compare commits
1266 Commits
2206676214
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
c9530ac8b5
|
|||
|
4ba7d38d78
|
|||
|
8642737a07
|
|||
|
8181938aaf
|
|||
|
922afc2239
|
|||
|
a071bd2738
|
|||
|
43945fc524
|
|||
|
e477429a35
|
|||
|
fe3a057185
|
|||
|
ad3c104c5c
|
|||
|
2020d625aa
|
|||
|
f471c5635d
|
|||
|
eaeaa28c60
|
|||
|
ee5c7cb7ce
|
|||
|
33abf12e41
|
|||
|
4a71f92ef0
|
|||
|
4faa1a4b64
|
|||
|
e49a1ec49a
|
|||
|
a88f42b26a
|
|||
|
c45be62331
|
|||
|
c8228e0c8e
|
|||
|
c642c6d646
|
|||
|
270c211cb8
|
|||
|
74c8f3490d
|
|||
|
b364edc74b
|
|||
|
9addf38677
|
|||
|
a02ed10434
|
|||
|
aca28f9318
|
|||
|
c2f72993b7
|
|||
|
158cc75c5b
|
|||
|
fa2f53ff7a
|
|||
|
2cce5ebf80
|
|||
|
13b2e46ecc
|
|||
|
cbd68c9ae6
|
|||
|
b99b61e0f9
|
|||
|
94f4e68120
|
|||
|
d5510f7e4d
|
|||
|
c038ab9e3c
|
|||
|
e97719ec84
|
|||
|
40b8ea8eb8
|
|||
|
f9b4dd45d7
|
|||
|
a46de4662c
|
|||
|
fdd14b860e
|
|||
|
cb62df81e2
|
|||
|
46717e39a7
|
|||
|
344ed6e348
|
|||
|
a8b62fb0eb
|
|||
|
00b3087d6a
|
|||
|
78f3873a0c
|
|||
|
a7f4173df7
|
|||
|
f51c3c1724
|
|||
|
a92dc7e140
|
|||
|
c42befed6b
|
|||
|
2b95d58611
|
|||
|
726a752fbb
|
|||
|
2024972832
|
|||
|
d553ca2ca7
|
|||
|
aeef16495f
|
|||
|
9b26a2a7eb
|
|||
|
2317033dae
|
|||
|
fd6e9c9780
|
|||
|
af0a2ff493
|
|||
|
b142a71c32
|
|||
|
27e3cc853a
|
|||
|
590519c28f
|
|||
|
8ccf8100d4
|
|||
|
ec21a94921
|
|||
|
7b7a6c9218
|
|||
|
0e44d9c514
|
|||
|
e449e16d33
|
|||
|
3ce2b36c15
|
|||
|
f7388822e0
|
|||
|
3800dae8b7
|
|||
|
c62ed191f3
|
|||
|
8b77f0e0ad
|
|||
|
2b56c6f1e5
|
|||
|
ef02265ccd
|
|||
|
f4505d2ecc
|
|||
|
9d2242d331
|
|||
|
c806365a81
|
|||
|
bd1715c9a3
|
|||
|
0b0598712e
|
|||
|
92a4899e7c
|
|||
|
bdc8db3091
|
|||
|
a16da37221
|
|||
|
70a18b07ff
|
|||
|
98b8d5f33b
|
|||
|
2a35786204
|
|||
|
7016a0a943
|
|||
|
cad72502d9
|
|||
|
226a64df41
|
|||
|
75b8567a28
|
|||
|
3aa5561a07
|
|||
|
c0ebb496fe
|
|||
|
afccb27bd4
|
|||
|
6ed96780ab
|
|||
|
8e5cdfbc62
|
|||
|
1b774c1de6
|
|||
|
9b4cbade5c
|
|||
|
a52e54f672
|
|||
|
aa48d5e25d
|
|||
|
ce18b194a5
|
|||
|
382579a20e
|
|||
|
18d50346a9
|
|||
|
ac51bbde6c
|
|||
|
4ab0dcf1c2
|
|||
|
587066d847
|
|||
|
faa375042a
|
|||
|
65b6f3a606
|
|||
|
fa1a40c637
|
|||
|
d43ce7cb11
|
|||
|
92b28d830d
|
|||
|
1fa6c893a5
|
|||
|
ba57becba8
|
|||
|
4280168002
|
|||
|
a172128d84
|
|||
|
34e78294a1
|
|||
|
82afdb3922
|
|||
|
260b3e7bc6
|
|||
|
713777cd8a
|
|||
|
5cd09bc2d0
|
|||
|
861fc7cafa
|
|||
|
6313f15375
|
|||
|
337cc1be97
|
|||
|
9b4f61fcda
|
|||
|
6252988390
|
|||
|
aace3b48b1
|
|||
|
5a097c7518
|
|||
|
ba3be1e3bb
|
|||
|
6fd90c424d
|
|||
|
a0ac3b5820
|
|||
|
076bf347c8
|
|||
|
788326381f
|
|||
|
a035b23242
|
|||
|
b29f4fce4d
|
|||
|
5418489f77
|
|||
|
310f2c1497
|
|||
|
0ae8a2cfd4
|
|||
|
c69256bda6
|
|||
|
80ea44f2cc
|
|||
|
b5f9faa724
|
|||
|
05985e0852
|
|||
|
6814b5690e
|
|||
|
78447de1b6
|
|||
|
e54dcccad9
|
|||
|
429a08930f
|
|||
|
b94b288755
|
|||
|
1c50c2f822
|
|||
|
73700e7cfd
|
|||
|
bd2943345a
|
|||
|
1647aa2f1e
|
|||
|
b137021b1f
|
|||
|
ffca94f789
|
|||
|
e2b2bdd262
|
|||
|
ce715cd6b0
|
|||
|
f7b3926338
|
|||
|
68cd23d64f
|
|||
|
db7d994039
|
|||
|
741ed18ce5
|
|||
|
2bfb50cc71
|
|||
|
db98fa240e
|
|||
|
d96937aabc
|
|||
|
dc0be3467f
|
|||
|
6101de741f
|
|||
|
6c8ad05872
|
|||
|
f5b37e9419
|
|||
|
ce5f3434eb
|
|||
|
c08503d2f3
|
|||
|
c8fec66e07
|
|||
|
61b49377a7
|
|||
|
0123c74ab8
|
|||
|
637cc0cfa4
|
|||
|
94a0ec71da
|
|||
|
1351db5482
|
|||
|
3e98ac29b7
|
|||
|
09625335f0
|
|||
|
ee9ad6d87f
|
|||
|
67fc82a8fb
|
|||
|
58e79655e8
|
|||
|
f271681b5d
|
|||
|
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 | |||
| afdbde951c | |||
| e66abe2e0c | |||
| 4a7f2e18b3 | |||
| e1b47bc7d1 | |||
| b6d416a3a8 | |||
| 2a8cbbfa24 | |||
| 33f56c4ef5 | |||
| 0318364bcf | |||
| ba49d1c7a7 | |||
| 29d752bdd9 | |||
| b12e3315fe | |||
| ce3958d397 | |||
| 26ea2503a4 | |||
| d6ce068490 | |||
| da4ee81c95 | |||
| bec294365f | |||
| 51a8b684fd | |||
| 7b026eeae1 | |||
| e76c80eead | |||
| 4dd4542c37 | |||
| 2a3918134f | |||
| 734e5ca4a0 | |||
| ff0789904d | |||
| 17330fc104 | |||
| 7c0ad46deb | |||
| b8fcd0d94f | |||
| fc6edd7378 | |||
| 1f2cdb146d | |||
| be236a27c6 | |||
| 99c36ae548 | |||
| ed2961a5d5 | |||
| 08b5ffa02f | |||
| 837a123c3b | |||
| ad1166190f | |||
| 8e8c938132 | |||
| 8e5b6ace45 | |||
| 5757526ea5 | |||
| 6a9cd0905d | |||
| 082a096470 | |||
| 3a72347432 | |||
| 19b1e957dd | |||
| 6449926334 | |||
| fb885e138d | |||
| 5bdc21ebc5 | |||
| f177377fe3 | |||
| 0df4864888 | |||
| 29b0ad184e | |||
| ad730832db | |||
| 71fcc26534 | |||
| fb8fc69920 | |||
| 05bf2cd055 | |||
| ccb8a4e3f4 | |||
| ca5be5a01c | |||
| c4ea15097e | |||
| cdeed3c318 | |||
| a53fcb10dd | |||
| c0879d30d4 | |||
| 0226bf8fa3 | |||
| 217b434cc4 | |||
| f8295c6a18 | |||
| d4fa08d320 | |||
| 8bd0ea0fa1 | |||
| 9ab31d79ce | |||
| ee5d6ef821 | |||
| d7b443e678 | |||
| 98b2eeb13d | |||
| ec3961d546 | |||
| a5dae37525 | |||
| 933d762f24 | |||
| 8251a9ec7d | |||
| 38243f9eba | |||
| b0b7afd6b3 | |||
| 6237fd6140 | |||
| 2e8d6a3667 | |||
| ac496777ed | |||
| 19ddc1b363 | |||
| 661b612537 | |||
| 8432436fcf | |||
| 2a28948418 | |||
| 5dd138949e | |||
| f540544a47 | |||
| 9f8eec792b | |||
| 0bdd429d87 | |||
| b2203fb464 | |||
| c5bbd58f5c | |||
| 35a9dcffbc | |||
| 1d50f225c1 | |||
| b7263b9804 | |||
| c63d6e0fbc | |||
| cebd1bd65a | |||
| da58e10d88 | |||
| d492c9ce1f | |||
| f170793928 | |||
| 1a137fbb6a | |||
| 21cf212d8f | |||
| c6cb2a0dc3 | |||
| d9747daab9 | |||
| d91b705b9a | |||
| 5ce3598cc9 | |||
| 1b45f07419 | |||
| 6bec0a672e | |||
| c338512c16 | |||
| 9444913b72 | |||
| 50bfec59ee | |||
| a97bf15362 | |||
| feb612afcd | |||
| 049a5c9b6f | |||
| 694bc77921 | |||
| be0b48cfd9 | |||
| a23338c263 | |||
| c5ef9b065b | |||
| 5990b17b4c | |||
| de7a2cea09 | |||
| 698442ad13 | |||
| 9fd6016308 | |||
| 516090a5f8 | |||
| 6b0e5f919d | |||
| c6450757be | |||
| 38abe16ba6 | |||
| bf40b51c41 | |||
| f50894a3d1 | |||
| d1fb0b9b55 | |||
| f1a47fd079 | |||
| 546b65f4c6 | |||
| 1baa3109bc | |||
| eadf25f389 | |||
| d385abbf57 | |||
| a431fbbd51 | |||
| d83c69620f | |||
| cb8e720af1 | |||
| 5f30b56ef8 | |||
| 95010e4188 | |||
| 3824fba8e5 | |||
| aefc38c5a3 | |||
| bcd107ae2c | |||
| 700c818df8 | |||
| 27276c66c5 | |||
| abc89dc782 | |||
| 15edd74a9f | |||
| cfa63c7c93 | |||
| eb1c283971 | |||
| ea599fb15b | |||
| e40514e440 | |||
| ca2d37eb39 | |||
| 2a5926a94a | |||
| aba0f6b5e2 | |||
| 3b9db74a34 | |||
| 5c02c63f70 | |||
| ada84f85e9 | |||
| 3aad515ab8 | |||
| 5e455599fd | |||
| b634d587a7 | |||
| 50d8f74a98 | |||
| 1a5d0bbfc0 | |||
| b868d0c153 | |||
| 60a8338c9a | |||
| fe04b12561 | |||
| 47caff569d | |||
| b27b6b8c1b | |||
| c806c5d139 | |||
| 70aeb5e0cb | |||
| 90eca43284 | |||
| 44ff09c119 | |||
| 16ff5588b9 | |||
| bf013a108b | |||
| d00917fb39 | |||
| c8b1c1ba55 | |||
| e7942fc687 | |||
| 1f2e9b1de8 | |||
| 2821beb1b7 | |||
| fcab12f175 | |||
| f5b04fa745 | |||
| 8af2dddb45 | |||
| 34902d0486 | |||
| ffb3f83b96 | |||
| 2e09e63022 | |||
| ebac6698ff | |||
| 45f8cab555 | |||
| 3db32caf7e | |||
| ee14c942f2 | |||
| ee36ad41d0 | |||
| 2fb29284fb | |||
| 922bf110ac | |||
| 9e17be38d8 | |||
| 0c48694493 | |||
| eef64df81a | |||
| f155b4e2ac | |||
| 877dd04b1f | |||
| f96de0d325 | |||
| b8341734df | |||
| 39533cced3 | |||
| 4bae2ea427 | |||
| de64f64c0e | |||
| 144b7fcfc2 | |||
| b1faabb07b | |||
| 9d534660af | |||
| 3a978441b6 | |||
| a8503735d1 | |||
| 026c405cd4 | |||
| 5a0c6dc4b0 | |||
| b69dd659d4 | |||
| b1c12685c8 | |||
| 0e78f7f7d2 | |||
| 2f051d0615 | |||
| 8938d347c6 | |||
| af39694be6 | |||
| 3c123be6a7 | |||
| bb7c9ca9d8 | |||
| aef6c60621 | |||
| b6aa0e83a3 | |||
| f62d86d4a7 | |||
| f961469db1 | |||
| a98bfec86f | |||
| eacb7c8f2f | |||
| 1f01a4088c | |||
| 2f9df8009b | |||
| db9b04ef47 | |||
| 49d5ee6184 | |||
| e62b2cc5ff | |||
| c4f6798fd0 | |||
| 130ad8f186 | |||
| 09e4150294 | |||
| b25b08b5c5 | |||
| 2be92d503e | |||
| 9b7a3be5c9 | |||
| 740f5ad3fc | |||
| 5487b4e607 | |||
| 2691c5d9ac | |||
| 0550c4f6de | |||
| 782cf56927 | |||
| abd44b8ddb | |||
| 3f2e86916d | |||
| 140b4eb699 | |||
| 7eabaf6a0d | |||
| 88f9157ff8 | |||
| bf5ae17741 | |||
| f5fb133e99 | |||
| 568afc981e | |||
| d48a2a8fe5 | |||
| 28ba9871bf | |||
| 1307114b76 | |||
| 3c52a6d787 | |||
| 00229fd406 | |||
| 02ae634690 | |||
| 7dee2a15e7 | |||
| 57775eb0a1 | |||
| ca57faa7c3 | |||
| 7040b236e9 | |||
| 6dc0f523e0 | |||
| 7c351de594 | |||
| fb2f138925 | |||
| 9a96cf68bb | |||
| a78e92a23a | |||
| 7fa0dfdcad | |||
| 28ff78d3e2 | |||
| 6965744d5a | |||
| b8c15bde1a | |||
| c3095f2a9b | |||
| 7656a8b298 | |||
| 1024721e0e | |||
| ed2e9571ab | |||
| 6670c69fda | |||
| a0cd779f85 | |||
| 472221302d | |||
| b7960e3060 | |||
| 0bbd322c2e | |||
| 8beeac09ef | |||
| fac9c3ae88 | |||
| 14dd610b3e | |||
| 9f5e0d8b80 | |||
| c5d7535bd2 | |||
| 06a97c57c0 | |||
| 5f69d8ac80 | |||
| 2778626b1f | |||
| 7f4c756365 | |||
| 6a426efde9 | |||
| 7e309bb5c7 | |||
| bb739c1d90 | |||
| 39d9d8a839 | |||
| bf6dbfdca0 | |||
| acece9cbce | |||
| fcaeb9afbe | |||
| 25c721a42b | |||
| 093055f9ab | |||
| c21cdeba74 | |||
| b913682866 | |||
| 7d5a804865 | |||
| 315b20182c | |||
| 3004536cc1 | |||
| 99f2e724a6 | |||
| d76e0dd83b | |||
| e20666160f | |||
| cfe29f5def | |||
| 33767a6d7f | |||
| cbe913e535 | |||
| b4c26f2d55 | |||
| 916d9500a2 | |||
| c562f52538 | |||
| 68399dd371 | |||
| 185ab13ec9 | |||
| 9e7ba820c4 | |||
| 59bc9edd4b | |||
| 80b7812a87 | |||
| 363c1aedf4 | |||
| 445e5d3705 | |||
| 460ce62452 | |||
| d4da5d7afc | |||
| 1cc7a7473a | |||
| 8da8c4bedd | |||
| 2eff4364c9 | |||
| b905d674b7 | |||
| 213d81a5ca | |||
| 1b2ca34aad | |||
| 4bbd695e27 | |||
| f439dca094 | |||
| da14504a69 | |||
| 4e5ad12e36 | |||
| 44b309878b | |||
| 81bf2c9650 | |||
| 4e672c9f96 | |||
| 55f853c411 | |||
| a6ca869f29 | |||
| 19174de873 | |||
| c3390d7248 | |||
| 8e8a120a90 | |||
| aa0d2ab3c4 | |||
| 95b3ab6bcd | |||
| 288d66221a | |||
| 2399bf0309 | |||
| b0a616c17c | |||
| 79fbbc283a | |||
| b1e3f91acd | |||
| 61f7764510 | |||
| 793043aba2 | |||
| 99f5d931c3 | |||
| 6bd125408e | |||
| 7845e4c0d7 | |||
| 9f8a83a4cf | |||
| ce53f28f19 | |||
| 353edc58a7 | |||
| 2fca5310be | |||
| a86a0fef37 | |||
| 8d246a19ad | |||
| cf9084b8c0 | |||
| 5b9b28d77a | |||
| 18fde9f16c | |||
| 4e794ceb9b | |||
| b40282e43a | |||
| 205ccd66b3 | |||
| fdfdffa382 | |||
| c597df3937 | |||
| 5951dab6f1 | |||
| 27f934c634 | |||
| 3d197b667a | |||
| b5226a72f2 | |||
| 6728bd5607 | |||
| d3b56b741e | |||
| cbef69ba5e | |||
| a77d00c3b9 | |||
| d59dba9c02 | |||
| 8ab17569ee | |||
| 6fe0b9b50a | |||
| b489a79df2 | |||
| 88977ccda3 | |||
| aabe8269f5 | |||
| 6358c49090 | |||
| 0db003abc2 | |||
| d7d4fde06a | |||
| 9576870373 | |||
| aeeed24290 | |||
| d1d4eb180f | |||
| 73fc7b3f47 | |||
| b275f06061 | |||
| b20bc3c443 | |||
| 3d5d4db3e3 | |||
| eab775e224 | |||
| 790dcafeb0 | |||
| 4fd8a588fa | |||
| b370c69670 | |||
| d70b081752 | |||
| 9b589af816 | |||
| 891dbfb255 | |||
| ee7dc31b20 | |||
| fb07071603 | |||
| 02aee07116 |
4
.aspire/settings.json
Normal file
4
.aspire/settings.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
**/.dockerignore
|
**/.dockerignore
|
||||||
**/.env
|
**/.env
|
||||||
**/.git
|
|
||||||
**/.gitignore
|
**/.gitignore
|
||||||
**/.project
|
**/.project
|
||||||
**/.settings
|
**/.settings
|
||||||
@@ -21,5 +20,7 @@
|
|||||||
**/obj
|
**/obj
|
||||||
**/secrets.dev.yaml
|
**/secrets.dev.yaml
|
||||||
**/values.dev.yaml
|
**/values.dev.yaml
|
||||||
|
**/node_modules
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
103
.github/workflows/docker-build.yml
vendored
Normal file
103
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
name: Build and Push Microservices
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
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" "Zone")
|
||||||
|
images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight" "zone")
|
||||||
|
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@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 GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image for ${{ matrix.service }}
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: DysonNetwork.${{ matrix.service }}/Dockerfile
|
||||||
|
push: true
|
||||||
|
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
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
/packages/
|
/packages/
|
||||||
|
/Certificates/
|
||||||
riderModule.iml
|
riderModule.iml
|
||||||
/_ReSharper.Caches/
|
/_ReSharper.Caches/
|
||||||
.idea
|
.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
|
||||||
77
DysonNetwork.Control/AppHost.cs
Normal file
77
DysonNetwork.Control/AppHost.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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);
|
||||||
|
var zoneService = builder.AddProject<Projects.DysonNetwork_Zone>("zone")
|
||||||
|
.WithReference(passService)
|
||||||
|
.WithReference(ringService)
|
||||||
|
.WithReference(sphereService)
|
||||||
|
.WithReference(developService)
|
||||||
|
.WithReference(insightService);
|
||||||
|
|
||||||
|
passService.WithReference(developService).WithReference(driveService);
|
||||||
|
|
||||||
|
List<IResourceBuilder<ProjectResource>> services =
|
||||||
|
[ringService, passService, driveService, sphereService, developService, insightService, zoneService];
|
||||||
|
|
||||||
|
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();
|
||||||
29
DysonNetwork.Control/DysonNetwork.Control.csproj
Normal file
29
DysonNetwork.Control/DysonNetwork.Control.csproj
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<Sdk Name="Aspire.AppHost.Sdk" Version="13.0.0"/>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.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="13.0.0"/>
|
||||||
|
<PackageReference Include="Aspire.Hosting.Docker" Version="13.0.0-preview.1.25560.3"/>
|
||||||
|
<PackageReference Include="Aspire.Hosting.Nats" Version="13.0.0"/>
|
||||||
|
<PackageReference Include="Aspire.Hosting.Redis" Version="13.0.0"/>
|
||||||
|
</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"/>
|
||||||
|
<ProjectReference Include="..\DysonNetwork.Zone\DysonNetwork.Zone.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:17169;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"
|
||||||
|
}
|
||||||
|
}
|
||||||
357
DysonNetwork.Control/aspire-manifest.json
Normal file
357
DysonNetwork.Control/aspire-manifest.json
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/aspire-8.0.json",
|
||||||
|
"resources": {
|
||||||
|
"cache": {
|
||||||
|
"type": "container.v1",
|
||||||
|
"connectionString": "{cache.bindings.tcp.host}:{cache.bindings.tcp.port},password={cache-password.value}",
|
||||||
|
"image": "docker.io/library/redis:8.2",
|
||||||
|
"entrypoint": "/bin/sh",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"redis-server --requirepass $REDIS_PASSWORD"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"REDIS_PASSWORD": "{cache-password.value}"
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"tcp": {
|
||||||
|
"scheme": "tcp",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "tcp",
|
||||||
|
"targetPort": 6379
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"type": "container.v1",
|
||||||
|
"connectionString": "nats://nats:{queue-password.value}@{queue.bindings.tcp.host}:{queue.bindings.tcp.port}",
|
||||||
|
"image": "docker.io/library/nats:2.11",
|
||||||
|
"args": [
|
||||||
|
"--user",
|
||||||
|
"nats",
|
||||||
|
"--pass",
|
||||||
|
"{queue-password.value}",
|
||||||
|
"-js"
|
||||||
|
],
|
||||||
|
"bindings": {
|
||||||
|
"tcp": {
|
||||||
|
"scheme": "tcp",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "tcp",
|
||||||
|
"targetPort": 4222
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ring": {
|
||||||
|
"type": "project.v1",
|
||||||
|
"path": "../DysonNetwork.Ring/DysonNetwork.Ring.csproj",
|
||||||
|
"env": {
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||||
|
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||||
|
"HTTP_PORTS": "8001",
|
||||||
|
"HTTPS_PORTS": "{ring.bindings.grpc.targetPort}",
|
||||||
|
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||||
|
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||||
|
"GRPC_PORT": "7002",
|
||||||
|
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||||
|
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||||
|
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||||
|
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||||
|
"OTEL_SERVICE_NAME": "ring"
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"http": {
|
||||||
|
"scheme": "http",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 8001
|
||||||
|
},
|
||||||
|
"grpc": {
|
||||||
|
"scheme": "https",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 7002
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pass": {
|
||||||
|
"type": "project.v1",
|
||||||
|
"path": "../DysonNetwork.Pass/DysonNetwork.Pass.csproj",
|
||||||
|
"env": {
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||||
|
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||||
|
"HTTP_PORTS": "8002",
|
||||||
|
"HTTPS_PORTS": "{pass.bindings.grpc.targetPort}",
|
||||||
|
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||||
|
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||||
|
"services__develop__http__0": "{develop.bindings.http.url}",
|
||||||
|
"services__develop__grpc__0": "{develop.bindings.grpc.url}",
|
||||||
|
"services__drive__http__0": "{drive.bindings.http.url}",
|
||||||
|
"services__drive__grpc__0": "{drive.bindings.grpc.url}",
|
||||||
|
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||||
|
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||||
|
"GRPC_PORT": "7003",
|
||||||
|
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||||
|
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||||
|
"OTEL_SERVICE_NAME": "pass"
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"http": {
|
||||||
|
"scheme": "http",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 8002
|
||||||
|
},
|
||||||
|
"grpc": {
|
||||||
|
"scheme": "https",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 7003
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"drive": {
|
||||||
|
"type": "project.v1",
|
||||||
|
"path": "../DysonNetwork.Drive/DysonNetwork.Drive.csproj",
|
||||||
|
"env": {
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||||
|
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||||
|
"HTTP_PORTS": "8003",
|
||||||
|
"HTTPS_PORTS": "{drive.bindings.grpc.targetPort}",
|
||||||
|
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||||
|
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||||
|
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||||
|
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||||
|
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||||
|
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||||
|
"GRPC_PORT": "7004",
|
||||||
|
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||||
|
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||||
|
"OTEL_SERVICE_NAME": "drive"
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"http": {
|
||||||
|
"scheme": "http",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 8003
|
||||||
|
},
|
||||||
|
"grpc": {
|
||||||
|
"scheme": "https",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 7004
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sphere": {
|
||||||
|
"type": "project.v1",
|
||||||
|
"path": "../DysonNetwork.Sphere/DysonNetwork.Sphere.csproj",
|
||||||
|
"env": {
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||||
|
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||||
|
"HTTP_PORTS": "8004",
|
||||||
|
"HTTPS_PORTS": "{sphere.bindings.grpc.targetPort}",
|
||||||
|
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||||
|
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||||
|
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||||
|
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||||
|
"services__drive__http__0": "{drive.bindings.http.url}",
|
||||||
|
"services__drive__grpc__0": "{drive.bindings.grpc.url}",
|
||||||
|
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||||
|
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||||
|
"GRPC_PORT": "7005",
|
||||||
|
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||||
|
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||||
|
"OTEL_SERVICE_NAME": "sphere"
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"http": {
|
||||||
|
"scheme": "http",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 8004
|
||||||
|
},
|
||||||
|
"grpc": {
|
||||||
|
"scheme": "https",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 7005
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"develop": {
|
||||||
|
"type": "project.v1",
|
||||||
|
"path": "../DysonNetwork.Develop/DysonNetwork.Develop.csproj",
|
||||||
|
"env": {
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||||
|
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||||
|
"HTTP_PORTS": "8005",
|
||||||
|
"HTTPS_PORTS": "{develop.bindings.grpc.targetPort}",
|
||||||
|
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||||
|
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||||
|
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||||
|
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||||
|
"services__sphere__http__0": "{sphere.bindings.http.url}",
|
||||||
|
"services__sphere__grpc__0": "{sphere.bindings.grpc.url}",
|
||||||
|
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||||
|
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||||
|
"GRPC_PORT": "7006",
|
||||||
|
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||||
|
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||||
|
"OTEL_SERVICE_NAME": "develop"
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"http": {
|
||||||
|
"scheme": "http",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 8005
|
||||||
|
},
|
||||||
|
"grpc": {
|
||||||
|
"scheme": "https",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 7006
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insight": {
|
||||||
|
"type": "project.v1",
|
||||||
|
"path": "../DysonNetwork.Insight/DysonNetwork.Insight.csproj",
|
||||||
|
"env": {
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||||
|
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||||
|
"HTTP_PORTS": "8006",
|
||||||
|
"HTTPS_PORTS": "{insight.bindings.grpc.targetPort}",
|
||||||
|
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||||
|
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||||
|
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||||
|
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||||
|
"services__sphere__http__0": "{sphere.bindings.http.url}",
|
||||||
|
"services__sphere__grpc__0": "{sphere.bindings.grpc.url}",
|
||||||
|
"services__develop__http__0": "{develop.bindings.http.url}",
|
||||||
|
"services__develop__grpc__0": "{develop.bindings.grpc.url}",
|
||||||
|
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||||
|
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||||
|
"GRPC_PORT": "7007",
|
||||||
|
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||||
|
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||||
|
"OTEL_SERVICE_NAME": "insight"
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"http": {
|
||||||
|
"scheme": "http",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 8006
|
||||||
|
},
|
||||||
|
"grpc": {
|
||||||
|
"scheme": "https",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 7007
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gateway": {
|
||||||
|
"type": "project.v1",
|
||||||
|
"path": "../DysonNetwork.Gateway/DysonNetwork.Gateway.csproj",
|
||||||
|
"env": {
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||||
|
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||||
|
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||||
|
"HTTP_PORTS": "5001",
|
||||||
|
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||||
|
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||||
|
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||||
|
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||||
|
"services__drive__http__0": "{drive.bindings.http.url}",
|
||||||
|
"services__drive__grpc__0": "{drive.bindings.grpc.url}",
|
||||||
|
"services__sphere__http__0": "{sphere.bindings.http.url}",
|
||||||
|
"services__sphere__grpc__0": "{sphere.bindings.grpc.url}",
|
||||||
|
"services__develop__http__0": "{develop.bindings.http.url}",
|
||||||
|
"services__develop__grpc__0": "{develop.bindings.grpc.url}",
|
||||||
|
"services__insight__http__0": "{insight.bindings.http.url}",
|
||||||
|
"services__insight__grpc__0": "{insight.bindings.grpc.url}",
|
||||||
|
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||||
|
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||||
|
"OTEL_SERVICE_NAME": "gateway"
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"http": {
|
||||||
|
"scheme": "http",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 5001
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"docker-compose": {
|
||||||
|
"error": "This resource does not support generation in the manifest."
|
||||||
|
},
|
||||||
|
"cache-password": {
|
||||||
|
"type": "parameter.v0",
|
||||||
|
"value": "{cache-password.inputs.value}",
|
||||||
|
"inputs": {
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"secret": true,
|
||||||
|
"default": {
|
||||||
|
"generate": {
|
||||||
|
"minLength": 22,
|
||||||
|
"special": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"queue-password": {
|
||||||
|
"type": "parameter.v0",
|
||||||
|
"value": "{queue-password.inputs.value}",
|
||||||
|
"inputs": {
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"secret": true,
|
||||||
|
"default": {
|
||||||
|
"generate": {
|
||||||
|
"minLength": 22,
|
||||||
|
"special": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"docker-compose-dashboard": {
|
||||||
|
"type": "container.v1",
|
||||||
|
"image": "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest",
|
||||||
|
"bindings": {
|
||||||
|
"http": {
|
||||||
|
"scheme": "http",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 18888
|
||||||
|
},
|
||||||
|
"otlp-grpc": {
|
||||||
|
"scheme": "http",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"transport": "http",
|
||||||
|
"targetPort": 18889
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
DysonNetwork.Develop/AppDatabase.cs
Normal file
61
DysonNetwork.Develop/AppDatabase.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
this.ApplyAuditableAndSoftDelete();
|
||||||
|
return await base.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.ApplySoftDeleteFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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:10.0 AS base
|
||||||
|
USER $APP_UID
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.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"]
|
||||||
32
DysonNetwork.Develop/DysonNetwork.Develop.csproj
Normal file
32
DysonNetwork.Develop/DysonNetwork.Develop.csproj
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
|
||||||
|
<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]
|
||||||
|
[AskPermission("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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
DysonNetwork.Develop/Properties/launchSettings.json
Normal file
21
DysonNetwork.Develop/Properties/launchSettings.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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<RemotePermissionMiddleware>();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.MapGrpcService<CustomAppServiceGrpc>();
|
||||||
|
app.MapGrpcReflectionService();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs
Normal file
60
DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
DysonNetwork.Develop/appsettings.json
Normal file
28
DysonNetwork.Develop/appsettings.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
|
"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
|
||||||
156
DysonNetwork.Drive/AppDatabase.cs
Normal file
156
DysonNetwork.Drive/AppDatabase.cs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using DysonNetwork.Drive.Billing;
|
||||||
|
using DysonNetwork.Drive.Storage;
|
||||||
|
using DysonNetwork.Drive.Storage.Model;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using NodaTime;
|
||||||
|
using Quartz;
|
||||||
|
using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive;
|
||||||
|
|
||||||
|
public class AppDatabase(
|
||||||
|
DbContextOptions<AppDatabase> options,
|
||||||
|
IConfiguration configuration
|
||||||
|
) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<FilePool> Pools { get; set; } = null!;
|
||||||
|
public DbSet<SnFileBundle> Bundles { get; set; } = null!;
|
||||||
|
|
||||||
|
public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!;
|
||||||
|
|
||||||
|
public DbSet<SnCloudFile> Files { get; set; } = null!;
|
||||||
|
public DbSet<SnCloudFileReference> FileReferences { get; set; } = null!;
|
||||||
|
public DbSet<SnCloudFileIndex> FileIndexes { get; set; }
|
||||||
|
|
||||||
|
public DbSet<PersistentTask> Tasks { get; set; } = null!;
|
||||||
|
public DbSet<PersistentUploadTask> UploadTasks { get; set; } = null!; // Backward compatibility
|
||||||
|
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
optionsBuilder.UseNpgsql(
|
||||||
|
configuration.GetConnectionString("App"),
|
||||||
|
opt => opt
|
||||||
|
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
||||||
|
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||||
|
.UseNodaTime()
|
||||||
|
).UseSnakeCaseNamingConvention();
|
||||||
|
|
||||||
|
base.OnConfiguring(optionsBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
modelBuilder.ApplySoftDeleteFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
this.ApplyAuditableAndSoftDelete();
|
||||||
|
return await base.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
logger.LogInformation("Deleting soft-deleted records...");
|
||||||
|
|
||||||
|
var threshold = now - Duration.FromDays(7);
|
||||||
|
|
||||||
|
var entityTypes = db.Model.GetEntityTypes()
|
||||||
|
.Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
|
||||||
|
.Select(t => t.ClrType);
|
||||||
|
|
||||||
|
foreach (var entityType in entityTypes)
|
||||||
|
{
|
||||||
|
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
|
||||||
|
.MakeGenericMethod(entityType).Invoke(db, null)!;
|
||||||
|
var parameter = Expression.Parameter(entityType, "e");
|
||||||
|
var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
|
||||||
|
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
|
||||||
|
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
|
||||||
|
var finalCondition = Expression.AndAlso(notNull, condition);
|
||||||
|
var lambda = Expression.Lambda(finalCondition, parameter);
|
||||||
|
|
||||||
|
var queryable = set.Provider.CreateQuery(
|
||||||
|
Expression.Call(
|
||||||
|
typeof(Queryable),
|
||||||
|
"Where",
|
||||||
|
[entityType],
|
||||||
|
set.Expression,
|
||||||
|
Expression.Quote(lambda)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
|
||||||
|
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
|
||||||
|
.MakeGenericMethod(entityType);
|
||||||
|
|
||||||
|
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
|
||||||
|
db.RemoveRange(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PersistentTaskCleanupJob(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<PersistentTaskCleanupJob> logger
|
||||||
|
) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Cleaning up stale persistent tasks...");
|
||||||
|
|
||||||
|
// Get the PersistentTaskService from DI
|
||||||
|
using var scope = serviceProvider.CreateScope();
|
||||||
|
var persistentTaskService = scope.ServiceProvider.GetService(typeof(PersistentTaskService));
|
||||||
|
|
||||||
|
if (persistentTaskService is PersistentTaskService service)
|
||||||
|
{
|
||||||
|
// Clean up tasks for all users (you might want to add user-specific logic here)
|
||||||
|
// For now, we'll clean up tasks older than 30 days for all users
|
||||||
|
var cutoff = SystemClock.Instance.GetCurrentInstant() - Duration.FromDays(30);
|
||||||
|
var tasksToClean = await service.GetUserTasksAsync(
|
||||||
|
Guid.Empty, // This would need to be adjusted for multi-user cleanup
|
||||||
|
status: TaskStatus.Completed | TaskStatus.Failed | TaskStatus.Cancelled | TaskStatus.Expired
|
||||||
|
);
|
||||||
|
|
||||||
|
var cleanedCount = 0;
|
||||||
|
foreach (var task in tasksToClean.Items.Where(t => t.UpdatedAt < cutoff))
|
||||||
|
{
|
||||||
|
await service.CancelTaskAsync(task.TaskId); // Or implement a proper cleanup method
|
||||||
|
cleanedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Cleaned up {Count} stale persistent tasks", cleanedCount);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("PersistentTaskService not found in DI container");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
DysonNetwork.Drive/Billing/Quota.cs
Normal file
28
DysonNetwork.Drive/Billing/Quota.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Billing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The quota record stands for the extra quota that a user has.
|
||||||
|
/// For normal users, the quota is 1GiB.
|
||||||
|
/// For stellar program t1 users, the quota is 5GiB
|
||||||
|
/// For stellar program t2 users, the quota is 10GiB
|
||||||
|
/// For stellar program t3 users, the quota is 15GiB
|
||||||
|
///
|
||||||
|
/// If users want to increase the quota, they need to pay for it.
|
||||||
|
/// Each 1NSD they paid for one GiB.
|
||||||
|
///
|
||||||
|
/// But the quota record unit is MiB, the minimal billable unit.
|
||||||
|
/// </summary>
|
||||||
|
public class QuotaRecord : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public long Quota { get; set; }
|
||||||
|
|
||||||
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
}
|
||||||
66
DysonNetwork.Drive/Billing/QuotaController.cs
Normal file
66
DysonNetwork.Drive/Billing/QuotaController.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Billing;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/api/billing/quota")]
|
||||||
|
public class QuotaController(AppDatabase db, QuotaService quota) : ControllerBase
|
||||||
|
{
|
||||||
|
public class QuotaDetails
|
||||||
|
{
|
||||||
|
public long BasedQuota { get; set; }
|
||||||
|
public long ExtraQuota { get; set; }
|
||||||
|
public long TotalQuota { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<QuotaDetails>> GetQuota()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var (based, extra) = await quota.GetQuotaVerbose(accountId);
|
||||||
|
return Ok(new QuotaDetails
|
||||||
|
{
|
||||||
|
BasedQuota = based,
|
||||||
|
ExtraQuota = extra,
|
||||||
|
TotalQuota = based + extra
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("records")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<QuotaRecord>>> GetQuotaRecords(
|
||||||
|
[FromQuery] bool expired = false,
|
||||||
|
[FromQuery] int offset = 0,
|
||||||
|
[FromQuery] int take = 20
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var query = db.QuotaRecords
|
||||||
|
.Where(r => r.AccountId == accountId)
|
||||||
|
.AsQueryable();
|
||||||
|
if (!expired)
|
||||||
|
query = query
|
||||||
|
.Where(r => !r.ExpiredAt.HasValue || r.ExpiredAt > now);
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
Response.Headers.Append("X-Total", total.ToString());
|
||||||
|
|
||||||
|
var records = await query
|
||||||
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(records);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
DysonNetwork.Drive/Billing/QuotaService.cs
Normal file
69
DysonNetwork.Drive/Billing/QuotaService.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using DysonNetwork.Shared.Auth;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Billing;
|
||||||
|
|
||||||
|
public class QuotaService(
|
||||||
|
AppDatabase db,
|
||||||
|
UsageService usage,
|
||||||
|
AccountService.AccountServiceClient accounts,
|
||||||
|
ICacheService cache
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public async Task<(bool ok, long billable, long quota)> IsFileAcceptable(Guid accountId, double costMultiplier, long newFileSize)
|
||||||
|
{
|
||||||
|
// The billable unit is MiB
|
||||||
|
var billableUnit = (long)Math.Ceiling(newFileSize / 1024.0 / 1024.0 * costMultiplier);
|
||||||
|
var totalBillableUsage = await usage.GetTotalBillableUsage(accountId);
|
||||||
|
var quota = await GetQuota(accountId);
|
||||||
|
return (totalBillableUsage + billableUnit <= quota, billableUnit, quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> GetQuota(Guid accountId)
|
||||||
|
{
|
||||||
|
var cacheKey = $"file:quota:{accountId}";
|
||||||
|
var cachedResult = await cache.GetAsync<long?>(cacheKey);
|
||||||
|
if (cachedResult.HasValue) return cachedResult.Value;
|
||||||
|
|
||||||
|
var (based, extra) = await GetQuotaVerbose(accountId);
|
||||||
|
var quota = based + extra;
|
||||||
|
await cache.SetAsync(cacheKey, quota, expiry: TimeSpan.FromMinutes(30));
|
||||||
|
return quota;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(long based, long extra)> GetQuotaVerbose(Guid accountId)
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
var response = await accounts.GetAccountAsync(new GetAccountRequest { Id = accountId.ToString() });
|
||||||
|
var perkSubscription = response.PerkSubscription;
|
||||||
|
|
||||||
|
// The base quota is 1GiB, T1 is 5GiB, T2 is 10GiB, T3 is 15GiB
|
||||||
|
var basedQuota = 1L;
|
||||||
|
if (perkSubscription != null)
|
||||||
|
{
|
||||||
|
var privilege = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(perkSubscription.Identifier);
|
||||||
|
basedQuota = privilege switch
|
||||||
|
{
|
||||||
|
1 => 5L,
|
||||||
|
2 => 10L,
|
||||||
|
3 => 15L,
|
||||||
|
_ => basedQuota
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The based quota is in GiB, we need to convert it to MiB
|
||||||
|
basedQuota *= 1024L;
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var extraQuota = await db.QuotaRecords
|
||||||
|
.Where(e => e.AccountId == accountId)
|
||||||
|
.Where(e => !e.ExpiredAt.HasValue || e.ExpiredAt > now)
|
||||||
|
.SumAsync(e => e.Quota);
|
||||||
|
|
||||||
|
return (basedQuota, extraQuota);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
DysonNetwork.Drive/Billing/UsageController.cs
Normal file
49
DysonNetwork.Drive/Billing/UsageController.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Billing;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/billing/usage")]
|
||||||
|
public class UsageController(UsageService usage, QuotaService quota, ICacheService cache) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<TotalUsageDetails>> GetTotalUsage()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var cacheKey = $"file:usage:{accountId}";
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
var (found, cachedResult) = await cache.GetAsyncWithStatus<TotalUsageDetails>(cacheKey);
|
||||||
|
if (found && cachedResult != null)
|
||||||
|
return Ok(cachedResult);
|
||||||
|
|
||||||
|
// If not in cache, get from services
|
||||||
|
var result = await usage.GetTotalUsage(accountId);
|
||||||
|
var totalQuota = await quota.GetQuota(accountId);
|
||||||
|
result.TotalQuota = totalQuota;
|
||||||
|
|
||||||
|
// Cache the result for 5 minutes
|
||||||
|
await cache.SetAsync(cacheKey, result, TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpGet("{poolId:guid}")]
|
||||||
|
public async Task<ActionResult<UsageDetails>> GetPoolUsage(Guid poolId)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var usageDetails = await usage.GetPoolUsage(poolId, accountId);
|
||||||
|
if (usageDetails == null)
|
||||||
|
return NotFound();
|
||||||
|
return usageDetails;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
DysonNetwork.Drive/Billing/UsageService.cs
Normal file
121
DysonNetwork.Drive/Billing/UsageService.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Billing;
|
||||||
|
|
||||||
|
public class UsageDetails
|
||||||
|
{
|
||||||
|
public required Guid PoolId { get; set; }
|
||||||
|
public required string PoolName { get; set; }
|
||||||
|
public required long UsageBytes { get; set; }
|
||||||
|
public required double Cost { get; set; }
|
||||||
|
public required long FileCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TotalUsageDetails
|
||||||
|
{
|
||||||
|
public required List<UsageDetails> PoolUsages { get; set; }
|
||||||
|
public required long TotalUsageBytes { get; set; }
|
||||||
|
public required long TotalFileCount { get; set; }
|
||||||
|
|
||||||
|
// Quota, cannot be loaded in the service, cause circular dependency
|
||||||
|
// Let the controller do the calculation
|
||||||
|
public long? TotalQuota { get; set; }
|
||||||
|
public long? UsedQuota { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UsageService(AppDatabase db)
|
||||||
|
{
|
||||||
|
public async Task<TotalUsageDetails> GetTotalUsage(Guid accountId)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var fileQuery = db.Files
|
||||||
|
.Where(f => !f.IsMarkedRecycle)
|
||||||
|
.Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now)
|
||||||
|
.Where(f => f.AccountId == accountId)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
var poolUsages = await db.Pools
|
||||||
|
.Select(p => new UsageDetails
|
||||||
|
{
|
||||||
|
PoolId = p.Id,
|
||||||
|
PoolName = p.Name,
|
||||||
|
UsageBytes = fileQuery
|
||||||
|
.Where(f => f.PoolId == p.Id)
|
||||||
|
.Sum(f => f.Size),
|
||||||
|
Cost = fileQuery
|
||||||
|
.Where(f => f.PoolId == p.Id)
|
||||||
|
.Sum(f => f.Size) / 1024.0 / 1024.0 *
|
||||||
|
(p.BillingConfig.CostMultiplier ?? 1.0),
|
||||||
|
FileCount = fileQuery
|
||||||
|
.Count(f => f.PoolId == p.Id)
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var totalUsage = poolUsages.Sum(p => p.UsageBytes);
|
||||||
|
var totalFileCount = poolUsages.Sum(p => p.FileCount);
|
||||||
|
|
||||||
|
return new TotalUsageDetails
|
||||||
|
{
|
||||||
|
PoolUsages = poolUsages,
|
||||||
|
TotalUsageBytes = totalUsage,
|
||||||
|
TotalFileCount = totalFileCount,
|
||||||
|
UsedQuota = await GetTotalBillableUsage(accountId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UsageDetails?> GetPoolUsage(Guid poolId, Guid accountId)
|
||||||
|
{
|
||||||
|
var pool = await db.Pools.FindAsync(poolId);
|
||||||
|
if (pool == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var fileQuery = db.Files
|
||||||
|
.Where(f => !f.IsMarkedRecycle)
|
||||||
|
.Where(f => f.ExpiredAt.HasValue && f.ExpiredAt > now)
|
||||||
|
.Where(f => f.AccountId == accountId)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
var usageBytes = await fileQuery
|
||||||
|
.SumAsync(f => f.Size);
|
||||||
|
|
||||||
|
var fileCount = await fileQuery
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
var cost = usageBytes / 1024.0 / 1024.0 *
|
||||||
|
(pool.BillingConfig.CostMultiplier ?? 1.0);
|
||||||
|
|
||||||
|
return new UsageDetails
|
||||||
|
{
|
||||||
|
PoolId = pool.Id,
|
||||||
|
PoolName = pool.Name,
|
||||||
|
UsageBytes = usageBytes,
|
||||||
|
Cost = cost,
|
||||||
|
FileCount = fileCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> GetTotalBillableUsage(Guid accountId)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var files = await db.Files
|
||||||
|
.Where(f => f.AccountId == accountId)
|
||||||
|
.Where(f => f.PoolId.HasValue)
|
||||||
|
.Where(f => !f.IsMarkedRecycle)
|
||||||
|
.Include(f => f.Pool)
|
||||||
|
.Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now)
|
||||||
|
.Select(f => new
|
||||||
|
{
|
||||||
|
f.Size,
|
||||||
|
Multiplier = f.Pool!.BillingConfig.CostMultiplier ?? 1.0
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var totalCost = files.Sum(f => f.Size * f.Multiplier) / 1024.0 / 1024.0;
|
||||||
|
|
||||||
|
return (long)Math.Ceiling(totalCost);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
DysonNetwork.Drive/Dockerfile
Normal file
42
DysonNetwork.Drive/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.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:10.0 AS build
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["DysonNetwork.Drive/DysonNetwork.Drive.csproj", "DysonNetwork.Drive/"]
|
||||||
|
RUN dotnet restore "DysonNetwork.Drive/DysonNetwork.Drive.csproj"
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
WORKDIR "/src/DysonNetwork.Drive"
|
||||||
|
RUN dotnet build "./DysonNetwork.Drive.csproj" -c $BUILD_CONFIGURATION -o /app/build \
|
||||||
|
-p:TypeScriptCompileBlocked=true \
|
||||||
|
-p:UseRazorBuildServer=false
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "./DysonNetwork.Drive.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "DysonNetwork.Drive.dll"]
|
||||||
53
DysonNetwork.Drive/DysonNetwork.Drive.csproj
Normal file
53
DysonNetwork.Drive/DysonNetwork.Drive.csproj
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
|
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
|
||||||
|
<PackageReference Include="FFMpegCore" Version="5.4.0" />
|
||||||
|
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</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="7.0.0" />
|
||||||
|
<PackageReference Include="Nanoid" Version="3.1.0" />
|
||||||
|
<PackageReference Include="NetVips" Version="3.1.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="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.PostgreSql" Version="9.0.2" />
|
||||||
|
<!-- 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" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\.dockerignore">
|
||||||
|
<Link>.dockerignore</Link>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
585
DysonNetwork.Drive/Index/FileIndexController.cs
Normal file
585
DysonNetwork.Drive/Index/FileIndexController.cs
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Drive.Storage;
|
||||||
|
using DysonNetwork.Shared.Auth;
|
||||||
|
using DysonNetwork.Shared.Http;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Index;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/api/index")]
|
||||||
|
[Authorize]
|
||||||
|
public class FileIndexController(
|
||||||
|
FileIndexService fileIndexService,
|
||||||
|
AppDatabase db,
|
||||||
|
ILogger<FileIndexController> logger
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets files in a specific path for the current user
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to browse (defaults to root "/")</param>
|
||||||
|
/// <param name="query">Optional query to filter files by name</param>
|
||||||
|
/// <param name="order">The field to order by (date, size, name - defaults to date)</param>
|
||||||
|
/// <param name="orderDesc">Whether to order in descending order (defaults to true)</param>
|
||||||
|
/// <returns>List of files in the specified path</returns>
|
||||||
|
[HttpGet("browse")]
|
||||||
|
public async Task<IActionResult> BrowseFiles(
|
||||||
|
[FromQuery] string path = "/",
|
||||||
|
[FromQuery] string? query = null,
|
||||||
|
[FromQuery] string order = "date",
|
||||||
|
[FromQuery] bool orderDesc = true
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileIndexes = await fileIndexService.GetByPathAsync(accountId, path);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
fileIndexes = fileIndexes
|
||||||
|
.Where(fi => fi.File.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
fileIndexes = order.ToLower() switch
|
||||||
|
{
|
||||||
|
"name" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Name).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.Name).ToList(),
|
||||||
|
"size" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Size).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.Size).ToList(),
|
||||||
|
_ => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.CreatedAt).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.CreatedAt).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all file indexes for this account to extract child folders
|
||||||
|
var allFileIndexes = await fileIndexService.GetByAccountIdAsync(accountId);
|
||||||
|
|
||||||
|
// Extract unique child folder paths
|
||||||
|
var childFolders = ExtractChildFolders(allFileIndexes, path);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Path = path,
|
||||||
|
Files = fileIndexes,
|
||||||
|
Folders = childFolders,
|
||||||
|
TotalCount = fileIndexes.Count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to browse files for account {AccountId} at path {Path}", accountId, path);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "BROWSE_FAILED",
|
||||||
|
Message = "Failed to browse files",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts unique child folder paths from all file indexes for a given parent path
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="allFileIndexes">All file indexes for the account</param>
|
||||||
|
/// <param name="parentPath">The parent path to find children for</param>
|
||||||
|
/// <returns>List of unique child folder names</returns>
|
||||||
|
private List<string> ExtractChildFolders(List<SnCloudFileIndex> allFileIndexes, string parentPath)
|
||||||
|
{
|
||||||
|
var normalizedParentPath = FileIndexService.NormalizePath(parentPath);
|
||||||
|
var childFolders = new HashSet<string>();
|
||||||
|
|
||||||
|
foreach (var index in allFileIndexes)
|
||||||
|
{
|
||||||
|
var normalizedIndexPath = FileIndexService.NormalizePath(index.Path);
|
||||||
|
|
||||||
|
// Check if this path is a direct child of the parent path
|
||||||
|
if (normalizedIndexPath.StartsWith(normalizedParentPath) &&
|
||||||
|
normalizedIndexPath != normalizedParentPath)
|
||||||
|
{
|
||||||
|
// Remove the parent path prefix to get the relative path
|
||||||
|
var relativePath = normalizedIndexPath.Substring(normalizedParentPath.Length);
|
||||||
|
|
||||||
|
// Extract the first folder name (direct child)
|
||||||
|
var firstSlashIndex = relativePath.IndexOf('/');
|
||||||
|
if (firstSlashIndex > 0)
|
||||||
|
{
|
||||||
|
var folderName = relativePath.Substring(0, firstSlashIndex);
|
||||||
|
childFolders.Add(folderName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return childFolders.OrderBy(f => f).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all files for the current user (across all paths)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">Optional query to filter files by name</param>
|
||||||
|
/// <param name="order">The field to order by (date, size, name - defaults to date)</param>
|
||||||
|
/// <param name="orderDesc">Whether to order in descending order (defaults to true)</param>
|
||||||
|
/// <returns>List of all files for the user</returns>
|
||||||
|
[HttpGet("all")]
|
||||||
|
public async Task<IActionResult> GetAllFiles(
|
||||||
|
[FromQuery] string? query = null,
|
||||||
|
[FromQuery] string order = "date",
|
||||||
|
[FromQuery] bool orderDesc = true
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileIndexes = await fileIndexService.GetByAccountIdAsync(accountId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
fileIndexes = fileIndexes
|
||||||
|
.Where(fi => fi.File.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
fileIndexes = order.ToLower() switch
|
||||||
|
{
|
||||||
|
"name" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Name).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.Name).ToList(),
|
||||||
|
"size" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Size).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.Size).ToList(),
|
||||||
|
_ => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.CreatedAt).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.CreatedAt).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Files = fileIndexes,
|
||||||
|
TotalCount = fileIndexes.Count()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get all files for account {AccountId}", accountId);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "GET_ALL_FAILED",
|
||||||
|
Message = "Failed to get files",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets files that have not been indexed for the current user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="recycled">Shows recycled files or not</param>
|
||||||
|
/// <param name="offset">The number of files to skip</param>
|
||||||
|
/// <param name="take">The number of files to return</param>
|
||||||
|
/// <param name="pool">The pool ID of those files</param>
|
||||||
|
/// <param name="query">Optional query to filter files by name</param>
|
||||||
|
/// <param name="order">The field to order by (date, size, name - defaults to date)</param>
|
||||||
|
/// <param name="orderDesc">Whether to order in descending order (defaults to true)</param>
|
||||||
|
/// <returns>List of unindexed files</returns>
|
||||||
|
[HttpGet("unindexed")]
|
||||||
|
public async Task<IActionResult> GetUnindexedFiles(
|
||||||
|
[FromQuery] Guid? pool,
|
||||||
|
[FromQuery] bool recycled = false,
|
||||||
|
[FromQuery] int offset = 0,
|
||||||
|
[FromQuery] int take = 20,
|
||||||
|
[FromQuery] string? query = null,
|
||||||
|
[FromQuery] string order = "date",
|
||||||
|
[FromQuery] bool orderDesc = true
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filesQuery = db.Files
|
||||||
|
.Where(f => f.AccountId == accountId
|
||||||
|
&& f.IsMarkedRecycle == recycled
|
||||||
|
&& !db.FileIndexes.Any(fi => fi.FileId == f.Id && fi.AccountId == accountId)
|
||||||
|
)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
filesQuery = order.ToLower() switch
|
||||||
|
{
|
||||||
|
"name" => orderDesc ? filesQuery.OrderByDescending(f => f.Name)
|
||||||
|
: filesQuery.OrderBy(f => f.Name),
|
||||||
|
"size" => orderDesc ? filesQuery.OrderByDescending(f => f.Size)
|
||||||
|
: filesQuery.OrderBy(f => f.Size),
|
||||||
|
_ => orderDesc ? filesQuery.OrderByDescending(f => f.CreatedAt)
|
||||||
|
: filesQuery.OrderBy(f => f.CreatedAt)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pool.HasValue) filesQuery = filesQuery.Where(f => f.PoolId == pool);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
filesQuery = filesQuery.Where(f => f.Name.Contains(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await filesQuery.CountAsync();
|
||||||
|
|
||||||
|
Response.Headers.Append("X-Total", totalCount.ToString());
|
||||||
|
|
||||||
|
var unindexedFiles = await filesQuery
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(unindexedFiles);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get unindexed files for account {AccountId}", accountId);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "GET_UNINDEXED_FAILED",
|
||||||
|
Message = "Failed to get unindexed files",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves a file to a new path
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="indexId">The file index ID</param>
|
||||||
|
/// <param name="newPath">The new path</param>
|
||||||
|
/// <returns>The updated file index</returns>
|
||||||
|
[HttpPost("move/{indexId}")]
|
||||||
|
public async Task<IActionResult> MoveFile(Guid indexId, [FromBody] MoveFileRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verify ownership
|
||||||
|
var existingIndex = await db.FileIndexes
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.FirstOrDefaultAsync(fi => fi.Id == indexId && fi.AccountId == accountId);
|
||||||
|
|
||||||
|
if (existingIndex == null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
var updatedIndex = await fileIndexService.UpdateAsync(indexId, request.NewPath);
|
||||||
|
|
||||||
|
if (updatedIndex == null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
updatedIndex.FileId,
|
||||||
|
IndexId = updatedIndex.Id,
|
||||||
|
OldPath = existingIndex.Path,
|
||||||
|
NewPath = updatedIndex.Path,
|
||||||
|
Message = "File moved successfully"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to move file index {IndexId} for account {AccountId}", indexId, accountId);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "MOVE_FAILED",
|
||||||
|
Message = "Failed to move file",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a file index (does not delete the actual file by default)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="indexId">The file index ID</param>
|
||||||
|
/// <param name="deleteFile">Whether to also delete the actual file data</param>
|
||||||
|
/// <returns>Success message</returns>
|
||||||
|
[HttpDelete("remove/{indexId}")]
|
||||||
|
public async Task<IActionResult> RemoveFileIndex(Guid indexId, [FromQuery] bool deleteFile = false)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verify ownership
|
||||||
|
var existingIndex = await db.FileIndexes
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.FirstOrDefaultAsync(fi => fi.Id == indexId && fi.AccountId == accountId);
|
||||||
|
|
||||||
|
if (existingIndex == null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
var fileId = existingIndex.FileId;
|
||||||
|
var fileName = existingIndex.File.Name;
|
||||||
|
var filePath = existingIndex.Path;
|
||||||
|
|
||||||
|
// Remove the index
|
||||||
|
var removed = await fileIndexService.RemoveAsync(indexId);
|
||||||
|
|
||||||
|
if (!removed)
|
||||||
|
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
// Optionally delete the actual file
|
||||||
|
if (!deleteFile)
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Message = deleteFile
|
||||||
|
? "File index and file data removed successfully"
|
||||||
|
: "File index removed successfully",
|
||||||
|
FileId = fileId,
|
||||||
|
FileName = fileName,
|
||||||
|
Path = filePath,
|
||||||
|
FileDataDeleted = deleteFile
|
||||||
|
});
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check if there are any other indexes for this file
|
||||||
|
var remainingIndexes = await fileIndexService.GetByFileIdAsync(fileId);
|
||||||
|
if (remainingIndexes.Count == 0)
|
||||||
|
{
|
||||||
|
// No other indexes exist, safe to delete the file
|
||||||
|
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId.ToString());
|
||||||
|
if (file != null)
|
||||||
|
{
|
||||||
|
db.Files.Remove(file);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Deleted file {FileId} ({FileName}) as requested", fileId, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to delete file {FileId} while removing index", fileId);
|
||||||
|
// Continue even if file deletion fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Message = deleteFile
|
||||||
|
? "File index and file data removed successfully"
|
||||||
|
: "File index removed successfully",
|
||||||
|
FileId = fileId,
|
||||||
|
FileName = fileName,
|
||||||
|
Path = filePath,
|
||||||
|
FileDataDeleted = deleteFile
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to remove file index {IndexId} for account {AccountId}", indexId, accountId);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "REMOVE_FAILED",
|
||||||
|
Message = "Failed to remove file",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes all file indexes in a specific path
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to clear</param>
|
||||||
|
/// <param name="deleteFiles">Whether to also delete the actual file data</param>
|
||||||
|
/// <returns>Success message with count of removed items</returns>
|
||||||
|
[HttpDelete("clear-path")]
|
||||||
|
public async Task<IActionResult> ClearPath([FromQuery] string path = "/", [FromQuery] bool deleteFiles = false)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var removedCount = await fileIndexService.RemoveByPathAsync(accountId, path);
|
||||||
|
|
||||||
|
if (!deleteFiles || removedCount <= 0)
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Message = deleteFiles
|
||||||
|
? $"Cleared {removedCount} file indexes from path and deleted orphaned files"
|
||||||
|
: $"Cleared {removedCount} file indexes from path",
|
||||||
|
Path = path,
|
||||||
|
RemovedCount = removedCount,
|
||||||
|
FilesDeleted = deleteFiles
|
||||||
|
});
|
||||||
|
// Get the files that were in this path and check if they have other indexes
|
||||||
|
var filesInPath = await fileIndexService.GetByPathAsync(accountId, path);
|
||||||
|
var fileIdsToCheck = filesInPath.Select(fi => fi.FileId).Distinct().ToList();
|
||||||
|
|
||||||
|
foreach (var fileId in fileIdsToCheck)
|
||||||
|
{
|
||||||
|
var remainingIndexes = await fileIndexService.GetByFileIdAsync(fileId);
|
||||||
|
if (remainingIndexes.Count != 0) continue;
|
||||||
|
// No other indexes exist, safe to delete the file
|
||||||
|
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId.ToString());
|
||||||
|
if (file == null) continue;
|
||||||
|
db.Files.Remove(file);
|
||||||
|
logger.LogInformation("Deleted orphaned file {FileId} after clearing path {Path}", fileId, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Message = deleteFiles
|
||||||
|
? $"Cleared {removedCount} file indexes from path and deleted orphaned files"
|
||||||
|
: $"Cleared {removedCount} file indexes from path",
|
||||||
|
Path = path,
|
||||||
|
RemovedCount = removedCount,
|
||||||
|
FilesDeleted = deleteFiles
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to clear path {Path} for account {AccountId}", path, accountId);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "CLEAR_PATH_FAILED",
|
||||||
|
Message = "Failed to clear path",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new file index (useful for adding existing files to a path)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The create index request</param>
|
||||||
|
/// <returns>The created file index</returns>
|
||||||
|
[HttpPost("create")]
|
||||||
|
public async Task<IActionResult> CreateFileIndex([FromBody] CreateFileIndexRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verify the file exists and belongs to the user
|
||||||
|
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == request.FileId);
|
||||||
|
if (file == null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("File")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
if (file.AccountId != accountId)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
// Check if index already exists for this file and path
|
||||||
|
var existingIndex = await db.FileIndexes
|
||||||
|
.FirstOrDefaultAsync(fi =>
|
||||||
|
fi.FileId == request.FileId && fi.Path == request.Path && fi.AccountId == accountId);
|
||||||
|
|
||||||
|
if (existingIndex != null)
|
||||||
|
return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
{ "fileId", ["File index already exists for this path"] }
|
||||||
|
})) { StatusCode = 400 };
|
||||||
|
|
||||||
|
var fileIndex = await fileIndexService.CreateAsync(request.Path, request.FileId, accountId);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
IndexId = fileIndex.Id,
|
||||||
|
fileIndex.FileId,
|
||||||
|
fileIndex.Path,
|
||||||
|
Message = "File index created successfully"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to create file index for file {FileId} at path {Path} for account {AccountId}",
|
||||||
|
request.FileId, request.Path, accountId);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "CREATE_INDEX_FAILED",
|
||||||
|
Message = "Failed to create file index",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for files by name or metadata
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The search query</param>
|
||||||
|
/// <param name="path">Optional path to limit search to</param>
|
||||||
|
/// <returns>Matching files</returns>
|
||||||
|
[HttpGet("search")]
|
||||||
|
public async Task<IActionResult> SearchFiles([FromQuery] string query, [FromQuery] string? path = null)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Build the query with all conditions at once
|
||||||
|
var searchTerm = query.ToLower();
|
||||||
|
var fileIndexes = await db.FileIndexes
|
||||||
|
.Where(fi => fi.AccountId == accountId)
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.Where(fi =>
|
||||||
|
(string.IsNullOrEmpty(path) || fi.Path == FileIndexService.NormalizePath(path)) &&
|
||||||
|
(fi.File.Name.ToLower().Contains(searchTerm) ||
|
||||||
|
(fi.File.Description != null && fi.File.Description.ToLower().Contains(searchTerm)) ||
|
||||||
|
(fi.File.MimeType != null && fi.File.MimeType.ToLower().Contains(searchTerm))))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Query = query,
|
||||||
|
Path = path,
|
||||||
|
Results = fileIndexes,
|
||||||
|
TotalCount = fileIndexes.Count()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to search files for account {AccountId} with query {Query}", accountId, query);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "SEARCH_FAILED",
|
||||||
|
Message = "Failed to search files",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MoveFileRequest
|
||||||
|
{
|
||||||
|
public string NewPath { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateFileIndexRequest
|
||||||
|
{
|
||||||
|
[MaxLength(32)] public string FileId { get; set; } = null!;
|
||||||
|
public string Path { get; set; } = null!;
|
||||||
|
}
|
||||||
197
DysonNetwork.Drive/Index/FileIndexService.cs
Normal file
197
DysonNetwork.Drive/Index/FileIndexService.cs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Index;
|
||||||
|
|
||||||
|
public class FileIndexService(AppDatabase db)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new file index entry
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The parent folder path with a trailing slash</param>
|
||||||
|
/// <param name="fileId">The file ID</param>
|
||||||
|
/// <param name="accountId">The account ID</param>
|
||||||
|
/// <returns>The created file index</returns>
|
||||||
|
public async Task<SnCloudFileIndex> CreateAsync(string path, string fileId, Guid accountId)
|
||||||
|
{
|
||||||
|
// Ensure a path has a trailing slash and is query-safe
|
||||||
|
var normalizedPath = NormalizePath(path);
|
||||||
|
|
||||||
|
// Check if a file with the same name already exists in the same path for this account
|
||||||
|
var existingFileIndex = await db.FileIndexes
|
||||||
|
.FirstOrDefaultAsync(fi => fi.AccountId == accountId && fi.Path == normalizedPath && fi.FileId == fileId);
|
||||||
|
|
||||||
|
if (existingFileIndex != null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"A file with ID '{fileId}' already exists in path '{normalizedPath}' for account '{accountId}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileIndex = new SnCloudFileIndex
|
||||||
|
{
|
||||||
|
Path = normalizedPath,
|
||||||
|
FileId = fileId,
|
||||||
|
AccountId = accountId
|
||||||
|
};
|
||||||
|
|
||||||
|
db.FileIndexes.Add(fileIndex);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return fileIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing file index entry by removing the old one and creating a new one
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The file index ID</param>
|
||||||
|
/// <param name="newPath">The new parent folder path with trailing slash</param>
|
||||||
|
/// <returns>The updated file index</returns>
|
||||||
|
public async Task<SnCloudFileIndex?> UpdateAsync(Guid id, string newPath)
|
||||||
|
{
|
||||||
|
var fileIndex = await db.FileIndexes.FindAsync(id);
|
||||||
|
if (fileIndex == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Since properties are init-only, we need to remove the old index and create a new one
|
||||||
|
db.FileIndexes.Remove(fileIndex);
|
||||||
|
|
||||||
|
var newFileIndex = new SnCloudFileIndex
|
||||||
|
{
|
||||||
|
Path = NormalizePath(newPath),
|
||||||
|
FileId = fileIndex.FileId,
|
||||||
|
AccountId = fileIndex.AccountId
|
||||||
|
};
|
||||||
|
|
||||||
|
db.FileIndexes.Add(newFileIndex);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return newFileIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a file index entry by ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The file index ID</param>
|
||||||
|
/// <returns>True if the index was found and removed, false otherwise</returns>
|
||||||
|
public async Task<bool> RemoveAsync(Guid id)
|
||||||
|
{
|
||||||
|
var fileIndex = await db.FileIndexes.FindAsync(id);
|
||||||
|
if (fileIndex == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
db.FileIndexes.Remove(fileIndex);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes file index entries by file ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileId">The file ID</param>
|
||||||
|
/// <returns>The number of indexes removed</returns>
|
||||||
|
public async Task<int> RemoveByFileIdAsync(string fileId)
|
||||||
|
{
|
||||||
|
var indexes = await db.FileIndexes
|
||||||
|
.Where(fi => fi.FileId == fileId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (indexes.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
db.FileIndexes.RemoveRange(indexes);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return indexes.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes file index entries by account ID and path
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accountId">The account ID</param>
|
||||||
|
/// <param name="path">The parent folder path</param>
|
||||||
|
/// <returns>The number of indexes removed</returns>
|
||||||
|
public async Task<int> RemoveByPathAsync(Guid accountId, string path)
|
||||||
|
{
|
||||||
|
var normalizedPath = NormalizePath(path);
|
||||||
|
|
||||||
|
var indexes = await db.FileIndexes
|
||||||
|
.Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (!indexes.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
db.FileIndexes.RemoveRange(indexes);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return indexes.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets file indexes by account ID and path
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accountId">The account ID</param>
|
||||||
|
/// <param name="path">The parent folder path</param>
|
||||||
|
/// <returns>List of file indexes</returns>
|
||||||
|
public async Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path)
|
||||||
|
{
|
||||||
|
var normalizedPath = NormalizePath(path);
|
||||||
|
|
||||||
|
return await db.FileIndexes
|
||||||
|
.Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets file indexes by file ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileId">The file ID</param>
|
||||||
|
/// <returns>List of file indexes</returns>
|
||||||
|
public async Task<List<SnCloudFileIndex>> GetByFileIdAsync(string fileId)
|
||||||
|
{
|
||||||
|
return await db.FileIndexes
|
||||||
|
.Where(fi => fi.FileId == fileId)
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all file indexes for an account
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accountId">The account ID</param>
|
||||||
|
/// <returns>List of file indexes</returns>
|
||||||
|
public async Task<List<SnCloudFileIndex>> GetByAccountIdAsync(Guid accountId)
|
||||||
|
{
|
||||||
|
return await db.FileIndexes
|
||||||
|
.Where(fi => fi.AccountId == accountId)
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes the path to ensure it has a trailing slash and is query-safe
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The original path</param>
|
||||||
|
/// <returns>The normalized path</returns>
|
||||||
|
public static string NormalizePath(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
return "/";
|
||||||
|
|
||||||
|
// Ensure the path starts with a slash
|
||||||
|
if (!path.StartsWith('/'))
|
||||||
|
path = "/" + path;
|
||||||
|
|
||||||
|
// Ensure the path ends with a slash (unless it's just the root)
|
||||||
|
if (path != "/" && !path.EndsWith('/'))
|
||||||
|
path += "/";
|
||||||
|
|
||||||
|
// Make path query-safe by removing problematic characters
|
||||||
|
// This is a basic implementation - you might want to add more robust validation
|
||||||
|
path = path.Replace("%", "").Replace("'", "").Replace("\"", "");
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
341
DysonNetwork.Drive/Index/README.md
Normal file
341
DysonNetwork.Drive/Index/README.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# File Indexing System Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The File Indexing System provides a hierarchical file organization layer on top of the existing file storage system in DysonNetwork Drive. It allows users to organize their files in folders and paths while maintaining the underlying file storage capabilities.
|
||||||
|
|
||||||
|
When using with the gateway, replace the `/api` with the `/drive` in the path.
|
||||||
|
And all the arguments will be transformed into snake case via the gateway.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
1. **SnCloudFileIndex Model** - Represents the file-to-path mapping
|
||||||
|
2. **FileIndexService** - Business logic for file index operations
|
||||||
|
3. **FileIndexController** - REST API endpoints for file management
|
||||||
|
4. **FileUploadController Integration** - Automatic index creation during upload
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- File Indexes table
|
||||||
|
CREATE TABLE "FileIndexes" (
|
||||||
|
"Id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"Path" character varying(8192) NOT NULL,
|
||||||
|
"FileId" uuid NOT NULL,
|
||||||
|
"AccountId" uuid NOT NULL,
|
||||||
|
"CreatedAt" timestamp with time zone NOT NULL DEFAULT (now() at time zone 'utc'),
|
||||||
|
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT (now() at time zone 'utc'),
|
||||||
|
CONSTRAINT "PK_FileIndexes" PRIMARY KEY ("Id"),
|
||||||
|
CONSTRAINT "FK_FileIndexes_Files_FileId" FOREIGN KEY ("FileId") REFERENCES "Files" ("Id") ON DELETE CASCADE,
|
||||||
|
INDEX "IX_FileIndexes_Path_AccountId" ("Path", "AccountId")
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Browse Files
|
||||||
|
**GET** `/api/index/browse?path=/documents/`
|
||||||
|
|
||||||
|
Browse files in a specific path.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `path` (optional, default: "/") - The path to browse
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "/documents/",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "guid",
|
||||||
|
"path": "/documents/",
|
||||||
|
"fileId": "guid",
|
||||||
|
"accountId": "guid",
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"file": {
|
||||||
|
"id": "string",
|
||||||
|
"name": "document.pdf",
|
||||||
|
"size": 1024,
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"hash": "sha256-hash",
|
||||||
|
"uploadedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"expiredAt": null,
|
||||||
|
"hasCompression": false,
|
||||||
|
"hasThumbnail": true,
|
||||||
|
"isEncrypted": false,
|
||||||
|
"description": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalCount": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get All Files
|
||||||
|
**GET** `/api/index/all`
|
||||||
|
|
||||||
|
Get all files for the current user across all paths.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
// Same structure as browse endpoint
|
||||||
|
],
|
||||||
|
"totalCount": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Move File
|
||||||
|
**POST** `/api/index/move/{indexId}`
|
||||||
|
|
||||||
|
Move a file to a new path.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `indexId` - The file index ID
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"newPath": "/archived/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileId": "guid",
|
||||||
|
"indexId": "guid",
|
||||||
|
"oldPath": "/documents/",
|
||||||
|
"newPath": "/archived/",
|
||||||
|
"message": "File moved successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove File Index
|
||||||
|
**DELETE** `/api/index/remove/{indexId}?deleteFile=false`
|
||||||
|
|
||||||
|
Remove a file index. Optionally delete the actual file data.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `indexId` - The file index ID
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `deleteFile` (optional, default: false) - Whether to also delete the file data
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "File index removed successfully",
|
||||||
|
"fileId": "guid",
|
||||||
|
"fileName": "document.pdf",
|
||||||
|
"path": "/documents/",
|
||||||
|
"fileDataDeleted": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear Path
|
||||||
|
**DELETE** `/api/index/clear-path?path=/temp/&deleteFiles=false`
|
||||||
|
|
||||||
|
Remove all file indexes in a specific path.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `path` (optional, default: "/") - The path to clear
|
||||||
|
- `deleteFiles` (optional, default: false) - Whether to also delete orphaned files
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Cleared 5 file indexes from path",
|
||||||
|
"path": "/temp/",
|
||||||
|
"removedCount": 5,
|
||||||
|
"filesDeleted": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create File Index
|
||||||
|
**POST** `/api/index/create`
|
||||||
|
|
||||||
|
Create a new file index for an existing file.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileId": "guid",
|
||||||
|
"path": "/documents/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"indexId": "guid",
|
||||||
|
"fileId": "guid",
|
||||||
|
"path": "/documents/",
|
||||||
|
"message": "File index created successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Files
|
||||||
|
**GET** `/api/index/search?query=report&path=/documents/`
|
||||||
|
|
||||||
|
Search for files by name or metadata.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `query` (required) - The search query
|
||||||
|
- `path` (optional) - Limit search to specific path
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "report",
|
||||||
|
"path": "/documents/",
|
||||||
|
"results": [
|
||||||
|
// Same structure as browse endpoint
|
||||||
|
],
|
||||||
|
"totalCount": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Path Normalization
|
||||||
|
|
||||||
|
The system automatically normalizes paths to ensure consistency:
|
||||||
|
|
||||||
|
- **Trailing Slash**: All paths end with `/`
|
||||||
|
- **Root Path**: User home folder is represented as `/`
|
||||||
|
- **Query Safety**: Paths are validated to avoid SQL injection
|
||||||
|
- **Examples**:
|
||||||
|
- `/documents/` ✅ (correct)
|
||||||
|
- `/documents` → `/documents/` ✅ (normalized)
|
||||||
|
- `/documents/reports/` ✅ (correct)
|
||||||
|
- `/documents/reports` → `/documents/reports/` ✅ (normalized)
|
||||||
|
|
||||||
|
## File Upload Integration
|
||||||
|
|
||||||
|
When uploading files with the `FileUploadController`, you can specify a path to automatically create file indexes:
|
||||||
|
|
||||||
|
**Create Upload Task Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileName": "document.pdf",
|
||||||
|
"fileSize": 1024,
|
||||||
|
"contentType": "application/pdf",
|
||||||
|
"hash": "sha256-hash",
|
||||||
|
"path": "/documents/" // New field for file indexing
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The system will automatically create a file index when the upload completes successfully.
|
||||||
|
|
||||||
|
## Service Methods
|
||||||
|
|
||||||
|
### FileIndexService
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class FileIndexService
|
||||||
|
{
|
||||||
|
// Create a new file index
|
||||||
|
Task<SnCloudFileIndex> CreateAsync(string path, Guid fileId, Guid accountId);
|
||||||
|
|
||||||
|
// Get files by path
|
||||||
|
Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path);
|
||||||
|
|
||||||
|
// Get all files for account
|
||||||
|
Task<List<SnCloudFileIndex>> GetByAccountIdAsync(Guid accountId);
|
||||||
|
|
||||||
|
// Get indexes for specific file
|
||||||
|
Task<List<SnCloudFileIndex>> GetByFileIdAsync(Guid fileId);
|
||||||
|
|
||||||
|
// Move file to new path
|
||||||
|
Task<SnCloudFileIndex?> UpdateAsync(Guid indexId, string newPath);
|
||||||
|
|
||||||
|
// Remove file index
|
||||||
|
Task<bool> RemoveAsync(Guid indexId);
|
||||||
|
|
||||||
|
// Remove all indexes in path
|
||||||
|
Task<int> RemoveByPathAsync(Guid accountId, string path);
|
||||||
|
|
||||||
|
// Normalize path format
|
||||||
|
public static string NormalizePath(string path);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The API returns appropriate HTTP status codes and error messages:
|
||||||
|
|
||||||
|
- **400 Bad Request**: Invalid input parameters
|
||||||
|
- **401 Unauthorized**: User not authenticated
|
||||||
|
- **403 Forbidden**: User lacks permission
|
||||||
|
- **404 Not Found**: Resource not found
|
||||||
|
- **500 Internal Server Error**: Server-side error
|
||||||
|
|
||||||
|
**Error Response Format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "BROWSE_FAILED",
|
||||||
|
"message": "Failed to browse files",
|
||||||
|
"status": 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Ownership Verification**: All operations verify that the user owns the file indexes
|
||||||
|
2. **Path Validation**: Paths are normalized and validated
|
||||||
|
3. **Cascade Deletion**: File indexes are automatically removed when files are deleted
|
||||||
|
4. **Safe File Deletion**: Files are only deleted when no other indexes reference them
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Upload File to Specific Path
|
||||||
|
```bash
|
||||||
|
# Create upload task with path
|
||||||
|
curl -X POST /api/files/upload/create \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"fileName": "report.pdf",
|
||||||
|
"fileSize": 2048,
|
||||||
|
"contentType": "application/pdf",
|
||||||
|
"path": "/documents/reports/"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browse Files
|
||||||
|
```bash
|
||||||
|
curl -X GET "/api/index/browse?path=/documents/reports/" \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Move File
|
||||||
|
```bash
|
||||||
|
curl -X POST "/api/index/move/{indexId}" \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"newPath": "/archived/"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Files
|
||||||
|
```bash
|
||||||
|
curl -X GET "/api/index/search?query=invoice&path=/documents/" \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use Trailing Slashes**: Always include trailing slashes in paths
|
||||||
|
2. **Organize Hierarchically**: Use meaningful folder structures
|
||||||
|
3. **Search Efficiently**: Use the search endpoint instead of client-side filtering
|
||||||
|
4. **Clean Up**: Use the clear-path endpoint for temporary directories
|
||||||
|
5. **Monitor Usage**: Check total file counts for quota management
|
||||||
|
|
||||||
|
## Integration Notes
|
||||||
|
|
||||||
|
- The file indexing system works alongside the existing file storage
|
||||||
|
- Files can exist in multiple paths (hard links)
|
||||||
|
- File deletion is optional and only removes data when safe
|
||||||
|
- The system maintains referential integrity between files and indexes
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RemoveUploadedTo : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "uploaded_to",
|
||||||
|
table: "files");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
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", ",,");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
567
DysonNetwork.Drive/Migrations/20251108191230_AddPersistentTask.Designer.cs
generated
Normal file
567
DysonNetwork.Drive/Migrations/20251108191230_AddPersistentTask.Designer.cs
generated
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
// <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("20251108191230_AddPersistentTask")]
|
||||||
|
partial class AddPersistentTask
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.10")
|
||||||
|
.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.Model.PersistentTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<Instant?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("completed_at");
|
||||||
|
|
||||||
|
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(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<string>("Discriminator")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(21)
|
||||||
|
.HasColumnType("character varying(21)")
|
||||||
|
.HasColumnName("discriminator");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("error_message");
|
||||||
|
|
||||||
|
b.Property<long?>("EstimatedDurationSeconds")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("estimated_duration_seconds");
|
||||||
|
|
||||||
|
b.Property<Instant?>("ExpiredAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expired_at");
|
||||||
|
|
||||||
|
b.Property<Instant>("LastActivity")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("last_activity");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("Parameters")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("parameters");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("priority");
|
||||||
|
|
||||||
|
b.Property<double>("Progress")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("progress");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("Results")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("results");
|
||||||
|
|
||||||
|
b.Property<Instant?>("StartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("started_at");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<string>("TaskId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)")
|
||||||
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_tasks");
|
||||||
|
|
||||||
|
b.ToTable("tasks", (string)null);
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("PersistentTask");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.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.Shared.Models.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.Shared.Models.SnCloudFile", 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.Shared.Models.SnFileBundle", 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.Model.PersistentUploadTask", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("DysonNetwork.Drive.Storage.Model.PersistentTask");
|
||||||
|
|
||||||
|
b.Property<Guid?>("BundleId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("bundle_id");
|
||||||
|
|
||||||
|
b.Property<long>("ChunkSize")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("chunk_size");
|
||||||
|
|
||||||
|
b.Property<int>("ChunksCount")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("chunks_count");
|
||||||
|
|
||||||
|
b.Property<int>("ChunksUploaded")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("chunks_uploaded");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("content_type");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptPassword")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("encrypt_password");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("file_name");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("file_size");
|
||||||
|
|
||||||
|
b.Property<string>("Hash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("hash");
|
||||||
|
|
||||||
|
b.Property<Guid>("PoolId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("pool_id");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<int>>("UploadedChunks")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("integer[]")
|
||||||
|
.HasColumnName("uploaded_chunks");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("PersistentUploadTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.CloudFileReference", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
|
||||||
|
.WithMany("References")
|
||||||
|
.HasForeignKey("FileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_file_references_files_file_id");
|
||||||
|
|
||||||
|
b.Navigation("File");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
|
||||||
|
.WithMany("Files")
|
||||||
|
.HasForeignKey("BundleId")
|
||||||
|
.HasConstraintName("fk_files_bundles_bundle_id");
|
||||||
|
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PoolId")
|
||||||
|
.HasConstraintName("fk_files_pools_pool_id");
|
||||||
|
|
||||||
|
b.Navigation("Bundle");
|
||||||
|
|
||||||
|
b.Navigation("Pool");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("References");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Files");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPersistentTask : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "tasks",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
task_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
description = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
progress = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
parameters = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
|
||||||
|
results = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
|
||||||
|
error_message = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
started_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
completed_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
last_activity = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
priority = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
estimated_duration_seconds = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
discriminator = table.Column<string>(type: "character varying(21)", maxLength: 21, nullable: false),
|
||||||
|
file_name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
file_size = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
content_type = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
chunk_size = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
chunks_count = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
chunks_uploaded = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
pool_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
bundle_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
encrypt_password = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
hash = table.Column<string>(type: "text", nullable: true),
|
||||||
|
uploaded_chunks = table.Column<List<int>>(type: "integer[]", 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_tasks", x => x.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
632
DysonNetwork.Drive/Migrations/20251112135535_AddFileIndex.Designer.cs
generated
Normal file
632
DysonNetwork.Drive/Migrations/20251112135535_AddFileIndex.Designer.cs
generated
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
// <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("20251112135535_AddFileIndex")]
|
||||||
|
partial class AddFileIndex
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.10")
|
||||||
|
.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.Model.PersistentTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<Instant?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("completed_at");
|
||||||
|
|
||||||
|
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(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<string>("Discriminator")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(21)
|
||||||
|
.HasColumnType("character varying(21)")
|
||||||
|
.HasColumnName("discriminator");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("error_message");
|
||||||
|
|
||||||
|
b.Property<long?>("EstimatedDurationSeconds")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("estimated_duration_seconds");
|
||||||
|
|
||||||
|
b.Property<Instant?>("ExpiredAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expired_at");
|
||||||
|
|
||||||
|
b.Property<Instant>("LastActivity")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("last_activity");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("Parameters")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("parameters");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("priority");
|
||||||
|
|
||||||
|
b.Property<double>("Progress")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("progress");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("Results")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("results");
|
||||||
|
|
||||||
|
b.Property<Instant?>("StartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("started_at");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<string>("TaskId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)")
|
||||||
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_tasks");
|
||||||
|
|
||||||
|
b.ToTable("tasks", (string)null);
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("PersistentTask");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.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.Shared.Models.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.Shared.Models.SnCloudFile", 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.Shared.Models.SnCloudFileIndex", 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>("FileId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("file_id");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8192)
|
||||||
|
.HasColumnType("character varying(8192)")
|
||||||
|
.HasColumnName("path");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_file_indexes");
|
||||||
|
|
||||||
|
b.HasIndex("FileId")
|
||||||
|
.HasDatabaseName("ix_file_indexes_file_id");
|
||||||
|
|
||||||
|
b.HasIndex("Path", "AccountId")
|
||||||
|
.HasDatabaseName("ix_file_indexes_path_account_id");
|
||||||
|
|
||||||
|
b.ToTable("file_indexes", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", 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.Model.PersistentUploadTask", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("DysonNetwork.Drive.Storage.Model.PersistentTask");
|
||||||
|
|
||||||
|
b.Property<Guid?>("BundleId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("bundle_id");
|
||||||
|
|
||||||
|
b.Property<long>("ChunkSize")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("chunk_size");
|
||||||
|
|
||||||
|
b.Property<int>("ChunksCount")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("chunks_count");
|
||||||
|
|
||||||
|
b.Property<int>("ChunksUploaded")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("chunks_uploaded");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("content_type");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptPassword")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("encrypt_password");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("file_name");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("file_size");
|
||||||
|
|
||||||
|
b.Property<string>("Hash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("hash");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("path");
|
||||||
|
|
||||||
|
b.Property<Guid>("PoolId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("pool_id");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<int>>("UploadedChunks")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("integer[]")
|
||||||
|
.HasColumnName("uploaded_chunks");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("PersistentUploadTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.CloudFileReference", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
|
||||||
|
.WithMany("References")
|
||||||
|
.HasForeignKey("FileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_file_references_files_file_id");
|
||||||
|
|
||||||
|
b.Navigation("File");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
|
||||||
|
.WithMany("Files")
|
||||||
|
.HasForeignKey("BundleId")
|
||||||
|
.HasConstraintName("fk_files_bundles_bundle_id");
|
||||||
|
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PoolId")
|
||||||
|
.HasConstraintName("fk_files_pools_pool_id");
|
||||||
|
|
||||||
|
b.Navigation("Bundle");
|
||||||
|
|
||||||
|
b.Navigation("Pool");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
|
||||||
|
.WithMany("FileIndexes")
|
||||||
|
.HasForeignKey("FileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_file_indexes_files_file_id");
|
||||||
|
|
||||||
|
b.Navigation("File");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("FileIndexes");
|
||||||
|
|
||||||
|
b.Navigation("References");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Files");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
DysonNetwork.Drive/Migrations/20251112135535_AddFileIndex.cs
Normal file
66
DysonNetwork.Drive/Migrations/20251112135535_AddFileIndex.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFileIndex : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "path",
|
||||||
|
table: "tasks",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "file_indexes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
path = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
|
||||||
|
file_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
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_file_indexes", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_file_indexes_files_file_id",
|
||||||
|
column: x => x.file_id,
|
||||||
|
principalTable: "files",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_file_indexes_file_id",
|
||||||
|
table: "file_indexes",
|
||||||
|
column: "file_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_file_indexes_path_account_id",
|
||||||
|
table: "file_indexes",
|
||||||
|
columns: new[] { "path", "account_id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "file_indexes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "path",
|
||||||
|
table: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
629
DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs
Normal file
629
DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
// <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.10")
|
||||||
|
.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.Model.PersistentTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<Instant?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("completed_at");
|
||||||
|
|
||||||
|
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(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<string>("Discriminator")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(21)
|
||||||
|
.HasColumnType("character varying(21)")
|
||||||
|
.HasColumnName("discriminator");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("error_message");
|
||||||
|
|
||||||
|
b.Property<long?>("EstimatedDurationSeconds")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("estimated_duration_seconds");
|
||||||
|
|
||||||
|
b.Property<Instant?>("ExpiredAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expired_at");
|
||||||
|
|
||||||
|
b.Property<Instant>("LastActivity")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("last_activity");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("Parameters")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("parameters");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("priority");
|
||||||
|
|
||||||
|
b.Property<double>("Progress")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("progress");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("Results")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("results");
|
||||||
|
|
||||||
|
b.Property<Instant?>("StartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("started_at");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<string>("TaskId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)")
|
||||||
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_tasks");
|
||||||
|
|
||||||
|
b.ToTable("tasks", (string)null);
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("PersistentTask");
|
||||||
|
|
||||||
|
b.UseTphMappingStrategy();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", 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.Shared.Models.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.Shared.Models.SnCloudFile", 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.Shared.Models.SnCloudFileIndex", 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>("FileId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("file_id");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8192)
|
||||||
|
.HasColumnType("character varying(8192)")
|
||||||
|
.HasColumnName("path");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_file_indexes");
|
||||||
|
|
||||||
|
b.HasIndex("FileId")
|
||||||
|
.HasDatabaseName("ix_file_indexes_file_id");
|
||||||
|
|
||||||
|
b.HasIndex("Path", "AccountId")
|
||||||
|
.HasDatabaseName("ix_file_indexes_path_account_id");
|
||||||
|
|
||||||
|
b.ToTable("file_indexes", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", 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.Model.PersistentUploadTask", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("DysonNetwork.Drive.Storage.Model.PersistentTask");
|
||||||
|
|
||||||
|
b.Property<Guid?>("BundleId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("bundle_id");
|
||||||
|
|
||||||
|
b.Property<long>("ChunkSize")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("chunk_size");
|
||||||
|
|
||||||
|
b.Property<int>("ChunksCount")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("chunks_count");
|
||||||
|
|
||||||
|
b.Property<int>("ChunksUploaded")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("chunks_uploaded");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("content_type");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptPassword")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("encrypt_password");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("file_name");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("file_size");
|
||||||
|
|
||||||
|
b.Property<string>("Hash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("hash");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("path");
|
||||||
|
|
||||||
|
b.Property<Guid>("PoolId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("pool_id");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<int>>("UploadedChunks")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("integer[]")
|
||||||
|
.HasColumnName("uploaded_chunks");
|
||||||
|
|
||||||
|
b.HasDiscriminator().HasValue("PersistentUploadTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
|
||||||
|
.WithMany("References")
|
||||||
|
.HasForeignKey("FileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_file_references_files_file_id");
|
||||||
|
|
||||||
|
b.Navigation("File");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
|
||||||
|
.WithMany("Files")
|
||||||
|
.HasForeignKey("BundleId")
|
||||||
|
.HasConstraintName("fk_files_bundles_bundle_id");
|
||||||
|
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PoolId")
|
||||||
|
.HasConstraintName("fk_files_pools_pool_id");
|
||||||
|
|
||||||
|
b.Navigation("Bundle");
|
||||||
|
|
||||||
|
b.Navigation("Pool");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
|
||||||
|
.WithMany("FileIndexes")
|
||||||
|
.HasForeignKey("FileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_file_indexes_files_file_id");
|
||||||
|
|
||||||
|
b.Navigation("File");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("FileIndexes");
|
||||||
|
|
||||||
|
b.Navigation("References");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Files");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
DysonNetwork.Drive/Program.cs
Normal file
50
DysonNetwork.Drive/Program.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using DysonNetwork.Drive;
|
||||||
|
using DysonNetwork.Drive.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.Configuration, maxRequestBodySize: long.MaxValue);
|
||||||
|
|
||||||
|
// Add application services
|
||||||
|
|
||||||
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
|
builder.Services.AddAppAuthentication();
|
||||||
|
builder.Services.AddDysonAuth();
|
||||||
|
builder.Services.AddRingService();
|
||||||
|
builder.Services.AddAccountService();
|
||||||
|
|
||||||
|
builder.Services.AddAppFlushHandlers();
|
||||||
|
builder.Services.AddAppBusinessServices();
|
||||||
|
builder.Services.AddAppScheduledJobs();
|
||||||
|
|
||||||
|
builder.AddSwaggerManifest(
|
||||||
|
"DysonNetwork.Drive",
|
||||||
|
"The file upload and storage service in the Solar Network."
|
||||||
|
);
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.MapDefaultEndpoints();
|
||||||
|
|
||||||
|
// Run database migrations
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.ConfigureAppMiddleware();
|
||||||
|
|
||||||
|
// Configure gRPC
|
||||||
|
app.ConfigureGrpcServices();
|
||||||
|
|
||||||
|
app.UseSwaggerManifest("DysonNetwork.Drive");
|
||||||
|
|
||||||
|
app.Run();
|
||||||
21
DysonNetwork.Drive/Properties/launchSettings.json
Normal file
21
DysonNetwork.Drive/Properties/launchSettings.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
DysonNetwork.Drive/Startup/ApplicationBuilderExtensions.cs
Normal file
24
DysonNetwork.Drive/Startup/ApplicationBuilderExtensions.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Startup;
|
||||||
|
|
||||||
|
public static class ApplicationBuilderExtensions
|
||||||
|
{
|
||||||
|
public static WebApplication ConfigureAppMiddleware(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WebApplication ConfigureGrpcServices(this WebApplication app)
|
||||||
|
{
|
||||||
|
// Map your gRPC services here
|
||||||
|
app.MapGrpcService<FileServiceGrpc>();
|
||||||
|
app.MapGrpcService<FileReferenceServiceGrpc>();
|
||||||
|
app.MapGrpcReflectionService();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
351
DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
Normal file
351
DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using DysonNetwork.Drive.Storage;
|
||||||
|
using DysonNetwork.Drive.Storage.Model;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
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 persistentTaskService = scope.ServiceProvider.GetRequiredService<PersistentTaskService>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Find the upload task associated with this file
|
||||||
|
var uploadTask = await scopedDb.Tasks
|
||||||
|
.OfType<PersistentUploadTask>()
|
||||||
|
.FirstOrDefaultAsync(t => t.FileName == fileToUpdate.Name && t.FileSize == fileToUpdate.Size);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Complete the upload task if found
|
||||||
|
if (uploadTask != null)
|
||||||
|
{
|
||||||
|
await persistentTaskService.MarkTaskCompletedAsync(uploadTask.TaskId, new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
{ "FileId", fileId },
|
||||||
|
{ "FileName", fileToUpdate.Name },
|
||||||
|
{ "FileInfo", fileToUpdate },
|
||||||
|
{ "FileSize", fileToUpdate.Size },
|
||||||
|
{ "MimeType", newMimeType },
|
||||||
|
{ "HasCompression", hasCompression },
|
||||||
|
{ "HasThumbnail", hasThumbnail }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notification for large files (>5MB) that took longer to process
|
||||||
|
if (fileToUpdate.Size > 5 * 1024 * 1024) // 5MB threshold
|
||||||
|
await SendLargeFileProcessingCompleteNotificationAsync(uploadTask, fileToUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendLargeFileProcessingCompleteNotificationAsync(PersistentUploadTask task, SnCloudFile file)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ringService = serviceProvider.GetRequiredService<RingService.RingServiceClient>();
|
||||||
|
|
||||||
|
var pushNotification = new PushNotification
|
||||||
|
{
|
||||||
|
Topic = "drive.tasks.upload",
|
||||||
|
Title = "File Processing Complete",
|
||||||
|
Subtitle = file.Name,
|
||||||
|
Body = $"Your file '{file.Name}' has finished processing and is now available.",
|
||||||
|
IsSavable = true
|
||||||
|
};
|
||||||
|
|
||||||
|
await ringService.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
|
||||||
|
{
|
||||||
|
UserId = task.AccountId.ToString(),
|
||||||
|
Notification = pushNotification
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to send large file processing notification for task {TaskId}", task.TaskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
DysonNetwork.Drive/Startup/ScheduledJobsConfiguration.cs
Normal file
37
DysonNetwork.Drive/Startup/ScheduledJobsConfiguration.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using DysonNetwork.Drive.Storage;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Startup;
|
||||||
|
|
||||||
|
public static class ScheduledJobsConfiguration
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddQuartz(q =>
|
||||||
|
{
|
||||||
|
var appDatabaseRecyclingJob = new JobKey("AppDatabaseRecycling");
|
||||||
|
q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity(appDatabaseRecyclingJob));
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(appDatabaseRecyclingJob)
|
||||||
|
.WithIdentity("AppDatabaseRecyclingTrigger")
|
||||||
|
.WithCronSchedule("0 0 0 * * ?"));
|
||||||
|
|
||||||
|
var cloudFileUnusedRecyclingJob = new JobKey("CloudFileUnusedRecycling");
|
||||||
|
q.AddJob<CloudFileUnusedRecyclingJob>(opts => opts.WithIdentity(cloudFileUnusedRecyclingJob));
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(cloudFileUnusedRecyclingJob)
|
||||||
|
.WithIdentity("CloudFileUnusedRecyclingTrigger")
|
||||||
|
.WithCronSchedule("0 0 0 * * ?"));
|
||||||
|
|
||||||
|
var persistentTaskCleanupJob = new JobKey("PersistentTaskCleanup");
|
||||||
|
q.AddJob<PersistentTaskCleanupJob>(opts => opts.WithIdentity(persistentTaskCleanupJob));
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(persistentTaskCleanupJob)
|
||||||
|
.WithIdentity("PersistentTaskCleanupTrigger")
|
||||||
|
.WithCronSchedule("0 0 2 * * ?")); // Run daily at 2 AM
|
||||||
|
});
|
||||||
|
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
DysonNetwork.Drive/Startup/ServiceCollectionExtensions.cs
Normal file
66
DysonNetwork.Drive/Startup/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Drive.Index;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.SystemTextJson;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Startup;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
services.AddHttpClient();
|
||||||
|
|
||||||
|
// Register gRPC services
|
||||||
|
services.AddGrpc(options =>
|
||||||
|
{
|
||||||
|
options.EnableDetailedErrors = true; // Will be adjusted in Program.cs
|
||||||
|
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
|
||||||
|
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
|
||||||
|
});
|
||||||
|
services.AddGrpcReflection();
|
||||||
|
|
||||||
|
services.AddControllers().AddJsonOptions(options =>
|
||||||
|
{
|
||||||
|
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||||
|
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
|
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
|
|
||||||
|
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddAuthorization();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<FlushBufferService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<Storage.FileService>();
|
||||||
|
services.AddScoped<Storage.FileReferenceService>();
|
||||||
|
services.AddScoped<Storage.PersistentTaskService>();
|
||||||
|
services.AddScoped<FileIndexService>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
123
DysonNetwork.Drive/Storage/CloudFileUnusedRecyclingJob.cs
Normal file
123
DysonNetwork.Drive/Storage/CloudFileUnusedRecyclingJob.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
|
public class CloudFileUnusedRecyclingJob(
|
||||||
|
AppDatabase db,
|
||||||
|
ILogger<CloudFileUnusedRecyclingJob> logger,
|
||||||
|
IConfiguration configuration
|
||||||
|
)
|
||||||
|
: IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Cleaning tus cloud files...");
|
||||||
|
var storePath = configuration["Storage:Uploads"];
|
||||||
|
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.FileIndexes.Count == 0)
|
||||||
|
.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);
|
||||||
|
|
||||||
|
// Define a timestamp to limit the age of files we're processing in this run
|
||||||
|
// This spreads the processing across multiple job runs for very large databases
|
||||||
|
var ageThreshold = now - Duration.FromDays(30); // Process files up to 90 days old in this run
|
||||||
|
|
||||||
|
// Instead of loading all files at once, use pagination
|
||||||
|
var hasMoreFiles = true;
|
||||||
|
string? lastProcessedId = null;
|
||||||
|
|
||||||
|
while (hasMoreFiles)
|
||||||
|
{
|
||||||
|
// 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
|
||||||
|
.Take(batchSize)
|
||||||
|
.Select(f => f.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (fileBatch.Count == 0)
|
||||||
|
{
|
||||||
|
hasMoreFiles = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedCount += fileBatch.Count;
|
||||||
|
lastProcessedId = fileBatch.Last();
|
||||||
|
|
||||||
|
// Optimized query: Find files that have no references OR all references are expired
|
||||||
|
// This replaces the memory-intensive approach of loading all references
|
||||||
|
var filesToMark = await db.Files
|
||||||
|
.Where(f => fileBatch.Contains(f.Id))
|
||||||
|
.Where(f => !db.FileReferences.Any(r => r.FileId == f.Id) || // No references at all
|
||||||
|
!db.FileReferences.Any(r => r.FileId == f.Id && // OR has references but all are expired
|
||||||
|
(r.ExpiredAt == null || r.ExpiredAt > now)))
|
||||||
|
.Select(f => f.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (filesToMark.Count > 0)
|
||||||
|
{
|
||||||
|
// Use a bulk update for better performance - mark all qualifying files at once
|
||||||
|
var updateCount = await db.Files
|
||||||
|
.Where(f => filesToMark.Contains(f.Id))
|
||||||
|
.ExecuteUpdateAsync(setter => setter
|
||||||
|
.SetProperty(f => f.IsMarkedRecycle, true));
|
||||||
|
|
||||||
|
markedCount += updateCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log progress periodically
|
||||||
|
if (processedCount % 10000 == 0 || !hasMoreFiles)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Progress: processed {ProcessedCount}/{TotalFiles} files, marked {MarkedCount} for recycling",
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
390
DysonNetwork.Drive/Storage/FileController.cs
Normal file
390
DysonNetwork.Drive/Storage/FileController.cs
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
using DysonNetwork.Shared.Auth;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Minio.DataModel.Args;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/api/files")]
|
||||||
|
public class FileController(
|
||||||
|
AppDatabase db,
|
||||||
|
FileService fs,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IWebHostEnvironment env,
|
||||||
|
FileReferenceService fileReferenceService
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<ActionResult> OpenFile(
|
||||||
|
string id,
|
||||||
|
[FromQuery] bool download = false,
|
||||||
|
[FromQuery] bool original = false,
|
||||||
|
[FromQuery] bool thumbnail = false,
|
||||||
|
[FromQuery] string? overrideMimeType = null,
|
||||||
|
[FromQuery] string? passcode = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var (fileId, fileExtension) = ParseFileId(id);
|
||||||
|
var file = await fs.GetFileAsync(fileId);
|
||||||
|
if (file is null) return NotFound("File not found.");
|
||||||
|
|
||||||
|
var accessResult = await ValidateFileAccess(file, passcode);
|
||||||
|
if (accessResult is not null) return accessResult;
|
||||||
|
|
||||||
|
// Handle direct storage URL redirect
|
||||||
|
if (!string.IsNullOrWhiteSpace(file.StorageUrl))
|
||||||
|
return Redirect(file.StorageUrl);
|
||||||
|
|
||||||
|
// Handle files not yet uploaded to remote storage
|
||||||
|
if (file.UploadedAt is null)
|
||||||
|
return await ServeLocalFile(file);
|
||||||
|
|
||||||
|
// Handle uploaded files
|
||||||
|
return await ServeRemoteFile(file, fileExtension, download, original, thumbnail, overrideMimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string fileId, string? extension) ParseFileId(string id)
|
||||||
|
{
|
||||||
|
if (!id.Contains('.')) return (id, null);
|
||||||
|
|
||||||
|
var parts = id.Split('.');
|
||||||
|
return (parts.First(), parts.Last());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult?> ValidateFileAccess(SnCloudFile file, string? passcode)
|
||||||
|
{
|
||||||
|
if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode))
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<ActionResult> ServeLocalFile(SnCloudFile file)
|
||||||
|
{
|
||||||
|
// Try temp storage first
|
||||||
|
var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id);
|
||||||
|
if (System.IO.File.Exists(tempFilePath))
|
||||||
|
{
|
||||||
|
if (file.IsEncrypted)
|
||||||
|
return Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
"Encrypted files cannot be accessed before they are processed and stored."));
|
||||||
|
|
||||||
|
return Task.FromResult<ActionResult>(PhysicalFile(tempFilePath, file.MimeType ?? "application/octet-stream",
|
||||||
|
file.Name, enableRangeProcessing: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for tus uploads
|
||||||
|
var tusStorePath = configuration.GetValue<string>("Storage:Uploads");
|
||||||
|
if (string.IsNullOrEmpty(tusStorePath))
|
||||||
|
return Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status400BadRequest,
|
||||||
|
"File is being processed. Please try again later."));
|
||||||
|
var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id);
|
||||||
|
return System.IO.File.Exists(tusFilePath)
|
||||||
|
? Task.FromResult<ActionResult>(PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream",
|
||||||
|
file.Name, enableRangeProcessing: true))
|
||||||
|
: Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status400BadRequest,
|
||||||
|
"File is being processed. Please try again later."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult> ServeRemoteFile(
|
||||||
|
SnCloudFile file,
|
||||||
|
string? fileExtension,
|
||||||
|
bool download,
|
||||||
|
bool original,
|
||||||
|
bool thumbnail,
|
||||||
|
string? overrideMimeType
|
||||||
|
)
|
||||||
|
{
|
||||||
|
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.");
|
||||||
|
|
||||||
|
if (!pool.PolicyConfig.AllowAnonymous && HttpContext.Items["CurrentUser"] is not Account)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var dest = pool.StorageConfig;
|
||||||
|
var fileName = BuildRemoteFileName(file, original, thumbnail);
|
||||||
|
|
||||||
|
// Try proxy redirects first
|
||||||
|
var proxyResult = TryProxyRedirect(file, dest, fileName);
|
||||||
|
if (proxyResult is not null) return proxyResult;
|
||||||
|
|
||||||
|
// Handle signed URLs
|
||||||
|
if (dest.EnableSigned)
|
||||||
|
return await CreateSignedUrl(file, dest, fileName, fileExtension, download, overrideMimeType);
|
||||||
|
|
||||||
|
// Fallback to direct S3 endpoint
|
||||||
|
var protocol = dest.EnableSsl ? "https" : "http";
|
||||||
|
return Redirect($"{protocol}://{dest.Endpoint}/{dest.Bucket}/{fileName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildRemoteFileName(SnCloudFile file, bool original, bool thumbnail)
|
||||||
|
{
|
||||||
|
var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId;
|
||||||
|
|
||||||
|
if (thumbnail)
|
||||||
|
{
|
||||||
|
if (!file.HasThumbnail) throw new InvalidOperationException("Thumbnail not available");
|
||||||
|
fileName += ".thumbnail";
|
||||||
|
}
|
||||||
|
else if (!original && file.HasCompression)
|
||||||
|
{
|
||||||
|
fileName += ".compressed";
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult? TryProxyRedirect(SnCloudFile file, RemoteStorageConfig dest, string fileName)
|
||||||
|
{
|
||||||
|
if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false))
|
||||||
|
{
|
||||||
|
return Redirect(BuildProxyUrl(dest.ImageProxy, fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dest.AccessProxy is not null ? Redirect(BuildProxyUrl(dest.AccessProxy, fileName)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildProxyUrl(string proxyUrl, string fileName)
|
||||||
|
{
|
||||||
|
var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
|
||||||
|
var fullUri = new Uri(baseUri, fileName);
|
||||||
|
return fullUri.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult> CreateSignedUrl(
|
||||||
|
SnCloudFile file,
|
||||||
|
RemoteStorageConfig dest,
|
||||||
|
string fileName,
|
||||||
|
string? fileExtension,
|
||||||
|
bool download,
|
||||||
|
string? overrideMimeType
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var client = fs.CreateMinioClient(dest);
|
||||||
|
if (client is null)
|
||||||
|
return BadRequest("Failed to configure client for remote destination, file got an invalid storage remote.");
|
||||||
|
|
||||||
|
var headers = BuildSignedUrlHeaders(file, fileExtension, overrideMimeType, download);
|
||||||
|
|
||||||
|
var openUrl = await client.PresignedGetObjectAsync(
|
||||||
|
new PresignedGetObjectArgs()
|
||||||
|
.WithBucket(dest.Bucket)
|
||||||
|
.WithObject(fileName)
|
||||||
|
.WithExpiry(3600)
|
||||||
|
.WithHeaders(headers)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Redirect(openUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> BuildSignedUrlHeaders(
|
||||||
|
SnCloudFile file,
|
||||||
|
string? fileExtension,
|
||||||
|
string? overrideMimeType,
|
||||||
|
bool download
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var headers = new Dictionary<string, string>();
|
||||||
|
|
||||||
|
string? contentType = null;
|
||||||
|
if (fileExtension is not null && MimeTypes.TryGetMimeType(fileExtension, out var mimeType))
|
||||||
|
{
|
||||||
|
contentType = mimeType;
|
||||||
|
}
|
||||||
|
else if (overrideMimeType is not null)
|
||||||
|
{
|
||||||
|
contentType = overrideMimeType;
|
||||||
|
}
|
||||||
|
else if (file.MimeType is not null && !file.MimeType.EndsWith("unknown"))
|
||||||
|
{
|
||||||
|
contentType = file.MimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType is not null)
|
||||||
|
{
|
||||||
|
headers.Add("Response-Content-Type", contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (download)
|
||||||
|
{
|
||||||
|
headers.Add("Response-Content-Disposition", $"attachment; filename=\"{file.Name}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/info")]
|
||||||
|
public async Task<ActionResult<SnCloudFile>> GetFileInfo(string id)
|
||||||
|
{
|
||||||
|
var file = await fs.GetFileAsync(id);
|
||||||
|
if (file is null) return NotFound("File not found.");
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/references")]
|
||||||
|
public async Task<ActionResult<List<Shared.Models.SnCloudFileReference>>> GetFileReferences(string id)
|
||||||
|
{
|
||||||
|
var file = await fs.GetFileAsync(id);
|
||||||
|
if (file is null) return NotFound("File not found.");
|
||||||
|
|
||||||
|
// Check if user has access to the file
|
||||||
|
var accessResult = await ValidateFileAccess(file, null);
|
||||||
|
if (accessResult is not null) return accessResult;
|
||||||
|
|
||||||
|
// Get references using the injected FileReferenceService
|
||||||
|
var references = await fileReferenceService.GetReferencesAsync(id);
|
||||||
|
return Ok(references);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPatch("{id}/name")]
|
||||||
|
public async Task<ActionResult<SnCloudFile>> UpdateFileName(string id, [FromBody] string name)
|
||||||
|
{
|
||||||
|
return await UpdateFileProperty(id, file => file.Name = name);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return await UpdateFileProperty(id, file => file.SensitiveMarks = request.SensitiveMarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPut("{id}/meta")]
|
||||||
|
public async Task<ActionResult<SnCloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta)
|
||||||
|
{
|
||||||
|
return await UpdateFileProperty(id, file => file.UserMeta = meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult<SnCloudFile>> UpdateFileProperty(string fileId, Action<SnCloudFile> updateAction)
|
||||||
|
{
|
||||||
|
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 == fileId && f.AccountId == accountId);
|
||||||
|
if (file is null) return NotFound();
|
||||||
|
|
||||||
|
updateAction(file);
|
||||||
|
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,
|
||||||
|
[FromQuery] string? query = null,
|
||||||
|
[FromQuery] string order = "date",
|
||||||
|
[FromQuery] bool orderDesc = true
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var filesQuery = db.Files
|
||||||
|
.Where(e => e.IsMarkedRecycle == recycled)
|
||||||
|
.Where(e => e.AccountId == accountId)
|
||||||
|
.Include(e => e.Pool)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (pool.HasValue) filesQuery = filesQuery.Where(e => e.PoolId == pool);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
filesQuery = filesQuery.Where(e => e.Name.Contains(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
filesQuery = order.ToLower() switch
|
||||||
|
{
|
||||||
|
"date" => orderDesc ? filesQuery.OrderByDescending(e => e.CreatedAt) : filesQuery.OrderBy(e => e.CreatedAt),
|
||||||
|
"size" => orderDesc ? filesQuery.OrderByDescending(e => e.Size) : filesQuery.OrderBy(e => e.Size),
|
||||||
|
"name" => orderDesc ? filesQuery.OrderByDescending(e => e.Name) : filesQuery.OrderBy(e => e.Name),
|
||||||
|
_ => filesQuery.OrderByDescending(e => e.CreatedAt)
|
||||||
|
};
|
||||||
|
|
||||||
|
var total = await filesQuery.CountAsync();
|
||||||
|
Response.Headers.Append("X-Total", total.ToString());
|
||||||
|
|
||||||
|
var files = await filesQuery
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FileBatchDeletionRequest
|
||||||
|
{
|
||||||
|
public List<string> FileIds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("batches/delete")]
|
||||||
|
public async Task<ActionResult> DeleteFileBatch([FromBody] FileBatchDeletionRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var userId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var count = await fs.DeleteAccountFileBatchAsync(userId, request.FileIds);
|
||||||
|
return Ok(new { Count = count });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<ActionResult<SnCloudFile>> DeleteFile(string id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var userId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var file = await db.Files
|
||||||
|
.Where(e => e.Id == id)
|
||||||
|
.Where(e => e.AccountId == userId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (file is null) return NotFound();
|
||||||
|
|
||||||
|
await fs.DeleteFileDataAsync(file, force: true);
|
||||||
|
await fs.DeleteFileAsync(file, skipData: true);
|
||||||
|
|
||||||
|
return Ok(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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")]
|
||||||
|
[AskPermission("files.delete.recycle")]
|
||||||
|
public async Task<ActionResult> DeleteAllRecycledFiles()
|
||||||
|
{
|
||||||
|
var count = await fs.DeleteAllRecycledFilesAsync();
|
||||||
|
return Ok(new { Count = count });
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
DysonNetwork.Drive/Storage/FileExpirationJob.cs
Normal file
70
DysonNetwork.Drive/Storage/FileExpirationJob.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Job responsible for cleaning up expired file references
|
||||||
|
/// </summary>
|
||||||
|
public class FileExpirationJob(AppDatabase db, FileService fileService, ILogger<FileExpirationJob> logger) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
logger.LogInformation("Running file reference expiration job at {now}", now);
|
||||||
|
|
||||||
|
// Delete expired references in bulk and get affected file IDs
|
||||||
|
var affectedFileIds = await db.FileReferences
|
||||||
|
.Where(r => r.ExpiredAt < now && r.ExpiredAt != null)
|
||||||
|
.Select(r => r.FileId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (!affectedFileIds.Any())
|
||||||
|
{
|
||||||
|
logger.LogInformation("No expired file references found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Found expired references for {count} files", affectedFileIds.Count);
|
||||||
|
|
||||||
|
// Delete expired references in bulk
|
||||||
|
var deletedReferencesCount = await db.FileReferences
|
||||||
|
.Where(r => r.ExpiredAt < now && r.ExpiredAt != null)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
logger.LogInformation("Deleted {count} expired file references", deletedReferencesCount);
|
||||||
|
|
||||||
|
// Find files that now have no remaining references (bulk operation)
|
||||||
|
var filesToDelete = await db.Files
|
||||||
|
.Where(f => affectedFileIds.Contains(f.Id))
|
||||||
|
.Where(f => !db.FileReferences.Any(r => r.FileId == f.Id))
|
||||||
|
.Select(f => f.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (filesToDelete.Any())
|
||||||
|
{
|
||||||
|
logger.LogInformation("Deleting {count} files that have no remaining references", filesToDelete.Count);
|
||||||
|
|
||||||
|
// Get files for deletion
|
||||||
|
var files = await db.Files
|
||||||
|
.Where(f => filesToDelete.Contains(f.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Delete files and their data in parallel
|
||||||
|
var deleteTasks = files.Select(f => fileService.DeleteFileAsync(f));
|
||||||
|
await Task.WhenAll(deleteTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge cache for files that still have references
|
||||||
|
var filesWithRemainingRefs = affectedFileIds.Except(filesToDelete).ToList();
|
||||||
|
if (filesWithRemainingRefs.Any())
|
||||||
|
{
|
||||||
|
var cachePurgeTasks = filesWithRemainingRefs.Select(fileService._PurgeCacheAsync);
|
||||||
|
await Task.WhenAll(cachePurgeTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Completed file reference expiration job");
|
||||||
|
}
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
524
DysonNetwork.Drive/Storage/FileReferenceService.cs
Normal file
524
DysonNetwork.Drive/Storage/FileReferenceService.cs
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using EFCore.BulkExtensions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
|
public class FileReferenceService(AppDatabase db, FileService fileService, ICacheService cache)
|
||||||
|
{
|
||||||
|
private const string CacheKeyPrefix = "file:ref:";
|
||||||
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new reference to a file for a specific resource
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileId">The ID of the file to reference</param>
|
||||||
|
/// <param name="usage">The usage context (e.g., "avatar", "post-attachment")</param>
|
||||||
|
/// <param name="resourceId">The ID of the resource using the file</param>
|
||||||
|
/// <param name="expiredAt">Optional expiration time for the file</param>
|
||||||
|
/// <param name="duration">Optional duration after which the file expires (alternative to expiredAt)</param>
|
||||||
|
/// <returns>The created file reference</returns>
|
||||||
|
public async Task<SnCloudFileReference> CreateReferenceAsync(
|
||||||
|
string fileId,
|
||||||
|
string usage,
|
||||||
|
string resourceId,
|
||||||
|
Instant? expiredAt = null,
|
||||||
|
Duration? duration = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Calculate expiration time if needed
|
||||||
|
var finalExpiration = expiredAt;
|
||||||
|
if (duration.HasValue)
|
||||||
|
finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value;
|
||||||
|
|
||||||
|
var reference = new SnCloudFileReference
|
||||||
|
{
|
||||||
|
FileId = fileId,
|
||||||
|
Usage = usage,
|
||||||
|
ResourceId = resourceId,
|
||||||
|
ExpiredAt = finalExpiration
|
||||||
|
};
|
||||||
|
|
||||||
|
db.FileReferences.Add(reference);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await fileService._PurgeCacheAsync(fileId);
|
||||||
|
|
||||||
|
return reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SnCloudFileReference>> CreateReferencesAsync(
|
||||||
|
List<string> fileId,
|
||||||
|
string usage,
|
||||||
|
string resourceId,
|
||||||
|
Instant? expiredAt = null,
|
||||||
|
Duration? duration = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var data = fileId.Select(id => new SnCloudFileReference
|
||||||
|
{
|
||||||
|
FileId = id,
|
||||||
|
Usage = usage,
|
||||||
|
ResourceId = resourceId,
|
||||||
|
ExpiredAt = expiredAt ?? now + duration,
|
||||||
|
CreatedAt = now,
|
||||||
|
UpdatedAt = now
|
||||||
|
}).ToList();
|
||||||
|
await db.BulkInsertAsync(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all references to a file
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileId">The ID of the file</param>
|
||||||
|
/// <returns>A list of all references to the file</returns>
|
||||||
|
public async Task<List<SnCloudFileReference>> GetReferencesAsync(string fileId)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}list:{fileId}";
|
||||||
|
|
||||||
|
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
|
||||||
|
if (cachedReferences is not null)
|
||||||
|
return cachedReferences;
|
||||||
|
|
||||||
|
var references = await db.FileReferences
|
||||||
|
.Where(r => r.FileId == fileId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await cache.SetAsync(cacheKey, references, CacheDuration);
|
||||||
|
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<string, List<SnCloudFileReference>>> GetReferencesAsync(IEnumerable<string> fileIds)
|
||||||
|
{
|
||||||
|
var fileIdList = fileIds.ToList();
|
||||||
|
var result = new Dictionary<string, List<SnCloudFileReference>>();
|
||||||
|
|
||||||
|
// Check cache for each file ID
|
||||||
|
var uncachedFileIds = new List<string>();
|
||||||
|
foreach (var fileId in fileIdList)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}list:{fileId}";
|
||||||
|
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
|
||||||
|
if (cachedReferences is not null)
|
||||||
|
{
|
||||||
|
result[fileId] = cachedReferences;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
uncachedFileIds.Add(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch uncached references from database
|
||||||
|
if (uncachedFileIds.Any())
|
||||||
|
{
|
||||||
|
var dbReferences = await db.FileReferences
|
||||||
|
.Where(r => uncachedFileIds.Contains(r.FileId))
|
||||||
|
.GroupBy(r => r.FileId)
|
||||||
|
.ToDictionaryAsync(r => r.Key, r => r.ToList());
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
foreach (var kvp in dbReferences)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}list:{kvp.Key}";
|
||||||
|
await cache.SetAsync(cacheKey, kvp.Value, CacheDuration);
|
||||||
|
result[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of references to a file
|
||||||
|
/// </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)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}count:{fileId}";
|
||||||
|
|
||||||
|
var cachedCount = await cache.GetAsync<int?>(cacheKey);
|
||||||
|
if (cachedCount.HasValue)
|
||||||
|
return cachedCount.Value;
|
||||||
|
|
||||||
|
var count = await db.FileReferences
|
||||||
|
.Where(r => r.FileId == fileId)
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
await cache.SetAsync(cacheKey, count, CacheDuration);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all references for a specific resource
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resourceId">The ID of the resource</param>
|
||||||
|
/// <returns>A list of file references associated with the resource</returns>
|
||||||
|
public async Task<List<SnCloudFileReference>> GetResourceReferencesAsync(string resourceId)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}resource:{resourceId}";
|
||||||
|
|
||||||
|
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
|
||||||
|
if (cachedReferences is not null)
|
||||||
|
return cachedReferences;
|
||||||
|
|
||||||
|
var references = await db.FileReferences
|
||||||
|
.Where(r => r.ResourceId == resourceId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await cache.SetAsync(cacheKey, references, CacheDuration);
|
||||||
|
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all file references for a specific usage context
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="usage">The usage context</param>
|
||||||
|
/// <returns>A list of file references with the specified usage</returns>
|
||||||
|
public async Task<List<SnCloudFileReference>> GetUsageReferencesAsync(string usage)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}usage:{usage}";
|
||||||
|
|
||||||
|
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
|
||||||
|
if (cachedReferences is not null)
|
||||||
|
return cachedReferences;
|
||||||
|
|
||||||
|
var references = await db.FileReferences
|
||||||
|
.Where(r => r.Usage == usage)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await cache.SetAsync(cacheKey, references, CacheDuration);
|
||||||
|
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes references for a specific resource
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resourceId">The ID of the resource</param>
|
||||||
|
/// <returns>The number of deleted references</returns>
|
||||||
|
public async Task<int> DeleteResourceReferencesAsync(string resourceId)
|
||||||
|
{
|
||||||
|
var references = await db.FileReferences
|
||||||
|
.Where(r => r.ResourceId == resourceId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||||
|
|
||||||
|
db.FileReferences.RemoveRange(references);
|
||||||
|
var deletedCount = await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Purge caches
|
||||||
|
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
|
||||||
|
tasks.Add(PurgeCacheForResourceAsync(resourceId));
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes references for a specific resource and usage
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resourceId">The ID of the resource</param>
|
||||||
|
/// <param name="usage">The usage context</param>
|
||||||
|
/// <returns>The number of deleted references</returns>
|
||||||
|
public async Task<int> DeleteResourceReferencesAsync(string resourceId, string usage)
|
||||||
|
{
|
||||||
|
var references = await db.FileReferences
|
||||||
|
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (references.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||||
|
|
||||||
|
db.FileReferences.RemoveRange(references);
|
||||||
|
var deletedCount = await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Purge caches
|
||||||
|
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
|
||||||
|
tasks.Add(PurgeCacheForResourceAsync(resourceId));
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> DeleteResourceReferencesBatchAsync(IEnumerable<string> resourceIds, string? usage = null)
|
||||||
|
{
|
||||||
|
var resourceIdList = resourceIds.ToList();
|
||||||
|
var references = await db.FileReferences
|
||||||
|
.Where(r => resourceIdList.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 for files and resources
|
||||||
|
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
|
||||||
|
tasks.AddRange(resourceIdList.Select(PurgeCacheForResourceAsync));
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a specific file reference
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="referenceId">The ID of the reference to delete</param>
|
||||||
|
/// <returns>True if the reference was deleted, false otherwise</returns>
|
||||||
|
public async Task<bool> DeleteReferenceAsync(Guid referenceId)
|
||||||
|
{
|
||||||
|
var reference = await db.FileReferences
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == referenceId);
|
||||||
|
|
||||||
|
if (reference == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
db.FileReferences.Remove(reference);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Purge caches
|
||||||
|
await fileService._PurgeCacheAsync(reference.FileId);
|
||||||
|
await PurgeCacheForResourceAsync(reference.ResourceId);
|
||||||
|
await PurgeCacheForFileAsync(reference.FileId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the files referenced by a resource
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resourceId">The ID of the resource</param>
|
||||||
|
/// <param name="newFileIds">The new list of file IDs</param>
|
||||||
|
/// <param name="usage">The usage context</param>
|
||||||
|
/// <param name="expiredAt">Optional expiration time for newly added files</param>
|
||||||
|
/// <param name="duration">Optional duration after which newly added files expire</param>
|
||||||
|
/// <returns>A list of the updated file references</returns>
|
||||||
|
public async Task<List<SnCloudFileReference>> UpdateResourceFilesAsync(
|
||||||
|
string resourceId,
|
||||||
|
IEnumerable<string>? newFileIds,
|
||||||
|
string usage,
|
||||||
|
Instant? expiredAt = null,
|
||||||
|
Duration? duration = null)
|
||||||
|
{
|
||||||
|
if (newFileIds == null)
|
||||||
|
return new List<SnCloudFileReference>();
|
||||||
|
|
||||||
|
var existingReferences = await db.FileReferences
|
||||||
|
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var existingFileIds = existingReferences.Select(r => r.FileId).ToHashSet();
|
||||||
|
var newFileIdsList = newFileIds.ToList();
|
||||||
|
var newFileIdsSet = newFileIdsList.ToHashSet();
|
||||||
|
|
||||||
|
// Files to remove
|
||||||
|
var toRemove = existingReferences
|
||||||
|
.Where(r => !newFileIdsSet.Contains(r.FileId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Files to add
|
||||||
|
var toAdd = newFileIdsList
|
||||||
|
.Where(id => !existingFileIds.Contains(id))
|
||||||
|
.Select(id => new SnCloudFileReference
|
||||||
|
{
|
||||||
|
FileId = id,
|
||||||
|
Usage = usage,
|
||||||
|
ResourceId = resourceId
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Apply changes
|
||||||
|
if (toRemove.Any())
|
||||||
|
db.FileReferences.RemoveRange(toRemove);
|
||||||
|
|
||||||
|
if (toAdd.Any())
|
||||||
|
db.FileReferences.AddRange(toAdd);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Update expiration for newly added references if specified
|
||||||
|
if ((expiredAt.HasValue || duration.HasValue) && toAdd.Any())
|
||||||
|
{
|
||||||
|
var finalExpiration = expiredAt;
|
||||||
|
if (duration.HasValue)
|
||||||
|
{
|
||||||
|
finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update newly added references with the expiration time
|
||||||
|
var referenceIds = await db.FileReferences
|
||||||
|
.Where(r => toAdd.Select(a => a.FileId).Contains(r.FileId) &&
|
||||||
|
r.ResourceId == resourceId &&
|
||||||
|
r.Usage == usage)
|
||||||
|
.Select(r => r.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await db.FileReferences
|
||||||
|
.Where(r => referenceIds.Contains(r.Id))
|
||||||
|
.ExecuteUpdateAsync(setter => setter.SetProperty(
|
||||||
|
r => r.ExpiredAt,
|
||||||
|
_ => finalExpiration
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge caches
|
||||||
|
var allFileIds = existingFileIds.Union(newFileIdsSet).ToList();
|
||||||
|
var tasks = allFileIds.Select(fileService._PurgeCacheAsync).ToList();
|
||||||
|
tasks.Add(PurgeCacheForResourceAsync(resourceId));
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
// Return updated references
|
||||||
|
return await db.FileReferences
|
||||||
|
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all files referenced by a resource
|
||||||
|
/// </summary>
|
||||||
|
/// <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<SnCloudFile>> GetResourceFilesAsync(string resourceId, string? usage = null)
|
||||||
|
{
|
||||||
|
var query = db.FileReferences.Where(r => r.ResourceId == resourceId);
|
||||||
|
|
||||||
|
if (usage != null)
|
||||||
|
query = query.Where(r => r.Usage == usage);
|
||||||
|
|
||||||
|
var references = await query.ToListAsync();
|
||||||
|
var fileIds = references.Select(r => r.FileId).ToList();
|
||||||
|
|
||||||
|
return await db.Files
|
||||||
|
.Where(f => fileIds.Contains(f.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purges all caches related to a resource
|
||||||
|
/// </summary>
|
||||||
|
private async Task PurgeCacheForResourceAsync(string resourceId)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}resource:{resourceId}";
|
||||||
|
await cache.RemoveAsync(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purges all caches related to a file
|
||||||
|
/// </summary>
|
||||||
|
private async Task PurgeCacheForFileAsync(string fileId)
|
||||||
|
{
|
||||||
|
var cacheKeys = new[]
|
||||||
|
{
|
||||||
|
$"{CacheKeyPrefix}list:{fileId}",
|
||||||
|
$"{CacheKeyPrefix}count:{fileId}"
|
||||||
|
};
|
||||||
|
|
||||||
|
var tasks = cacheKeys.Select(cache.RemoveAsync);
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the expiration time for a file reference
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="referenceId">The ID of the reference</param>
|
||||||
|
/// <param name="expiredAt">The new expiration time, or null to remove expiration</param>
|
||||||
|
/// <returns>True if the reference was found and updated, false otherwise</returns>
|
||||||
|
public async Task<bool> SetReferenceExpirationAsync(Guid referenceId, Instant? expiredAt)
|
||||||
|
{
|
||||||
|
var reference = await db.FileReferences
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == referenceId);
|
||||||
|
|
||||||
|
if (reference == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
reference.ExpiredAt = expiredAt;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await PurgeCacheForFileAsync(reference.FileId);
|
||||||
|
await PurgeCacheForResourceAsync(reference.ResourceId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the expiration time for all references to a file
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileId">The ID of the file</param>
|
||||||
|
/// <param name="expiredAt">The new expiration time, or null to remove expiration</param>
|
||||||
|
/// <returns>The number of references updated</returns>
|
||||||
|
public async Task<int> SetFileReferencesExpirationAsync(string fileId, Instant? expiredAt)
|
||||||
|
{
|
||||||
|
var rowsAffected = await db.FileReferences
|
||||||
|
.Where(r => r.FileId == fileId)
|
||||||
|
.ExecuteUpdateAsync(setter => setter.SetProperty(
|
||||||
|
r => r.ExpiredAt,
|
||||||
|
_ => expiredAt
|
||||||
|
));
|
||||||
|
|
||||||
|
if (rowsAffected > 0)
|
||||||
|
{
|
||||||
|
await fileService._PurgeCacheAsync(fileId);
|
||||||
|
await PurgeCacheForFileAsync(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowsAffected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all file references for a specific resource and usage type
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resourceId">The resource ID</param>
|
||||||
|
/// <param name="usageType">The usage type</param>
|
||||||
|
/// <returns>List of file references</returns>
|
||||||
|
public async Task<List<SnCloudFileReference>> GetResourceReferencesAsync(string resourceId, string usageType)
|
||||||
|
{
|
||||||
|
return await db.FileReferences
|
||||||
|
.Where(r => r.ResourceId == resourceId && r.Usage == usageType)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if a file has any references
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileId">The file ID to check</param>
|
||||||
|
/// <returns>True if the file has references, false otherwise</returns>
|
||||||
|
public async Task<bool> HasFileReferencesAsync(string fileId)
|
||||||
|
{
|
||||||
|
return await db.FileReferences.AnyAsync(r => r.FileId == fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the expiration time for a file reference using a duration from now
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="referenceId">The ID of the reference</param>
|
||||||
|
/// <param name="duration">The duration after which the reference expires, or null to remove expiration</param>
|
||||||
|
/// <returns>True if the reference was found and updated, false otherwise</returns>
|
||||||
|
public async Task<bool> SetReferenceExpirationDurationAsync(Guid referenceId, Duration? duration)
|
||||||
|
{
|
||||||
|
Instant? expiredAt = null;
|
||||||
|
if (duration.HasValue)
|
||||||
|
{
|
||||||
|
expiredAt = SystemClock.Instance.GetCurrentInstant() + duration.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await SetReferenceExpirationAsync(referenceId, expiredAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
174
DysonNetwork.Drive/Storage/FileReferenceServiceGrpc.cs
Normal file
174
DysonNetwork.Drive/Storage/FileReferenceServiceGrpc.cs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Grpc.Core;
|
||||||
|
using NodaTime;
|
||||||
|
using Duration = NodaTime.Duration;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
|
public class FileReferenceServiceGrpc(FileReferenceService fileReferenceService)
|
||||||
|
: Shared.Proto.FileReferenceService.FileReferenceServiceBase
|
||||||
|
{
|
||||||
|
public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
Instant? expiredAt = null;
|
||||||
|
if (request.ExpiredAt != null)
|
||||||
|
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
|
||||||
|
else if (request.Duration != null)
|
||||||
|
expiredAt = SystemClock.Instance.GetCurrentInstant() +
|
||||||
|
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
|
||||||
|
|
||||||
|
var reference = await fileReferenceService.CreateReferenceAsync(
|
||||||
|
request.FileId,
|
||||||
|
request.Usage,
|
||||||
|
request.ResourceId,
|
||||||
|
expiredAt
|
||||||
|
);
|
||||||
|
return reference.ToProtoValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<CreateReferenceBatchResponse> CreateReferenceBatch(CreateReferenceBatchRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
Instant? expiredAt = null;
|
||||||
|
if (request.ExpiredAt != null)
|
||||||
|
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
|
||||||
|
else if (request.Duration != null)
|
||||||
|
expiredAt = SystemClock.Instance.GetCurrentInstant() +
|
||||||
|
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
|
||||||
|
|
||||||
|
var references = await fileReferenceService.CreateReferencesAsync(
|
||||||
|
request.FilesId.ToList(),
|
||||||
|
request.Usage,
|
||||||
|
request.ResourceId,
|
||||||
|
expiredAt
|
||||||
|
);
|
||||||
|
var response = new CreateReferenceBatchResponse();
|
||||||
|
response.References.AddRange(references.Select(r => r.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var references = await fileReferenceService.GetReferencesAsync(request.FileId);
|
||||||
|
var response = new GetReferencesResponse();
|
||||||
|
response.References.AddRange(references.Select(r => r.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetReferenceCountResponse> GetReferenceCount(GetReferenceCountRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var count = await fileReferenceService.GetReferenceCountAsync(request.FileId);
|
||||||
|
return new GetReferenceCountResponse { Count = count };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetReferencesResponse> GetResourceReferences(GetResourceReferencesRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var references = await fileReferenceService.GetResourceReferencesAsync(request.ResourceId, request.Usage);
|
||||||
|
var response = new GetReferencesResponse();
|
||||||
|
response.References.AddRange(references.Select(r => r.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetResourceFilesResponse> GetResourceFiles(GetResourceFilesRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var files = await fileReferenceService.GetResourceFilesAsync(request.ResourceId, request.Usage);
|
||||||
|
var response = new GetResourceFilesResponse();
|
||||||
|
response.Files.AddRange(files.Select(f => f.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
|
||||||
|
DeleteResourceReferencesRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
int deletedCount;
|
||||||
|
if (request.Usage is null)
|
||||||
|
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
|
||||||
|
else
|
||||||
|
deletedCount =
|
||||||
|
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
|
||||||
|
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var resourceIds = request.ResourceIds.ToList();
|
||||||
|
int deletedCount;
|
||||||
|
if (request.Usage is null)
|
||||||
|
deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds);
|
||||||
|
else
|
||||||
|
deletedCount =
|
||||||
|
await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!);
|
||||||
|
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var success = await fileReferenceService.DeleteReferenceAsync(Guid.Parse(request.ReferenceId));
|
||||||
|
return new DeleteReferenceResponse { Success = success };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<UpdateResourceFilesResponse> UpdateResourceFiles(UpdateResourceFilesRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
Instant? expiredAt = null;
|
||||||
|
if (request.ExpiredAt != null)
|
||||||
|
{
|
||||||
|
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
|
||||||
|
}
|
||||||
|
else if (request.Duration != null)
|
||||||
|
{
|
||||||
|
expiredAt = SystemClock.Instance.GetCurrentInstant() +
|
||||||
|
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
|
||||||
|
}
|
||||||
|
|
||||||
|
var references = await fileReferenceService.UpdateResourceFilesAsync(
|
||||||
|
request.ResourceId,
|
||||||
|
request.FileIds,
|
||||||
|
request.Usage,
|
||||||
|
expiredAt
|
||||||
|
);
|
||||||
|
var response = new UpdateResourceFilesResponse();
|
||||||
|
response.References.AddRange(references.Select(r => r.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<SetReferenceExpirationResponse> SetReferenceExpiration(
|
||||||
|
SetReferenceExpirationRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
Instant? expiredAt = null;
|
||||||
|
if (request.ExpiredAt != null)
|
||||||
|
{
|
||||||
|
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
|
||||||
|
}
|
||||||
|
else if (request.Duration != null)
|
||||||
|
{
|
||||||
|
expiredAt = SystemClock.Instance.GetCurrentInstant() +
|
||||||
|
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
|
||||||
|
}
|
||||||
|
|
||||||
|
var success =
|
||||||
|
await fileReferenceService.SetReferenceExpirationAsync(Guid.Parse(request.ReferenceId), expiredAt);
|
||||||
|
return new SetReferenceExpirationResponse { Success = success };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<SetFileReferencesExpirationResponse> SetFileReferencesExpiration(
|
||||||
|
SetFileReferencesExpirationRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
|
||||||
|
var updatedCount = await fileReferenceService.SetFileReferencesExpirationAsync(request.FileId, expiredAt);
|
||||||
|
return new SetFileReferencesExpirationResponse { UpdatedCount = updatedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<HasFileReferencesResponse> HasFileReferences(HasFileReferencesRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var hasReferences = await fileReferenceService.HasFileReferencesAsync(request.FileId);
|
||||||
|
return new HasFileReferencesResponse { HasReferences = hasReferences };
|
||||||
|
}
|
||||||
|
}
|
||||||
806
DysonNetwork.Drive/Storage/FileService.cs
Normal file
806
DysonNetwork.Drive/Storage/FileService.cs
Normal file
@@ -0,0 +1,806 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using FFMpegCore;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using DysonNetwork.Drive.Storage.Model;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Minio;
|
||||||
|
using Minio.DataModel.Args;
|
||||||
|
using NATS.Client.Core;
|
||||||
|
using NetVips;
|
||||||
|
using NodaTime;
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
using Microsoft.EntityFrameworkCore.Query;
|
||||||
|
using NATS.Net;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
|
public class FileService(
|
||||||
|
AppDatabase db,
|
||||||
|
ILogger<FileService> logger,
|
||||||
|
ICacheService cache,
|
||||||
|
INatsConnection nats
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private const string CacheKeyPrefix = "file:";
|
||||||
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
public async Task<SnCloudFile?> GetFileAsync(string fileId)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
||||||
|
|
||||||
|
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
|
||||||
|
if (cachedFile is not null)
|
||||||
|
return cachedFile;
|
||||||
|
|
||||||
|
var file = await db.Files
|
||||||
|
.Where(f => f.Id == fileId)
|
||||||
|
.Include(f => f.Pool)
|
||||||
|
.Include(f => f.Bundle)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (file != null)
|
||||||
|
await cache.SetAsync(cacheKey, file, CacheDuration);
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SnCloudFile>> GetFilesAsync(List<string> fileIds)
|
||||||
|
{
|
||||||
|
var cachedFiles = new Dictionary<string, SnCloudFile>();
|
||||||
|
var uncachedIds = new List<string>();
|
||||||
|
|
||||||
|
foreach (var fileId in fileIds)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
||||||
|
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
|
||||||
|
|
||||||
|
if (cachedFile != null)
|
||||||
|
cachedFiles[fileId] = cachedFile;
|
||||||
|
else
|
||||||
|
uncachedIds.Add(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uncachedIds.Count > 0)
|
||||||
|
{
|
||||||
|
var dbFiles = await db.Files
|
||||||
|
.Where(f => uncachedIds.Contains(f.Id))
|
||||||
|
.Include(f => f.Pool)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var file in dbFiles)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{file.Id}";
|
||||||
|
await cache.SetAsync(cacheKey, file, CacheDuration);
|
||||||
|
cachedFiles[file.Id] = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileIds
|
||||||
|
.Select(f => cachedFiles.GetValueOrDefault(f))
|
||||||
|
.Where(f => f != null)
|
||||||
|
.Cast<SnCloudFile>()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SnCloudFile> ProcessNewFileAsync(
|
||||||
|
Account account,
|
||||||
|
string fileId,
|
||||||
|
string filePool,
|
||||||
|
string? fileBundleId,
|
||||||
|
string filePath,
|
||||||
|
string fileName,
|
||||||
|
string? contentType,
|
||||||
|
string? encryptPassword,
|
||||||
|
Instant? expiredAt
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var accountId = Guid.Parse(account.Id);
|
||||||
|
var pool = await ValidateAndGetPoolAsync(filePool);
|
||||||
|
var bundle = await ValidateAndGetBundleAsync(fileBundleId, accountId);
|
||||||
|
var finalExpiredAt = CalculateFinalExpiration(expiredAt, pool, bundle);
|
||||||
|
|
||||||
|
var (managedTempPath, fileSize, finalContentType) =
|
||||||
|
await PrepareFileAsync(fileId, filePath, fileName, contentType);
|
||||||
|
|
||||||
|
var file = CreateFileObject(fileId, fileName, finalContentType, fileSize, finalExpiredAt, bundle, accountId);
|
||||||
|
|
||||||
|
if (!pool.PolicyConfig.NoMetadata)
|
||||||
|
{
|
||||||
|
await ExtractMetadataAsync(file, managedTempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var (processingPath, isTempFile) =
|
||||||
|
await ProcessEncryptionAsync(fileId, managedTempPath, encryptPassword, pool, file);
|
||||||
|
|
||||||
|
file.Hash = await HashFileAsync(processingPath);
|
||||||
|
|
||||||
|
await SaveFileToDatabaseAsync(file);
|
||||||
|
|
||||||
|
await PublishFileUploadedEventAsync(file, pool, processingPath, isTempFile);
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<FilePool> ValidateAndGetPoolAsync(string filePool)
|
||||||
|
{
|
||||||
|
var pool = await GetPoolAsync(Guid.Parse(filePool));
|
||||||
|
return pool ?? throw new InvalidOperationException("Pool not found: " + filePool);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SnFileBundle?> ValidateAndGetBundleAsync(string? fileBundleId, Guid accountId)
|
||||||
|
{
|
||||||
|
if (fileBundleId is null) return null;
|
||||||
|
|
||||||
|
var bundle = await GetBundleAsync(Guid.Parse(fileBundleId), accountId);
|
||||||
|
return bundle ?? throw new InvalidOperationException("Bundle not found: " + fileBundleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Instant? CalculateFinalExpiration(Instant? expiredAt, FilePool pool, SnFileBundle? bundle)
|
||||||
|
{
|
||||||
|
var finalExpiredAt = expiredAt;
|
||||||
|
|
||||||
|
// Apply pool expiration policy
|
||||||
|
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;
|
||||||
|
finalExpiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bundle expiration takes precedence
|
||||||
|
if (bundle?.ExpiredAt != null)
|
||||||
|
finalExpiredAt = bundle.ExpiredAt.Value;
|
||||||
|
|
||||||
|
return finalExpiredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string tempPath, long fileSize, string contentType)> PrepareFileAsync(
|
||||||
|
string fileId,
|
||||||
|
string filePath,
|
||||||
|
string fileName,
|
||||||
|
string? contentType
|
||||||
|
)
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
|
||||||
|
return (managedTempPath, fileSize, finalContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SnCloudFile CreateFileObject(
|
||||||
|
string fileId,
|
||||||
|
string fileName,
|
||||||
|
string contentType,
|
||||||
|
long fileSize,
|
||||||
|
Instant? expiredAt,
|
||||||
|
SnFileBundle? bundle,
|
||||||
|
Guid accountId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return new SnCloudFile
|
||||||
|
{
|
||||||
|
Id = fileId,
|
||||||
|
Name = fileName,
|
||||||
|
MimeType = contentType,
|
||||||
|
Size = fileSize,
|
||||||
|
ExpiredAt = expiredAt,
|
||||||
|
BundleId = bundle?.Id,
|
||||||
|
AccountId = accountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string processingPath, bool isTempFile)> ProcessEncryptionAsync(
|
||||||
|
string fileId,
|
||||||
|
string managedTempPath,
|
||||||
|
string? encryptPassword,
|
||||||
|
FilePool pool,
|
||||||
|
SnCloudFile file
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(encryptPassword))
|
||||||
|
return (managedTempPath, true);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
file.IsEncrypted = true;
|
||||||
|
file.MimeType = "application/octet-stream";
|
||||||
|
file.Size = new FileInfo(encryptedPath).Length;
|
||||||
|
|
||||||
|
return (encryptedPath, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveFileToDatabaseAsync(SnCloudFile file)
|
||||||
|
{
|
||||||
|
db.Files.Add(file);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
file.StorageId ??= file.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishFileUploadedEventAsync(SnCloudFile file, FilePool pool, string processingPath,
|
||||||
|
bool isTempFile)
|
||||||
|
{
|
||||||
|
var js = nats.CreateJetStreamContext();
|
||||||
|
await js.PublishAsync(
|
||||||
|
FileUploadedEvent.Type,
|
||||||
|
GrpcTypeHelper.ConvertObjectToByteString(new FileUploadedEventPayload(
|
||||||
|
file.Id,
|
||||||
|
pool.Id,
|
||||||
|
file.StorageId,
|
||||||
|
file.MimeType,
|
||||||
|
processingPath,
|
||||||
|
isTempFile)
|
||||||
|
).ToByteArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExtractMetadataAsync(SnCloudFile file, string filePath)
|
||||||
|
{
|
||||||
|
switch (file.MimeType?.Split('/')[0])
|
||||||
|
{
|
||||||
|
case "image":
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var blurhash = BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(3, 3, filePath);
|
||||||
|
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
using var vipsImage = Image.NewFromStream(stream);
|
||||||
|
var width = vipsImage.Width;
|
||||||
|
var height = vipsImage.Height;
|
||||||
|
var orientation = 1;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
orientation = vipsImage.Get("orientation") as int? ?? 1;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["blur"] = blurhash,
|
||||||
|
["format"] = vipsImage.Get("vips-loader") ?? "unknown",
|
||||||
|
["width"] = width,
|
||||||
|
["height"] = height,
|
||||||
|
["orientation"] = orientation,
|
||||||
|
};
|
||||||
|
var exif = new Dictionary<string, object>();
|
||||||
|
|
||||||
|
foreach (var field in vipsImage.GetFields())
|
||||||
|
{
|
||||||
|
if (IsIgnoredField(field)) continue;
|
||||||
|
var value = vipsImage.Get(field);
|
||||||
|
if (field.StartsWith("exif-"))
|
||||||
|
exif[field.Replace("exif-", "")] = value;
|
||||||
|
else
|
||||||
|
meta[field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation is 6 or 8) (width, height) = (height, width);
|
||||||
|
meta["exif"] = exif;
|
||||||
|
meta["ratio"] = height != 0 ? (double)width / height : 0;
|
||||||
|
file.FileMeta = meta;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
file.FileMeta = new Dictionary<string, object?>();
|
||||||
|
logger.LogError(ex, "Failed to analyze image file {FileId}", file.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "video":
|
||||||
|
case "audio":
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mediaInfo = await FFProbe.AnalyseAsync(filePath);
|
||||||
|
file.FileMeta = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["width"] = mediaInfo.PrimaryVideoStream?.Width,
|
||||||
|
["height"] = mediaInfo.PrimaryVideoStream?.Height,
|
||||||
|
["duration"] = mediaInfo.Duration.TotalSeconds,
|
||||||
|
["format_name"] = mediaInfo.Format.FormatName,
|
||||||
|
["format_long_name"] = mediaInfo.Format.FormatLongName,
|
||||||
|
["start_time"] = mediaInfo.Format.StartTime.ToString(),
|
||||||
|
["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture),
|
||||||
|
["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(),
|
||||||
|
["chapters"] = mediaInfo.Chapters,
|
||||||
|
["video_streams"] = mediaInfo.VideoStreams.Select(s => new
|
||||||
|
{
|
||||||
|
s.AvgFrameRate,
|
||||||
|
s.BitRate,
|
||||||
|
s.CodecName,
|
||||||
|
s.Duration,
|
||||||
|
s.Height,
|
||||||
|
s.Width,
|
||||||
|
s.Language,
|
||||||
|
s.PixelFormat,
|
||||||
|
s.Rotation
|
||||||
|
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
|
||||||
|
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
|
||||||
|
{
|
||||||
|
s.BitRate,
|
||||||
|
s.Channels,
|
||||||
|
s.ChannelLayout,
|
||||||
|
s.CodecName,
|
||||||
|
s.Duration,
|
||||||
|
s.Language,
|
||||||
|
s.SampleRateHz
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
};
|
||||||
|
if (mediaInfo.PrimaryVideoStream is not null)
|
||||||
|
file.FileMeta["ratio"] = (double)mediaInfo.PrimaryVideoStream.Width /
|
||||||
|
mediaInfo.PrimaryVideoStream.Height;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to analyze media file {FileId}", file.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024)
|
||||||
|
{
|
||||||
|
var fileInfo = new FileInfo(filePath);
|
||||||
|
if (fileInfo.Length > chunkSize * 1024 * 5)
|
||||||
|
return await HashFastApproximateAsync(filePath, chunkSize);
|
||||||
|
|
||||||
|
await using var stream = File.OpenRead(filePath);
|
||||||
|
using var md5 = MD5.Create();
|
||||||
|
var hashBytes = await md5.ComputeHashAsync(stream);
|
||||||
|
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> HashFastApproximateAsync(string filePath, int chunkSize = 1024 * 1024)
|
||||||
|
{
|
||||||
|
await using var stream = File.OpenRead(filePath);
|
||||||
|
|
||||||
|
var buffer = new byte[chunkSize * 2];
|
||||||
|
var fileLength = stream.Length;
|
||||||
|
|
||||||
|
var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, chunkSize));
|
||||||
|
|
||||||
|
if (fileLength > chunkSize)
|
||||||
|
{
|
||||||
|
stream.Seek(-chunkSize, SeekOrigin.End);
|
||||||
|
bytesRead += await stream.ReadAsync(buffer.AsMemory(chunkSize, chunkSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = MD5.HashData(buffer.AsSpan(0, bytesRead));
|
||||||
|
stream.Position = 0;
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UploadFileToRemoteAsync(
|
||||||
|
string storageId,
|
||||||
|
Guid targetRemote,
|
||||||
|
string filePath,
|
||||||
|
string? suffix = null,
|
||||||
|
string? contentType = null,
|
||||||
|
bool selfDestruct = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await using var fileStream = File.OpenRead(filePath);
|
||||||
|
await UploadFileToRemoteAsync(storageId, targetRemote, fileStream, suffix, contentType);
|
||||||
|
if (selfDestruct) File.Delete(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UploadFileToRemoteAsync(
|
||||||
|
string storageId,
|
||||||
|
Guid targetRemote,
|
||||||
|
Stream stream,
|
||||||
|
string? suffix = null,
|
||||||
|
string? contentType = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var dest = await GetRemoteStorageConfig(targetRemote);
|
||||||
|
if (dest is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to configure client for remote destination '{targetRemote}'"
|
||||||
|
);
|
||||||
|
var client = CreateMinioClient(dest);
|
||||||
|
|
||||||
|
var bucket = dest.Bucket;
|
||||||
|
contentType ??= "application/octet-stream";
|
||||||
|
|
||||||
|
await client!.PutObjectAsync(new PutObjectArgs()
|
||||||
|
.WithBucket(bucket)
|
||||||
|
.WithObject(string.IsNullOrWhiteSpace(suffix) ? storageId : storageId + suffix)
|
||||||
|
.WithStreamData(stream)
|
||||||
|
.WithObjectSize(stream.Length)
|
||||||
|
.WithContentType(contentType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SnCloudFile> UpdateFileAsync(SnCloudFile file, FieldMask updateMask)
|
||||||
|
{
|
||||||
|
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Id == file.Id);
|
||||||
|
if (existingFile == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"File with ID {file.Id} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatable = new UpdatableCloudFile(existingFile);
|
||||||
|
|
||||||
|
foreach (var path in updateMask.Paths)
|
||||||
|
{
|
||||||
|
switch (path)
|
||||||
|
{
|
||||||
|
case "name":
|
||||||
|
updatable.Name = file.Name;
|
||||||
|
break;
|
||||||
|
case "description":
|
||||||
|
updatable.Description = file.Description;
|
||||||
|
break;
|
||||||
|
case "file_meta":
|
||||||
|
updatable.FileMeta = file.FileMeta;
|
||||||
|
break;
|
||||||
|
case "user_meta":
|
||||||
|
updatable.UserMeta = file.UserMeta;
|
||||||
|
break;
|
||||||
|
case "is_marked_recycle":
|
||||||
|
updatable.IsMarkedRecycle = file.IsMarkedRecycle;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.LogWarning("Attempted to update unmodifiable field: {Field}", path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls());
|
||||||
|
|
||||||
|
await _PurgeCacheAsync(file.Id);
|
||||||
|
return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteFileAsync(SnCloudFile file, bool skipData = false)
|
||||||
|
{
|
||||||
|
db.Remove(file);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await _PurgeCacheAsync(file.Id);
|
||||||
|
|
||||||
|
if (!skipData)
|
||||||
|
await DeleteFileDataAsync(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false)
|
||||||
|
{
|
||||||
|
if (!file.PoolId.HasValue) return;
|
||||||
|
|
||||||
|
if (!force)
|
||||||
|
{
|
||||||
|
var sameOriginFiles = await db.Files
|
||||||
|
.Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
|
||||||
|
.Select(f => f.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (sameOriginFiles.Count != 0)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dest = await GetRemoteStorageConfig(file.PoolId.Value);
|
||||||
|
if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
|
||||||
|
var client = CreateMinioClient(dest);
|
||||||
|
if (client is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to configure client for remote destination '{file.PoolId}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
var bucket = dest.Bucket;
|
||||||
|
var objectId = file.StorageId ?? file.Id;
|
||||||
|
|
||||||
|
await client.RemoveObjectAsync(
|
||||||
|
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (file.HasCompression)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.RemoveObjectAsync(
|
||||||
|
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId + ".compressed")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.HasThumbnail)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.RemoveObjectAsync(
|
||||||
|
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId + ".thumbnail")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteFileDataBatchAsync(List<SnCloudFile> files)
|
||||||
|
{
|
||||||
|
files = files.Where(f => f.PoolId.HasValue).ToList();
|
||||||
|
|
||||||
|
foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
|
||||||
|
{
|
||||||
|
var dest = await GetRemoteStorageConfig(fileGroup.Key);
|
||||||
|
if (dest is null)
|
||||||
|
throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
|
||||||
|
var client = CreateMinioClient(dest);
|
||||||
|
if (client is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to configure client for remote destination '{fileGroup.Key}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
List<string> objectsToDelete = [];
|
||||||
|
|
||||||
|
foreach (var file in fileGroup)
|
||||||
|
{
|
||||||
|
objectsToDelete.Add(file.StorageId ?? file.Id);
|
||||||
|
if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
|
||||||
|
if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.RemoveObjectsAsync(
|
||||||
|
new RemoveObjectsArgs().WithBucket(dest.Bucket).WithObjects(objectsToDelete)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SnFileBundle?> GetBundleAsync(Guid id, Guid accountId)
|
||||||
|
{
|
||||||
|
var bundle = await db.Bundles
|
||||||
|
.Where(e => e.Id == id)
|
||||||
|
.Where(e => e.AccountId == accountId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FilePool?> GetPoolAsync(Guid destination)
|
||||||
|
{
|
||||||
|
var cacheKey = $"file:pool:{destination}";
|
||||||
|
var cachedResult = await cache.GetAsync<FilePool?>(cacheKey);
|
||||||
|
if (cachedResult != null) return cachedResult;
|
||||||
|
|
||||||
|
var pool = await db.Pools.FirstOrDefaultAsync(p => p.Id == destination);
|
||||||
|
if (pool != null)
|
||||||
|
await cache.SetAsync(cacheKey, pool);
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RemoteStorageConfig?> GetRemoteStorageConfig(Guid destination)
|
||||||
|
{
|
||||||
|
var pool = await GetPoolAsync(destination);
|
||||||
|
return pool?.StorageConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RemoteStorageConfig?> GetRemoteStorageConfig(string destination)
|
||||||
|
{
|
||||||
|
var id = Guid.Parse(destination);
|
||||||
|
return await GetRemoteStorageConfig(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IMinioClient? CreateMinioClient(RemoteStorageConfig dest)
|
||||||
|
{
|
||||||
|
var client = new MinioClient()
|
||||||
|
.WithEndpoint(dest.Endpoint)
|
||||||
|
.WithRegion(dest.Region)
|
||||||
|
.WithCredentials(dest.SecretId, dest.SecretKey);
|
||||||
|
if (dest.EnableSsl) client = client.WithSSL();
|
||||||
|
|
||||||
|
return client.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task _PurgeCacheAsync(string fileId)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
||||||
|
await cache.RemoveAsync(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds)
|
||||||
|
{
|
||||||
|
var tasks = fileIds.Select(_PurgeCacheAsync);
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SnCloudFile?>> LoadFromReference(List<SnCloudFileReferenceObject> references)
|
||||||
|
{
|
||||||
|
var cachedFiles = new Dictionary<string, SnCloudFile>();
|
||||||
|
var uncachedIds = new List<string>();
|
||||||
|
|
||||||
|
foreach (var reference in references)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{reference.Id}";
|
||||||
|
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
|
||||||
|
|
||||||
|
if (cachedFile != null)
|
||||||
|
{
|
||||||
|
cachedFiles[reference.Id] = cachedFile;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
uncachedIds.Add(reference.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uncachedIds.Count > 0)
|
||||||
|
{
|
||||||
|
var dbFiles = await db.Files
|
||||||
|
.Where(f => uncachedIds.Contains(f.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var file in dbFiles)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{file.Id}";
|
||||||
|
await cache.SetAsync(cacheKey, file, CacheDuration);
|
||||||
|
cachedFiles[file.Id] = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
.. references
|
||||||
|
.Select(r => cachedFiles.GetValueOrDefault(r.Id))
|
||||||
|
.Where(f => f != null)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetReferenceCountAsync(string fileId)
|
||||||
|
{
|
||||||
|
return await db.FileReferences
|
||||||
|
.Where(r => r.FileId == fileId)
|
||||||
|
.CountAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsReferencedAsync(string fileId)
|
||||||
|
{
|
||||||
|
return await db.FileReferences
|
||||||
|
.Where(r => r.FileId == fileId)
|
||||||
|
.AnyAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsIgnoredField(string fieldName)
|
||||||
|
{
|
||||||
|
var gpsFields = new[]
|
||||||
|
{
|
||||||
|
"gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "gps-longitude-ref",
|
||||||
|
"gps-altitude-ref", "gps-timestamp", "gps-datestamp", "gps-speed", "gps-speed-ref", "gps-track",
|
||||||
|
"gps-track-ref", "gps-img-direction", "gps-img-direction-ref", "gps-dest-latitude",
|
||||||
|
"gps-dest-longitude", "gps-dest-latitude-ref", "gps-dest-longitude-ref", "gps-processing-method",
|
||||||
|
"gps-area-information"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fieldName.StartsWith("exif-GPS")) return true;
|
||||||
|
if (fieldName.StartsWith("ifd3-GPS")) return true;
|
||||||
|
if (fieldName.EndsWith("-data")) return true;
|
||||||
|
return gpsFields.Any(gpsField => fieldName.StartsWith(gpsField, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> DeleteAccountRecycledFilesAsync(Guid accountId)
|
||||||
|
{
|
||||||
|
var files = await db.Files
|
||||||
|
.Where(f => f.AccountId == accountId && f.IsMarkedRecycle)
|
||||||
|
.ToListAsync();
|
||||||
|
var count = files.Count;
|
||||||
|
var tasks = files.Select(f => DeleteFileDataAsync(f, true));
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
var fileIds = files.Select(f => f.Id).ToList();
|
||||||
|
await _PurgeCacheRangeAsync(fileIds);
|
||||||
|
db.RemoveRange(files);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> DeleteAccountFileBatchAsync(Guid accountId, List<string> fileIds)
|
||||||
|
{
|
||||||
|
var files = await db.Files
|
||||||
|
.Where(f => f.AccountId == accountId && fileIds.Contains(f.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
var count = files.Count;
|
||||||
|
var tasks = files.Select(f => DeleteFileDataAsync(f, true));
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
var fileIdsList = files.Select(f => f.Id).ToList();
|
||||||
|
await _PurgeCacheRangeAsync(fileIdsList);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
687
DysonNetwork.Drive/Storage/FileUploadController.cs
Normal file
687
DysonNetwork.Drive/Storage/FileUploadController.cs
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Drive.Billing;
|
||||||
|
using DysonNetwork.Drive.Index;
|
||||||
|
using DysonNetwork.Drive.Storage.Model;
|
||||||
|
using DysonNetwork.Shared.Auth;
|
||||||
|
using DysonNetwork.Shared.Http;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NanoidDotNet;
|
||||||
|
using NodaTime;
|
||||||
|
using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/api/files/upload")]
|
||||||
|
[Authorize]
|
||||||
|
public class FileUploadController(
|
||||||
|
IConfiguration configuration,
|
||||||
|
FileService fileService,
|
||||||
|
AppDatabase db,
|
||||||
|
PermissionService.PermissionServiceClient permission,
|
||||||
|
QuotaService quotaService,
|
||||||
|
PersistentTaskService persistentTaskService,
|
||||||
|
FileIndexService fileIndexService,
|
||||||
|
ILogger<FileUploadController> logger
|
||||||
|
)
|
||||||
|
: 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 };
|
||||||
|
|
||||||
|
var permissionCheck = await ValidateUserPermissions(currentUser);
|
||||||
|
if (permissionCheck is not null) return permissionCheck;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|
||||||
|
var poolValidation = await ValidatePoolAccess(currentUser, pool, request);
|
||||||
|
if (poolValidation is not null) return poolValidation;
|
||||||
|
|
||||||
|
var policyValidation = ValidatePoolPolicy(pool.PolicyConfig, request);
|
||||||
|
if (policyValidation is not null) return policyValidation;
|
||||||
|
|
||||||
|
var quotaValidation = await ValidateQuota(currentUser, pool, request.FileSize);
|
||||||
|
if (quotaValidation is not null) return quotaValidation;
|
||||||
|
|
||||||
|
EnsureTempDirectoryExists();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
// Check if a file with the same hash already exists
|
||||||
|
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == request.Hash);
|
||||||
|
if (existingFile != null)
|
||||||
|
{
|
||||||
|
// Create the file index if a path is provided, even for existing files
|
||||||
|
if (string.IsNullOrEmpty(request.Path))
|
||||||
|
return Ok(new CreateUploadTaskResponse
|
||||||
|
{
|
||||||
|
FileExists = true,
|
||||||
|
File = existingFile
|
||||||
|
});
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await fileIndexService.CreateAsync(request.Path, existingFile.Id, accountId);
|
||||||
|
logger.LogInformation("Created file index for existing file {FileId} at path {Path}",
|
||||||
|
existingFile.Id, request.Path);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to create file index for existing file {FileId} at path {Path}",
|
||||||
|
existingFile.Id, request.Path);
|
||||||
|
// Don't fail the request if index creation fails, just log it
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new CreateUploadTaskResponse
|
||||||
|
{
|
||||||
|
FileExists = true,
|
||||||
|
File = existingFile
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskId = await Nanoid.GenerateAsync();
|
||||||
|
|
||||||
|
// Create persistent upload task
|
||||||
|
var persistentTask = await persistentTaskService.CreateUploadTaskAsync(taskId, request, accountId);
|
||||||
|
|
||||||
|
return Ok(new CreateUploadTaskResponse
|
||||||
|
{
|
||||||
|
FileExists = false,
|
||||||
|
TaskId = taskId,
|
||||||
|
ChunkSize = persistentTask.ChunkSize,
|
||||||
|
ChunksCount = persistentTask.ChunksCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult?> ValidateUserPermissions(Account currentUser)
|
||||||
|
{
|
||||||
|
if (currentUser.IsSuperuser) return null;
|
||||||
|
|
||||||
|
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
|
||||||
|
{ Actor = currentUser.Id, Key = "files.create" });
|
||||||
|
|
||||||
|
return allowed.HasPermission
|
||||||
|
? null
|
||||||
|
: new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<IActionResult?> ValidatePoolAccess(Account currentUser, FilePool pool, CreateUploadTaskRequest request)
|
||||||
|
{
|
||||||
|
if (pool.PolicyConfig.RequirePrivilege <= 0) return Task.FromResult<IActionResult?>(null);
|
||||||
|
|
||||||
|
var privilege = currentUser.PerkSubscription is null
|
||||||
|
? 0
|
||||||
|
: PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
|
||||||
|
|
||||||
|
if (privilege < pool.PolicyConfig.RequirePrivilege)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IActionResult?>(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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IActionResult?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IActionResult? ValidatePoolPolicy(PolicyConfig policy, CreateUploadTaskRequest request)
|
||||||
|
{
|
||||||
|
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))
|
||||||
|
return acceptType.Equals(request.ContentType, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var type = acceptType[..^2];
|
||||||
|
return request.ContentType.StartsWith($"{type}/", 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult?> ValidateQuota(Account currentUser, FilePool pool, long fileSize)
|
||||||
|
{
|
||||||
|
var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable(
|
||||||
|
Guid.Parse(currentUser.Id),
|
||||||
|
pool.BillingConfig.CostMultiplier ?? 1.0,
|
||||||
|
fileSize
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return new ObjectResult(
|
||||||
|
ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB",
|
||||||
|
true))
|
||||||
|
{ StatusCode = 403 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureTempDirectoryExists()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_tempPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UploadChunkRequest
|
||||||
|
{
|
||||||
|
[Required] public IFormFile Chunk { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("chunk/{taskId}/{chunkIndex:int}")]
|
||||||
|
[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;
|
||||||
|
|
||||||
|
// Check if chunk is already uploaded (resumable upload)
|
||||||
|
if (await persistentTaskService.IsChunkUploadedAsync(taskId, chunkIndex))
|
||||||
|
{
|
||||||
|
return Ok(new { message = "Chunk already uploaded" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskPath = Path.Combine(_tempPath, taskId);
|
||||||
|
if (!Directory.Exists(taskPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(taskPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk");
|
||||||
|
await using var stream = new FileStream(chunkPath, FileMode.Create);
|
||||||
|
await chunk.CopyToAsync(stream);
|
||||||
|
|
||||||
|
// Update persistent task progress
|
||||||
|
await persistentTaskService.UpdateChunkProgressAsync(taskId, chunkIndex);
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("complete/{taskId}")]
|
||||||
|
public async Task<IActionResult> CompleteUpload(string taskId)
|
||||||
|
{
|
||||||
|
// Get persistent task
|
||||||
|
var persistentTask = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||||
|
if (persistentTask is null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (persistentTask.AccountId != Guid.Parse(currentUser.Id))
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
var taskPath = Path.Combine(_tempPath, taskId);
|
||||||
|
if (!Directory.Exists(taskPath))
|
||||||
|
return new ObjectResult(ApiError.NotFound("Upload task directory")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await MergeChunks(taskId, taskPath, mergedFilePath, persistentTask.ChunksCount, persistentTaskService);
|
||||||
|
|
||||||
|
var fileId = await Nanoid.GenerateAsync();
|
||||||
|
var cloudFile = await fileService.ProcessNewFileAsync(
|
||||||
|
currentUser,
|
||||||
|
fileId,
|
||||||
|
persistentTask.PoolId.ToString(),
|
||||||
|
persistentTask.BundleId?.ToString(),
|
||||||
|
mergedFilePath,
|
||||||
|
persistentTask.FileName,
|
||||||
|
persistentTask.ContentType,
|
||||||
|
persistentTask.EncryptPassword,
|
||||||
|
persistentTask.ExpiredAt
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create the file index if a path is provided
|
||||||
|
if (!string.IsNullOrEmpty(persistentTask.Path))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
await fileIndexService.CreateAsync(persistentTask.Path, fileId, accountId);
|
||||||
|
logger.LogInformation("Created file index for file {FileId} at path {Path}", fileId,
|
||||||
|
persistentTask.Path);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to create file index for file {FileId} at path {Path}", fileId,
|
||||||
|
persistentTask.Path);
|
||||||
|
// Don't fail the upload if index creation fails, just log it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the task status to "processing" - background processing is now happening
|
||||||
|
await persistentTaskService.UpdateTaskProgressAsync(taskId, 0.95, "Processing file in background...");
|
||||||
|
|
||||||
|
// Send upload completion notification (a file is uploaded, but processing continues)
|
||||||
|
await persistentTaskService.SendUploadCompletedNotificationAsync(persistentTask, fileId);
|
||||||
|
|
||||||
|
return Ok(cloudFile);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Log the actual exception for debugging
|
||||||
|
logger.LogError(ex, "Failed to complete upload for task {TaskId}. Error: {ErrorMessage}", taskId,
|
||||||
|
ex.Message);
|
||||||
|
|
||||||
|
// Mark task as failed
|
||||||
|
await persistentTaskService.MarkTaskFailedAsync(taskId);
|
||||||
|
|
||||||
|
// Send failure notification
|
||||||
|
await persistentTaskService.SendUploadFailedNotificationAsync(persistentTask, ex.Message);
|
||||||
|
|
||||||
|
await CleanupTempFiles(taskPath, mergedFilePath);
|
||||||
|
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "UPLOAD_FAILED",
|
||||||
|
Message = $"Failed to complete file upload: {ex.Message}",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Always clean up temp files
|
||||||
|
await CleanupTempFiles(taskPath, mergedFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task MergeChunks(
|
||||||
|
string taskId,
|
||||||
|
string taskPath,
|
||||||
|
string mergedFilePath,
|
||||||
|
int chunksCount,
|
||||||
|
PersistentTaskService persistentTaskService)
|
||||||
|
{
|
||||||
|
await using var mergedStream = new FileStream(mergedFilePath, FileMode.Create);
|
||||||
|
|
||||||
|
const double baseProgress = 0.8; // Start from 80% (chunk upload is already at 95%)
|
||||||
|
const double remainingProgress = 0.15; // Remaining 15% progress distributed across chunks
|
||||||
|
var progressPerChunk = remainingProgress / chunksCount;
|
||||||
|
|
||||||
|
for (var i = 0; i < chunksCount; i++)
|
||||||
|
{
|
||||||
|
var chunkPath = Path.Combine(taskPath, i + ".chunk");
|
||||||
|
if (!System.IO.File.Exists(chunkPath))
|
||||||
|
throw new InvalidOperationException("Chunk " + i + " is missing.");
|
||||||
|
|
||||||
|
await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
|
||||||
|
await chunkStream.CopyToAsync(mergedStream);
|
||||||
|
|
||||||
|
// Update progress after each chunk is merged
|
||||||
|
var currentProgress = baseProgress + progressPerChunk * (i + 1);
|
||||||
|
await persistentTaskService.UpdateTaskProgressAsync(
|
||||||
|
taskId,
|
||||||
|
currentProgress,
|
||||||
|
"Merging chunks... (" + (i + 1) + "/" + chunksCount + ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task CleanupTempFiles(string taskPath, string mergedFilePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(taskPath))
|
||||||
|
Directory.Delete(taskPath, true);
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(mergedFilePath))
|
||||||
|
System.IO.File.Delete(mergedFilePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore cleanup errors to avoid masking the original exception
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New endpoints for resumable uploads
|
||||||
|
|
||||||
|
[HttpGet("tasks")]
|
||||||
|
public async Task<IActionResult> GetMyUploadTasks(
|
||||||
|
[FromQuery] UploadTaskStatus? status = null,
|
||||||
|
[FromQuery] string? sortBy = "lastActivity",
|
||||||
|
[FromQuery] bool sortDescending = true,
|
||||||
|
[FromQuery] int offset = 0,
|
||||||
|
[FromQuery] int limit = 50
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
var tasks = await persistentTaskService.GetUserUploadTasksAsync(accountId, status, sortBy, sortDescending,
|
||||||
|
offset, limit);
|
||||||
|
|
||||||
|
Response.Headers.Append("X-Total", tasks.TotalCount.ToString());
|
||||||
|
|
||||||
|
return Ok(tasks.Items.Select(t => new
|
||||||
|
{
|
||||||
|
t.TaskId,
|
||||||
|
t.FileName,
|
||||||
|
t.FileSize,
|
||||||
|
t.ContentType,
|
||||||
|
t.ChunkSize,
|
||||||
|
t.ChunksCount,
|
||||||
|
t.ChunksUploaded,
|
||||||
|
Progress = t.ChunksCount > 0 ? (double)t.ChunksUploaded / t.ChunksCount * 100 : 0,
|
||||||
|
t.Status,
|
||||||
|
t.LastActivity,
|
||||||
|
t.CreatedAt,
|
||||||
|
t.UpdatedAt,
|
||||||
|
t.UploadedChunks,
|
||||||
|
Pool = new { t.PoolId, Name = "Pool Name" }, // Could be expanded to include pool details
|
||||||
|
Bundle = t.BundleId.HasValue ? new { t.BundleId } : null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("progress/{taskId}")]
|
||||||
|
public async Task<IActionResult> GetUploadProgress(string taskId)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||||
|
if (task is null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
var progress = await persistentTaskService.GetUploadProgressAsync(taskId);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
task.TaskId,
|
||||||
|
task.FileName,
|
||||||
|
task.FileSize,
|
||||||
|
task.ChunksCount,
|
||||||
|
task.ChunksUploaded,
|
||||||
|
Progress = progress,
|
||||||
|
task.Status,
|
||||||
|
task.LastActivity,
|
||||||
|
task.UploadedChunks
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("resume/{taskId}")]
|
||||||
|
public async Task<IActionResult> ResumeUploadTask(string taskId)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||||
|
if (task is null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
// Ensure temp directory exists
|
||||||
|
var taskPath = Path.Combine(_tempPath, taskId);
|
||||||
|
if (!Directory.Exists(taskPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(taskPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
task.TaskId,
|
||||||
|
task.FileName,
|
||||||
|
task.FileSize,
|
||||||
|
task.ContentType,
|
||||||
|
task.ChunkSize,
|
||||||
|
task.ChunksCount,
|
||||||
|
task.ChunksUploaded,
|
||||||
|
task.UploadedChunks,
|
||||||
|
Progress = task.ChunksCount > 0 ? (double)task.ChunksUploaded / task.ChunksCount * 100 : 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("task/{taskId}")]
|
||||||
|
public async Task<IActionResult> CancelUploadTask(string taskId)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||||
|
if (task is null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
// Mark as failed (cancelled)
|
||||||
|
await persistentTaskService.MarkTaskFailedAsync(taskId);
|
||||||
|
|
||||||
|
// Clean up temp files
|
||||||
|
var taskPath = Path.Combine(_tempPath, taskId);
|
||||||
|
await CleanupTempFiles(taskPath, string.Empty);
|
||||||
|
|
||||||
|
return Ok(new { message = "Upload task cancelled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("stats")]
|
||||||
|
public async Task<IActionResult> GetUploadStats()
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
var stats = await persistentTaskService.GetUserUploadStatsAsync(accountId);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
stats.TotalTasks,
|
||||||
|
stats.InProgressTasks,
|
||||||
|
stats.CompletedTasks,
|
||||||
|
stats.FailedTasks,
|
||||||
|
stats.ExpiredTasks,
|
||||||
|
stats.TotalUploadedBytes,
|
||||||
|
stats.AverageProgress,
|
||||||
|
stats.RecentActivity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("tasks/cleanup")]
|
||||||
|
public async Task<IActionResult> CleanupFailedTasks()
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
var cleanedCount = await persistentTaskService.CleanupUserFailedTasksAsync(accountId);
|
||||||
|
|
||||||
|
return Ok(new { message = $"Cleaned up {cleanedCount} failed tasks" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/recent")]
|
||||||
|
public async Task<IActionResult> GetRecentTasks([FromQuery] int limit = 10)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
var tasks = await persistentTaskService.GetRecentUserTasksAsync(accountId, limit);
|
||||||
|
|
||||||
|
return Ok(tasks.Select(t => new
|
||||||
|
{
|
||||||
|
t.TaskId,
|
||||||
|
t.FileName,
|
||||||
|
t.FileSize,
|
||||||
|
t.ContentType,
|
||||||
|
Progress = t.ChunksCount > 0 ? (double)t.ChunksUploaded / t.ChunksCount * 100 : 0,
|
||||||
|
t.Status,
|
||||||
|
t.LastActivity,
|
||||||
|
t.CreatedAt
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{taskId}/details")]
|
||||||
|
public async Task<IActionResult> GetTaskDetails(string taskId)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||||
|
if (task is null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
// Get pool information
|
||||||
|
var pool = await fileService.GetPoolAsync(task.PoolId);
|
||||||
|
var bundle = task.BundleId.HasValue
|
||||||
|
? await db.Bundles.FirstOrDefaultAsync(b => b.Id == task.BundleId.Value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Task = new
|
||||||
|
{
|
||||||
|
task.TaskId,
|
||||||
|
task.FileName,
|
||||||
|
task.FileSize,
|
||||||
|
task.ContentType,
|
||||||
|
task.ChunkSize,
|
||||||
|
task.ChunksCount,
|
||||||
|
task.ChunksUploaded,
|
||||||
|
Progress = task.ChunksCount > 0 ? (double)task.ChunksUploaded / task.ChunksCount * 100 : 0,
|
||||||
|
task.Status,
|
||||||
|
task.LastActivity,
|
||||||
|
task.CreatedAt,
|
||||||
|
task.UpdatedAt,
|
||||||
|
task.ExpiredAt,
|
||||||
|
task.Hash,
|
||||||
|
task.UploadedChunks
|
||||||
|
},
|
||||||
|
Pool = pool != null
|
||||||
|
? new
|
||||||
|
{
|
||||||
|
pool.Id,
|
||||||
|
pool.Name,
|
||||||
|
pool.Description
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
Bundle = bundle != null
|
||||||
|
? new
|
||||||
|
{
|
||||||
|
bundle.Id,
|
||||||
|
bundle.Name,
|
||||||
|
bundle.Description
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
EstimatedTimeRemaining = CalculateEstimatedTime(task),
|
||||||
|
UploadSpeed = CalculateUploadSpeed(task)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? CalculateEstimatedTime(PersistentUploadTask task)
|
||||||
|
{
|
||||||
|
if (task.Status != TaskStatus.InProgress || task.ChunksUploaded == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var elapsed = NodaTime.SystemClock.Instance.GetCurrentInstant() - task.CreatedAt;
|
||||||
|
var elapsedSeconds = elapsed.TotalSeconds;
|
||||||
|
var chunksPerSecond = task.ChunksUploaded / elapsedSeconds;
|
||||||
|
var remainingChunks = task.ChunksCount - task.ChunksUploaded;
|
||||||
|
|
||||||
|
if (chunksPerSecond <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var remainingSeconds = remainingChunks / chunksPerSecond;
|
||||||
|
|
||||||
|
return remainingSeconds switch
|
||||||
|
{
|
||||||
|
< 60 => $"{remainingSeconds:F0} seconds",
|
||||||
|
< 3600 => $"{remainingSeconds / 60:F0} minutes",
|
||||||
|
_ => $"{remainingSeconds / 3600:F1} hours"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? CalculateUploadSpeed(PersistentUploadTask task)
|
||||||
|
{
|
||||||
|
if (task.ChunksUploaded == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var elapsed = SystemClock.Instance.GetCurrentInstant() - task.CreatedAt;
|
||||||
|
var elapsedSeconds = elapsed.TotalSeconds;
|
||||||
|
var bytesUploaded = task.ChunksUploaded * task.ChunkSize;
|
||||||
|
var bytesPerSecond = bytesUploaded / elapsedSeconds;
|
||||||
|
|
||||||
|
return bytesPerSecond switch
|
||||||
|
{
|
||||||
|
< 1024 => $"{bytesPerSecond:F0} B/s",
|
||||||
|
< 1024 * 1024 => $"{bytesPerSecond / 1024:F0} KB/s",
|
||||||
|
_ => $"{bytesPerSecond / (1024 * 1024):F1} MB/s"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
);
|
||||||
670
DysonNetwork.Drive/Storage/Model/FileUploadModels.cs
Normal file
670
DysonNetwork.Drive/Storage/Model/FileUploadModels.cs
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Google.Protobuf.Collections;
|
||||||
|
using NodaTime;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Storage.Model;
|
||||||
|
|
||||||
|
// File Upload Task Parameters
|
||||||
|
public class FileUploadParameters
|
||||||
|
{
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
public string ContentType { get; set; } = string.Empty;
|
||||||
|
public long ChunkSize { get; set; } = 5242880L;
|
||||||
|
public int ChunksCount { get; set; }
|
||||||
|
public int ChunksUploaded { get; set; }
|
||||||
|
public Guid PoolId { get; set; }
|
||||||
|
public Guid? BundleId { get; set; }
|
||||||
|
public string? EncryptPassword { get; set; }
|
||||||
|
public string Hash { get; set; } = string.Empty;
|
||||||
|
public List<int> UploadedChunks { get; set; } = [];
|
||||||
|
public string? Path { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Move Task Parameters
|
||||||
|
public class FileMoveParameters
|
||||||
|
{
|
||||||
|
public List<string> FileIds { get; set; } = [];
|
||||||
|
public Guid TargetPoolId { get; set; }
|
||||||
|
public Guid? TargetBundleId { get; set; }
|
||||||
|
public int FilesProcessed { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Compression Task Parameters
|
||||||
|
public class FileCompressParameters
|
||||||
|
{
|
||||||
|
public List<string> FileIds { get; set; } = [];
|
||||||
|
public string CompressionFormat { get; set; } = "zip";
|
||||||
|
public int CompressionLevel { get; set; } = 6;
|
||||||
|
public string? OutputFileName { get; set; }
|
||||||
|
public int FilesProcessed { get; set; }
|
||||||
|
public string? ResultFileId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk Operation Task Parameters
|
||||||
|
public class BulkOperationParameters
|
||||||
|
{
|
||||||
|
public string OperationType { get; set; } = string.Empty;
|
||||||
|
public List<string> TargetIds { get; set; } = [];
|
||||||
|
public Dictionary<string, object?> OperationParameters { get; set; } = new();
|
||||||
|
public int ItemsProcessed { get; set; }
|
||||||
|
public Dictionary<string, object?>? OperationResults { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage Migration Task Parameters
|
||||||
|
public class StorageMigrationParameters
|
||||||
|
{
|
||||||
|
public Guid SourcePoolId { get; set; }
|
||||||
|
public Guid TargetPoolId { get; set; }
|
||||||
|
public List<string> FileIds { get; set; } = new();
|
||||||
|
public bool PreserveOriginals { get; set; } = true;
|
||||||
|
public long TotalBytesToTransfer { get; set; }
|
||||||
|
public long BytesTransferred { get; set; }
|
||||||
|
public int FilesMigrated { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper class for parameter operations using GrpcTypeHelper
|
||||||
|
public static class ParameterHelper
|
||||||
|
{
|
||||||
|
public static T? Typed<T>(Dictionary<string, object?> parameters)
|
||||||
|
{
|
||||||
|
var rawParams = GrpcTypeHelper.ConvertObjectToByteString(parameters);
|
||||||
|
return GrpcTypeHelper.ConvertByteStringToObject<T>(rawParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> Untyped<T>(T parameters)
|
||||||
|
{
|
||||||
|
var rawParams = GrpcTypeHelper.ConvertObjectToByteString(parameters);
|
||||||
|
return GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(rawParams) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 string? Path { 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!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PersistentTask : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[MaxLength(64)] public string TaskId { get; set; } = null!;
|
||||||
|
|
||||||
|
[MaxLength(256)] public string Name { get; set; } = null!;
|
||||||
|
|
||||||
|
[MaxLength(1024)] public string? Description { get; set; }
|
||||||
|
|
||||||
|
public TaskType Type { get; set; }
|
||||||
|
|
||||||
|
public TaskStatus Status { get; set; } = TaskStatus.InProgress;
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
|
||||||
|
// Progress tracking (0-100)
|
||||||
|
public double Progress { get; set; }
|
||||||
|
|
||||||
|
// Task-specific parameters stored as JSON
|
||||||
|
[Column(TypeName = "jsonb")] public Dictionary<string, object?> Parameters { get; set; } = new();
|
||||||
|
|
||||||
|
// Task results/output stored as JSON
|
||||||
|
[Column(TypeName = "jsonb")] public Dictionary<string, object?> Results { get; set; } = new();
|
||||||
|
|
||||||
|
[MaxLength(1024)] public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
public Instant? StartedAt { get; set; }
|
||||||
|
public Instant? CompletedAt { get; set; }
|
||||||
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
|
||||||
|
public Instant LastActivity { get; set; }
|
||||||
|
|
||||||
|
// Priority (higher = more important)
|
||||||
|
public int Priority { get; set; } = 0;
|
||||||
|
|
||||||
|
// Estimated duration in seconds
|
||||||
|
public long? EstimatedDurationSeconds { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility - UploadTask inherits from PersistentTask
|
||||||
|
public class PersistentUploadTask : PersistentTask
|
||||||
|
{
|
||||||
|
public PersistentUploadTask()
|
||||||
|
{
|
||||||
|
Type = TaskType.FileUpload;
|
||||||
|
Name = "File Upload";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience properties using typed parameters
|
||||||
|
[NotMapped]
|
||||||
|
public FileUploadParameters TypedParameters
|
||||||
|
{
|
||||||
|
get => ParameterHelper.Typed<FileUploadParameters>(Parameters)!;
|
||||||
|
set => Parameters = ParameterHelper.Untyped(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string FileName
|
||||||
|
{
|
||||||
|
get => TypedParameters.FileName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FileName = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long FileSize
|
||||||
|
{
|
||||||
|
get => TypedParameters.FileSize;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FileSize = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string ContentType
|
||||||
|
{
|
||||||
|
get => TypedParameters.ContentType;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ContentType = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long ChunkSize
|
||||||
|
{
|
||||||
|
get => TypedParameters.ChunkSize;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ChunkSize = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ChunksCount
|
||||||
|
{
|
||||||
|
get => TypedParameters.ChunksCount;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ChunksCount = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ChunksUploaded
|
||||||
|
{
|
||||||
|
get => TypedParameters.ChunksUploaded;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ChunksUploaded = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
Progress = ChunksCount > 0 ? (double)value / ChunksCount * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid PoolId
|
||||||
|
{
|
||||||
|
get => TypedParameters.PoolId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.PoolId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid? BundleId
|
||||||
|
{
|
||||||
|
get => TypedParameters.BundleId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.BundleId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string? EncryptPassword
|
||||||
|
{
|
||||||
|
get => TypedParameters.EncryptPassword;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.EncryptPassword = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Hash
|
||||||
|
{
|
||||||
|
get => TypedParameters.Hash;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.Hash = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON array of uploaded chunk indices for resumability
|
||||||
|
public List<int> UploadedChunks
|
||||||
|
{
|
||||||
|
get => TypedParameters.UploadedChunks;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.UploadedChunks = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Path
|
||||||
|
{
|
||||||
|
get => TypedParameters.Path;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.Path = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TaskType
|
||||||
|
{
|
||||||
|
FileUpload,
|
||||||
|
FileMove,
|
||||||
|
FileCompress,
|
||||||
|
FileDecompress,
|
||||||
|
FileEncrypt,
|
||||||
|
FileDecrypt,
|
||||||
|
BulkOperation,
|
||||||
|
StorageMigration,
|
||||||
|
FileConversion,
|
||||||
|
Custom
|
||||||
|
}
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum TaskStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
InProgress,
|
||||||
|
Paused,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
Cancelled,
|
||||||
|
Expired
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Move Task
|
||||||
|
public class FileMoveTask : PersistentTask
|
||||||
|
{
|
||||||
|
public FileMoveTask()
|
||||||
|
{
|
||||||
|
Type = TaskType.FileMove;
|
||||||
|
Name = "Move Files";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience properties using typed parameters
|
||||||
|
public FileMoveParameters TypedParameters
|
||||||
|
{
|
||||||
|
get => ParameterHelper.Typed<FileMoveParameters>(Parameters)!;
|
||||||
|
set => Parameters = ParameterHelper.Untyped(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> FileIds
|
||||||
|
{
|
||||||
|
get => TypedParameters.FileIds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FileIds = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid TargetPoolId
|
||||||
|
{
|
||||||
|
get => TypedParameters.TargetPoolId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.TargetPoolId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid? TargetBundleId
|
||||||
|
{
|
||||||
|
get => TypedParameters.TargetBundleId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.TargetBundleId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int FilesProcessed
|
||||||
|
{
|
||||||
|
get => TypedParameters.FilesProcessed;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FilesProcessed = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
Progress = FileIds.Count > 0 ? (double)value / FileIds.Count * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Compression Task
|
||||||
|
public class FileCompressTask : PersistentTask
|
||||||
|
{
|
||||||
|
public FileCompressTask()
|
||||||
|
{
|
||||||
|
Type = TaskType.FileCompress;
|
||||||
|
Name = "Compress Files";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience properties using typed parameters
|
||||||
|
public FileCompressParameters TypedParameters
|
||||||
|
{
|
||||||
|
get => ParameterHelper.Typed<FileCompressParameters>(Parameters)!;
|
||||||
|
set => Parameters = ParameterHelper.Untyped(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> FileIds
|
||||||
|
{
|
||||||
|
get => TypedParameters.FileIds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FileIds = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string CompressionFormat
|
||||||
|
{
|
||||||
|
get => TypedParameters.CompressionFormat;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.CompressionFormat = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CompressionLevel
|
||||||
|
{
|
||||||
|
get => TypedParameters.CompressionLevel;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.CompressionLevel = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? OutputFileName
|
||||||
|
{
|
||||||
|
get => TypedParameters.OutputFileName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.OutputFileName = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int FilesProcessed
|
||||||
|
{
|
||||||
|
get => TypedParameters.FilesProcessed;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FilesProcessed = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
Progress = FileIds.Count > 0 ? (double)value / FileIds.Count * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ResultFileId
|
||||||
|
{
|
||||||
|
get => TypedParameters.ResultFileId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ResultFileId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk Operation Task
|
||||||
|
public class BulkOperationTask : PersistentTask
|
||||||
|
{
|
||||||
|
public BulkOperationTask()
|
||||||
|
{
|
||||||
|
Type = TaskType.BulkOperation;
|
||||||
|
Name = "Bulk Operation";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience properties using typed parameters
|
||||||
|
public BulkOperationParameters TypedParameters
|
||||||
|
{
|
||||||
|
get => ParameterHelper.Typed<BulkOperationParameters>(Parameters)!;
|
||||||
|
set => Parameters = ParameterHelper.Untyped(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string OperationType
|
||||||
|
{
|
||||||
|
get => TypedParameters.OperationType;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.OperationType = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> TargetIds
|
||||||
|
{
|
||||||
|
get => TypedParameters.TargetIds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.TargetIds = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")]
|
||||||
|
public Dictionary<string, object?> OperationParameters
|
||||||
|
{
|
||||||
|
get => TypedParameters.OperationParameters;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.OperationParameters = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ItemsProcessed
|
||||||
|
{
|
||||||
|
get => TypedParameters.ItemsProcessed;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ItemsProcessed = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
Progress = TargetIds.Count > 0 ? (double)value / TargetIds.Count * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")]
|
||||||
|
public Dictionary<string, object?>? OperationResults
|
||||||
|
{
|
||||||
|
get => TypedParameters.OperationResults;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.OperationResults = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage Migration Task
|
||||||
|
public class StorageMigrationTask : PersistentTask
|
||||||
|
{
|
||||||
|
public StorageMigrationTask()
|
||||||
|
{
|
||||||
|
Type = TaskType.StorageMigration;
|
||||||
|
Name = "Storage Migration";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience properties using typed parameters
|
||||||
|
public StorageMigrationParameters TypedParameters
|
||||||
|
{
|
||||||
|
get => ParameterHelper.Typed<StorageMigrationParameters>(Parameters)!;
|
||||||
|
set => Parameters = ParameterHelper.Untyped(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid SourcePoolId
|
||||||
|
{
|
||||||
|
get => TypedParameters.SourcePoolId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.SourcePoolId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid TargetPoolId
|
||||||
|
{
|
||||||
|
get => TypedParameters.TargetPoolId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.TargetPoolId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> FileIds
|
||||||
|
{
|
||||||
|
get => TypedParameters.FileIds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FileIds = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool PreserveOriginals
|
||||||
|
{
|
||||||
|
get => TypedParameters.PreserveOriginals;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.PreserveOriginals = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long TotalBytesToTransfer
|
||||||
|
{
|
||||||
|
get => TypedParameters.TotalBytesToTransfer;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.TotalBytesToTransfer = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long BytesTransferred
|
||||||
|
{
|
||||||
|
get => TypedParameters.BytesTransferred;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.BytesTransferred = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
Progress = TotalBytesToTransfer > 0 ? (double)value / TotalBytesToTransfer * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int FilesMigrated
|
||||||
|
{
|
||||||
|
get => TypedParameters.FilesMigrated;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FilesMigrated = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy enum for backward compatibility
|
||||||
|
public enum UploadTaskStatus
|
||||||
|
{
|
||||||
|
InProgress = TaskStatus.InProgress,
|
||||||
|
Completed = TaskStatus.Completed,
|
||||||
|
Failed = TaskStatus.Failed,
|
||||||
|
Expired = TaskStatus.Expired
|
||||||
|
}
|
||||||
1143
DysonNetwork.Drive/Storage/PersistentTaskService.cs
Normal file
1143
DysonNetwork.Drive/Storage/PersistentTaskService.cs
Normal file
File diff suppressed because it is too large
Load Diff
994
DysonNetwork.Drive/Storage/README.md
Normal file
994
DysonNetwork.Drive/Storage/README.md
Normal file
@@ -0,0 +1,994 @@
|
|||||||
|
# DysonNetwork Drive - Persistent Task System
|
||||||
|
|
||||||
|
A comprehensive, production-ready generic task system with support for file uploads, background operations, real-time progress tracking, and dynamic notifications powered by RingService.
|
||||||
|
|
||||||
|
When using with the Gateway, use the `/drive` to replace `/api`.
|
||||||
|
The realtime messages are from the websocket gateway.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
### Core Task Features
|
||||||
|
- **Generic Task System**: Support for various background operations beyond file uploads
|
||||||
|
- **Resumable Uploads**: Pause and resume uploads across app restarts
|
||||||
|
- **Chunked Uploads**: Efficient large file handling with configurable chunk sizes
|
||||||
|
- **Progress Persistence**: Task state survives server restarts and network interruptions
|
||||||
|
- **Duplicate Detection**: Automatic detection of already uploaded files via hash checking
|
||||||
|
- **Quota Management**: Integration with user quota and billing systems
|
||||||
|
- **Pool-based Storage**: Support for multiple storage pools with different policies
|
||||||
|
|
||||||
|
### Real-Time Features
|
||||||
|
- **Live Progress Updates**: WebSocket-based real-time progress tracking for all task types
|
||||||
|
- **Task Lifecycle Notifications**: Instant notifications for task creation, progress, completion, and failure
|
||||||
|
- **Failure Alerts**: Immediate notification of task failures with error details
|
||||||
|
- **Push Notifications**: Cross-platform push notifications for mobile/desktop
|
||||||
|
- **Smart Throttling**: Optimized update frequency to prevent network spam
|
||||||
|
|
||||||
|
### Management Features
|
||||||
|
- **Task Listing**: Comprehensive API for listing and filtering all task types
|
||||||
|
- **Task Statistics**: Detailed analytics and usage statistics for all operations
|
||||||
|
- **Cleanup Operations**: Automatic and manual cleanup of failed/stale tasks
|
||||||
|
- **Ownership Verification**: Secure access control for all operations
|
||||||
|
- **Detailed Task Info**: Rich metadata including progress, parameters, and results
|
||||||
|
- **Task Lifecycle Management**: Full control over task states (pause, resume, cancel)
|
||||||
|
|
||||||
|
## 📋 Table of Contents
|
||||||
|
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [API Reference](#api-reference)
|
||||||
|
- [WebSocket Events](#websocket-events)
|
||||||
|
- [Database Schema](#database-schema)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Usage Examples](#usage-examples)
|
||||||
|
- [Error Handling](#error-handling)
|
||||||
|
- [Performance](#performance)
|
||||||
|
- [Security](#security)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### 1. Create Upload Task
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/files/upload/create
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"fileName": "large-video.mp4",
|
||||||
|
"fileSize": 1073741824,
|
||||||
|
"contentType": "video/mp4",
|
||||||
|
"poolId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"chunkSize": 8388608
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"taskId": "abc123def456ghi789",
|
||||||
|
"chunkSize": 8388608,
|
||||||
|
"chunksCount": 128
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Upload Chunks
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/files/upload/chunk/abc123def456ghi789/0
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
(chunk data as form file)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Complete Upload
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/files/upload/complete/abc123def456ghi789
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 API Reference
|
||||||
|
|
||||||
|
### Upload Task Management
|
||||||
|
|
||||||
|
#### `POST /api/files/upload/create`
|
||||||
|
Creates a new resumable upload task.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileName": "string",
|
||||||
|
"fileSize": "long",
|
||||||
|
"contentType": "string",
|
||||||
|
"poolId": "uuid",
|
||||||
|
"bundleId": "uuid",
|
||||||
|
"chunkSize": "long",
|
||||||
|
"encryptPassword": "string",
|
||||||
|
"expiredAt": "datetime",
|
||||||
|
"hash": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field Descriptions:**
|
||||||
|
- `fileName`: Required - Name of the file
|
||||||
|
- `fileSize`: Required - Size in bytes
|
||||||
|
- `contentType`: Required - MIME type
|
||||||
|
- `poolId`: Optional - Storage pool ID
|
||||||
|
- `bundleId`: Optional - File bundle ID
|
||||||
|
- `chunkSize`: Optional - Chunk size (default: 5MB)
|
||||||
|
- `encryptPassword`: Optional - Encryption password
|
||||||
|
- `expiredAt`: Optional - Expiration date
|
||||||
|
- `hash`: Required - File hash for deduplication
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileExists": false,
|
||||||
|
"taskId": "string",
|
||||||
|
"chunkSize": 5242880,
|
||||||
|
"chunksCount": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/files/upload/chunk/{taskId}/{chunkIndex}`
|
||||||
|
Uploads a specific chunk of the file.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `taskId`: Upload task identifier
|
||||||
|
- `chunkIndex`: Zero-based chunk index
|
||||||
|
|
||||||
|
**Request:** Multipart form data with chunk file
|
||||||
|
|
||||||
|
**Response:** `200 OK` or `409 Conflict` (chunk already uploaded)
|
||||||
|
|
||||||
|
#### `POST /api/files/upload/complete/{taskId}`
|
||||||
|
Completes the upload and processes the file.
|
||||||
|
|
||||||
|
**Response:** CloudFile object with file metadata
|
||||||
|
|
||||||
|
### Task Information & Management
|
||||||
|
|
||||||
|
#### `GET /api/files/upload/tasks`
|
||||||
|
Lists user's upload tasks with filtering and pagination.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `status`: Filter by status (`InProgress`, `Completed`, `Failed`, `Expired`)
|
||||||
|
- `sortBy`: Sort field (`filename`, `filesize`, `createdAt`, `updatedAt`, `lastActivity`)
|
||||||
|
- `sortDescending`: Sort direction (default: `true`)
|
||||||
|
- `offset`: Pagination offset (default: `0`)
|
||||||
|
- `limit`: Page size (default: `50`)
|
||||||
|
|
||||||
|
**Response Headers:**
|
||||||
|
- `X-Total`: Total number of tasks matching filters
|
||||||
|
|
||||||
|
#### `GET /api/files/upload/progress/{taskId}`
|
||||||
|
Gets current progress for a specific task.
|
||||||
|
|
||||||
|
#### `GET /api/files/upload/resume/{taskId}`
|
||||||
|
Gets task information needed to resume an interrupted upload.
|
||||||
|
|
||||||
|
#### `DELETE /api/files/upload/task/{taskId}`
|
||||||
|
Cancels an upload task and cleans up resources.
|
||||||
|
|
||||||
|
#### `GET /api/files/upload/tasks/{taskId}/details`
|
||||||
|
Gets comprehensive details about a specific task including:
|
||||||
|
- Full task metadata
|
||||||
|
- Pool and bundle information
|
||||||
|
- Estimated time remaining
|
||||||
|
- Current upload speed
|
||||||
|
|
||||||
|
#### `GET /api/files/upload/stats`
|
||||||
|
Gets upload statistics for the current user.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"totalTasks": 25,
|
||||||
|
"inProgressTasks": 3,
|
||||||
|
"completedTasks": 20,
|
||||||
|
"failedTasks": 1,
|
||||||
|
"expiredTasks": 1,
|
||||||
|
"totalUploadedBytes": 5368709120,
|
||||||
|
"averageProgress": 67.5,
|
||||||
|
"recentActivity": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `DELETE /api/files/upload/tasks/cleanup`
|
||||||
|
Cleans up all failed and expired tasks for the current user.
|
||||||
|
|
||||||
|
#### `GET /api/files/upload/tasks/recent?limit=10`
|
||||||
|
Gets the most recent upload tasks.
|
||||||
|
|
||||||
|
## 🔌 WebSocket Events
|
||||||
|
|
||||||
|
The system sends real-time updates via WebSocket using RingService. Connect to the WebSocket endpoint and listen for task-related events.
|
||||||
|
|
||||||
|
### Event Types
|
||||||
|
|
||||||
|
#### `task.created`
|
||||||
|
Sent when a new task is created.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task.created",
|
||||||
|
"data": {
|
||||||
|
"taskId": "task123",
|
||||||
|
"name": "Upload File",
|
||||||
|
"type": "FileUpload",
|
||||||
|
"createdAt": "2025-11-09T02:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `task.progress`
|
||||||
|
Sent when task progress changes significantly (every 5% or major milestones).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task.progress",
|
||||||
|
"data": {
|
||||||
|
"taskId": "task123",
|
||||||
|
"name": "Upload File",
|
||||||
|
"type": "FileUpload",
|
||||||
|
"progress": 67.5,
|
||||||
|
"status": "InProgress",
|
||||||
|
"lastActivity": "2025-11-09T02:05:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `task.completed`
|
||||||
|
Sent when a task completes successfully.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task.completed",
|
||||||
|
"data": {
|
||||||
|
"taskId": "task123",
|
||||||
|
"name": "Upload File",
|
||||||
|
"type": "FileUpload",
|
||||||
|
"completedAt": "2025-11-09T02:10:00Z",
|
||||||
|
"results": {
|
||||||
|
"fileId": "file456",
|
||||||
|
"fileName": "document.pdf",
|
||||||
|
"fileSize": 10485760
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `task.failed`
|
||||||
|
Sent when a task fails.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task.failed",
|
||||||
|
"data": {
|
||||||
|
"taskId": "task123",
|
||||||
|
"name": "Upload File",
|
||||||
|
"type": "FileUpload",
|
||||||
|
"failedAt": "2025-11-09T02:15:00Z",
|
||||||
|
"errorMessage": "File processing failed: invalid format"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Integration Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// WebSocket connection
|
||||||
|
const ws = new WebSocket('wss://api.dysonnetwork.com/ws');
|
||||||
|
|
||||||
|
// Authentication (implement based on your auth system)
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'auth',
|
||||||
|
token: 'your-jwt-token'
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle task events
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const packet = JSON.parse(event.data);
|
||||||
|
|
||||||
|
switch (packet.type) {
|
||||||
|
case 'task.progress':
|
||||||
|
updateProgressBar(packet.data);
|
||||||
|
break;
|
||||||
|
case 'task.completed':
|
||||||
|
showSuccessNotification(packet.data);
|
||||||
|
break;
|
||||||
|
case 'task.failed':
|
||||||
|
showErrorNotification(packet.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateProgressBar(data) {
|
||||||
|
const progressBar = document.getElementById(`progress-${data.taskId}`);
|
||||||
|
if (progressBar) {
|
||||||
|
progressBar.style.width = `${data.progress}%`;
|
||||||
|
progressBar.textContent = `${data.progress.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Note on Upload-Specific Notifications
|
||||||
|
|
||||||
|
The system also includes upload-specific notifications (`upload.progress`, `upload.completed`, `upload.failed`) for backward compatibility. However, for new implementations, it's recommended to use the generic task notifications as they provide the same functionality with less object allocation overhead. Since users are typically in the foreground during upload operations, the generic task notifications provide sufficient progress visibility.
|
||||||
|
|
||||||
|
## 🗄️ Database Schema
|
||||||
|
|
||||||
|
### `upload_tasks` Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE upload_tasks (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
task_id VARCHAR NOT NULL UNIQUE,
|
||||||
|
file_name VARCHAR NOT NULL,
|
||||||
|
file_size BIGINT NOT NULL,
|
||||||
|
content_type VARCHAR NOT NULL,
|
||||||
|
chunk_size BIGINT NOT NULL,
|
||||||
|
chunks_count INTEGER NOT NULL,
|
||||||
|
chunks_uploaded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pool_id UUID NOT NULL,
|
||||||
|
bundle_id UUID,
|
||||||
|
encrypt_password VARCHAR,
|
||||||
|
expired_at TIMESTAMPTZ,
|
||||||
|
hash VARCHAR NOT NULL,
|
||||||
|
account_id UUID NOT NULL,
|
||||||
|
status INTEGER NOT NULL DEFAULT 0,
|
||||||
|
uploaded_chunks JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
last_activity TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX idx_upload_tasks_account_id ON upload_tasks(account_id);
|
||||||
|
CREATE INDEX idx_upload_tasks_status ON upload_tasks(status);
|
||||||
|
CREATE INDEX idx_upload_tasks_last_activity ON upload_tasks(last_activity);
|
||||||
|
CREATE INDEX idx_upload_tasks_hash ON upload_tasks(hash);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Enum Values
|
||||||
|
- `0`: InProgress
|
||||||
|
- `1`: Completed
|
||||||
|
- `2`: Failed
|
||||||
|
- `3`: Expired
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Storage configuration
|
||||||
|
STORAGE_UPLOADS_PATH=/tmp/uploads
|
||||||
|
STORAGE_PREFERRED_REMOTE=550e8400-e29b-41d4-a716-446655440000
|
||||||
|
|
||||||
|
# Chunk size settings
|
||||||
|
UPLOAD_DEFAULT_CHUNK_SIZE=5242880 # 5MB
|
||||||
|
UPLOAD_MAX_CHUNK_SIZE=16777216 # 16MB
|
||||||
|
|
||||||
|
# Cleanup settings
|
||||||
|
UPLOAD_STALE_THRESHOLD_HOURS=24
|
||||||
|
UPLOAD_CLEANUP_INTERVAL_MINUTES=60
|
||||||
|
|
||||||
|
# Cache settings
|
||||||
|
UPLOAD_CACHE_DURATION_MINUTES=30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Injection
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// In Program.cs or Startup.cs
|
||||||
|
builder.Services.AddScoped<PersistentTaskService>();
|
||||||
|
builder.Services.AddSingleton<RingService.RingServiceClient>(sp => {
|
||||||
|
// Configure gRPC client for RingService
|
||||||
|
var channel = GrpcChannel.ForAddress("https://ring-service:50051");
|
||||||
|
return new RingService.RingServiceClient(channel);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💡 Usage Examples
|
||||||
|
|
||||||
|
### Basic Upload Flow
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class UploadManager {
|
||||||
|
constructor() {
|
||||||
|
this.ws = new WebSocket('wss://api.dysonnetwork.com/ws');
|
||||||
|
this.tasks = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(file, poolId) {
|
||||||
|
// 1. Create upload task
|
||||||
|
const taskResponse = await fetch('/api/files/upload/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
contentType: file.type,
|
||||||
|
poolId: poolId,
|
||||||
|
hash: await this.calculateHash(file)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = await taskResponse.json();
|
||||||
|
if (task.fileExists) {
|
||||||
|
return task.file; // File already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Upload chunks
|
||||||
|
const chunks = this.splitFileIntoChunks(file, task.chunkSize);
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
await this.uploadChunk(task.taskId, i, chunks[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Complete upload
|
||||||
|
const result = await fetch(`/api/files/upload/complete/${task.taskId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
return await result.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadChunk(taskId, chunkIndex, chunkData) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('chunk', chunkData);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/files/upload/chunk/${taskId}/${chunkIndex}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
// Chunk already uploaded, skip
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Upload failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
splitFileIntoChunks(file, chunkSize) {
|
||||||
|
const chunks = [];
|
||||||
|
for (let offset = 0; offset < file.size; offset += chunkSize) {
|
||||||
|
chunks.push(file.slice(offset, offset + chunkSize));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateHash(file) {
|
||||||
|
// Implement file hashing (SHA-256 recommended)
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||||
|
return Array.from(new Uint8Array(hashBuffer))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resume Interrupted Upload
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async resumeUpload(taskId) {
|
||||||
|
// Get task information
|
||||||
|
const resumeResponse = await fetch(`/api/files/upload/resume/${taskId}`);
|
||||||
|
const taskInfo = await resumeResponse.json();
|
||||||
|
|
||||||
|
// Get uploaded chunks
|
||||||
|
const uploadedChunks = new Set(taskInfo.uploadedChunks);
|
||||||
|
|
||||||
|
// Upload missing chunks
|
||||||
|
for (let i = 0; i < taskInfo.chunksCount; i++) {
|
||||||
|
if (!uploadedChunks.has(i)) {
|
||||||
|
await this.uploadChunk(taskId, i, this.getChunkData(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete upload
|
||||||
|
await fetch(`/api/files/upload/complete/${taskId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitor Upload Progress
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function setupProgressMonitoring(taskId) {
|
||||||
|
// Listen for WebSocket progress events
|
||||||
|
this.ws.addEventListener('message', (event) => {
|
||||||
|
const packet = JSON.parse(event.data);
|
||||||
|
if (packet.type === 'upload.progress' && packet.data.taskId === taskId) {
|
||||||
|
updateProgressUI(packet.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgressUI(progressData) {
|
||||||
|
const progressBar = document.getElementById('upload-progress');
|
||||||
|
const progressText = document.getElementById('progress-text');
|
||||||
|
const speedText = document.getElementById('upload-speed');
|
||||||
|
|
||||||
|
progressBar.style.width = `${progressData.progress}%`;
|
||||||
|
progressText.textContent = `${progressData.progress.toFixed(1)}%`;
|
||||||
|
|
||||||
|
// Calculate speed if we have timing data
|
||||||
|
if (this.lastProgress) {
|
||||||
|
const timeDiff = Date.now() - this.lastUpdate;
|
||||||
|
const progressDiff = progressData.progress - this.lastProgress.progress;
|
||||||
|
const speed = (progressDiff / 100) * (progressData.fileSize / 1024 / 1024) / (timeDiff / 1000);
|
||||||
|
speedText.textContent = `${speed.toFixed(1)} MB/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastProgress = progressData;
|
||||||
|
this.lastUpdate = Date.now();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Error Handling
|
||||||
|
|
||||||
|
### Common Error Codes
|
||||||
|
|
||||||
|
- `400 Bad Request`: Invalid request parameters
|
||||||
|
- `401 Unauthorized`: Authentication required
|
||||||
|
- `403 Forbidden`: Insufficient permissions or quota exceeded
|
||||||
|
- `404 Not Found`: Task or resource not found
|
||||||
|
- `409 Conflict`: Chunk already uploaded (resumable upload)
|
||||||
|
- `413 Payload Too Large`: File exceeds size limits
|
||||||
|
- `429 Too Many Requests`: Rate limit exceeded
|
||||||
|
|
||||||
|
### Error Response Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "UPLOAD_FAILED",
|
||||||
|
"message": "Failed to complete file upload",
|
||||||
|
"status": 500,
|
||||||
|
"details": {
|
||||||
|
"taskId": "abc123def456",
|
||||||
|
"error": "File processing failed: invalid format"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handling Upload Failures
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
const result = await completeUpload(taskId);
|
||||||
|
showSuccess(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 500) {
|
||||||
|
// Server error, can retry
|
||||||
|
showRetryButton(taskId);
|
||||||
|
} else if (error.status === 403) {
|
||||||
|
// Permission/quota error
|
||||||
|
showQuotaExceeded();
|
||||||
|
} else {
|
||||||
|
// Other error
|
||||||
|
showGenericError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚡ Performance
|
||||||
|
|
||||||
|
### Optimizations
|
||||||
|
|
||||||
|
- **Chunked Uploads**: Reduces memory usage for large files
|
||||||
|
- **Progress Throttling**: Prevents WebSocket spam during fast uploads
|
||||||
|
- **Caching Layer**: Redis-based caching for task metadata
|
||||||
|
- **Database Indexing**: Optimized queries for task listing and filtering
|
||||||
|
- **Async Processing**: Non-blocking I/O operations throughout
|
||||||
|
|
||||||
|
### Benchmarks
|
||||||
|
|
||||||
|
- **Small Files (< 10MB)**: ~2-5 seconds total upload time
|
||||||
|
- **Large Files (1GB+)**: Maintains consistent throughput
|
||||||
|
- **Concurrent Uploads**: Supports 100+ simultaneous uploads per server
|
||||||
|
- **WebSocket Updates**: < 10ms latency for progress notifications
|
||||||
|
|
||||||
|
### Scaling Considerations
|
||||||
|
|
||||||
|
- **Horizontal Scaling**: Stateless design supports multiple instances
|
||||||
|
- **Load Balancing**: Session affinity not required for uploads
|
||||||
|
- **Storage Backend**: Compatible with S3, local storage, and distributed systems
|
||||||
|
- **Database**: PostgreSQL with connection pooling recommended
|
||||||
|
|
||||||
|
## 🔒 Security
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
- **JWT Tokens**: All endpoints require valid authentication
|
||||||
|
- **Ownership Verification**: Users can only access their own tasks
|
||||||
|
- **Permission Checks**: Integration with role-based access control
|
||||||
|
- **Rate Limiting**: Built-in protection against abuse
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
|
||||||
|
- **Encryption Support**: Optional client-side encryption
|
||||||
|
- **Secure Storage**: Files stored with proper access controls
|
||||||
|
- **Hash Verification**: Integrity checking via SHA-256 hashes
|
||||||
|
- **Audit Logging**: Comprehensive logging of all operations
|
||||||
|
|
||||||
|
### Network Security
|
||||||
|
|
||||||
|
- **HTTPS Only**: All communications encrypted in transit
|
||||||
|
- **CORS Configuration**: Proper cross-origin resource sharing
|
||||||
|
- **Input Validation**: Comprehensive validation of all inputs
|
||||||
|
- **SQL Injection Prevention**: Parameterized queries throughout
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### Upload Stuck at 99%
|
||||||
|
**Problem**: Final chunk fails to upload or process
|
||||||
|
**Solution**: Check server logs, verify file integrity, retry completion
|
||||||
|
|
||||||
|
#### WebSocket Not Connecting
|
||||||
|
**Problem**: Real-time updates not working
|
||||||
|
**Solution**: Check WebSocket server configuration, verify client authentication
|
||||||
|
|
||||||
|
#### Progress Not Updating
|
||||||
|
**Problem**: UI not reflecting upload progress
|
||||||
|
**Solution**: Verify WebSocket connection, check for JavaScript errors
|
||||||
|
|
||||||
|
#### Upload Fails with 403
|
||||||
|
**Problem**: Permission denied errors
|
||||||
|
**Solution**: Check user permissions, quota limits, and pool access
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Enable detailed logging by setting environment variable:
|
||||||
|
```bash
|
||||||
|
LOG_LEVEL=DysonNetwork.Drive.Storage:Debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
Monitor system health via:
|
||||||
|
```http
|
||||||
|
GET /health/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns status of upload service, database connectivity, and queue lengths.
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For issues and questions:
|
||||||
|
|
||||||
|
1. Check the troubleshooting section above
|
||||||
|
2. Review server logs for error details
|
||||||
|
3. Verify client implementation against examples
|
||||||
|
4. Contact the development team with specific error messages
|
||||||
|
|
||||||
|
## 📝 Changelog
|
||||||
|
|
||||||
|
### Version 1.0.0
|
||||||
|
- Initial release with resumable uploads
|
||||||
|
- Real-time progress tracking via WebSocket
|
||||||
|
- Push notification integration
|
||||||
|
- Comprehensive task management APIs
|
||||||
|
- Automatic cleanup and quota management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Generic Task System (v2.0)
|
||||||
|
|
||||||
|
The upload system has been extended with a powerful generic task framework that supports various types of background operations beyond just file uploads.
|
||||||
|
|
||||||
|
### Supported Task Types
|
||||||
|
|
||||||
|
#### File Operations
|
||||||
|
- **FileUpload**: Resumable file uploads (original functionality)
|
||||||
|
- **FileMove**: Move files between storage pools or bundles
|
||||||
|
- **FileCompress**: Compress multiple files into archives
|
||||||
|
- **FileDecompress**: Extract compressed archives
|
||||||
|
- **FileEncrypt**: Encrypt files with passwords
|
||||||
|
- **FileDecrypt**: Decrypt encrypted files
|
||||||
|
|
||||||
|
#### Bulk Operations
|
||||||
|
- **BulkOperation**: Custom bulk operations on multiple files
|
||||||
|
- **StorageMigration**: Migrate files between storage pools
|
||||||
|
- **FileConversion**: Convert files between formats
|
||||||
|
|
||||||
|
#### Custom Operations
|
||||||
|
- **Custom**: Extensible framework for custom task types
|
||||||
|
|
||||||
|
### Task Architecture
|
||||||
|
|
||||||
|
#### Core Classes
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Base task class with common functionality
|
||||||
|
public class PersistentTask : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string TaskId { get; set; } = null!;
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public TaskType Type { get; set; }
|
||||||
|
public TaskStatus Status { get; set; }
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
public double Progress { get; set; }
|
||||||
|
public Dictionary<string, object?> Parameters { get; set; } = new();
|
||||||
|
public Dictionary<string, object?> Results { get; set; } = new();
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public Instant LastActivity { get; set; }
|
||||||
|
public int Priority { get; set; }
|
||||||
|
public long? EstimatedDurationSeconds { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specialized task implementations
|
||||||
|
public class FileMoveTask : PersistentTask
|
||||||
|
{
|
||||||
|
public FileMoveTask() { Type = TaskType.FileMove; Name = "Move Files"; }
|
||||||
|
public List<string> FileIds { get; set; } = new();
|
||||||
|
public Guid TargetPoolId { get; set; }
|
||||||
|
public Guid? TargetBundleId { get; set; }
|
||||||
|
public int FilesProcessed { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FileCompressTask : PersistentTask
|
||||||
|
{
|
||||||
|
public FileCompressTask() { Type = TaskType.FileCompress; Name = "Compress Files"; }
|
||||||
|
public List<string> FileIds { get; set; } = new();
|
||||||
|
public string CompressionFormat { get; set; } = "zip";
|
||||||
|
public int CompressionLevel { get; set; } = 6;
|
||||||
|
public string? OutputFileName { get; set; }
|
||||||
|
public int FilesProcessed { get; set; }
|
||||||
|
public string? ResultFileId { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task Service
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class PersistentTaskService(
|
||||||
|
AppDatabase db,
|
||||||
|
ICacheService cache,
|
||||||
|
ILogger<PersistentTaskService> logger,
|
||||||
|
RingService.RingServiceClient ringService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Create any type of task
|
||||||
|
public async Task<T> CreateTaskAsync<T>(T task) where T : PersistentTask
|
||||||
|
|
||||||
|
// Update progress with automatic notifications
|
||||||
|
public async Task UpdateTaskProgressAsync(string taskId, double progress, string? statusMessage = null)
|
||||||
|
|
||||||
|
// Mark tasks as completed/failed with results
|
||||||
|
public async Task MarkTaskCompletedAsync(string taskId, Dictionary<string, object?>? results = null)
|
||||||
|
public async Task MarkTaskFailedAsync(string taskId, string? errorMessage = null)
|
||||||
|
|
||||||
|
// Task lifecycle management
|
||||||
|
public async Task PauseTaskAsync(string taskId)
|
||||||
|
public async Task ResumeTaskAsync(string taskId)
|
||||||
|
public async Task CancelTaskAsync(string taskId)
|
||||||
|
|
||||||
|
// Query tasks with filtering and pagination
|
||||||
|
public async Task<(List<PersistentTask> Items, int TotalCount)> GetUserTasksAsync(
|
||||||
|
Guid accountId,
|
||||||
|
TaskType? type = null,
|
||||||
|
TaskStatus? status = null,
|
||||||
|
string? sortBy = "lastActivity",
|
||||||
|
bool sortDescending = true,
|
||||||
|
int offset = 0,
|
||||||
|
int limit = 50
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Real-Time Task Notifications
|
||||||
|
|
||||||
|
All task operations send WebSocket notifications via RingService using the shared `GrpcTypeHelper` for consistent JSON serialization:
|
||||||
|
|
||||||
|
#### Task Created
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task.created",
|
||||||
|
"data": {
|
||||||
|
"taskId": "task123",
|
||||||
|
"name": "Compress Files",
|
||||||
|
"type": "FileCompress",
|
||||||
|
"createdAt": "2025-11-09T02:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task Progress
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task.progress",
|
||||||
|
"data": {
|
||||||
|
"taskId": "task123",
|
||||||
|
"name": "Compress Files",
|
||||||
|
"type": "FileCompress",
|
||||||
|
"progress": 67.5,
|
||||||
|
"status": "InProgress",
|
||||||
|
"lastActivity": "2025-11-09T02:05:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task Completed
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task.completed",
|
||||||
|
"data": {
|
||||||
|
"taskId": "task123",
|
||||||
|
"name": "Compress Files",
|
||||||
|
"type": "FileCompress",
|
||||||
|
"completedAt": "2025-11-09T02:10:00Z",
|
||||||
|
"results": {
|
||||||
|
"resultFileId": "file456",
|
||||||
|
"compressedSize": 10485760,
|
||||||
|
"compressionRatio": 0.75
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage Examples
|
||||||
|
|
||||||
|
#### Create a File Compression Task
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var compressTask = new FileCompressTask
|
||||||
|
{
|
||||||
|
Name = "Compress Project Files",
|
||||||
|
Description = "Compress all project files into a ZIP archive",
|
||||||
|
AccountId = userId,
|
||||||
|
FileIds = new List<string> { "file1", "file2", "file3" },
|
||||||
|
CompressionFormat = "zip",
|
||||||
|
CompressionLevel = 9,
|
||||||
|
OutputFileName = "project-backup.zip"
|
||||||
|
};
|
||||||
|
|
||||||
|
var createdTask = await taskService.CreateTaskAsync(compressTask);
|
||||||
|
// Task ID: createdTask.TaskId
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Monitor Task Progress
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// WebSocket monitoring
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const packet = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (packet.type === 'task.progress') {
|
||||||
|
const { taskId, progress, name } = packet.data;
|
||||||
|
updateTaskProgress(taskId, progress, name);
|
||||||
|
} else if (packet.type === 'task.completed') {
|
||||||
|
const { taskId, results } = packet.data;
|
||||||
|
handleTaskCompletion(taskId, results);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Bulk File Operations
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var bulkTask = new BulkOperationTask
|
||||||
|
{
|
||||||
|
Name = "Bulk Delete Old Files",
|
||||||
|
OperationType = "delete",
|
||||||
|
TargetIds = fileIds,
|
||||||
|
OperationParameters = new Dictionary<string, object?> {
|
||||||
|
{ "olderThanDays", 30 },
|
||||||
|
{ "confirm", true }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await taskService.CreateTaskAsync(bulkTask);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Status Management
|
||||||
|
|
||||||
|
Tasks support multiple statuses:
|
||||||
|
- **Pending**: Queued for execution
|
||||||
|
- **InProgress**: Currently executing
|
||||||
|
- **Paused**: Temporarily suspended
|
||||||
|
- **Completed**: Successfully finished
|
||||||
|
- **Failed**: Execution failed
|
||||||
|
- **Cancelled**: Manually cancelled
|
||||||
|
- **Expired**: Timed out or expired
|
||||||
|
|
||||||
|
### Available Service Methods
|
||||||
|
|
||||||
|
Based on the `PersistentTaskService` implementation, the following methods are available:
|
||||||
|
|
||||||
|
#### Core Task Operations
|
||||||
|
- `CreateTaskAsync<T>(T task)`: Creates any type of persistent task
|
||||||
|
- `GetTaskAsync<T>(string taskId)`: Retrieves a task by ID with caching
|
||||||
|
- `UpdateTaskProgressAsync(string taskId, double progress, string? statusMessage)`: Updates task progress with automatic notifications
|
||||||
|
- `MarkTaskCompletedAsync(string taskId, Dictionary<string, object?>? results)`: Marks task as completed with optional results
|
||||||
|
- `MarkTaskFailedAsync(string taskId, string? errorMessage)`: Marks task as failed with error message
|
||||||
|
- `PauseTaskAsync(string taskId)`: Pauses an in-progress task
|
||||||
|
- `ResumeTaskAsync(string taskId)`: Resumes a paused task
|
||||||
|
- `CancelTaskAsync(string taskId)`: Cancels a task
|
||||||
|
|
||||||
|
#### Task Querying & Statistics
|
||||||
|
- `GetUserTasksAsync()`: Gets tasks for a user with filtering and pagination
|
||||||
|
- `GetUserTaskStatsAsync(Guid accountId)`: Gets comprehensive task statistics
|
||||||
|
- `CleanupOldTasksAsync(Guid accountId, Duration maxAge)`: Cleans up old completed/failed tasks
|
||||||
|
|
||||||
|
#### Upload-Specific Operations
|
||||||
|
- `CreateUploadTaskAsync()`: Creates a new persistent upload task
|
||||||
|
- `GetUploadTaskAsync(string taskId)`: Gets an existing upload task
|
||||||
|
- `UpdateChunkProgressAsync(string taskId, int chunkIndex)`: Updates chunk upload progress
|
||||||
|
- `IsChunkUploadedAsync(string taskId, int chunkIndex)`: Checks if a chunk has been uploaded
|
||||||
|
- `GetUploadProgressAsync(string taskId)`: Gets upload progress as percentage
|
||||||
|
- `GetUserUploadTasksAsync()`: Gets user upload tasks with filtering
|
||||||
|
- `GetUserUploadStatsAsync(Guid accountId)`: Gets upload statistics for a user
|
||||||
|
- `CleanupUserFailedTasksAsync(Guid accountId)`: Cleans up failed upload tasks
|
||||||
|
- `GetRecentUserTasksAsync(Guid accountId, int limit)`: Gets recent upload tasks
|
||||||
|
|
||||||
|
### Priority System
|
||||||
|
|
||||||
|
Tasks can be assigned priorities (0-100, higher = more important) to control execution order in background processing.
|
||||||
|
|
||||||
|
### Automatic Cleanup
|
||||||
|
|
||||||
|
Old completed/failed tasks are automatically cleaned up after 30 days to prevent database bloat.
|
||||||
|
|
||||||
|
### Extensibility
|
||||||
|
|
||||||
|
The task system is designed to be easily extensible:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Create custom task types
|
||||||
|
public class CustomProcessingTask : PersistentTask
|
||||||
|
{
|
||||||
|
public CustomProcessingTask()
|
||||||
|
{
|
||||||
|
Type = TaskType.Custom;
|
||||||
|
Name = "Custom Processing";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CustomParameter
|
||||||
|
{
|
||||||
|
get => Parameters.GetValueOrDefault("customParam") as string ?? "";
|
||||||
|
set => Parameters["customParam"] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? CustomResult
|
||||||
|
{
|
||||||
|
get => Results.GetValueOrDefault("customResult");
|
||||||
|
set => Results["customResult"] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Schema Extensions
|
||||||
|
|
||||||
|
The task system uses JSONB columns for flexible parameter and result storage:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Extended tasks table
|
||||||
|
ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE tasks ADD COLUMN estimated_duration_seconds BIGINT;
|
||||||
|
ALTER TABLE tasks ADD COLUMN started_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE tasks ADD COLUMN completed_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX idx_tasks_type ON tasks(type);
|
||||||
|
CREATE INDEX idx_tasks_status ON tasks(status);
|
||||||
|
CREATE INDEX idx_tasks_priority ON tasks(priority);
|
||||||
|
CREATE INDEX idx_tasks_account_type ON tasks(account_id, type);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
The system maintains backward compatibility with existing upload tasks while adding the new generic framework. Existing `PersistentUploadTask` entities continue to work unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: This system is designed for production use and includes comprehensive error handling, security measures, and performance optimizations. Always test thoroughly in your environment before deploying to production.
|
||||||
121
DysonNetwork.Drive/appsettings.json
Normal file
121
DysonNetwork.Drive/appsettings.json
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
{
|
||||||
|
"Debug": true,
|
||||||
|
"BaseUrl": "http://localhost:5090",
|
||||||
|
"GatewayUrl": "http://localhost:5094",
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Schemes": {
|
||||||
|
"Bearer": {
|
||||||
|
"ValidAudiences": [
|
||||||
|
"http://localhost:5071",
|
||||||
|
"https://localhost:7099"
|
||||||
|
],
|
||||||
|
"ValidIssuer": "solar-network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AuthToken": {
|
||||||
|
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||||
|
"PrivateKeyPath": "Keys/PrivateKey.pem"
|
||||||
|
},
|
||||||
|
"Storage": {
|
||||||
|
"Uploads": "Uploads",
|
||||||
|
"PreferredRemote": "c53136a6-9152-4ecb-9f88-43c41438c23e",
|
||||||
|
"Remote": [
|
||||||
|
{
|
||||||
|
"Id": "minio",
|
||||||
|
"Label": "Minio",
|
||||||
|
"Region": "auto",
|
||||||
|
"Bucket": "solar-network-development",
|
||||||
|
"Endpoint": "localhost:9000",
|
||||||
|
"SecretId": "littlesheep",
|
||||||
|
"SecretKey": "password",
|
||||||
|
"EnabledSigned": true,
|
||||||
|
"EnableSsl": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "cloudflare",
|
||||||
|
"Label": "Cloudflare R2",
|
||||||
|
"Region": "auto",
|
||||||
|
"Bucket": "solar-network",
|
||||||
|
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
|
||||||
|
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
|
||||||
|
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
|
||||||
|
"EnableSigned": true,
|
||||||
|
"EnableSsl": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Captcha": {
|
||||||
|
"Provider": "cloudflare",
|
||||||
|
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
|
||||||
|
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
|
||||||
|
},
|
||||||
|
"Notifications": {
|
||||||
|
"Topic": "dev.solsynth.solian",
|
||||||
|
"Endpoint": "http://localhost:8088"
|
||||||
|
},
|
||||||
|
"Email": {
|
||||||
|
"Server": "smtp4dev.orb.local",
|
||||||
|
"Port": 25,
|
||||||
|
"UseSsl": false,
|
||||||
|
"Username": "no-reply@mail.solsynth.dev",
|
||||||
|
"Password": "password",
|
||||||
|
"FromAddress": "no-reply@mail.solsynth.dev",
|
||||||
|
"FromName": "Alphabot",
|
||||||
|
"SubjectPrefix": "Solar Network"
|
||||||
|
},
|
||||||
|
"RealtimeChat": {
|
||||||
|
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
|
||||||
|
"ApiKey": "APIs6TiL8wj3A4j",
|
||||||
|
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
|
||||||
|
},
|
||||||
|
"GeoIp": {
|
||||||
|
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||||
|
},
|
||||||
|
"Oidc": {
|
||||||
|
"Google": {
|
||||||
|
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
|
||||||
|
"ClientSecret": ""
|
||||||
|
},
|
||||||
|
"Apple": {
|
||||||
|
"ClientId": "dev.solsynth.solian",
|
||||||
|
"TeamId": "W7HPZ53V6B",
|
||||||
|
"KeyId": "B668YP4KBG",
|
||||||
|
"PrivateKeyPath": "./Keys/Solarpass.p8"
|
||||||
|
},
|
||||||
|
"Microsoft": {
|
||||||
|
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
|
||||||
|
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
|
||||||
|
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Payment": {
|
||||||
|
"Auth": {
|
||||||
|
"Afdian": "<token here>"
|
||||||
|
},
|
||||||
|
"Subscriptions": {
|
||||||
|
"Afdian": {
|
||||||
|
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
|
||||||
|
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
|
||||||
|
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
|
"KnownProxies": [
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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:10.0 AS base
|
||||||
|
USER $APP_UID
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.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"]
|
||||||
22
DysonNetwork.Gateway/DysonNetwork.Gateway.csproj
Normal file
22
DysonNetwork.Gateway/DysonNetwork.Gateway.csproj
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Nerdbank.GitVersioning" Version="3.9.50">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<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", "zone" };
|
||||||
|
|
||||||
|
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();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user