Compare commits

...

191 Commits

Author SHA1 Message Date
46bbc34956 refactor: fix error WHITESPACE and FINALNEWLINE
All checks were successful
.NET Test Pipeline / build (pull_request) Successful in 1m37s
.NET Test Pipeline / build (push) Successful in 1m33s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m18s
2025-02-11 17:13:13 +03:00
047ccfa754 fix: correct calculate next occurrence
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 44s
2025-02-11 16:35:56 +03:00
b0d9a67c1c refacotr: clean code 2025-02-11 15:36:55 +03:00
3eb043b24c build: fix code style with CRLF
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 42s
2025-02-11 15:29:43 +03:00
4cd476764d build: fix secrets
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 43s
2025-02-11 15:25:47 +03:00
90b4662dda Release 1.0.0
Some checks failed
.NET Test Pipeline / build (pull_request) Failing after 8s
2025-02-11 15:16:51 +03:00
e7edc79ebc build: instead build run analyze 2025-02-11 15:04:38 +03:00
aabeed0aa5 feat: add backend version to swagger 2025-02-10 16:07:51 +03:00
e79ec360ea Merge branch 'release/v1.0.0' of https://git.winsomnia.net/Winsomnia/MireaBackend into release/v1.0.0
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 1m36s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m37s
2025-02-06 16:29:48 +03:00
31c1d2804d fix: hotfix calculate next run time 2025-02-06 16:27:20 +03:00
ea4c8b61e0 refactor: use thread pool instead task
Some checks failed
.NET Test Pipeline / build-and-test (push) Has been cancelled
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m31s
2025-02-03 11:25:39 +03:00
b40e394bcf fix: System.ObjectDisposedException for db context into sync secrvice
Some checks failed
.NET Test Pipeline / build-and-test (push) Has been cancelled
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m45s
2025-02-03 10:55:47 +03:00
885b937b0b feat: add parsing from files
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 49s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m42s
2025-02-03 03:44:40 +03:00
dc08285ec8 feat: clear old records 2025-02-02 20:31:52 +03:00
b3a0964aac fix: correct filter data 2025-02-02 20:28:04 +03:00
7d6b21c5bb fix: move from body to query 2025-02-02 04:51:09 +03:00
93912caf01 fix: return correct value 2025-02-02 04:50:54 +03:00
c725cfed32 refactor: increase max value 2025-02-02 04:50:35 +03:00
7c7707b1e2 fix: if delay more than int set max of int 2025-02-02 04:50:04 +03:00
1687e9d89b fix: continue if in filter exist value 2025-02-02 04:49:25 +03:00
8d1b709b43 feat: add start term update and cron schedule update 2025-02-02 03:39:30 +03:00
ce6b0f2673 feat: add cron skipping date 2025-02-02 03:30:52 +03:00
16afc0bc69 feat: show enum name instead value 2025-02-02 03:29:19 +03:00
c9bc6a3565 refactor: remove "swagger" in class name 2025-02-02 03:28:24 +03:00
ad8f356fc1 fix: get non negative number 2025-02-02 01:57:08 +03:00
dda0a29300 refactor: subscribe to onChange instead of waiting for the event to be received from the manager 2025-02-01 21:23:51 +03:00
369901db78 fix: set long, because the value may be greater than int 2025-02-01 21:19:56 +03:00
a67b72b7fb refactor: rename cancellation to cancellationToken 2025-02-01 21:18:56 +03:00
2453b2bd51 build: upgrade ref 2025-02-01 20:47:25 +03:00
5870eef552 feat: add a tag schema to combine similar controllers. 2025-02-01 20:47:08 +03:00
52de98969d refactor: remove unused brackets 2025-02-01 20:45:08 +03:00
bc86e077bd refactor: move to SetupConfiguration namespace 2025-02-01 19:39:02 +03:00
03b6560bc4 feat: add lesson type controller 2025-02-01 17:08:00 +03:00
5bcb7bfbc1 feat: allow filter by lesson type 2025-02-01 17:06:02 +03:00
38fba5556f feat: add filter by type of occupation (lesson type) 2025-02-01 16:46:20 +03:00
fd26178a24 build: update ref
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 1m5s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m54s
2025-01-24 17:12:39 +03:00
7eb307b65e fix: return empty string if null 2025-01-24 17:10:46 +03:00
56c7196100 refactor: change const name to class with name 2025-01-24 17:10:18 +03:00
92081156cf fix: save token after update
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m23s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m13s
2024-12-28 08:34:19 +03:00
6358410f18 sec: to establish the ownership of the token for the first one who received it 2024-12-28 08:30:56 +03:00
e79ddf220f sec: set the absolute time of the token 2024-12-28 08:29:31 +03:00
c3c9844e2f refactor: improve logging 2024-12-28 08:29:06 +03:00
206720cd63 fix: add force select account 2024-12-28 08:16:00 +03:00
d9f4176aca fix: return message if 401 2024-12-28 08:15:43 +03:00
1de344ac25 refactor: to enable oauth during registration, use the appropriate controller. 2024-12-28 07:46:06 +03:00
61a11ea223 fix: return exception message if controller exception 2024-12-28 06:47:21 +03:00
07111b9b61 sec: do not return the error text to the user
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m40s
.NET Test Pipeline / build-and-test (push) Successful in 1m18s
2024-12-26 16:40:30 +03:00
538f1d67c8 fix: change the link to the error type 2024-12-26 16:39:29 +03:00
233458ed89 refactor: add standard traceId 2024-12-26 16:38:53 +03:00
7f87b4d856 style: add space between point and provider 2024-12-26 16:38:13 +03:00
0c6d1c9bfb refactor: compact two factor auth 2024-12-26 16:16:33 +03:00
516ba5bb8e feat: add a token handler 2024-12-26 16:14:55 +03:00
9d5007ef3a refactor: add user converter 2024-12-26 16:14:28 +03:00
c75ac60b0b sec: add verification for OAuth authorization 2024-12-26 15:47:38 +03:00
5b7412f20f feat: return the provider 2024-12-26 15:46:55 +03:00
c4a4478b8c refactor: standardize the order of arguments 2024-12-26 15:46:30 +03:00
05166188be feat: add a method for getting info about a token 2024-12-26 14:32:28 +03:00
157708d00f feat: store the result at each stage 2024-12-26 14:18:12 +03:00
36026b3afb refactor: distribute the domain folder 2024-12-26 13:38:43 +03:00
43edab2912 sec: return the token instead of performing actions with the user
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m0s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m53s
2024-12-26 08:51:22 +03:00
dcdd43469b feat: add payload model 2024-12-26 08:48:17 +03:00
17fd260068 sec: add payload 2024-12-26 08:47:56 +03:00
97187a8e45 sec: save readonly byte array instead string 2024-12-26 08:44:05 +03:00
cfe08dcf9b refactor: get interface instead array 2024-12-26 08:42:24 +03:00
ae4d2073c4 feat: add a 200 result schema 2024-12-25 07:22:07 +03:00
269d976ad4 refactor: move arguments to a new line 2024-12-25 05:54:27 +03:00
5fa545e981 fix: trim the variable to avoid the effects of whitespace characters 2024-12-25 05:53:59 +03:00
2ab5dea8ba feat: add a change to the User Agent and Ip address in case of a mismatch 2024-12-25 05:52:39 +03:00
5e65aded79 refactor: instead of Reason, add explicit arguments 2024-12-25 05:51:54 +03:00
dfac9ddca8 sec: add failed attempts for 2FA 2024-12-25 05:49:13 +03:00
c66f3355ec feat: add logging for empty secret 2024-12-25 05:48:22 +03:00
c12323dc29 refactor: rename methods to match the context 2024-12-25 05:47:51 +03:00
71c31c0bbb refactor: separate the method of counting failed attempts 2024-12-25 05:46:27 +03:00
8c51ba83a4 fix: add trim for email and username 2024-12-25 05:44:37 +03:00
9ff0f51e19 refactor: add data annotations 2024-12-25 05:44:15 +03:00
408a95e4b3 refactor: add .editorconfig and refactor code 2024-12-25 05:43:30 +03:00
2a33ecbf07 build: fix deploy script
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m19s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m14s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-12-23 16:54:05 +03:00
97e50b5331 fix: set minimum level for authorization and authentication to warning
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m31s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m47s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-12-23 10:53:32 +03:00
d505041c72 fix: escape data for state
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m38s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m9s
2024-12-23 08:10:19 +03:00
5ff8744a55 feat: improve logging
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m12s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m11s
2024-12-23 07:48:28 +03:00
053f01eec1 fix: hotfix getting current port
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m33s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m35s
2024-12-23 06:56:01 +03:00
e8e94e45a5 fix: add missing using
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m21s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m21s
2024-12-23 06:32:08 +03:00
55562a9f00 feat: add default response type
Some checks failed
Build and Deploy Docker Container / build-and-deploy (push) Failing after 1m30s
.NET Test Pipeline / build-and-test (push) Failing after 1m7s
2024-12-23 06:29:29 +03:00
57b9819d13 feat: add maintenance ignore 2024-12-23 06:29:00 +03:00
78254ed23d fix: change same name 2024-12-23 06:28:28 +03:00
202d20bb25 build: add providers 2024-12-23 06:28:06 +03:00
3e05863aea refactor: clean code 2024-12-22 07:25:41 +03:00
b82fbc491f fix: add missing using
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m24s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m14s
2024-12-22 07:21:51 +03:00
85722f8552 feat: add integration with seq
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 1m43s
Build and Deploy Docker Container / build-and-deploy (push) Failing after 2m6s
2024-12-22 07:13:59 +03:00
9231c4d5ca refactor: remove unused code 2024-12-22 06:39:12 +03:00
7b94f9cc1f refactor: for maintenance mode, return the standard error 2024-12-22 05:51:11 +03:00
7bafbb95c4 build: update ref 2024-12-22 05:27:07 +03:00
544ad6e791 feat: add an annotation to the data 2024-12-22 05:26:10 +03:00
e4b942d062 refactor: to return the result according to the RFC 7807 standard and add a traceId 2024-12-22 05:25:19 +03:00
f2e79e51f2 sec: transfer user verification to the appropriate service 2024-12-22 05:13:46 +03:00
5cc54eac44 feat: add a method for getting data from OAuth
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m20s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m24s
2024-12-18 07:40:07 +03:00
e9ff1cabe8 feat: add a return of the data that has been configured 2024-12-18 07:39:17 +03:00
fd578aa61e fix: add condition for token 2024-12-18 07:32:00 +03:00
cff42d0a31 feat: add password policy to general config 2024-12-18 07:31:16 +03:00
8250957b85 refactor: use another origin 2024-12-18 07:30:47 +03:00
39208037f0 fix: add method for ignoring mode attribute 2024-12-18 07:30:23 +03:00
5e072d88c2 fix: converter dto 2024-12-18 07:29:58 +03:00
25eddbe776 feat: add generator totp qr-code 2024-12-18 07:29:31 +03:00
74ba4e901a fix: replace localhost to 127.0.0.1 2024-12-18 07:29:05 +03:00
e760ddae0a feat: give the user the ability to make a password policy 2024-12-18 07:27:57 +03:00
598ebabc5c sec: use HMAC to encrypt state 2024-12-18 07:24:33 +03:00
08aeb7ea3c sec: get links to the backend to initiate the receipt of provider data 2024-12-18 07:23:23 +03:00
182235c4cd feat: add generator base32 for totp 2024-12-18 07:14:04 +03:00
5437623a20 build: update ref 2024-12-18 07:13:27 +03:00
2c09122971 fix: add cookie expire time 2024-11-04 03:15:13 +03:00
503f5792fb docs: add comment 2024-11-04 03:14:42 +03:00
95627003e5 refactor: change the error 2024-11-04 03:14:17 +03:00
a96073d44d feat: add available providers list 2024-11-04 02:59:51 +03:00
5f36e0f75b docs: update 2024-11-04 02:39:45 +03:00
e977de3e4f feat: add authorize in OAuth 2024-11-04 02:39:10 +03:00
65d928ec2d fix: remove authorize 2024-11-04 02:36:22 +03:00
713bbfa16f feat: add calculate correct api url 2024-11-04 02:35:43 +03:00
6b5eda7756 fix: remove the latest api 2024-11-04 02:34:50 +03:00
dbd9e1a070 refactor: change Name to NameIdentifier 2024-11-04 02:33:56 +03:00
0dda336de1 fix: logout for all users to delete cookies 2024-11-04 02:32:13 +03:00
727f5c276e refactor: move files 2024-11-02 23:34:23 +03:00
db70e4dd96 refactor: change log text 2024-11-02 22:10:46 +03:00
6831d9c708 fix: return bool instead 2024-11-02 22:09:40 +03:00
1b24954c3e refactor: change int to string for Id 2024-11-02 20:21:46 +03:00
c5ba1cfcca refactor: transfer two factor method to security 2024-11-02 01:09:15 +03:00
3811d879ab refactor: return next step from security 2024-11-02 01:06:58 +03:00
61dc0a8bc4 feat: add converter for two factor 2024-11-02 01:05:24 +03:00
b3b00aa9e1 refator: move converter to MapperDto 2024-11-02 00:59:37 +03:00
6c9af942f4 refactor: change token to instance token 2024-11-02 00:51:27 +03:00
23f74b3bdf refactor: change name enums 2024-11-02 00:50:10 +03:00
eb272baa38 fix: change cookie name
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m12s
.NET Test Pipeline / build-and-test (push) Successful in 2m44s
2024-10-31 04:23:43 +03:00
a0ff624481 fix: add forgotten changes
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m39s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 3m11s
2024-10-31 04:12:22 +03:00
cd6f25deba refactor: transfer logic
All logic related to token manipulation has been transferred to the AuthService. Also added TOTP 2FA and rethought the logic of logging into the application
2024-10-31 04:12:02 +03:00
0f47a98ad9 feat: return security exception 2024-10-31 04:07:35 +03:00
3279ef594b fix: change current culture to russian for import 2024-10-31 04:06:58 +03:00
5bc729eb66 fix: add an implementation for saving primitive data 2024-10-31 04:05:40 +03:00
5317b7b563 feat: add import to excel
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m33s
.NET Test Pipeline / build-and-test (push) Successful in 2m34s
Made at the request of the customer
2024-10-27 08:25:46 +03:00
665544236f build: add timezone 2024-10-27 07:34:26 +03:00
f203ee71f0 fix: add an additional condition
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m57s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 3m36s
2024-10-27 07:14:30 +03:00
d8dbf1562f refactor: clean code 2024-10-27 06:51:05 +03:00
dead9f89bb feat: remove unused ref campus 2024-10-27 06:50:47 +03:00
8c932cf0be docs: update 2024-10-27 06:09:35 +03:00
80e74b34c1 feat: add background task 2024-10-27 05:42:50 +03:00
b095ca9749 feat: add sync and mapper schedule 2024-10-27 05:41:49 +03:00
8fad070a9c refactor: error logging 2024-10-27 04:36:20 +03:00
6c20713d81 feat: add docker as localhost 2024-10-27 04:09:31 +03:00
fc5ec1fd54 fix: log exception 2024-10-27 03:02:25 +03:00
ed99fce9b8 build: fix unhealth
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m13s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m52s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-10-25 09:38:28 +03:00
2ccc476686 feat: add search professors by name
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m14s
.NET Test Pipeline / build-and-test (push) Successful in 2m41s
2024-10-25 04:44:18 +03:00
84d7b095f0 fix: return altName 2024-10-25 04:43:30 +03:00
4605c81895 docs: add some specification 2024-10-25 04:43:18 +03:00
0788c36bd2 style: remove "id" from text 2024-10-25 04:42:27 +03:00
f5dbc46856 build: add healthcheck for docker
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 5m14s
2024-10-25 02:37:28 +03:00
ebec0a2d2b fix: remove request to /health from log 2024-10-25 02:37:08 +03:00
4fc28378c5 feat: add healthcheck for main project 2024-10-25 02:36:39 +03:00
98ee3c389c feat: add healthcheck for databases 2024-10-25 02:35:36 +03:00
428c2dc3ba refactor: return the modified interfaces for further modification 2024-10-25 02:22:42 +03:00
4970dd782a build: update ref 2024-10-25 01:54:56 +03:00
2e48b0067f build: update ref
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 4m13s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 6m13s
2024-10-09 03:08:15 +03:00
71e8eca5f4 refactor: remove unused param 2024-10-09 03:02:35 +03:00
1f3aaca3cf sec: move token from responce to cookie 2024-10-09 03:00:26 +03:00
b49df925d4 fix: doesn't set expire time for fingerpring 2024-10-09 02:58:52 +03:00
c9ebddf27a fix: remove cache for method 2024-10-09 02:58:09 +03:00
f5739647b2 refactor: remove default produce 200 code 2024-10-07 02:45:38 +03:00
26dbf608b9 refactor: sync namespace 2024-10-07 02:25:36 +03:00
2b89dd07a9 feat: output a token when generating a token for the header 2024-10-07 02:16:20 +03:00
1c981fb7bf refactor: code restructuring 2024-10-07 02:13:35 +03:00
de5dc274d7 build: add cors for debugging frontend 2024-10-07 01:42:00 +03:00
c5ecf00932 revert: remove produced 2024-10-07 01:20:10 +03:00
412751e30f build: update ref 2024-09-30 01:10:37 +03:00
076d6498a1 fix: set to correct produces 2024-09-18 06:05:40 +03:00
88d78dfab3 build: update ref 2024-09-18 06:00:07 +03:00
332e5a013b fix: add forgotten changes
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m35s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 4m2s
2024-09-08 03:24:32 +03:00
e8450400c7 build: update ref
Some checks failed
Build and Deploy Docker Container / build-and-deploy (push) Has been cancelled
.NET Test Pipeline / build-and-test (push) Has been cancelled
2024-09-07 04:56:41 +03:00
65709e1f83 refactor: move files to another namespace 2024-09-07 04:28:07 +03:00
1e204c948c refactor: set cookie name to attribute 2024-09-07 04:19:51 +03:00
0ced152fc9 fix: remove database when check connect to sqlite 2024-09-07 04:19:05 +03:00
6f9bfd3880 fix: set correct password condition 2024-09-07 04:18:04 +03:00
ae0f437e2c fix: remove Regex
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m42s
.NET Test Pipeline / build-and-test (push) Successful in 3m8s
2024-08-27 22:58:05 +03:00
592e8a1b42 feat: add renew password
Some checks failed
Build and Deploy Docker Container / build-and-deploy (push) Failing after 1m34s
.NET Test Pipeline / build-and-test (push) Has been cancelled
2024-08-27 22:52:07 +03:00
a27549092b refactor: move checking password 2024-08-27 22:51:14 +03:00
f27d07fb5a build: upgrade ref 2024-08-27 22:50:21 +03:00
535bafa73a fix: set 8-th mounth instead 9-th
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m41s
.NET Test Pipeline / build-and-test (push) Successful in 2m16s
2024-08-27 21:35:35 +03:00
fba842acc3 feat: add a cache with a short lifetime
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m3s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 4m34s
2024-08-24 04:30:31 +03:00
31087a57c9 feat: add cache for api 2024-08-24 02:27:05 +03:00
24c75e4306 refator: set fingerprint expire instead session mode 2024-08-24 02:26:11 +03:00
dee89b278b refactor: set HttpOnly for debug mode too 2024-08-24 02:25:29 +03:00
565252382c feat: add cache control in response
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m47s
.NET Test Pipeline / build-and-test (push) Successful in 2m43s
2024-08-12 21:54:05 +03:00
80dc2e412c refactor: change Invoke to async 2024-08-12 21:36:07 +03:00
b1250616a7 refactor: use this in static method 2024-08-10 23:11:43 +03:00
c51a9cecc9 fix: storing data protection keys 2024-08-10 23:03:28 +03:00
195 changed files with 6948 additions and 1456 deletions

