Compare commits
745 Commits
master
...
6e091299e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e091299e4 | |||
| 74882904a0 | |||
| 7639c6909a | |||
| b58066d563 | |||
| 14df8f1e8d | |||
| 188e4568da | |||
| 5006d81d00 | |||
| 97e5404069 | |||
| aa9aca8f6f | |||
| dddc4baa5d | |||
| 3d2c14ef41 | |||
| 628cfea773 | |||
| d2d1fc7e50 | |||
| 27f89f3820 | |||
| ecac0cdc2e | |||
| 5fd338906c | |||
| 88476e0409 | |||
| 7848923445 | |||
| 3b51a35e2c | |||
| 7686f04023 | |||
| 31d917c56e | |||
| bc71887726 | |||
| 89c91b2291 | |||
| 4a6bc2f8aa | |||
| ac5b75c0d6 | |||
| 7e1a2a7ed6 | |||
| e98aa6167e | |||
| 17ddd1c332 | |||
| b0b65e808d | |||
| 0a99178416 | |||
| 91cb420743 | |||
| f4a62eafc2 | |||
| dc5ac98c68 | |||
| b741f331e9 | |||
| 04a528ce55 | |||
| ac7833a069 | |||
| 27eec196a0 | |||
| c6d8d2c746 | |||
| 71ac41086c | |||
| da3a2a0a4b | |||
| 0203644b3e | |||
| dd43d069d5 | |||
| 7dc5eff14b | |||
| 314ac766e4 | |||
| 7456850dec | |||
| 30de7d0784 | |||
| 3a892f6f7a | |||
| b8be4f7314 | |||
| 5514bf89d9 | |||
| 326966b774 | |||
| 8ad378b649 | |||
| 41f977d262 | |||
| 5b04f39bfe | |||
| 71dba46565 | |||
| c16749bdf6 | |||
| 1d107d2de6 | |||
| 5b445cbe89 | |||
| 3482f5adde | |||
| 3dd73d3a63 | |||
| 3b4a06709a | |||
| 5ffb114532 | |||
| 590aef2ece | |||
| e3a2a78b21 | |||
| d0bf3b7ec7 | |||
| 2af3995ae7 | |||
| 02bdba3891 | |||
| 8ba1c9b6b2 | |||
| e188cc35e3 | |||
| 75cbe2717f | |||
| 4c63e80b6c | |||
| 354198c413 | |||
| 56c3f9c8e1 | |||
| b59c3b68ec | |||
| 47eef8aad0 | |||
| d4e0af2c29 | |||
| 9e4b0ecb27 | |||
| 64220d0818 | |||
| 55af7dccec | |||
| 3bddd4385d | |||
| d8927d90c2 | |||
| afad073d0c | |||
| 15b3fcedf1 | |||
| 3f56f9afdc | |||
| c541c45e9b | |||
| 459079d708 | |||
|
|
220581579b | ||
|
|
254e98b37b | ||
|
|
652143d738 | ||
| 5a74edd29f | |||
|
|
7f447077b0 | ||
|
|
b813c32756 | ||
|
|
cdd2e9a1ef | ||
|
|
64bee6c92a | ||
|
|
870ebb06f8 | ||
|
|
2f7cd3d7da | ||
| 18ddcf681f | |||
|
|
ede759b50b | ||
| 9f74bc91a9 | |||
| 8922dbcc57 | |||
| 095a2bdce0 | |||
| b807e4b636 | |||
| 9c17843dec | |||
| c1aafc4223 | |||
| a8d06959b5 | |||
| dbbb25da32 | |||
| 9f180742cd | |||
| e8c1a80264 | |||
| 7e16c77cb6 | |||
| 8f6a4396db | |||
| 34fbbc91a0 | |||
| 9be49ac9c6 | |||
| c96b22fe62 | |||
| 7f50d3c3e2 | |||
| 191cba4946 | |||
| a38f627ca4 | |||
| f31b753182 | |||
| 1377d02520 | |||
| bac1ad0afc | |||
| fa434b180d | |||
| 4d169e6303 | |||
| e1d7822b7c | |||
| cc611828b9 | |||
| 9aead51481 | |||
| 67d00efb10 | |||
| b71fef147a | |||
| 8004dc2b43 | |||
| c9f08ea48e | |||
| 52b88a3ba4 | |||
| e39d753a85 | |||
| 83025065b3 | |||
| 0c65e9de1b | |||
| 1559bf081e | |||
| 79305646dd | |||
| b9ca9f7b45 | |||
| 5aed999671 | |||
| 56b80038a3 | |||
| 3d3c59d58b | |||
| 76bbdbd2f4 | |||
| 22b15c387b | |||
| bcfb838b5e | |||
| 63a16392d7 | |||
| d3ef5ff4c1 | |||
| f45ef7c8b2 | |||
| 8228cc2b1a | |||
| 6b9234dfac | |||
| f150f6d863 | |||
| 7521f15b63 | |||
| 88eb22d454 | |||
| c1c6a4cead | |||
| 845751fca1 | |||
| 2dc0153b10 | |||
| 7143af14f0 | |||
| 51a3d0d19b | |||
| 53b1d0fbf3 | |||
| 20fd6a930b | |||
| 8f0af8d544 | |||
| 16161ebfb8 | |||
| ca224feafb | |||
| 0909842c37 | |||
| 9c785423a9 | |||
|
|
d1f545bb59 | ||
|
|
584e24d751 | ||
|
|
ea25eca9fa | ||
|
|
b8f0eea848 | ||
|
|
b417f37780 | ||
|
|
f52b684722 | ||
|
|
5c15f89f59 | ||
|
|
f2e2997988 | ||
|
|
1589c7fe29 | ||
|
|
5e23180c7c | ||
|
|
f8eafed093 | ||
|
|
debd0dd4af | ||
|
|
04d425b88d | ||
|
|
ca21dc3dd9 | ||
| f4d4b6a75a | |||
|
|
4ff3b24df8 | ||
|
|
1da7236d5d | ||
|
|
df14b8b227 | ||
|
|
15508013a3 | ||
|
|
cb47336230 | ||
| 76cabe058b | |||
| 33175ffdcb | |||
| 54cf5eff2b | |||
| 94bba45f45 | |||
| 23ef7e3ded | |||
| a84d93882d | |||
| 0ec6888809 | |||
| 977d2325fa | |||
| 20975a2872 | |||
| 61cf886174 | |||
| bf638ca995 | |||
| a599648d87 | |||
| e5fc1a017e | |||
| 44394daebf | |||
| ac76aab168 | |||
| 4f56acb320 | |||
| 70fe586fc6 | |||
| 58bd4ee766 | |||
| 3d789d68e7 | |||
| 9327a739f8 | |||
| de731095cc | |||
| 696ea613de | |||
| 9b17b6b231 | |||
| b1d5356220 | |||
| 37dad0d1d7 | |||
| c5e41f2c05 | |||
| d45a915ba9 | |||
| b7d1912fc6 | |||
| d51efe8b3c | |||
| 48a97beb74 | |||
| e94406932b | |||
| 4fe1fb0da2 | |||
| dcc8eba931 | |||
| 4c90e553af | |||
| b8f75f9529 | |||
| d10e062373 | |||
| 3c1d2ef14b | |||
| b58c8ebce8 | |||
| d602d2d34b | |||
| de938e6d1e | |||
| 949dec9bdc | |||
| 89c4bfe13f | |||
| 7a0b72584c | |||
| ebfab19df4 | |||
| 77630f4e83 | |||
| 16619f9631 | |||
| 9829e02a38 | |||
| 632ca0d3e4 | |||
| 4d436620a5 | |||
| 78da2b7207 | |||
| d4a8b272f2 | |||
| bb01f6526e | |||
| 866d01c4e2 | |||
| 2159eaec08 | |||
| bf3e5e619b | |||
|
|
458cf70081 | ||
|
|
3d81b7a97e | ||
|
|
03f52cfb5b | ||
|
|
4b0a6df5dd | ||
|
|
744bc9e4d0 | ||
|
|
76ba4fd74a | ||
|
|
132e3d2930 | ||
|
|
7554dbac9c | ||
|
|
defd97e7f0 | ||
|
|
6c9a608ce6 | ||
|
|
dc1e44f2be | ||
| 923aaa6865 | |||
| 53fe2cc3d5 | |||
| 970f99db54 | |||
| 96f431fb41 | |||
| 604f0e33a0 | |||
| 6d75843dcd | |||
| 0cb5d097d6 | |||
| 0549be6d42 | |||
| 5cd16a7416 | |||
| 5573be1502 | |||
| 99ef2db822 | |||
| 0502cbbd76 | |||
| 6d29f053f4 | |||
|
|
75c667d1c2 | ||
|
|
740941790b | ||
| 5511bc56f2 | |||
| d90a9421e9 | |||
| ba1071ba6c | |||
| 29b021bee5 | |||
| 07fcd05031 | |||
| 64a0fdf81d | |||
| 9e8723b79f | |||
| 1ecd6d167a | |||
| 78ecec2e12 | |||
| 6ac692f137 | |||
| a6d26067b3 | |||
| 2b90a4893e | |||
| 001be4160b | |||
| 9f8ec47a15 | |||
| 1aad7e1d76 | |||
| 05d0ef1a9a | |||
|
|
c7d555d90b | ||
|
|
0d12b45563 | ||
|
|
b20b7d20fd | ||
| 25ac326d7e | |||
| 095c80071f | |||
| 9dd93494c1 | |||
| a7b71153e9 | |||
| cbe374a010 | |||
| ffffb7ff2b | |||
| 167cbb8ad1 | |||
| 9e8d31f457 | |||
| 82250739e3 | |||
| 699ab85614 | |||
| 4cffb152b7 | |||
| 4372206d9c | |||
| 7e6e0ed3ec | |||
| 7a580a950c | |||
| 3b007d577b | |||
| 7dbd79315f | |||
| d93036f754 | |||
| 9fde1d263a | |||
| dcda9a8feb | |||
| d6d167f40c | |||
| e33d87bb30 | |||
| e297a81ebb | |||
| ff3fbc1ff1 | |||
| 4cf29ab4f2 | |||
| 08dba4b257 | |||
| e88a5c4ea7 | |||
| 09883c797c | |||
| b73b0b245c | |||
| d5962501b2 | |||
| eb9f0d14d3 | |||
| c8a862b29f | |||
| 5b12c3e697 | |||
| 7ce738df44 | |||
| db8c576c29 | |||
| 0a9c3ba207 | |||
| b9a0e2f1b6 | |||
| f9dea23757 | |||
| 4128ea1b58 | |||
| 4876bb3e74 | |||
| 3a5729045e | |||
| 902c4418ae | |||
| 9863126262 | |||
| e0508c2a22 | |||
| 0f505575aa | |||
| 0177a99449 | |||
| 8a395da790 | |||
| af0545fdd1 | |||
| 1e13e5694e | |||
| 0a48663d35 | |||
|
|
0b47a8d2b2 | ||
|
|
836a5472b8 | ||
| 491003aeac | |||
| 8c5b4895ac | |||
| e2041dd2c9 | |||
| b16ad2d096 | |||
| 55995eca18 | |||
|
|
ab5e9e7de4 | ||
|
|
7b81887729 | ||
|
|
3b274ff38c | ||
|
|
01e2d236a1 | ||
|
|
d1ef731a9e | ||
|
|
7a1a4fd519 | ||
|
|
c3a352eb9c | ||
|
|
b57314a44a | ||
|
|
12a1e7624d | ||
|
|
35cb672403 | ||
|
|
230d0a7d1b | ||
|
|
2cff9525bb | ||
|
|
3ff8b3de8f | ||
|
|
3e9f5c1164 | ||
|
|
e0cddb8aaa | ||
|
|
4c158c87d3 | ||
|
|
8df80f8e2c | ||
|
|
76e6a08923 | ||
|
|
a1d5e23817 | ||
|
|
e2f08607e2 | ||
|
|
374bd48adb | ||
|
|
cdb7103809 | ||
|
|
de3445318e | ||
|
|
4e1d608143 | ||
|
|
27259c7241 | ||
|
|
7df482f9e9 | ||
|
|
9ee59e50c9 | ||
|
|
960e2c5f7c | ||
|
|
84c1548221 | ||
|
|
b183521af8 | ||
|
|
16f53cb931 | ||
|
|
b052a025a7 | ||
|
|
f31369bea9 | ||
|
|
b2cfdc5af4 | ||
|
|
3dacf84a85 | ||
|
|
bd18d8f636 | ||
|
|
19b57901f1 | ||
|
|
3bee6c7939 | ||
|
|
dde98a9740 | ||
|
|
2b84d66e8b | ||
|
|
7c2029c7e4 | ||
|
|
ccbc75a31c | ||
|
|
a170025520 | ||
|
|
627239f0a3 | ||
|
|
28e53eb31b | ||
|
|
e070031ec0 | ||
|
|
a4747ca417 | ||
|
|
74f3584dd0 | ||
|
|
1e3a0eae53 | ||
|
|
9d6da2af06 | ||
|
|
4beddeaa2e | ||
|
|
b18f294db4 | ||
|
|
6719cb6546 | ||
|
|
8778cef71d | ||
|
|
21d865e654 | ||
|
|
8e5ba0ec31 | ||
|
|
d5148c1722 | ||
|
|
67a7eec155 | ||
|
|
5689098e00 | ||
|
|
49b53b0886 | ||
|
|
ed3ca19d66 | ||
|
|
c6295546ad | ||
|
|
ff8ae77ebe | ||
|
|
01a9fd654d | ||
|
|
516c4dba61 | ||
|
|
e296b142a3 | ||
|
|
a4457a824a | ||
|
|
4cab2988e1 | ||
|
|
3bd30dfde9 | ||
|
|
0a57919271 | ||
|
|
8d4186c137 | ||
|
|
1fd6ebc570 | ||
|
|
2aeaf9b5fe | ||
|
|
5827f88956 | ||
|
|
bd00119fe9 | ||
|
|
ca290bcba7 | ||
|
|
4e5a7ce3f2 | ||
|
|
fe544ebf24 | ||
|
|
94ab51220a | ||
|
|
21d99ef14f | ||
|
|
39378c604a | ||
|
|
cec2d805ca | ||
|
|
c40bdb1600 | ||
|
|
0b9acbf7b3 | ||
|
|
ab28e549e0 | ||
|
|
0a814ec6c0 | ||
|
|
67cd13e012 | ||
|
|
597533d70c | ||
|
|
d46ec912bb | ||
|
|
8ead1d0ee1 | ||
|
|
a279025647 | ||
|
|
1db0ea89cf | ||
|
|
a267ecb5bb | ||
|
|
c6a61879d9 | ||
|
|
9342bcf895 | ||
|
|
2e882e7c13 | ||
|
|
53143291d2 | ||
|
|
6a47f84fcc | ||
|
|
3de1cd6b14 | ||
|
|
b0e84119ac | ||
|
|
bd9cef0352 | ||
|
|
597b20483c | ||
|
|
d28b53e793 | ||
|
|
e411c0ce27 | ||
|
|
96ce0a777a | ||
|
|
8f18978ad9 | ||
|
|
c3a749c960 | ||
|
|
d99cc61056 | ||
|
|
b479981c32 | ||
|
|
0ae0f4ec35 | ||
|
|
c0c4c2ea67 | ||
|
|
5a05822a9f | ||
|
|
fc33bcd5d9 | ||
|
|
445ae26360 | ||
|
|
06708c53bd | ||
|
|
adad2dcfd9 | ||
|
|
d23f813e25 | ||
|
|
7c58a270b3 | ||
|
|
848b91e399 | ||
|
|
83ecd83f83 | ||
|
|
0ee27a4260 | ||
|
|
c07332657b | ||
|
|
f67af97c3f | ||
|
|
860ed07429 | ||
|
|
738e2d5105 | ||
|
|
1d7bb08b79 | ||
|
|
8eb5644096 | ||
|
|
bcd85b8f61 | ||
|
|
ece0a58b58 | ||
|
|
d52f4da3bb | ||
|
|
e2949f9e55 | ||
|
|
82c6394c82 | ||
|
|
968798f697 | ||
|
|
330071a864 | ||
|
|
8f6e725409 | ||
|
|
040e590802 | ||
|
|
269f82a0da | ||
|
|
c062b4073c | ||
|
|
f1f3abb05a | ||
|
|
2d726e77dc | ||
|
|
3ade5c8c64 | ||
|
|
a6e1159158 | ||
|
|
7b472f337f | ||
|
|
8e423ca1ab | ||
|
|
3630291ad9 | ||
|
|
e63f04efcf | ||
|
|
8265c7c051 | ||
|
|
7ca023dde1 | ||
|
|
f4773ec096 | ||
|
|
6d327ce8a2 | ||
|
|
c00f9f505e | ||
|
|
5780aaec73 | ||
|
|
07741f4129 | ||
|
|
781c594f37 | ||
|
|
c8f0bfe85b | ||
|
|
c31d698bdf | ||
|
|
c04522a992 | ||
|
|
c3c2a5810f | ||
|
|
3a2d0341b0 | ||
|
|
6c1bd7d45f | ||
|
|
1b3a545809 | ||
|
|
e95619c969 | ||
|
|
cd60649cc0 | ||
|
|
c52ddb9820 | ||
|
|
0c8ebd743d | ||
|
|
6994dbab7a | ||
|
|
e786c68612 | ||
|
|
21299543ec | ||
|
|
65207456db | ||
|
|
b80f6eb0a3 | ||
|
|
10d44f079c | ||
|
|
2033877fe9 | ||
|
|
42384b3b8d | ||
|
|
2018fed2c9 | ||
|
|
0277afe995 | ||
|
|
fc086e63de | ||
|
|
9dedb9622a | ||
|
|
278eaed931 | ||
|
|
ee52d9560f | ||
|
|
020d8618aa | ||
|
|
709004d3c1 | ||
|
|
89c0933f45 | ||
|
|
3cb30a49be | ||
|
|
b0318c4d8b | ||
|
|
d763f640e1 | ||
|
|
66fcba5d26 | ||
|
|
5282dea339 | ||
|
|
494531be62 | ||
|
|
91d14ef8fa | ||
|
|
d0ec91aa2a | ||
|
|
15c19a0c19 | ||
|
|
aebd61a885 | ||
|
|
208547f1f5 | ||
|
|
cf972e3d6e | ||
|
|
1c51a36510 | ||
|
|
65e344f2c4 | ||
|
|
1f462cb075 | ||
|
|
6a734c7b01 | ||
|
|
966ae30787 | ||
|
|
b54d20cb05 | ||
|
|
022b7b8ff0 | ||
|
|
3e7031b74b | ||
|
|
e7c985ccb0 | ||
|
|
698876fca6 | ||
|
|
78c2f95e5e | ||
|
|
7b4d743b0e | ||
|
|
99e49898fd | ||
|
|
c5deb2af73 | ||
|
|
54faa08bd3 | ||
|
|
732a6da1e4 | ||
|
|
7c400ce3c0 | ||
|
|
99ddb5e87d | ||
|
|
4be744b224 | ||
|
|
c1c399c03a | ||
|
|
81d02ec1ff | ||
|
|
8bb0741069 | ||
|
|
6742947422 | ||
|
|
409a32e1d2 | ||
|
|
70adbee4d2 | ||
|
|
4c0dce5cdd | ||
|
|
3b71e052fd | ||
|
|
264daecb3e | ||
|
|
c739c17266 | ||
|
|
54d0cd0343 | ||
|
|
ce7dba34c8 | ||
|
|
abe0868c3b | ||
|
|
08f105faf3 | ||
|
|
607fef4cb0 | ||
|
|
308e9fe21b | ||
|
|
dbdce9d731 | ||
|
|
87432ad663 | ||
|
|
1c0ac12e76 | ||
|
|
7ce3c548db | ||
|
|
610cc1452b | ||
|
|
b320d34bda | ||
|
|
0817bd148a | ||
|
|
176f33a8a0 | ||
|
|
6171ef6f8c | ||
|
|
43f9b8cacb | ||
|
|
cff19120a3 | ||
|
|
916dc8f384 | ||
|
|
e3d49b2f48 | ||
|
|
c05dce0b87 | ||
|
|
962a83e708 | ||
|
|
0dd7efac84 | ||
|
|
647c5be859 | ||
|
|
1bb5730908 | ||
|
|
67df71942c | ||
|
|
9979e4183f | ||
|
|
617edf6d32 | ||
|
|
450f62ba1f | ||
|
|
a3cd7b128e | ||
|
|
ddaea8744b | ||
|
|
e95713a721 | ||
|
|
a3e619e472 | ||
|
|
d8d5814d9b | ||
|
|
3e629e4c7d | ||
|
|
c76ce88b24 | ||
|
|
0201db730b | ||
|
|
10eb0d20aa | ||
|
|
a7cc3f5b61 | ||
|
|
b5731f7cfd | ||
|
|
c4d1deafb0 | ||
|
|
68da5ceff7 | ||
|
|
7e05c1a37f | ||
|
|
fafc97f3de | ||
|
|
32261eb92a | ||
|
|
de7e5696c4 | ||
|
|
3f2e048092 | ||
|
|
c52fb203ea | ||
|
|
fdfa6a277d | ||
|
|
a67a6cd81d | ||
|
|
0adabf49d2 | ||
|
|
53b5d1105c | ||
|
|
61febac376 | ||
|
|
ec100eb364 | ||
|
|
c27f0d4f4f | ||
|
|
920374aeea | ||
|
|
d6e48a9b62 | ||
|
|
ebecb976ae | ||
|
|
6a406fedf5 | ||
|
|
33ba29c4a0 | ||
|
|
874a05fc92 | ||
|
|
94300d326b | ||
|
|
938ab6a415 | ||
|
|
81433c7964 | ||
|
|
8d64cbc08e | ||
|
|
8c69e91dd7 | ||
|
|
9b783ecdbf | ||
|
|
2e6f854c0c | ||
|
|
1318ed334c | ||
|
|
a3099916bf | ||
|
|
84de282c13 | ||
|
|
bf8405d790 | ||
|
|
5046d83dde | ||
|
|
a1e154b23c | ||
|
|
d38238e805 | ||
|
|
ee3c6ec9ac | ||
|
|
bb1dd95482 | ||
|
|
71f4b677d9 | ||
|
|
4b2f117e3c | ||
|
|
067af4ab37 | ||
|
|
196b755f08 | ||
|
|
e4f2ca48e3 | ||
|
|
395ebe985f | ||
|
|
a31e2fc89e | ||
|
|
d097502a18 | ||
|
|
5bcf011c6d | ||
|
|
1ef42c69bf | ||
|
|
d557cadc81 | ||
|
|
537e642cb6 | ||
|
|
e6e4ed2183 | ||
|
|
86b36facc3 | ||
|
|
aa6b86b0f6 | ||
|
|
26618d4c25 | ||
|
|
6896695db5 | ||
|
|
a6ec9dc928 | ||
|
|
26495d71fd | ||
|
|
acd159fda0 | ||
|
|
a9dca7da69 | ||
|
|
847e93eda4 | ||
|
|
645c6e02a2 | ||
|
|
2f2290deb0 | ||
|
|
3cabbc7a04 | ||
|
|
741a1453da | ||
|
|
6702f4e4ee | ||
|
|
cf77a0943f | ||
|
|
9bd6632264 | ||
|
|
1b179abc50 | ||
|
|
58204f23dd | ||
|
|
7d8b585731 | ||
|
|
c813fef4c3 | ||
|
|
906cbc354f | ||
|
|
0eeb84bfb3 | ||
|
|
d09d078dd8 | ||
|
|
6c1299062c | ||
|
|
56a27720bd | ||
|
|
b3c3cbffe8 | ||
|
|
7773966525 | ||
|
|
e2d358c5db | ||
|
|
374745a3fb | ||
|
|
b72acbd42c | ||
|
|
700161a3f2 | ||
|
|
cdfe768dfe | ||
|
|
138bdeac5a | ||
|
|
3355de1cd1 | ||
|
|
52fd1528d2 | ||
|
|
894ac99a34 | ||
|
|
54c546aa10 | ||
|
|
5dd11eec1e | ||
|
|
ebd17f55ae | ||
|
|
f25c322b1e | ||
|
|
8c42051461 | ||
|
|
0b7011e4f3 | ||
|
|
88e18636ab | ||
|
|
8f5cd758c5 | ||
|
|
b1bb076906 | ||
|
|
f1cadb4a63 | ||
|
|
b81dd0f7a4 | ||
|
|
eb25e3f7dc | ||
|
|
83a54c4c2f | ||
|
|
ceaa6e8637 | ||
|
|
85e743110c | ||
|
|
a3a47be4cd | ||
|
|
4ca6fa7c95 | ||
|
|
9595919d26 | ||
|
|
48e42fdfff | ||
|
|
46514c052f | ||
|
|
17dd6bda0c | ||
| b52ecee6a0 | |||
|
|
b1ce2fc0bb | ||
|
|
d2d05fdcfa | ||
|
|
145f12dc8a | ||
|
|
a75b26ef63 | ||
|
|
4b08ae7b5a | ||
|
|
cd0d04a024 | ||
|
|
295a0ce41d | ||
|
|
259bbe3135 | ||
|
|
e11622716e | ||
| 7a0e732363 | |||
|
|
2f04f36659 | ||
|
|
9969dddb11 | ||
|
|
076c1115f7 | ||
|
|
37dbac2611 | ||
|
|
79d47257f3 | ||
|
|
bd0aabd75c | ||
|
|
2acd38d75b | ||
|
|
5df852c0c7 | ||
|
|
4a05e5c5ce | ||
|
|
4875972fa4 | ||
|
|
b2038321b2 | ||
|
|
932572b7c6 | ||
|
|
b6fc0be25c | ||
|
|
e6c27553a7 | ||
|
|
6a3ceb9ffc | ||
|
|
dbef20e6b5 | ||
|
|
4ed4ef82ad | ||
|
|
19b95084a6 | ||
|
|
c72fcd922e | ||
|
|
786b6128e1 | ||
|
|
833af15e9d | ||
|
|
51095a28e3 | ||
|
|
8ccef5c2f6 | ||
|
|
b583e4d045 | ||
|
|
9325ed73b8 | ||
|
|
506ec5f54a | ||
|
|
c95ebc7842 | ||
|
|
22f8767c42 | ||
|
|
7a3d935df9 |
@@ -23,3 +23,4 @@
|
|||||||
**/values.dev.yaml
|
**/values.dev.yaml
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
/storageImages
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.jpg filter=lfs diff=lfs merge=lfs -text
|
||||||
320
.github/agents.md
vendored
Normal file
320
.github/agents.md
vendored
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
# MilkyShots – Agent Guidelines
|
||||||
|
|
||||||
|
This file captures the coding conventions, architecture rules, and standards extracted from the codebase. Follow them in every contribution to keep the project consistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
MilkyShots is a .NET 8 media-library application composed of four projects:
|
||||||
|
|
||||||
|
| Project | Role |
|
||||||
|
|---|---|
|
||||||
|
| **Butter** | Shared class library – DTOs, enums, MIME-type tables, settings definitions |
|
||||||
|
| **Lactose** | ASP.NET Core Web API backend – controllers, EF Core models, repositories, services, background jobs |
|
||||||
|
| **MilkStream** | Blazor WebAssembly host – serves static WASM files and provides dynamic configuration |
|
||||||
|
| **MilkStream.Client** | Blazor WASM client – Razor components, frontend services, SCSS styles (runs in browser) |
|
||||||
|
|
||||||
|
The backend stores data in **PostgreSQL** (with the `pgvecto-rs` extension) accessed via **Entity Framework Core**.
|
||||||
|
The WASM client runs entirely in the browser and communicates with the Lactose backend over HTTP/JSON using named `HttpClient` instances.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **.NET 8 / C# 12** — target framework and language version for all projects.
|
||||||
|
- **ASP.NET Core** (Lactose) and **Blazor WebAssembly** (MilkStream.Client hosted by MilkStream).
|
||||||
|
- **Entity Framework Core** with `Npgsql.EntityFrameworkCore.PostgreSQL`.
|
||||||
|
- **JWT Bearer** authentication + refresh-token rotation.
|
||||||
|
- **Bootstrap 5** and **SCSS** (compiled by `AspNetCore.SassCompiler` in debug) for styling.
|
||||||
|
- **Docker / docker-compose** for containerised deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solution Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
MilkyShots/
|
||||||
|
├── Butter/ # Shared library
|
||||||
|
│ ├── Dtos/ # DTOs grouped by entity (Album/, Asset/, …)
|
||||||
|
│ ├── Settings/ # Settings enum + extensions
|
||||||
|
│ └── Types/ # Shared enums (EAccessLevel, EAssetType)
|
||||||
|
├── Lactose/ # Web API
|
||||||
|
│ ├── Authorization/ # Claims transformation + authorization handlers
|
||||||
|
│ ├── Configuration/ # Options classes (SignKeyConfiguration, SignKeyProvider)
|
||||||
|
│ ├── Context/ # EF Core DbContext (LactoseDbContext)
|
||||||
|
│ ├── Controllers/ # API controllers
|
||||||
|
│ ├── Jobs/ # Background job infrastructure + concrete jobs
|
||||||
|
│ ├── Mapper/ # Model → DTO mapping (static extension methods)
|
||||||
|
│ ├── Migrations/ # EF Core migrations (generated – do NOT hand-edit)
|
||||||
|
│ ├── Models/ # EF Core entity classes
|
||||||
|
│ ├── Repositories/ # Interfaces + implementations for data access
|
||||||
|
│ ├── Services/ # Application services (auth, DB initialiser, scheduler)
|
||||||
|
│ └── Utils/ # Small helpers / extension methods
|
||||||
|
├── MilkStream/ # Blazor WASM static host
|
||||||
|
│ ├── Program.cs # Minimal API – serves WASM files + dynamic /appsettings.json endpoint
|
||||||
|
│ └── appsettings.json # Server-side config (LactoseBaseUrl for dynamic injection)
|
||||||
|
└── MilkStream.Client/ # Blazor WASM client (runs in browser)
|
||||||
|
├── Components/ # Razor components (Pages/, Layout/, SettingBoxes/, …)
|
||||||
|
├── Services/ # Frontend services (LoginService, MediaService, …)
|
||||||
|
├── Styles/ # SCSS source files
|
||||||
|
└── wwwroot/ # Static assets bundled into WASM
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
### C# identifiers
|
||||||
|
|
||||||
|
| Category | Pattern | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Enums | `E` prefix | `EAccessLevel`, `EAssetType`, `EJobStatus` |
|
||||||
|
| Interfaces | `I` prefix | `IAssetRepository`, `IAlbumRepository` |
|
||||||
|
| DTOs | `…Dto` suffix (PascalCase) | `AssetDto`, `AlbumCreateDto` |
|
||||||
|
| DTO sets: create / update / preview / full | `…CreateDto`, `…UpdateDto`, `…PreviewDto`, `…FullDto` | `AlbumCreateDto` |
|
||||||
|
| Repository interfaces | `I…Repository` | `IAssetRepository` |
|
||||||
|
| Repository implementations | `…Repository` | `AssetRepository` |
|
||||||
|
| Mapper classes | `…Mapper` | `AssetsMapper`, `UsersMapper` |
|
||||||
|
| Controller classes | `…Controller` | `AssetController` |
|
||||||
|
| Background jobs | extend `Job` | `FileSystemCrawlJob` |
|
||||||
|
| Services | `…Service` | `LactoseAuthService`, `LoginService` |
|
||||||
|
|
||||||
|
> **Note:** The suffix `DTO` (all-caps) appears in one legacy file (`PagedParametersDTO.cs`).
|
||||||
|
> New files must use `Dto` (mixed-case) to match the rest of the codebase.
|
||||||
|
|
||||||
|
### Files and directories
|
||||||
|
|
||||||
|
- One public type per file; file name equals the type name.
|
||||||
|
- Folders mirror the namespace suffix (e.g. `Lactose.Repositories` → `Lactose/Repositories/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C# Coding Style
|
||||||
|
|
||||||
|
- Enable **nullable reference types** (`<Nullable>enable</Nullable>`) and **implicit usings** in all projects.
|
||||||
|
- Prefer **primary constructors** (C# 12) for constructor injection:
|
||||||
|
```csharp
|
||||||
|
public class AssetController(
|
||||||
|
ILogger<AssetController> logger,
|
||||||
|
LactoseAuthService authService,
|
||||||
|
IAssetRepository assetRepository
|
||||||
|
) : ControllerBase { … }
|
||||||
|
```
|
||||||
|
- Use **`var`** for local variables when the type is obvious from the right-hand side.
|
||||||
|
- Use **object initialisers** when creating entity or DTO instances.
|
||||||
|
- Use the **`required`** keyword on properties that must be set during object construction.
|
||||||
|
- Default string properties to `string.Empty`, not `null`.
|
||||||
|
- Default collection navigation properties to `null` (lazy) — the EF Core model builder wires up relationships.
|
||||||
|
- Mark navigation property regions with `#region Navigation Properties … #endregion`.
|
||||||
|
|
||||||
|
### Documentation comments
|
||||||
|
|
||||||
|
- Every **model class** and every property on it must have a `/// <summary>` XML comment.
|
||||||
|
- Every **interface method** must have `<summary>`, `<param>`, and `<returns>` XML comments.
|
||||||
|
- **Mapper classes** must have a `<summary>` on the class and on each method.
|
||||||
|
- Controllers may omit XML comments on action methods (use inline comments instead).
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Inject `ILogger<T>` into every controller and service.
|
||||||
|
- Use structured logging with message templates (avoid string concatenation in log calls where possible).
|
||||||
|
- Use verbatim interpolated string literals (`$"""…"""`) for multi-line log entries.
|
||||||
|
- Log-level conventions:
|
||||||
|
|
||||||
|
| Level | When to use |
|
||||||
|
|---|---|
|
||||||
|
| `LogTrace` | Detailed per-request data (IDs, field values) |
|
||||||
|
| `LogDebug` | Medium-detail operational info |
|
||||||
|
| `LogInformation` | Key lifecycle events (job started, user registered, …) |
|
||||||
|
| `LogWarning` | Non-fatal problems (missing resource, permission violation) |
|
||||||
|
| `LogError` | Exceptions and failures |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity / Model Rules
|
||||||
|
|
||||||
|
- Primary keys are **`Guid`** annotated with `[Key]`.
|
||||||
|
- String columns must specify `[Column(TypeName = "VARCHAR(n)")]` with an appropriate max length.
|
||||||
|
- Foreign-key scalar properties use `[ForeignKey(nameof(NavigationProperty))]`.
|
||||||
|
- Timestamps follow the pattern: `CreatedAt`, `UpdatedAt`, `DeletedAt` (nullable `DateTime?` for soft delete).
|
||||||
|
- **Soft delete** — entities are never hard-deleted from the database via normal application flows; set `DeletedAt = DateTime.Now` instead.
|
||||||
|
- `[Index(nameof(Field), IsUnique = true)]` is used on fields that must be unique at the DB level.
|
||||||
|
- Model classes live in `Lactose.Models`; shared enums/types live in `Butter.Types`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Pattern
|
||||||
|
|
||||||
|
- Every repository exposes its contract through an `I…Repository` interface in the same folder.
|
||||||
|
- All repository interfaces implement `IDisposable` (or `IAsyncDisposable` where async teardown is needed).
|
||||||
|
- Standard method set:
|
||||||
|
|
||||||
|
| Method | Signature |
|
||||||
|
|---|---|
|
||||||
|
| Find by PK | `T? Find(Guid id)` |
|
||||||
|
| Find multiple | `IEnumerable<T> FindBulk(IEnumerable<Guid> ids)` |
|
||||||
|
| Insert | `void Insert(T entity)` |
|
||||||
|
| Update | `void Update(T entity)` (sets `UpdatedAt = DateTime.Now`) |
|
||||||
|
| Bulk update | `void UpdateBulk(IEnumerable<T> entities)` |
|
||||||
|
| Save | `void Save()` → `context.SaveChanges()` |
|
||||||
|
| Delete | `void Delete(T entity)` or `void Delete(Guid id)` |
|
||||||
|
|
||||||
|
- **`Save()` must be called explicitly** after insert/update/delete operations; repositories do not auto-save except `FolderRepository.Create()` (which needs to fire an event after commit).
|
||||||
|
- Repositories may expose **static events** for cross-service notification (e.g. `FolderRepository.FolderAdded`, `SettingsRepository.SettingChanged`). Keep these static and `EventHandler<T>` typed.
|
||||||
|
- Register all repositories as **`Transient`** in `Program.cs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mapper / DTO Rules
|
||||||
|
|
||||||
|
- Mapper classes are **`public static`** and live in `Lactose.Mapper`.
|
||||||
|
- Mapping methods are **extension methods** on the model type:
|
||||||
|
```csharp
|
||||||
|
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) { … }
|
||||||
|
```
|
||||||
|
- Naming convention: `To<TargetType>()` or `To<TargetType>(extraParam)`.
|
||||||
|
- Never put business logic in mappers — only field assignments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Controller Rules
|
||||||
|
|
||||||
|
- Decorate every controller with `[ApiController]` and `[Route("api/[controller]")]`.
|
||||||
|
- Route URLs are lowercase (`builder.Services.Configure<RouteOptions>(o => o.LowercaseUrls = true)`).
|
||||||
|
- HTTP verb → CRUD mapping:
|
||||||
|
|
||||||
|
| Verb | Operation |
|
||||||
|
|---|---|
|
||||||
|
| `[HttpGet]` / `[HttpGet("{id}")]` | Read single / search/list |
|
||||||
|
| `[HttpPut]` | Create a new resource |
|
||||||
|
| `[HttpPost("{id}")]` | Update a specific resource |
|
||||||
|
| `[HttpPost]` | Bulk update |
|
||||||
|
| `[HttpDelete("{id}")]` | Delete a specific resource |
|
||||||
|
| `[HttpDelete]` | Bulk delete |
|
||||||
|
|
||||||
|
- Return `ActionResult<T>` for endpoints that return a body, and `IStatusCodeActionResult` or `ActionResult` for status-only responses.
|
||||||
|
- Standard error returns: `NotFound()`, `BadRequest()`, `Unauthorized()`, `Forbid()`, `Conflict(…)`.
|
||||||
|
- Always validate `null` after a repository lookup and return `NotFound()` before proceeding.
|
||||||
|
- Access-level checks must be performed inside the action method as well as via `[Authorize]` attributes (defence-in-depth).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication & Authorisation
|
||||||
|
|
||||||
|
- Auth uses **JWT Bearer** tokens (`JwtBearerDefaults.AuthenticationScheme`).
|
||||||
|
- Access is controlled by three roles (in ascending privilege order):
|
||||||
|
|
||||||
|
| Role | Description |
|
||||||
|
|---|---|
|
||||||
|
| `User` | Read-only access to publicly shared / explicitly shared content |
|
||||||
|
| `Curator` | Can create/update/delete their own content |
|
||||||
|
| `Admin` | Unrestricted access |
|
||||||
|
|
||||||
|
- `[Authorize]` with no roles = any authenticated user.
|
||||||
|
- `[Authorize(Roles = "Admin")]` or `[Authorize(Roles = "Admin, Curator")]` for restricted endpoints.
|
||||||
|
Note the space after the comma in the role list — this is the project's established style.
|
||||||
|
- Use `LactoseAuthService.GetUserData(User)` inside action methods to retrieve the typed `LactoseAuthenticatedUser` from claims.
|
||||||
|
- Refresh tokens are stored (hashed by ASP.NET Identity) in the `User` table.
|
||||||
|
`RefreshTokenTransformation` (a `IClaimsTransformation`) validates the refresh token on every request and adds a `"LoginValid"` claim.
|
||||||
|
- Access tokens expire after **10 minutes**; refresh tokens expire after **60 minutes**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background Jobs System
|
||||||
|
|
||||||
|
- All background jobs **extend `Job`** (abstract base in `Lactose.Jobs`).
|
||||||
|
- Override `TaskJob(CancellationToken token)` to implement job logic.
|
||||||
|
- Check `token.IsCancellationRequested` inside loops and call `Status.Cancel()` + `return` when set.
|
||||||
|
- Use `Status.UpdateProgress(float, string)` to report progress (value between 0 and 1).
|
||||||
|
- Use `Status.Complete(string)` / `Status.Fail(string)` for terminal states.
|
||||||
|
- New job types must be instantiatable via `ActivatorUtilities.CreateInstance<T>(serviceProvider, extraArgs)`.
|
||||||
|
- Enqueue jobs with `JobManager.CreateJob<T>(args)` then `JobManager.EnqueueJob(job)`.
|
||||||
|
- Maximum concurrent jobs defaults to **8** (`JobManager.MaxConcurrentJobs`).
|
||||||
|
- Timer-based scheduling is handled by `JobScheduler` which reacts to `SettingsRepository.SettingChanged`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings System
|
||||||
|
|
||||||
|
- Runtime settings are persisted in the `Settings` database table.
|
||||||
|
- Canonical names are defined in `Butter.Settings.Settings` enum with `AsString()` extension.
|
||||||
|
- Default values and metadata are loaded from **`Lactose/DefaultSettings.json`** on startup by `DbInitializer`.
|
||||||
|
- When adding a new setting:
|
||||||
|
1. Add an entry to `Settings` enum and `AsString()`.
|
||||||
|
2. Add the default entry to `DefaultSettings.json`.
|
||||||
|
3. React to changes via `SettingsRepository.SettingChanged` event.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend (MilkStream.Client / Blazor WASM)
|
||||||
|
|
||||||
|
- Use **Blazor WebAssembly** – the client runs entirely in the browser.
|
||||||
|
- Pages live in `MilkStream.Client/Components/Pages/`, reusable components in `MilkStream.Client/Components/` (or a subfolder by concern).
|
||||||
|
- Services that call the backend extend either `ServiceBase` (no auth) or `AuthServiceBase` (requires auth).
|
||||||
|
- `AuthServiceBase` subscribes to `LoginService.AuthInfoChanged` to keep the `Authorization` header up to date.
|
||||||
|
- Use the **`"MilkStreamClient"`** named `HttpClient` (configured with `JwtTokenRefresher`) for any authenticated API call.
|
||||||
|
- `JwtTokenRefresher` automatically refreshes the access token when it is expired or expires within 1 minute.
|
||||||
|
- Use `ProtectedLocalStorage` / `ProtectedSessionStorage` (Blazor's encrypted storage) for auth-related persistence.
|
||||||
|
|
||||||
|
### Dynamic Configuration
|
||||||
|
- MilkStream's `Program.cs` registers a `GET /appsettings.json` endpoint **before** `UseStaticFiles()`, shadowing the static file.
|
||||||
|
- This endpoint returns `{ "BaseUrl": "<LactoseBaseUrl>" }` from server-side configuration, ensuring the WASM client always receives the externally-reachable Lactose URL.
|
||||||
|
- The `LactoseBaseUrl` setting is injected via environment variable in Docker deployments and configured in `appsettings.json` / `appsettings.Development.json` for local development.
|
||||||
|
|
||||||
|
- SCSS source files go in `MilkStream.Client/Styles/`; compiled output is written to `MilkStream.Client/wwwroot/css/` (do not edit the compiled files by hand).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker / Deployment
|
||||||
|
|
||||||
|
- Each deployable project has its own **multi-stage `Dockerfile`** (build → publish → base).
|
||||||
|
- `docker-compose.yml` at repo root wires up `lactose`, `milkstream`, and `database` services.
|
||||||
|
- Media is mounted as a **read-only volume** at `/diary` inside the `lactose` container.
|
||||||
|
- Data-protection keys for MilkStream are persisted via a named Docker volume.
|
||||||
|
- Environment-specific config is loaded from `appsettings.{EnvironmentName}.json`; secrets are injected via environment variables or Docker secrets — **never commit real credentials**.
|
||||||
|
- When `CorsAllowedOrigins` is set to `["*"]` in Lactose configuration, the API accepts requests from any origin (useful for browser-based WASM clients in unpredictable deployment environments). Lock this down to specific origins in production if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration & Secrets
|
||||||
|
|
||||||
|
- `appsettings.json` contains placeholder/default values only.
|
||||||
|
- Sensitive values (DB password, JWT signing key) must be provided via environment variables or .NET User Secrets (development only, keyed by `UserSecretsId`).
|
||||||
|
- The JWT signing key (`SignKey:Key`) must be **at least 32 characters**.
|
||||||
|
- The DB port mapping in `docker-compose.yml` exposes PostgreSQL on host port `3306` (maps to container port `5432`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git & Commit Conventions
|
||||||
|
|
||||||
|
This project uses **Conventional Commits** and generates a changelog with **git-cliff**.
|
||||||
|
|
||||||
|
| Prefix | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `feat:` | New feature |
|
||||||
|
| `fix:` | Bug fix |
|
||||||
|
| `refactor:` | Code restructuring without behaviour change |
|
||||||
|
| `perf:` | Performance improvement |
|
||||||
|
| `doc:` | Documentation changes |
|
||||||
|
| `style:` | Formatting / style only |
|
||||||
|
| `test:` | Tests |
|
||||||
|
| `chore:` / `ci:` | Build system, tooling, CI |
|
||||||
|
| `revert:` | Revert a previous commit |
|
||||||
|
|
||||||
|
- Commits with `chore(release):`, `chore(deps)`, `chore(pr)`, `chore(pull)` are excluded from the changelog automatically.
|
||||||
|
- Breaking changes must be marked with `[**breaking**]` in the commit body or footer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODOs & Known Gaps (as of initial analysis)
|
||||||
|
|
||||||
|
The following items appear as `TODO` comments in the codebase and should be tracked:
|
||||||
|
|
||||||
|
- `AssetController.Create` — endpoint marked `[NonAction]`; purpose unclear, may need removal or redesign.
|
||||||
|
- `AlbumController`, `FolderController`, `SettingsController`, `TagController` — logging not yet wired in.
|
||||||
|
- `AuthController.Register` — should return a full `AuthResultDto` instead of a bare Guid.
|
||||||
|
- `UserController.Get` / `GetAll` — lower access levels should receive reduced user information.
|
||||||
|
- `FileSystemCrawlJob.AssetFromPath` — folder ID is not populated when crawling.
|
||||||
|
- `TagController.Get` (ancestors traversal) — not protected against circular references.
|
||||||
|
- Self-registration flow in `AuthController` is gated by the `UserRegistrationEnabled` setting but that check is not yet implemented in the controller.
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -3,3 +3,8 @@ obj/
|
|||||||
/packages/
|
/packages/
|
||||||
riderModule.iml
|
riderModule.iml
|
||||||
/_ReSharper.Caches/
|
/_ReSharper.Caches/
|
||||||
|
.idea/.idea.MilkyShots/Docker/docker-compose.generated.override.yml
|
||||||
|
storageImages/
|
||||||
|
dotnet-tools.json
|
||||||
|
.opencode/
|
||||||
|
*.css.map
|
||||||
|
|||||||
1
.idea/.gitignore
generated
vendored
Normal file
1
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/dataSources/*
|
||||||
384
.idea/.idea.BlazorTest/.idea/workspace.xml
generated
384
.idea/.idea.BlazorTest/.idea/workspace.xml
generated
@@ -1,384 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AnalysisUIOptions">
|
|
||||||
<option name="SCOPE_TYPE" value="3" />
|
|
||||||
</component>
|
|
||||||
<component name="AutoGeneratedRunConfigurationManager">
|
|
||||||
<projectFile profileName="IIS Express">BlazorTest/BlazorTest.csproj</projectFile>
|
|
||||||
<projectFile profileName="http">BlazorTest/BlazorTest.csproj</projectFile>
|
|
||||||
<projectFile profileName="https">BlazorTest/BlazorTest.csproj</projectFile>
|
|
||||||
<projectFile profileName="IIS Express">WepApiTest/WepApiTest.csproj</projectFile>
|
|
||||||
<projectFile profileName="http">WepApiTest/WepApiTest.csproj</projectFile>
|
|
||||||
<projectFile profileName="https">WepApiTest/WepApiTest.csproj</projectFile>
|
|
||||||
</component>
|
|
||||||
<component name="AutoImportSettings">
|
|
||||||
<option name="autoReloadType" value="SELECTIVE" />
|
|
||||||
</component>
|
|
||||||
<component name="ChangeListManager">
|
|
||||||
<list default="true" id="8146fea4-834d-47b3-9aaa-32befb744c64" name="Changes" comment="" />
|
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
|
||||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
|
||||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
|
||||||
</component>
|
|
||||||
<component name="DatabaseLocalColorSettings">
|
|
||||||
<colors>
|
|
||||||
<entry key="eab007eb-4f50-4961-9bce-5c4940a7cdff" value="Blue" />
|
|
||||||
</colors>
|
|
||||||
</component>
|
|
||||||
<component name="DpaMonitoringSettings">
|
|
||||||
<option name="autoShow" value="false" />
|
|
||||||
</component>
|
|
||||||
<component name="FileTemplateManagerImpl">
|
|
||||||
<option name="RECENT_TEMPLATES">
|
|
||||||
<list>
|
|
||||||
<option value="HTTP Public Environment File" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="Git.Settings">
|
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
|
||||||
</component>
|
|
||||||
<component name="HighlightingSettingsPerFile">
|
|
||||||
<setting file="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/post-requests.http" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
<setting file="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/requests-with-authorization.http" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/1013dc174d8e4a313727d545767084e3df6affa8d4723b41a5f7881e96b1d/ControllerContext.cs" root0="SKIP_HIGHLIGHTING" />
|
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/36f346b6c0454bc8a6afa7aed38119fe5bbffcc983298d9bfa4dbd5f49461f/Monitor.CoreCLR.cs" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/3bd4df5aff92cabbc4d630be64227073db1b8539b3a1e47786b4b189d7cdb7/DbContext.cs" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/449b441523c469ed34ff5a5e14f0bafcd8f097aa463655303dc19048fa44ac3/EntityFrameworkServiceCollectionExtensions.cs" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/7b3b6985aad11c5d633d75b545f5f5f4fc7e50918ad1cb9f6b615682f5bfb76d/SingleQueryingEnumerable.cs" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/96a4cc66aa48dda8a9d8be8febec4a769fc143f7a86dd53ecdebfb9c3177d/ControllerBase.cs" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/9e6ecf3790a24c43487ae724bf9ea8185bdedaf6c28da376d6ea862e66453/WebApplicationBuilder.cs" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/a42ba4db448ed38dcb9fb39194c637a317a8281856b88e139e8a905ee6d12b/Guid.Windows.cs" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/bd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182/ExceptionDispatchInfo.cs" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
<setting file="file://$PROJECT_DIR$/WebApiTest/GlobalUsings.cs" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
<setting file="file://$PROJECT_DIR$/WebApiTest/Program.cs" root0="FORCE_HIGHLIGHTING" />
|
|
||||||
</component>
|
|
||||||
<component name="HttpClientSelectedEnvironments">
|
|
||||||
<file url="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/post-requests.http" environment="test" />
|
|
||||||
<file url="file://$USER_HOME$/AppData/Local/Temp/whats-new-dir/whats-new.http" environment="test" />
|
|
||||||
<file url="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/requests-with-authorization.http" environment="test" />
|
|
||||||
</component>
|
|
||||||
<component name="KubernetesApiPersistence">{}</component>
|
|
||||||
<component name="KubernetesApiProvider">{
|
|
||||||
"isMigrated": true
|
|
||||||
}</component>
|
|
||||||
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
|
|
||||||
<component name="ProblemsViewState">
|
|
||||||
<option name="selectedTabId" value="CurrentFile" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectColorInfo">{
|
|
||||||
"associatedIndex": 4
|
|
||||||
}</component>
|
|
||||||
<component name="ProjectId" id="2mIMBmlQ4hFJsyahkv7dCj0zoOT" />
|
|
||||||
<component name="ProjectLevelVcsManager" settingsEditedManually="true">
|
|
||||||
<ConfirmationsSetting value="2" id="Add" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectViewState">
|
|
||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
|
||||||
<option name="showLibraryContents" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PropertiesComponent">{
|
|
||||||
"keyToString": {
|
|
||||||
".NET Launch Settings Profile.BlazorTest: http.executor": "Debug",
|
|
||||||
".NET Launch Settings Profile.BlazorTest: https.executor": "Debug",
|
|
||||||
".NET Launch Settings Profile.WepApiTest: http.executor": "Debug",
|
|
||||||
"Docker.MariaDbDev.executor": "Run",
|
|
||||||
"Docker.WebApiDocker.executor": "Run",
|
|
||||||
"Docker.WebApiTest/Dockerfile.executor": "Debug",
|
|
||||||
"HTTP Request.WepApiTest | #1 (1).executor": "Run",
|
|
||||||
"HTTP Request.WepApiTest | #1.executor": "Run",
|
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
|
||||||
"git-widget-placeholder": "master",
|
|
||||||
"ignore.virus.scanning.warn.message": "true",
|
|
||||||
"node.js.detected.package.eslint": "true",
|
|
||||||
"node.js.detected.package.tslint": "true",
|
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
|
||||||
"nodejs_package_manager_path": "npm",
|
|
||||||
"settings.editor.selected.configurable": "preferences.lookFeel",
|
|
||||||
"vue.rearranger.settings.migration": "true"
|
|
||||||
},
|
|
||||||
"keyToStringList": {
|
|
||||||
"DatabaseDriversLRU": [
|
|
||||||
"mariadb"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}</component>
|
|
||||||
<component name="RunManager" selected=".NET Launch Settings Profile.WepApiTest: http">
|
|
||||||
<configuration name="WepApiTest | #1 (1)" type="HttpClient.HttpRequestRunConfigurationType" factoryName="HTTP Request" temporary="true" nameIsGenerated="true" path="$PROJECT_DIR$/WebApiTest/WepApiTest.http" requestIdentifier="#1" runType="Run single request">
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
<configuration name="BlazorTest: IIS Express" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
|
||||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
|
|
||||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
|
||||||
<option name="LAUNCH_PROFILE_NAME" value="IIS Express" />
|
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
|
||||||
<option name="USE_MONO" value="0" />
|
|
||||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
|
||||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
|
||||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
|
||||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
|
||||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
|
||||||
<method v="2">
|
|
||||||
<option name="Build" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="BlazorTest: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
|
||||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
|
|
||||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
|
||||||
<option name="LAUNCH_PROFILE_NAME" value="http" />
|
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
|
||||||
<option name="USE_MONO" value="0" />
|
|
||||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
|
||||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
|
||||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
|
||||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
|
||||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
|
||||||
<method v="2">
|
|
||||||
<option name="Build" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="BlazorTest: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
|
||||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
|
|
||||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
|
||||||
<option name="LAUNCH_PROFILE_NAME" value="https" />
|
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
|
||||||
<option name="USE_MONO" value="0" />
|
|
||||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
|
||||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
|
||||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
|
||||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
|
||||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
|
||||||
<method v="2">
|
|
||||||
<option name="Build" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="WepApiTest: IIS Express" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
|
||||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
|
|
||||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
|
||||||
<option name="LAUNCH_PROFILE_NAME" value="IIS Express" />
|
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
|
||||||
<option name="USE_MONO" value="0" />
|
|
||||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
|
||||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
|
||||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
|
||||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
|
||||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
|
||||||
<method v="2">
|
|
||||||
<option name="Build" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="WepApiTest: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
|
||||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
|
|
||||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
|
||||||
<option name="LAUNCH_PROFILE_NAME" value="http" />
|
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
|
||||||
<option name="USE_MONO" value="0" />
|
|
||||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
|
||||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
|
||||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
|
||||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
|
||||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
|
||||||
<method v="2">
|
|
||||||
<option name="Build" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="WepApiTest: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
|
||||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
|
|
||||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
|
||||||
<option name="LAUNCH_PROFILE_NAME" value="https" />
|
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
|
||||||
<option name="USE_MONO" value="0" />
|
|
||||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
|
||||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
|
||||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
|
||||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
|
||||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
|
||||||
<method v="2">
|
|
||||||
<option name="Build" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="BlazorTest/Dockerfile" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
|
|
||||||
<deployment type="dockerfile">
|
|
||||||
<settings>
|
|
||||||
<option name="imageTag" value="blazortest" />
|
|
||||||
<option name="containerName" value="blazortest" />
|
|
||||||
<option name="contextFolderPath" value="." />
|
|
||||||
<option name="portBindings">
|
|
||||||
<list>
|
|
||||||
<DockerPortBindingImpl>
|
|
||||||
<option name="containerPort" value="8080" />
|
|
||||||
<option name="hostIp" value="127.0.0.1" />
|
|
||||||
<option name="hostPort" value="8080" />
|
|
||||||
</DockerPortBindingImpl>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
<option name="sourceFilePath" value="BlazorTest/Dockerfile" />
|
|
||||||
</settings>
|
|
||||||
</deployment>
|
|
||||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
<configuration name="MariaDbDev" type="docker-deploy" factoryName="docker-image" temporary="true" server-name="Docker">
|
|
||||||
<deployment type="docker-image">
|
|
||||||
<settings>
|
|
||||||
<option name="imageTag" value="mariadb:latest" />
|
|
||||||
<option name="containerName" value="MariaDbDev" />
|
|
||||||
<option name="envVars">
|
|
||||||
<list>
|
|
||||||
<DockerEnvVarImpl>
|
|
||||||
<option name="name" value="MYSQL_ROOT_PASSWORD" />
|
|
||||||
<option name="value" value="testOnlyDb" />
|
|
||||||
</DockerEnvVarImpl>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
<option name="portBindings">
|
|
||||||
<list>
|
|
||||||
<DockerPortBindingImpl>
|
|
||||||
<option name="containerPort" value="3306" />
|
|
||||||
<option name="hostPort" value="3306" />
|
|
||||||
</DockerPortBindingImpl>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</settings>
|
|
||||||
</deployment>
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
<configuration name="WebApiDocker" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
|
|
||||||
<deployment type="dockerfile">
|
|
||||||
<settings>
|
|
||||||
<option name="imageTag" value="wepapitest" />
|
|
||||||
<option name="containerName" value="wepapitest" />
|
|
||||||
<option name="contextFolderPath" value="." />
|
|
||||||
<option name="portBindings">
|
|
||||||
<list>
|
|
||||||
<DockerPortBindingImpl>
|
|
||||||
<option name="containerPort" value="8080" />
|
|
||||||
<option name="hostIp" value="127.0.0.1" />
|
|
||||||
<option name="hostPort" value="8080" />
|
|
||||||
</DockerPortBindingImpl>
|
|
||||||
<DockerPortBindingImpl>
|
|
||||||
<option name="containerPort" value="5162" />
|
|
||||||
<option name="hostPort" value="5162" />
|
|
||||||
</DockerPortBindingImpl>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
<option name="sourceFilePath" value="WebApiTest/Dockerfile" />
|
|
||||||
</settings>
|
|
||||||
</deployment>
|
|
||||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
<configuration default="true" type="docker-deploy" factoryName="docker-compose.yml" temporary="true">
|
|
||||||
<deployment type="docker-compose.yml">
|
|
||||||
<settings />
|
|
||||||
</deployment>
|
|
||||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
<configuration default="true" type="docker-deploy" factoryName="docker-image" temporary="true">
|
|
||||||
<deployment type="docker-image">
|
|
||||||
<settings />
|
|
||||||
</deployment>
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
<configuration default="true" type="docker-deploy" factoryName="dockerfile" temporary="true">
|
|
||||||
<deployment type="dockerfile">
|
|
||||||
<settings />
|
|
||||||
</deployment>
|
|
||||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
<configuration name="docker-compose.yml: Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
|
|
||||||
<deployment type="docker-compose.yml">
|
|
||||||
<settings>
|
|
||||||
<option name="envFilePath" value="" />
|
|
||||||
<option name="sourceFilePath" value="docker-compose.yml" />
|
|
||||||
</settings>
|
|
||||||
</deployment>
|
|
||||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
<list>
|
|
||||||
<item itemvalue=".NET Launch Settings Profile.BlazorTest: http" />
|
|
||||||
<item itemvalue=".NET Launch Settings Profile.BlazorTest: https" />
|
|
||||||
<item itemvalue=".NET Launch Settings Profile.BlazorTest: IIS Express" />
|
|
||||||
<item itemvalue=".NET Launch Settings Profile.WepApiTest: http" />
|
|
||||||
<item itemvalue=".NET Launch Settings Profile.WepApiTest: https" />
|
|
||||||
<item itemvalue=".NET Launch Settings Profile.WepApiTest: IIS Express" />
|
|
||||||
<item itemvalue="Docker.BlazorTest/Dockerfile" />
|
|
||||||
<item itemvalue="Docker.docker-compose.yml: Compose Deployment" />
|
|
||||||
<item itemvalue="Docker.WebApiDocker" />
|
|
||||||
<item itemvalue="Docker.MariaDbDev" />
|
|
||||||
<item itemvalue="HTTP Request.WepApiTest | #1 (1)" />
|
|
||||||
</list>
|
|
||||||
<recent_temporary>
|
|
||||||
<list>
|
|
||||||
<item itemvalue="HTTP Request.WepApiTest | #1 (1)" />
|
|
||||||
<item itemvalue="Docker.MariaDbDev" />
|
|
||||||
</list>
|
|
||||||
</recent_temporary>
|
|
||||||
</component>
|
|
||||||
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
|
|
||||||
<component name="TaskManager">
|
|
||||||
<task active="true" id="Default" summary="Default task">
|
|
||||||
<changelist id="8146fea4-834d-47b3-9aaa-32befb744c64" name="Changes" comment="" />
|
|
||||||
<created>1726759505353</created>
|
|
||||||
<option name="number" value="Default" />
|
|
||||||
<option name="presentableId" value="Default" />
|
|
||||||
<updated>1726759505353</updated>
|
|
||||||
<workItem from="1726759506537" duration="8762000" />
|
|
||||||
<workItem from="1726775138143" duration="20326000" />
|
|
||||||
<workItem from="1726877247262" duration="2104000" />
|
|
||||||
<workItem from="1726879467881" duration="2586000" />
|
|
||||||
<workItem from="1726882140090" duration="5969000" />
|
|
||||||
<workItem from="1726888243937" duration="249000" />
|
|
||||||
<workItem from="1726888519041" duration="1373000" />
|
|
||||||
<workItem from="1727188917179" duration="1894000" />
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00001" summary="BaseFiles">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1726786753746</created>
|
|
||||||
<option name="number" value="00001" />
|
|
||||||
<option name="presentableId" value="LOCAL-00001" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1726786753746</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00002" summary="BaseFiles">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1726786763975</created>
|
|
||||||
<option name="number" value="00002" />
|
|
||||||
<option name="presentableId" value="LOCAL-00002" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1726786763975</updated>
|
|
||||||
</task>
|
|
||||||
<option name="localTasksCounter" value="3" />
|
|
||||||
<servers />
|
|
||||||
</component>
|
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
|
||||||
<option name="version" value="3" />
|
|
||||||
</component>
|
|
||||||
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
|
|
||||||
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
|
|
||||||
<component name="Vcs.Log.Tabs.Properties">
|
|
||||||
<option name="TAB_STATES">
|
|
||||||
<map>
|
|
||||||
<entry key="MAIN">
|
|
||||||
<value>
|
|
||||||
<State />
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="VcsManagerConfiguration">
|
|
||||||
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
|
|
||||||
<MESSAGE value="BaseFiles" />
|
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="BaseFiles" />
|
|
||||||
</component>
|
|
||||||
<component name="XSLT-Support.FileAssociations.UIState">
|
|
||||||
<expand />
|
|
||||||
<select />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.agent.xml
generated
Normal file
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.edit.xml
generated
Normal file
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.edit.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EditMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
28
.idea/.idea.MilkyShots/.idea/dataSources.xml
generated
28
.idea/.idea.MilkyShots/.idea/dataSources.xml
generated
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
<data-source source="LOCAL" name="TestDb@192.168.50.110" uuid="eab007eb-4f50-4961-9bce-5c4940a7cdff">
|
<data-source source="LOCAL" name="MariaDb" uuid="eab007eb-4f50-4961-9bce-5c4940a7cdff">
|
||||||
<driver-ref>mariadb</driver-ref>
|
<driver-ref>mariadb</driver-ref>
|
||||||
<synchronize>true</synchronize>
|
<synchronize>true</synchronize>
|
||||||
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
|
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
|
||||||
<jdbc-url>jdbc:mariadb://localhost:3306</jdbc-url>
|
<jdbc-url>jdbc:mariadb://localhost:3306/</jdbc-url>
|
||||||
<jdbc-additional-properties>
|
<jdbc-additional-properties>
|
||||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
@@ -15,5 +15,29 @@
|
|||||||
<working-dir>$ProjectFileDir$</working-dir>
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
<time-zone>Europe/Rome</time-zone>
|
<time-zone>Europe/Rome</time-zone>
|
||||||
</data-source>
|
</data-source>
|
||||||
|
<data-source source="LOCAL" name="TestDb@localhost" uuid="80498c22-67da-4c4a-8106-c4789470ef83">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://localhost:3306/TestDb</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
<data-source source="LOCAL" name="TestDb@pm" uuid="a0f3e480-c856-41e0-8fb4-5cac87b9faf6">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://192.168.51.101:3306/TestDb</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
11
.idea/.idea.MilkyShots/.idea/db-forest-config.xml
generated
Normal file
11
.idea/.idea.MilkyShots/.idea/db-forest-config.xml
generated
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="db-forest-configuration">
|
||||||
|
<data version="2">.
|
||||||
|
----------------------------------------
|
||||||
|
1:0:eab007eb-4f50-4961-9bce-5c4940a7cdff
|
||||||
|
2:0:80498c22-67da-4c4a-8106-c4789470ef83
|
||||||
|
3:0:a0f3e480-c856-41e0-8fb4-5cac87b9faf6
|
||||||
|
.</data>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
14
.idea/.idea.MilkyShots/.idea/discord.xml
generated
Normal file
14
.idea/.idea.MilkyShots/.idea/discord.xml
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DiscordProjectSettings">
|
||||||
|
<option name="show" value="PROJECT_FILES" />
|
||||||
|
<option name="description" value="" />
|
||||||
|
<option name="applicationTheme" value="default" />
|
||||||
|
<option name="iconsTheme" value="default" />
|
||||||
|
<option name="button1Title" value="" />
|
||||||
|
<option name="button1Url" value="" />
|
||||||
|
<option name="button2Title" value="" />
|
||||||
|
<option name="button2Url" value="" />
|
||||||
|
<option name="customApplicationId" value="" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/.idea.MilkyShots/.idea/jsLibraryMappings.xml
generated
Normal file
6
.idea/.idea.MilkyShots/.idea/jsLibraryMappings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="JavaScriptLibraryMappings">
|
||||||
|
<file url="PROJECT" libraries="{bootstrap-icons}" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/.idea.MilkyShots/.idea/swagger-settings.xml
generated
Normal file
10
.idea/.idea.MilkyShots/.idea/swagger-settings.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SwaggerSettings">
|
||||||
|
<remoteSpecificationUrls>
|
||||||
|
<UrlInfo>
|
||||||
|
<option name="urlString" value="http://192.168.51.101:5162/swagger/v1/swagger.json" />
|
||||||
|
</UrlInfo>
|
||||||
|
</remoteSpecificationUrls>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
25
.idea/.idea.MilkyShots/.idea/watcherTasks.xml
generated
Normal file
25
.idea/.idea.MilkyShots/.idea/watcherTasks.xml
generated
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectTasksOptions">
|
||||||
|
<TaskOptions isEnabled="false">
|
||||||
|
<option name="arguments" value="$FileName$:$FileNameWithoutExtension$.css" />
|
||||||
|
<option name="checkSyntaxErrors" value="true" />
|
||||||
|
<option name="description" />
|
||||||
|
<option name="exitCodeBehavior" value="ERROR" />
|
||||||
|
<option name="fileExtension" value="scss" />
|
||||||
|
<option name="immediateSync" value="true" />
|
||||||
|
<option name="name" value="SCSS" />
|
||||||
|
<option name="output" value="$FileNameWithoutExtension$.css:$FileNameWithoutExtension$.css.map" />
|
||||||
|
<option name="outputFilters">
|
||||||
|
<array />
|
||||||
|
</option>
|
||||||
|
<option name="outputFromStdout" value="false" />
|
||||||
|
<option name="program" value="sass" />
|
||||||
|
<option name="runOnExternalChanges" value="true" />
|
||||||
|
<option name="scopeName" value="All Places" />
|
||||||
|
<option name="trackOnlyRoot" value="true" />
|
||||||
|
<option name="workingDir" value="$FileDir$" />
|
||||||
|
<envs />
|
||||||
|
</TaskOptions>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/MilkyShots.iml
generated
Normal file
8
.idea/MilkyShots.iml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="DBE_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
37
.idea/dataSources.local.xml
generated
Normal file
37
.idea/dataSources.local.xml
generated
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="dataSourceStorageLocal" created-in="DB-243.22562.115">
|
||||||
|
<data-source name="MilkyShots DB" uuid="5c3a7e69-b472-4b75-a109-a12ad8f595c4">
|
||||||
|
<database-info product="MariaDB" version="11.5.2-MariaDB-ubu2404" jdbc-version="4.2" driver-name="MariaDB Connector/J" driver-version="3.3.3" dbms="MARIADB" exact-version="11.5.2" exact-driver-version="3.3">
|
||||||
|
<extra-name-characters>#@</extra-name-characters>
|
||||||
|
<identifier-quote-string>`</identifier-quote-string>
|
||||||
|
</database-info>
|
||||||
|
<case-sensitivity plain-identifiers="exact" quoted-identifiers="exact" />
|
||||||
|
<secret-storage>master_key</secret-storage>
|
||||||
|
<user-name>root</user-name>
|
||||||
|
<schema-mapping>
|
||||||
|
<introspection-scope>
|
||||||
|
<node kind="schema">
|
||||||
|
<name qname="TestDb" />
|
||||||
|
<name qname="TestDB" />
|
||||||
|
</node>
|
||||||
|
</introspection-scope>
|
||||||
|
</schema-mapping>
|
||||||
|
</data-source>
|
||||||
|
<data-source name="TestDb@localhost" uuid="dd4c02e2-288e-48c7-9edb-c171a228f8e7">
|
||||||
|
<database-info product="PostgreSQL" version="17.1 (Debian 17.1-1.pgdg120+1)" jdbc-version="4.2" driver-name="PostgreSQL JDBC Driver" driver-version="42.7.3" dbms="POSTGRES" exact-version="17.1" exact-driver-version="42.7">
|
||||||
|
<identifier-quote-string>"</identifier-quote-string>
|
||||||
|
</database-info>
|
||||||
|
<case-sensitivity plain-identifiers="lower" quoted-identifiers="exact" />
|
||||||
|
<secret-storage>master_key</secret-storage>
|
||||||
|
<user-name>root</user-name>
|
||||||
|
<schema-mapping>
|
||||||
|
<introspection-scope>
|
||||||
|
<node kind="database" qname="@">
|
||||||
|
<node kind="schema" qname="@" />
|
||||||
|
</node>
|
||||||
|
</introspection-scope>
|
||||||
|
</schema-mapping>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
19
.idea/dataSources.xml
generated
Normal file
19
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="MilkyShots DB" uuid="5c3a7e69-b472-4b75-a109-a12ad8f595c4">
|
||||||
|
<driver-ref>mariadb</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:mariadb://localhost:3306</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
<data-source source="LOCAL" name="TestDb@localhost" uuid="dd4c02e2-288e-48c7-9edb-c171a228f8e7">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://localhost:3306/TestDb</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/MilkyShots.iml" filepath="$PROJECT_DIR$/.idea/MilkyShots.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
45
.idea/workspace.xml
generated
Normal file
45
.idea/workspace.xml
generated
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AutoImportSettings">
|
||||||
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
|
</component>
|
||||||
|
<component name="ChangeListManager">
|
||||||
|
<list default="true" id="a41b55c3-f6d1-4218-8aa4-78c5f70285ef" name="Changes" comment="">
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
|
</list>
|
||||||
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
|
</component>
|
||||||
|
<component name="Git.Settings">
|
||||||
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectColorInfo">{
|
||||||
|
"associatedIndex": 6
|
||||||
|
}</component>
|
||||||
|
<component name="ProjectId" id="2ohtQjPWvrPgJ9BoCV3cyJMXtHw" />
|
||||||
|
<component name="ProjectViewState">
|
||||||
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
|
<option name="showLibraryContents" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="PropertiesComponent">{
|
||||||
|
"keyToString": {
|
||||||
|
"RunOnceActivity.OpenDatabaseViewOnStart": "true",
|
||||||
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
|
"database.data.extractors.current.export.id": "Pretty-Groovy.txt.groovy",
|
||||||
|
"database.data.extractors.current.id": "Pretty-Groovy.txt.groovy",
|
||||||
|
"git-widget-placeholder": "feature/webapi/endpoint-impl",
|
||||||
|
"ignore.virus.scanning.warn.message": "true",
|
||||||
|
"last_opened_file_path": "C:/Users/airon"
|
||||||
|
},
|
||||||
|
"keyToStringList": {
|
||||||
|
"DatabaseDriversLRU": [
|
||||||
|
"mariadb",
|
||||||
|
"postgresql"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}</component>
|
||||||
|
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
|
||||||
|
</project>
|
||||||
5
.run/All in WepApiTest.run.xml
Normal file
5
.run/All in WepApiTest.run.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="All in WepApiTest" type="HttpClient.HttpRequestRunConfigurationType" factoryName="HTTP Request" path="$PROJECT_DIR$/Lactose/WepApiTest.http">
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
25
.run/Compose Deployment (Local).run.xml
Normal file
25
.run/Compose Deployment (Local).run.xml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Compose Deployment (Local)" type="docker-deploy" factoryName="docker-compose.yml" activateToolWindowBeforeRun="false" server-name="Docker">
|
||||||
|
<deployment type="docker-compose.yml">
|
||||||
|
<settings>
|
||||||
|
<option name="containerName" value="" />
|
||||||
|
<option name="envVars">
|
||||||
|
<list>
|
||||||
|
<DockerEnvVarImpl>
|
||||||
|
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
|
||||||
|
<option name="value" value="Docker" />
|
||||||
|
</DockerEnvVarImpl>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="removeImagesOnComposeDown" value="LOCAL" />
|
||||||
|
<option name="removeOrphansOnComposeDown" value="false" />
|
||||||
|
<option name="commandLineOptions" value="--build" />
|
||||||
|
<option name="sourceFilePath" value="docker-compose.yml" />
|
||||||
|
<option name="upForceRecreate" value="true" />
|
||||||
|
<option name="upRemoveOrphans" value="true" />
|
||||||
|
</settings>
|
||||||
|
</deployment>
|
||||||
|
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
34
.run/Compose Deployment (PM).run.xml
Normal file
34
.run/Compose Deployment (PM).run.xml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Compose Deployment (PM)" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker-PM">
|
||||||
|
<deployment type="docker-compose.yml">
|
||||||
|
<settings>
|
||||||
|
<option name="containerName" value="" />
|
||||||
|
<option name="envFilePath" value="" />
|
||||||
|
<option name="envFilePaths">
|
||||||
|
<list>
|
||||||
|
<option value="" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="envVars">
|
||||||
|
<list>
|
||||||
|
<DockerEnvVarImpl>
|
||||||
|
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
|
||||||
|
<option name="value" value="Docker" />
|
||||||
|
</DockerEnvVarImpl>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="removeOrphansOnComposeDown" value="false" />
|
||||||
|
<option name="commandLineOptions" value="--build" />
|
||||||
|
<option name="secondarySourceFiles">
|
||||||
|
<list>
|
||||||
|
<option value="docker-compose.pm.yml" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="sourceFilePath" value="docker-compose.yml" />
|
||||||
|
<option name="upRemoveOrphans" value="true" />
|
||||||
|
</settings>
|
||||||
|
</deployment>
|
||||||
|
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="Lactose: IIS Express" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
|
||||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
|
|
||||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
|
||||||
<option name="LAUNCH_PROFILE_NAME" value="IIS Express" />
|
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
|
||||||
<option name="USE_MONO" value="0" />
|
|
||||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
|
||||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
|
||||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
|
||||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
|
||||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
|
||||||
<method v="2">
|
|
||||||
<option name="Build" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
18
.run/MilkStream HotReload.run.xml
Normal file
18
.run/MilkStream HotReload.run.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="MilkStream HotReload" type="RunDotNetWatch" factoryName="RunDotNetWatch">
|
||||||
|
<option name="envs">
|
||||||
|
<map>
|
||||||
|
<entry key="ASPNETCORE_ENVIRONMENT" value="Development" />
|
||||||
|
<entry key="DOTNET_ENVIRONMENT" value="Hotreload" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
<option name="exePath" value="$PROJECT_DIR$/MilkStream/bin/Debug/net8.0/MilkStream.exe" />
|
||||||
|
<option name="programParameters" value="" />
|
||||||
|
<option name="projectFilePath" value="$PROJECT_DIR$/MilkStream/MilkStream.csproj" />
|
||||||
|
<option name="projectTfm" value="net8.0" />
|
||||||
|
<option name="suppressBrowserLaunch" value="true" />
|
||||||
|
<option name="watchParameters" value="" />
|
||||||
|
<option name="workingDirectory" value="$PROJECT_DIR$/MilkStream" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
13
.run/MilkStream.run.xml
Normal file
13
.run/MilkStream.run.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="MilkStream" type="docker-deploy" factoryName="dockerfile" server-name="Docker-PM">
|
||||||
|
<deployment type="dockerfile">
|
||||||
|
<settings>
|
||||||
|
<option name="containerName" value="milkstream" />
|
||||||
|
<option name="contextFolderPath" value="." />
|
||||||
|
<option name="sourceFilePath" value="MilkStream/Dockerfile" />
|
||||||
|
</settings>
|
||||||
|
</deployment>
|
||||||
|
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
97
AGENTS.md
Normal file
97
AGENTS.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# MilkyShots – Agent Guide
|
||||||
|
|
||||||
|
**Gitea repo owner:** MilkyShots
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **.NET 10 SDK** (pinned in `global.json`; `allowPrerelease: true`)
|
||||||
|
- **PostgreSQL with pgvector extension**
|
||||||
|
- **SCSS** compiled by `AspNetCore.SassCompiler` MSBuild task (no Node.js needed; compiles during `dotnet build`)
|
||||||
|
- Run `dotnet tool restore` before first migration — installs `dotnet-ef`
|
||||||
|
|
||||||
|
## SCSS
|
||||||
|
|
||||||
|
SCSS source is in `MilkStream.Client/Styles/`. Compiled via `AspNetCore.SassCompiler` MSBuild target:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build # compiles SCSS automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
- Config: `MilkStream.Client/Styles/sasscompiler.json` (expanded in Debug, compressed in Release)
|
||||||
|
- Source maps disabled
|
||||||
|
- Bootstrap 5.3.7 SCSS vendored in `Styles/bootstrap/`
|
||||||
|
- Theme variables adjustable in `Styles/_variables.scss` (`$accent` for primary color)
|
||||||
|
- Aggressive gradient mixins in `Styles/_gradients.scss`
|
||||||
|
- Works in Docker — MSBuild task runs during `dotnet build`/`dotnet publish`, no hosted service
|
||||||
|
|
||||||
|
## Projects (4 in solution)
|
||||||
|
|
||||||
|
| Project | Role | Entrypoint |
|
||||||
|
|---|---|---|
|
||||||
|
| **Butter** | Shared class library (DTOs, enums, MIME types) | — |
|
||||||
|
| **Lactose** | ASP.NET Core Web API — controllers, EF Core, repos, background jobs | `Lactose/Program.cs` |
|
||||||
|
| **MilkStream** | Blazor WASM host — serves WASM files + dynamic `/appsettings.json` | `MilkStream/Program.cs` |
|
||||||
|
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services, shared UI components (ModalFrame, EmptyState) | `MilkStream.Client/Program.cs` |
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build MilkyShots.sln # .NET 10, C# 12
|
||||||
|
dotnet run --project Lactose # API on :5162 (host) / :8080 (container)
|
||||||
|
dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (container)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database:** Default credentials in `appsettings.json`. Docker compose spins up all services.
|
||||||
|
|
||||||
|
**Port gotcha:** `appsettings.json` defaults `DatabaseAddress:Port` to **3306** (legacy MySQL port). PostgreSQL runs on 5432. The raw connection string must be overridden, or use `docker-compose.yml` which maps host 3306 → container 5432 automatically.
|
||||||
|
|
||||||
|
## Infrastructure gotchas
|
||||||
|
|
||||||
|
- **Npgsql legacy timestamps:** `AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true)` is set in `Lactose/Program.cs`. Do not remove without understanding the timestamp implications.
|
||||||
|
- **MilkStream `/appsettings.json`:** Dynamically generated from server config — maps **before** `UseStaticFiles`, so it shadows the static file in `MilkStream.Client/wwwroot/`. This endpoint provides `LactoseBaseUrl` to the WASM client.
|
||||||
|
- **Auth requires claims transformation:** `RefreshTokenTransformation` (registered as `IClaimsTransformation`) must succeed on every authenticated request, or all endpoints return 403.
|
||||||
|
- **EF Core Tools v8.0.15 vs EF Core Design v10.0.9:** Version mismatch may cause `dotnet ef` CLI quirks.
|
||||||
|
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (default) for login, and one named `"MilkstreamClient"` which has `JwtTokenRefresher` as a `DelegatingHandler`. The handler transparently refreshes expired tokens pre-flight and retries once on 401 — authenticated services must **never** handle token refreshes manually. Do not add `SendWithRefreshAsync` or similar wrappers; rely solely on the named client.
|
||||||
|
- **HTTPS redirection is commented out** in Lactose — API does not enforce HTTPS.
|
||||||
|
|
||||||
|
## XML docs enforced
|
||||||
|
|
||||||
|
`Directory.Build.props` at solution root enables `GenerateDocumentationFile` and treats **CS1591** (missing XML comment) as error. All public APIs must have XML docs. Repo implementations use `<inheritdoc />` — keep interface docs in sync.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **`HttpPut` = create, `HttpPost("{id}")` = update** — not REST-idiomatic; do not "fix" without team buy-in
|
||||||
|
- **Soft delete only** — set `DeletedAt`, never hard-delete (models: Asset, User)
|
||||||
|
- **Repository `Save()` must be called explicitly** after insert/update/delete
|
||||||
|
- **Enum prefix `E`** (e.g. `EAccessLevel`, `EAssetType`, `EJobStatus`)
|
||||||
|
- **JWT access token**: 10 min, refresh token: 60 min (constants in `LactoseAuthService.cs`)
|
||||||
|
- **Conventional Commits** enforced via `cliff.toml`; changelog generated with `git-cliff`
|
||||||
|
- **Granular commits** — commit each logical change separately (e.g., DTO change, controller logic, UI component) with a descriptive commit message
|
||||||
|
- **Reuse API endpoints** — prefer adding optional query parameters to existing endpoints over creating new routes. New endpoints are a last resort when the existing ones fundamentally cannot serve the need.
|
||||||
|
|
||||||
|
## Modularity
|
||||||
|
|
||||||
|
Reusable UI components live in `MilkStream.Client/Components/Shared/`.
|
||||||
|
When a markup pattern appears in 2+ places, extract it into a shared component.
|
||||||
|
See issue #52 for the tracking list of candidate components.
|
||||||
|
|
||||||
|
## CORS gotchas
|
||||||
|
|
||||||
|
- `CorsAllowedOrigins` env var **replaces** the JSON array entirely (semicolon-separated)
|
||||||
|
- MilkStream browser origin must be listed in Lactose's CORS
|
||||||
|
- `LactoseBaseUrl` on MilkStream = URL the browser uses to reach Lactose
|
||||||
|
|
||||||
|
## EF Core / Migrations
|
||||||
|
|
||||||
|
Migrations in `Lactose/Migrations/` — generated, do not hand-edit.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet ef migrations add <Name> --project Lactose
|
||||||
|
dotnet ef database update --project Lactose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
No test project found in solution.
|
||||||
|
|
||||||
|
|
||||||
9
Butter/Butter.csproj
Normal file
9
Butter/Butter.csproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
31
Butter/Dtos/Album/AlbumAssetPreviewDto.cs
Normal file
31
Butter/Dtos/Album/AlbumAssetPreviewDto.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Butter.Dtos.Album;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal asset info needed for displaying album assets.
|
||||||
|
/// </summary>
|
||||||
|
public class AlbumAssetPreviewDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the asset ID.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the resolution width.
|
||||||
|
/// </summary>
|
||||||
|
public int ResolutionWidth { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the resolution height.
|
||||||
|
/// </summary>
|
||||||
|
public int ResolutionHeight { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the asset has a thumbnail.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasThumbnail { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the asset has a preview.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasPreview { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the original filename of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
}
|
||||||
27
Butter/Dtos/Album/AlbumCreateDto.cs
Normal file
27
Butter/Dtos/Album/AlbumCreateDto.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Butter.Dtos.Album;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to create a new album.
|
||||||
|
/// </summary>
|
||||||
|
public class AlbumCreateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the album.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the owner ID of the album.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Owner { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the person ID associated with the album.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Person { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the cover asset ID for the album.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? CoverAssetId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of asset IDs in the album.
|
||||||
|
/// </summary>
|
||||||
|
public List<Guid>? Assets { get; set; }
|
||||||
|
}
|
||||||
15
Butter/Dtos/Album/AlbumFullDto.cs
Normal file
15
Butter/Dtos/Album/AlbumFullDto.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Butter.Dtos.Album;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the full details of an album, including its associated assets.
|
||||||
|
/// </summary>
|
||||||
|
public class AlbumFullDto : AlbumPreviewDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of asset IDs in the album.
|
||||||
|
/// </summary>
|
||||||
|
public List<Guid> Images { get; set; } = [];
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the asset preview information for rendering.
|
||||||
|
/// </summary>
|
||||||
|
public List<AlbumAssetPreviewDto> AssetPreviews { get; set; } = [];
|
||||||
|
}
|
||||||
35
Butter/Dtos/Album/AlbumPreviewDto.cs
Normal file
35
Butter/Dtos/Album/AlbumPreviewDto.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Butter.Dtos.Album;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a summary view of an album.
|
||||||
|
/// </summary>
|
||||||
|
public class AlbumPreviewDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique identifier of the album.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the album.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the owner ID of the album.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Owner { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the person ID associated with the album.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Person { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the person name associated with the album.
|
||||||
|
/// </summary>
|
||||||
|
public string? PersonName { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ID of the cover asset for this album.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? CoverAssetId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets in the album.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetCount { get; set; }
|
||||||
|
}
|
||||||
11
Butter/Dtos/Album/AlbumSearchParametersDto.cs
Normal file
11
Butter/Dtos/Album/AlbumSearchParametersDto.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Butter.Dtos.Album;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Search and filter parameters for album queries.
|
||||||
|
/// </summary>
|
||||||
|
public class AlbumSearchParametersDto : PagedSearchParametersDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether to return only albums with no person assigned.
|
||||||
|
/// </summary>
|
||||||
|
public bool Unassigned { get; set; }
|
||||||
|
}
|
||||||
31
Butter/Dtos/Album/AlbumUpdateDto.cs
Normal file
31
Butter/Dtos/Album/AlbumUpdateDto.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Butter.Dtos.Album;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to update an existing album.
|
||||||
|
/// </summary>
|
||||||
|
public class AlbumUpdateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the album.
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the owner ID of the album.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Owner { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the person ID associated with the album.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Person { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether to remove the person assignment from the album.
|
||||||
|
/// </summary>
|
||||||
|
public bool RemovePerson { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the cover asset ID for the album.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? CoverAssetId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of asset IDs in the album.
|
||||||
|
/// </summary>
|
||||||
|
public List<Guid>? Assets { get; set; }
|
||||||
|
}
|
||||||
49
Butter/Dtos/Asset/AssetCreateDto.cs
Normal file
49
Butter/Dtos/Asset/AssetCreateDto.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
|
namespace Butter.Dtos.Asset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to create a new asset.
|
||||||
|
/// </summary>
|
||||||
|
public class AssetCreateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the creation date of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the owner ID of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Owner { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public EAssetType AssetType { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the asset is publicly shared.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPublic { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the MIME type of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public string MimeType { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the resolution width in pixels.
|
||||||
|
/// </summary>
|
||||||
|
public int ResolutionWidth { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the resolution height in pixels.
|
||||||
|
/// </summary>
|
||||||
|
public int ResolutionHeight { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the file size in bytes.
|
||||||
|
/// </summary>
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the duration in seconds (for video/audio).
|
||||||
|
/// </summary>
|
||||||
|
public int? Duration { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the frame rate (for video).
|
||||||
|
/// </summary>
|
||||||
|
public int? FrameRate { get; set; }
|
||||||
|
}
|
||||||
50
Butter/Dtos/Asset/AssetDto.cs
Normal file
50
Butter/Dtos/Asset/AssetDto.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using Butter.Dtos.User;
|
||||||
|
using Butter.Types;
|
||||||
|
|
||||||
|
namespace Butter.Dtos.Asset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the full information needed to display an asset on a detail page.
|
||||||
|
/// </summary>
|
||||||
|
public class AssetDto : AssetPreviewDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the asset (e.g., Image, Video).
|
||||||
|
/// </summary>
|
||||||
|
public EAssetType AssetType { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the asset is publicly shared.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPublic { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the owner of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public required UserInfoDto Owner { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the creation date of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the last updated date of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the deletion date of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? DeletedAt { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the MIME type of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public new string MimeType { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the file size of the asset in bytes.
|
||||||
|
/// </summary>
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the duration of the asset in seconds (for video/audio).
|
||||||
|
/// </summary>
|
||||||
|
public float Duration { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the frame rate of the asset (for video).
|
||||||
|
/// </summary>
|
||||||
|
public float FrameRate { get; set; }
|
||||||
|
}
|
||||||
51
Butter/Dtos/Asset/AssetPreviewDto.cs
Normal file
51
Butter/Dtos/Asset/AssetPreviewDto.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
namespace Butter.Dtos.Asset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the minimum information needed to display an asset in a list.
|
||||||
|
/// </summary>
|
||||||
|
public class AssetPreviewDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique identifier of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the MIME type of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public string MimeType { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the resolution width of the asset in pixels.
|
||||||
|
/// </summary>
|
||||||
|
public int ResolutionWidth { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the resolution height of the asset in pixels.
|
||||||
|
/// </summary>
|
||||||
|
public int ResolutionHeight { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the asset has a generated thumbnail.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasThumbnail { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the asset has a generated preview.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasPreview { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of album titles associated with the asset.
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? AlbumNames { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of album IDs associated with the asset, parallel to <see cref="AlbumNames"/>.
|
||||||
|
/// </summary>
|
||||||
|
public List<Guid>? AlbumIds { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of cosplayer names associated with the asset, derived from album person owners.
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? CosplayerNames { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of cosplayer IDs associated with the asset, parallel to <see cref="CosplayerNames"/>.
|
||||||
|
/// </summary>
|
||||||
|
public List<Guid>? CosplayerIds { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the original filename of the asset.
|
||||||
|
/// </summary>
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
}
|
||||||
29
Butter/Dtos/Asset/AssetSearchOptionsDto.cs
Normal file
29
Butter/Dtos/Asset/AssetSearchOptionsDto.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
|
namespace Butter.Dtos.Asset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the search options for a detailed search of assets.
|
||||||
|
/// </summary>
|
||||||
|
public class AssetSearchOptionsDto: PagedSearchParametersDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the start date for filtering assets by date range.
|
||||||
|
/// </summary>
|
||||||
|
public string? StartDate { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the end date for filtering assets by date range.
|
||||||
|
/// </summary>
|
||||||
|
public string? EndDate { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the optional asset type filter.
|
||||||
|
/// </summary>
|
||||||
|
public EAssetType? Type { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the seed for deterministic random ordering.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Seed { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether to order results randomly instead of by date.
|
||||||
|
/// </summary>
|
||||||
|
public bool Random { get; set; }
|
||||||
|
}
|
||||||
19
Butter/Dtos/Asset/AssetUpdateDto.cs
Normal file
19
Butter/Dtos/Asset/AssetUpdateDto.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Butter.Dtos.Asset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to update an existing asset.
|
||||||
|
/// </summary>
|
||||||
|
public class AssetUpdateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the new owner ID for the asset.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Owner { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the deletion date to set on the asset.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? DeletedAt { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the asset is publicly shared.
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsPublic { get; set; }
|
||||||
|
}
|
||||||
27
Butter/Dtos/AuthResultDto.cs
Normal file
27
Butter/Dtos/AuthResultDto.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Butter.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of an authentication operation.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthResultDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the user ID if authentication succeeded.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? UserId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the JWT access token.
|
||||||
|
/// </summary>
|
||||||
|
public string? Token { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the refresh token.
|
||||||
|
/// </summary>
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets an error message if authentication failed.
|
||||||
|
/// </summary>
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the authentication was successful.
|
||||||
|
/// </summary>
|
||||||
|
public bool Success { get; set; }
|
||||||
|
}
|
||||||
16
Butter/Dtos/BulkDto.cs
Normal file
16
Butter/Dtos/BulkDto.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Butter.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a bulk operation targeting multiple entities with shared update data.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TDto">The type of the update data to apply to each entity.</typeparam>
|
||||||
|
public class BulkDto<TDto> {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of entity IDs to update.
|
||||||
|
/// </summary>
|
||||||
|
public required List<Guid> Ids { get; set; } = [];
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the update data to apply to each entity.
|
||||||
|
/// </summary>
|
||||||
|
public required TDto Data { get; set; }
|
||||||
|
}
|
||||||
16
Butter/Dtos/CredentialsDto.cs
Normal file
16
Butter/Dtos/CredentialsDto.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Butter.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents user login credentials.
|
||||||
|
/// </summary>
|
||||||
|
public class CredentialsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the user identifier (username or email).
|
||||||
|
/// </summary>
|
||||||
|
public required string Identifier { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the user password.
|
||||||
|
/// </summary>
|
||||||
|
public required string Password { get; set; }
|
||||||
|
}
|
||||||
16
Butter/Dtos/Folder/FolderCreateDto.cs
Normal file
16
Butter/Dtos/Folder/FolderCreateDto.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Butter.Dtos.Folder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to create a new folder.
|
||||||
|
/// </summary>
|
||||||
|
public class FolderCreateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the base path of the folder on disk.
|
||||||
|
/// </summary>
|
||||||
|
public string BasePath { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the regex pattern used to extract person and group names from asset paths.
|
||||||
|
/// Must contain named groups <c>person</c> and <c>group</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? RegexPattern { get; set; }
|
||||||
|
}
|
||||||
24
Butter/Dtos/Folder/FolderFullDto.cs
Normal file
24
Butter/Dtos/Folder/FolderFullDto.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Butter.Dtos.Folder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the full details of a folder.
|
||||||
|
/// </summary>
|
||||||
|
public class FolderFullDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique identifier of the folder.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the base path of the folder on disk.
|
||||||
|
/// </summary>
|
||||||
|
public string BasePath { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the folder is active for scanning.
|
||||||
|
/// </summary>
|
||||||
|
public bool Active { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the regex pattern used to extract person and group names from asset paths.
|
||||||
|
/// Must contain named groups <c>person</c> and <c>group</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? RegexPattern { get; set; }
|
||||||
|
}
|
||||||
15
Butter/Dtos/Folder/FolderSamplePathsDto.cs
Normal file
15
Butter/Dtos/Folder/FolderSamplePathsDto.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Butter.Dtos.Folder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contains a sample of asset paths from a folder for regex pattern testing.
|
||||||
|
/// </summary>
|
||||||
|
public class FolderSamplePathsDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the folder ID these paths belong to.
|
||||||
|
/// </summary>
|
||||||
|
public Guid FolderId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a random selection of asset paths from the folder.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Paths { get; set; } = new();
|
||||||
|
}
|
||||||
20
Butter/Dtos/Folder/FolderUpdateDto.cs
Normal file
20
Butter/Dtos/Folder/FolderUpdateDto.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Butter.Dtos.Folder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to update an existing folder.
|
||||||
|
/// </summary>
|
||||||
|
public class FolderUpdateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the base path of the folder.
|
||||||
|
/// </summary>
|
||||||
|
public string? BasePath { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the folder is active for scanning.
|
||||||
|
/// </summary>
|
||||||
|
public bool? Active { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the regex pattern used to extract person and group names from asset paths.
|
||||||
|
/// Must contain named groups <c>person</c> and <c>group</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? RegexPattern { get; set; }
|
||||||
|
}
|
||||||
15
Butter/Dtos/Jobs/ChildrenResponse.cs
Normal file
15
Butter/Dtos/Jobs/ChildrenResponse.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Butter.Dtos.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a response containing child jobs and a timestamp for delta-based polling.
|
||||||
|
/// </summary>
|
||||||
|
public class ChildrenResponse {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of child job statuses.
|
||||||
|
/// </summary>
|
||||||
|
public List<JobStatusDto> Children { get; init; } = [];
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the server timestamp to use as <c>since</c> for the next delta poll.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime Since { get; init; }
|
||||||
|
}
|
||||||
85
Butter/Dtos/Jobs/JobStatusDto.cs
Normal file
85
Butter/Dtos/Jobs/JobStatusDto.cs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
|
namespace Butter.Dtos.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the status of a job in the system.
|
||||||
|
/// </summary>
|
||||||
|
public class JobStatusDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique identifier of the job.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ID of the parent job, if this is a sub-job.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ParentJobId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the job.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = null!;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the type of the job.
|
||||||
|
/// </summary>
|
||||||
|
public EJobType JobType { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current status of the job.
|
||||||
|
/// </summary>
|
||||||
|
public EJobStatus Status { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the creation date of the job.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime Created { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the start date of the job, if it has been started.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? Started { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the completion date of the job, if it has finished.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? Finished { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an optional message providing additional information about the job status.
|
||||||
|
/// </summary>
|
||||||
|
public string? Message { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the progress of the job as a value between 0 and 1.
|
||||||
|
/// </summary>
|
||||||
|
public float Progress { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the types of jobs that can be executed by the system.
|
||||||
|
/// </summary>
|
||||||
|
public enum EJobType {
|
||||||
|
/// <summary>Scans the file system for new assets.</summary>
|
||||||
|
FileSystemScan,
|
||||||
|
/// <summary>Generates JPEG thumbnails for assets.</summary>
|
||||||
|
ThumbnailGeneration,
|
||||||
|
/// <summary>Generates preview images for assets.</summary>
|
||||||
|
PreviewGeneration,
|
||||||
|
/// <summary>Extracts metadata from assets.</summary>
|
||||||
|
MetadataExtraction,
|
||||||
|
/// <summary>Performs an integrity check on the system.</summary>
|
||||||
|
IntegrityCheck,
|
||||||
|
/// <summary>Computes perceptual hashes for assets.</summary>
|
||||||
|
PHashGeneration,
|
||||||
|
/// <summary>Creates missing Person entities extracted from asset paths via folder regex patterns.</summary>
|
||||||
|
CreatePersons,
|
||||||
|
/// <summary>Creates missing Person and Album entities extracted from asset paths via folder regex patterns.</summary>
|
||||||
|
CreateAlbums
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a request to enqueue a new job.
|
||||||
|
/// </summary>
|
||||||
|
public class JobRequestDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the type of job to enqueue.
|
||||||
|
/// </summary>
|
||||||
|
public EJobType JobType { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the parameters for the job.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Parameters { get; init; } = new();
|
||||||
|
}
|
||||||
15
Butter/Dtos/Jobs/PastJobsResponse.cs
Normal file
15
Butter/Dtos/Jobs/PastJobsResponse.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Butter.Dtos.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a paginated response of past jobs.
|
||||||
|
/// </summary>
|
||||||
|
public class PastJobsResponse {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of job statuses.
|
||||||
|
/// </summary>
|
||||||
|
public List<JobStatusDto> Jobs { get; init; } = [];
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the total number of jobs matching the query.
|
||||||
|
/// </summary>
|
||||||
|
public int Total { get; init; }
|
||||||
|
}
|
||||||
15
Butter/Dtos/MediaDto.cs
Normal file
15
Butter/Dtos/MediaDto.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Butter.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents media file data with its MIME type and file path.
|
||||||
|
/// </summary>
|
||||||
|
public class MediaDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the MIME type of the media file.
|
||||||
|
/// </summary>
|
||||||
|
public required string MimeType { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the file path of the media file.
|
||||||
|
/// </summary>
|
||||||
|
public required string Path { get; init; }
|
||||||
|
}
|
||||||
25
Butter/Dtos/PagedParametersDTO.cs
Normal file
25
Butter/Dtos/PagedParametersDTO.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Butter.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents pagination parameters for a query.
|
||||||
|
/// </summary>
|
||||||
|
public class PagedParametersDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the page number (zero-based).
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; } = 0;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of items per page.
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; } = 150;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents pagination parameters with an optional search term.
|
||||||
|
/// </summary>
|
||||||
|
public class PagedSearchParametersDto: PagedParametersDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the search term to filter results.
|
||||||
|
/// </summary>
|
||||||
|
public string? Search { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
27
Butter/Dtos/Person/PersonCreateDto.cs
Normal file
27
Butter/Dtos/Person/PersonCreateDto.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Butter.Dtos.Person;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to create a new person.
|
||||||
|
/// </summary>
|
||||||
|
public class PersonCreateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the person.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the asset ID to use as the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ProfileAssetId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropX { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the vertical crop offset for the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropY { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the zoom level for the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropZoom { get; set; }
|
||||||
|
}
|
||||||
45
Butter/Dtos/Person/PersonDetailedDto.cs
Normal file
45
Butter/Dtos/Person/PersonDetailedDto.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using Butter.Dtos.Album;
|
||||||
|
|
||||||
|
namespace Butter.Dtos.Person;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the full details of a person, including associated albums.
|
||||||
|
/// </summary>
|
||||||
|
public class PersonDetailedDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique identifier of the person.
|
||||||
|
/// </summary>
|
||||||
|
public required Guid Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the person.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the asset ID used as the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ProfileAssetId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the horizontal crop offset for the profile picture (0–100).
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropX { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the vertical crop offset for the profile picture (0–100).
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropY { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the zoom level for the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropZoom { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of non-deleted albums.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalAlbums { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of non-deleted assets across all albums.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalAssets { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of album previews associated with this person.
|
||||||
|
/// </summary>
|
||||||
|
public List<AlbumPreviewDto>? Albums { get; set; }
|
||||||
|
}
|
||||||
35
Butter/Dtos/Person/PersonPreviewDto.cs
Normal file
35
Butter/Dtos/Person/PersonPreviewDto.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Butter.Dtos.Person;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a summary view of a person.
|
||||||
|
/// </summary>
|
||||||
|
public class PersonPreviewDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique identifier of the person.
|
||||||
|
/// </summary>
|
||||||
|
public required Guid Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the person.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the asset ID used as the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ProfileAssetId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropX { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the vertical crop offset for the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropY { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the zoom level for the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropZoom { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of albums associated with this person.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalAlbums { get; set; }
|
||||||
|
}
|
||||||
27
Butter/Dtos/Person/PersonUpdateDto.cs
Normal file
27
Butter/Dtos/Person/PersonUpdateDto.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Butter.Dtos.Person;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to update an existing person.
|
||||||
|
/// </summary>
|
||||||
|
public class PersonUpdateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the person.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the asset ID to use as the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ProfileAssetId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropX { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the vertical crop offset for the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropY { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the zoom level for the profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public float? ProfileCropZoom { get; set; }
|
||||||
|
}
|
||||||
15
Butter/Dtos/RefreshDto.cs
Normal file
15
Butter/Dtos/RefreshDto.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Butter.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a request to refresh an authentication token.
|
||||||
|
/// </summary>
|
||||||
|
public class RefreshDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the user ID requesting the token refresh.
|
||||||
|
/// </summary>
|
||||||
|
public required Guid UserId { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the refresh token to validate.
|
||||||
|
/// </summary>
|
||||||
|
public required string RefreshToken { get; init; }
|
||||||
|
}
|
||||||
33
Butter/Dtos/Settings/SettingDto.cs
Normal file
33
Butter/Dtos/Settings/SettingDto.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using Butter.Settings;
|
||||||
|
|
||||||
|
namespace Butter.Dtos.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an application setting with its value, type, and display configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class SettingDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the setting.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the current value of the setting.
|
||||||
|
/// </summary>
|
||||||
|
public string? Value { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the description of the setting.
|
||||||
|
/// </summary>
|
||||||
|
public required string? Description { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the data type of the setting value.
|
||||||
|
/// </summary>
|
||||||
|
public required EType Type { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets how the setting should be displayed in the UI.
|
||||||
|
/// </summary>
|
||||||
|
public required DisplayType DisplayType { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the available options for the setting (for range types: min, max, step).
|
||||||
|
/// </summary>
|
||||||
|
public string[]? Options { get; set; }
|
||||||
|
}
|
||||||
15
Butter/Dtos/Stats/MimeTypeStatDto.cs
Normal file
15
Butter/Dtos/Stats/MimeTypeStatDto.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Butter.Dtos.Stats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A MIME type and its associated asset count for statistics.
|
||||||
|
/// </summary>
|
||||||
|
public class MimeTypeStatDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the MIME type string.
|
||||||
|
/// </summary>
|
||||||
|
public string MimeType { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets with this MIME type.
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
23
Butter/Dtos/Stats/MonthlyStatDto.cs
Normal file
23
Butter/Dtos/Stats/MonthlyStatDto.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Butter.Dtos.Stats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Monthly growth statistics for assets and users.
|
||||||
|
/// </summary>
|
||||||
|
public class MonthlyStatDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the year.
|
||||||
|
/// </summary>
|
||||||
|
public int Year { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the month (1-12).
|
||||||
|
/// </summary>
|
||||||
|
public int Month { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of new assets added in this month.
|
||||||
|
/// </summary>
|
||||||
|
public int NewAssets { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of new users registered in this month.
|
||||||
|
/// </summary>
|
||||||
|
public int NewUsers { get; set; }
|
||||||
|
}
|
||||||
15
Butter/Dtos/Stats/ResolutionBucketDto.cs
Normal file
15
Butter/Dtos/Stats/ResolutionBucketDto.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Butter.Dtos.Stats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A resolution bucket with a label and the count of assets that fall into it.
|
||||||
|
/// </summary>
|
||||||
|
public class ResolutionBucketDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the display label for the resolution bucket.
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets in this resolution bucket.
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
143
Butter/Dtos/Stats/StatsDto.cs
Normal file
143
Butter/Dtos/Stats/StatsDto.cs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
|
namespace Butter.Dtos.Stats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comprehensive statistics about the MilkyShots instance, gatherable from fast aggregate queries.
|
||||||
|
/// </summary>
|
||||||
|
public class StatsDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of assets (excluding soft-deleted).
|
||||||
|
/// </summary>
|
||||||
|
public int TotalAssets { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of registered users (excluding soft-deleted).
|
||||||
|
/// </summary>
|
||||||
|
public int TotalUsers { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of albums.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalAlbums { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of tags.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalTags { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of people.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalPeople { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of detected faces.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalFaces { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of watched folders.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalFolders { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the asset count broken down by type (Image, Video, Animation, PhotoSphere).
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<EAssetType, int> AssetsByType { get; set; } = [];
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total storage used by all assets in bytes.
|
||||||
|
/// </summary>
|
||||||
|
public long TotalStorageBytes { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the storage used broken down by asset type.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<EAssetType, long> StorageByType { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the user count broken down by access level.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<EAccessLevel, int> UsersByAccessLevel { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets added in the last 7 days.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetsAddedLast7Days { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets added in the last 30 days.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetsAddedLast30Days { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of users registered in the last 30 days.
|
||||||
|
/// </summary>
|
||||||
|
public int UsersRegisteredLast30Days { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets with no associated folder.
|
||||||
|
/// </summary>
|
||||||
|
public int OrphanAssets { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of publicly shared assets.
|
||||||
|
/// </summary>
|
||||||
|
public int PublicAssets { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of non-publicly-shared assets.
|
||||||
|
/// </summary>
|
||||||
|
public int PrivateAssets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of image assets missing resolution metadata.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetsMissingMetadata { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets missing a generated thumbnail.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetsMissingThumbnail { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets whose thumbnail was generated at a different size than the current setting.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetsMissingThumbnailStale { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets missing a generated preview.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetsMissingPreviews { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets whose preview was generated at a different size than the current setting.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetsMissingPreviewsStale { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets missing a perceptual hash.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetsMissingPhash { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets with no person assigned (no album with a PersonOwner).
|
||||||
|
/// </summary>
|
||||||
|
public int AssetsWithNoPerson { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets with no album assigned.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetsWithNoAlbum { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of albums without a cover image.
|
||||||
|
/// </summary>
|
||||||
|
public int AlbumsMissingCover { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of people/cosplayers without an album that has a cover image.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Not wired yet — reserved for future use.</remarks>
|
||||||
|
public int CosplayersMissingCover { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the top tags by asset count.
|
||||||
|
/// </summary>
|
||||||
|
public List<TagStatDto> TopTags { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the resolution distribution of assets.
|
||||||
|
/// </summary>
|
||||||
|
public List<ResolutionBucketDto> ResolutionDistribution { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the file format breakdown by MIME type.
|
||||||
|
/// </summary>
|
||||||
|
public List<MimeTypeStatDto> FileFormatBreakdown { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the monthly new-asset and new-user counts for the last 12 months.
|
||||||
|
/// </summary>
|
||||||
|
public List<MonthlyStatDto> MonthlyGrowth { get; set; } = [];
|
||||||
|
}
|
||||||
19
Butter/Dtos/Stats/TagStatDto.cs
Normal file
19
Butter/Dtos/Stats/TagStatDto.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Butter.Dtos.Stats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A tag and its associated asset count for statistics.
|
||||||
|
/// </summary>
|
||||||
|
public class TagStatDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the tag identifier.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the tag name.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of assets associated with the tag.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetCount { get; set; }
|
||||||
|
}
|
||||||
15
Butter/Dtos/Tag/TagCreateDto.cs
Normal file
15
Butter/Dtos/Tag/TagCreateDto.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Butter.Dtos.Tag;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to create a new tag.
|
||||||
|
/// </summary>
|
||||||
|
public class TagCreateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the tag.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the parent tag ID, if this tag should be a child.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Parent { get; set; }
|
||||||
|
}
|
||||||
23
Butter/Dtos/Tag/TagDto.cs
Normal file
23
Butter/Dtos/Tag/TagDto.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Butter.Dtos.Tag;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a tag with its hierarchical relationships.
|
||||||
|
/// </summary>
|
||||||
|
public class TagDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique identifier of the tag.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the tag.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the parent tag.
|
||||||
|
/// </summary>
|
||||||
|
public TagDto? Parent { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the child tags.
|
||||||
|
/// </summary>
|
||||||
|
public List<TagDto>? Children { get; set; } = [];
|
||||||
|
}
|
||||||
15
Butter/Dtos/Tag/TagUpdateDto.cs
Normal file
15
Butter/Dtos/Tag/TagUpdateDto.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Butter.Dtos.Tag;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to update an existing tag.
|
||||||
|
/// </summary>
|
||||||
|
public class TagUpdateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the tag.
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the parent tag ID.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Parent { get; set; }
|
||||||
|
}
|
||||||
25
Butter/Dtos/User/UserCreateDto.cs
Normal file
25
Butter/Dtos/User/UserCreateDto.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
|
namespace Butter.Dtos.User;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to create a new user by an administrator.
|
||||||
|
/// </summary>
|
||||||
|
public class UserCreateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the username.
|
||||||
|
/// </summary>
|
||||||
|
public required string Username { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the email address.
|
||||||
|
/// </summary>
|
||||||
|
public required string Email { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the password.
|
||||||
|
/// </summary>
|
||||||
|
public required string Password { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the initial access level.
|
||||||
|
/// </summary>
|
||||||
|
public EAccessLevel? AccessLevel { get; set; }
|
||||||
|
}
|
||||||
49
Butter/Dtos/User/UserInfoDto.cs
Normal file
49
Butter/Dtos/User/UserInfoDto.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
|
namespace Butter.Dtos.User;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the public information of a user.
|
||||||
|
/// </summary>
|
||||||
|
public class UserInfoDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique identifier of the user.
|
||||||
|
/// </summary>
|
||||||
|
public required Guid Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the username.
|
||||||
|
/// </summary>
|
||||||
|
public required string Username { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the email address.
|
||||||
|
/// </summary>
|
||||||
|
public string? Email { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the account creation date.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the last update date.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the last login date.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastLogin { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the date the user was banned, if applicable.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? BannedAt { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the date the user was deleted (soft delete), if applicable.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? DeletedAt { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the user is currently banned.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsBanned { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the access level of the user.
|
||||||
|
/// </summary>
|
||||||
|
public required EAccessLevel AccessLevel { get; set; } = EAccessLevel.User;
|
||||||
|
}
|
||||||
19
Butter/Dtos/User/UserRegisterDto.cs
Normal file
19
Butter/Dtos/User/UserRegisterDto.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Butter.Dtos.User;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to register a new user.
|
||||||
|
/// </summary>
|
||||||
|
public class UserRegisterDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the desired username.
|
||||||
|
/// </summary>
|
||||||
|
public required string Username { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the email address.
|
||||||
|
/// </summary>
|
||||||
|
public required string Email { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the password.
|
||||||
|
/// </summary>
|
||||||
|
public required string Password { get; set; }
|
||||||
|
}
|
||||||
41
Butter/Dtos/User/UserUpdateDto.cs
Normal file
41
Butter/Dtos/User/UserUpdateDto.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
|
namespace Butter.Dtos.User;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the data needed to update an existing user.
|
||||||
|
/// </summary>
|
||||||
|
public class UserUpdateDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ID of the user to update.
|
||||||
|
/// </summary>
|
||||||
|
public required Guid Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the new username.
|
||||||
|
/// </summary>
|
||||||
|
public string? Username { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the new email address.
|
||||||
|
/// </summary>
|
||||||
|
public string? Email { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the new password.
|
||||||
|
/// </summary>
|
||||||
|
public string? Password { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the current password (required for self-service password changes).
|
||||||
|
/// </summary>
|
||||||
|
public string? OldPassword { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the user is banned.
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsBanned { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the user is deleted (soft delete).
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsDeleted { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the new access level.
|
||||||
|
/// </summary>
|
||||||
|
public EAccessLevel? AccessLevel { get; set; }
|
||||||
|
}
|
||||||
121
Butter/MimeTypes.cs
Normal file
121
Butter/MimeTypes.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
namespace Butter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a mapping between a MIME type and its associated file extensions.
|
||||||
|
/// </summary>
|
||||||
|
public struct MimeTypeMap(string mimeType, string[] extensions) {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the MIME type string (e.g., "image/jpeg").
|
||||||
|
/// </summary>
|
||||||
|
public string MimeType { get; set; } = mimeType;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the file extensions associated with this MIME type.
|
||||||
|
/// </summary>
|
||||||
|
public string[] Extensions { get; set; } = extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides predefined mappings of MIME types to file extensions for images and videos.
|
||||||
|
/// </summary>
|
||||||
|
public static class MimeTypes{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the MIME type mappings for image formats.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly MimeTypeMap[] Image = [
|
||||||
|
new("image/avif", [".avif", ".avifs"]),
|
||||||
|
new("image/bmp}", [".bmp"]),
|
||||||
|
new("image/cgm", [".cgm"]),
|
||||||
|
new("image/g3fax", [".g3"]),
|
||||||
|
new("image/gif", [".gif"]),
|
||||||
|
new("image/heic", [".heif", ".heic"]),
|
||||||
|
new("image/ief", [".ief"]),
|
||||||
|
new("image/jpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfif", ".jfif-tbnl", ".jif"]),
|
||||||
|
new("image/pjpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfi", ".jfif", ".jfif-tbnl", ".jif"]),
|
||||||
|
new("image/png", [".png"]),
|
||||||
|
new("image/prs.btif", [".btif"]),
|
||||||
|
new("image/svg+xml", [".svg", ".svgz"]),
|
||||||
|
new("image/tiff", [".tif", ".tiff"]),
|
||||||
|
new("image/vnd.adobe.photoshop", [".psd"]),
|
||||||
|
new("image/vnd.djvu", [".djv", ".djvu"]),
|
||||||
|
new("image/vnd.dwg", [".dwg"]),
|
||||||
|
new("image/vnd.dxf", [".dxf"]),
|
||||||
|
new("image/vnd.fastbidsheet", [".fbs"]),
|
||||||
|
new("image/vnd.fpx", [".fpx"]),
|
||||||
|
new("image/vnd.fst", [".fst"]),
|
||||||
|
new("image/vnd.fujixerox.edmics-mmr", [".mmr"]),
|
||||||
|
new("image/vnd.fujixerox.edmics-rlc", [".rlc"]),
|
||||||
|
new("image/vnd.ms-modi", [".mdi"]),
|
||||||
|
new("image/vnd.net-fpx", [".npx"]),
|
||||||
|
new("image/vnd.wap.wbmp", [".wbmp"]),
|
||||||
|
new("image/vnd.xiff", [".xif"]),
|
||||||
|
new("image/webp", [".webp"]),
|
||||||
|
new("image/x-adobe-dng", [".dng"]),
|
||||||
|
new("image/x-canon-cr2", [".cr2"]),
|
||||||
|
new("image/x-canon-crw", [".crw"]),
|
||||||
|
new("image/x-cmu-raster", [".ras"]),
|
||||||
|
new("image/x-cmx", [".cmx"]),
|
||||||
|
new("image/x-epson-erf", [".erf"]),
|
||||||
|
new("image/x-freehand", [".fh", ".fh4", ".fh5", ".fh7", ".fhc"]),
|
||||||
|
new("image/x-fuji-raf", [".raf"]),
|
||||||
|
new("image/x-icns", [".icns"]),
|
||||||
|
new("image/x-icon", [".ico"]),
|
||||||
|
new("image/x-kodak-dcr", [".dcr"]),
|
||||||
|
new("image/x-kodak-k25", [".k25"]),
|
||||||
|
new("image/x-kodak-kdc", [".kdc"]),
|
||||||
|
new("image/x-minolta-mrw", [".mrw"]),
|
||||||
|
new("image/x-nikon-nef", [".nef"]),
|
||||||
|
new("image/x-olympus-orf", [".orf"]),
|
||||||
|
new("image/x-panasonic-raw", [".raw", ".rw2", ".rwl"]),
|
||||||
|
new("image/x-pcx", [".pcx"]),
|
||||||
|
new("image/x-pentax-pef", [".pef", ".ptx"]),
|
||||||
|
new("image/x-pict", [".pct", ".pic"]),
|
||||||
|
new("image/x-portable-anymap", [".pnm"]),
|
||||||
|
new("image/x-portable-bitmap", [".pbm"]),
|
||||||
|
new("image/x-portable-graymap", [".pgm"]),
|
||||||
|
new("image/x-portable-pixmap", [".ppm"]),
|
||||||
|
new("image/x-rgb", [".rgb"]),
|
||||||
|
new("image/x-sigma-x3f", [".x3f"]),
|
||||||
|
new("image/x-sony-arw", [".arw"]),
|
||||||
|
new("image/x-sony-sr2", [".sr2"]),
|
||||||
|
new("image/x-sony-srf", [".srf"]),
|
||||||
|
new("image/x-xbitmap", [".xbm"]),
|
||||||
|
new("image/x-xpixmap", [".xpm"]),
|
||||||
|
new("image/x-xwindowdump", [".xwd"])
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the MIME type mappings for video formats.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly MimeTypeMap[] Video = [
|
||||||
|
new("video/3gpp", [".3gp"]),
|
||||||
|
new("video/3gpp2", [".3g2"]),
|
||||||
|
new("video/h261", [".h261"]),
|
||||||
|
new("video/h263", [".h263"]),
|
||||||
|
new("video/h264", [".h264"]),
|
||||||
|
new("video/jpeg", [".jpgv"]),
|
||||||
|
new("video/jpm", [".jpgm, .jpm"]),
|
||||||
|
new("video/mj2", [".mj2, .mjp2"]),
|
||||||
|
new("video/mp2t", [".ts"]),
|
||||||
|
new("video/mp4", [".mp4, .mp4v, .mpg4"]),
|
||||||
|
new("video/mpeg", [".m1v, .m2v, .mpa, .mpe, .mpeg, .mpg"]),
|
||||||
|
new("video/ogg", [".ogv"]),
|
||||||
|
new("video/quicktime", [".mov", ".qt"]),
|
||||||
|
new("video/vnd.fvt", [".fvt"]),
|
||||||
|
new("video/vnd.mpegurl", [".m4u", ".mxu"]),
|
||||||
|
new("video/vnd.ms-playready.media.pyv", [".pyv"]),
|
||||||
|
new("video/vnd.vivo", [".viv"]),
|
||||||
|
new("video/webm", [".webm"]),
|
||||||
|
new("video/x-f4v", [".f4v"]),
|
||||||
|
new("video/x-fli", [".fli"]),
|
||||||
|
new("video/x-flv", [".flv"]),
|
||||||
|
new("video/x-m4v", [".m4v"]),
|
||||||
|
new("video/x-matroska", [".mkv"]),
|
||||||
|
new("video/x-ms-asf", [".asf", ".asx"]),
|
||||||
|
new("video/x-ms-wm", [".wm"]),
|
||||||
|
new("video/x-ms-wmv", [".wmv"]),
|
||||||
|
new("video/x-ms-wmx", [".wmx"]),
|
||||||
|
new("video/x-ms-wvx", [".wvx"]),
|
||||||
|
new("video/x-msvideo", [".avi"]),
|
||||||
|
new("video/x-sgi-movie", [".movie"])
|
||||||
|
];
|
||||||
|
}
|
||||||
42
Butter/Settings/DisplayType.cs
Normal file
42
Butter/Settings/DisplayType.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
namespace Butter.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the display types available for application settings.
|
||||||
|
/// </summary>
|
||||||
|
public enum DisplayType {
|
||||||
|
/// <summary>Text input field.</summary>
|
||||||
|
Text = 0,
|
||||||
|
/// <summary>Password input field.</summary>
|
||||||
|
Password = 1,
|
||||||
|
/// <summary>Integer number input.</summary>
|
||||||
|
IntNumber = 2,
|
||||||
|
/// <summary>Floating-point number input.</summary>
|
||||||
|
FloatNumber = 3,
|
||||||
|
/// <summary>Checkbox toggle.</summary>
|
||||||
|
Checkbox = 4,
|
||||||
|
/// <summary>Switch toggle.</summary>
|
||||||
|
Switch = 5,
|
||||||
|
/// <summary>Date and time picker.</summary>
|
||||||
|
DateTimePicker = 6,
|
||||||
|
/// <summary>Time picker.</summary>
|
||||||
|
TimePicker = 7,
|
||||||
|
/// <summary>Power of 2 selection.</summary>
|
||||||
|
Po2W = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides extension methods for the <see cref="DisplayType"/> enum.
|
||||||
|
/// </summary>
|
||||||
|
public static class DisplayTypeExtension {
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a string value to its corresponding <see cref="DisplayType"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The string to convert.</param>
|
||||||
|
/// <returns>The parsed <see cref="DisplayType"/> value, or null if the string is not a valid display type.</returns>
|
||||||
|
public static DisplayType? ToDisplayType(this string type) {
|
||||||
|
var success = Enum.TryParse(type, out DisplayType result);
|
||||||
|
if (success) return result;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Butter/Settings/EType.cs
Normal file
34
Butter/Settings/EType.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Butter.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the data types available for application setting values.
|
||||||
|
/// </summary>
|
||||||
|
public enum EType {
|
||||||
|
/// <summary>String/text value.</summary>
|
||||||
|
String = 0,
|
||||||
|
/// <summary>Integer numeric value.</summary>
|
||||||
|
Integer = 1,
|
||||||
|
/// <summary>Boolean true/false value.</summary>
|
||||||
|
Boolean = 2,
|
||||||
|
/// <summary>Floating-point numeric value.</summary>
|
||||||
|
Float = 3,
|
||||||
|
/// <summary>Date and time value.</summary>
|
||||||
|
DateTime = 4
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides extension methods for the <see cref="EType"/> enum.
|
||||||
|
/// </summary>
|
||||||
|
public static class ETypeExtension {
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a string value to its corresponding <see cref="EType"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The string to convert.</param>
|
||||||
|
/// <returns>The parsed <see cref="EType"/> value, or null if the string is not a valid type.</returns>
|
||||||
|
public static EType? ToType(this string type) {
|
||||||
|
var success = Enum.TryParse(type, out EType result);
|
||||||
|
if (success) return result;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
Butter/Settings/Settings.cs
Normal file
65
Butter/Settings/Settings.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
namespace Butter.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the available application settings keys.
|
||||||
|
/// </summary>
|
||||||
|
public enum Settings {
|
||||||
|
/// <summary>Enable or disable self user registration.</summary>
|
||||||
|
UserRegistrationEnabled,
|
||||||
|
/// <summary>Enable or disable folder scanning.</summary>
|
||||||
|
FolderScanEnabled,
|
||||||
|
/// <summary>Interval in minutes for folder scanning.</summary>
|
||||||
|
FolderScanInterval,
|
||||||
|
/// <summary>Enable or disable file upload.</summary>
|
||||||
|
FileUploadEnabled,
|
||||||
|
/// <summary>Maximum file size for uploads in bytes.</summary>
|
||||||
|
FileUploadMaxSize,
|
||||||
|
/// <summary>Path to store thumbnails.</summary>
|
||||||
|
ThumbnailPath,
|
||||||
|
/// <summary>Path to store previews.</summary>
|
||||||
|
PreviewPath,
|
||||||
|
/// <summary>Longest side size for thumbnails.</summary>
|
||||||
|
ThumbnailSize,
|
||||||
|
/// <summary>Longest side size for previews.</summary>
|
||||||
|
PreviewSize,
|
||||||
|
/// <summary>Maximum number of concurrent jobs.</summary>
|
||||||
|
MaxConcurrentJobs,
|
||||||
|
/// <summary>Number of days to keep completed/failed/canceled jobs.</summary>
|
||||||
|
JobRetentionDays,
|
||||||
|
/// <summary>Number of assets to process per sub-job batch.</summary>
|
||||||
|
JobBatchSize,
|
||||||
|
/// <summary>Quality setting (1–100) for WebP thumbnail encoding.</summary>
|
||||||
|
ThumbnailQuality,
|
||||||
|
/// <summary>Quality setting (1–100) for WebP preview encoding.</summary>
|
||||||
|
PreviewQuality
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides extension methods for the <see cref="Settings"/> enum.
|
||||||
|
/// </summary>
|
||||||
|
public static class SettingsExtensions {
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a <see cref="Settings"/> value to its human-readable display string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="setting">The setting to convert.</param>
|
||||||
|
/// <returns>A human-readable string representation of the setting.</returns>
|
||||||
|
public static string AsString(this Settings setting) {
|
||||||
|
return setting switch {
|
||||||
|
Settings.UserRegistrationEnabled => "User Registration Enabled",
|
||||||
|
Settings.FolderScanEnabled => "Folder Scan Enabled",
|
||||||
|
Settings.FolderScanInterval => "Folder Scan Interval",
|
||||||
|
Settings.FileUploadEnabled => "File Upload Enabled",
|
||||||
|
Settings.FileUploadMaxSize => "File Upload MaxSize",
|
||||||
|
Settings.ThumbnailPath => "Thumbnail Path",
|
||||||
|
Settings.PreviewPath => "Preview Path",
|
||||||
|
Settings.ThumbnailSize => "Thumbnail Size",
|
||||||
|
Settings.PreviewSize => "Preview Size",
|
||||||
|
Settings.MaxConcurrentJobs => "Max Concurrent Jobs",
|
||||||
|
Settings.JobRetentionDays => "Job Retention Days",
|
||||||
|
Settings.JobBatchSize => "Job Batch Size",
|
||||||
|
Settings.ThumbnailQuality => "Thumbnail Quality",
|
||||||
|
Settings.PreviewQuality => "Preview Quality",
|
||||||
|
_ => setting.ToString() // Fallback to the enum name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Butter/Types/EAccessLevel.cs
Normal file
13
Butter/Types/EAccessLevel.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Butter.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the access levels for users in the system.
|
||||||
|
/// </summary>
|
||||||
|
public enum EAccessLevel {
|
||||||
|
/// <summary>Standard user with basic permissions.</summary>
|
||||||
|
User,
|
||||||
|
/// <summary>Curator with elevated permissions for managing assets.</summary>
|
||||||
|
Curator,
|
||||||
|
/// <summary>Administrator with full system access.</summary>
|
||||||
|
Admin
|
||||||
|
}
|
||||||
15
Butter/Types/EAssetType.cs
Normal file
15
Butter/Types/EAssetType.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Butter.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the types of assets supported by the system.
|
||||||
|
/// </summary>
|
||||||
|
public enum EAssetType {
|
||||||
|
/// <summary>Standard image files (JPEG, PNG, etc.).</summary>
|
||||||
|
Image,
|
||||||
|
/// <summary>Video files (MP4, MOV, etc.).</summary>
|
||||||
|
Video,
|
||||||
|
/// <summary>Animated images (GIF, etc.).</summary>
|
||||||
|
Animation,
|
||||||
|
/// <summary>360-degree photo spheres.</summary>
|
||||||
|
PhotoSphere
|
||||||
|
}
|
||||||
21
Butter/Types/EJobStatus.cs
Normal file
21
Butter/Types/EJobStatus.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Butter.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the possible states of a job in the system.
|
||||||
|
/// </summary>
|
||||||
|
public enum EJobStatus {
|
||||||
|
/// <summary>Not yet started.</summary>
|
||||||
|
Queued,
|
||||||
|
/// <summary>Actively executing.</summary>
|
||||||
|
Running,
|
||||||
|
/// <summary>Waiting for sub-jobs or paused.</summary>
|
||||||
|
Waiting,
|
||||||
|
/// <summary>Successfully finished.</summary>
|
||||||
|
Completed,
|
||||||
|
/// <summary>Finished, but some sub-jobs or steps had errors.</summary>
|
||||||
|
CompletedWithErrors,
|
||||||
|
/// <summary>Finished with an error.</summary>
|
||||||
|
Failed,
|
||||||
|
/// <summary>Canceled by user or system.</summary>
|
||||||
|
Canceled
|
||||||
|
}
|
||||||
6
Directory.Build.props
Normal file
6
Directory.Build.props
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<WarningsAsErrors>CS1591</WarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
23
Lactose.Analyzers/Lactose.Analyzers.csproj
Normal file
23
Lactose.Analyzers/Lactose.Analyzers.csproj
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<IsRoslynComponent>true</IsRoslynComponent>
|
||||||
|
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||||
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
|
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||||
|
<DevelopmentDependency>true</DevelopmentDependency>
|
||||||
|
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
|
||||||
|
<NoWarn>$(NoWarn);RS1038;RS2008</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
132
Lactose.Analyzers/OnlyUtcDateTimeAnalyzer.cs
Normal file
132
Lactose.Analyzers/OnlyUtcDateTimeAnalyzer.cs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Diagnostics;
|
||||||
|
|
||||||
|
namespace Lactose.Analyzers;
|
||||||
|
|
||||||
|
[DiagnosticAnalyzer(LanguageNames.CSharp)]
|
||||||
|
public class OnlyUtcDateTimeAnalyzer : DiagnosticAnalyzer
|
||||||
|
{
|
||||||
|
const string Category = "Correctness";
|
||||||
|
|
||||||
|
static readonly DiagnosticDescriptor DateTimeNowRule = new(
|
||||||
|
id: "MS001",
|
||||||
|
title: "Use DateTime.UtcNow instead of DateTime.Now",
|
||||||
|
messageFormat: "Use DateTime.UtcNow instead of DateTime.Now to ensure UTC semantics",
|
||||||
|
category: Category,
|
||||||
|
defaultSeverity: DiagnosticSeverity.Error,
|
||||||
|
isEnabledByDefault: true);
|
||||||
|
|
||||||
|
static readonly DiagnosticDescriptor DateTimeTodayRule = new(
|
||||||
|
id: "MS002",
|
||||||
|
title: "Use DateTime.UtcNow.Date instead of DateTime.Today",
|
||||||
|
messageFormat: "Use DateTime.UtcNow.Date instead of DateTime.Today to ensure UTC semantics",
|
||||||
|
category: Category,
|
||||||
|
defaultSeverity: DiagnosticSeverity.Error,
|
||||||
|
isEnabledByDefault: true);
|
||||||
|
|
||||||
|
static readonly DiagnosticDescriptor DateTimeDateRule = new(
|
||||||
|
id: "MS003",
|
||||||
|
title: "DateTime.Date may return non-UTC value",
|
||||||
|
messageFormat: "DateTime.Date produces a DateTime with Kind=Local; ensure this is intentional or use DateTime.UtcNow.Date",
|
||||||
|
category: Category,
|
||||||
|
defaultSeverity: DiagnosticSeverity.Warning,
|
||||||
|
isEnabledByDefault: true);
|
||||||
|
|
||||||
|
static readonly DiagnosticDescriptor SpecifyKindRule = new(
|
||||||
|
id: "MS004",
|
||||||
|
title: "DateTime.SpecifyKind must use DateTimeKind.Utc",
|
||||||
|
messageFormat: "DateTime.SpecifyKind with {0} is not allowed; use DateTimeKind.Utc",
|
||||||
|
category: Category,
|
||||||
|
defaultSeverity: DiagnosticSeverity.Error,
|
||||||
|
isEnabledByDefault: true);
|
||||||
|
|
||||||
|
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
|
||||||
|
ImmutableArray.Create(DateTimeNowRule, DateTimeTodayRule, DateTimeDateRule, SpecifyKindRule);
|
||||||
|
|
||||||
|
public override void Initialize(AnalysisContext context)
|
||||||
|
{
|
||||||
|
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||||
|
context.EnableConcurrentExecution();
|
||||||
|
context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression);
|
||||||
|
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context)
|
||||||
|
{
|
||||||
|
var memberAccess = (MemberAccessExpressionSyntax)context.Node;
|
||||||
|
if (memberAccess.Name is not IdentifierNameSyntax memberName)
|
||||||
|
return;
|
||||||
|
|
||||||
|
switch (memberName.Identifier.Text)
|
||||||
|
{
|
||||||
|
case "Now":
|
||||||
|
if (IsSystemDateTime(context, memberAccess.Expression))
|
||||||
|
context.ReportDiagnostic(Diagnostic.Create(DateTimeNowRule, memberAccess.GetLocation()));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Today":
|
||||||
|
if (IsSystemDateTime(context, memberAccess.Expression))
|
||||||
|
context.ReportDiagnostic(Diagnostic.Create(DateTimeTodayRule, memberAccess.GetLocation()));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Date":
|
||||||
|
{
|
||||||
|
var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression, context.CancellationToken);
|
||||||
|
if (typeInfo.Type is INamedTypeSymbol namedType &&
|
||||||
|
namedType.SpecialType == SpecialType.System_DateTime)
|
||||||
|
{
|
||||||
|
context.ReportDiagnostic(Diagnostic.Create(DateTimeDateRule, memberAccess.GetLocation()));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
|
||||||
|
{
|
||||||
|
var invocation = (InvocationExpressionSyntax)context.Node;
|
||||||
|
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (memberAccess.Name is not IdentifierNameSyntax { Identifier.Text: "SpecifyKind" })
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!IsSystemDateTime(context, memberAccess.Expression))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (invocation.ArgumentList.Arguments.Count < 2)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var kindArg = invocation.ArgumentList.Arguments[1].Expression;
|
||||||
|
var kindValue = context.SemanticModel.GetConstantValue(kindArg, context.CancellationToken);
|
||||||
|
|
||||||
|
if (!kindValue.HasValue || kindValue.Value is not int intKind)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var kindName = intKind switch
|
||||||
|
{
|
||||||
|
0 => "DateTimeKind.Unspecified",
|
||||||
|
1 => "DateTimeKind.Utc",
|
||||||
|
2 => "DateTimeKind.Local",
|
||||||
|
_ => $"DateTimeKind value {intKind}"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (intKind != 1)
|
||||||
|
{
|
||||||
|
context.ReportDiagnostic(Diagnostic.Create(
|
||||||
|
SpecifyKindRule,
|
||||||
|
kindArg.GetLocation(),
|
||||||
|
kindName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsSystemDateTime(SyntaxNodeAnalysisContext context, ExpressionSyntax expression)
|
||||||
|
{
|
||||||
|
var typeInfo = context.SemanticModel.GetTypeInfo(expression, context.CancellationToken);
|
||||||
|
return typeInfo.Type is INamedTypeSymbol named &&
|
||||||
|
named.SpecialType == SpecialType.System_DateTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
Lactose.Analyzers/OnlyUtcDateTimeCodeFix.cs
Normal file
109
Lactose.Analyzers/OnlyUtcDateTimeCodeFix.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Composition;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CodeActions;
|
||||||
|
using Microsoft.CodeAnalysis.CodeFixes;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Editing;
|
||||||
|
|
||||||
|
namespace Lactose.Analyzers;
|
||||||
|
|
||||||
|
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(OnlyUtcDateTimeCodeFix))]
|
||||||
|
[Shared]
|
||||||
|
public class OnlyUtcDateTimeCodeFix : CodeFixProvider
|
||||||
|
{
|
||||||
|
public override ImmutableArray<string> FixableDiagnosticIds =>
|
||||||
|
ImmutableArray.Create("MS001", "MS002", "MS004");
|
||||||
|
|
||||||
|
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
|
||||||
|
|
||||||
|
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
|
||||||
|
{
|
||||||
|
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
|
||||||
|
if (root is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var diagnostic = context.Diagnostics[0];
|
||||||
|
var diagnosticSpan = diagnostic.Location.SourceSpan;
|
||||||
|
var node = root.FindNode(diagnosticSpan);
|
||||||
|
|
||||||
|
switch (diagnostic.Id)
|
||||||
|
{
|
||||||
|
case "MS001":
|
||||||
|
context.RegisterCodeFix(
|
||||||
|
CodeAction.Create(
|
||||||
|
title: "Replace with DateTime.UtcNow",
|
||||||
|
createChangedDocument: ct => ReplaceNowWithUtcNow(context.Document, node, ct),
|
||||||
|
equivalenceKey: "MS001"),
|
||||||
|
diagnostic);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MS002":
|
||||||
|
context.RegisterCodeFix(
|
||||||
|
CodeAction.Create(
|
||||||
|
title: "Replace with DateTime.UtcNow.Date",
|
||||||
|
createChangedDocument: ct => ReplaceTodayWithUtcNowDate(context.Document, node, ct),
|
||||||
|
equivalenceKey: "MS002"),
|
||||||
|
diagnostic);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MS004":
|
||||||
|
context.RegisterCodeFix(
|
||||||
|
CodeAction.Create(
|
||||||
|
title: "Replace with DateTimeKind.Utc",
|
||||||
|
createChangedDocument: ct => ReplaceWithUtcKind(context.Document, node, ct),
|
||||||
|
equivalenceKey: "MS004"),
|
||||||
|
diagnostic);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<Document> ReplaceNowWithUtcNow(Document document, SyntaxNode node, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
|
||||||
|
if (node is MemberAccessExpressionSyntax memberAccess)
|
||||||
|
{
|
||||||
|
var utcNow = SyntaxFactory.MemberAccessExpression(
|
||||||
|
SyntaxKind.SimpleMemberAccessExpression,
|
||||||
|
SyntaxFactory.IdentifierName("DateTime"),
|
||||||
|
SyntaxFactory.IdentifierName("UtcNow"));
|
||||||
|
editor.ReplaceNode(memberAccess, utcNow.WithTriviaFrom(memberAccess));
|
||||||
|
}
|
||||||
|
return editor.GetChangedDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<Document> ReplaceTodayWithUtcNowDate(Document document, SyntaxNode node, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
|
||||||
|
if (node is MemberAccessExpressionSyntax memberAccess)
|
||||||
|
{
|
||||||
|
var utcNow = SyntaxFactory.MemberAccessExpression(
|
||||||
|
SyntaxKind.SimpleMemberAccessExpression,
|
||||||
|
SyntaxFactory.IdentifierName("DateTime"),
|
||||||
|
SyntaxFactory.IdentifierName("UtcNow"));
|
||||||
|
var utcNowDotDate = SyntaxFactory.MemberAccessExpression(
|
||||||
|
SyntaxKind.SimpleMemberAccessExpression,
|
||||||
|
utcNow,
|
||||||
|
SyntaxFactory.IdentifierName("Date"));
|
||||||
|
editor.ReplaceNode(memberAccess, utcNowDotDate.WithTriviaFrom(memberAccess));
|
||||||
|
}
|
||||||
|
return editor.GetChangedDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<Document> ReplaceWithUtcKind(Document document, SyntaxNode node, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
|
||||||
|
if (node is ArgumentSyntax argument)
|
||||||
|
{
|
||||||
|
var utcKind = SyntaxFactory.MemberAccessExpression(
|
||||||
|
SyntaxKind.SimpleMemberAccessExpression,
|
||||||
|
SyntaxFactory.IdentifierName("DateTimeKind"),
|
||||||
|
SyntaxFactory.IdentifierName("Utc"));
|
||||||
|
editor.ReplaceNode(argument.Expression, utcKind.WithTriviaFrom(argument.Expression));
|
||||||
|
}
|
||||||
|
return editor.GetChangedDocument();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
ServerName="MariaDbDev"
|
|
||||||
DbUser="root"
|
|
||||||
DbPassword="testOnlyDb"
|
|
||||||
DatabaseName="WebApiTest"
|
|
||||||
79
Lactose/Authorization/RefreshTokenAuthorization.cs
Normal file
79
Lactose/Authorization/RefreshTokenAuthorization.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
using Lactose.Repositories;
|
||||||
|
using Lactose.Services;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Lactose.Authorization;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Technical note:
|
||||||
|
*
|
||||||
|
* The RefreshTokenTransformation is used to add a claim to the user's claims principal that indicates if
|
||||||
|
* the user's login is still valid. while UnexpiredLoginRequirement is used to check the claim.
|
||||||
|
*
|
||||||
|
* More Claim Transformation and Requirements can be added with IClaimsTransformation IAuthorizationRequirement and
|
||||||
|
* added to Program.cs
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authorization requirement that checks whether the user's login (refresh token) is still valid.
|
||||||
|
/// </summary>
|
||||||
|
public class UnexpiredLoginRequirement : IAuthorizationRequirement {
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles the <see cref="UnexpiredLoginRequirement"/> by checking the login validity claim.
|
||||||
|
/// </summary>
|
||||||
|
public class RefreshTokenValidityHandler : AuthorizationHandler<UnexpiredLoginRequirement> {
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override Task HandleRequirementAsync(
|
||||||
|
AuthorizationHandlerContext context,
|
||||||
|
UnexpiredLoginRequirement requirement
|
||||||
|
) {
|
||||||
|
Claim? firstOrDefault
|
||||||
|
= context.User.Claims.FirstOrDefault(claim => claim.Type == RefreshTokenTransformation.ClaimType);
|
||||||
|
|
||||||
|
if (firstOrDefault is not { Value: "true" }) {
|
||||||
|
context.Fail();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Succeed(requirement);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Claims transformation that adds a login validity claim based on the user's refresh token status.
|
||||||
|
/// </summary>
|
||||||
|
public class RefreshTokenTransformation(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
LactoseAuthService authService
|
||||||
|
) : IClaimsTransformation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The claim type used to indicate login validity.
|
||||||
|
/// </summary>
|
||||||
|
public const string ClaimType = "LoginValid";
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
ClaimsIdentity claimsIdentity = new ClaimsIdentity();
|
||||||
|
if (principal.HasClaim(claim => claim.Type == ClaimType)) {
|
||||||
|
foreach (ClaimsIdentity identity in principal.Identities) {
|
||||||
|
if (identity.HasClaim(claim => claim.Type == ClaimType)) {
|
||||||
|
identity.RemoveClaim(identity.FindFirst(ClaimType)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var userData = authService.GetUserData(principal);
|
||||||
|
if (userData == null) return Task.FromResult(principal);
|
||||||
|
var user = userRepository.Find(userData.Id);
|
||||||
|
if (user == null) return Task.FromResult(principal);
|
||||||
|
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return Task.FromResult(principal);
|
||||||
|
claimsIdentity.AddClaim(new Claim(ClaimType, "true", ClaimValueTypes.Boolean));
|
||||||
|
principal.AddIdentity(claimsIdentity);
|
||||||
|
return Task.FromResult(principal);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Lactose/Configuration/SignKeyConfiguration.cs
Normal file
24
Lactose/Configuration/SignKeyConfiguration.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Lactose.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for the JWT signing key.
|
||||||
|
/// </summary>
|
||||||
|
public class SignKeyConfiguration {
|
||||||
|
/// <summary>
|
||||||
|
/// The configuration section name for the signing key.
|
||||||
|
/// </summary>
|
||||||
|
public const string SectionName = "SignKey";
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the signing key string. Must be at least 256 bits (32 characters).
|
||||||
|
/// </summary>
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a <see cref="SecurityKey"/> from the configured key string.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A symmetric security key.</returns>
|
||||||
|
public SecurityKey GetSecurityKey() => new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.Key));
|
||||||
|
}
|
||||||
29
Lactose/Configuration/SignKeyProvider.cs
Normal file
29
Lactose/Configuration/SignKeyProvider.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Lactose.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures <see cref="SignKeyConfiguration"/> from the application configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class SignKeyProvider(IConfiguration configuration) : IConfigureOptions<SignKeyConfiguration> {
|
||||||
|
|
||||||
|
const string SectionName = SignKeyConfiguration.SectionName+":Key";
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Configure(SignKeyConfiguration options) {
|
||||||
|
|
||||||
|
if (configuration[SectionName]!.Length <= 32 ) {
|
||||||
|
throw new Exception("SignKey:Key is not 256 bits long");
|
||||||
|
}
|
||||||
|
options.Key = configuration[SectionName] ?? throw new Exception("SignKey:Key is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a configured <see cref="SignKeyConfiguration"/> instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The configured signing key configuration.</returns>
|
||||||
|
public SignKeyConfiguration Get() {
|
||||||
|
var options = new SignKeyConfiguration();
|
||||||
|
this.Configure(options);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
Lactose/Context/LactoseDbContext.cs
Normal file
91
Lactose/Context/LactoseDbContext.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
using Lactose.Models;
|
||||||
|
|
||||||
|
namespace Lactose.Context;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entity Framework Core database context for the Lactose application.
|
||||||
|
/// </summary>
|
||||||
|
public class LactoseDbContext : DbContext {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the assets DbSet.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<Asset> Assets { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the albums DbSet.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<Album> Albums { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the faces DbSet.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<Face> Faces { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the people DbSet.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<Person> People { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the users DbSet.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<User> Users { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the tags DbSet.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<Tag> Tags { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the folders DbSet.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<Folder> Folders { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the settings DbSet.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<Setting> Settings { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the job records DbSet.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<JobRecord> JobRecords { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialises a new instance of the <see cref="LactoseDbContext"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The database context options.</param>
|
||||||
|
public LactoseDbContext(DbContextOptions options) : base(options) {}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) {
|
||||||
|
configurationBuilder.Properties<DateTime>()
|
||||||
|
.HaveColumnType("timestamp with time zone");
|
||||||
|
configurationBuilder.Properties<DateTime?>()
|
||||||
|
.HaveColumnType("timestamp with time zone");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||||
|
modelBuilder.HasPostgresExtension("vector");
|
||||||
|
//Album Relationships
|
||||||
|
modelBuilder.Entity<Album>().HasOne(e => e.UserOwner).WithMany(e => e.OwnedAlbums);
|
||||||
|
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
|
||||||
|
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
|
||||||
|
modelBuilder.Entity<Album>().HasMany(e => e.Assets).WithMany(e => e.Albums);
|
||||||
|
//Asset Relationships
|
||||||
|
modelBuilder.Entity<Asset>().HasOne(e => e.Owner).WithMany(e => e.OwnedAssets);
|
||||||
|
modelBuilder.Entity<Asset>().HasMany(e => e.SharedWith).WithMany(e => e.SharedAssets);
|
||||||
|
modelBuilder.Entity<Asset>().HasMany(e => e.Albums).WithMany(e => e.Assets);
|
||||||
|
modelBuilder.Entity<Asset>().HasMany(e => e.Tags).WithMany(e => e.Assets);
|
||||||
|
modelBuilder.Entity<Asset>().HasMany(e => e.Faces).WithOne(e => e.Asset);
|
||||||
|
modelBuilder.Entity<Asset>().HasOne(e => e.Folder).WithMany(e => e.Assets);
|
||||||
|
//Face Relationships
|
||||||
|
modelBuilder.Entity<Face>().HasOne(e => e.Asset).WithMany(e => e.Faces);
|
||||||
|
modelBuilder.Entity<Face>().HasOne(e => e.Person).WithMany(e => e.Faces);
|
||||||
|
//Person Relationships
|
||||||
|
modelBuilder.Entity<Person>().HasOne(e => e.ProfileAsset).WithMany().HasForeignKey(e => e.ProfileAssetId);
|
||||||
|
modelBuilder.Entity<Person>().HasMany(e => e.Faces).WithOne(e => e.Person);
|
||||||
|
modelBuilder.Entity<Person>().HasMany(e => e.Albums).WithOne(e => e.PersonOwner);
|
||||||
|
//Tag Relationships
|
||||||
|
modelBuilder.Entity<Tag>().HasOne(e => e.Parent);
|
||||||
|
modelBuilder.Entity<Tag>().HasMany(e => e.Assets).WithMany(e => e.Tags);
|
||||||
|
//User Relationships
|
||||||
|
modelBuilder.Entity<User>().HasMany(e => e.OwnedAssets).WithOne(e => e.Owner);
|
||||||
|
modelBuilder.Entity<User>().HasMany(e => e.OwnedAlbums).WithOne(e => e.UserOwner);
|
||||||
|
//Folder Relationships
|
||||||
|
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using Lactose.Models;
|
|
||||||
|
|
||||||
namespace Lactose.Context;
|
|
||||||
|
|
||||||
public class TestDbContext : DbContext {
|
|
||||||
public DbSet<Asset> Assets { get; set; }
|
|
||||||
public DbSet<Album> Albums { get; set; }
|
|
||||||
public DbSet<Face> Faces { get; set; }
|
|
||||||
public DbSet<Person> People { get; set; }
|
|
||||||
public DbSet<User> Users { get; set; }
|
|
||||||
public DbSet<Tag> Tags { get; set; }
|
|
||||||
|
|
||||||
public TestDbContext(DbContextOptions options) : base(options) {}
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
|
||||||
//Album Relationships
|
|
||||||
modelBuilder.Entity<Album>().HasOne(e => e.Owner).WithMany(e => e.OwnedAlbums);
|
|
||||||
modelBuilder.Entity<Album>().HasMany(e => e.Assets).WithMany(e => e.Albums);
|
|
||||||
//Asset Relationships
|
|
||||||
modelBuilder.Entity<Asset>().HasOne(e => e.Owner).WithMany(e => e.OwnedAssets);
|
|
||||||
modelBuilder.Entity<Asset>().HasMany(e => e.SharedWith);
|
|
||||||
modelBuilder.Entity<Asset>().HasMany(e => e.Albums).WithMany(e => e.Assets);
|
|
||||||
modelBuilder.Entity<Asset>().HasMany(e => e.Tags).WithMany(e => e.Assets);
|
|
||||||
modelBuilder.Entity<Asset>().HasMany(e => e.Faces).WithOne(e => e.Asset);
|
|
||||||
//Face Relationships
|
|
||||||
modelBuilder.Entity<Face>().HasOne(e => e.Asset).WithMany(e => e.Faces);
|
|
||||||
modelBuilder.Entity<Face>().HasOne(e => e.Person).WithMany(e => e.Faces);
|
|
||||||
//Person Relationships
|
|
||||||
modelBuilder.Entity<Person>().HasMany(e => e.Faces).WithOne(e => e.Person);
|
|
||||||
//Tag Relationships
|
|
||||||
modelBuilder.Entity<Tag>().HasOne(e => e.Parent);
|
|
||||||
modelBuilder.Entity<Tag>().HasMany(e => e.Assets).WithMany(e => e.Tags);
|
|
||||||
//User Relationships
|
|
||||||
modelBuilder.Entity<User>().HasMany(e => e.OwnedAssets).WithOne(e => e.Owner);
|
|
||||||
modelBuilder.Entity<User>().HasMany(e => e.OwnedAlbums).WithOne(e => e.Owner);
|
|
||||||
|
|
||||||
//base.OnModelCreating(modelBuilder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
269
Lactose/Controllers/AlbumController.cs
Normal file
269
Lactose/Controllers/AlbumController.cs
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
using Butter.Dtos;
|
||||||
|
using Butter.Dtos.Album;
|
||||||
|
using Butter.Types;
|
||||||
|
using Lactose.Mapper;
|
||||||
|
using Lactose.Models;
|
||||||
|
using Lactose.Repositories;
|
||||||
|
using Lactose.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages albums — collections of assets.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authService">Authentication service.</param>
|
||||||
|
/// <param name="albumRepository">Album repository.</param>
|
||||||
|
/// <param name="assetRepository">Asset repository.</param>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AlbumController(
|
||||||
|
//TODO: Add logging
|
||||||
|
//ILogger<AlbumController> logger,
|
||||||
|
LactoseAuthService authService,
|
||||||
|
IAlbumRepository albumRepository,
|
||||||
|
IAssetRepository assetRepository
|
||||||
|
) : ControllerBase {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an album by its ID with filtered assets based on access level.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The album ID.</param>
|
||||||
|
/// <returns>The full album details with accessible assets, or 404 if not found.</returns>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public ActionResult<AlbumFullDto> Get([FromRoute] Guid id) {
|
||||||
|
Guid? uid = authService.GetUserData(User)?.Id;
|
||||||
|
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
var album = albumRepository.Find(id);
|
||||||
|
|
||||||
|
// If the album does not exist, return a 404
|
||||||
|
if (album == null) return NotFound();
|
||||||
|
|
||||||
|
// If the album has no assets, return an empty list
|
||||||
|
if (album.Assets == null) return Ok(album.ToAlbumFullDto());
|
||||||
|
|
||||||
|
//Storage for assets that will be sent out;
|
||||||
|
List<Asset> assets = new();
|
||||||
|
|
||||||
|
switch (accessLevel) {
|
||||||
|
case EAccessLevel.User:
|
||||||
|
// Only publicly shared assets or owned by user
|
||||||
|
assets.AddRange(album.Assets.Where(asset => (asset.IsPubliclyShared || asset.OwnerId == uid) && asset.DeletedAt == null));
|
||||||
|
// Add shared assets
|
||||||
|
assets.AddRange(album.Assets.Where(asset => asset.SharedWith!.Any(user => user.Id == uid) && asset.DeletedAt == null));
|
||||||
|
break;
|
||||||
|
case EAccessLevel.Curator:
|
||||||
|
// all assets minus deleted
|
||||||
|
assets.AddRange(album.Assets.Where(asset => asset.DeletedAt == null));
|
||||||
|
break;
|
||||||
|
case EAccessLevel.Admin:
|
||||||
|
// All assets
|
||||||
|
assets.AddRange(album.Assets);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// changes the retrieved album to the new filtered
|
||||||
|
album.Assets = assets.OrderBy(a => a.OriginalFilename).ToList();
|
||||||
|
return Ok(album.ToAlbumFullDto());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches albums with pagination, optional search term, and optional unassigned filter.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pagingOptions">Pagination, search, and filter parameters.</param>
|
||||||
|
/// <returns>A list of album previews accessible to the user.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
public ActionResult<List<AlbumPreviewDto>> Search([FromQuery] AlbumSearchParametersDto pagingOptions) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 0) return BadRequest();
|
||||||
|
|
||||||
|
var albums = albumRepository.SearchQuery(pagingOptions.Search ?? string.Empty, pagingOptions.Page, pagingOptions.PageSize).ToList();
|
||||||
|
|
||||||
|
if (pagingOptions.Unassigned)
|
||||||
|
albums = albums.Where(a => a.PersonOwnerId == null).ToList();
|
||||||
|
|
||||||
|
switch (accessLevel) {
|
||||||
|
default:
|
||||||
|
case EAccessLevel.User:
|
||||||
|
List<Album> filteredAlbums;
|
||||||
|
// Only publicly shared albums or owned by user
|
||||||
|
filteredAlbums = albums.Where(album => album.Assets != null && (album.UserOwnerId == uid || album.Assets.Any(asset => asset.IsPubliclyShared && asset.DeletedAt == null))).ToList();
|
||||||
|
// Add shared albums
|
||||||
|
filteredAlbums.AddRange(albums.Where(album => album.Assets != null && album.Assets.Any(x => x.DeletedAt == null && x.SharedWith!.Any(user => user.Id == uid))));
|
||||||
|
return Ok(filteredAlbums.Select(x => x.ToAlbumPreviewDto()).ToList());
|
||||||
|
case EAccessLevel.Curator:
|
||||||
|
case EAccessLevel.Admin:
|
||||||
|
// All albums
|
||||||
|
return Ok(albums.Select(x => x.ToAlbumPreviewDto()).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing album.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The album ID.</param>
|
||||||
|
/// <param name="albumDto">The album update data.</param>
|
||||||
|
/// <returns>200 on success, 403 if forbidden, 404 if not found.</returns>
|
||||||
|
[HttpPost("{id}")]
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
|
||||||
|
Guid? uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
var album = albumRepository.Find(id);
|
||||||
|
|
||||||
|
// If the album does not exist, return a 404
|
||||||
|
if (album == null) return NotFound();
|
||||||
|
|
||||||
|
switch (accessLevel) {
|
||||||
|
case EAccessLevel.User:
|
||||||
|
// Users can't update albums they don't own
|
||||||
|
if(album.UserOwnerId != uid) return Forbid();
|
||||||
|
break;
|
||||||
|
// Admins and Curator can update any album
|
||||||
|
case EAccessLevel.Curator:
|
||||||
|
case EAccessLevel.Admin:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update album properties
|
||||||
|
album.Title = string.IsNullOrEmpty(albumDto.Name) ? album.Title : albumDto.Name;
|
||||||
|
album.UpdatedAt = DateTime.UtcNow;
|
||||||
|
album.PersonOwnerId = albumDto.RemovePerson ? null : albumDto.Person ?? album.PersonOwnerId;
|
||||||
|
album.UserOwnerId = albumDto.Owner ?? album.UserOwnerId;
|
||||||
|
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
|
||||||
|
album.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
|
||||||
|
|
||||||
|
// Save changes
|
||||||
|
albumRepository.Update(album);
|
||||||
|
albumRepository.Save();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates multiple albums in a bulk operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="bulkDto">The bulk update data with album IDs and shared values.</param>
|
||||||
|
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
var albums = albumRepository.FindBulk(bulkDto.Ids).ToList();
|
||||||
|
|
||||||
|
switch (accessLevel) {
|
||||||
|
case EAccessLevel.User:
|
||||||
|
// Users can't update albums
|
||||||
|
return Forbid();
|
||||||
|
case EAccessLevel.Curator:
|
||||||
|
// Curators can only update albums they own
|
||||||
|
albums = albums.Where(x => x.UserOwnerId == uid).ToList();
|
||||||
|
break;
|
||||||
|
case EAccessLevel.Admin:
|
||||||
|
// Admins can update any album
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var album in albums) {
|
||||||
|
album.Title = bulkDto.Data.Name ?? album.Title;
|
||||||
|
album.UpdatedAt = DateTime.UtcNow;
|
||||||
|
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
|
||||||
|
album.UserOwnerId = bulkDto.Data.Owner ?? album.UserOwnerId;
|
||||||
|
album.Assets = bulkDto.Data.Assets != null ? assetRepository.FindBulk(bulkDto.Data.Assets).ToList() : album.Assets;
|
||||||
|
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
albumRepository.UpdateBulk(albums);
|
||||||
|
albumRepository.Save();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new album.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="albumDto">The album creation data.</param>
|
||||||
|
/// <returns>200 on success.</returns>
|
||||||
|
[HttpPut]
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
public ActionResult Create([FromBody] AlbumCreateDto albumDto) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
// If not admin, the owner of a new album is the current user
|
||||||
|
albumDto.Owner = accessLevel == EAccessLevel.Admin ? albumDto.Owner : uid;
|
||||||
|
|
||||||
|
var assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : new List<Asset>();
|
||||||
|
|
||||||
|
var album = new Album {
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Title = albumDto.Name,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
PersonOwnerId = albumDto.Person ?? null,
|
||||||
|
UserOwnerId = albumDto.Owner,
|
||||||
|
CoverAssetId = albumDto.CoverAssetId ?? assets.OrderBy(a => a.OriginalFilename).FirstOrDefault()?.Id,
|
||||||
|
Assets = assets
|
||||||
|
};
|
||||||
|
|
||||||
|
albumRepository.Insert(album);
|
||||||
|
albumRepository.Save();
|
||||||
|
|
||||||
|
return Ok(album.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes multiple albums (soft delete).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="albumIds">The list of album IDs to remove.</param>
|
||||||
|
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||||
|
[HttpDelete]
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
public ActionResult BulkDelete([FromBody] List<Guid> albumIds) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
var albums = albumRepository.FindBulk(albumIds).ToList();
|
||||||
|
|
||||||
|
// If the user is not an admin, filter out albums they do not own
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { albums = albums.Where(x => x.UserOwnerId == uid).ToList(); }
|
||||||
|
|
||||||
|
// If no albums are left after filtering, return a 403
|
||||||
|
if (albumIds.Count == 0) return Forbid();
|
||||||
|
|
||||||
|
albums.ForEach(albumRepository.Remove);
|
||||||
|
albumRepository.Save();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes an album by its ID (soft delete).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The album ID.</param>
|
||||||
|
/// <returns>200 on success, 404 if not found, 403 if forbidden.</returns>
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
public ActionResult Delete(Guid id) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
var album = albumRepository.Find(id);
|
||||||
|
|
||||||
|
// If the album does not exist, return a 404
|
||||||
|
if (album == null) return NotFound();
|
||||||
|
// If the user is not the owner or an admin, return a 403
|
||||||
|
if (accessLevel != EAccessLevel.Admin && album.UserOwnerId != uid) return Forbid();
|
||||||
|
|
||||||
|
albumRepository.Remove(album);
|
||||||
|
albumRepository.Save();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
358
Lactose/Controllers/AssetController.cs
Normal file
358
Lactose/Controllers/AssetController.cs
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
using Butter.Dtos;
|
||||||
|
using Butter.Dtos.Asset;
|
||||||
|
using Butter.Types;
|
||||||
|
using Lactose.Mapper;
|
||||||
|
using Lactose.Models;
|
||||||
|
using Lactose.Repositories;
|
||||||
|
using Lactose.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||||
|
|
||||||
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages assets — images, videos, and other media files.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="authService">Authentication service.</param>
|
||||||
|
/// <param name="assetRepository">Asset repository.</param>
|
||||||
|
/// <param name="userRepository">User repository.</param>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AssetController(
|
||||||
|
ILogger<AssetController> logger,
|
||||||
|
LactoseAuthService authService,
|
||||||
|
IAssetRepository assetRepository,
|
||||||
|
IUserRepository userRepository
|
||||||
|
) : ControllerBase {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an asset by its ID with full details, respecting access level.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The asset ID.</param>
|
||||||
|
/// <returns>The full asset details, or 404/401 based on permissions.</returns>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public ActionResult<AssetDto> Get(Guid id) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
logger.LogTrace(
|
||||||
|
$"""
|
||||||
|
Requested asset: {id}
|
||||||
|
by user: {uid}
|
||||||
|
access level: {accessLevel}
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
var asset = assetRepository.Find(id);
|
||||||
|
|
||||||
|
if (asset == null) {
|
||||||
|
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (accessLevel) {
|
||||||
|
case EAccessLevel.User: {
|
||||||
|
//Not publicly shared && Not shared with the user
|
||||||
|
if (!asset.IsPubliclyShared && asset.SharedWith?.Find(x => x.Id == uid) == null) {
|
||||||
|
logger.LogTrace($"{asset.Id} is not shared with user {uid}");
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
//Deleted asset
|
||||||
|
if (asset.DeletedAt != null) {
|
||||||
|
logger.LogTrace($"{asset.Id} is deleted and thus not exists for this user");
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case EAccessLevel.Curator: {
|
||||||
|
//Deleted asset owned by the user
|
||||||
|
if (asset.DeletedAt != null)
|
||||||
|
if (asset.Owner?.Id != uid) {
|
||||||
|
logger.LogTrace(
|
||||||
|
$"{asset.Id} is deleted and it's not owned by this user so it does not exists for this user"
|
||||||
|
);
|
||||||
|
|
||||||
|
return NotFound();
|
||||||
|
} else { logger.LogTrace($"{asset.Id} is deleted but it's owned by this user so it exists for this user"); }
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case EAccessLevel.Admin: {
|
||||||
|
//Admins will see this regardless
|
||||||
|
logger.LogTrace($"{asset.Id} is being requested by an admin, sending it regardless of the visibility state");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetDto dto = asset.ToFullAssetsDto(accessLevel);
|
||||||
|
|
||||||
|
logger.LogTrace(
|
||||||
|
$"""
|
||||||
|
Outbound DTO:
|
||||||
|
dto.Id: {dto.Id}
|
||||||
|
dto.FileName: {dto.FileName}
|
||||||
|
dto.AssetType: {dto.AssetType}
|
||||||
|
dto.IsPublic: {dto.IsPublic}
|
||||||
|
dto.CreatedAt: {dto.CreatedAt}
|
||||||
|
dto.UpdatedAt: {dto.UpdatedAt}
|
||||||
|
dto.DeletedAt: {dto.DeletedAt}
|
||||||
|
dto.Owner: {dto.Owner.Id} {dto.Owner.Username}
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches assets with optional date range, type filter, and random ordering with pagination.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="searchOptionsDto">Search options including date range, type filter, random ordering, and pagination.</param>
|
||||||
|
/// <returns>A list of asset previews.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
|
||||||
|
var userData = authService.GetUserData(User);
|
||||||
|
var uid = userData?.Id;
|
||||||
|
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
DateTime? from = string.IsNullOrEmpty(searchOptionsDto.StartDate) ? null : DateTime.Parse(searchOptionsDto.StartDate);
|
||||||
|
DateTime? to = string.IsNullOrEmpty(searchOptionsDto.EndDate) ? null : DateTime.Parse(searchOptionsDto.EndDate);
|
||||||
|
|
||||||
|
if (from.HasValue && to.HasValue && from > to) {
|
||||||
|
logger.LogWarning("Invalid date range provided");
|
||||||
|
return BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchOptionsDto.Page < 1 || searchOptionsDto.PageSize < 1) {
|
||||||
|
logger.LogWarning("Invalid pagination parameters provided");
|
||||||
|
return BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
var assets = assetRepository.GetAssets(
|
||||||
|
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
||||||
|
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.LogTrace(
|
||||||
|
$"""
|
||||||
|
Requested assets
|
||||||
|
by user: {uid}
|
||||||
|
access level: {accessLevel}
|
||||||
|
type: {searchOptionsDto.Type}
|
||||||
|
random: {searchOptionsDto.Random}
|
||||||
|
date range: {from} - {to}
|
||||||
|
page: {searchOptionsDto.Page}
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (accessLevel) {
|
||||||
|
case EAccessLevel.User: {
|
||||||
|
assets = assets.Where(
|
||||||
|
a => a.DeletedAt == null && (a.IsPubliclyShared || a.SharedWith?.Find(x => x.Id == uid) != null)
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case EAccessLevel.Curator:
|
||||||
|
case EAccessLevel.Admin: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dtoList = assets.ToAssetPreviewDto().ToList();
|
||||||
|
|
||||||
|
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
|
||||||
|
Response.Headers["X-Total-Count"] = total.ToString();
|
||||||
|
return Ok(dtoList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an asset's metadata.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The asset ID.</param>
|
||||||
|
/// <param name="dto">The asset update data.</param>
|
||||||
|
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||||
|
[HttpPost("{id}")]
|
||||||
|
[Authorize(Roles = "Curator,Admin")]
|
||||||
|
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||||
|
var asset = assetRepository.Find(id);
|
||||||
|
|
||||||
|
if (asset == null) {
|
||||||
|
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
|
||||||
|
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to update asset {id} that they do not own");
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
string log = "";
|
||||||
|
|
||||||
|
asset.IsPubliclyShared = dto.IsPublic ?? asset.IsPubliclyShared;
|
||||||
|
log += $"PubliclyShared: {asset.IsPubliclyShared} -> {dto.IsPublic ?? asset.IsPubliclyShared}\n";
|
||||||
|
|
||||||
|
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
|
||||||
|
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
|
||||||
|
|
||||||
|
asset.Owner = dto.Owner != null ? userRepository.Find(dto.Owner.Value) : asset.Owner;
|
||||||
|
log += $"Owner: {asset.Owner?.Id} -> {dto.Owner ?? asset.Owner?.Id}\n";
|
||||||
|
|
||||||
|
logger.LogTrace($"Updating asset {id}\n{log}");
|
||||||
|
|
||||||
|
assetRepository.Update(asset);
|
||||||
|
assetRepository.Save();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates multiple assets in a bulk operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="bulkDto">The bulk update data with asset IDs and shared values.</param>
|
||||||
|
/// <returns>200 on success, or an error response.</returns>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "Curator,Admin")]
|
||||||
|
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||||
|
var assets = assetRepository.FindBulk(bulkDto.Ids);
|
||||||
|
|
||||||
|
var enumerable = assets as List<Asset> ?? assets.ToList();
|
||||||
|
|
||||||
|
if (enumerable.Count() != bulkDto.Ids.Count) {
|
||||||
|
logger.LogWarning(
|
||||||
|
$"Some assets were not found in the database" +
|
||||||
|
$"\nrequested assets: {bulkDto.Ids.Count}, found assets: {enumerable.Count()}"
|
||||||
|
);
|
||||||
|
|
||||||
|
var missingIds = bulkDto.Ids.Except(enumerable.Select(a => a.Id)).ToList();
|
||||||
|
logger.LogTrace($"Missing IDs: {string.Join(", ", missingIds)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
//If the user is not an admin, they can only update their own assets
|
||||||
|
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
|
||||||
|
|
||||||
|
enumerable.ForEach(
|
||||||
|
x => {
|
||||||
|
x.IsPubliclyShared = bulkDto.Data.IsPublic ?? x.IsPubliclyShared;
|
||||||
|
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
|
||||||
|
x.Owner = bulkDto.Data.Owner != null ? userRepository.Find(bulkDto.Data.Owner.Value) : x.Owner;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assetRepository.UpdateBulk(enumerable);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soft-deletes an asset by its ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The asset ID.</param>
|
||||||
|
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
[Authorize(Roles = "Curator,Admin")]
|
||||||
|
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||||
|
var asset = assetRepository.Find(id);
|
||||||
|
|
||||||
|
if (asset == null) {
|
||||||
|
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
|
||||||
|
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to delete asset {id} that they do not own");
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
asset.DeletedAt = DateTime.UtcNow;
|
||||||
|
assetRepository.Update(asset);
|
||||||
|
assetRepository.Save();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soft-deletes multiple assets in a bulk operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ids">The list of asset IDs to delete.</param>
|
||||||
|
/// <returns>200 on success, or an error response.</returns>
|
||||||
|
[HttpDelete]
|
||||||
|
[Authorize(Roles = "Curator,Admin")]
|
||||||
|
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||||
|
var assets = assetRepository.FindBulk(ids);
|
||||||
|
|
||||||
|
var enumerable = assets as List<Asset> ?? assets.ToList();
|
||||||
|
|
||||||
|
if (enumerable.Count() != ids.Count) {
|
||||||
|
logger.LogWarning(
|
||||||
|
$"Some assets were not found in the database" +
|
||||||
|
$"\nrequested assets: {ids.Count}, found assets: {enumerable.Count()}"
|
||||||
|
);
|
||||||
|
|
||||||
|
var missingIds = ids.Except(enumerable.Select(a => a.Id)).ToList();
|
||||||
|
logger.LogTrace($"Missing IDs: {string.Join(", ", missingIds)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
//If the user is not an admin, they can only delete their own assets
|
||||||
|
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
|
||||||
|
|
||||||
|
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
|
||||||
|
|
||||||
|
assetRepository.UpdateBulk(enumerable);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new asset record.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto">The asset creation data.</param>
|
||||||
|
/// <returns>200 with the new asset ID, or an error response.</returns>
|
||||||
|
[NonAction]
|
||||||
|
[HttpPut]
|
||||||
|
[Authorize(Roles = "Curator,Admin")]
|
||||||
|
public ActionResult<Guid> Create([FromBody] AssetCreateDto dto) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||||
|
|
||||||
|
if (userRepository.Find(dto.Owner) == null) {
|
||||||
|
logger.LogWarning($"User {dto.Owner} does not exist in the database");
|
||||||
|
return BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accesslevel != EAccessLevel.Admin && dto.Owner != uid) {
|
||||||
|
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.Owner}");
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: This makes exactly zero sense. How are we supposed to create an asset externally here?
|
||||||
|
// Does this even need to exist?
|
||||||
|
var asset = new Asset {
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = dto.AssetType,
|
||||||
|
IsPubliclyShared = dto.IsPublic,
|
||||||
|
OwnerId = dto.Owner,
|
||||||
|
CreatedAt = dto.CreatedAt,
|
||||||
|
MimeType = dto.MimeType,
|
||||||
|
ResolutionWidth = dto.ResolutionWidth,
|
||||||
|
ResolutionHeight = dto.ResolutionHeight,
|
||||||
|
FileSize = dto.FileSize,
|
||||||
|
Duration = dto.Duration ?? 0,
|
||||||
|
FrameRate = dto.FrameRate ?? 0,
|
||||||
|
OriginalPath = String.Empty,
|
||||||
|
OriginalFilename = String.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
assetRepository.Insert(asset);
|
||||||
|
assetRepository.Save();
|
||||||
|
|
||||||
|
return Ok(asset.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
Lactose/Controllers/AuthController.cs
Normal file
214
Lactose/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
using Butter.Dtos;
|
||||||
|
using Butter.Dtos.User;
|
||||||
|
using Butter.Types;
|
||||||
|
using Lactose.Models;
|
||||||
|
using Lactose.Repositories;
|
||||||
|
using Lactose.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles user authentication — login, registration, token refresh, and logout.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="authService">Authentication service.</param>
|
||||||
|
/// <param name="userRepository">User repository.</param>
|
||||||
|
/// <param name="passwordHasher">Password hasher.</param>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AuthController(
|
||||||
|
ILogger<AuthController> logger,
|
||||||
|
LactoseAuthService authService,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IPasswordHasher<User> passwordHasher
|
||||||
|
) : ControllerBase {
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates a user by username/email and password, issuing JWT and refresh tokens.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userDto">The login credentials.</param>
|
||||||
|
/// <returns>An authentication result with tokens, or an error.</returns>
|
||||||
|
[HttpPost("login")]
|
||||||
|
public ActionResult<AuthResultDto> Login([FromBody] CredentialsDto userDto) {
|
||||||
|
User? user;
|
||||||
|
|
||||||
|
if (userDto.Identifier.Contains('@')) {
|
||||||
|
//search for user in database (by email)
|
||||||
|
user = userRepository.FindByEmail(userDto.Identifier);
|
||||||
|
} else {
|
||||||
|
//search for user in database (by username)
|
||||||
|
user = userRepository.FindByUsername(userDto.Identifier);
|
||||||
|
}
|
||||||
|
if (user == null) {
|
||||||
|
return NotFound(new AuthResultDto() {
|
||||||
|
Success = false,
|
||||||
|
ErrorMessage = "User or Password was wrong"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (user.BannedAt != null) {
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status403Forbidden,
|
||||||
|
new AuthResultDto() {
|
||||||
|
Success = false,
|
||||||
|
ErrorMessage = "User is banned"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.DeletedAt != null) {
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status403Forbidden,
|
||||||
|
new AuthResultDto() {
|
||||||
|
Success = false,
|
||||||
|
ErrorMessage = "User is disabled"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
PasswordVerificationResult result = passwordHasher.VerifyHashedPassword(user, user.Password, userDto.Password);
|
||||||
|
|
||||||
|
switch (result) {
|
||||||
|
case PasswordVerificationResult.Failed: return NotFound(new AuthResultDto() { Success = false, ErrorMessage = "User or Password was wrong"});
|
||||||
|
case PasswordVerificationResult.SuccessRehashNeeded:
|
||||||
|
user.Password = passwordHasher.HashPassword(user, userDto.Password);
|
||||||
|
userRepository.Save();
|
||||||
|
break;
|
||||||
|
case PasswordVerificationResult.Success: break;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.LastLogin = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var token = authService.GenerateAccessToken(user);
|
||||||
|
var refreshToken = authService.GenerateRefreshToken(user);
|
||||||
|
userRepository.Save();
|
||||||
|
return new AuthResultDto() {
|
||||||
|
UserId = user.Id,
|
||||||
|
Token = token,
|
||||||
|
RefreshToken = refreshToken,
|
||||||
|
Success = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new user account.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto">The registration data.</param>
|
||||||
|
/// <returns>200 with the new user ID, or 409 if the user already exists.</returns>
|
||||||
|
//TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token)
|
||||||
|
[HttpPost("register")]
|
||||||
|
public ActionResult Register([FromBody] UserRegisterDto dto) {
|
||||||
|
logger.LogDebug(
|
||||||
|
$"""
|
||||||
|
Registering user:
|
||||||
|
Username: {dto.Username}
|
||||||
|
Email: {dto.Email}
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userRepository.FindByEmail(dto.Email) != null || userRepository.FindByUsername(dto.Username) != null) {
|
||||||
|
return Conflict("User already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new User {
|
||||||
|
Username = dto.Username,
|
||||||
|
Email = dto.Email,
|
||||||
|
AccessLevel = EAccessLevel.User,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
user.Password = passwordHasher.HashPassword(user, dto.Password);
|
||||||
|
|
||||||
|
userRepository.Insert(user);
|
||||||
|
userRepository.Save();
|
||||||
|
return Ok(user.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Logs out the user by invalidating their refresh token.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>200 on success, 401 if unauthorized.</returns>
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("logout")]
|
||||||
|
public ActionResult Logout() {
|
||||||
|
LactoseAuthenticatedUser? identity = authService.GetUserData(User);
|
||||||
|
|
||||||
|
if (identity == null) { return Unauthorized(); }
|
||||||
|
|
||||||
|
//Reset token from the database
|
||||||
|
User? user = userRepository.Find(identity.Id);
|
||||||
|
if (user == null) { return Unauthorized(); }
|
||||||
|
user.RefreshToken = string.Empty;
|
||||||
|
user.RefreshTokenExpires = null;
|
||||||
|
userRepository.Save();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes an expired JWT access token using a valid refresh token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The refresh token request data.</param>
|
||||||
|
/// <returns>A new access token, and optionally a new refresh token.</returns>
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public ActionResult<AuthResultDto> RefreshToken([FromBody] RefreshDto token) {
|
||||||
|
User? user = userRepository.Find(token.UserId);
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
return NotFound(
|
||||||
|
new AuthResultDto() {
|
||||||
|
Success = false,
|
||||||
|
ErrorMessage = "No user found"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.BannedAt != null) {
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status403Forbidden,
|
||||||
|
new AuthResultDto() {
|
||||||
|
Success = false,
|
||||||
|
ErrorMessage = "User is banned"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.DeletedAt != null) {
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status403Forbidden,
|
||||||
|
new AuthResultDto() {
|
||||||
|
Success = false,
|
||||||
|
ErrorMessage = "User is disabled"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authService.ValidateRefreshToken(user, token.RefreshToken)) {
|
||||||
|
return Unauthorized(
|
||||||
|
new AuthResultDto() {
|
||||||
|
Success = false,
|
||||||
|
ErrorMessage = "Invalid refresh token"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var authDto = new AuthResultDto() {
|
||||||
|
Success = true,
|
||||||
|
Token = authService.GenerateAccessToken(user)
|
||||||
|
};
|
||||||
|
|
||||||
|
if ((user.RefreshTokenExpires - TimeSpan.FromMinutes(LactoseAuthService.BaseExpirationTime)) < DateTime.UtcNow) {
|
||||||
|
var refreshToken = authService.GenerateRefreshToken(user);
|
||||||
|
authDto.RefreshToken = refreshToken;
|
||||||
|
user.RefreshToken = refreshToken;
|
||||||
|
user.RefreshTokenExpires = DateTime.UtcNow.AddMinutes(LactoseAuthService.LongExpirationTime);
|
||||||
|
userRepository.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return authDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
151
Lactose/Controllers/FolderController.cs
Normal file
151
Lactose/Controllers/FolderController.cs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
using Butter.Dtos.Folder;
|
||||||
|
using Butter.Types;
|
||||||
|
using Lactose.Mapper;
|
||||||
|
using Lactose.Models;
|
||||||
|
using Lactose.Repositories;
|
||||||
|
using Lactose.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages file system folders for asset scanning (admin only).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authService">Authentication service.</param>
|
||||||
|
/// <param name="folderRepository">Folder repository.</param>
|
||||||
|
/// <param name="assetRepository">Asset repository for sample path queries.</param>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class FolderController(
|
||||||
|
//TODO: add logging
|
||||||
|
//ILogger<FolderController> logger,
|
||||||
|
LactoseAuthService authService,
|
||||||
|
IFolderRepository folderRepository,
|
||||||
|
IAssetRepository assetRepository
|
||||||
|
) : ControllerBase {
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new folder for asset scanning.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="folderCreateDto">The folder creation data.</param>
|
||||||
|
/// <returns>200 on success.</returns>
|
||||||
|
[HttpPut]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult Create([FromBody] FolderCreateDto folderCreateDto) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
|
||||||
|
Folder newFolder = new Folder {
|
||||||
|
BasePath = folderCreateDto.BasePath,
|
||||||
|
RegexPattern = folderCreateDto.RegexPattern,
|
||||||
|
Active = false
|
||||||
|
};
|
||||||
|
|
||||||
|
folderRepository.Create(newFolder);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all tracked folders.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A list of all folders.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult<List<FolderFullDto>> GetAll() {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
return Ok(
|
||||||
|
folderRepository.GetAll()
|
||||||
|
.Select(folder => folder.ToFolderDto())
|
||||||
|
.ToList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a specific folder by ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The folder ID.</param>
|
||||||
|
/// <returns>The folder details, or 404 if not found.</returns>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult<FolderFullDto> Get([FromRoute] Guid id) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
|
||||||
|
var folder = folderRepository.Get(id);
|
||||||
|
|
||||||
|
if (folder == null) return NotFound();
|
||||||
|
|
||||||
|
return Ok(folder.ToFolderDto());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing folder.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The folder ID.</param>
|
||||||
|
/// <param name="folderUpdateDto">The folder update data.</param>
|
||||||
|
/// <returns>200 on success, 404 if not found.</returns>
|
||||||
|
[HttpPost("{id}")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult Update([FromRoute] Guid id, [FromBody] FolderUpdateDto folderUpdateDto) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
|
||||||
|
Folder? folder = folderRepository.Get(id);
|
||||||
|
if (folder == null) return NotFound();
|
||||||
|
|
||||||
|
folder.BasePath = folderUpdateDto.BasePath ?? folder.BasePath;
|
||||||
|
folder.Active = folderUpdateDto.Active ?? folder.Active;
|
||||||
|
folder.RegexPattern = folderUpdateDto.RegexPattern ?? folder.RegexPattern;
|
||||||
|
|
||||||
|
folderRepository.Update(id, folder);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a random sample of asset paths from the folder for testing regex patterns.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The folder ID.</param>
|
||||||
|
/// <param name="count">Number of sample paths to return (default 10, max 50).</param>
|
||||||
|
/// <returns>A list of up to <paramref name="count"/> random asset paths, or 404 if the folder is not found.</returns>
|
||||||
|
[HttpGet("{id}/sample-paths")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult<FolderSamplePathsDto> GetSamplePaths([FromRoute] Guid id, [FromQuery] int count = 10) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
|
||||||
|
var folder = folderRepository.Get(id);
|
||||||
|
if (folder == null) return NotFound();
|
||||||
|
|
||||||
|
count = Math.Clamp(count, 1, 50);
|
||||||
|
|
||||||
|
var paths = assetRepository.GetRandomPathsByFolder(id, count);
|
||||||
|
|
||||||
|
return Ok(new FolderSamplePathsDto {
|
||||||
|
FolderId = id,
|
||||||
|
Paths = paths
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a folder (stops tracking it).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The folder ID.</param>
|
||||||
|
/// <returns>200 on success.</returns>
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult Delete([FromRoute] Guid id) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
|
||||||
|
folderRepository.Delete(id);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
153
Lactose/Controllers/JobsController.cs
Normal file
153
Lactose/Controllers/JobsController.cs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
using Butter.Dtos.Jobs;
|
||||||
|
using Butter.Types;
|
||||||
|
using Lactose.Jobs;
|
||||||
|
using Lactose.Mapper;
|
||||||
|
using Lactose.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages job lifecycle — listing active/past jobs, cancelling, and enqueuing new jobs.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobManager">The job manager for job lifecycle.</param>
|
||||||
|
/// <param name="jobScheduler">The job scheduler for queueing jobs.</param>
|
||||||
|
/// <param name="authService">The authentication service.</param>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class JobsController(JobManager jobManager, JobScheduler jobScheduler, LactoseAuthService authService) : ControllerBase {
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns active root jobs only — no children, no past jobs. Lightweight, polled frequently.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult<List<JobStatusDto>> GetActiveRoots() {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
return Ok(jobManager.GetActiveRootJobs());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a page of past root jobs with total count. Polled less frequently or on demand.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("past")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 1, [FromQuery] int pageSize = 5) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);
|
||||||
|
return Ok(new PastJobsResponse { Jobs = jobs, Total = total });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns children of a specific parent job (active + past).
|
||||||
|
/// Supports delta sync — pass <paramref name="since"/> to receive only children changed after that timestamp.
|
||||||
|
/// The <see cref="ChildrenResponse.Since"/> field contains the timestamp to use for the next delta poll.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{parentId}/children")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult<ChildrenResponse> GetChildren(Guid parentId, [FromQuery] DateTime? since) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
var children = jobManager.GetChildren(parentId, since);
|
||||||
|
return Ok(new ChildrenResponse { Children = children, Since = DateTime.UtcNow });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the status of a specific job by its ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobId">The ID of the job.</param>
|
||||||
|
/// <returns>The job status, or 404 if not found.</returns>
|
||||||
|
[HttpGet("{jobId}")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult<JobStatusDto> Get(Guid jobId) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
var job = jobManager.GetJobStatus(jobId);
|
||||||
|
if (job == null) { return NotFound(); }
|
||||||
|
return Ok(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels a running or queued job.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobId">The ID of the job to cancel.</param>
|
||||||
|
/// <returns>200 if cancelled, 404 if not found.</returns>
|
||||||
|
[HttpDelete("{jobId}")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult Cancel(Guid jobId) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
var job = jobManager.GetJob(jobId);
|
||||||
|
|
||||||
|
if (job == null) { return NotFound(); }
|
||||||
|
job.Cancel();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueues a new job of the specified type.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobRequest">The job request containing the job type and optional parameters.</param>
|
||||||
|
/// <returns>200 if enqueued, 400 if invalid request, 501 if not implemented.</returns>
|
||||||
|
[HttpPut]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult Enqueue([FromBody] JobRequestDto jobRequest) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
|
||||||
|
switch (jobRequest.JobType) {
|
||||||
|
case EJobType.FileSystemScan:
|
||||||
|
if (jobRequest.Parameters.Count != 0)
|
||||||
|
return BadRequest("FullFileSystemScan job does not require any parameters.");
|
||||||
|
|
||||||
|
jobScheduler.QueueFileSystemCrawl(null);
|
||||||
|
return Ok();
|
||||||
|
case EJobType.ThumbnailGeneration:
|
||||||
|
if (jobRequest.Parameters.Count != 0)
|
||||||
|
return BadRequest("ThumbnailGeneration job does not require any parameters.");
|
||||||
|
|
||||||
|
jobScheduler.QueueThumbnailGeneration(null);
|
||||||
|
return Ok();
|
||||||
|
case EJobType.PreviewGeneration:
|
||||||
|
if (jobRequest.Parameters.Count != 0)
|
||||||
|
return BadRequest("PreviewGeneration job does not require any parameters.");
|
||||||
|
|
||||||
|
jobScheduler.QueuePreviewGeneration(null);
|
||||||
|
return Ok();
|
||||||
|
case EJobType.MetadataExtraction:
|
||||||
|
if (jobRequest.Parameters.Count != 0)
|
||||||
|
return BadRequest("MetadataExtraction job does not require any parameters.");
|
||||||
|
|
||||||
|
jobScheduler.QueueMetadataExtraction(null);
|
||||||
|
return Ok();
|
||||||
|
case EJobType.IntegrityCheck:
|
||||||
|
if (jobRequest.Parameters.Count != 0)
|
||||||
|
return BadRequest("IntegrityCheck job does not require any parameters.");
|
||||||
|
|
||||||
|
jobScheduler.QueueIntegrityCheck(null);
|
||||||
|
return Ok();
|
||||||
|
case EJobType.CreatePersons:
|
||||||
|
if (jobRequest.Parameters.Count != 0)
|
||||||
|
return BadRequest("CreatePersons job does not require any parameters.");
|
||||||
|
jobScheduler.QueueCreatePersons(null);
|
||||||
|
return Ok();
|
||||||
|
case EJobType.CreateAlbums:
|
||||||
|
if (jobRequest.Parameters.Count != 0)
|
||||||
|
return BadRequest("CreateAlbums job does not require any parameters.");
|
||||||
|
jobScheduler.QueueCreateAlbums(null);
|
||||||
|
return Ok();
|
||||||
|
case EJobType.PHashGeneration:
|
||||||
|
if (jobRequest.Parameters.Count > 1)
|
||||||
|
return BadRequest("PHashGeneration job takes zero or one parameter (the asset ID).");
|
||||||
|
jobScheduler.QueuePHashGeneration(jobRequest.Parameters.FirstOrDefault());
|
||||||
|
return Ok();
|
||||||
|
default: return BadRequest("Invalid job type.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
Lactose/Controllers/MediaController.cs
Normal file
135
Lactose/Controllers/MediaController.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
using Lactose.Configuration;
|
||||||
|
using Lactose.Repositories;
|
||||||
|
using Lactose.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
|
||||||
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves media files (original, thumbnail, preview) for assets.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authService">Authentication service.</param>
|
||||||
|
/// <param name="mediaRepository">Media repository.</param>
|
||||||
|
/// <param name="signKeyOptions">JWT signing key configuration.</param>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class MediaController(
|
||||||
|
LactoseAuthService authService,
|
||||||
|
IMediaRepository mediaRepository,
|
||||||
|
IAssetRepository assetRepository,
|
||||||
|
IOptions<SignKeyConfiguration> signKeyOptions
|
||||||
|
) : ControllerBase {
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the original image file for an asset.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The asset ID.</param>
|
||||||
|
/// <returns>The physical image file, or 404 if not found or access denied.</returns>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
[HttpGet("original/{id}")]
|
||||||
|
public ActionResult GetImage(Guid id) {
|
||||||
|
var user = GetUserFromRequest();
|
||||||
|
var asset = assetRepository.Find(id);
|
||||||
|
|
||||||
|
if (asset == null) return NotFound();
|
||||||
|
|
||||||
|
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||||
|
return PhysicalFile(asset.OriginalPath, asset.MimeType);
|
||||||
|
}
|
||||||
|
var data = mediaRepository.GetOriginalData(id, user?.Id);
|
||||||
|
if (data == null) return NotFound();
|
||||||
|
|
||||||
|
return PhysicalFile(data.Path, data.MimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the thumbnail image for an asset.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The asset ID.</param>
|
||||||
|
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
|
||||||
|
[HttpGet("thumb/{id}")]
|
||||||
|
public ActionResult GetThumb(Guid id) {
|
||||||
|
var user = GetUserFromRequest();
|
||||||
|
var asset = assetRepository.Find(id);
|
||||||
|
|
||||||
|
if (asset == null) return NotFound();
|
||||||
|
|
||||||
|
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||||
|
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
|
||||||
|
return PhysicalFile(asset.ThumbnailPath, "image/webp");
|
||||||
|
}
|
||||||
|
var data = mediaRepository.GetThumbData(id, user?.Id);
|
||||||
|
if (data == null) return NotFound();
|
||||||
|
|
||||||
|
return PhysicalFile(data.Path, data.MimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the preview image for an asset.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The asset ID.</param>
|
||||||
|
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
|
||||||
|
[HttpGet("preview/{id}")]
|
||||||
|
public ActionResult GetPreview(Guid id) {
|
||||||
|
var user = GetUserFromRequest();
|
||||||
|
var asset = assetRepository.Find(id);
|
||||||
|
|
||||||
|
if (asset == null) return NotFound();
|
||||||
|
|
||||||
|
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||||
|
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
|
||||||
|
return PhysicalFile(asset.PreviewPath, "image/webp");
|
||||||
|
}
|
||||||
|
var data = mediaRepository.GetPreviewData(id, user?.Id);
|
||||||
|
if (data == null) return NotFound();
|
||||||
|
|
||||||
|
return PhysicalFile(data.Path, data.MimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the authenticated user from the request, first trying the Authorization header,
|
||||||
|
/// then falling back to the <c>token</c> query parameter for browser image requests.
|
||||||
|
/// </summary>
|
||||||
|
private LactoseAuthenticatedUser? GetUserFromRequest() {
|
||||||
|
var user = authService.GetUserData(User);
|
||||||
|
if (user != null) return user;
|
||||||
|
|
||||||
|
var token = Request.Query["token"].FirstOrDefault();
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var key = signKeyOptions.Value.GetSecurityKey();
|
||||||
|
var principal = handler.ValidateToken(token, new TokenValidationParameters {
|
||||||
|
IssuerSigningKey = key,
|
||||||
|
ValidateIssuer = false,
|
||||||
|
ValidateAudience = false,
|
||||||
|
ValidateLifetime = true
|
||||||
|
}, out _);
|
||||||
|
return authService.GetUserData(principal);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the requester can access asset files directly without repository-level filtering.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The authenticated user, if any.</param>
|
||||||
|
/// <param name="deletedAt">The asset deletion timestamp.</param>
|
||||||
|
/// <returns><see langword="true"/> for admins, or for curators when the asset is not deleted; otherwise <see langword="false"/>.</returns>
|
||||||
|
private static bool CanAccessAssetDirectly(LactoseAuthenticatedUser? user, DateTime? deletedAt) {
|
||||||
|
return user?.AccessLevel switch {
|
||||||
|
EAccessLevel.Admin => true,
|
||||||
|
EAccessLevel.Curator => deletedAt == null,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
203
Lactose/Controllers/PersonController.cs
Normal file
203
Lactose/Controllers/PersonController.cs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
using Butter.Dtos;
|
||||||
|
using Butter.Dtos.Person;
|
||||||
|
using Butter.Types;
|
||||||
|
using Lactose.Mapper;
|
||||||
|
using Lactose.Repositories;
|
||||||
|
using Lactose.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages people (faces/individuals identified in assets).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class PersonController(
|
||||||
|
IPersonRepository personRepository,
|
||||||
|
IAlbumRepository albumRepository,
|
||||||
|
LactoseAuthService authService
|
||||||
|
) : ControllerBase {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a person by ID with their associated albums, filtered by access level.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a person by ID with their associated albums, filtered by access level and paginated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The person ID.</param>
|
||||||
|
/// <param name="albumPaging">Pagination parameters for albums.</param>
|
||||||
|
/// <returns>The person details with albums, or 404 if not found.</returns>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedParametersDto albumPaging) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
var person = personRepository.Find(id);
|
||||||
|
if (person == null) return NotFound();
|
||||||
|
|
||||||
|
if (person.Albums != null && accessLevel == EAccessLevel.User) {
|
||||||
|
var owned = person.Albums.Where(a => a.UserOwnerId == uid).ToList();
|
||||||
|
var publicShared = person.Albums
|
||||||
|
.Where(a => a.Assets != null && a.Assets.Any(asset => asset is { IsPubliclyShared: true, DeletedAt: null }))
|
||||||
|
.ToList();
|
||||||
|
var shared = person.Albums
|
||||||
|
.Where(a => a.Assets != null && a.Assets.Any(asset => asset is {
|
||||||
|
DeletedAt: null, SharedWith: { Count: > 0 }
|
||||||
|
} && asset.SharedWith!.Any(u => u.Id == uid)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
person.Albums = owned
|
||||||
|
.Union(publicShared)
|
||||||
|
.Union(shared)
|
||||||
|
.DistinctBy(a => a.Id)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person.Albums != null) {
|
||||||
|
person.Albums = person.Albums
|
||||||
|
.Skip(albumPaging.Page * albumPaging.PageSize)
|
||||||
|
.Take(albumPaging.PageSize)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(person.ToPersonDetailedDto());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches people with pagination, filtering out cosplayers with no visible content for non-admin users.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pagedSearch">Pagination and search parameters.</param>
|
||||||
|
/// <returns>A list of people previews.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
public ActionResult<List<PersonPreviewDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
var people = personRepository.GetAllVisible(uid!.Value, accessLevel)
|
||||||
|
.Select(p => p.ToPersonPreviewDto())
|
||||||
|
.ToList();
|
||||||
|
return Ok(people);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates a person.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The person ID.</param>
|
||||||
|
/// <param name="dto">The updated person data.</param>
|
||||||
|
/// <returns>200 on success.</returns>
|
||||||
|
[HttpPost("{id}")]
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
|
||||||
|
var person = personRepository.Find(id);
|
||||||
|
if (person == null) return NotFound();
|
||||||
|
|
||||||
|
person.Name = dto.Name;
|
||||||
|
person.ProfileAssetId = dto.ProfileAssetId;
|
||||||
|
person.ProfileCropX = dto.ProfileCropX;
|
||||||
|
person.ProfileCropY = dto.ProfileCropY;
|
||||||
|
person.ProfileCropZoom = dto.ProfileCropZoom;
|
||||||
|
person.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
personRepository.Save();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates multiple people in a bulk operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="personBulkDto">The bulk update data.</param>
|
||||||
|
/// <returns>200 on success.</returns>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
public IActionResult BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
|
||||||
|
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
|
||||||
|
|
||||||
|
foreach (var person in people) {
|
||||||
|
person.Name = personBulkDto.Data.Name;
|
||||||
|
person.ProfileAssetId = personBulkDto.Data.ProfileAssetId;
|
||||||
|
person.ProfileCropX = personBulkDto.Data.ProfileCropX;
|
||||||
|
person.ProfileCropY = personBulkDto.Data.ProfileCropY;
|
||||||
|
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom;
|
||||||
|
person.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
personRepository.Save();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a person by ID (soft delete).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The person ID.</param>
|
||||||
|
/// <returns>200 on success, 404 if not found.</returns>
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a person by ID (hard delete). Cascades to unlink all associated albums.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The person ID.</param>
|
||||||
|
/// <returns>200 on success, 404 if not found.</returns>
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
public IActionResult Delete([FromRoute] Guid id) {
|
||||||
|
var person = personRepository.Find(id);
|
||||||
|
if (person == null) return NotFound();
|
||||||
|
|
||||||
|
// Unlink all albums owned by this person
|
||||||
|
var albums = albumRepository.FindByPerson(id).ToList();
|
||||||
|
foreach (var album in albums) {
|
||||||
|
album.PersonOwnerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
personRepository.Remove(person);
|
||||||
|
personRepository.Save();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes multiple people in a bulk operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ids">The list of person IDs to delete.</param>
|
||||||
|
/// <returns>200 on success.</returns>
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes multiple people in a bulk operation (hard delete). Cascades to unlink all associated albums.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ids">The list of person IDs to delete.</param>
|
||||||
|
/// <returns>200 on success.</returns>
|
||||||
|
[HttpDelete]
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||||
|
var people = personRepository.FindBulk(ids).ToList();
|
||||||
|
|
||||||
|
foreach (var person in people) {
|
||||||
|
// Unlink all albums owned by this person
|
||||||
|
var albums = albumRepository.FindByPerson(person.Id).ToList();
|
||||||
|
foreach (var album in albums) {
|
||||||
|
album.PersonOwnerId = null;
|
||||||
|
}
|
||||||
|
personRepository.Remove(person);
|
||||||
|
}
|
||||||
|
|
||||||
|
personRepository.Save();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new person.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto">The person creation data.</param>
|
||||||
|
/// <returns>200 with the new person ID.</returns>
|
||||||
|
[HttpPut]
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
public ActionResult<Guid> Create([FromBody] PersonCreateDto dto) {
|
||||||
|
var person = new Models.Person {
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = dto.Name,
|
||||||
|
ProfileAssetId = dto.ProfileAssetId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
personRepository.Insert(person);
|
||||||
|
personRepository.Save();
|
||||||
|
return Ok(person.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
Lactose/Controllers/SettingsController.cs
Normal file
108
Lactose/Controllers/SettingsController.cs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
using Butter.Dtos.Settings;
|
||||||
|
using Butter.Types;
|
||||||
|
using Lactose.Mapper;
|
||||||
|
using Lactose.Models;
|
||||||
|
using Lactose.Repositories;
|
||||||
|
using Lactose.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages application settings (admin only).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authService">Authentication service.</param>
|
||||||
|
/// <param name="settingsRepository">Settings repository.</param>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class SettingsController(
|
||||||
|
//TODO: Add logging
|
||||||
|
//ILogger<SettingsController> logger,
|
||||||
|
LactoseAuthService authService,
|
||||||
|
ISettingsRepository settingsRepository
|
||||||
|
) : ControllerBase {
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new application setting.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="settingDto">The setting to create.</param>
|
||||||
|
/// <returns>200 on success.</returns>
|
||||||
|
[HttpPut]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult Create([FromBody] SettingDto settingDto) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
|
|
||||||
|
settingsRepository.Create(
|
||||||
|
new Setting {
|
||||||
|
Name = settingDto.Name,
|
||||||
|
Value = settingDto.Value,
|
||||||
|
Description = settingDto.Description,
|
||||||
|
Type = settingDto.Type,
|
||||||
|
DisplayType = settingDto.DisplayType
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all application settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A list of all settings.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult<SettingDto> Get() {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||||
|
|
||||||
|
return Ok(settingsRepository.Get().Select(s => s.ToSettingDto()).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing application setting.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="settingDto">The updated setting data.</param>
|
||||||
|
/// <returns>200 on success.</returns>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult Update([FromBody] SettingDto settingDto) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||||
|
|
||||||
|
settingsRepository.Update(settingDto.ToSetting());
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a specific application setting.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="settingDto">The setting to delete.</param>
|
||||||
|
/// <returns>200 on success.</returns>
|
||||||
|
[HttpDelete]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public ActionResult Delete([FromBody] SettingDto settingDto) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||||
|
|
||||||
|
settingsRepository.Delete(settingDto.ToSetting());
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes all application settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>200 on success.</returns>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[Route("delete-all")]
|
||||||
|
public ActionResult DeleteAll() {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||||
|
|
||||||
|
settingsRepository.Delete();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Lactose/Controllers/StatsController.cs
Normal file
30
Lactose/Controllers/StatsController.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using Butter.Dtos.Stats;
|
||||||
|
using Lactose.Repositories;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides aggregate statistics about the MilkyShots instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="statsRepository">Stats repository.</param>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize(Roles = "Curator,Admin")]
|
||||||
|
public class StatsController(
|
||||||
|
ILogger<StatsController> logger,
|
||||||
|
IStatsRepository statsRepository
|
||||||
|
) : ControllerBase {
|
||||||
|
/// <summary>
|
||||||
|
/// Returns comprehensive aggregate statistics about the instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A <see cref="StatsDto"/> with all gathered statistics.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
public ActionResult<StatsDto> Get() {
|
||||||
|
logger.LogTrace("Stats requested");
|
||||||
|
var stats = statsRepository.GetStats();
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
Lactose/Controllers/TagController.cs
Normal file
217
Lactose/Controllers/TagController.cs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
using Butter.Dtos;
|
||||||
|
using Butter.Dtos.Tag;
|
||||||
|
using Lactose.Mapper;
|
||||||
|
using Lactose.Models;
|
||||||
|
using Lactose.Repositories;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||||
|
|
||||||
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
|
//TODO: logging
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages tags — hierarchical categorization of assets.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagRepository">Tag repository.</param>
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class TagController(
|
||||||
|
//TODO: add logging
|
||||||
|
//ILogger<TagController> logger,
|
||||||
|
ITagRepository tagRepository
|
||||||
|
) : ControllerBase {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a tag by its ID, optionally including its children and ancestors.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The tag ID.</param>
|
||||||
|
/// <param name="children">Whether to include child tags.</param>
|
||||||
|
/// <param name="ancestors">Whether to include ancestor tags.</param>
|
||||||
|
/// <returns>The tag with its hierarchy, or 404 if not found.</returns>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public ActionResult<TagDto> Get(Guid id, bool children = false, bool ancestors = false) {
|
||||||
|
Tag? tag = tagRepository.Find(id);
|
||||||
|
|
||||||
|
if (tag == null) { return NotFound(); }
|
||||||
|
var tagDto = TagsMapper.ToTagDto(tag);
|
||||||
|
tagDto.Parent = tag.Parent == null ? null : TagsMapper.ToTagDto(tag.Parent);
|
||||||
|
|
||||||
|
if (children) {
|
||||||
|
tagDto.Children = [];
|
||||||
|
tagRepository.GetChildren(tag.Id).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: Protect from overflow
|
||||||
|
if (ancestors) {
|
||||||
|
Tag? index = tag.Parent;
|
||||||
|
TagDto? indexDto = tagDto;
|
||||||
|
List<Tag> ancestorsTags = [];
|
||||||
|
|
||||||
|
// This is redundant, because circular references shouldn't be present
|
||||||
|
while (index != null && !ancestorsTags.Contains(tag)) {
|
||||||
|
ancestorsTags.Add(index);
|
||||||
|
indexDto.Parent = TagsMapper.ToTagDto(index);
|
||||||
|
index = index.Parent;
|
||||||
|
indexDto = indexDto.Parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches tags with pagination and optional search term.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pagedSearch">Pagination and search parameters.</param>
|
||||||
|
/// <returns>A list of tags matching the search criteria.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
public ActionResult<List<TagDto>> GetAll(PagedSearchParametersDto pagedSearch) {
|
||||||
|
pagedSearch.Search ??= string.Empty;
|
||||||
|
if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); }
|
||||||
|
if(pagedSearch.PageSize < 0) { return BadRequest("PageSize must be greater than 0"); }
|
||||||
|
|
||||||
|
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
|
||||||
|
List<TagDto> tagsDto = [];
|
||||||
|
|
||||||
|
tags.ForEach(
|
||||||
|
t => {
|
||||||
|
var tagDto = TagsMapper.ToTagDto(t);
|
||||||
|
tagDto.Parent = t.Parent == null ? null : TagsMapper.ToTagDto(t.Parent);
|
||||||
|
tagsDto.Add(tagDto);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return tagsDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The tag ID.</param>
|
||||||
|
/// <param name="tagDto">The updated tag data.</param>
|
||||||
|
/// <returns>200 on success, 404 if not found, 409 if name conflict.</returns>
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
[HttpPost("{id}")]
|
||||||
|
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) {
|
||||||
|
|
||||||
|
IStatusCodeActionResult result = UpdateTag(id, tagDto.Name, tagDto.Parent);
|
||||||
|
|
||||||
|
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates multiple tags in a bulk operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagBulkDto">The bulk update data with IDs and shared update values.</param>
|
||||||
|
/// <returns>200 on success, or the first error response.</returns>
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
[HttpPost]
|
||||||
|
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto) {
|
||||||
|
|
||||||
|
foreach (Guid id in tagBulkDto.Ids) {
|
||||||
|
IStatusCodeActionResult result = UpdateTag(id, tagBulkDto.Data.Name, tagBulkDto.Data.Parent);
|
||||||
|
|
||||||
|
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
|
||||||
|
}
|
||||||
|
|
||||||
|
tagRepository.Save();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a tag by its ID (soft delete).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The tag ID.</param>
|
||||||
|
/// <returns>200 on success, 404 if not found.</returns>
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public ActionResult Delete([FromRoute] Guid id) {
|
||||||
|
IStatusCodeActionResult result = DeleteTag(id);
|
||||||
|
|
||||||
|
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
|
||||||
|
|
||||||
|
return (ActionResult)result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes multiple tags in a bulk operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ids">The list of tag IDs to delete.</param>
|
||||||
|
/// <returns>200 on success, or the first error response.</returns>
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
[HttpDelete]
|
||||||
|
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||||
|
foreach (Guid id in ids) {
|
||||||
|
IStatusCodeActionResult result = DeleteTag(id);
|
||||||
|
|
||||||
|
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
|
||||||
|
}
|
||||||
|
|
||||||
|
tagRepository.Save();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagDto">The tag creation data.</param>
|
||||||
|
/// <returns>200 on success, 409 if a tag with the same name already exists, 400 if parent not found.</returns>
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
[HttpPut]
|
||||||
|
public ActionResult Create([FromBody] TagCreateDto tagDto) {
|
||||||
|
|
||||||
|
if (tagRepository.FindByName(tagDto.Name) != null) { return Conflict($"Tag with name {tagDto.Name} already exist"); }
|
||||||
|
|
||||||
|
var tag = new Tag() {
|
||||||
|
Name = tagDto.Name
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tagDto.Parent != null && tagRepository.Find(tagDto.Parent.Value) == null) {
|
||||||
|
return BadRequest($"No Tag with id {tagDto.Parent} found");
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.ParentId = tagDto.Parent;
|
||||||
|
tagRepository.Insert(tag);
|
||||||
|
tagRepository.Save();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
IStatusCodeActionResult DeleteTag(Guid id) {
|
||||||
|
Tag? tag = tagRepository.Find(id);
|
||||||
|
|
||||||
|
if (tag == null) { return NotFound($"Tag {id} not found"); }
|
||||||
|
tagRepository.Delete(tag);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
IStatusCodeActionResult UpdateTag(Guid id, string? name, Guid? parentId) {
|
||||||
|
|
||||||
|
Tag? tag = tagRepository.Find(id);
|
||||||
|
|
||||||
|
if (tag == null) { return NotFound($"Tag {id} not found"); }
|
||||||
|
|
||||||
|
if (name != null) {
|
||||||
|
Tag? foundTag = tagRepository.FindByName(name);
|
||||||
|
|
||||||
|
if (foundTag != null && foundTag.Id != tag.Id) { return Conflict($"Tag with name '{name}' already exists"); }
|
||||||
|
|
||||||
|
tag.Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId == null) { return Ok(); }
|
||||||
|
|
||||||
|
if (parentId.Value.Equals(Guid.Empty)) { tag.Parent = null; } else {
|
||||||
|
Tag? parent = tagRepository.Find(parentId.Value);
|
||||||
|
|
||||||
|
if (parent == null) { return NotFound($"Parent tag on {id} was not found"); }
|
||||||
|
tag.Parent = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,119 +1,213 @@
|
|||||||
using Lactose.Context;
|
using Butter.Dtos.User;
|
||||||
using Lactose.Dtos;
|
using Butter.Types;
|
||||||
using Lactose.Mapper;
|
using Lactose.Mapper;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
|
using Lactose.Repositories;
|
||||||
|
using Lactose.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
|
||||||
|
|
||||||
namespace Lactose.Controllers;
|
namespace Lactose.Controllers {
|
||||||
|
/// <summary>
|
||||||
|
/// Manages user accounts — creation, retrieval, updates, and deletion.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="authService">Authentication service.</param>
|
||||||
|
/// <param name="userRepository">User repository.</param>
|
||||||
|
/// <param name="passwordHasher">Password hasher.</param>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class UserController(
|
||||||
|
ILogger<UserController> logger,
|
||||||
|
LactoseAuthService authService,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IPasswordHasher<User> passwordHasher
|
||||||
|
) : ControllerBase {
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new user (admin only).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userCreateDto">The user creation data.</param>
|
||||||
|
/// <returns>200 with the new user ID, or conflict if the email/username already exists.</returns>
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[HttpPut]
|
||||||
|
public ActionResult<UserInfoDto> Create([FromBody] UserCreateDto userCreateDto) {
|
||||||
|
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||||
|
|
||||||
|
if (authenticatedUser != null) {
|
||||||
|
logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authenticatedUser != null && authenticatedUser.AccessLevel != EAccessLevel.Admin) return Forbid();
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class UserController(ILogger<UserController> logger, TestDbContext context) : ControllerBase {
|
|
||||||
[HttpPost("register")]
|
|
||||||
public ActionResult<Guid> Register([FromBody] RegisterUserDto userDto) {
|
|
||||||
logger.LogDebug(
|
logger.LogDebug(
|
||||||
$"""
|
$"""
|
||||||
Registering user:
|
Registering user:
|
||||||
Username: {userDto.Username}
|
Username: {userCreateDto.Username}
|
||||||
Email: {userDto.Email}
|
Email: {userCreateDto.Email}
|
||||||
Access: {userDto.AccessLevel}
|
Access: {userCreateDto.AccessLevel}
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (userRepository.GetAll().Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
|
||||||
|
if (userRepository.GetAll().Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
|
||||||
|
|
||||||
var user = new User {
|
var user = new User {
|
||||||
Username = userDto.Username,
|
Username = userCreateDto.Username,
|
||||||
Email = userDto.Email,
|
Email = userCreateDto.Email,
|
||||||
Password = userDto.Password
|
Password = userCreateDto.Password,
|
||||||
|
AccessLevel = userCreateDto.AccessLevel ?? EAccessLevel.User
|
||||||
};
|
};
|
||||||
|
|
||||||
//TODO: add a check for permission to create a privileged account
|
user.Password = passwordHasher.HashPassword(user, userCreateDto.Password);
|
||||||
if (true) { user.AccessLevel = userDto.AccessLevel; } else { return Unauthorized(); }
|
|
||||||
|
userRepository.Insert(user);
|
||||||
|
userRepository.Save();
|
||||||
|
|
||||||
context.Users.Add(user);
|
|
||||||
context.SaveChanges();
|
|
||||||
return Ok(user.Id);
|
return Ok(user.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a user by their ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The user ID.</param>
|
||||||
|
/// <returns>The user info, or 404 if not found.</returns>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
public ActionResult<GetUserDto> Get(Guid id) {
|
public ActionResult<UserInfoDto> Get(Guid id) {
|
||||||
|
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||||
|
|
||||||
|
if (authenticatedUser == null) {
|
||||||
|
logger.LogWarning(@"Anonymous User hasn't enough permissions to read User data. it shouldn't be here");
|
||||||
|
return Unauthorized("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: Lower level of access should fill less information of users
|
||||||
logger.LogDebug($"Sending user with id {id}");
|
logger.LogDebug($"Sending user with id {id}");
|
||||||
|
|
||||||
//TODO: add authentication check
|
User? user = userRepository.Find(id);
|
||||||
if (false) return Unauthorized();
|
|
||||||
|
|
||||||
User? user = context.Users.Find(id);
|
if (user is null) return NotFound();
|
||||||
|
|
||||||
if (user == null) return NotFound();
|
|
||||||
|
|
||||||
return Ok(user.ToGetUsersDto());
|
return Ok(user.ToGetUsersDto());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all users.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A list of all users.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<List<GetUserDto>> GetAll() {
|
public ActionResult<List<UserInfoDto>> GetAll() {
|
||||||
logger.LogDebug("Sending Users list");
|
logger.LogDebug("Sending Users list");
|
||||||
|
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||||
|
|
||||||
//TODO: add authentication check
|
if (authenticatedUser == null) {
|
||||||
if (false) return Unauthorized();
|
logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here");
|
||||||
|
return Unauthorized("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(context.Users.Select(u => u.ToGetUsersDto()).ToList());
|
//TODO: Lower level of access should fill less information of users
|
||||||
|
//if (false) return Unauthorized();
|
||||||
|
|
||||||
|
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto()).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing user's profile.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto">The user update data.</param>
|
||||||
|
/// <returns>The updated user info, or 403/404 if unauthorized or not found.</returns>
|
||||||
[HttpPost("update")]
|
[HttpPost("update")]
|
||||||
public ActionResult<GetUserDto> Update([FromBody] UpdateUserDto userDto) {
|
public ActionResult<UserInfoDto> Update([FromBody] UserUpdateDto dto) {
|
||||||
|
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||||
|
|
||||||
|
if (authenticatedUser == null) {
|
||||||
|
logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here");
|
||||||
|
return Unauthorized("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
logger.LogDebug(
|
logger.LogDebug(
|
||||||
$"""
|
$"""
|
||||||
Editing user:
|
Editing user:
|
||||||
Id: {userDto.Id}
|
Id: {dto.Id}
|
||||||
Username: {userDto.Username}
|
Username: {dto.Username}
|
||||||
Email: {userDto.Email}
|
Email: {dto.Email}
|
||||||
IsBanned: {userDto.IsBanned}
|
IsBanned: {dto.IsBanned}
|
||||||
Access: {userDto.AccessLevel}
|
IsDeleted:{dto.IsDeleted}
|
||||||
|
Access: {dto.AccessLevel}
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
//TODO: add authorization checks
|
if (authenticatedUser.AccessLevel != EAccessLevel.Admin && authenticatedUser.Id != dto.Id) return Forbid();
|
||||||
if (true) return Unauthorized();
|
|
||||||
|
|
||||||
User user = context.Users.Find(userDto.Id);
|
User? user = userRepository.Find(dto.Id);
|
||||||
if (user == null) return NotFound();
|
if (user == null) return NotFound();
|
||||||
|
|
||||||
user.Username = userDto.Username;
|
if (dto.Username != null) { user.Username = dto.Username; }
|
||||||
user.Email = userDto.Email;
|
|
||||||
user.Password = userDto.Password;
|
|
||||||
|
|
||||||
//TODO: add a check for permission to execute these two operations
|
if (dto.Email != null) { user.Email = dto.Email; }
|
||||||
if (true) {
|
|
||||||
//if the user is already banned, do not update banned date
|
if (dto.Password != null) {
|
||||||
if (user.BannedAt != null) {
|
if (dto.OldPassword != null) {
|
||||||
// if the user is being banned set banned date to now otherwise set banned date to null (unban)
|
if (passwordHasher.VerifyHashedPassword(user, user.Password, dto.OldPassword) == PasswordVerificationResult.Failed) {
|
||||||
user.BannedAt = userDto.IsBanned ? DateTime.Now : null;
|
return BadRequest("Old password is incorrect");
|
||||||
|
}
|
||||||
|
} else if (authenticatedUser.AccessLevel != EAccessLevel.Admin) {
|
||||||
|
return BadRequest("Old password is required to change your own password");
|
||||||
}
|
}
|
||||||
|
|
||||||
user.AccessLevel = userDto.AccessLevel;
|
if (authenticatedUser.AccessLevel != EAccessLevel.Admin) {
|
||||||
} else { return Unauthorized(); }
|
if (dto.Password.Length < 8) return BadRequest("Password must be at least 8 characters long");
|
||||||
|
if (!dto.Password.Any(char.IsUpper)) return BadRequest("Password must contain at least one uppercase letter");
|
||||||
|
if (!dto.Password.Any(char.IsLower)) return BadRequest("Password must contain at least one lowercase letter");
|
||||||
|
if (!dto.Password.Any(c => char.IsDigit(c) || char.IsPunctuation(c) || char.IsSymbol(c))) return BadRequest("Password must contain at least one number or special character");
|
||||||
|
}
|
||||||
|
|
||||||
context.SaveChanges();
|
user.Password = passwordHasher.HashPassword(user, dto.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.IsDeleted != null) { user.DeletedAt = dto.IsDeleted.Value ? DateTime.UtcNow : null; }
|
||||||
|
|
||||||
|
if (authenticatedUser.AccessLevel == EAccessLevel.Admin) {
|
||||||
|
if (dto.IsBanned != null) {
|
||||||
|
if (user.BannedAt != null) user.BannedAt = dto.IsBanned.Value ? DateTime.UtcNow : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepository.Save();
|
||||||
return Ok(user.ToGetUsersDto());
|
return Ok(user.ToGetUsersDto());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soft-deletes a user by their ID (admin only).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The user ID.</param>
|
||||||
|
/// <returns>200 with the deleted user ID, or 404 if not found.</returns>
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
public ActionResult<Guid> Delete(Guid id) {
|
public ActionResult<Guid> Delete([FromRoute] Guid id) {
|
||||||
|
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||||
|
|
||||||
|
if (authenticatedUser == null) {
|
||||||
|
logger.LogWarning(@"Anonymous hasn't enough permissions to delete a User. it shouldn't be here");
|
||||||
|
return Unauthorized("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
logger.LogDebug($"Deleting user: {id}");
|
logger.LogDebug($"Deleting user: {id}");
|
||||||
|
|
||||||
//TODO: add authorization checks
|
User? user = userRepository.Find(id);
|
||||||
if (false) return Unauthorized();
|
|
||||||
|
|
||||||
User? user = context.Users.Find(id);
|
|
||||||
|
|
||||||
if (user == null) return NotFound();
|
if (user == null) return NotFound();
|
||||||
|
|
||||||
user.DeletedAt = DateTime.Now;
|
userRepository.Delete(user);
|
||||||
|
|
||||||
//TODO: eventually remove/hide all of the user content if required
|
//TODO: eventually remove/hide all of the user content if required
|
||||||
|
userRepository.Save();
|
||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
135
Lactose/DefaultSettings.json
Normal file
135
Lactose/DefaultSettings.json
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"Name": "User Registration Enabled",
|
||||||
|
"Value": "false",
|
||||||
|
"Description": "Sets if user registration is enabled or not.",
|
||||||
|
"Type": 2,
|
||||||
|
"DisplayType": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Folder Scan Enabled",
|
||||||
|
"Value": "true",
|
||||||
|
"Description": "Sets if the folder scan service should be running or not.",
|
||||||
|
"Type": 2,
|
||||||
|
"DisplayType": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Folder Scan Interval",
|
||||||
|
"Value": "",
|
||||||
|
"Description": "Time interval (minutes) after the previous scan has finished before a new folder scan is started.",
|
||||||
|
"Type": 4,
|
||||||
|
"DisplayType": 7,
|
||||||
|
"Options": [
|
||||||
|
"5",
|
||||||
|
"360",
|
||||||
|
"5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "File Upload Enabled",
|
||||||
|
"Value": "false",
|
||||||
|
"Description": "NOT IMPLEMENTED YET: Sets if the file upload service should be running or not.",
|
||||||
|
"Type": 2,
|
||||||
|
"DisplayType": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "File Upload MaxSize",
|
||||||
|
"Value": "0",
|
||||||
|
"Description": "Max file size for uploads in bytes.",
|
||||||
|
"Type": 1,
|
||||||
|
"DisplayType": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Thumbnail Path",
|
||||||
|
"Value": "",
|
||||||
|
"Description": "Path where thumbnails are stored.",
|
||||||
|
"Type": 0,
|
||||||
|
"DisplayType": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Preview Path",
|
||||||
|
"Value": "",
|
||||||
|
"Description": "Path where thumbnails are stored.",
|
||||||
|
"Type": 0,
|
||||||
|
"DisplayType": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Thumbnail Size",
|
||||||
|
"Value": "512",
|
||||||
|
"Description": "Size for longest side of the thumbnail in pixels.",
|
||||||
|
"Type": 1,
|
||||||
|
"DisplayType": 8,
|
||||||
|
"Options": [
|
||||||
|
"64",
|
||||||
|
"1024",
|
||||||
|
"1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Preview Size",
|
||||||
|
"Value": "2048",
|
||||||
|
"Description": "Size for longest side of the preview in pixels.",
|
||||||
|
"Type": 1,
|
||||||
|
"DisplayType": 8,
|
||||||
|
"Options": [
|
||||||
|
"256",
|
||||||
|
"4096",
|
||||||
|
"1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Max Concurrent Jobs",
|
||||||
|
"Value": "4",
|
||||||
|
"Description": "Max number of concurrent jobs",
|
||||||
|
"Type": 1,
|
||||||
|
"DisplayType": 2,
|
||||||
|
"Options": [
|
||||||
|
"1",
|
||||||
|
"32",
|
||||||
|
"1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Job Retention Days",
|
||||||
|
"Value": "30",
|
||||||
|
"Description": "Number of days to keep completed/failed/canceled jobs before automatic cleanup.",
|
||||||
|
"Type": 1,
|
||||||
|
"DisplayType": 2,
|
||||||
|
"Options": [
|
||||||
|
"1",
|
||||||
|
"365",
|
||||||
|
"1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Thumbnail Quality",
|
||||||
|
"Value": "75",
|
||||||
|
"Description": "WebP encoding quality for thumbnails (1-100).",
|
||||||
|
"Type": 1,
|
||||||
|
"DisplayType": 7,
|
||||||
|
"Options": [
|
||||||
|
"1",
|
||||||
|
"100",
|
||||||
|
"1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Preview Quality",
|
||||||
|
"Value": "85",
|
||||||
|
"Description": "WebP encoding quality for previews (1-100).",
|
||||||
|
"Type": 1,
|
||||||
|
"DisplayType": 7,
|
||||||
|
"Options": [
|
||||||
|
"1",
|
||||||
|
"100",
|
||||||
|
"1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Job Batch Size",
|
||||||
|
"Value": "200",
|
||||||
|
"Description": "Number of assets to process per sub-job batch.",
|
||||||
|
"Type": 1,
|
||||||
|
"DisplayType": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||||
|
RUN mkdir -p /app/thumbnails /app/previews && chown -R $APP_UID /app
|
||||||
USER $APP_UID
|
USER $APP_UID
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
EXPOSE 8081
|
EXPOSE 8081
|
||||||
EXPOSE 5162
|
EXPOSE 5162
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR "/src"
|
||||||
COPY ["WepApiTest/WepApiTest.csproj", "WepApiTest/"]
|
COPY ["Lactose/Lactose.csproj", "Lactose/"]
|
||||||
RUN dotnet restore "WepApiTest/WepApiTest.csproj"
|
RUN dotnet restore "Lactose/Lactose.csproj"
|
||||||
COPY . .
|
COPY . .
|
||||||
WORKDIR "/src/WepApiTest"
|
WORKDIR "/src/Lactose"
|
||||||
RUN dotnet build "WepApiTest.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
RUN dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
FROM build AS publish
|
FROM build AS publish
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
RUN dotnet publish "WepApiTest.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
FROM base AS final
|
FROM base AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=publish /app/publish .
|
COPY --from=publish /app/publish .
|
||||||
ENTRYPOINT ["dotnet", "WepApiTest.dll"]
|
|
||||||
|
ENTRYPOINT ["dotnet", "Lactose.dll"]
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
using Lactose.Models;
|
|
||||||
|
|
||||||
namespace Lactose.Dtos;
|
|
||||||
|
|
||||||
public class GetUserDto {
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public string? Username { get; set; }
|
|
||||||
public string? Email { get; set; }
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
|
||||||
public DateTime? LastLogin { get; set; }
|
|
||||||
public DateTime? BannedAt { get; set; }
|
|
||||||
public bool IsBanned { get; set; }
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user