278
.editorconfig Normal file
View File

@ -0,0 +1,278 @@
# Удалите строку ниже, если вы хотите наследовать параметры .editorconfig из каталогов, расположенных выше в иерархии
root = true
# Файлы C#
[*.cs]
#### Основные параметры EditorConfig ####
# Отступы и интервалы
indent_size = 4
indent_style = space
tab_width = 4
# Предпочтения для новых строк
end_of_line = unset
insert_final_newline = false
#### Действия кода .NET ####
# Члены типа
dotnet_hide_advanced_members = false
dotnet_member_insertion_location = with_other_members_of_the_same_kind
dotnet_property_generation_behavior = prefer_throwing_properties
# Поиск символов
dotnet_search_reference_assemblies = true
#### Рекомендации по написанию кода .NET ####
# Упорядочение Using
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = false
file_header_template = unset
# Предпочтения для this. и Me.
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Параметры использования ключевых слов языка и типов BCL
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Предпочтения для скобок
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Предпочтения модификатора
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Выражения уровень предпочтения
dotnet_prefer_system_hash_code = true
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_namespace_match_folder = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true
dotnet_style_prefer_collection_expression = when_types_loosely_match
dotnet_style_prefer_compound_assignment = true
dotnet_style_prefer_conditional_expression_over_assignment = true
dotnet_style_prefer_conditional_expression_over_return = true
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
# Предпочтения для полей
dotnet_style_readonly_field = true
# Настройки параметров
dotnet_code_quality_unused_parameters = non_public
# Параметры подавления
dotnet_remove_unnecessary_suppression_exclusions = none
# Предпочтения для новых строк
dotnet_style_allow_multiple_blank_lines_experimental = true
dotnet_style_allow_statement_immediately_after_block_experimental = false
#### Рекомендации по написанию кода C# ####
# Предпочтения var
csharp_style_var_elsewhere = true:suggestion
csharp_style_var_for_built_in_types = true:silent
csharp_style_var_when_type_is_apparent = true:silent
# Члены, заданные выражениями
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = true:silent
csharp_style_expression_bodied_methods = true:silent
csharp_style_expression_bodied_operators = true:silent
csharp_style_expression_bodied_properties = true:silent
# Настройки соответствия шаблонов
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_extended_property_pattern = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_prefer_pattern_matching = true:silent
csharp_style_prefer_switch_expression = true:suggestion
# Настройки проверки на null
csharp_style_conditional_delegate_call = true:suggestion
# Предпочтения модификатора
csharp_prefer_static_anonymous_function = true:suggestion
csharp_prefer_static_local_function = true:suggestion
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
csharp_style_prefer_readonly_struct = true:suggestion
csharp_style_prefer_readonly_struct_member = true:suggestion
# Предпочтения для блоков кода
csharp_prefer_braces = when_multiline:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_system_threading_lock = true:suggestion
csharp_style_namespace_declarations = file_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_prefer_top_level_statements = false:silent
# Выражения уровень предпочтения
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
# предпочтения для директивы using
csharp_using_directive_placement = outside_namespace:silent
# Предпочтения для новых строк
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:silent
csharp_style_allow_embedded_statements_on_same_line_experimental = false:silent
#### Правила форматирования C# ####
# Предпочтения для новых строк
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Предпочтения для отступов
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Предпочтения для интервалов
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Предпочтения переноса
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### Стили именования ####
# Правила именования
dotnet_naming_rule.interface_should_be_begins_with_i.severity = error
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = error
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Спецификации символов
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Стили именования
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
[*.{cs,vb}]
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = unset
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_style_namespace_match_folder = true:suggestion
dotnet_code_quality_unused_parameters = non_public:suggestion
dotnet_style_predefined_type_for_member_access = true:silent
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_property = false:silent
dotnet_style_qualification_for_method = false:silent
dotnet_style_qualification_for_event = false:silent
dotnet_style_allow_multiple_blank_lines_experimental = true:silent
dotnet_style_allow_statement_immediately_after_block_experimental = false:silent
dotnet_style_readonly_field = true:suggestion
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent

48
.env
View File

@ -21,6 +21,8 @@ PATH_TO_SAVE=
# The actual sub path to the api
# string
# (optional)
# If the specified path ends with "/api", the system will avoid duplicating "api" in the final URL.
# This allows flexible API structuring, especially when running behind a reverse proxy or in containerized environments.
ACTUAL_SUB_PATH=
# The sub path to the swagger
@ -114,4 +116,48 @@ SECURITY_HASH_TOKEN=
# The size of the salt used to hash passwords
# integer
# The salt is a random value added to the password before hashing to prevent the use of rainbow hash tables and other attacks.
SECURITY_SALT_SIZE=16
SECURITY_SALT_SIZE=16
### OAuth2
#### GOOGLE
# The client ID for Google OAuth
# string
# This is the client ID provided by Google when you register your application for OAuth.
# It's necessary for enabling Google login functionality.
GOOGLE_CLIENT_ID=
# The client secret for Google OAuth
# string
# This is the client secret provided by Google, used alongside the client ID to authenticate your application.
# Make sure to keep it confidential.
GOOGLE_CLIENT_SECRET=
#### Yandex
# The client ID for Yandex OAuth
# string
# This is the client ID provided by Yandex when you register your application for OAuth.
# It's required for enabling Yandex login functionality.
YANDEX_CLIENT_ID=
# The client secret for Yandex OAuth
# string
# This is the client secret provided by Yandex, used alongside the client ID to authenticate your application.
# Keep it confidential to ensure the security of your app.
YANDEX_CLIENT_SECRET=
#### MailRu
# The client ID for MailRu OAuth
# string
# This is the client ID provided by MailRu (Mail.ru Group) when you register your application for OAuth.
# It's necessary for enabling MailRu login functionality.
MAILRU_CLIENT_ID=
# The client secret for MailRu OAuth
# string
# This is the client secret provided by MailRu, used alongside the client ID to authenticate your application.
# Keep it confidential to ensure the security of your app.
MAILRU_CLIENT_SECRET=

View File

@ -1,29 +0,0 @@
name: .NET Test Pipeline
on:
pull_request:
push:
branches:
[master, 'release/*']
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build the solution
run: dotnet build --configuration Release
- name: Run tests
run: dotnet test --configuration Release --no-build --no-restore --verbosity normal

30
.github/workflows/code-analyze.yaml vendored Normal file
View File

@ -0,0 +1,30 @@
name: .NET Test Pipeline
on:
push:
branches:
- master
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checking out
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarScanner for .NET 8 with pull request decoration support
uses: highbyte/sonarscan-dotnet@v2.3.3
with:
sonarProjectKey: $(echo "${{ github.repository }}" | cut -d'/' -f2)
sonarProjectName: $(echo "${{ github.repository }}" | cut -d'/' -f2)
sonarHostname: ${{ secrets.SONAR_HOST_URL }}
dotnetPreBuildCmd: dotnet nuget add source --name="Winsomnia" --username ${{ secrets.NUGET_USERNAME }} --password ${{ secrets.NUGET_PASSWORD }} --store-password-in-clear-text ${{ secrets.NUGET_ADDRESS }} && dotnet format --verify-no-changes --diagnostics -v diag --severity warn
dotnetTestArguments: --logger trx --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
sonarBeginArguments: /d:sonar.cs.opencover.reportsPaths="**/TestResults/**/coverage.opencover.xml" -d:sonar.cs.vstest.reportsPaths="**/TestResults/*.trx"
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@ -1,9 +1,9 @@
name: Build and Deploy Docker Container
name: Build and Deploy Docker Container
on:
push:
branches:
[master, 'release/*']
[master]
jobs:
build-and-deploy:
@ -24,7 +24,7 @@ jobs:
- name: Build and push Docker image
run: |
docker build --build-arg NUGET_USERNAME=${{ secrets.NUGET_USERNAME }} --build-arg NUGET_PASSWORD=${{ secrets.NUGET_PASSWORD }} -t ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest .
docker build --build-arg NUGET_USERNAME=${{ secrets.NUGET_USERNAME }} --build-arg NUGET_PASSWORD=${{ secrets.NUGET_PASSWORD }} --build-arg NUGET_ADDRESS=${{ secrets.NUGET_ADDRESS }} -t ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest .
docker push ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest
- name: Start ssh-agent
@ -52,6 +52,12 @@ jobs:
SECURITY_HASH_SIZE: ${{ secrets.SECURITY_HASH_SIZE }}
SECURITY_HASH_TOKEN: ${{ secrets.SECURITY_HASH_TOKEN }}
SECURITY_SALT_SIZE: ${{ secrets.SECURITY_SALT_SIZE }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
YANDEX_CLIENT_ID: ${{ secrets.YANDEX_CLIENT_ID }}
YANDEX_CLIENT_SECRET: ${{ secrets.YANDEX_CLIENT_SECRET }}
MAILRU_CLIENT_ID: ${{ secrets.MAILRU_CLIENT_ID }}
MAILRU_CLIENT_SECRET: ${{ secrets.MAILRU_CLIENT_SECRET }}
run: |
ssh-keyscan $SSH_HOST >> ~/.ssh/known_hosts
ssh $SSH_USER@$SSH_HOST "
@ -77,6 +83,13 @@ jobs:
-e SECURITY_SALT_SIZE=$SECURITY_SALT_SIZE \
-e ACTUAL_SUB_PATH=api \
-e SWAGGER_SUB_PATH=swagger \
-e TZ=Europe/Moscow \
-e GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \
-e GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \
-e YANDEX_CLIENT_ID=$YANDEX_CLIENT_ID \
-e YANDEX_CLIENT_SECRET=$YANDEX_CLIENT_SECRET \
-e MAILRU_CLIENT_ID=$MAILRU_CLIENT_ID \
-e MAILRU_CLIENT_SECRET=$MAILRU_CLIENT_SECRET \
$DOCKER_IMAGE
"

View File

@ -5,9 +5,9 @@
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<Version>1.0.0-a0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion>
<AssemblyName>Mirea.Api.Dto</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<GenerateDocumentationFile>True</GenerateDocumentationFile>

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Specifies the types of caching mechanisms available.
/// </summary>
public enum CacheType
{
/// <summary>
/// Memcached caching type.
/// </summary>
Memcached,
/// <summary>
/// Redis caching type.
/// </summary>
Redis
}

View File

@ -0,0 +1,24 @@
using System;
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents a date or date range to skip during cron update scheduling.
/// </summary>
public class CronUpdateSkip
{
/// <summary>
/// Gets or sets the start date of the skip range.
/// </summary>
public DateOnly? Start { get; set; }
/// <summary>
/// Gets or sets the end date of the skip range.
/// </summary>
public DateOnly? End { get; set; }
/// <summary>
/// Gets or sets a specific date to skip.
/// </summary>
public DateOnly? Date { get; set; }
}

View File

@ -0,0 +1,22 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Specifies the types of databases supported.
/// </summary>
public enum DatabaseType
{
/// <summary>
/// MySQL database type.
/// </summary>
Mysql,
/// <summary>
/// SQLite database type.
/// </summary>
Sqlite,
/// <summary>
/// PostgreSQL database type.
/// </summary>
PostgresSql
}

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Defines the actions that can be performed with an OAuth token.
/// </summary>
public enum OAuthAction
{
/// <summary>
/// The action to log in the user using the provided OAuth token.
/// </summary>
Login,
/// <summary>
/// The action to bind an OAuth provider to the user's account.
/// </summary>
Bind
}

View File

@ -0,0 +1,22 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents different OAuth providers for authentication.
/// </summary>
public enum OAuthProvider
{
/// <summary>
/// OAuth provider for Google.
/// </summary>
Google,
/// <summary>
/// OAuth provider for Yandex.
/// </summary>
Yandex,
/// <summary>
/// OAuth provider for Mail.ru.
/// </summary>
MailRu
}

View File

@ -0,0 +1,32 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents the password policy settings for user authentication.
/// </summary>
public class PasswordPolicy
{
/// <summary>
/// Gets or sets the minimum length required for a password.
/// </summary>
public int MinimumLength { get; set; }
/// <summary>
/// Gets or sets a value indicating whether at least one letter is required in the password.
/// </summary>
public bool RequireLetter { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the password must contain both lowercase and uppercase letters.
/// </summary>
public bool RequireLettersDifferentCase { get; set; }
/// <summary>
/// Gets or sets a value indicating whether at least one digit is required in the password.
/// </summary>
public bool RequireDigit { get; set; }
/// <summary>
/// Gets or sets a value indicating whether at least one special character is required in the password.
/// </summary>
public bool RequireSpecialCharacter { get; set; }
}

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents the steps required after a login attempt.
/// </summary>
public enum TwoFactorAuthentication
{
/// <summary>
/// No additional steps required; the user is successfully logged in.
/// </summary>
None,
/// <summary>
/// TOTP (Time-based One-Time Password) is required for additional verification.
/// </summary>
TotpRequired,
}

View File

@ -22,4 +22,17 @@ public class LoggingRequest
/// Gets or sets the log file path.
/// </summary>
public string? LogFilePath { get; set; }
/// <summary>
/// Gets or sets the API key for integrating with Seq, a log aggregation service.
/// If provided, logs will be sent to a Seq server using this API key.
/// </summary>
public string? ApiKeySeq { get; set; }
/// <summary>
/// Gets or sets the server URL for the Seq logging service.
/// This property specifies the Seq server endpoint to which logs will be sent.
/// If <see cref="ApiKeySeq"/> is provided, logs will be sent to this server.
/// </summary>
public string? ApiServerSeq { get; set; }
}

View File

@ -10,27 +10,21 @@ public class CreateUserRequest
/// <summary>
/// Gets or sets the email address of the user.
/// </summary>
/// <remarks>
/// The email address is a required field.
/// </remarks>
[Required]
[EmailAddress]
public required string Email { get; set; }
/// <summary>
/// Gets or sets the username of the user.
/// </summary>
/// <remarks>
/// The username is a required field.
/// </remarks>
[Required]
[MinLength(2)]
public required string Username { get; set; }
/// <summary>
/// Gets or sets the password of the user.
/// </summary>
/// <remarks>
/// The password is a required field.
/// </remarks>
[Required]
[MinLength(2)]
public required string Password { get; set; }
}
}

View File

@ -8,30 +8,30 @@ public class ScheduleRequest
/// <summary>
/// Gets or sets an array of group IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Groups { get; set; } = null;
/// <summary>
/// Gets or sets a value indicating whether to retrieve schedules for even weeks.
/// </summary>
/// <remarks>This property can contain null.</remarks>
public bool? IsEven { get; set; } = null;
/// <summary>
/// Gets or sets an array of discipline IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Disciplines { get; set; } = null;
/// <summary>
/// Gets or sets an array of professor IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Professors { get; set; } = null;
/// <summary>
/// Gets or sets an array of lecture hall IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? LectureHalls { get; set; } = null;
}
/// <summary>
/// Gets or sets an array of lesson type IDs.
/// </summary>
public int[]? LessonType { get; set; } = null;
}

View File

@ -0,0 +1,19 @@
using Mirea.Api.Dto.Common;
namespace Mirea.Api.Dto.Requests;
/// <summary>
/// Represents a request for verifying two-factor authentication.
/// </summary>
public class TwoFactorAuthRequest
{
/// <summary>
/// Gets or sets the two-factor authentication code provided by the user.
/// </summary>
public required string Code { get; set; }
/// <summary>
/// Gets or sets the type of the two-factor authentication method used (e.g., TOTP, Email).
/// </summary>
public TwoFactorAuthentication Method { get; set; }
}

View File

@ -0,0 +1,27 @@
using Mirea.Api.Dto.Common;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the response containing information about available OAuth providers.
/// </summary>
public class AvailableOAuthProvidersResponse
{
/// <summary>
/// Gets or sets the name of the OAuth provider.
/// </summary>
[Required]
public required string ProviderName { get; set; }
/// <summary>
/// Gets or sets the enum value representing the OAuth provider.
/// </summary>
public OAuthProvider Provider { get; set; }
/// <summary>
/// Gets or sets the redirect URL for the OAuth provider's authorization process.
/// </summary>
[Required]
public required string Redirect { get; set; }
}

View File

@ -28,4 +28,4 @@ public class CampusDetailsResponse
/// Gets or sets the address of the campus (optional).
/// </summary>
public string? Address { get; set; }
}
}

View File

@ -0,0 +1,29 @@
using Mirea.Api.Dto.Common;
namespace Mirea.Api.Dto.Responses.Configuration;
/// <summary>
/// Represents a response containing cache configuration details.
/// </summary>
public class CacheResponse
{
/// <summary>
/// Gets or sets the type of cache database.
/// </summary>
public CacheType Type { get; set; }
/// <summary>
/// Gets or sets the server address.
/// </summary>
public string? Server { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
public int Port { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string? Password { get; set; }
}

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses.Configuration;
/// <summary>
/// Represents the response containing the cron update schedule and the next scheduled task dates.
/// </summary>
public class CronUpdateScheduleResponse
{
/// <summary>
/// Gets or sets the cron expression representing the update schedule.
/// </summary>
[Required]
public required string Cron { get; set; }
/// <summary>
/// Gets or sets the list of next scheduled task dates based on the cron expression.
/// </summary>
[Required]
public required List<DateTime> NextStart { get; set; }
}

View File

@ -0,0 +1,49 @@
using Mirea.Api.Dto.Common;
namespace Mirea.Api.Dto.Responses.Configuration;
/// <summary>
/// Represents a response containing database configuration details.
/// </summary>
public class DatabaseResponse
{
/// <summary>
/// Gets or sets the type of database.
/// </summary>
public DatabaseType Type { get; set; }
/// <summary>
/// Gets or sets the server address.
/// </summary>
public string? Server { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
public int Port { get; set; }
/// <summary>
/// Gets or sets the database name.
/// </summary>
public string? Database { get; set; }
/// <summary>
/// Gets or sets the username.
/// </summary>
public string? User { get; set; }
/// <summary>
/// Gets or sets a value indicating whether SSL is enabled.
/// </summary>
public bool Ssl { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Gets or sets the path to database. Only for Sqlite
/// </summary>
public string? PathToDatabase { get; set; }
}

View File

@ -1,22 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// A class for providing information about an error
/// </summary>
public class ErrorResponse
{
/// <summary>
/// The text or translation code of the error. This field may not contain information in specific scenarios.
/// For example, it might be empty for HTTP 204 responses where no content is returned or if the validation texts have not been configured.
/// </summary>
[Required]
public required string Error { get; set; }
/// <summary>
/// In addition to returning the response code in the header, it is also duplicated in this field.
/// Represents the HTTP response code.
/// </summary>
[Required]
public required int Code { get; set; }
}

View File

@ -1,36 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents detailed information about a faculty.
/// </summary>
public class FacultyDetailsResponse
{
/// <summary>
/// Gets or sets the unique identifier of the faculty.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the faculty.
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the unique identifier of the campus to which the faculty belongs (optional).
/// </summary>
public int? CampusId { get; set; }
/// <summary>
/// Gets or sets the name of the campus to which the faculty belongs (optional).
/// </summary>
public string? CampusName { get; set; }
/// <summary>
/// Gets or sets the code name of the campus to which the faculty belongs (optional).
/// </summary>
public string? CampusCode { get; set; }
}

View File

@ -3,7 +3,7 @@
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents basic information about a faculty.
/// Represents information about a faculty.
/// </summary>
public class FacultyResponse
{
@ -18,9 +18,4 @@ public class FacultyResponse
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the unique identifier of the campus to which the faculty belongs (optional).
/// </summary>
public int? CampusId { get; set; }
}

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents information about a lesson type.
/// </summary>
public class LessonTypeResponse
{
/// <summary>
/// Gets or sets the unique identifier of the lesson type.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the lesson type.
/// </summary>
[Required]
public required string Name { get; set; }
}

View File

@ -114,4 +114,4 @@ public class ScheduleResponse
/// Gets or sets the links to online meetings for the schedule entry.
/// </summary>
public required IEnumerable<string?> LinkToMeet { get; set; }
}
}

View File

@ -1,23 +0,0 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Provides a JWT and RT token.
/// </summary>
public class TokenResponse
{
/// <summary>
/// A JWT token for accessing protected resources.
/// </summary>
[Required]
public required string AccessToken { get; set; }
/// <summary>
/// The date and time when the JWT token expires.
/// </summary>
/// <remarks>After this date, a new JWT token must be requested.</remarks>
[Required]
public required DateTime ExpiresIn { get; set; }
}

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the response containing the TOTP (Time-Based One-Time Password) key details.
/// </summary>
public class TotpKeyResponse
{
/// <summary>
/// Gets or sets the secret key used for TOTP generation.
/// </summary>
public required string Secret { get; set; }
/// <summary>
/// Gets or sets the image (QR code) representing the TOTP key.
/// </summary>
public required string Image { get; set; }
}

View File

@ -0,0 +1,35 @@
using Mirea.Api.Dto.Common;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents a response containing user information.
/// </summary>
public class UserResponse
{
/// <summary>
/// Gets or sets the email address of the user.
/// </summary>
[Required]
public required string Email { get; set; }
/// <summary>
/// Gets or sets the username of the user.
/// </summary>
[Required]
public required string Username { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user has two-factor authentication enabled.
/// </summary>
[Required]
public bool TwoFactorAuthenticatorEnabled { get; set; }
/// <summary>
/// Gets or sets a collection of OAuth providers used by the user.
/// </summary>
[Required]
public required IEnumerable<OAuthProvider> UsedOAuthProviders { get; set; }
}

View File

@ -8,14 +8,15 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Elements of the solution", "Elements of the solution", "{3E087889-A4A0-4A55-A07D-7D149A5BC928}"
ProjectSection(SolutionItems) = preProject
.dockerignore = .dockerignore
.editorconfig = .editorconfig
.env = .env
.gitattributes = .gitattributes
.gitignore = .gitignore
.gitea\workflows\deploy-stage.yaml = .gitea\workflows\deploy-stage.yaml
.github\workflows\code-analyze.yaml = .github\workflows\code-analyze.yaml
Dockerfile = Dockerfile
LICENSE.txt = LICENSE.txt
README.md = README.md
.gitea\workflows\test.yaml = .gitea\workflows\test.yaml
.github\workflows\release-version.yml = .github\workflows\release-version.yml
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApiDto", "ApiDto\ApiDto.csproj", "{0335FA36-E137-453F-853B-916674C168FE}"

View File

@ -1,18 +1,26 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
LABEL company="Winsomnia"
LABEL maintainer.name="Wesser" maintainer.email="support@winsomnia.net"
WORKDIR /app
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl --fail http://localhost:8080/health || exit 1
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
ARG NUGET_USERNAME
ARG NUGET_PASSWORD
ARG NUGET_ADDRESS
ENV NUGET_USERNAME=$NUGET_USERNAME
ENV NUGET_PASSWORD=$NUGET_PASSWORD
ENV NUGET_ADDRESS=$NUGET_ADDRESS
RUN dotnet restore ./Backend.sln --configfile nuget.config
RUN dotnet nuget add source --name="Winsomnia" --username ${NUGET_USERNAME} --store-password-in-clear-text --password ${NUGET_PASSWORD} ${NUGET_ADDRESS}
RUN dotnet restore ./Backend.sln
WORKDIR /app
WORKDIR /src
RUN dotnet publish ./Endpoint/Endpoint.csproj -c Release --self-contained false -p:PublishSingleFile=false -o /app

View File

@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.Dto.Responses;
using System;
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
public class BadRequestResponseAttribute() : ProducesResponseTypeAttribute(typeof(ErrorResponse), StatusCodes.Status400BadRequest);
public class BadRequestResponseAttribute() : ProducesResponseTypeAttribute(typeof(ProblemDetails), StatusCodes.Status400BadRequest);

View File

@ -0,0 +1,26 @@
using System;
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)]
public class CacheMaxAgeAttribute : Attribute
{
public int MaxAge { get; }
public CacheMaxAgeAttribute(int days = 0, int hours = 0, int minutes = 0)
{
MaxAge = (int)new TimeSpan(days, hours, minutes, 0).TotalSeconds;
}
public CacheMaxAgeAttribute(int minutes) : this(0, 0, minutes)
{
}
public CacheMaxAgeAttribute(bool usingSetting = false)
{
if (usingSetting)
MaxAge = -1;
else
MaxAge = 0;
}
}

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System;
using System.Net;
namespace Mirea.Api.Endpoint.Common.Attributes;
@ -9,11 +10,21 @@ public class LocalhostAttribute : ActionFilterAttribute
public override void OnActionExecuting(ActionExecutingContext context)
{
var ip = context.HttpContext.Connection.RemoteIpAddress;
if (ip == null || !IPAddress.IsLoopback(ip))
if (ip == null)
{
context.Result = new UnauthorizedResult();
return;
}
base.OnActionExecuting(context);
var isRunningInContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")?.ToLower() == "true";
if (IPAddress.IsLoopback(ip) || (isRunningInContainer && ip.ToString().StartsWith("172.")))
{
base.OnActionExecuting(context);
return;
}
context.Result = new UnauthorizedResult();
}
}

View File

@ -2,5 +2,5 @@
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)]
public class MaintenanceModeIgnoreAttribute : Attribute;

View File

@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.Dto.Responses;
using System;
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
public class NotFoundResponseAttribute() : ProducesResponseTypeAttribute(typeof(ErrorResponse), StatusCodes.Status404NotFound);
public class NotFoundResponseAttribute() : ProducesResponseTypeAttribute(typeof(ProblemDetails), StatusCodes.Status404NotFound);

View File

@ -9,10 +9,11 @@ namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Method)]
public class TokenAuthenticationAttribute : Attribute, IActionFilter
{
public const string AuthToken = "AuthToken";
public void OnActionExecuting(ActionExecutingContext context)
{
var setupToken = context.HttpContext.RequestServices.GetRequiredService<ISetupToken>();
if (!context.HttpContext.Request.Cookies.TryGetValue("AuthToken", out string? tokenFromCookie))
if (!context.HttpContext.Request.Cookies.TryGetValue(AuthToken, out var tokenFromCookie))
{
context.Result = new UnauthorizedResult();
return;

View File

@ -0,0 +1,8 @@
using System;
namespace Mirea.Api.Endpoint.Common.Exceptions;
public class ServerUnavailableException(string message, bool addRetryAfter) : Exception(message)
{
public bool AddRetryAfter { get; } = addRetryAfter;
}

View File

@ -3,6 +3,5 @@
public interface IMaintenanceModeNotConfigureService
{
bool IsMaintenanceMode { get; }
void DisableMaintenanceMode();
}

View File

@ -3,8 +3,6 @@
public interface IMaintenanceModeService
{
bool IsMaintenanceMode { get; }
void EnableMaintenanceMode();
void DisableMaintenanceMode();
}

View File

@ -0,0 +1,27 @@
using Mirea.Api.Dto.Responses;
using Mirea.Api.Security.Common.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class AvailableProvidersConverter
{
public static Dto.Common.OAuthProvider ConvertToDto(this OAuthProvider provider) =>
provider switch
{
OAuthProvider.Google => Dto.Common.OAuthProvider.Google,
OAuthProvider.Yandex => Dto.Common.OAuthProvider.Yandex,
OAuthProvider.MailRu => Dto.Common.OAuthProvider.MailRu,
_ => throw new ArgumentOutOfRangeException(nameof(provider), provider, null)
};
public static List<AvailableOAuthProvidersResponse> ConvertToDto(this IEnumerable<(OAuthProvider Provider, Uri Redirect)> data) =>
data.Select(x => new AvailableOAuthProvidersResponse()
{
ProviderName = Enum.GetName(x.Provider)!,
Provider = x.Provider.ConvertToDto(),
Redirect = x.Redirect.ToString()
}).ToList();
}

View File

@ -0,0 +1,19 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class CronUpdateSkipConverter
{
public static List<Dto.Common.CronUpdateSkip> ConvertToDto(this IEnumerable<ScheduleSettings.CronUpdateSkip> pairPeriod) =>
pairPeriod.Select(x => new Dto.Common.CronUpdateSkip()
{
Start = x.Start,
End = x.End,
Date = x.Date
}).ToList();
public static List<ScheduleSettings.CronUpdateSkip> ConvertFromDto(this IEnumerable<Dto.Common.CronUpdateSkip> pairPeriod) =>
pairPeriod.Select(x => x.Get()).ToList();
}

View File

@ -1,13 +1,14 @@
using Mirea.Api.Endpoint.Configuration.General.Settings;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.Services;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class PairPeriodTimeConverter
{
public static Dictionary<int, Dto.Common.PairPeriodTime> ConvertToDto(this IDictionary<int, ScheduleSettings.PairPeriodTime> pairPeriod) =>
pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new Dto.Common.PairPeriodTime { Start = kvp.Value.Start, End = kvp.Value.End });
public static Dictionary<int, ScheduleSettings.PairPeriodTime> ConvertFromDto(this IDictionary<int, Dto.Common.PairPeriodTime> pairPeriod) => pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new ScheduleSettings.PairPeriodTime(kvp.Value.Start, kvp.Value.End));
}
public static Dictionary<int, ScheduleSettings.PairPeriodTime> ConvertFromDto(this IDictionary<int, Dto.Common.PairPeriodTime> pairPeriod) =>
pairPeriod.ToDictionary(kvp => kvp.Key, kvp => new ScheduleSettings.PairPeriodTime(kvp.Value.Start, kvp.Value.End));
}

View File

@ -0,0 +1,23 @@
using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class PasswordPolicyConverter
{
public static Security.Common.Model.PasswordPolicy ConvertFromDto(this PasswordPolicy policy) =>
new(policy.MinimumLength,
policy.RequireLetter,
policy.RequireLettersDifferentCase,
policy.RequireDigit,
policy.RequireSpecialCharacter);
public static PasswordPolicy ConvertToDto(this Security.Common.Model.PasswordPolicy policy) =>
new()
{
MinimumLength = policy.MinimumLength,
RequireLetter = policy.RequireLetter,
RequireDigit = policy.RequireDigit,
RequireSpecialCharacter = policy.RequireSpecialCharacter,
RequireLettersDifferentCase = policy.RequireLettersDifferentCase
};
}

View File

@ -0,0 +1,23 @@
using Mirea.Api.Dto.Common;
using System;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class TwoFactorAuthenticationConverter
{
public static TwoFactorAuthentication ConvertToDto(this Security.Common.Model.TwoFactorAuthenticator authenticator) =>
authenticator switch
{
Security.Common.Model.TwoFactorAuthenticator.None => TwoFactorAuthentication.None,
Security.Common.Model.TwoFactorAuthenticator.Totp => TwoFactorAuthentication.TotpRequired,
_ => throw new ArgumentOutOfRangeException(nameof(authenticator), authenticator, null)
};
public static Security.Common.Model.TwoFactorAuthenticator ConvertFromDto(this TwoFactorAuthentication authentication) =>
authentication switch
{
TwoFactorAuthentication.None => Security.Common.Model.TwoFactorAuthenticator.None,
TwoFactorAuthentication.TotpRequired => Security.Common.Model.TwoFactorAuthenticator.Totp,
_ => throw new ArgumentOutOfRangeException(nameof(authentication), authentication, null)
};
}

View File

@ -0,0 +1,20 @@
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Security.Common.Model;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class UserConverter
{
public static User ConvertToSecurity(this Admin data) =>
new()
{
Id = 1.ToString(),
Email = data.Email,
Username = data.Username,
PasswordHash = data.PasswordHash,
Salt = data.Salt,
SecondFactorToken = data.Secret,
TwoFactorAuthenticator = data.TwoFactorAuthenticator,
OAuthProviders = data.OAuthProviders
};
}

View File

@ -0,0 +1,78 @@
using Cronos;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.Services;
public static class CronUpdateSkipService
{
public static ScheduleSettings.CronUpdateSkip Get(this Dto.Common.CronUpdateSkip date)
{
if (date.Date.HasValue)
return new ScheduleSettings.CronUpdateSkip(date.Date.Value);
if (date is { Start: not null, End: not null })
return new ScheduleSettings.CronUpdateSkip(date.Start.Value, date.End.Value);
throw new ArgumentException("It is impossible to create a structure because it has incorrect values.");
}
public static List<ScheduleSettings.CronUpdateSkip> FilterDateEntry(this List<ScheduleSettings.CronUpdateSkip> data, DateOnly? currentDate = null)
{
currentDate ??= DateOnly.FromDateTime(DateTime.Now);
return data.OrderBy(x => x.End ?? x.Date)
.Where(x => x.Date == currentDate || (x.Start <= currentDate && x.End >= currentDate))
.ToList();
}
public static List<ScheduleSettings.CronUpdateSkip> FilterDateEntry(this List<ScheduleSettings.CronUpdateSkip> data, DateTime? currentDate = null) =>
data.FilterDateEntry(DateOnly.FromDateTime(currentDate ?? DateTime.Now));
public static List<ScheduleSettings.CronUpdateSkip> Filter(this List<ScheduleSettings.CronUpdateSkip> data, DateOnly? currentDate = null)
{
currentDate ??= DateOnly.FromDateTime(DateTime.Now);
return data.Where(x => x.Date >= currentDate || x.End >= currentDate)
.OrderBy(x => x.End ?? x.Date)
.ToList();
}
public static List<DateTimeOffset> GetNextTask(this List<ScheduleSettings.CronUpdateSkip> data,
CronExpression expression, int depth = 1, DateOnly? currentDate = null)
{
if (depth <= 0)
return [];
DateTimeOffset nextRunTime = (currentDate?.ToDateTime(TimeOnly.MinValue) ?? DateTime.Now).ToUniversalTime();
List<DateTimeOffset> result = [];
do
{
var lastSkippedEntry = data.FilterDateEntry(nextRunTime.DateTime).LastOrDefault();
if (lastSkippedEntry is { Start: not null, End: not null })
nextRunTime = lastSkippedEntry.End.Value.ToDateTime(TimeOnly.MinValue).AddDays(1);
else if (lastSkippedEntry.Date.HasValue)
nextRunTime = lastSkippedEntry.Date.Value.ToDateTime(TimeOnly.MinValue).AddDays(1);
var nextOccurrence = expression.GetNextOccurrence(nextRunTime.AddMinutes(-1), TimeZoneInfo.Local);
if (!nextOccurrence.HasValue)
return result;
if (data.FilterDateEntry(nextOccurrence.Value.DateTime).Count != 0)
{
nextRunTime = nextOccurrence.Value.AddDays(1);
continue;
}
result.Add(nextOccurrence.Value.ToLocalTime());
nextRunTime = nextOccurrence.Value.AddMinutes(1);
} while (result.Count < depth);
return result;
}
}

View File

@ -0,0 +1,10 @@
using System;
namespace Mirea.Api.Endpoint.Common.Services;
public static class ScheduleSyncManager
{
public static event Action? OnForceSyncRequested;
public static void RequestForceSync() =>
OnForceSyncRequested?.Invoke();
}

View File

@ -9,7 +9,8 @@ namespace Mirea.Api.Endpoint.Common.Services.Security;
public class DistributedCacheService(IDistributedCache cache) : ICacheService
{
public async Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default)
public async Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null,
CancellationToken cancellationToken = default)
{
var options = new DistributedCacheEntryOptions
{
@ -17,12 +18,43 @@ public class DistributedCacheService(IDistributedCache cache) : ICacheService
SlidingExpiration = slidingExpiration
};
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
if (type.IsPrimitive || type == typeof(string) || type == typeof(DateTime))
{
await cache.SetStringAsync(key, value?.ToString() ?? string.Empty, options, cancellationToken);
return;
}
var serializedValue = value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value);
await cache.SetAsync(key, serializedValue, options, cancellationToken);
}
public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
if (type.IsPrimitive || type == typeof(string) || type == typeof(DateTime))
{
var primitiveValue = await cache.GetStringAsync(key, cancellationToken);
if (string.IsNullOrEmpty(primitiveValue))
return default;
if (type == typeof(string))
return (T?)(object?)primitiveValue;
var tryParseMethod = type.GetMethod("TryParse", [typeof(string), type.MakeByRefType()])
?? throw new NotSupportedException($"Type {type.Name} does not support TryParse.");
var parameters = new[] { primitiveValue, Activator.CreateInstance(type) };
var success = (bool)tryParseMethod.Invoke(null, parameters)!;
if (success)
return (T)parameters[1]!;
return default;
}
var cachedValue = await cache.GetAsync(key, cancellationToken);
return cachedValue == null ? default : JsonSerializer.Deserialize<T>(cachedValue);
}

View File

@ -33,7 +33,7 @@ public class JwtTokenService : IAccessToken
SigningCredentials = signingCredentials,
Subject = new ClaimsIdentity(
[
new Claim(ClaimTypes.Name, userId),
new Claim(ClaimTypes.NameIdentifier, userId),
// todo: get role by userId
new Claim(ClaimTypes.Role, "")
]),

View File

@ -9,7 +9,8 @@ namespace Mirea.Api.Endpoint.Common.Services.Security;
public class MemoryCacheService(IMemoryCache cache) : ICacheService
{
public Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default)
public Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null,
CancellationToken cancellationToken = default)
{
var options = new MemoryCacheEntryOptions
{
@ -17,17 +18,41 @@ public class MemoryCacheService(IMemoryCache cache) : ICacheService
SlidingExpiration = slidingExpiration
};
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
if (type.IsPrimitive || type == typeof(string) || type == typeof(DateTime))
{
cache.Set(key, value?.ToString() ?? string.Empty, options);
return Task.CompletedTask;
}
cache.Set(key, value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value), options);
return Task.CompletedTask;
}
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
return Task.FromResult(
cache.TryGetValue(key, out byte[]? value) ?
JsonSerializer.Deserialize<T>(value) :
default
);
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
if (!type.IsPrimitive && type != typeof(string) && type != typeof(DateTime))
return Task.FromResult(
cache.TryGetValue(key, out byte[]? value) ? JsonSerializer.Deserialize<T>(value) : default
);
var primitiveValue = cache.Get(key);
if (string.IsNullOrEmpty(primitiveValue?.ToString()))
return Task.FromResult<T?>(default);
if (type == typeof(string))
return Task.FromResult((T?)primitiveValue);
var tryParseMethod = type.GetMethod("TryParse", [typeof(string), type.MakeByRefType()])
?? throw new NotSupportedException($"Type {type.Name} does not support TryParse.");
var parameters = new[] { primitiveValue, Activator.CreateInstance(type) };
var success = (bool)tryParseMethod.Invoke(null, parameters)!;
return success ? Task.FromResult((T?)parameters[1]) : Task.FromResult<T?>(default);
}
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)

View File

@ -6,8 +6,14 @@ namespace Mirea.Api.Endpoint.Common.Services;
public static class UrlHelper
{
public static string CurrentDomain(HttpContext context) =>
public static string GetCurrentScheme(this HttpContext context) =>
context.Request.Headers["X-Forwarded-Proto"].FirstOrDefault() ?? context.Request.Scheme;
public static string GetCurrentDomain(this HttpContext context) =>
context.Request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? context.Request.Host.Host;
public static int? GetCurrentPort(this HttpContext context) =>
string.IsNullOrEmpty(context.Request.Headers["X-Forwarded-Port"].FirstOrDefault()) ? context.Request.Host.Port :
int.Parse(context.Request.Headers["X-Forwarded-Port"].First()!);
private static string CreateSubPath(string? path)
{
@ -30,17 +36,23 @@ public static class UrlHelper
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < parts.Length; i++)
{
if (!parts[i].Equals("api", StringComparison.CurrentCultureIgnoreCase)) continue;
parts = parts.Take(i).Concat(parts.Skip(i + 1)).ToArray();
break;
}
if (parts[^1].Equals("api", StringComparison.CurrentCultureIgnoreCase))
parts = parts.Take(parts.Length - 1).ToArray();
return CreateSubPath(string.Join("/", parts));
}
}
public static string GetSubPathSwagger => CreateSubPath(Environment.GetEnvironmentVariable("SWAGGER_SUB_PATH"));
public static string GetApiUrl(this HttpContext context, string apiPath = "")
{
var scheme = GetCurrentScheme(context);
var domain = GetCurrentDomain(context).TrimEnd('/').Replace("localhost", "127.0.0.1");
var port = GetCurrentPort(context);
var portString = port.HasValue && port != 80 && port != 443 ? $":{port}" : string.Empty;
return $"{scheme}://{domain}{portString}{GetSubPathWithoutFirstApiName}{apiPath.Trim('/')}";
}
}

View File

@ -1,25 +0,0 @@
using Mirea.Api.Endpoint.Common.Services;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Mirea.Api.Endpoint.Common.Settings;
public class Admin : ISaveSettings
{
[JsonIgnore] private const string FileName = "admin.json";
[JsonIgnore]
public static string FilePath => PathBuilder.Combine(FileName);
public required string Username { get; set; }
public required string Email { get; set; }
public required string PasswordHash { get; set; }
public required string Salt { get; set; }
public void SaveSetting()
{
File.WriteAllText(FilePath, JsonSerializer.Serialize(this));
}
}

View File

@ -1,24 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Mirea.Api.Endpoint.Common.Settings;
using Mirea.Api.Endpoint.Configuration.General.Settings;
namespace Mirea.Api.Endpoint.Configuration.AppConfig;
public static class CacheConfiguration
{
public static IServiceCollection AddCustomRedis(this IServiceCollection services, IConfiguration configuration)
{
var cache = configuration.Get<GeneralConfig>()?.CacheSettings;
if (cache?.TypeDatabase == CacheSettings.CacheEnum.Redis)
{
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = cache.ConnectionString;
options.InstanceName = "mirea_";
});
}
return services;
}
}

View File

@ -0,0 +1,149 @@
using Cronos;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Sync;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.Core.BackgroundTasks;
public class ScheduleSyncService : IHostedService, IDisposable
{
private Timer? _timer;
private string _cronUpdate;
private List<ScheduleSettings.CronUpdateSkip> _cronUpdateSkip;
private readonly ILogger<ScheduleSyncService> _logger;
private CancellationTokenSource _cancellationTokenSource = new();
private readonly IServiceProvider _serviceProvider;
private readonly IDisposable? _onChangeUpdateCron;
public ScheduleSyncService(IOptionsMonitor<GeneralConfig> generalConfigMonitor, ILogger<ScheduleSyncService> logger, IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
_cronUpdate = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSchedule;
_cronUpdateSkip = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSkipDateList;
ScheduleSyncManager.OnForceSyncRequested += OnForceSyncRequested;
_onChangeUpdateCron = generalConfigMonitor.OnChange((config) =>
{
var updated = false;
if (config.ScheduleSettings?.CronUpdateSchedule != null && _cronUpdate != config.ScheduleSettings.CronUpdateSchedule)
{
_cronUpdate = config.ScheduleSettings.CronUpdateSchedule;
updated = true;
}
if (config.ScheduleSettings?.CronUpdateSkipDateList != null && !config.ScheduleSettings.CronUpdateSkipDateList.SequenceEqual(_cronUpdateSkip))
{
_cronUpdateSkip = config.ScheduleSettings.CronUpdateSkipDateList
.OrderBy(x => x.End ?? x.Date)
.ToList();
updated = true;
}
if (updated)
OnUpdateIntervalRequested();
});
}
private void OnForceSyncRequested()
{
_logger.LogInformation("It was requested to synchronize the data immediately.");
StopAsync(CancellationToken.None).ContinueWith(_ =>
{
_cancellationTokenSource = new CancellationTokenSource();
ExecuteTask(null);
});
}
private void OnUpdateIntervalRequested()
{
_logger.LogInformation("It was requested to update the time interval immediately.");
StopAsync(CancellationToken.None).ContinueWith(_ =>
{
StartAsync(CancellationToken.None);
});
}
private void ScheduleNextRun()
{
if (string.IsNullOrEmpty(_cronUpdate))
{
_logger.LogWarning("Cron expression is not set. The scheduled task will not run.");
return;
}
var expression = CronExpression.Parse(_cronUpdate);
var nextRunTime = _cronUpdateSkip.GetNextTask(expression).FirstOrDefault();
if (nextRunTime == default)
{
_logger.LogWarning("No next run time found. The task will not be scheduled. Timezone: {TimeZone}",
TimeZoneInfo.Local.DisplayName);
return;
}
_logger.LogInformation("Next task run in {Time}", nextRunTime.ToString("G"));
var delay = (nextRunTime - DateTimeOffset.Now).TotalMilliseconds;
// The chance is small, but it's better to check
if (delay <= 0)
delay = 1;
_cancellationTokenSource = new CancellationTokenSource();
_timer = new Timer(ExecuteTask, null, delay > int.MaxValue ? int.MaxValue : (int)delay, Timeout.Infinite);
}
private async void ExecuteTask(object? state)
{
try
{
using var scope = _serviceProvider.CreateScope();
var syncService = ActivatorUtilities.GetServiceOrCreateInstance<ScheduleSynchronizer>(scope.ServiceProvider);
await syncService.StartSync(_cancellationTokenSource.Token);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred during schedule synchronization.");
}
finally
{
ScheduleNextRun();
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
ScheduleNextRun();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_cancellationTokenSource.Cancel();
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose()
{
StopAsync(CancellationToken.None).GetAwaiter().GetResult();
_timer?.Dispose();
ScheduleSyncManager.OnForceSyncRequested -= OnForceSyncRequested;
_onChangeUpdateCron?.Dispose();
_cancellationTokenSource.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@ -0,0 +1,81 @@
using Cronos;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Configuration.Model;
using System;
using System.Reflection;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
public class CacheMaxAgeMiddleware(RequestDelegate next, IServiceProvider serviceProvider)
{
public async Task InvokeAsync(HttpContext context)
{
if (!context.Response.StatusCode.ToString().StartsWith('2'))
{
await next(context);
return;
}
var endpoint = context.GetEndpoint();
var actionDescriptor = endpoint?.Metadata.GetMetadata<Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor>();
if (actionDescriptor == null)
{
await next(context);
return;
}
var controllerType = actionDescriptor.ControllerTypeInfo;
var methodInfo = actionDescriptor.MethodInfo;
var maxAgeAttribute = methodInfo.GetCustomAttribute<CacheMaxAgeAttribute>() ?? controllerType.GetCustomAttribute<CacheMaxAgeAttribute>();
if (maxAgeAttribute == null)
{
await next(context);
return;
}
switch (maxAgeAttribute.MaxAge)
{
case < 0:
{
DateTime? nextDate;
var now = DateTime.UtcNow;
using (var scope = serviceProvider.CreateScope())
{
var updateCronString = scope.ServiceProvider.GetRequiredService<IOptionsSnapshot<GeneralConfig>>().Value.ScheduleSettings?.CronUpdateSchedule;
if (string.IsNullOrEmpty(updateCronString) ||
!CronExpression.TryParse(updateCronString, CronFormat.Standard, out var updateCron))
{
await next(context);
return;
}
nextDate = updateCron.GetNextOccurrence(now);
}
if (!nextDate.HasValue)
{
await next(context);
return;
}
context.Response.Headers.CacheControl = "max-age=" + (int)(nextDate.Value - now).TotalSeconds;
break;
}
case > 0:
context.Response.Headers.CacheControl = "max-age=" + maxAgeAttribute.MaxAge;
break;
}
await next(context);
}
}

View File

@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Http;
using Mirea.Api.Security.Common;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
public class CookieAuthorizationMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Cookies.ContainsKey(CookieNames.AccessToken))
context.Request.Headers.Authorization = "Bearer " + context.Request.Cookies[CookieNames.AccessToken];
await next(context);
}
}

View File

@ -0,0 +1,94 @@
using FluentValidation;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Mirea.Api.DataAccess.Application.Common.Exceptions;
using Mirea.Api.Endpoint.Common.Exceptions;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security;
using System.Text.Json;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
public class CustomExceptionHandlerMiddleware(RequestDelegate next, ILogger<CustomExceptionHandlerMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception exception)
{
await HandleExceptionAsync(context, exception);
}
}
private Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var traceId = Activity.Current?.Id ?? context.TraceIdentifier;
var problemDetails = new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
Title = "An unexpected error occurred.",
Status = StatusCodes.Status500InternalServerError,
Detail = "Please provide this traceId to the administrator for further investigation.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", traceId }
}
};
switch (exception)
{
case ValidationException validationException:
problemDetails.Status = StatusCodes.Status400BadRequest;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1";
problemDetails.Title = "Validation errors occurred.";
problemDetails.Extensions = new Dictionary<string, object?>
{
{ "errors", validationException.Errors.Select(e => e.ErrorMessage).ToArray() },
{ "traceId", traceId }
};
break;
case NotFoundException:
problemDetails.Status = StatusCodes.Status404NotFound;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.4";
problemDetails.Title = "Resource not found.";
break;
case ControllerArgumentException:
problemDetails.Status = StatusCodes.Status400BadRequest;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1";
problemDetails.Title = "Invalid arguments provided.";
problemDetails.Detail = exception.Message;
break;
case SecurityException:
problemDetails.Status = StatusCodes.Status401Unauthorized;
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
problemDetails.Title = "Unauthorized access.";
problemDetails.Detail = exception.Message;
break;
case ServerUnavailableException unavailableException:
problemDetails.Status = StatusCodes.Status503ServiceUnavailable;
problemDetails.Type = "https://datatracker.ietf.org/doc/html/rfc9110#section-15.6.4";
problemDetails.Title = "Server unavailable.";
problemDetails.Detail = unavailableException.Message;
if (unavailableException.AddRetryAfter)
context.Response.Headers.RetryAfter = "600";
break;
}
if (problemDetails.Status == StatusCodes.Status500InternalServerError)
logger.LogError(exception, "Internal server error when processing the request");
context.Response.ContentType = "application/json";
context.Response.StatusCode = problemDetails.Status.Value;
return context.Response.WriteAsync(JsonSerializer.Serialize(problemDetails));
}
}

View File

@ -2,11 +2,11 @@
using Mirea.Api.Security.Common.Interfaces;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Middleware;
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
public class JwtRevocationMiddleware(RequestDelegate next)
{
public async Task Invoke(HttpContext context, IRevokedToken revokedTokenStore)
public async Task InvokeAsync(HttpContext context, IRevokedToken revokedTokenStore)
{
if (context.Request.Headers.ContainsKey("Authorization"))
{

View File

@ -1,9 +1,10 @@
using Microsoft.AspNetCore.Http;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.Interfaces;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Middleware;
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
public class MaintenanceModeMiddleware(RequestDelegate next, IMaintenanceModeService maintenanceModeService, IMaintenanceModeNotConfigureService maintenanceModeNotConfigureService)
{
@ -13,7 +14,7 @@ public class MaintenanceModeMiddleware(RequestDelegate next, IMaintenanceModeSer
return endpoint?.Metadata.GetMetadata<MaintenanceModeIgnoreAttribute>() != null;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context)
{
if (!maintenanceModeService.IsMaintenanceMode && !maintenanceModeNotConfigureService.IsMaintenanceMode || IsIgnoreMaintenanceMode(context))
await next(context);
@ -23,17 +24,11 @@ public class MaintenanceModeMiddleware(RequestDelegate next, IMaintenanceModeSer
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
context.Response.ContentType = "plain/text";
string error;
if (maintenanceModeService.IsMaintenanceMode)
{
context.Response.Headers.RetryAfter = "600";
error = "The service is currently undergoing maintenance. Please try again later.";
}
else
error =
"The service is currently not configured. Go to the setup page if you are an administrator or try again later.";
throw new ServerUnavailableException("The service is currently undergoing maintenance. Please try again later.", true);
await context.Response.WriteAsync(error);
throw new ServerUnavailableException(
"The service is currently not configured. Go to the setup page if you are an administrator or try again later.", false);
}
}
}

View File

@ -1,27 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Versioning;
using Asp.Versioning;
using Microsoft.Extensions.DependencyInjection;
namespace Mirea.Api.Endpoint.Configuration.AppConfig;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class ApiVersioningConfiguration
{
public static void AddCustomApiVersioning(this IServiceCollection services)
public static IApiVersioningBuilder AddCustomApiVersioning(this IServiceCollection services)
{
services.AddApiVersioning(options =>
return services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
});
services.AddVersionedApiExplorer(options =>
}).AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
services.AddEndpointsApiExplorer();
}
}

View File

@ -0,0 +1,26 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Mirea.Api.Dto.Common;
using Mirea.Api.Endpoint.Configuration.Model;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class CacheConfiguration
{
public static IServiceCollection AddCustomRedis(this IServiceCollection services, IConfiguration configuration, IHealthChecksBuilder? healthChecksBuilder = null)
{
var cache = configuration.Get<GeneralConfig>()?.CacheSettings;
if (cache?.TypeDatabase != CacheType.Redis)
return services;
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = cache.ConnectionString;
options.InstanceName = "mirea_";
});
healthChecksBuilder?.AddRedis(cache.ConnectionString!, name: "Redis");
return services;
}
}

View File

@ -5,7 +5,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Mirea.Api.Endpoint.Configuration.AppConfig;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class EnvironmentConfiguration
{
@ -21,7 +21,7 @@ public static class EnvironmentConfiguration
var commentIndex = line.IndexOf('#', StringComparison.Ordinal);
string arg = line;
var arg = line;
if (commentIndex != -1)
arg = arg.Remove(commentIndex, arg.Length - commentIndex);

View File

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
@ -7,23 +8,25 @@ using Mirea.Api.Security.Common.Interfaces;
using System;
using System.Text;
namespace Mirea.Api.Endpoint.Configuration.AppConfig;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class JwtConfiguration
{
public static IServiceCollection AddJwtToken(this IServiceCollection services, IConfiguration configuration)
public static AuthenticationBuilder AddJwtToken(this IServiceCollection services, IConfiguration configuration)
{
var lifeTimeJwt = TimeSpan.FromMinutes(int.Parse(configuration["SECURITY_LIFE_TIME_JWT"]!));
var jwtDecrypt = Encoding.UTF8.GetBytes(configuration["SECURITY_ENCRYPTION_TOKEN"] ?? string.Empty);
if (jwtDecrypt.Length != 32)
throw new InvalidOperationException("The secret token \"SECURITY_ENCRYPTION_TOKEN\" cannot be less than 32 characters long. Now the size is equal is " + jwtDecrypt.Length);
throw new InvalidOperationException("The secret token \"SECURITY_ENCRYPTION_TOKEN\" cannot be less than 32 characters long. " +
"Now the size is equal is " + jwtDecrypt.Length);
var jwtKey = Encoding.UTF8.GetBytes(configuration["SECURITY_SIGNING_TOKEN"] ?? string.Empty);
if (jwtKey.Length != 64)
throw new InvalidOperationException("The signature token \"SECURITY_SIGNING_TOKEN\" cannot be less than 64 characters. Now the size is " + jwtKey.Length);
throw new InvalidOperationException("The signature token \"SECURITY_SIGNING_TOKEN\" cannot be less than 64 characters. " +
"Now the size is " + jwtKey.Length);
var jwtIssuer = configuration["SECURITY_JWT_ISSUER"];
var jwtAudience = configuration["SECURITY_JWT_AUDIENCE"];
@ -40,7 +43,7 @@ public static class JwtConfiguration
SigningKey = jwtKey
});
services.AddAuthentication(options =>
return services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
@ -60,7 +63,5 @@ public static class JwtConfiguration
TokenDecryptionKey = new SymmetricSecurityKey(jwtDecrypt)
};
});
return services;
}
}
}

View File

@ -2,20 +2,23 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Common.Settings;
using Mirea.Api.Endpoint.Configuration.Model;
using Serilog;
using Serilog.Context;
using Serilog.Events;
using Serilog.Filters;
using Serilog.Formatting.Compact;
using System;
using System.Diagnostics;
using System.IO;
namespace Mirea.Api.Endpoint.Configuration.AppConfig;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class LoggerConfiguration
{
public static IHostBuilder AddCustomSerilog(this IHostBuilder hostBuilder)
{
hostBuilder.UseSerilog((context, _, configuration) =>
return hostBuilder.UseSerilog((context, _, configuration) =>
{
var generalConfig = context.Configuration.Get<GeneralConfig>()?.LogSettings;
configuration
@ -43,39 +46,57 @@ public static class LoggerConfiguration
rollingInterval: RollingInterval.Day);
}
if (generalConfig != null && !string.IsNullOrEmpty(generalConfig.ApiServerSeq) &&
Uri.TryCreate(generalConfig.ApiServerSeq, UriKind.Absolute, out var _))
configuration.WriteTo.Seq(generalConfig.ApiServerSeq, apiKey: generalConfig.ApiKeySeq);
configuration
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning);
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Authentication", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Authorization", LogEventLevel.Warning);
configuration.Filter.ByExcluding(Matching.WithProperty<string>("SourceContext", sc =>
sc.Contains("Microsoft.EntityFrameworkCore.Database.Command")));
});
return hostBuilder;
}
public static IApplicationBuilder UseCustomSerilog(this IApplicationBuilder app)
{
app.UseSerilogRequestLogging(options =>
return app.Use(async (context, next) =>
{
var traceId = Activity.Current?.Id ?? context.TraceIdentifier;
using (LogContext.PushProperty("TraceId", traceId))
using (LogContext.PushProperty("UserAgent", context.Request.Headers.UserAgent.ToString()))
using (LogContext.PushProperty("RemoteIPAddress", context.Connection.RemoteIpAddress?.ToString()))
{
await next();
}
}).UseSerilogRequestLogging(options =>
{
options.MessageTemplate = "[{RequestMethod}] {RequestPath} [Client {RemoteIPAddress}] [{StatusCode}] in {Elapsed:0.0000} ms";
options.GetLevel = (_, elapsed, ex) => elapsed >= 2500 || ex != null
? LogEventLevel.Warning
: elapsed >= 1000
? LogEventLevel.Information
: LogEventLevel.Debug;
options.GetLevel = (httpContext, elapsed, ex) =>
{
if (httpContext.Request.Path.StartsWithSegments("/health"))
return LogEventLevel.Verbose;
return elapsed >= 2500 || ex != null
? LogEventLevel.Warning
: elapsed >= 1000
? LogEventLevel.Information
: LogEventLevel.Debug;
};
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent);
diagnosticContext.Set("RemoteIPAddress", httpContext.Connection.RemoteIpAddress?.ToString());
};
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent);
diagnosticContext.Set("RemoteIPAddress", httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty);
};
});
return app;
}
}
}

View File

@ -1,12 +1,12 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Mirea.Api.Dto.Common;
using Mirea.Api.Endpoint.Common.Services.Security;
using Mirea.Api.Endpoint.Common.Settings;
using Mirea.Api.Endpoint.Configuration.General.Settings;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Security;
using Mirea.Api.Security.Common.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.AppConfig;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class SecureConfiguration
{
@ -16,7 +16,7 @@ public static class SecureConfiguration
services.AddSingleton<IRevokedToken, MemoryRevokedTokenService>();
if (configuration.Get<GeneralConfig>()?.CacheSettings?.TypeDatabase == CacheSettings.CacheEnum.Redis)
if (configuration.Get<GeneralConfig>()?.CacheSettings?.TypeDatabase == CacheType.Redis)
services.AddSingleton<ICacheService, DistributedCacheService>();
else
services.AddSingleton<ICacheService, MemoryCacheService>();

View File

@ -1,15 +1,15 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Asp.Versioning.ApiExplorer;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Swagger;
using Mirea.Api.Endpoint.Configuration.SwaggerOptions;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.IO;
namespace Mirea.Api.Endpoint.Configuration.AppConfig;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class SwaggerConfiguration
{
@ -17,8 +17,11 @@ public static class SwaggerConfiguration
{
services.AddSwaggerGen(options =>
{
options.SchemaFilter<SwaggerExampleFilter>();
options.OperationFilter<SwaggerDefaultValues>();
options.OperationFilter<TagSchemeFilter>();
options.SchemaFilter<ExampleFilter>();
options.OperationFilter<DefaultValues>();
options.OperationFilter<ActionResultSchemaFilter>();
options.SchemaFilter<EnumSchemaFilter>();
var basePath = AppDomain.CurrentDomain.BaseDirectory;
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
@ -51,15 +54,13 @@ public static class SwaggerConfiguration
options.IncludeXmlComments(Path.Combine(basePath, "ApiDtoDocs.xml"));
});
services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
return services;
return services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
}
public static IApplicationBuilder UseCustomSwagger(this IApplicationBuilder app, IServiceProvider services)
{
app.UseSwagger();
app.UseSwaggerUI(options =>
return app.UseSwaggerUI(options =>
{
options.InjectStylesheet($"{UrlHelper.GetSubPath}css/swagger/SwaggerDark.css");
var provider = services.GetService<IApiVersionDescriptionProvider>();
@ -72,7 +73,5 @@ public static class SwaggerConfiguration
options.RoutePrefix = UrlHelper.GetSubPathSwagger.Trim('/');
}
});
return app;
}
}

View File

@ -1,6 +0,0 @@
namespace Mirea.Api.Endpoint.Configuration.General.Interfaces;
public interface IIsConfigured
{
bool IsConfigured();
}

View File

@ -1,23 +0,0 @@
using Mirea.Api.Endpoint.Configuration.General.Attributes;
using Mirea.Api.Endpoint.Configuration.General.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.General.Settings;
[RequiredSettings]
public class CacheSettings : IIsConfigured
{
public enum CacheEnum
{
Memcached,
Redis
}
public CacheEnum TypeDatabase { get; set; }
public string? ConnectionString { get; set; }
public bool IsConfigured()
{
return TypeDatabase == CacheEnum.Memcached ||
!string.IsNullOrEmpty(ConnectionString);
}
}

View File

@ -1,33 +0,0 @@
using Mirea.Api.DataAccess.Persistence.Common;
using Mirea.Api.Endpoint.Configuration.General.Attributes;
using Mirea.Api.Endpoint.Configuration.General.Interfaces;
using System;
using System.Text.Json.Serialization;
namespace Mirea.Api.Endpoint.Configuration.General.Settings;
[RequiredSettings]
public class DbSettings : IIsConfigured
{
public enum DatabaseEnum
{
Mysql,
Sqlite,
PostgresSql
}
public DatabaseEnum TypeDatabase { get; set; }
public required string ConnectionStringSql { get; set; }
[JsonIgnore]
public DatabaseProvider DatabaseProvider =>
TypeDatabase switch
{
DatabaseEnum.PostgresSql => DatabaseProvider.Postgresql,
DatabaseEnum.Mysql => DatabaseProvider.Mysql,
DatabaseEnum.Sqlite => DatabaseProvider.Sqlite,
_ => throw new ArgumentOutOfRangeException()
};
public bool IsConfigured() =>
!string.IsNullOrEmpty(ConnectionStringSql);
}

View File

@ -1,5 +1,5 @@
namespace Mirea.Api.Endpoint.Common.Settings;
namespace Mirea.Api.Endpoint.Configuration;
public interface ISaveSettings
{
void SaveSetting();
}
}

View File

@ -0,0 +1,41 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Model;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Mirea.Api.Endpoint.Configuration.Model;
public class Admin : ISaveSettings
{
[JsonIgnore] private const string FileName = "admin.json";
private string _username = string.Empty;
private string _email = string.Empty;
[JsonIgnore]
public static string FilePath => PathBuilder.Combine(FileName);
public required string Username
{
get => _username;
set => _username = value.Trim();
}
public required string Email
{
get => _email;
set => _email = value.Trim();
}
public required string PasswordHash { get; set; }
public required string Salt { get; set; }
public TwoFactorAuthenticator TwoFactorAuthenticator { get; set; } = TwoFactorAuthenticator.None;
public string? Secret { get; set; }
public Dictionary<OAuthProvider, OAuthUser>? OAuthProviders { get; set; }
public void SaveSetting()
{
File.WriteAllText(FilePath, JsonSerializer.Serialize(this));
}
}

View File

@ -1,10 +1,11 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.General.Settings;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Security.Common.Model;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Mirea.Api.Endpoint.Common.Settings;
namespace Mirea.Api.Endpoint.Configuration.Model;
public class GeneralConfig : ISaveSettings
{
@ -18,6 +19,8 @@ public class GeneralConfig : ISaveSettings
public ScheduleSettings? ScheduleSettings { get; set; }
public EmailSettings? EmailSettings { get; set; }
public LogSettings? LogSettings { get; set; }
public PasswordPolicy PasswordPolicy { get; set; } = new();
public string? SecretForwardToken { get; set; }
public void SaveSetting()

View File

@ -0,0 +1,18 @@
using Mirea.Api.Dto.Common;
using Mirea.Api.Endpoint.Configuration.Validation.Attributes;
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
[RequiredSettings]
public class CacheSettings : IIsConfigured
{
public CacheType TypeDatabase { get; set; }
public string? ConnectionString { get; set; }
public bool IsConfigured()
{
return TypeDatabase == CacheType.Memcached ||
!string.IsNullOrEmpty(ConnectionString);
}
}

View File

@ -0,0 +1,28 @@
using Mirea.Api.DataAccess.Persistence.Common;
using Mirea.Api.Dto.Common;
using Mirea.Api.Endpoint.Configuration.Validation.Attributes;
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
using System;
using System.Text.Json.Serialization;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
[RequiredSettings]
public class DbSettings : IIsConfigured
{
public DatabaseType TypeDatabase { get; set; }
public required string ConnectionStringSql { get; set; }
[JsonIgnore]
public DatabaseProvider DatabaseProvider =>
TypeDatabase switch
{
DatabaseType.PostgresSql => DatabaseProvider.Postgresql,
DatabaseType.Mysql => DatabaseProvider.Mysql,
DatabaseType.Sqlite => DatabaseProvider.Sqlite,
_ => throw new ArgumentOutOfRangeException()
};
public bool IsConfigured() =>
!string.IsNullOrEmpty(ConnectionStringSql);
}

View File

@ -1,6 +1,6 @@
using Mirea.Api.Endpoint.Configuration.General.Interfaces;
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.General.Settings;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
public class EmailSettings : IIsConfigured
{

View File

@ -1,7 +1,7 @@
using Mirea.Api.Endpoint.Configuration.General.Attributes;
using Mirea.Api.Endpoint.Configuration.General.Interfaces;
using Mirea.Api.Endpoint.Configuration.Validation.Attributes;
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.General.Settings;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
[RequiredSettings]
public class LogSettings : IIsConfigured
@ -9,6 +9,8 @@ public class LogSettings : IIsConfigured
public bool EnableLogToFile { get; set; }
public string? LogFilePath { get; set; }
public string? LogFileName { get; set; }
public string? ApiKeySeq { get; set; }
public string? ApiServerSeq { get; set; }
public bool IsConfigured()
{

View File

@ -1,10 +1,10 @@
using Mirea.Api.Endpoint.Configuration.General.Attributes;
using Mirea.Api.Endpoint.Configuration.General.Interfaces;
using Mirea.Api.Endpoint.Configuration.Validation.Attributes;
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Configuration.General.Settings;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
[RequiredSettings]
public class ScheduleSettings : IIsConfigured
@ -31,9 +31,33 @@ public class ScheduleSettings : IIsConfigured
public PairPeriodTime(Dto.Common.PairPeriodTime time) : this(time.Start, time.End) { }
}
public record struct CronUpdateSkip
{
public DateOnly? Start { get; set; }
public DateOnly? End { get; set; }
public DateOnly? Date { get; set; }
public CronUpdateSkip(DateOnly d1, DateOnly d2)
{
if (d1 > d2)
{
Start = d2;
End = d1;
}
else
{
Start = d1;
End = d2;
}
}
public CronUpdateSkip(DateOnly d1) => Date = d1;
}
public required string CronUpdateSchedule { get; set; }
public DateOnly StartTerm { get; set; }
public required IDictionary<int, PairPeriodTime> PairPeriod { get; set; }
public List<CronUpdateSkip> CronUpdateSkipDateList { get; set; } = [];
public bool IsConfigured()
{

View File

@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class ActionResultSchemaFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var returnType = context.MethodInfo.ReturnType;
if (!returnType.IsEquivalentTo(typeof(ActionResult)) &&
!returnType.IsEquivalentTo(typeof(ContentResult)) &&
!returnType.IsEquivalentTo(typeof(FileStreamResult)) &&
!returnType.IsGenericType)
return;
if (returnType.IsGenericType &&
!returnType.GetGenericTypeDefinition().IsEquivalentTo(typeof(ActionResult<>)) &&
!returnType.GetGenericTypeDefinition().IsEquivalentTo(typeof(Task<>)))
return;
var genericType = returnType.IsGenericType ? returnType.GetGenericArguments().FirstOrDefault() : returnType;
if (genericType == null)
return;
var responseTypeAttributes = context.MethodInfo.GetCustomAttributes(typeof(ProducesResponseTypeAttribute), false)
.Cast<ProducesResponseTypeAttribute>()
.Where(attr => attr.StatusCode == 200)
.ToList();
var contentType = "application/json";
if (context.MethodInfo.GetCustomAttributes(typeof(ProducesAttribute), false)
.FirstOrDefault() is ProducesAttribute producesAttribute)
contentType = producesAttribute.ContentTypes.FirstOrDefault() ?? "application/json";
if (responseTypeAttributes.Count != 0)
{
var responseType = responseTypeAttributes.First().Type;
genericType = responseType;
}
if (genericType.IsEquivalentTo(typeof(ContentResult)) || genericType.IsEquivalentTo(typeof(FileStreamResult)))
{
operation.Responses["200"] = new OpenApiResponse
{
Description = "OK",
Content = new Dictionary<string, OpenApiMediaType>
{
[contentType] = new()
}
};
}
else if (genericType == typeof(ActionResult))
{
operation.Responses["200"] = new OpenApiResponse { Description = "OK" };
}
else
{
OpenApiSchema schema;
if (genericType.IsGenericType && genericType.GetGenericTypeDefinition() == typeof(ActionResult<>))
schema = context.SchemaGenerator.GenerateSchema(genericType.GetGenericArguments().FirstOrDefault(),
context.SchemaRepository);
else
schema = context.SchemaGenerator.GenerateSchema(genericType, context.SchemaRepository);
operation.Responses["200"] = new OpenApiResponse
{
Description = "OK",
Content = new Dictionary<string, OpenApiMediaType>
{
[contentType] = new() { Schema = schema }
}
};
}
}
}

View File

@ -1,29 +1,30 @@
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.Diagnostics;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.Swagger;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) : IConfigureOptions<SwaggerGenOptions>
{
public void Configure(SwaggerGenOptions options)
{
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description));
}
}
private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description)
{
var info = new OpenApiInfo()
{
Title = "MIREA Schedule Web API",
Title = $"MIREA Schedule Web API ({FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).ProductVersion})",
Version = description.ApiVersion.ToString(),
Description = "This API provides a convenient interface for retrieving data stored in the database. Special attention was paid to the lightweight and easy transfer of all necessary data. Made by the Winsomnia team.",
Description = "This API provides a convenient interface for retrieving data stored in the database. " +
"Special attention was paid to the lightweight and easy transfer of all necessary data. Made by the Winsomnia team.",
Contact = new OpenApiContact { Name = "Author name", Email = "support@winsomnia.net" },
License = new OpenApiLicense { Name = "MIT", Url = new Uri("https://opensource.org/licenses/MIT") }
};

View File

@ -6,9 +6,9 @@ using System;
using System.Linq;
using System.Text.Json;
namespace Mirea.Api.Endpoint.Configuration.Swagger;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerDefaultValues : IOperationFilter
public class DefaultValues : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
@ -23,16 +23,12 @@ public class SwaggerDefaultValues : IOperationFilter
foreach (var contentType in response.Content.Keys)
{
if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType))
{
response.Content.Remove(contentType);
}
}
}
if (operation.Parameters == null)
{
return;
}
foreach (var parameter in operation.Parameters)
{

View File

@ -0,0 +1,28 @@
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.Linq;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class EnumSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
if (!context.Type.IsEnum)
return;
schema.Enum.Clear();
var enumValues = Enum.GetNames(context.Type)
.Select(name => new OpenApiString(name))
.ToList();
foreach (var value in enumValues)
schema.Enum.Add(value);
schema.Type = "string";
schema.Format = null;
}
}

View File

@ -3,9 +3,9 @@ using Mirea.Api.Endpoint.Common.Attributes;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.Swagger;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerExampleFilter : ISchemaFilter
public class ExampleFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{

View File

@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Linq;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class TagSchemeFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (context.ApiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
return;
var controllerType = controllerActionDescriptor.ControllerTypeInfo;
var tagsAttribute = controllerType.GetCustomAttributes<TagsAttribute>(inherit: true).FirstOrDefault();
if (tagsAttribute == null)
{
var baseType = controllerType.BaseType;
while (baseType != null)
{
tagsAttribute = baseType.GetCustomAttributes<TagsAttribute>(inherit: true).FirstOrDefault();
if (tagsAttribute != null)
break;
baseType = baseType.BaseType;
}
}
if (tagsAttribute == null)
return;
operation.Tags ??= [];
operation.Tags.Add(new OpenApiTag { Name = tagsAttribute.Tags[0] });
}
}

View File

@ -1,6 +1,6 @@
using System;
namespace Mirea.Api.Endpoint.Configuration.General.Attributes;
namespace Mirea.Api.Endpoint.Configuration.Validation.Attributes;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class RequiredSettingsAttribute : Attribute;

View File

@ -0,0 +1,6 @@
namespace Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
public interface IIsConfigured
{
bool IsConfigured();
}

View File

@ -1,7 +1,7 @@
using Mirea.Api.Endpoint.Common.Interfaces;
using System;
namespace Mirea.Api.Endpoint.Configuration.General;
namespace Mirea.Api.Endpoint.Configuration.Validation;
public class SetupTokenService : ISetupToken
{
@ -14,8 +14,8 @@ public class SetupTokenService : ISetupToken
var token2 = Token.Value.Span;
int result = 0;
for (int i = 0; i < Token.Value.Length; i++)
var result = 0;
for (var i = 0; i < Token.Value.Length; i++)
result |= token2[i] ^ token[i];
return result == 0;

View File

@ -1,11 +1,11 @@
using Microsoft.Extensions.Options;
using Mirea.Api.Endpoint.Common.Settings;
using Mirea.Api.Endpoint.Configuration.General.Attributes;
using Mirea.Api.Endpoint.Configuration.General.Interfaces;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Validation.Attributes;
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
using System;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.General.Validators;
namespace Mirea.Api.Endpoint.Configuration.Validation.Validators;
public class SettingsRequiredValidator
{

View File

@ -1,349 +0,0 @@
using Cronos;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Caching.Memory;
using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Requests.Configuration;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.Interfaces;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Common.Settings;
using Mirea.Api.Endpoint.Configuration.General.Settings;
using Mirea.Api.Endpoint.Configuration.General.Validators;
using Mirea.Api.Security.Services;
using MySqlConnector;
using Npgsql;
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Net.Mail;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
namespace Mirea.Api.Endpoint.Controllers.Configuration;
[ApiVersion("1.0")]
[ApiController]
[MaintenanceModeIgnore]
[ApiExplorerSettings(IgnoreApi = true)]
public partial class SetupController(
ISetupToken setupToken,
IMaintenanceModeNotConfigureService notConfigureService,
IMemoryCache cache,
PasswordHashService passwordHashService) : BaseController
{
private const string CacheGeneralKey = "config_general";
private const string CacheAdminKey = "config_admin";
private GeneralConfig GeneralConfig
{
get => cache.Get<GeneralConfig>(CacheGeneralKey) ?? new GeneralConfig();
set => cache.Set(CacheGeneralKey, value);
}
[HttpGet("GenerateToken")]
[Localhost]
public ActionResult<string> GenerateToken()
{
if (!notConfigureService.IsMaintenanceMode)
throw new ControllerArgumentException(
"The token cannot be generated because the server has been configured. " +
$"If you need to restart the configuration, then delete the \"{GeneralConfig.FilePath}\" file and restart the application.");
var token = new byte[32];
RandomNumberGenerator.Create().GetBytes(token);
setupToken.SetToken(token);
return Ok(Convert.ToBase64String(token));
}
[HttpGet("IsConfigured")]
public ActionResult<bool> IsConfigured() =>
!notConfigureService.IsMaintenanceMode;
[HttpGet("CheckToken")]
public ActionResult<bool> CheckToken([FromQuery] string token)
{
if (!setupToken.MatchToken(Convert.FromBase64String(token))) return Unauthorized("The token is not valid");
Response.Cookies.Append("AuthToken", token, new CookieOptions
{
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api",
Domain = UrlHelper.CurrentDomain(ControllerContext.HttpContext),
Secure = true,
HttpOnly = true
});
return Ok(true);
}
private ActionResult<bool> SetDatabase<TConnection, TException>(string connectionString, DbSettings.DatabaseEnum databaseType)
where TConnection : class, IDbConnection, new()
where TException : Exception
{
try
{
using var connection = new TConnection();
connection.ConnectionString = connectionString;
connection.Open();
connection.Close();
var general = GeneralConfig;
general.DbSettings = new DbSettings
{
ConnectionStringSql = connectionString,
TypeDatabase = databaseType
};
GeneralConfig = general;
return Ok(true);
}
catch (TException ex)
{
throw new ControllerArgumentException($"Error when connecting: {ex.Message}");
}
}
[HttpPost("SetPsql")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetPsql([FromBody] DatabaseRequest request)
{
string connectionString = $"Host={request.Server}:{request.Port};Username={request.User};Database={request.Database}";
if (request.Password != null)
connectionString += $";Password={request.Password}";
if (request.Ssl)
connectionString += ";SSL Mode=Require;";
return SetDatabase<NpgsqlConnection, NpgsqlException>(connectionString, DbSettings.DatabaseEnum.PostgresSql);
}
[HttpPost("SetMysql")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetMysql([FromBody] DatabaseRequest request)
{
string connectionString = $"Server={request.Server}:{request.Port};Uid={request.User};Database={request.Database};";
if (request.Password != null)
connectionString += $"Pwd={request.Password};";
if (request.Ssl)
connectionString += "SslMode=Require;";
return SetDatabase<MySqlConnection, MySqlException>(connectionString, DbSettings.DatabaseEnum.Mysql);
}
[HttpPost("SetSqlite")]
[TokenAuthentication]
public ActionResult<bool> SetSqlite([FromQuery] string? path)
{
if (string.IsNullOrEmpty(path)) path = "database";
path = PathBuilder.Combine(path);
if (!Directory.Exists(path))
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
Directory.CreateDirectory(path);
else
Directory.CreateDirectory(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
else
throw new ControllerArgumentException("Such a folder exists. Enter a different name");
string connectionString = $"Data Source={PathBuilder.Combine(path, "database.db3")}";
return SetDatabase<SqliteConnection, SqliteException>(connectionString, DbSettings.DatabaseEnum.Sqlite);
}
[HttpPost("SetRedis")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetRedis([FromBody] CacheRequest request)
{
string connectionString = $"{request.Server}:{request.Port},ssl=false";
if (request.Password != null)
connectionString += $",password={request.Password}";
try
{
var redis = ConnectionMultiplexer.Connect(connectionString);
redis.Close();
var general = GeneralConfig;
general.CacheSettings = new CacheSettings
{
ConnectionString = connectionString,
TypeDatabase = CacheSettings.CacheEnum.Redis
};
GeneralConfig = general;
return Ok(true);
}
catch (Exception ex)
{
throw new ControllerArgumentException("Error when connecting to Redis: " + ex.Message);
}
}
[HttpPost("SetMemcached")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetMemcached()
{
var general = GeneralConfig;
general.CacheSettings = new CacheSettings
{
ConnectionString = null,
TypeDatabase = CacheSettings.CacheEnum.Memcached
};
GeneralConfig = general;
return Ok(true);
}
[HttpPost("CreateAdmin")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<string> CreateAdmin([FromBody] CreateUserRequest user)
{
if (user.Password.Length < 8 || !PasswordExistUpperLetter().IsMatch(user.Password) || !PasswordExistSpecialSymbol().IsMatch(user.Password))
throw new ControllerArgumentException("The password must be at least 8 characters long and contain at least one uppercase letter and one special character.");
if (!MailAddress.TryCreate(user.Email, out _))
throw new ControllerArgumentException("The email address is incorrect.");
var (salt, hash) = passwordHashService.HashPassword(user.Password);
var admin = new Admin
{
Username = user.Username,
Email = user.Email,
PasswordHash = hash,
Salt = salt
};
cache.Set(CacheAdminKey, admin);
return Ok(true);
}
[HttpPost("SetLogging")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetLogging([FromBody] LoggingRequest? request = null)
{
var settings = (request == null) switch
{
true => new LogSettings
{
EnableLogToFile = true
},
false => new LogSettings
{
EnableLogToFile = request.EnableLogToFile,
LogFileName = request.LogFileName,
LogFilePath = request.LogFilePath
}
};
if (settings.EnableLogToFile)
{
if (string.IsNullOrEmpty(settings.LogFileName))
settings.LogFileName = "log-";
if (string.IsNullOrEmpty(settings.LogFilePath))
settings.LogFilePath = OperatingSystem.IsWindows() || PathBuilder.IsDefaultPath ?
PathBuilder.Combine("logs") :
"/var/log/mirea";
}
var general = GeneralConfig;
general.LogSettings = settings;
GeneralConfig = general;
return true;
}
[HttpPost("SetEmail")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetEmail([FromBody] EmailRequest? request = null)
{
var settings = (request == null) switch
{
true => new EmailSettings(),
false => new EmailSettings
{
Server = request.Server,
From = request.From,
Password = request.Password,
Port = request.Port,
Ssl = request.Ssl,
User = request.User
}
};
var general = GeneralConfig;
general.EmailSettings = settings;
GeneralConfig = general;
return true;
}
[HttpPost("SetSchedule")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetSchedule([FromBody] ScheduleConfigurationRequest request)
{
var general = GeneralConfig;
general.ScheduleSettings = new ScheduleSettings
{
// every 6 hours
CronUpdateSchedule = request.CronUpdateSchedule ?? "0 */6 * * *",
StartTerm = request.StartTerm,
PairPeriod = new Dictionary<int, ScheduleSettings.PairPeriodTime>
{
{1, new ScheduleSettings.PairPeriodTime(new TimeOnly(9, 0, 0), new TimeOnly(10, 30, 0))},
{2, new ScheduleSettings.PairPeriodTime(new TimeOnly(10, 40, 0), new TimeOnly(12, 10, 0))},
{3, new ScheduleSettings.PairPeriodTime(new TimeOnly(12, 40, 0), new TimeOnly(14, 10, 0))},
{4, new ScheduleSettings.PairPeriodTime(new TimeOnly(14, 20, 0), new TimeOnly(15, 50, 0))},
{5, new ScheduleSettings.PairPeriodTime(new TimeOnly(16, 20, 0), new TimeOnly(17, 50, 0))},
{6, new ScheduleSettings.PairPeriodTime(new TimeOnly(18, 0, 0), new TimeOnly(19, 30, 0))},
{7, new ScheduleSettings.PairPeriodTime(new TimeOnly(19, 40, 0), new TimeOnly(21, 10, 0))},
}
};
if (!CronExpression.TryParse(general.ScheduleSettings.CronUpdateSchedule, CronFormat.Standard, out _))
throw new ControllerArgumentException("The Cron task could not be parsed. Check the format of the entered data.");
GeneralConfig = general;
return true;
}
[HttpPost("Submit")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> Submit()
{
if (!new SettingsRequiredValidator(GeneralConfig).AreSettingsValid())
throw new ControllerArgumentException("The necessary data has not been configured.");
if (!cache.TryGetValue(CacheAdminKey, out Admin? admin) || admin == null)
throw new ControllerArgumentException("The administrator's data was not set.");
admin.SaveSetting();
GeneralConfig.SaveSetting();
return true;
}
[GeneratedRegex("[A-Z]+")]
private static partial Regex PasswordExistUpperLetter();
[GeneratedRegex("[!@#$%^&*]+")]
private static partial Regex PasswordExistSpecialSymbol();
}

View File

@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Mirea.Api.Endpoint.Controllers;
[Route("api/v{version:apiVersion}/Configuration/[controller]")]
[Authorize]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Tags("Configuration")]
public class ConfigurationBaseController : BaseController;

View File

@ -0,0 +1,599 @@
using Asp.Versioning;
using Cronos;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Caching.Memory;
using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Requests.Configuration;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Dto.Responses.Configuration;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.Interfaces;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Configuration.Validation.Validators;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Model;
using Mirea.Api.Security.Services;
using MySqlConnector;
using Npgsql;
using Serilog;
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security;
using System.Security.Cryptography;
using System.Threading.Tasks;
using CookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy;
namespace Mirea.Api.Endpoint.Controllers.SetupConfiguration;
[ApiVersion("1.0")]
[MaintenanceModeIgnore]
[ApiExplorerSettings(IgnoreApi = true)]
public class SetupController(
ISetupToken setupToken,
IMaintenanceModeNotConfigureService notConfigureService,
IMemoryCache cache,
PasswordHashService passwordHashService,
OAuthService oAuthService) : BaseController
{
private const string CacheGeneralKey = "config_general";
private const string CacheAdminKey = "config_admin";
private GeneralConfig GeneralConfig
{
get => cache.Get<GeneralConfig>(CacheGeneralKey) ?? new GeneralConfig();
set => cache.Set(CacheGeneralKey, value);
}
[HttpGet("GenerateToken")]
[Localhost]
public ActionResult<string> GenerateToken()
{
if (!notConfigureService.IsMaintenanceMode)
throw new ControllerArgumentException(
"The token cannot be generated because the server has been configured. " +
$"If you need to restart the configuration, then delete the \"{GeneralConfig.FilePath}\" file and restart the application.");
var token = new byte[32];
RandomNumberGenerator.Create().GetBytes(token);
setupToken.SetToken(token);
return Ok(Convert.ToBase64String(token));
}
[HttpGet("IsConfigured")]
public ActionResult<bool> IsConfigured() =>
!notConfigureService.IsMaintenanceMode;
[HttpGet("CheckToken")]
public ActionResult<bool> CheckToken([FromQuery] string token)
{
byte[] tokenBase64;
try
{
tokenBase64 = Convert.FromBase64String(token);
}
catch (FormatException)
{
throw new ControllerArgumentException("A token of the wrong format.");
}
if (!setupToken.MatchToken(tokenBase64))
throw new SecurityException("The token is not valid");
Response.Cookies.Append(TokenAuthenticationAttribute.AuthToken, token, new CookieOptions
{
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api",
Domain = HttpContext.GetCurrentDomain(),
HttpOnly = true,
#if !DEBUG
Secure = true
#endif
});
return Ok(true);
}
[HttpGet("IsConfiguredToken")]
[TokenAuthentication]
public ActionResult<bool> IsConfiguredToken() =>
Ok(true);
private void SetDatabase<TConnection, TException>(string connectionString, DatabaseType databaseType)
where TConnection : class, IDbConnection, new()
where TException : Exception
{
try
{
using (var connection = new TConnection())
{
connection.ConnectionString = connectionString;
connection.Open();
connection.Close();
if (connection is SqliteConnection)
SqliteConnection.ClearAllPools();
}
var general = GeneralConfig;
general.DbSettings = new DbSettings
{
ConnectionStringSql = connectionString,
TypeDatabase = databaseType
};
GeneralConfig = general;
}
catch (TException ex)
{
throw new ControllerArgumentException($"Error when connecting: {ex.Message}");
}
}
[HttpPost("SetPsql")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetPsql([FromBody] DatabaseRequest request)
{
var connectionString = $"Host={request.Server}:{request.Port};Username={request.User};Database={request.Database}";
if (request.Password != null)
connectionString += $";Password={request.Password}";
if (request.Ssl)
connectionString += ";SSL Mode=Require;";
SetDatabase<NpgsqlConnection, NpgsqlException>(connectionString, DatabaseType.PostgresSql);
cache.Set("database", new DatabaseResponse
{
Type = DatabaseType.PostgresSql,
Database = request.Database,
Password = request.Password,
Ssl = request.Ssl,
Port = request.Port,
Server = request.Server,
User = request.User
});
return Ok(true);
}
[HttpPost("SetMysql")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetMysql([FromBody] DatabaseRequest request)
{
var connectionString = $"Server={request.Server}:{request.Port};Uid={request.User};Database={request.Database};";
if (request.Password != null)
connectionString += $"Pwd={request.Password};";
if (request.Ssl)
connectionString += "SslMode=Require;";
SetDatabase<MySqlConnection, MySqlException>(connectionString, DatabaseType.Mysql);
cache.Set("database", new DatabaseResponse
{
Type = DatabaseType.Mysql,
Database = request.Database,
Password = request.Password,
Ssl = request.Ssl,
Port = request.Port,
Server = request.Server,
User = request.User
});
return Ok(true);
}
[HttpPost("SetSqlite")]
[TokenAuthentication]
public ActionResult<bool> SetSqlite([FromQuery] string? path)
{
if (string.IsNullOrEmpty(path)) path = "database";
path = PathBuilder.Combine(path);
if (!Directory.Exists(path))
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
Directory.CreateDirectory(path);
else
Directory.CreateDirectory(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
else if (Directory.GetDirectories(path).Length != 0 ||
!Directory.GetFiles(path).Select(x => string.Equals(Path.GetFileName(x), "database.db3")).All(x => x))
{
throw new ControllerArgumentException("Such a folder exists. Enter a different name");
}
var filePath = Path.Combine(path, "database.db3");
var connectionString = $"Data Source={filePath}";
SetDatabase<SqliteConnection, SqliteException>(connectionString, DatabaseType.Sqlite);
foreach (var file in Directory.GetFiles(path))
System.IO.File.Delete(file);
cache.Set("database", new DatabaseResponse
{
Type = DatabaseType.Sqlite,
PathToDatabase = path
});
return Ok(true);
}
[HttpGet("DatabaseConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<DatabaseRequest> DatabaseConfiguration() =>
cache.TryGetValue<DatabaseResponse>("database", out var response) ? Ok(response) : NoContent();
[HttpPost("SetRedis")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetRedis([FromBody] CacheRequest request)
{
var connectionString = $"{request.Server}:{request.Port},ssl=false";
if (request.Password != null)
connectionString += $",password={request.Password}";
try
{
var redis = ConnectionMultiplexer.Connect(connectionString);
redis.Close();
var general = GeneralConfig;
general.CacheSettings = new CacheSettings
{
ConnectionString = connectionString,
TypeDatabase = CacheType.Redis
};
GeneralConfig = general;
cache.Set("cache", new CacheResponse
{
Type = CacheType.Redis,
Server = request.Server,
Password = request.Password,
Port = request.Port
});
return Ok(true);
}
catch (Exception ex)
{
throw new ControllerArgumentException("Error when connecting to Redis: " + ex.Message);
}
}
[HttpPost("SetMemcached")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetMemcached()
{
var general = GeneralConfig;
general.CacheSettings = new CacheSettings
{
ConnectionString = null,
TypeDatabase = CacheType.Memcached
};
GeneralConfig = general;
cache.Set("cache", new CacheResponse
{
Type = CacheType.Memcached
});
return Ok(true);
}
[HttpGet("CacheConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<CacheResponse> CacheConfiguration() =>
cache.TryGetValue<CacheResponse>("cache", out var response) ? Ok(response) : NoContent();
[HttpPost("CreateAdmin")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<string> CreateAdmin([FromBody] CreateUserRequest userRequest)
{
new PasswordPolicyService(GeneralConfig.PasswordPolicy).ValidatePasswordOrThrow(userRequest.Password);
var (salt, hash) = passwordHashService.HashPassword(userRequest.Password);
var admin = new Admin
{
Username = userRequest.Username,
Email = userRequest.Email,
PasswordHash = hash,
Salt = salt
};
cache.Set(CacheAdminKey, admin);
return Ok(true);
}
[HttpGet("HandleToken")]
[TokenAuthentication]
public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token)
{
var (user, error, isSuccess, provider) = await oAuthService.GetOAuthUser(new Security.Common.Model.CookieOptions
{
Domain = HttpContext.GetCurrentDomain(),
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
}, HttpContext, token);
if (!isSuccess || user == null || provider == null)
throw new ControllerArgumentException(error ?? "Token processing error.");
if (!cache.TryGetValue<Admin>(CacheAdminKey, out var admin))
{
admin = new Admin()
{
Email = user.Email ?? string.Empty,
Username = user.Username ?? string.Empty,
PasswordHash = string.Empty,
Salt = string.Empty,
OAuthProviders = new Dictionary<OAuthProvider, OAuthUser>
{
{provider.Value, user}
}
};
cache.Set(CacheAdminKey, admin);
return Ok();
}
if (admin!.OAuthProviders != null && admin.OAuthProviders.ContainsKey(provider.Value))
return Conflict(new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10",
Title = "Conflict",
Status = StatusCodes.Status409Conflict,
Detail = "This OAuth provider is already associated with the account.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
admin.OAuthProviders ??= [];
admin.OAuthProviders.Add(provider.Value, user);
cache.Set(CacheAdminKey, admin);
return Ok();
}
[HttpGet("AdminConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<UserResponse> AdminConfiguration() =>
cache.TryGetValue<Admin>(CacheAdminKey, out var admin) ? Ok(new UserResponse()
{
Email = admin!.Email,
Username = admin.Username,
TwoFactorAuthenticatorEnabled = admin.TwoFactorAuthenticator != TwoFactorAuthenticator.None,
UsedOAuthProviders = admin.OAuthProviders == null ? [] : admin.OAuthProviders.Keys.Select(x => x.ConvertToDto())
}) : NoContent();
[HttpGet("GenerateTotpKey")]
[TokenAuthentication]
public ActionResult<string> GenerateTotpKey()
{
if (cache.TryGetValue<string>("totpSecret", out var secret))
return secret!;
secret = GeneratorKey.GenerateAlphaNumericBase32Compatible(16);
cache.Set("totpSecret", secret);
return secret;
}
[HttpGet("VerifyTotp")]
[TokenAuthentication]
public ActionResult<bool> VerifyTotp([FromQuery] string code)
{
var isCorrect = cache.TryGetValue<string>("totpSecret", out var secret) &&
new TotpService(secret!).VerifyToken(code);
if (!isCorrect || !cache.TryGetValue<Admin>(CacheAdminKey, out var admin))
return false;
admin!.Secret = secret;
admin.TwoFactorAuthenticator = TwoFactorAuthenticator.Totp;
cache.Set(CacheAdminKey, admin);
return true;
}
[HttpPost("SetLogging")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetLogging([FromBody] LoggingRequest? request = null)
{
var settings = (request == null) switch
{
true => new LogSettings
{
EnableLogToFile = true
},
false => new LogSettings
{
EnableLogToFile = request.EnableLogToFile,
LogFileName = request.LogFileName,
LogFilePath = request.LogFilePath
}
};
if (!string.IsNullOrEmpty(request?.ApiServerSeq))
{
settings.ApiServerSeq = request.ApiServerSeq;
settings.ApiKeySeq = request.ApiKeySeq;
try
{
Log.Logger = new LoggerConfiguration()
.WriteTo.Seq(settings.ApiServerSeq, apiKey: settings.ApiKeySeq)
.CreateLogger();
Log.Warning("Testing configuration Seq.");
}
catch
{
// ignoring
}
finally
{
Log.CloseAndFlush();
}
}
if (settings.EnableLogToFile)
{
if (string.IsNullOrEmpty(settings.LogFileName))
settings.LogFileName = "log-";
if (string.IsNullOrEmpty(settings.LogFilePath))
settings.LogFilePath = OperatingSystem.IsWindows() || PathBuilder.IsDefaultPath ?
PathBuilder.Combine("logs") :
"/var/log/mirea";
}
var general = GeneralConfig;
general.LogSettings = settings;
GeneralConfig = general;
cache.Set("logging", new LoggingRequest
{
EnableLogToFile = settings.EnableLogToFile,
LogFileName = settings.LogFileName,
LogFilePath = settings.LogFilePath,
ApiKeySeq = settings.ApiKeySeq,
ApiServerSeq = settings.ApiServerSeq
});
return true;
}
[HttpGet("LoggingConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<LoggingRequest> LoggingConfiguration() =>
cache.TryGetValue<LoggingRequest>("logging", out var data) ? Ok(data) : NoContent();
[HttpPost("SetEmail")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetEmail([FromBody] EmailRequest? request = null)
{
var settings = (request == null) switch
{
true => new EmailSettings(),
false => new EmailSettings
{
Server = request.Server,
From = request.From,
Password = request.Password,
Port = request.Port,
Ssl = request.Ssl,
User = request.User
}
};
var general = GeneralConfig;
general.EmailSettings = settings;
GeneralConfig = general;
cache.Set("email", settings);
return true;
}
[HttpGet("EmailConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<EmailRequest> EmailConfiguration() =>
cache.TryGetValue<EmailRequest>("email", out var data) ? Ok(data) : NoContent();
[HttpPost("SetSchedule")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> SetSchedule([FromBody] ScheduleConfigurationRequest request)
{
var general = GeneralConfig;
general.ScheduleSettings = new ScheduleSettings
{
// every 6 hours
CronUpdateSchedule = request.CronUpdateSchedule ?? "0 */6 * * *",
StartTerm = request.StartTerm,
PairPeriod = new Dictionary<int, ScheduleSettings.PairPeriodTime>
{
{1, new ScheduleSettings.PairPeriodTime(new TimeOnly(9, 0, 0), new TimeOnly(10, 30, 0))},
{2, new ScheduleSettings.PairPeriodTime(new TimeOnly(10, 40, 0), new TimeOnly(12, 10, 0))},
{3, new ScheduleSettings.PairPeriodTime(new TimeOnly(12, 40, 0), new TimeOnly(14, 10, 0))},
{4, new ScheduleSettings.PairPeriodTime(new TimeOnly(14, 20, 0), new TimeOnly(15, 50, 0))},
{5, new ScheduleSettings.PairPeriodTime(new TimeOnly(16, 20, 0), new TimeOnly(17, 50, 0))},
{6, new ScheduleSettings.PairPeriodTime(new TimeOnly(18, 0, 0), new TimeOnly(19, 30, 0))},
{7, new ScheduleSettings.PairPeriodTime(new TimeOnly(19, 40, 0), new TimeOnly(21, 10, 0))},
}
};
if (!CronExpression.TryParse(general.ScheduleSettings.CronUpdateSchedule, CronFormat.Standard, out _))
throw new ControllerArgumentException("The Cron task could not be parsed. Check the format of the entered data.");
GeneralConfig = general;
cache.Set("schedule", new ScheduleConfigurationRequest()
{
StartTerm = general.ScheduleSettings.StartTerm,
CronUpdateSchedule = general.ScheduleSettings.CronUpdateSchedule
});
return true;
}
[HttpGet("ScheduleConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<ScheduleConfigurationRequest> ScheduleConfiguration() =>
cache.TryGetValue<ScheduleConfigurationRequest>("schedule", out var data) ? Ok(data) : NoContent();
[HttpPost("SetPasswordPolicy")]
[TokenAuthentication]
public ActionResult<bool> SetPasswordPolicy([FromBody] PasswordPolicy? policy = null)
{
GeneralConfig.PasswordPolicy = policy?.ConvertFromDto() ?? new Security.Common.Model.PasswordPolicy();
cache.Set("password", true);
return true;
}
[HttpGet("PasswordPolicyConfiguration")]
[TokenAuthentication]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<PasswordPolicy> PasswordPolicyConfiguration() =>
cache.TryGetValue("password", out _) ? Ok(GeneralConfig.PasswordPolicy) : NoContent();
[HttpPost("Submit")]
[TokenAuthentication]
[BadRequestResponse]
public ActionResult<bool> Submit()
{
if (!new SettingsRequiredValidator(GeneralConfig).AreSettingsValid())
throw new ControllerArgumentException("The necessary data has not been configured.");
if (!cache.TryGetValue(CacheAdminKey, out Admin? admin) || admin == null)
throw new ControllerArgumentException("The administrator's data was not set.");
admin.SaveSetting();
GeneralConfig.SaveSetting();
return true;
}
}

View File

@ -1,134 +1,271 @@
using Microsoft.AspNetCore.Authorization;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Requests;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Common.Settings;
using Mirea.Api.Security.Common.Dto.Requests;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Security.Services;
using System;
using System.Security;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using CookieOptions = Mirea.Api.Security.Common.Model.CookieOptions;
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, PasswordHashService passwordService) : BaseController, IActionFilter
public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<GeneralConfig> generalConfig, AuthService auth,
PasswordHashService passwordService, OAuthService oAuthService) : BaseController
{
private string Fingerprint { get; set; } = string.Empty;
private string Ip { get; set; } = string.Empty;
private string UserAgent { get; set; } = string.Empty;
private string RefreshToken { get; set; } = string.Empty;
private void SetCookie(string name, string value, DateTimeOffset? expires = null)
{
var cookieOptions = new CookieOptions
private CookieOptions GetCookieParams() =>
new()
{
Expires = expires,
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api",
Domain = UrlHelper.CurrentDomain(ControllerContext.HttpContext),
Secure = true,
HttpOnly = true
Domain = HttpContext.GetCurrentDomain(),
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
};
Response.Cookies.Append(name, value, cookieOptions);
}
private void SetRefreshToken(string value, DateTimeOffset? expires = null) =>
SetCookie("refresh_token", value, expires);
private void SetFirstToken(string value, DateTimeOffset? expires = null) =>
SetCookie("authentication_token", value, expires);
[ApiExplorerSettings(IgnoreApi = true)]
public void OnActionExecuting(ActionExecutingContext context)
private static string GenerateHtmlResponse(
string title,
string message,
Uri? callback,
string traceId,
bool isError)
{
Ip = context.HttpContext.Connection.RemoteIpAddress?.ToString()!;
UserAgent = context.HttpContext.Request.Headers.UserAgent.ToString();
Fingerprint = context.HttpContext.Request.Cookies["user_key"] ?? string.Empty;
RefreshToken = Request.Cookies["refresh_token"] ?? string.Empty;
var callbackUrl = callback?.ToString();
if (!string.IsNullOrWhiteSpace(Fingerprint)) return;
var script = callback == null ? string.Empty :
$"<script>setTimeout(()=>{{window.location.href='{callbackUrl}';}}, {(isError ? 15000 : 5000)});</script>";
Fingerprint = Guid.NewGuid().ToString().Replace("-", "");
SetCookie("user_key", Fingerprint);
var blockInfo = "<p>" + (callback == null ?
"Вернитесь обратно и попробуйте снова позже.</p>" :
$"Если вы не будете автоматически перенаправлены, нажмите ниже.</p>" +
$"<a href=\"{callbackUrl}\" style=\"color:inherit;text-decoration:underline;\">Перейти вручную</a>");
return $"<!DOCTYPE html><html lang=ru><head><meta charset=UTF-8><meta content=\"width=device-width,initial-scale=1\"name=viewport><link href=\"https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap\"rel=stylesheet><style>body{{background-color:#121212;color:#fff;font-family:Roboto,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;text-align:center}}.container{{max-width:600px;padding:20px;border-radius:8px;background-color:#1e1e1e;box-shadow:0 4px 20px rgba(0,0,0,.5)}}h1{{font-size:24px;margin-bottom:20px}}</style><title>{title}</title></head><body><div class=container><h1>{title}</h1>{blockInfo}<p style=font-size:14px;color:silver;>{message}</p><code style=font-size:12px;color:gray;>TraceId={traceId}</code></div>{script}</body></html>";
}
[ApiExplorerSettings(IgnoreApi = true)]
public void OnActionExecuted(ActionExecutedContext context) { }
/// <summary>
/// Handles user authentication by verifying the username/email and password,
/// then generating and returning an authentication token if successful.
/// Handles the callback from an OAuth2 provider and finalizes the authorization process.
/// </summary>
/// <param name="request">The login request containing the username/email and password.</param>
/// <returns>A TokenResponse containing the access token and its expiry if successful, otherwise an Unauthorized response.</returns>
/// <remarks>
/// This method processes the response from an OAuth provider after the user authorizes the application.
/// Upon successful authorization, it redirects the user back to the specified callback URL.
/// </remarks>
/// <param name="code">The authorization code returned by the OAuth provider.</param>
/// <param name="state">The state parameter to ensure the request's integrity and prevent CSRF attacks.</param>
/// <returns>
/// An HTML response indicating the success or failure of the authorization process.
/// If a callback URL is provided, the user will be redirected to it.
/// </returns>
[HttpGet("OAuth2")]
[BadRequestResponse]
[Produces("text/html")]
[MaintenanceModeIgnore]
public async Task<ContentResult> OAuth2([FromQuery] string? code, [FromQuery] string? state)
{
var traceId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
return Content(GenerateHtmlResponse(
"Ошибка передачи данных!",
"Провайдер OAuth не передал нужных данных.",
null,
traceId,
true), "text/html");
var result = await oAuthService.LoginOAuth(GetCookieParams(), HttpContext,
HttpContext.GetApiUrl(Url.Action("OAuth2")!), code, state);
string? callbackUrl = null;
if (result.Callback != null)
callbackUrl = result.Callback + (result.Callback.Query.Length > 0 ? "&" : "?") +
$"result={Uri.EscapeDataString(result.Token)}";
string title, message;
if (!result.Success)
{
if (callbackUrl != null)
callbackUrl += $"&traceId={Uri.EscapeDataString(traceId)}";
title = "Ошибка авторизации!";
message = result.ErrorMessage ?? "Произошла ошибка. Попробуйте ещё раз.";
}
else
{
title = "Авторизация завершена!";
message = "Вы будете перенаправлены обратно через несколько секунд.";
}
return Content(GenerateHtmlResponse(
title,
message,
callbackUrl == null ? null : new Uri(callbackUrl),
traceId,
!result.Success), "text/html");
}
/// <summary>
/// Initiates the OAuth2 authorization process for the selected provider.
/// </summary>
/// <remarks>
/// This method generates a redirect URL for the selected provider and redirects the user to it.
/// </remarks>
/// <param name="provider">The identifier of the OAuth provider to authorize with.</param>
/// <param name="callback">The address where the user will need to be redirected after the end of communication with the OAuth provider</param>
/// <returns>A redirect to the OAuth provider's authorization URL.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the specified provider is not valid.</exception>
[HttpGet("AuthorizeOAuth2")]
[MaintenanceModeIgnore]
public ActionResult AuthorizeOAuth2([FromQuery] int provider, [FromQuery] Uri callback)
{
if (!Enum.IsDefined(typeof(OAuthProvider), provider))
throw new ControllerArgumentException("There is no selected provider");
if (!callback.IsAbsoluteUri)
throw new ControllerArgumentException("The callback URL must be absolute.");
return Redirect(oAuthService.GetProviderRedirect(GetCookieParams(), HttpContext,
HttpContext.GetApiUrl(Url.Action("OAuth2")!),
(OAuthProvider)provider,
callback).AbsoluteUri);
}
/// <summary>
/// Retrieves a list of available OAuth providers with their corresponding authorization URLs.
/// </summary>
/// <remarks>
/// This allows the client to fetch all possible OAuth options and the URLs required to initiate authorization.
/// </remarks>
/// <returns>A list of available providers and their redirect URLs.</returns>
[HttpGet("AvailableProviders")]
[MaintenanceModeIgnore]
public ActionResult<List<AvailableOAuthProvidersResponse>> AvailableProviders([FromQuery] Uri callback) =>
Ok(oAuthService
.GetAvailableProviders(HttpContext.GetApiUrl(Url.Action("AuthorizeOAuth2")!))
.Select(x =>
{
if (!callback.IsAbsoluteUri)
throw new ControllerArgumentException("The callback URL must be absolute.");
x.Redirect = new Uri(x.Redirect + "&callback=" + Uri.EscapeDataString(callback.AbsoluteUri));
return x;
})
.ConvertToDto());
/// <summary>
/// Processes the OAuth token
/// </summary>
/// <param name="token">The OAuth token used for authentication or binding.</param>
/// <param name="action">The action to be performed: Login or Bind.</param>
/// <returns>If <see cref="OAuthAction.Bind"/> return Ok. If <see cref="OAuthAction.Login"/> return <see cref="TwoFactorAuthentication"/></returns>
[HttpGet("HandleToken")]
[BadRequestResponse]
public async Task<ActionResult> HandleToken([FromQuery][MinLength(2)] string token, [FromQuery] OAuthAction action)
{
var (oAuthUser, error, isSuccess, provider) = await oAuthService.GetOAuthUser(GetCookieParams(), HttpContext, token);
if (!isSuccess || oAuthUser == null || provider == null)
throw new ControllerArgumentException(error ?? "Token processing error.");
switch (action)
{
case OAuthAction.Login:
return Ok(await auth.LoginOAuthAsync(GetCookieParams(), HttpContext, user.Value.ConvertToSecurity(), oAuthUser, provider.Value));
case OAuthAction.Bind:
var userId = HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
var admin = user.Value;
if (string.IsNullOrEmpty(userId) || !int.TryParse(userId, out var result) || result != 1)
return Unauthorized(new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2",
Title = "Unauthorized",
Status = StatusCodes.Status401Unauthorized,
Detail = "The user is not logged in to link accounts.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
if (admin.OAuthProviders != null && admin.OAuthProviders.ContainsKey(provider.Value))
return Conflict(new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10",
Title = "Conflict",
Status = StatusCodes.Status409Conflict,
Detail = "This OAuth provider is already associated with the account.",
Extensions = new Dictionary<string, object?>()
{
{ "traceId", Activity.Current?.Id ?? HttpContext.TraceIdentifier }
}
});
admin.OAuthProviders ??= [];
admin.OAuthProviders.Add(provider.Value, oAuthUser);
admin.SaveSetting();
return Ok();
default:
throw new ControllerArgumentException("The action cannot be processed.");
}
}
/// <summary>
/// Logs in a user using their username or email and password.
/// </summary>
/// <param name="request">The login request containing username/email and password.</param>
/// <returns>A TwoFactorAuthentication token if the login is successful; otherwise, a BadRequest response.</returns>
[HttpPost("Login")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenResponse>> Login([FromBody] LoginRequest request)
[BadRequestResponse]
public async Task<ActionResult<TwoFactorAuthentication>> Login([FromBody] LoginRequest request)
{
var userEntity = user.Value;
if (!userEntity.Username.Equals(request.Username, StringComparison.OrdinalIgnoreCase) &&
!userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase) ||
!passwordService.VerifyPassword(request.Password, userEntity.Salt, userEntity.PasswordHash))
return Unauthorized("Invalid username/email or password");
var tokenResult = await auth.LoginAsync(
GetCookieParams(),
HttpContext,
userEntity.ConvertToSecurity(),
request.Password, request.Username);
var token = await auth.GenerateAuthTokensAsync(new TokenRequest
{
Fingerprint = Fingerprint,
Ip = Ip,
UserAgent = UserAgent
}, "1");
SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn);
return Ok(new TokenResponse
{
AccessToken = token.AccessToken,
ExpiresIn = token.AccessExpiresIn
});
return Ok(tokenResult.ConvertToDto());
}
/// <summary>
/// Performs two-factor authentication for the user.
/// </summary>
/// <param name="request">The request containing the method and code for two-factor authentication.</param>
/// <returns>A boolean indicating whether the two-factor authentication was successful.</returns>
[HttpPost("2FA")]
[BadRequestResponse]
public async Task<ActionResult<bool>> TwoFactorAuth([FromBody] TwoFactorAuthRequest request) =>
await auth.LoginAsync(GetCookieParams(), HttpContext, request.Method.ConvertFromDto(), request.Code);
/// <summary>
/// Refreshes the authentication token using the existing refresh token.
/// </summary>
/// <returns>A TokenResponse containing the new access token and its expiry if successful, otherwise an Unauthorized response.</returns>
/// <returns>User's AuthRoles.</returns>
[HttpGet("ReLogin")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenResponse>> ReLogin()
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AuthRoles>> ReLogin()
{
if (string.IsNullOrEmpty(RefreshToken))
return Unauthorized();
try
{
var token = await auth.RefreshTokenAsync(
new TokenRequest
{
Ip = Ip,
UserAgent = UserAgent,
Fingerprint = Fingerprint
},
RefreshToken
);
SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn);
return Ok(new TokenResponse
{
AccessToken = token.AccessToken,
ExpiresIn = token.AccessExpiresIn
});
}
catch (SecurityException)
{
return Unauthorized();
}
await auth.RefreshTokenAsync(GetCookieParams(), HttpContext);
return Ok(AuthRoles.Admin);
}
/// <summary>
@ -136,15 +273,9 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
/// </summary>
/// <returns>An Ok response if the logout was successful.</returns>
[HttpGet("Logout")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Authorize]
public async Task<ActionResult> Logout()
{
SetRefreshToken("", DateTimeOffset.MinValue);
SetFirstToken("", DateTimeOffset.MinValue);
await auth.LogoutAsync(Fingerprint);
await auth.LogoutAsync(GetCookieParams(), HttpContext);
return Ok();
}
@ -156,4 +287,32 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Authorize]
public ActionResult<AuthRoles> GetRole() => Ok(AuthRoles.Admin);
}
[HttpPost("RenewPassword")]
[ApiExplorerSettings(IgnoreApi = true)]
[Localhost]
[BadRequestResponse]
public ActionResult<string> RenewPassword([FromBody] string? password = null)
{
var passwordPolicy = generalConfig.Value.PasswordPolicy;
var passwordPolicyService = new PasswordPolicyService(passwordPolicy);
if (string.IsNullOrEmpty(password))
password = string.Empty;
else
passwordPolicyService.ValidatePasswordOrThrow(password);
while (!passwordPolicyService.TryValidatePassword(password))
password = GeneratorKey.GenerateAlphaNumeric(passwordPolicy.MinimumLength + 2, includes: "!@#%^");
var (salt, hash) = passwordService.HashPassword(password);
var admin = user.Value;
admin.Salt = salt;
admin.PasswordHash = hash;
admin.SaveSetting();
return Ok(password);
}
}

View File

@ -1,5 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.DataAccess.Application.Cqrs.Campus.Queries.GetCampusBasicInfoList;
using Mirea.Api.DataAccess.Application.Cqrs.Campus.Queries.GetCampusDetails;
@ -12,6 +12,7 @@ using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class CampusController(IMediator mediator) : BaseController
{
/// <summary>
@ -19,7 +20,6 @@ public class CampusController(IMediator mediator) : BaseController
/// </summary>
/// <returns>Basic information about campuses.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<List<CampusBasicInfoResponse>>> Get()
{
var result = await mediator.Send(new GetCampusBasicInfoListQuery());
@ -40,7 +40,6 @@ public class CampusController(IMediator mediator) : BaseController
/// <param name="id">Campus ID.</param>
/// <returns>Details of the specified campus.</returns>
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<CampusDetailsResponse>> GetDetails(int id)

View File

@ -0,0 +1,219 @@
using Asp.Versioning;
using Cronos;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mirea.Api.DataAccess.Persistence;
using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Responses.Configuration;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Sync;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1.Configuration;
[ApiVersion("1.0")]
public class ScheduleController(ILogger<ScheduleController> logger, IOptionsSnapshot<GeneralConfig> config, UberDbContext dbContext, IServiceProvider provider) : ConfigurationBaseController
{
/// <summary>
/// Retrieves the cron update schedule and calculates the next scheduled tasks based on the provided depth.
/// </summary>
/// <param name="depth">The depth of the next tasks to retrieve.</param>
/// <returns>Cron expression and the list of next scheduled task dates.</returns>
[HttpGet("CronUpdateSchedule")]
public ActionResult<CronUpdateScheduleResponse> CronUpdateSchedule([FromQuery][Range(0, 10)] int depth = 5)
{
var cronExpression = CronExpression.Parse(config.Value.ScheduleSettings!.CronUpdateSchedule);
var nextTasks = config.Value.ScheduleSettings!.CronUpdateSkipDateList.GetNextTask(cronExpression, depth);
return new CronUpdateScheduleResponse()
{
Cron = config.Value.ScheduleSettings!.CronUpdateSchedule,
NextStart = nextTasks.Select(x => DateTime.SpecifyKind(x.DateTime, DateTimeKind.Local)).ToList()
};
}
/// <summary>
/// Updates the cron update schedule with the provided cron expression.
/// </summary>
/// <param name="cron">The cron expression to set as the new schedule.</param>
/// <returns>Cron expression and the list of next scheduled task dates.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the provided cron expression is invalid.</exception>
[HttpPost("CronUpdateSchedule")]
public ActionResult<CronUpdateScheduleResponse> CronUpdateSchedule([FromQuery] string cron)
{
cron = cron.Trim();
if (!CronExpression.TryParse(cron, CronFormat.Standard, out _))
throw new ControllerArgumentException("Incorrect cron value.");
if (config.Value.ScheduleSettings!.CronUpdateSchedule == cron)
return CronUpdateSchedule();
config.Value.ScheduleSettings!.CronUpdateSchedule = cron;
config.Value.SaveSetting();
return CronUpdateSchedule();
}
/// <summary>
/// Retrieves the start term date from the configuration.
/// </summary>
/// <returns>Start term date.</returns>
[HttpGet("StartTerm")]
public ActionResult<DateOnly> StartTerm() =>
config.Value.ScheduleSettings!.StartTerm;
/// <summary>
/// Updates the start term date in the configuration.
/// </summary>
/// <param name="startTerm">The new start term date to set.</param>
/// <param name="force">If true, forces an update by deleting all existing lessons.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the start term date is more than 6 months in the past or future.</exception>
[HttpPost("StartTerm")]
public ActionResult StartTerm([FromQuery] DateOnly startTerm, [FromQuery] bool force = false)
{
var differentByTime = DateTime.Now - startTerm.ToDateTime(new TimeOnly(0, 0, 0));
if (differentByTime > TimeSpan.FromDays(190) || differentByTime.Multiply(-1) > TimeSpan.FromDays(190))
throw new ControllerArgumentException("The semester can't start more than 6 months from now, and it can't have started more than 6 months ago either.");
config.Value.ScheduleSettings!.StartTerm = startTerm;
config.Value.SaveSetting();
if (!force)
return Ok();
logger.LogWarning("A force update is being performed at the beginning of the semester (all classes will be deleted).");
dbContext.Lessons.RemoveRange(dbContext.Lessons.ToList());
dbContext.SaveChanges();
return Ok();
}
/// <summary>
/// Retrieves the list of cron update skip dates filtered by the current date.
/// </summary>
/// <returns>Cron update skip dates.</returns>
[HttpGet("CronUpdateSkip")]
public ActionResult<List<CronUpdateSkip>> CronUpdateSkip()
{
var generalConfig = config.Value;
generalConfig.ScheduleSettings!.CronUpdateSkipDateList =
generalConfig.ScheduleSettings.CronUpdateSkipDateList.Filter();
generalConfig.SaveSetting();
return generalConfig.ScheduleSettings!.CronUpdateSkipDateList
.ConvertToDto();
}
/// <summary>
/// Updates the list of cron update skip dates in the configuration.
/// </summary>
/// <param name="cronUpdateDate">The list of cron update skip dates to set.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the provided list of cron update skip dates is invalid.</exception>
[HttpPost("CronUpdateSkip")]
public ActionResult CronUpdateSkip([FromBody] List<CronUpdateSkip> cronUpdateDate)
{
List<ScheduleSettings.CronUpdateSkip> result;
try
{
result = cronUpdateDate.ConvertFromDto();
}
catch (ArgumentException ex)
{
throw new ControllerArgumentException(ex.Message);
}
config.Value.ScheduleSettings!.CronUpdateSkipDateList = result.Filter();
config.Value.SaveSetting();
return Ok();
}
/// <summary>
/// Uploads schedule files and initiates synchronization.
/// </summary>
/// <param name="files">The list of schedule files to upload.</param>
/// <param name="defaultCampus">The default campus for each uploaded file. Must match the number of files.</param>
/// <param name="force">If true, removes all existing lessons before synchronization. Default is false.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">
/// Thrown if:
/// - No files are provided.
/// - The number of default campuses does not match the number of files.
/// - Any default campus is null or empty.
/// </exception>
[HttpPost("Upload")]
public async Task<ActionResult> UploadScheduleFiles(List<IFormFile>? files, [FromQuery] string[]? defaultCampus, [FromQuery] bool force = false)
{
if (files == null || files.Count == 0)
throw new ControllerArgumentException("No files were found.");
if (defaultCampus == null || files.Count != defaultCampus.Length)
throw new ControllerArgumentException("No default campuses are specified for the file.");
if (defaultCampus.Any(string.IsNullOrEmpty))
throw new ControllerArgumentException("Each file should have a default campus.");
var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
if (!Directory.Exists(tempDirectory))
Directory.CreateDirectory(tempDirectory);
List<(string, string)> filePaths = [];
for (var i = 0; i < files.Count; i++)
{
if (files[i].Length <= 0)
continue;
var filePath = Path.Combine(tempDirectory, files[i].FileName);
await using var stream = new FileStream(filePath, FileMode.Create);
await files[i].CopyToAsync(stream);
filePaths.Add((filePath, defaultCampus[i]));
}
if (force)
{
dbContext.Lessons.RemoveRange(await dbContext.Lessons.ToListAsync());
await dbContext.SaveChangesAsync();
}
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
ThreadPool.QueueUserWorkItem(async void (_) =>
{
try
{
using var scope = scopeFactory.CreateScope();
var sync = (ScheduleSynchronizer)ActivatorUtilities.GetServiceOrCreateInstance(scope.ServiceProvider, typeof(ScheduleSynchronizer));
await sync.StartSync(filePaths, CancellationToken.None);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
});
return Ok();
}
}

View File

@ -1,17 +1,19 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.DataAccess.Application.Cqrs.Discipline.Queries.GetDisciplineDetails;
using Mirea.Api.DataAccess.Application.Cqrs.Discipline.Queries.GetDisciplineList;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class DisciplineController(IMediator mediator) : BaseController
{
/// <summary>
@ -21,9 +23,9 @@ public class DisciplineController(IMediator mediator) : BaseController
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of disciplines.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[BadRequestResponse]
public async Task<ActionResult<List<DisciplineResponse>>> Get([FromQuery] int? page, [FromQuery] int? pageSize)
public async Task<ActionResult<List<DisciplineResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
{
var result = await mediator.Send(new GetDisciplineListQuery()
{
@ -46,7 +48,6 @@ public class DisciplineController(IMediator mediator) : BaseController
/// <param name="id">Discipline ID.</param>
/// <returns>Details of the specified discipline.</returns>
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<DisciplineResponse>> GetDetails(int id)

View File

@ -1,17 +1,18 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.DataAccess.Application.Cqrs.Faculty.Queries.GetFacultyDetails;
using Mirea.Api.DataAccess.Application.Cqrs.Faculty.Queries.GetFacultyList;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class FacultyController(IMediator mediator) : BaseController
{
/// <summary>
@ -21,9 +22,9 @@ public class FacultyController(IMediator mediator) : BaseController
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of faculties.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[BadRequestResponse]
public async Task<ActionResult<List<FacultyResponse>>> Get([FromQuery] int? page, [FromQuery] int? pageSize)
public async Task<ActionResult<List<FacultyResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
{
var result = await mediator.Send(new GetFacultyListQuery()
{
@ -35,35 +36,8 @@ public class FacultyController(IMediator mediator) : BaseController
.Select(f => new FacultyResponse()
{
Id = f.Id,
Name = f.Name,
CampusId = f.CampusId
Name = f.Name
})
);
}
/// <summary>
/// Gets details of a specific faculty by ID.
/// </summary>
/// <param name="id">Faculty ID.</param>
/// <returns>Details of the specified faculty.</returns>
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<FacultyDetailsResponse>> GetDetails(int id)
{
var result = await mediator.Send(new GetFacultyInfoQuery()
{
Id = id
});
return Ok(new FacultyDetailsResponse()
{
Id = result.Id,
Name = result.Name,
CampusId = result.CampusId,
CampusCode = result.CampusCode,
CampusName = result.CampusName
});
}
}

View File

@ -1,5 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.DataAccess.Application.Cqrs.Group.Queries.GetGroupDetails;
using Mirea.Api.DataAccess.Application.Cqrs.Group.Queries.GetGroupList;
@ -7,12 +7,14 @@ using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class GroupController(IMediator mediator) : BaseController
{
private static int GetCourseNumber(string groupName)
@ -25,7 +27,7 @@ public class GroupController(IMediator mediator) : BaseController
// Convert a two-digit year to a four-digit one
yearOfGroup += current.Year / 100 * 100;
return current.Year - yearOfGroup + (current.Month < 9 ? 0 : 1);
return current.Year - yearOfGroup + (current.Month < 8 ? 0 : 1);
}
/// <summary>
@ -35,9 +37,9 @@ public class GroupController(IMediator mediator) : BaseController
/// <param name="pageSize">The page size for pagination (optional).</param>
/// <returns>A list of groups.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[BadRequestResponse]
public async Task<ActionResult<List<GroupResponse>>> Get([FromQuery] int? page, [FromQuery] int? pageSize)
public async Task<ActionResult<List<GroupResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
{
var result = await mediator.Send(new GetGroupListQuery()
{
@ -62,7 +64,6 @@ public class GroupController(IMediator mediator) : BaseController
/// <param name="id">The ID of the group to retrieve.</param>
/// <returns>Detailed information about the group.</returns>
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<GroupDetailsResponse>> GetDetails(int id)
@ -88,7 +89,6 @@ public class GroupController(IMediator mediator) : BaseController
/// <param name="id">The ID of the faculty.</param>
/// <returns>A list of groups belonging to the specified faculty.</returns>
[HttpGet("GetByFaculty/{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<List<GroupResponse>>> GetByFaculty(int id)

View File

@ -0,0 +1,165 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Mirea.Api.DataAccess.Application.Cqrs.Schedule.Queries.GetScheduleList;
using Mirea.Api.Dto.Requests;
using Mirea.Api.Endpoint.Configuration.Model;
using OfficeOpenXml;
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig> config) : BaseController
{
// todo: transfer data to storage
private static string GetFaculty(char c) =>
c switch
{
'У' => "ИТУ",
'Б' => "ИКБ",
'Х' => "ИТХТ",
'Э' => "ИПТИП",
'Т' => "ИПТИП",
'Р' => "ИРИ",
'К' => "ИИИ",
'И' => "ИИТ",
'П' => "ИИТ",
_ => throw new ArgumentOutOfRangeException(nameof(c), c, null)
};
/// <summary>
/// Creates an Excel file based on a schedule filter
/// </summary>
/// <param name="request">The request object containing filter criteria.</param>
/// <returns>Excel file</returns>
[HttpPost("ImportToExcel")]
[Produces("application/vnd.ms-excel")]
public async Task<FileStreamResult> ImportToExcel([FromBody] ScheduleRequest request)
{
var result = (await mediator.Send(new GetScheduleListQuery
{
IsEven = request.IsEven,
DisciplineIds = request.Disciplines,
GroupIds = request.Groups,
LectureHallIds = request.LectureHalls,
ProfessorIds = request.Professors,
LessonTypeIds = request.LessonType
})).Schedules.ToList();
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
using var package = new ExcelPackage();
var worksheet = package.Workbook.Worksheets.Add("Расписание");
var row = 1;
var col = 1;
worksheet.Cells[row, col++].Value = "День";
worksheet.Cells[row, col++].Value = "Пара";
worksheet.Cells[row, col++].Value = "Неделя";
worksheet.Cells[row, col++].Value = "Время";
worksheet.Cells[row, col++].Value = "Группа";
worksheet.Cells[row, col++].Value = "Институт";
worksheet.Cells[row, col++].Value = "Курс";
worksheet.Cells[row, col++].Value = "Дисциплина";
worksheet.Cells[row, col++].Value = "Преподаватель";
worksheet.Cells[row, col++].Value = "Вид";
worksheet.Cells[row, col++].Value = "Кампус";
worksheet.Cells[row, col].Value = "Ауд.";
row++;
col = 1;
var pairsDictionary = config.Value.ScheduleSettings!.PairPeriod;
var ruCulture = new CultureInfo("ru-RU");
foreach (var dto in result.GroupBy(s => new
{
s.DayOfWeek,
s.PairNumber,
s.IsEven,
s.DisciplineId,
TypeOfOccupations = string.Join(',', s.TypeOfOccupations.OrderBy(x => x)),
LectureHalls = string.Join(',', s.LectureHalls.OrderBy(x => x)),
Campus = string.Join(',', s.Campus.OrderBy(x => x)),
Professors = string.Join(',', s.Professors.OrderBy(x => x))
})
.Select(g => new
{
g.Key.DayOfWeek,
g.Key.PairNumber,
g.Key.IsEven,
g.First().Discipline,
g.First().LectureHalls,
g.First().Campus,
g.First().Professors,
Groups = string.Join('\n', g.Select(x => x.Group)),
IsExclude = g.First().IsExcludedWeeks,
g.First().TypeOfOccupations,
g.First().Weeks
})
.ToList())
{
// День
worksheet.Cells[row, col++].Value =
$"{(int)dto.DayOfWeek} [{ruCulture.DateTimeFormat.GetAbbreviatedDayName(dto.DayOfWeek).ToUpper()}]";
// Пара
worksheet.Cells[row, col++].Value = dto.PairNumber + " п";
// Неделя
worksheet.Cells[row, col++].Value = $"[{(dto.IsEven ? 2 : 1)}] {(dto.IsEven ? "Четная" : "Нечетная")}";
// Время
worksheet.Cells[row, col++].Value = pairsDictionary[dto.PairNumber].Start.ToString(ruCulture);
// Группа
worksheet.Cells[row, col].Style.WrapText = true;
worksheet.Cells[row, col++].Value = dto.Groups;
var groupTemplate = dto.Groups.Split('\n')[0];
// Институт
worksheet.Cells[row, col++].Value = GetFaculty(groupTemplate[0]);
// Курс
worksheet.Cells[row, col++].Value = groupTemplate[2] == 'М' ?
'М' :
(24 - int.Parse(groupTemplate.Split(' ')[0].Split('-').TakeLast(1).ElementAt(0)) + 1).ToString();
var disciplineAdditional = string.Empty;
if (dto.IsExclude.HasValue && dto.Weeks != null && dto.Weeks.Any())
disciplineAdditional += $"{(dto.IsExclude.Value ? "Кр. " : "")}{string.Join(", ", dto.Weeks.OrderBy(x => x))} н. ";
// Дисциплина
worksheet.Cells[row, col++].Value = disciplineAdditional + dto.Discipline;
// Преподаватель
worksheet.Cells[row, col++].Value = dto.Professors;
// Вид
worksheet.Cells[row, col++].Value = dto.TypeOfOccupations.FirstOrDefault();
// Кампус
worksheet.Cells[row, col++].Value = dto.Campus.FirstOrDefault()?.Replace("С-20", "С20").Replace("В-78", "В78");
// Ауд.
worksheet.Cells[row, col].Value = dto.LectureHalls;
col = 1;
row++;
}
worksheet.Cells[1, 1, 1, 12].AutoFilter = true;
worksheet.Cells[worksheet.Dimension.Address].AutoFitColumns();
var stream = new MemoryStream();
await package.SaveAsAsync(stream);
stream.Position = 0;
return File(stream, "application/vnd.ms-excel", "data.xlsx");
}
}

View File

@ -1,5 +1,5 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.DataAccess.Application.Cqrs.LectureHall.Queries.GetLectureHallDetails;
using Mirea.Api.DataAccess.Application.Cqrs.LectureHall.Queries.GetLectureHallList;
@ -12,6 +12,7 @@ using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class LectureHallController(IMediator mediator) : BaseController
{
/// <summary>
@ -19,7 +20,6 @@ public class LectureHallController(IMediator mediator) : BaseController
/// </summary>
/// <returns>A list of lecture halls.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<List<LectureHallResponse>>> Get()
{
var result = await mediator.Send(new GetLectureHallListQuery());
@ -40,7 +40,6 @@ public class LectureHallController(IMediator mediator) : BaseController
/// <param name="id">The ID of the lecture hall to retrieve.</param>
/// <returns>The details of the specified lecture hall.</returns>
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<LectureHallDetailsResponse>> GetDetails(int id)
@ -66,7 +65,6 @@ public class LectureHallController(IMediator mediator) : BaseController
/// <param name="id">The ID of the campus.</param>
/// <returns>A list of lecture halls in the specified campus.</returns>
[HttpGet("GetByCampus/{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<List<LectureHallResponse>>> GetByCampus(int id)

Some files were not shown because too many files have changed in this diff Show More