Compare commits

..

362 Commits

Author SHA1 Message Date
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
c189cc6955 docs: add readme file
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m56s
.NET Test Pipeline / build-and-test (push) Successful in 2m49s
2024-08-10 22:34:52 +03:00
c7b401eae7 fix: save log with PathBuilder 2024-08-10 22:04:11 +03:00
837205f66e build: update ref
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 8m20s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 9m46s
2024-07-24 21:50:04 +03:00
c6ca717b89 feat: add new endpoint
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 3m10s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 3m18s
2024-07-08 01:56:14 +03:00
497b7f146b fix: add use forwarded headers and clear known
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m39s
.NET Test Pipeline / build-and-test (push) Successful in 2m20s
2024-07-08 00:00:32 +03:00
3326b17d74 fix: try disable origins
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 3m31s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 3m58s
2024-07-07 23:20:54 +03:00
80b46754ad feat: add new generator key
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 5m10s
.NET Test Pipeline / build-and-test (push) Successful in 5m43s
2024-07-05 23:14:45 +03:00
3898463bc4 build: set SWAGGER_SUB_PATH
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m1s
.NET Test Pipeline / build-and-test (push) Successful in 2m43s
2024-07-05 02:50:20 +03:00
279ca9647b fix: path to wwwroot 2024-07-05 02:50:02 +03:00
ab660f69c8 fix: add listen any ip instead localhost
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 2m3s
.NET Test Pipeline / build-and-test (push) Successful in 2m42s
2024-07-05 02:37:12 +03:00
1c27bffa73 build: remove pdb files
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 1m48s
.NET Test Pipeline / build-and-test (push) Successful in 2m6s
2024-07-05 02:28:18 +03:00
76fd1347ce build: add ACTUAL_SUB_PATH 2024-07-05 01:59:54 +03:00
820828276e fix: get sub url without first "api" 2024-07-05 01:59:36 +03:00
ac4804e864 fix: get host name without port 2024-07-05 01:58:14 +03:00
21055176ac fix: check file exist 2024-07-05 01:57:34 +03:00
f42caa3a45 feat: add sub path for actual url 2024-07-05 01:35:19 +03:00
57f4d1b822 fix: add test env to variablesFromFile 2024-07-05 01:22:21 +03:00
cdb738ca42 fix: set default port 8080 2024-07-05 01:11:24 +03:00
d45c865f4e feat: add listen port from env 2024-07-05 00:44:55 +03:00
d87654a355 ref: update 2024-07-04 23:57:44 +03:00
75c1aebea6 refactor: change namespace 2024-07-04 23:54:17 +03:00
17e20fee2e refactor: Admin 2024-07-04 23:52:25 +03:00
e8ca2c42a6 sec: add random scret forward token for set ip if app under proxy 2024-07-04 23:46:43 +03:00
9133b57a1b refactor: GeneralConfig 2024-07-04 23:45:33 +03:00
05ca45db49 perf: precompile regex 2024-07-04 23:39:12 +03:00
7e2016080f style: compact css 2024-07-04 23:37:16 +03:00
fe24dfcd6a perf: return Dictionary instead interface 2024-07-04 23:31:05 +03:00
2041a187e7 fix: create directory if not exist 2024-07-04 22:40:59 +03:00
098fca5df8 build: fix copy docs
All checks were successful
Build and Deploy Docker Container / build-and-deploy (push) Successful in 4m24s
.NET Test Pipeline / build-and-test (push) Successful in 7m34s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-07-03 11:04:34 +03:00
e0cff050de build: fix endpoint name
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 3m31s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 7m39s
2024-07-03 10:10:58 +03:00
2f7c77e764 fix: try get value
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
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-07-03 10:09:50 +03:00
7e82d4a520 build: fix name of program
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m8s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 9m4s
2024-07-03 01:09:06 +03:00
07edf0e5ad build: fix restart policy
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 3m4s
Build and Deploy Docker Container / build-and-deploy (push) Successful in 6m4s
2024-07-03 00:15:41 +03:00
5b8d9e1f4a build: add secret env
Some checks failed
.NET Test Pipeline / build-and-test (push) Successful in 2m53s
Build and Deploy Docker Container / build-and-deploy (push) Failing after 1m39s
2024-07-03 00:05:01 +03:00
de2f909ed6 build: try create image
Some checks failed
.NET Test Pipeline / build-and-test (push) Successful in 4m26s
Build and Deploy Docker Container / build-and-deploy (push) Failing after 3m24s
2024-07-02 23:42:29 +03:00
2e2cee2ca7 build: change image name
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 0s
Build and Deploy Docker Container / build_and_deploy (push) Failing after 1m46s
2024-07-02 23:33:22 +03:00
8c340e2a97 build: add deploy to server
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 0s
Build and Deploy Docker Container / build_and_deploy (push) Failing after 38s
2024-07-02 23:24:28 +03:00
9abdb1ac43 build: create dockerfile 2024-07-02 23:24:00 +03:00
42f0b8ee0e Merge pull request 'Add authentication methods to access protected resources' (#15) from feat/auth into release/v1.0.0
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 3m48s
Reviewed-on: #15
2024-06-28 23:14:17 +03:00
41b5bb571b docs: add xml comments
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 5m16s
2024-06-28 22:55:18 +03:00
2c112d00df fix: add Admin model to configuration 2024-06-28 22:52:58 +03:00
f89136669d fix: change RT in cache after generation 2024-06-28 22:52:05 +03:00
612efcb91c fix: exception if value is null
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 2m53s
2024-06-28 01:49:45 +03:00
8d4c482bbd fix: set correct cache key
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 3m54s
2024-06-27 23:37:40 +03:00
160c7505f0 feat: add controller for authentication
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 3m45s
2024-06-21 21:52:21 +03:00
c62ec33130 feat: add user roles 2024-06-21 21:51:23 +03:00
55371cb675 feat: add request to log in 2024-06-21 21:51:06 +03:00
a36e0694ec feat: add token response 2024-06-21 21:50:42 +03:00
1a0d539e76 fix: if cache get bytes then skip serealize 2024-06-21 21:44:34 +03:00
79151e7da8 feat: add bearer auth to swagger 2024-06-21 21:43:40 +03:00
039d323643 fix: add refresh expire date 2024-06-21 21:36:11 +03:00
a5f9e67647 feat: add middleware for revocated tokens 2024-06-15 21:53:00 +03:00
21866d54cb fix: add private to get EncryptKey
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m34s
2024-06-13 00:42:41 +03:00
eba11f515d fix: singleton added in JwtTokenService 2024-06-13 00:42:15 +03:00
70780d620a fix: error CS0246
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 3m14s
2024-06-10 22:15:15 +03:00
7d3952c373 build: upgrade reference
Some checks failed
.NET Test Pipeline / build-and-test (push) Failing after 5m0s
2024-06-10 22:05:18 +03:00
0ecb796d54 fix: set default settings for logging if null or empty 2024-06-10 22:04:29 +03:00
2e389b252c refactor: use default pairPeriod 2024-06-10 22:03:48 +03:00
984791f193 style: change template for log message 2024-06-10 22:02:29 +03:00
b640902777 feat: add dark theme for swagger 2024-06-10 22:01:58 +03:00
4222e4702f refactor: provide single response for schedule 2024-06-10 21:59:34 +03:00
993e66a084 fix: add JsonIgnore to calculated property
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 3m46s
2024-06-01 11:11:21 +03:00
6797adac4f fix: get GeneralConfig 2024-06-01 11:10:42 +03:00
8e58c83526 build: ignore files
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 2m14s
2024-06-01 11:01:08 +03:00
2e64caf6ea refactor: code cleaning 2024-06-01 10:59:23 +03:00
9c56fa582b build: upgrade reference 2024-06-01 10:58:43 +03:00
b2a0a6dd7c feat: add default value attribute 2024-06-01 10:58:11 +03:00
78f589bb18 refactor: distribute configurations by classes 2024-06-01 10:57:52 +03:00
ca02509b97 build: add needed reference 2024-06-01 10:55:43 +03:00
f3a757d33d feat: add redis 2024-06-01 10:53:21 +03:00
34addd930f feat: add serilog for logging 2024-06-01 10:50:38 +03:00
054d319f7c Add an Administrator (#14)
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m57s
Reviewed-on: #14
2024-06-01 08:27:01 +03:00
2addd2aa78 feat: use Admin model
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 4m56s
2024-06-01 08:20:27 +03:00
bba9431733 feat: add data checks 2024-06-01 08:19:26 +03:00
6fb5a83183 feat: add model to endpoint 2024-06-01 08:18:27 +03:00
ea538ac340 Add Application configuration (#11)
All checks were successful
.NET Test Pipeline / build-and-test (push) Successful in 1m43s
Reviewed-on: #11
2024-06-01 07:35:29 +03:00
63216f3b66 feat: comment this for show controller in swagger
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m48s
2024-06-01 07:33:08 +03:00
e088374b14 feat: add request for create user 2024-06-01 07:31:14 +03:00
ded577f40a feat: add path depending on OS
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 1m56s
2024-06-01 07:27:18 +03:00
32621515db fix: default value is null for optional body 2024-06-01 07:26:22 +03:00
fdf0ecc9ef feat: add is default path 2024-06-01 07:25:51 +03:00
5400e0c873 feat: create submit configuration
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 6m2s
2024-06-01 06:29:16 +03:00
1fd6c8657a fix: change connction string for mysql 2024-06-01 06:28:13 +03:00
d09011d25a feat: add create admin 2024-06-01 06:27:49 +03:00
a902d9eb81 Use the configuration depending on the selected database provider (#13)
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 2m39s
Reviewed-on: #13
2024-06-01 05:45:17 +03:00
827cdaf9f9 refactor: change create database to migrate
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 2m1s
2024-06-01 05:43:00 +03:00
d2ba2d982c feat: add migrations 2024-06-01 05:42:23 +03:00
79118c5283 build: add ref to projects for migrations 2024-06-01 05:42:05 +03:00
8cd8277c22 feat: add assembly for migration 2024-06-01 05:40:48 +03:00
ae46823685 build: upgrade dependencies 2024-06-01 05:40:09 +03:00
7aa37618e0 feat: add postgresql configurations 2024-06-01 05:39:25 +03:00
04b6687181 feat: add mysql configurations 2024-06-01 05:39:02 +03:00
7a741d7783 test: remove testing
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 2m0s
2024-05-31 01:37:03 +03:00
f17ee43805 test: change docker to apt
Some checks failed
Test with Different Databases / test (sqlite) (pull_request) Failing after 4m6s
Test with Different Databases / test (postgresql) (pull_request) Failing after 4m8s
Test with Different Databases / test (mysql) (pull_request) Failing after 4m8s
.NET Test Pipeline / build-and-test (pull_request) Successful in 4m41s
2024-05-31 01:25:22 +03:00
b67f0a82ed test: fix port
Some checks failed
Test with Different Databases / test (sqlite) (pull_request) Failing after 1s
Test with Different Databases / test (postgresql) (pull_request) Failing after 1s
.NET Test Pipeline / build-and-test (pull_request) Successful in 3m4s
Test with Different Databases / test (mysql) (pull_request) Failing after 3m10s
2024-05-31 01:16:13 +03:00
815c860dc0 fix: error CS0103
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Successful in 2m15s
Test with Different Databases / test (mysql) (pull_request) Failing after 1s
Test with Different Databases / test (postgresql) (pull_request) Failing after 0s
Test with Different Databases / test (sqlite) (pull_request) Failing after 4s
2024-05-30 23:58:24 +03:00
f0544ff42e fix: error CS0103
Some checks failed
Test with Different Databases / test (mysql) (pull_request) Failing after 3s
Test with Different Databases / test (postgresql) (pull_request) Failing after 2s
Test with Different Databases / test (sqlite) (pull_request) Failing after 1s
.NET Test Pipeline / build-and-test (pull_request) Failing after 12m0s
2024-05-30 23:30:11 +03:00
c9c6a99fe9 test: trying to set up tests to test application in different databases
Some checks failed
Test with Different Databases / test (postgresql) (pull_request) Failing after 1s
Test with Different Databases / test (sqlite) (pull_request) Failing after 1s
Test with Different Databases / test (mysql) (pull_request) Failing after 2s
.NET Test Pipeline / build-and-test (pull_request) Failing after 4m48s
2024-05-30 21:45:45 +03:00
aa1e1000fa fix: set new path to projects 2024-05-30 21:45:27 +03:00
a353b4c3f8 test: upgrade test
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 2m13s
2024-05-30 21:19:54 +03:00
b81fe6d8c1 fix: rename table 2024-05-30 20:29:11 +03:00
8a103831eb refactor: send provider 2024-05-30 20:26:42 +03:00
62ccf94222 feat: add converter DatabaseEnum to DatabaseProvider 2024-05-30 20:25:21 +03:00
271df127a6 feat: add DbContext for UberDbContext 2024-05-30 20:20:20 +03:00
7c79f7d840 feat: add wrap for generic configuration 2024-05-30 20:19:58 +03:00
b8728cd490 feat: add factory DbContext for configuration by provider 2024-05-30 20:17:36 +03:00
7db4dc2c86 feat: add factory for DbContext 2024-05-30 20:15:40 +03:00
348b78b84e feat: add resolver for getting configuration Type 2024-05-30 20:14:46 +03:00
4c93ed282d refactor: move default configuration to sqlite 2024-05-30 20:14:15 +03:00
1bdf40f31f build: move projects 2024-05-30 20:13:06 +03:00
31c36443e1 refactor: use wrap DbContext for UberDbContext 2024-05-30 20:12:36 +03:00
43b6ab7934 feat: add sealed class for Mark configuration namespace 2024-05-30 20:12:04 +03:00
f79c7c7db9 refactor: use wrap DbContext 2024-05-30 20:11:18 +03:00
53a0439edb feat: add wrap DbContext for OnModelCreating 2024-05-30 20:10:13 +03:00
78a242f4c3 feat: add providers database for presistence 2024-05-30 20:07:59 +03:00
0a9c98cbf9 refactor: change GetService to GetRequiredService 2024-05-30 20:07:20 +03:00
164d575d98 refactor: move database-related projects to a separate folder 2024-05-29 08:08:58 +03:00
bf3a9d4b36 refactor: move database-related projects to separate folder 2024-05-29 07:49:42 +03:00
081c814036 feat: return the schedule-related settings
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m43s
2024-05-29 07:38:32 +03:00
f6f7ed6c86 Add hashing and other security features (#12)
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m50s
Reviewed-on: #12
2024-05-29 06:42:46 +03:00
d2ef99d0b2 feat: add security configure 2024-05-29 06:42:14 +03:00
38ec80a566 feat: add configuration for jwt token 2024-05-29 06:30:01 +03:00
85802aa514 feat: add jwt token service 2024-05-29 06:28:42 +03:00
f2aa274d0a build: add jwt ref 2024-05-29 06:28:21 +03:00
62a859b44c style: clean code 2024-05-29 06:11:29 +03:00
6f02021fe7 feat: add revoked token service 2024-05-29 06:11:18 +03:00
526bf5682b build: add security ref 2024-05-29 06:08:41 +03:00
9287acf7d2 feat: add cache implementations depending on the type 2024-05-29 06:08:14 +03:00
2efdc6dbfe feat: add auth service to DI 2024-05-29 06:04:09 +03:00
25b6c7d691 feat: add method if there is no pre-auth token 2024-05-29 06:00:15 +03:00
61218c38a0 feat: add logout 2024-05-29 05:56:27 +03:00
d84011cd71 feat: add refresh token 2024-05-29 05:55:57 +03:00
4138c70007 feat: add wrap for revoke access token 2024-05-29 05:55:31 +03:00
9dd505a608 feat: add auth token response 2024-05-29 05:55:13 +03:00
79fb05d428 feat: add token revocation 2024-05-29 05:54:45 +03:00
81f2f995b0 feat: add generate auth token 2024-05-29 05:51:32 +03:00
f3063c5322 feat: add generate access token 2024-05-29 05:51:03 +03:00
43011457d6 feat: add wrap for save to cache 2024-05-29 05:50:47 +03:00
4240ad8110 feat: add auth key for cache 2024-05-29 05:36:26 +03:00
a3a42dd5c2 feat: add generate refresh token 2024-05-29 05:35:44 +03:00
b25be758ad feat: add auth token 2024-05-29 05:33:55 +03:00
7df4c8e4b6 feat: add auth service 2024-05-29 05:32:22 +03:00
f55d701ff3 feat: add sliding expiration for cache 2024-05-29 05:30:00 +03:00
d3a60d2a30 feat: add interface for gen access token 2024-05-29 05:29:25 +03:00
470031af39 feat: add match token 2024-05-29 05:27:49 +03:00
916b3795ed feat: add ip to struct 2024-05-29 05:27:27 +03:00
f4ad1518ef style: rename variables 2024-05-29 04:58:21 +03:00
ac7bbde75e fix: add key for save pre auth token 2024-05-29 04:57:44 +03:00
47a57693f8 sec: complicate the token 2024-05-29 04:55:34 +03:00
d05ba5349f refactor: isolate key generation 2024-05-29 04:48:37 +03:00
5fde5bd396 build: add security to sln 2024-05-29 04:34:39 +03:00
8408b80c35 feat: add pre-auth to DI 2024-05-29 04:34:00 +03:00
b14ae26a48 feat: add pre-auth service 2024-05-29 04:31:47 +03:00
3c9694de08 feat: add request for get token 2024-05-29 04:31:19 +03:00
e3db6b73e0 feat: add pre-auth response 2024-05-29 04:30:55 +03:00
58ceca5313 feat: add pre-auth token structure 2024-05-29 04:30:32 +03:00
f749ed42f5 feat: add interface for save to cache 2024-05-29 04:29:50 +03:00
6029ea3c2c refactor: move hashing to services 2024-05-29 04:11:04 +03:00
656d7dca0b feat: add DI 2024-05-29 04:09:10 +03:00
e3dd0a8419 build: add ref for DI 2024-05-29 04:08:51 +03:00
3149f50586 refactor: move class to correct namespace 2024-05-29 04:05:18 +03:00
e1123cf36b feat: add password hashing 2024-05-29 04:04:02 +03:00
930edd4c2c build: add ref 2024-05-29 04:03:47 +03:00
7e283fe643 feat: add security layer 2024-05-29 04:03:20 +03:00
c427006283 docs: add env data 2024-05-29 03:52:31 +03:00
e1ad287da1 build: add missing reference
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m21s
2024-05-29 03:47:55 +03:00
f9750ef039 feat: add endpoint for schedule 2024-05-29 03:46:16 +03:00
17961ccefc feat: add email endpoint 2024-05-29 03:45:02 +03:00
5d308f1a24 feat: add request for cache 2024-05-29 03:44:39 +03:00
f5deeec6c9 feat: add endpoint for logging 2024-05-29 03:44:24 +03:00
29c9c10a53 feat: add endpoint for cache 2024-05-29 03:43:08 +03:00
c02240077f fix: add missing ref 2024-05-29 03:42:39 +03:00
2d67a565ca docs: add xml doc for request 2024-05-29 03:41:54 +03:00
ba8ccf8b7e feat: add set database endpoints 2024-05-29 03:38:21 +03:00
e7ed69169c feat: add setter database 2024-05-29 03:37:04 +03:00
eefb049e0e feat: add cache for save intermediate settings 2024-05-29 03:35:52 +03:00
07d7fec24f feat: add localhost for generate token
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 1m50s
2024-05-29 03:30:26 +03:00
22793c7882 feat: add localhost attribute 2024-05-29 03:30:00 +03:00
9bf9eabad7 fix: add full path to settings 2024-05-28 07:20:21 +03:00
966ab9bdda feat: add generate and check token 2024-05-28 07:19:40 +03:00
481839159c feat: add middleware for custom exception
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m54s
2024-05-28 07:16:15 +03:00
59785f600f feat: add argument exception for controllers 2024-05-28 07:15:13 +03:00
fb6e119a34 feat: add token for setup controllers 2024-05-28 07:14:17 +03:00
35eb1eab39 feat: implement validation of settings
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m33s
2024-05-28 07:12:58 +03:00
08f13108d8 feat: add validator for settings 2024-05-28 07:10:32 +03:00
ae0b9daefa refactor: change the api path
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m59s
2024-05-28 07:09:40 +03:00
3f30b98cf9 feat: add middleware for ignore maintenance
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m27s
2024-05-28 07:04:07 +03:00
af284e945f feat: add attribute maintenance ignore for controllers 2024-05-28 07:02:35 +03:00
b62ddc9015 fix: delete ';' from property 2024-05-28 07:01:58 +03:00
d1a806545d feat: add maintenance mode 2024-05-28 07:01:23 +03:00
7b779463bb feat: add converter from Dto.PairPeriodTime toEnpoint.PairPeriodTime and vice versa
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m51s
2024-05-28 06:59:28 +03:00
baedc667b7 feat: add PairPeriod for ApiDto 2024-05-28 06:57:54 +03:00
0e9bb04b96 feat: add setup token 2024-05-28 06:56:25 +03:00
36a78a8284 fix: add using Configuration.General
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 1m29s
2024-05-28 06:53:52 +03:00
7a1281692e fix: it is correct to delete comments 2024-05-28 06:51:40 +03:00
fb736a1c34 feat: use path wrapper
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 1m24s
2024-05-28 06:49:40 +03:00
d7299a1afd feat: add wrapper for Path.Combine with default path 2024-05-28 06:48:23 +03:00
817f9d2308 feat: add default path 2024-05-28 06:47:25 +03:00
99ecc4af5c fix: get connection string from configuration 2024-05-28 06:45:31 +03:00
202098b723 refactor: move the functionality to create a persistence database on Enpoint 2024-05-28 06:43:24 +03:00
266e66a35c feat: expand the configuration functionality 2024-05-28 06:38:24 +03:00
41689bca7f feat: add attribute for required configuration 2024-05-28 06:36:18 +03:00
c06ed8b479 refactor: move files
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m33s
2024-05-28 06:30:42 +03:00
dfdc2ec109 feat: add interface for check configured settings 2024-05-28 06:28:24 +03:00
6716ee9cf0 Add specific weeks of disciplines (#10) from feat/improved-sugroup into release/v1.0.0
Reviewed-on: #10
2024-05-19 13:57:09 +03:00
0d3461b769 feat: add specific weeks
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m52s
2024-05-19 13:53:25 +03:00
22579038d3 fix: change BIT to BOOLEAN 2024-05-19 13:52:10 +03:00
d8f2f51cd7 fix: error CS1061
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 2m7s
2024-05-19 12:44:35 +03:00
ca18804a33 fix: error CS1061
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 1m50s
2024-05-19 12:30:17 +03:00
97d2fae79e feat: add specific week to persistence
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 3m13s
2024-05-19 11:52:56 +03:00
628faf7e68 feat: add interface for specific week 2024-05-19 11:52:08 +03:00
05dadff455 feat: add specific week 2024-05-19 11:49:03 +03:00
ed0143cda1 Add controllers for the Get method (#9)
Reviewed-on: #9
2024-02-19 12:06:30 +03:00
d02fb8becb feat: get lecture halls by campus
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 2m4s
2024-02-19 12:03:10 +03:00
5536162521 feat: add get group by faculty id
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m41s
2024-02-19 12:02:37 +03:00
ee2351b5ed feat: add controller
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m23s
2024-02-19 11:20:49 +03:00
29485368f8 fix: add discipline id 2024-02-19 11:20:05 +03:00
e3e7c4550f feat: add a query to get schedule data 2024-02-19 10:06:44 +03:00
e3d74c373e feat: add controller for lecture hall
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m34s
2024-02-17 14:22:29 +03:00
59eccf8b93 feat: add model for lecture hall 2024-02-17 14:21:52 +03:00
477cec2f98 fix: error CS0234 2024-02-17 10:58:25 +03:00
2b4065bbdf fix: error CS0234
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 1m27s
2024-02-17 09:09:18 +03:00
6396d8a318 refactor: add an understanding of nullable for swagger gen 2024-02-17 09:07:47 +03:00
84872a5d6d feat: add course information
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 1m34s
2024-02-17 01:49:37 +03:00
5cfc07ab7d feat: add controller for professor
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 1m16s
2024-02-16 23:57:22 +03:00
14aedf9d46 feat: add controller for group 2024-02-16 23:57:13 +03:00
aad3c74fba feat: add controller for faculty 2024-02-16 23:57:05 +03:00
1caaa4759e refactor: rewrite the api for the dto project 2024-02-16 23:56:45 +03:00
1b8c82949a feat: write a dto in a separate project 2024-02-16 23:54:52 +03:00
511c0db3ee Merge remote-tracking branch 'origin/release/v1.0.0' into feat/controller-get
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 2m31s
2024-02-16 23:00:55 +03:00
2fe2dac76b Add queries (#8)
Reviewed-on: #8
2024-02-16 22:56:00 +03:00
ed823570f1 refactor: code small
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m51s
2024-02-16 22:52:36 +03:00
a00dc8ccd6 feat: add queries for lecture hall
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m42s
2024-02-16 22:51:26 +03:00
ce3bd23673 feat: add queries for faculty
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m18s
2024-02-16 22:40:21 +03:00
69a554ce76 fix: change entity
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m22s
2024-02-16 22:35:15 +03:00
70c9a6347e feat: add queries for group 2024-02-16 22:34:41 +03:00
c8c9b7f456 feat: add queries for professor
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m44s
2024-02-16 18:06:22 +03:00
851eccd876 fix: error CS0234
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m27s
2024-01-28 06:32:43 +03:00
93a1b6ea8e refactor move files
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 1m21s
2024-01-28 06:29:47 +03:00
810c8ec5cf refactor: move files
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 1m13s
2024-01-28 06:28:14 +03:00
37eb798269 Merge branch 'feat/controller-get' of https://git.winsomnia.net/Winsomnia/MireaBackend into feat/controller-get
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m30s
2024-01-28 06:05:58 +03:00
dfc9caf921 feat: add a controller for campus 2024-01-28 06:05:54 +03:00
88b4eb594e feat: add a controller for discipline 2024-01-28 06:05:27 +03:00
2403cb35ec feat: add a basic controller for API version 1 2024-01-28 06:01:23 +03:00
07e04b99c5 feat: add a basic controller 2024-01-28 06:01:23 +03:00
96a820dead feat: add an extension to simplify attributes 2024-01-28 06:01:23 +03:00
a1a83ce2e6 feat: add queries for the discipline
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m28s
2024-01-28 05:59:48 +03:00
64e8d1b8ce Merge branch 'feat/controller-get' of https://git.winsomnia.net/Winsomnia/MireaBackend into feat/controller-get
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m38s
2024-01-28 05:05:56 +03:00
8852351ca6 feat: add a basic controller for API version 1 2024-01-28 05:04:57 +03:00
fb6ca5df77 feat: add a basic controller 2024-01-28 05:04:56 +03:00
a4dfccbc22 feat: add an extension to simplify attributes 2024-01-28 05:04:56 +03:00
335298fd91 feat: add a basic controller for API version 1
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m17s
2024-01-28 04:34:56 +03:00
7b584b2dc5 feat: add a basic controller 2024-01-28 04:34:33 +03:00
77136cc7ec feat: add an extension to simplify attributes 2024-01-28 04:33:53 +03:00
bc99007d94 feat: add queries for the campus
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m36s
2024-01-28 04:31:18 +03:00
f25c815506 feat: add a not found exception 2024-01-28 04:30:25 +03:00
7a0a51f76a Configure ASP.NET for the API to work (#7)
Reviewed-on: #7
2024-01-26 20:41:00 +03:00
92504295f0 fix: add null ignoring
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m14s
2024-01-26 20:38:49 +03:00
c5c4a2a8da fix: add ignoring documentation warning 2024-01-26 20:38:13 +03:00
806c3eeb17 feat: add an initial DI container for working with Persistence
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m19s
2024-01-26 19:39:59 +03:00
3294325ad0 build: add the necessary packages 2024-01-26 19:39:01 +03:00
459d11dd6b feat: add a database creation class (initial) 2024-01-26 19:37:42 +03:00
2b02cfb616 feat: add and initialize work with the database 2024-01-26 19:36:18 +03:00
98ebe5ffdb feat: add configuration output to the console during debugging 2024-01-26 19:35:45 +03:00
7b8bff8e54 build: add dependencies to the project 2024-01-26 19:34:42 +03:00
f1ed45af96 feat: add API versioning 2024-01-26 19:33:25 +03:00
d5b3859e86 feat: add a CORS configuration 2024-01-26 19:32:10 +03:00
788103c8a5 fix: specify the current directory as a startup directory 2024-01-26 19:31:28 +03:00
e6cc9437d5 feat: add additional configuration from files 2024-01-26 19:30:41 +03:00
8f334ae5c2 feat: add reading from .env file 2024-01-26 19:29:08 +03:00
3c3bdc6155 feat: add the pre-needed server settings 2024-01-26 09:00:14 +03:00
e1c3165ad3 build: add a dependency on versioning
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 2m43s
2024-01-26 08:50:10 +03:00
e6c62eae09 feat: add swagger configuration
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 2m46s
2024-01-26 08:44:48 +03:00
cea8a14f8b refactor: remove unnecessary classes
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 2m47s
2024-01-26 07:58:05 +03:00
2d973eee3d Fix the sql schema (#6)
Reviewed-on: #6
2024-01-26 07:48:13 +03:00
66dc5e3e38 refactor: rewrite the configuration for new tables
All checks were successful
.NET Test Pipeline / build-and-test (pull_request) Successful in 1m21s
2024-01-26 07:46:23 +03:00
9493d4340f fix: do not delete lecture hall 2024-01-26 07:45:46 +03:00
194dd1b729 fix: data deletion 2024-01-26 07:44:25 +03:00
e7c05b4a68 refactor: fix database contexts 2024-01-26 07:43:27 +03:00
bd3a11486d refactor: correct new reference
Some checks failed
.NET Test Pipeline / build-and-test (pull_request) Failing after 1m12s
2024-01-26 07:39:47 +03:00
28e862c670 refactor: delete due to merging with lesson 2024-01-26 07:38:55 +03:00
9e4320f2d3 refactor: give the exact name 2024-01-26 07:38:26 +03:00
03ccec2119 refactor: create a discipline 2024-01-26 07:37:28 +03:00
96a120f017 refator: combine day and lesson 2024-01-26 07:36:13 +03:00
def7111651 Add the required minimum for tests (#4)
Reviewed-on: #4
2024-01-08 23:30:15 +03:00
b4bbc413f2 ci: add test pipeline 2024-01-08 23:25:50 +03:00
6f5a84f645 Add a Persistence layer (#3)
Reviewed-on: #3
2024-01-08 23:22:35 +03:00
f9a04ee84a feat: add uber context for creating all context in one db 2024-01-08 23:21:04 +03:00
84214e38cc feat: add configuration files 2024-01-08 23:20:27 +03:00
40279ab2b8 feat: add a data context 2024-01-08 16:51:57 +03:00
cb55567519 feat: add project 2024-01-08 16:49:44 +03:00
8e628d17da Add an Application layer (#2)
Reviewed-on: #2
2024-01-08 16:13:09 +03:00
9e9d4e06fd feat: add mapping 2024-01-08 16:11:01 +03:00
cfbd847d9a feat: add DI 2024-01-08 15:04:34 +03:00
386272d493 feat: add validation behavior 2024-01-08 15:02:03 +03:00
924f97332f style: sort reference 2024-01-08 14:50:52 +03:00
a389eb0a70 feat: add an interface for working with db set 2024-01-08 14:50:21 +03:00
8028d40005 feat: add an interface for standard saving changes 2024-01-08 14:43:05 +03:00
f7998a1798 feat: add project 2024-01-08 14:42:52 +03:00
9f1c3cd648 Add basic schedule data models (#1)
Reviewed-on: #1
2024-01-07 02:06:45 +03:00
4eecc19f4f feat: add basic schedule data models 2024-01-07 02:00:00 +03:00
255 changed files with 12988 additions and 98 deletions

117
.env Normal file
View File

@ -0,0 +1,117 @@
# The .env configuration file
# Please DO NOT share this file, it contains confidential data.
# All variables are specified according to this rule:
# DESCRIPTION - information about what the variable is responsible for
# TYPE - the type of the variable (string, boolean, etc.)
# Any additional information
# SOME_ENV_CODE=data - default data. If specified, then the variable is optional
# General
# The path to save the data
# string
# (optional)
# Saving logs (if the full path is not specified),
# databases (if Sqlite) and other data that should be saved in a place other than the place where the program is launched.
# REQUIRED if the application is inside the container
# If you want to change this value, you need to change the values in Settings.json and move the file itself to the desired location.
PATH_TO_SAVE=
# The actual sub path to the api
# string
# (optional)
ACTUAL_SUB_PATH=
# The sub path to the swagger
# string
# (optional)
SWAGGER_SUB_PATH=swagger
# Internal port configuration
# integer
# (optional)
# Specify the internal port on which the server will listen.
INTERNAL_PORT=8080
# Security
# JWT signature token
# string (UTF8)
# This token will be used to create and verify the signature of JWT tokens.
# The token must be equal to 64 characters
SECURITY_SIGNING_TOKEN=
# Token for JWT encryption
# string (UTF8)
# This token will be used to encrypt and decrypt JWT tokens.
# The token must be equal to 32 characters
SECURITY_ENCRYPTION_TOKEN=
# Time in minutes, which indicates after which time the Refresh Token will become invalid
# integer
# The token indicates how long after the user is inactive, he will need to log in again
SECURITY_LIFE_TIME_RT=1440
# The time in a minute, which indicates that this is exactly what it takes to become a non-state
# integer
# Do not specify a time that is too long or too short. Optimally 5 > x > 60
SECURITY_LIFE_TIME_JWT=15
# Time in minutes, which indicates after which time the token of the first factor will become invalid
# integer
# Do not specify a short time. The user must be able to log in using the second factor
SECURITY_LIFE_TIME_1_FA=15
# An identifier that points to the server that created the token
# string
SECURITY_JWT_ISSUER=
# ID of the audience for which the token is intended
# string
SECURITY_JWT_AUDIENCE=
### Hashing
# In order to set up hashing correctly, you need to start from the security requirements
# You can use the settings that were used in https://github.com/P-H-C/phc-winner-argon2
# These parameters have a STRONG impact on performance
# When testing the system, these values were used:
# 10 <= SECURITY_HASH_ITERATION <= 25 iterations
# 16384 <= SECURITY_HASH_MEMORY <= 32768 KB
# 4 <= SECURITY_HASH_PARALLELISM <= 8 lines
# If we take all the large values, it will take a little more than 1 second to get the hash. If this time is critical, reduce the parameters
# The number of iterations used to hash passwords in the Argon2 algorithm
# integer
# This parameter determines the number of iterations that the Argon2 algorithm goes through when hashing passwords.
# Increasing this value can improve security by increasing the time it takes to calculate the password hash.
# The average number of iterations to increase the security level should be set to at least 10.
SECURITY_HASH_ITERATION=
# The amount of memory used to hash passwords in the Argon2 algorithm
# integer
# 65536
# This parameter determines the number of kilobytes of memory that will be used for the password hashing process.
# Increasing this value may increase security, but it may also require more system resources.
SECURITY_HASH_MEMORY=
# Parallelism determines how many of the memory fragments divided into strips will be used to generate a hash
# integer
# This value affects the hash itself, but can be changed to achieve an ideal execution time, taking into account the processor and the number of cores.
SECURITY_HASH_PARALLELISM=
# The size of the output hash generated by the password hashing algorithm
# integer
SECURITY_HASH_SIZE=32
# Additional protection for Argon2
# string (BASE64)
# (optional)
# We recommend installing a token so that even if the data is compromised, an attacker cannot brute force a password without a token
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

View File

@ -0,0 +1,85 @@
name: Build and Deploy Docker Container
on:
push:
branches:
[master, 'release/*']
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- 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 push ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest
- name: Start ssh-agent
id: ssh-agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Deploy to Server
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
DOCKER_IMAGE: ${{ secrets.DOCKER_USERNAME }}/mirea-backend:latest
PATH_TO_SAVE: /data
SECURITY_SIGNING_TOKEN: ${{ secrets.SECURITY_SIGNING_TOKEN }}
SECURITY_ENCRYPTION_TOKEN: ${{ secrets.SECURITY_ENCRYPTION_TOKEN }}
SECURITY_LIFE_TIME_RT: ${{ secrets.SECURITY_LIFE_TIME_RT }}
SECURITY_LIFE_TIME_JWT: ${{ secrets.SECURITY_LIFE_TIME_JWT }}
SECURITY_LIFE_TIME_1_FA: ${{ secrets.SECURITY_LIFE_TIME_1_FA }}
SECURITY_JWT_ISSUER: ${{ secrets.SECURITY_JWT_ISSUER }}
SECURITY_JWT_AUDIENCE: ${{ secrets.SECURITY_JWT_AUDIENCE }}
SECURITY_HASH_ITERATION: ${{ secrets.SECURITY_HASH_ITERATION }}
SECURITY_HASH_MEMORY: ${{ secrets.SECURITY_HASH_MEMORY }}
SECURITY_HASH_PARALLELISM: ${{ secrets.SECURITY_HASH_PARALLELISM }}
SECURITY_HASH_SIZE: ${{ secrets.SECURITY_HASH_SIZE }}
SECURITY_HASH_TOKEN: ${{ secrets.SECURITY_HASH_TOKEN }}
SECURITY_SALT_SIZE: ${{ secrets.SECURITY_SALT_SIZE }}
run: |
ssh-keyscan $SSH_HOST >> ~/.ssh/known_hosts
ssh $SSH_USER@$SSH_HOST "
docker pull $DOCKER_IMAGE &&
docker stop mirea-backend || true &&
docker rm mirea-backend || true &&
docker run -d --name mirea-backend -p 8085:8080 \
--restart=on-failure:10 \
-v mirea-data:/data \
-e PATH_TO_SAVE=$PATH_TO_SAVE \
-e SECURITY_SIGNING_TOKEN=$SECURITY_SIGNING_TOKEN \
-e SECURITY_ENCRYPTION_TOKEN=$SECURITY_ENCRYPTION_TOKEN \
-e SECURITY_LIFE_TIME_RT=$SECURITY_LIFE_TIME_RT \
-e SECURITY_LIFE_TIME_JWT=$SECURITY_LIFE_TIME_JWT \
-e SECURITY_LIFE_TIME_1_FA=$SECURITY_LIFE_TIME_1_FA \
-e SECURITY_JWT_ISSUER=$SECURITY_JWT_ISSUER \
-e SECURITY_JWT_AUDIENCE=$SECURITY_JWT_AUDIENCE \
-e SECURITY_HASH_ITERATION=$SECURITY_HASH_ITERATION \
-e SECURITY_HASH_MEMORY=$SECURITY_HASH_MEMORY \
-e SECURITY_HASH_PARALLELISM=$SECURITY_HASH_PARALLELISM \
-e SECURITY_HASH_SIZE=$SECURITY_HASH_SIZE \
-e SECURITY_HASH_TOKEN=$SECURITY_HASH_TOKEN \
-e SECURITY_SALT_SIZE=$SECURITY_SALT_SIZE \
-e ACTUAL_SUB_PATH=api \
-e SWAGGER_SUB_PATH=swagger \
-e TZ=Europe/Moscow \
$DOCKER_IMAGE
"
- name: Remove all keys from ssh-agent
run: ssh-add -D

View File

@ -0,0 +1,29 @@
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

2
.gitignore vendored
View File

@ -361,3 +361,5 @@ MigrationBackup/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
/ApiDto/ApiDtoDocs.xml
/Endpoint/docs.xml

42
ApiDto/ApiDto.csproj Normal file
View File

@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<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>
<DocumentationFile>ApiDtoDocs.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<None Update="ApiDtoDocs.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<PropertyGroup>
<CopyAllFilesToSingleFolderForPackageDependsOn>
CopyXmlDocuments;
$(CopyAllFilesToSingleFolderForPackageDependsOn);
</CopyAllFilesToSingleFolderForPackageDependsOn>
<CopyAllFilesToSingleFolderForMsdeployDependsOn>
CopyXmlDocuments;
$(CopyAllFilesToSingleFolderForMsdeployDependsOn);
</CopyAllFilesToSingleFolderForMsdeployDependsOn>
</PropertyGroup>
<Target Name="CopyXmlDocuments">
<ItemGroup>
<XmlDocuments Include="$(OutDir)*.xml" />
<FilesForPackagingFromProject Include="%(XmlDocuments.Identity)">
<DestinationRelativePath>bin\%(RecursiveDir)%(Filename)%(Extension)</DestinationRelativePath>
</FilesForPackagingFromProject>
</ItemGroup>
</Target>
</Project>

View File

@ -0,0 +1,12 @@
namespace Mirea.Api.Dto.Common;
/// <summary>
/// An enumeration that indicates which role the user belongs to
/// </summary>
public enum AuthRoles
{
/// <summary>
/// Administrator
/// </summary>
Admin
}

View File

@ -0,0 +1,22 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents a pair of time periods.
/// </summary>
public class PairPeriodTime
{
/// <summary>
/// Gets or sets the start time of the period.
/// </summary>
[Required]
public TimeOnly Start { get; set; }
/// <summary>
/// Gets or sets the end time of the period.
/// </summary>
[Required]
public TimeOnly End { get; set; }
}

View File

@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests.Configuration;
/// <summary>
/// Represents a request to configure cache settings.
/// </summary>
public class CacheRequest
{
/// <summary>
/// Gets or sets the server address.
/// </summary>
[Required]
public required string Server { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
[Required]
public int Port { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string? Password { get; set; }
}

View File

@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests.Configuration;
/// <summary>
/// Represents a request to configure the database connection settings.
/// </summary>
public class DatabaseRequest
{
/// <summary>
/// Gets or sets the server address.
/// </summary>
[Required]
public required string Server { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
[Required]
public int Port { get; set; }
/// <summary>
/// Gets or sets the database name.
/// </summary>
[Required]
public required string Database { get; set; }
/// <summary>
/// Gets or sets the username.
/// </summary>
[Required]
public required string User { get; set; }
/// <summary>
/// Gets or sets a value indicating whether SSL is enabled.
/// </summary>
[Required]
public bool Ssl { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string? Password { get; set; }
}

View File

@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests.Configuration;
/// <summary>
/// Represents a request to configure email settings.
/// </summary>
public class EmailRequest
{
/// <summary>
/// Gets or sets the server address.
/// </summary>
[Required]
public required string Server { get; set; }
/// <summary>
/// Gets or sets the email address from which emails will be sent.
/// </summary>
[Required]
public required string From { get; set; }
/// <summary>
/// Gets or sets the password for the email account.
/// </summary>
[Required]
public required string Password { get; set; }
/// <summary>
/// Gets or sets the port number.
/// </summary>
[Required]
public int Port { get; set; }
/// <summary>
/// Gets or sets a value indicating whether SSL is enabled.
/// </summary>
[Required]
public bool Ssl { get; set; }
/// <summary>
/// Gets or sets the username.
/// </summary>
[Required]
public required string User { get; set; }
}

View File

@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests.Configuration;
/// <summary>
/// Represents a request to configure logging settings.
/// </summary>
public class LoggingRequest
{
/// <summary>
/// Gets or sets a value indicating whether logging to file is enabled.
/// </summary>
[Required]
public bool EnableLogToFile { get; set; }
/// <summary>
/// Gets or sets the log file name.
/// </summary>
public string? LogFileName { get; set; }
/// <summary>
/// Gets or sets the log file path.
/// </summary>
public string? LogFilePath { get; set; }
}

View File

@ -0,0 +1,21 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests.Configuration;
/// <summary>
/// Represents a request to configure the schedule settings.
/// </summary>
public class ScheduleConfigurationRequest
{
/// <summary>
/// Gets or sets the cron expression for updating the schedule.
/// </summary>
public string? CronUpdateSchedule { get; set; }
/// <summary>
/// Gets or sets the start date of the term.
/// </summary>
[Required]
public DateOnly StartTerm { get; set; }
}

View File

@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests;
/// <summary>
/// Request model for creating a user.
/// </summary>
public class CreateUserRequest
{
/// <summary>
/// Gets or sets the email address of the user.
/// </summary>
/// <remarks>
/// The email address is a required field.
/// </remarks>
[Required]
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]
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]
public required string Password { get; set; }
}

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Requests;
/// <summary>
/// Request to receive protected content
/// </summary>
public class LoginRequest
{
/// <summary>
/// Login or Email to identify the client.
/// </summary>
[Required]
public required string Username { get; set; }
/// <summary>
/// The client's password.
/// </summary>
[Required]
public required string Password { get; set; }
}

View File

@ -0,0 +1,37 @@
namespace Mirea.Api.Dto.Requests;
/// <summary>
/// Represents a request object for retrieving schedules based on various filters.
/// </summary>
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;
}

View File

@ -0,0 +1,17 @@
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the steps required after a login attempt.
/// </summary>
public enum AuthenticationStep
{
/// <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

@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents basic information about a campus.
/// </summary>
public class CampusBasicInfoResponse
{
/// <summary>
/// Gets or sets the unique identifier of the campus.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the code name of the campus.
/// </summary>
[Required]
public required string CodeName { get; set; }
/// <summary>
/// Gets or sets the full name of the campus (optional).
/// </summary>
public string? FullName { get; set; }
}

View File

@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents detailed information about a campus.
/// </summary>
public class CampusDetailsResponse
{
/// <summary>
/// Gets or sets the unique identifier of the campus.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the code name of the campus.
/// </summary>
[Required]
public required string CodeName { get; set; }
/// <summary>
/// Gets or sets the full name of the campus (optional).
/// </summary>
public string? FullName { get; set; }
/// <summary>
/// Gets or sets the address of the campus (optional).
/// </summary>
public string? Address { get; set; }
}

View File

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

View File

@ -0,0 +1,22 @@
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

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents basic information about a faculty.
/// </summary>
public class FacultyResponse
{
/// <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; }
}

View File

@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents detailed information about a group.
/// </summary>
public class GroupDetailsResponse
{
/// <summary>
/// Gets or sets the unique identifier of the group.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the group.
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the course number of the group.
/// </summary>
[Required]
public int CourseNumber { get; set; }
/// <summary>
/// Gets or sets the unique identifier of the faculty to which the group belongs (optional).
/// </summary>
public int? FacultyId { get; set; }
/// <summary>
/// Gets or sets the name of the faculty to which the group belongs (optional).
/// </summary>
public string? FacultyName { get; set; }
}

View File

@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents basic information about a group.
/// </summary>
public class GroupResponse
{
/// <summary>
/// Gets or sets the unique identifier of the group.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the group.
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the course number of the group.
/// </summary>
[Required]
public int CourseNumber { get; set; }
/// <summary>
/// Gets or sets the unique identifier of the faculty to which the group belongs (optional).
/// </summary>
public int? FacultyId { get; set; }
}

View File

@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the detailed response model for a lecture hall.
/// </summary>
public class LectureHallDetailsResponse
{
/// <summary>
/// Gets or sets the ID of the lecture hall.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the lecture hall.
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the ID of the campus to which the lecture hall belongs.
/// </summary>
[Required]
public int CampusId { get; set; }
/// <summary>
/// Gets or sets the name of the campus.
/// </summary>
public string? CampusName { get; set; }
/// <summary>
/// Gets or sets the code of the campus.
/// </summary>
public string? CampusCode { get; set; }
}

View File

@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents the response model for a lecture hall.
/// </summary>
public class LectureHallResponse
{
/// <summary>
/// Gets or sets the ID of the lecture hall.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the lecture hall.
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the ID of the campus to which the lecture hall belongs.
/// </summary>
[Required]
public int CampusId { get; set; }
}

View File

@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents information about a professor.
/// </summary>
public class ProfessorResponse
{
/// <summary>
/// Gets or sets the unique identifier of the professor.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the professor.
/// </summary>
[Required]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the alternate name of the professor (optional).
/// </summary>
public string? AltName { get; set; }
}

View File

@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents a response object containing schedule information.
/// </summary>
public class ScheduleResponse
{
/// <summary>
/// Gets or sets the day of the week for the schedule entry.
/// </summary>
[Required]
public DayOfWeek DayOfWeek { get; set; }
/// <summary>
/// Gets or sets the pair number for the schedule entry.
/// </summary>
[Required]
public int PairNumber { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the pair is on an even week.
/// </summary>
[Required]
public bool IsEven { get; set; }
/// <summary>
/// Gets or sets the name of the discipline for the schedule entry.
/// </summary>
[Required]
public required string Discipline { get; set; }
/// <summary>
/// Gets or sets the ID of the discipline for the schedule entry.
/// </summary>
[Required]
public required int DisciplineId { get; set; }
/// <summary>
/// Gets or sets exclude or include weeks for a specific discipline.
/// </summary>
/// <remarks>
/// If is <see langword="true"/>, then the values in <see cref="Weeks"/> show the weeks when there will be no discipline.
/// <br/>
/// If is <see langword="false"/>, then the values in <see cref="Weeks"/> indicate the weeks during which a particular discipline will be studied.
/// <br/>
/// If is <see langword="null"/>, then there are no specific <see cref="Weeks"/>
/// </remarks>
///
public bool? IsExcludedWeeks { get; set; }
/// <summary>
/// The week numbers required for the correct display of the schedule.
/// <br/>
/// Whether there will be <see cref="Discipline"/> during the week or not depends on the <see cref="IsExcludedWeeks"/> property.
/// </summary>
/// <remarks>
/// To get the current week's number, use other queries.
/// </remarks>
public IEnumerable<int>? Weeks { get; set; }
/// <summary>
/// Gets or sets the type of occupation for the schedule entry.
/// </summary>
[Required]
public required IEnumerable<string> TypeOfOccupations { get; set; }
/// <summary>
/// Gets or sets the name of the group for the schedule entry.
/// </summary>
[Required]
public required string Group { get; set; }
/// <summary>
/// Gets or sets the ID of the group for the schedule entry.
/// </summary>
[Required]
public required int GroupId { get; set; }
/// <summary>
/// Gets or sets the names of the lecture halls for the schedule entry.
/// </summary>
public required IEnumerable<string?> LectureHalls { get; set; }
/// <summary>
/// Gets or sets the IDs of the lecture halls for the schedule entry.
/// </summary>
public required IEnumerable<int?> LectureHallsId { get; set; }
/// <summary>
/// Gets or sets the names of the professors for the schedule entry.
/// </summary>
public required IEnumerable<string?> Professors { get; set; }
/// <summary>
/// Gets or sets the IDs of the professors for the schedule entry.
/// </summary>
public required IEnumerable<int?> ProfessorsId { get; set; }
/// <summary>
/// Gets or sets the names of the campuses for the schedule entry.
/// </summary>
public required IEnumerable<string?> Campus { get; set; }
/// <summary>
/// Gets or sets the IDs of the campuses for the schedule entry.
/// </summary>
public required IEnumerable<int?> CampusId { get; set; }
/// <summary>
/// Gets or sets the links to online meetings for the schedule entry.
/// </summary>
public required IEnumerable<string?> LinkToMeet { get; set; }
}

View File

@ -3,18 +3,48 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 17
VisualStudioVersion = 17.8.34330.188 VisualStudioVersion = 17.8.34330.188
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "Domain\Domain.csproj", "{C27FB5CD-6A70-4FB2-847A-847B34806902}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Endpoint", "Endpoint\Endpoint.csproj", "{F3A1D12E-F5B2-4339-9966-DBF869E78357}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Endpoint", "Endpoint\Endpoint.csproj", "{F3A1D12E-F5B2-4339-9966-DBF869E78357}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Elements of the solution", "Elements of the solution", "{3E087889-A4A0-4A55-A07D-7D149A5BC928}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Elements of the solution", "Elements of the solution", "{3E087889-A4A0-4A55-A07D-7D149A5BC928}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
.dockerignore = .dockerignore .dockerignore = .dockerignore
.env = .env
.gitattributes = .gitattributes .gitattributes = .gitattributes
.gitignore = .gitignore .gitignore = .gitignore
.gitea\workflows\deploy-stage.yaml = .gitea\workflows\deploy-stage.yaml
Dockerfile = Dockerfile Dockerfile = Dockerfile
LICENSE.txt = LICENSE.txt LICENSE.txt = LICENSE.txt
README.md = README.md README.md = README.md
.gitea\workflows\test.yaml = .gitea\workflows\test.yaml
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApiDto", "ApiDto\ApiDto.csproj", "{0335FA36-E137-453F-853B-916674C168FE}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Security", "Security\Security.csproj", "{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SqlData", "SqlData", "{7E7A63CD-547B-4FB4-A383-EB75298020A1}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain", "SqlData\Domain\Domain.csproj", "{3BFD6180-7CA7-4E85-A379-225B872439A1}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application", "SqlData\Application\Application.csproj", "{0B1F3656-E5B3-440C-961F-A7D004FBE9A8}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Persistence", "SqlData\Persistence\Persistence.csproj", "{48C9998C-ECE2-407F-835F-1A7255A5C99E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Migrations", "Migrations", "{79639CD4-7A16-4AB4-BBE8-672B9ACCB3F5}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SqliteMigrations", "SqlData\Migrations\SqliteMigrations\SqliteMigrations.csproj", "{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7}"
ProjectSection(ProjectDependencies) = postProject
{48C9998C-ECE2-407F-835F-1A7255A5C99E} = {48C9998C-ECE2-407F-835F-1A7255A5C99E}
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MysqlMigrations", "SqlData\Migrations\MysqlMigrations\MysqlMigrations.csproj", "{5861915B-9574-4D5D-872F-D54A09651697}"
ProjectSection(ProjectDependencies) = postProject
{48C9998C-ECE2-407F-835F-1A7255A5C99E} = {48C9998C-ECE2-407F-835F-1A7255A5C99E}
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PsqlMigrations", "SqlData\Migrations\PsqlMigrations\PsqlMigrations.csproj", "{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD}"
ProjectSection(ProjectDependencies) = postProject
{48C9998C-ECE2-407F-835F-1A7255A5C99E} = {48C9998C-ECE2-407F-835F-1A7255A5C99E}
EndProjectSection EndProjectSection
EndProject EndProject
Global Global
@ -23,18 +53,55 @@ Global
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C27FB5CD-6A70-4FB2-847A-847B34806902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C27FB5CD-6A70-4FB2-847A-847B34806902}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C27FB5CD-6A70-4FB2-847A-847B34806902}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C27FB5CD-6A70-4FB2-847A-847B34806902}.Release|Any CPU.Build.0 = Release|Any CPU
{F3A1D12E-F5B2-4339-9966-DBF869E78357}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F3A1D12E-F5B2-4339-9966-DBF869E78357}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F3A1D12E-F5B2-4339-9966-DBF869E78357}.Debug|Any CPU.Build.0 = Debug|Any CPU {F3A1D12E-F5B2-4339-9966-DBF869E78357}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F3A1D12E-F5B2-4339-9966-DBF869E78357}.Release|Any CPU.ActiveCfg = Release|Any CPU {F3A1D12E-F5B2-4339-9966-DBF869E78357}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F3A1D12E-F5B2-4339-9966-DBF869E78357}.Release|Any CPU.Build.0 = Release|Any CPU {F3A1D12E-F5B2-4339-9966-DBF869E78357}.Release|Any CPU.Build.0 = Release|Any CPU
{0335FA36-E137-453F-853B-916674C168FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0335FA36-E137-453F-853B-916674C168FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0335FA36-E137-453F-853B-916674C168FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0335FA36-E137-453F-853B-916674C168FE}.Release|Any CPU.Build.0 = Release|Any CPU
{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{47A3C065-4E1D-4B1E-AAB4-2BB8F40E56B4}.Release|Any CPU.Build.0 = Release|Any CPU
{3BFD6180-7CA7-4E85-A379-225B872439A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3BFD6180-7CA7-4E85-A379-225B872439A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3BFD6180-7CA7-4E85-A379-225B872439A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3BFD6180-7CA7-4E85-A379-225B872439A1}.Release|Any CPU.Build.0 = Release|Any CPU
{0B1F3656-E5B3-440C-961F-A7D004FBE9A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0B1F3656-E5B3-440C-961F-A7D004FBE9A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0B1F3656-E5B3-440C-961F-A7D004FBE9A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0B1F3656-E5B3-440C-961F-A7D004FBE9A8}.Release|Any CPU.Build.0 = Release|Any CPU
{48C9998C-ECE2-407F-835F-1A7255A5C99E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{48C9998C-ECE2-407F-835F-1A7255A5C99E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48C9998C-ECE2-407F-835F-1A7255A5C99E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48C9998C-ECE2-407F-835F-1A7255A5C99E}.Release|Any CPU.Build.0 = Release|Any CPU
{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7}.Release|Any CPU.Build.0 = Release|Any CPU
{5861915B-9574-4D5D-872F-D54A09651697}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5861915B-9574-4D5D-872F-D54A09651697}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5861915B-9574-4D5D-872F-D54A09651697}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5861915B-9574-4D5D-872F-D54A09651697}.Release|Any CPU.Build.0 = Release|Any CPU
{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{3BFD6180-7CA7-4E85-A379-225B872439A1} = {7E7A63CD-547B-4FB4-A383-EB75298020A1}
{0B1F3656-E5B3-440C-961F-A7D004FBE9A8} = {7E7A63CD-547B-4FB4-A383-EB75298020A1}
{48C9998C-ECE2-407F-835F-1A7255A5C99E} = {7E7A63CD-547B-4FB4-A383-EB75298020A1}
{79639CD4-7A16-4AB4-BBE8-672B9ACCB3F5} = {7E7A63CD-547B-4FB4-A383-EB75298020A1}
{EF5530BD-4BF4-4DD8-80BB-04C6B6623DA7} = {79639CD4-7A16-4AB4-BBE8-672B9ACCB3F5}
{5861915B-9574-4D5D-872F-D54A09651697} = {79639CD4-7A16-4AB4-BBE8-672B9ACCB3F5}
{E9E238CD-6DD8-4B29-8C36-C61F1168FCCD} = {79639CD4-7A16-4AB4-BBE8-672B9ACCB3F5}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E80A1224-87F5-4FEB-82AE-89006BE98B12} SolutionGuid = {E80A1224-87F5-4FEB-82AE-89006BE98B12}
EndGlobalSection EndGlobalSection

View File

@ -1,25 +1,28 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER app LABEL company="Winsomnia"
LABEL maintainer.name="Wesser" maintainer.email="support@winsomnia.net"
WORKDIR /app WORKDIR /app
EXPOSE 8080
EXPOSE 8081 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 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["Backend.csproj", "."]
RUN dotnet restore "./././Backend.csproj"
COPY . . COPY . .
WORKDIR "/src/."
RUN dotnet build "./Backend.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish ARG NUGET_USERNAME
ARG BUILD_CONFIGURATION=Release ARG NUGET_PASSWORD
RUN dotnet publish "./Backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false ENV NUGET_USERNAME=$NUGET_USERNAME
ENV NUGET_PASSWORD=$NUGET_PASSWORD
RUN dotnet restore ./Backend.sln --configfile nuget.config
WORKDIR /app
WORKDIR /src
RUN dotnet publish ./Endpoint/Endpoint.csproj -c Release --self-contained false -p:PublishSingleFile=false -o /app
FROM base AS final FROM base AS final
WORKDIR /app WORKDIR /app
COPY --from=publish /app/publish . COPY --from=build /app .
ENTRYPOINT ["dotnet", "Backend.dll"] RUN find . -name "*.pdb" -type f -delete
ENTRYPOINT ["dotnet", "Mirea.Api.Endpoint.dll"]

View File

@ -1,6 +0,0 @@
@Backend_HostAddress = http://localhost:5269
GET {{Backend_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -0,0 +1,9 @@
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);

View File

@ -0,0 +1,26 @@
using System;
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, 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

@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System;
using System.Net;
namespace Mirea.Api.Endpoint.Common.Attributes;
public class LocalhostAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
var ip = context.HttpContext.Connection.RemoteIpAddress;
if (ip == null)
{
context.Result = new UnauthorizedResult();
return;
}
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

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

View File

@ -0,0 +1,9 @@
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);

View File

@ -0,0 +1,9 @@
using System;
namespace Mirea.Api.Endpoint.Common.Attributes;
[AttributeUsage(AttributeTargets.Parameter)]
public class SwaggerDefaultAttribute(string value) : Attribute
{
public string Value { get; } = value;
}

View File

@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Mirea.Api.Endpoint.Common.Interfaces;
using System;
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))
{
context.Result = new UnauthorizedResult();
return;
}
if (setupToken.MatchToken(Convert.FromBase64String(tokenFromCookie))) return;
context.Result = new UnauthorizedResult();
}
public void OnActionExecuted(ActionExecutedContext context) { }
}

View File

@ -0,0 +1,5 @@
using System;
namespace Mirea.Api.Endpoint.Common.Exceptions;
public class ControllerArgumentException(string message) : Exception(message);

View File

@ -0,0 +1,8 @@
namespace Mirea.Api.Endpoint.Common.Interfaces;
public interface IMaintenanceModeNotConfigureService
{
bool IsMaintenanceMode { get; }
void DisableMaintenanceMode();
}

View File

@ -0,0 +1,10 @@
namespace Mirea.Api.Endpoint.Common.Interfaces;
public interface IMaintenanceModeService
{
bool IsMaintenanceMode { get; }
void EnableMaintenanceMode();
void DisableMaintenanceMode();
}

View File

@ -0,0 +1,9 @@
using System;
namespace Mirea.Api.Endpoint.Common.Interfaces;
public interface ISetupToken
{
bool MatchToken(ReadOnlySpan<byte> token);
void SetToken(ReadOnlySpan<byte> token);
}

View File

@ -0,0 +1,11 @@
using Mirea.Api.Endpoint.Common.Interfaces;
namespace Mirea.Api.Endpoint.Common.Services;
public class MaintenanceModeNotConfigureService : IMaintenanceModeNotConfigureService
{
public bool IsMaintenanceMode { get; private set; } = true;
public void DisableMaintenanceMode() =>
IsMaintenanceMode = false;
}

View File

@ -0,0 +1,14 @@
using Mirea.Api.Endpoint.Common.Interfaces;
namespace Mirea.Api.Endpoint.Common.Services;
public class MaintenanceModeService : IMaintenanceModeService
{
public bool IsMaintenanceMode { get; private set; }
public void EnableMaintenanceMode() =>
IsMaintenanceMode = true;
public void DisableMaintenanceMode() =>
IsMaintenanceMode = false;
}

View File

@ -0,0 +1,13 @@
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.Services;
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));
}

View File

@ -0,0 +1,12 @@
using System;
using System.IO;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.Services;
public static class PathBuilder
{
public static bool IsDefaultPath => Environment.GetEnvironmentVariable("PATH_TO_SAVE") == null;
public static string PathToSave => Environment.GetEnvironmentVariable("PATH_TO_SAVE") ?? Directory.GetCurrentDirectory();
public static string Combine(params string[] paths) => Path.Combine([.. paths.Prepend(PathToSave)]);
}

View File

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

View File

@ -0,0 +1,63 @@
using Microsoft.Extensions.Caching.Distributed;
using Mirea.Api.Security.Common.Interfaces;
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
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)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow,
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);
}
public Task RemoveAsync(string key, CancellationToken cancellationToken = default) =>
cache.RemoveAsync(key, cancellationToken);
}

View File

@ -0,0 +1,82 @@
using Microsoft.IdentityModel.Tokens;
using Mirea.Api.Security.Common.Interfaces;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
namespace Mirea.Api.Endpoint.Common.Services.Security;
public class JwtTokenService : IAccessToken
{
public required string Issuer { private get; init; }
public required string Audience { private get; init; }
public TimeSpan Lifetime { private get; init; }
public ReadOnlyMemory<byte> EncryptionKey { private get; init; }
public ReadOnlyMemory<byte> SigningKey { private get; init; }
public (string Token, DateTime ExpireIn) GenerateToken(string userId)
{
var tokenHandler = new JwtSecurityTokenHandler();
var signingKey = new SymmetricSecurityKey(SigningKey.ToArray());
var encryptionKey = new SymmetricSecurityKey(EncryptionKey.ToArray());
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha512);
var expires = DateTime.UtcNow.Add(Lifetime);
var tokenDescriptor = new SecurityTokenDescriptor
{
Issuer = Issuer,
Audience = Audience,
Expires = expires,
SigningCredentials = signingCredentials,
Subject = new ClaimsIdentity(
[
new Claim(ClaimTypes.Name, userId),
// todo: get role by userId
new Claim(ClaimTypes.Role, "")
]),
EncryptingCredentials = new EncryptingCredentials(encryptionKey, SecurityAlgorithms.Aes256KW, SecurityAlgorithms.Aes256CbcHmacSha512)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return (tokenHandler.WriteToken(token), expires);
}
public DateTimeOffset GetExpireDateTime(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
var signingKey = new SymmetricSecurityKey(SigningKey.ToArray());
var encryptionKey = new SymmetricSecurityKey(EncryptionKey.ToArray());
var tokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = Issuer,
ValidAudience = Audience,
IssuerSigningKey = signingKey,
TokenDecryptionKey = encryptionKey,
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = false
};
try
{
var claimsPrincipal = tokenHandler.ValidateToken(token, tokenValidationParameters, out _);
var expClaim = claimsPrincipal.Claims.FirstOrDefault(c => c.Type == "exp");
if (expClaim != null && long.TryParse(expClaim.Value, out var expUnix))
return DateTimeOffset.FromUnixTimeSeconds(expUnix);
}
catch (SecurityTokenException)
{
return DateTimeOffset.MinValue;
}
return DateTimeOffset.MinValue;
}
}

View File

@ -0,0 +1,62 @@
using Microsoft.Extensions.Caching.Memory;
using Mirea.Api.Security.Common.Interfaces;
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
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)
{
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow,
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)
{
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)
{
cache.Remove(key);
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,17 @@
using Microsoft.Extensions.Caching.Memory;
using Mirea.Api.Security.Common.Interfaces;
using System;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Common.Services.Security;
public class MemoryRevokedTokenService(IMemoryCache cache) : IRevokedToken
{
public Task AddTokenToRevokedAsync(string token, DateTimeOffset expiresIn)
{
cache.Set(token, true, expiresIn);
return Task.CompletedTask;
}
public Task<bool> IsTokenRevokedAsync(string token) => Task.FromResult(cache.TryGetValue(token, out _));
}

View File

@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Http;
using System;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.Services;
public static class UrlHelper
{
public static string GetCurrentDomain(this HttpContext context) =>
context.Request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? context.Request.Host.Host;
private static string CreateSubPath(string? path)
{
if (string.IsNullOrEmpty(path))
return "/";
return "/" + path.Trim('/') + "/";
}
public static string GetSubPath => CreateSubPath(Environment.GetEnvironmentVariable("ACTUAL_SUB_PATH"));
public static string GetSubPathWithoutFirstApiName
{
get
{
var path = GetSubPath;
if (string.IsNullOrEmpty(path) || path == "/")
return CreateSubPath(null);
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;
}
return CreateSubPath(string.Join("/", parts));
}
}
public static string GetSubPathSwagger => CreateSubPath(Environment.GetEnvironmentVariable("SWAGGER_SUB_PATH"));
}

View File

@ -0,0 +1,120 @@
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.Sync;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.Core.BackgroundTasks;
public class ScheduleSyncService : IHostedService, IDisposable
{
private Timer? _timer;
private readonly IOptionsMonitor<GeneralConfig> _generalConfigMonitor;
private readonly ILogger<ScheduleSyncService> _logger;
private CancellationTokenSource _cancellationTokenSource = new();
private readonly IServiceProvider _serviceProvider;
public ScheduleSyncService(IOptionsMonitor<GeneralConfig> generalConfigMonitor, ILogger<ScheduleSyncService> logger, IServiceProvider serviceProvider)
{
_generalConfigMonitor = generalConfigMonitor;
_logger = logger;
_serviceProvider = serviceProvider;
ScheduleSyncManager.OnForceSyncRequested += OnForceSyncRequested;
ScheduleSyncManager.OnUpdateIntervalRequested += OnUpdateIntervalRequested;
}
private void OnForceSyncRequested()
{
StopAsync(default).ContinueWith(_ =>
{
_cancellationTokenSource = new CancellationTokenSource();
ExecuteTask(null);
});
}
private void OnUpdateIntervalRequested()
{
StopAsync(default).ContinueWith(_ =>
{
StartAsync(default);
});
}
private void ScheduleNextRun()
{
var cronExpression = _generalConfigMonitor.CurrentValue.ScheduleSettings?.CronUpdateSchedule;
if (string.IsNullOrEmpty(cronExpression))
{
_logger.LogWarning("Cron expression is not set. The scheduled task will not run.");
return;
}
var nextRunTime = CronExpression.Parse(cronExpression).GetNextOccurrence(DateTimeOffset.Now, TimeZoneInfo.Local);
if (!nextRunTime.HasValue)
{
_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.Value.ToString("G"));
var delay = (nextRunTime.Value - 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, (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(default).GetAwaiter().GetResult();
_timer?.Dispose();
ScheduleSyncManager.OnForceSyncRequested -= OnForceSyncRequested;
ScheduleSyncManager.OnUpdateIntervalRequested -= OnUpdateIntervalRequested;
_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,76 @@
using FluentValidation;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Mirea.Api.DataAccess.Application.Common.Exceptions;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Exceptions;
using System;
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 code = StatusCodes.Status500InternalServerError;
var result = string.Empty;
switch (exception)
{
case ValidationException validationException:
code = StatusCodes.Status400BadRequest;
result = JsonSerializer.Serialize(new ErrorResponse()
{
Error = validationException.Message,
Code = code
});
break;
case NotFoundException:
code = StatusCodes.Status404NotFound;
break;
case ControllerArgumentException:
code = StatusCodes.Status400BadRequest;
break;
case SecurityException:
code = StatusCodes.Status401Unauthorized;
break;
}
context.Response.ContentType = "application/json";
context.Response.StatusCode = code;
if (!string.IsNullOrEmpty(result))
return context.Response.WriteAsync(result);
string error;
if (code == StatusCodes.Status500InternalServerError)
{
error = "Internal Server Error";
logger.LogError(exception, "Internal server error when processing the request");
}
else
error = exception.Message;
result = JsonSerializer.Serialize(new ErrorResponse()
{
Error = error,
Code = code
});
return context.Response.WriteAsync(result);
}
}

View File

@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Http;
using Mirea.Api.Security.Common.Interfaces;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
public class JwtRevocationMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, IRevokedToken revokedTokenStore)
{
if (context.Request.Headers.ContainsKey("Authorization"))
{
var token = context.Request.Headers.Authorization.ToString().Replace("Bearer ", "");
if (await revokedTokenStore.IsTokenRevokedAsync(token))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
}
await next(context);
}
}

View File

@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Http;
using Mirea.Api.Endpoint.Common.Attributes;
using Mirea.Api.Endpoint.Common.Interfaces;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Configuration.Core.Middleware;
public class MaintenanceModeMiddleware(RequestDelegate next, IMaintenanceModeService maintenanceModeService, IMaintenanceModeNotConfigureService maintenanceModeNotConfigureService)
{
private static bool IsIgnoreMaintenanceMode(HttpContext context)
{
var endpoint = context.GetEndpoint();
return endpoint?.Metadata.GetMetadata<MaintenanceModeIgnoreAttribute>() != null;
}
public async Task InvokeAsync(HttpContext context)
{
if (!maintenanceModeService.IsMaintenanceMode && !maintenanceModeNotConfigureService.IsMaintenanceMode || IsIgnoreMaintenanceMode(context))
await next(context);
else
{
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.";
await context.Response.WriteAsync(error);
}
}
}

View File

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

View File

@ -0,0 +1,26 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
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 != CacheSettings.CacheEnum.Redis)
return services;
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = cache.ConnectionString;
options.InstanceName = "mirea_";
});
healthChecksBuilder?.AddRedis(cache.ConnectionString!, name: "Redis");
return services;
}
}

View File

@ -0,0 +1,79 @@
using Microsoft.Extensions.Configuration;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class EnvironmentConfiguration
{
private static Dictionary<string, string> LoadEnvironment(string envFile)
{
Dictionary<string, string> environment = [];
if (!File.Exists(envFile)) return environment;
foreach (var line in File.ReadAllLines(envFile))
{
if (string.IsNullOrEmpty(line)) continue;
var commentIndex = line.IndexOf('#', StringComparison.Ordinal);
string arg = line;
if (commentIndex != -1)
arg = arg.Remove(commentIndex, arg.Length - commentIndex);
var parts = arg.Split(
'=',
StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 2)
parts = [parts[0], string.Join("=", parts[1..])];
if (parts.Length != 2)
continue;
environment.Add(parts[0].Trim(), parts[1].Trim());
}
return environment;
}
public static IConfigurationRoot GetEnvironment()
{
var variablesFromFile = LoadEnvironment(".env");
#if DEBUG
LoadEnvironment(".env.develop").ToList().ForEach(x => variablesFromFile.Add(x.Key, x.Value));
#endif
var environmentVariables = Environment.GetEnvironmentVariables()
.OfType<DictionaryEntry>()
.ToDictionary(
entry => entry.Key.ToString() ?? string.Empty,
entry => entry.Value?.ToString() ?? string.Empty
);
var result = new ConfigurationBuilder()
.AddInMemoryCollection(environmentVariables!)
.AddInMemoryCollection(variablesFromFile!);
if (variablesFromFile.TryGetValue("PATH_TO_SAVE", out var pathToSave))
{
Environment.SetEnvironmentVariable("PATH_TO_SAVE", pathToSave);
if (!Directory.Exists(pathToSave))
Directory.CreateDirectory(pathToSave);
}
if (variablesFromFile.TryGetValue("ACTUAL_SUB_PATH", out var actualSubPath))
Environment.SetEnvironmentVariable("ACTUAL_SUB_PATH", actualSubPath);
if (variablesFromFile.TryGetValue("SWAGGER_SUB_PATH", out var swaggerSubPath))
Environment.SetEnvironmentVariable("SWAGGER_SUB_PATH", swaggerSubPath);
return result.Build();
}
}

View File

@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Mirea.Api.Endpoint.Common.Services.Security;
using Mirea.Api.Security.Common.Interfaces;
using System;
using System.Text;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class JwtConfiguration
{
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);
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);
var jwtIssuer = configuration["SECURITY_JWT_ISSUER"];
var jwtAudience = configuration["SECURITY_JWT_AUDIENCE"];
if (string.IsNullOrEmpty(jwtAudience) || string.IsNullOrEmpty(jwtIssuer))
throw new InvalidOperationException("The \"SECURITY_JWT_ISSUER\" and \"SECURITY_JWT_AUDIENCE\" are not specified");
services.AddSingleton<IAccessToken, JwtTokenService>(_ => new JwtTokenService
{
Audience = jwtAudience,
Issuer = jwtIssuer,
Lifetime = lifeTimeJwt,
EncryptionKey = jwtDecrypt,
SigningKey = jwtKey
});
return services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtIssuer,
ValidateAudience = true,
ValidAudience = jwtAudience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(jwtKey),
TokenDecryptionKey = new SymmetricSecurityKey(jwtDecrypt)
};
});
}
}

View File

@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Serilog;
using Serilog.Events;
using Serilog.Filters;
using Serilog.Formatting.Compact;
using System.IO;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class LoggerConfiguration
{
public static IHostBuilder AddCustomSerilog(this IHostBuilder hostBuilder)
{
return hostBuilder.UseSerilog((context, _, configuration) =>
{
var generalConfig = context.Configuration.Get<GeneralConfig>()?.LogSettings;
configuration
.MinimumLevel.Debug()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console(
outputTemplate:
"[{Level:u3}] [{Timestamp:dd.MM.yyyy HH:mm:ss}] {Message:lj}{NewLine}{Exception}");
if (generalConfig?.EnableLogToFile == true)
{
generalConfig.LogFilePath = PathBuilder.Combine(generalConfig.LogFilePath ?? string.Empty);
if (!string.IsNullOrEmpty(generalConfig.LogFilePath) && Directory.Exists(generalConfig.LogFilePath))
Directory.CreateDirectory(generalConfig.LogFilePath);
configuration.WriteTo.File(
new CompactJsonFormatter(),
PathBuilder.Combine(
generalConfig.LogFilePath!,
generalConfig.LogFileName + ".json"
),
LogEventLevel.Debug,
rollingInterval: RollingInterval.Day);
}
configuration
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning);
configuration.Filter.ByExcluding(Matching.WithProperty<string>("SourceContext", sc =>
sc.Contains("Microsoft.EntityFrameworkCore.Database.Command")));
});
}
public static IApplicationBuilder UseCustomSerilog(this IApplicationBuilder app)
{
return app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate = "[{RequestMethod}] {RequestPath} [Client {RemoteIPAddress}] [{StatusCode}] in {Elapsed:0.0000} ms";
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());
};
});
}
}

View File

@ -0,0 +1,26 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Mirea.Api.Endpoint.Common.Services.Security;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Security;
using Mirea.Api.Security.Common.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class SecureConfiguration
{
public static IServiceCollection AddSecurity(this IServiceCollection services, IConfiguration configuration)
{
services.AddSecurityServices(configuration);
services.AddSingleton<IRevokedToken, MemoryRevokedTokenService>();
if (configuration.Get<GeneralConfig>()?.CacheSettings?.TypeDatabase == CacheSettings.CacheEnum.Redis)
services.AddSingleton<ICacheService, DistributedCacheService>();
else
services.AddSingleton<ICacheService, MemoryCacheService>();
return services;
}
}

View File

@ -0,0 +1,74 @@
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.SwaggerOptions;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.IO;
namespace Mirea.Api.Endpoint.Configuration.Core.Startup;
public static class SwaggerConfiguration
{
public static IServiceCollection AddCustomSwagger(this IServiceCollection services)
{
services.AddSwaggerGen(options =>
{
options.SchemaFilter<SwaggerExampleFilter>();
options.OperationFilter<SwaggerDefaultValues>();
var basePath = AppDomain.CurrentDomain.BaseDirectory;
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Description = "Keep the JWT token in the field (Bearer token)",
Name = "Authorization",
Type = SecuritySchemeType.ApiKey
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
[]
}
});
if (File.Exists(Path.Combine(basePath, "docs.xml")))
options.IncludeXmlComments(Path.Combine(basePath, "docs.xml"));
if (File.Exists(Path.Combine(basePath, "ApiDtoDocs.xml")))
options.IncludeXmlComments(Path.Combine(basePath, "ApiDtoDocs.xml"));
});
return services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
}
public static IApplicationBuilder UseCustomSwagger(this IApplicationBuilder app, IServiceProvider services)
{
app.UseSwagger();
return app.UseSwaggerUI(options =>
{
options.InjectStylesheet($"{UrlHelper.GetSubPath}css/swagger/SwaggerDark.css");
var provider = services.GetService<IApiVersionDescriptionProvider>();
foreach (var description in provider!.ApiVersionDescriptions)
{
var url = $"/swagger/{description.GroupName}/swagger.json";
var name = description.GroupName.ToUpperInvariant();
options.SwaggerEndpoint(url, name);
options.RoutePrefix = UrlHelper.GetSubPathSwagger.Trim('/');
}
});
}
}

View File

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

View File

@ -0,0 +1,27 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Security.Common.Domain;
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";
[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 SecondFactor SecondFactor { get; set; } = SecondFactor.None;
public string? Secret { get; set; }
public void SaveSetting()
{
File.WriteAllText(FilePath, JsonSerializer.Serialize(this));
}
}

View File

@ -0,0 +1,33 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Mirea.Api.Endpoint.Configuration.Model;
public class GeneralConfig : ISaveSettings
{
[JsonIgnore] private const string FileName = "Settings.json";
[JsonIgnore]
public static string FilePath => PathBuilder.Combine(FileName);
public DbSettings? DbSettings { get; set; }
public CacheSettings? CacheSettings { get; set; }
public ScheduleSettings? ScheduleSettings { get; set; }
public EmailSettings? EmailSettings { get; set; }
public LogSettings? LogSettings { get; set; }
public string? SecretForwardToken { get; set; }
public void SaveSetting()
{
File.WriteAllText(
FilePath,
JsonSerializer.Serialize(this, new JsonSerializerOptions
{
WriteIndented = true
})
);
}
}

View File

@ -0,0 +1,23 @@
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 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

@ -0,0 +1,33 @@
using Mirea.Api.DataAccess.Persistence.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 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

@ -0,0 +1,23 @@
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
public class EmailSettings : IIsConfigured
{
public string? Server { get; set; }
public string? User { get; set; }
public string? Password { get; set; }
public string? From { get; set; }
public int? Port { get; set; }
public bool? Ssl { get; set; }
public bool IsConfigured()
{
return !string.IsNullOrEmpty(Server) &&
!string.IsNullOrEmpty(User) &&
!string.IsNullOrEmpty(Password) &&
!string.IsNullOrEmpty(From) &&
Port.HasValue &&
Ssl.HasValue;
}
}

View File

@ -0,0 +1,19 @@
using Mirea.Api.Endpoint.Configuration.Validation.Attributes;
using Mirea.Api.Endpoint.Configuration.Validation.Interfaces;
namespace Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
[RequiredSettings]
public class LogSettings : IIsConfigured
{
public bool EnableLogToFile { get; set; }
public string? LogFilePath { get; set; }
public string? LogFileName { get; set; }
public bool IsConfigured()
{
return !EnableLogToFile ||
!string.IsNullOrEmpty(LogFilePath) &&
!string.IsNullOrEmpty(LogFileName);
}
}

View File

@ -0,0 +1,45 @@
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.Model.GeneralSettings;
[RequiredSettings]
public class ScheduleSettings : IIsConfigured
{
public struct PairPeriodTime
{
public TimeOnly Start { get; set; }
public TimeOnly End { get; set; }
public PairPeriodTime(TimeOnly t1, TimeOnly t2)
{
if (t1 > t2)
{
Start = t2;
End = t1;
}
else
{
Start = t1;
End = t2;
}
}
public PairPeriodTime(Dto.Common.PairPeriodTime time) : this(time.Start, time.End) { }
}
public required string CronUpdateSchedule { get; set; }
public DateOnly StartTerm { get; set; }
public required IDictionary<int, PairPeriodTime> PairPeriod { get; set; }
public bool IsConfigured()
{
return !string.IsNullOrEmpty(CronUpdateSchedule) &&
StartTerm != default &&
PairPeriod.Count != 0 &&
PairPeriod.Any();
}
}

View File

@ -0,0 +1,36 @@
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
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",
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.",
Contact = new OpenApiContact { Name = "Author name", Email = "support@winsomnia.net" },
License = new OpenApiLicense { Name = "MIT", Url = new Uri("https://opensource.org/licenses/MIT") }
};
if (description.IsDeprecated)
info.Description += " This API version has been deprecated.";
return info;
}
}

View File

@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.Linq;
using System.Text.Json;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerDefaultValues : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var apiDescription = context.ApiDescription;
operation.Deprecated |= apiDescription.IsDeprecated();
foreach (var responseType in context.ApiDescription.SupportedResponseTypes)
{
var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString();
var response = operation.Responses[responseKey];
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)
{
var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name);
parameter.Description ??= description.ModelMetadata.Description;
if (parameter.Schema.Default == null &&
description.DefaultValue != null &&
description.DefaultValue is not DBNull &&
description.ModelMetadata is ModelMetadata modelMetadata)
{
var json = JsonSerializer.Serialize(description.DefaultValue, modelMetadata.ModelType);
parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json);
}
parameter.Required |= description.IsRequired;
}
}
}

View File

@ -0,0 +1,16 @@
using Microsoft.OpenApi.Models;
using Mirea.Api.Endpoint.Common.Attributes;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerExampleFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
var att = context.ParameterInfo?.GetCustomAttribute<SwaggerDefaultAttribute>();
if (att != null)
schema.Example = new Microsoft.OpenApi.Any.OpenApiString(att.Value);
}
}

View File

@ -0,0 +1,8 @@
using System;
namespace Mirea.Api.Endpoint.Configuration.Validation.Attributes;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class RequiredSettingsAttribute : Attribute;
// todo: only with IIsConfigured. If possible add Roslyn Analyzer later

View File

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

View File

@ -0,0 +1,28 @@
using Mirea.Api.Endpoint.Common.Interfaces;
using System;
namespace Mirea.Api.Endpoint.Configuration.Validation;
public class SetupTokenService : ISetupToken
{
public ReadOnlyMemory<byte>? Token { get; private set; }
public bool MatchToken(ReadOnlySpan<byte> token)
{
if (Token == null || token.Length != Token.Value.Length)
return false;
var token2 = Token.Value.Span;
int result = 0;
for (int i = 0; i < Token.Value.Length; i++)
result |= token2[i] ^ token[i];
return result == 0;
}
public void SetToken(ReadOnlySpan<byte> token)
{
Token = token.ToArray();
}
}

View File

@ -0,0 +1,39 @@
using Microsoft.Extensions.Options;
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.Validation.Validators;
public class SettingsRequiredValidator
{
private readonly GeneralConfig _generalConfig;
public SettingsRequiredValidator(IOptionsSnapshot<GeneralConfig> configuration) =>
_generalConfig = configuration.Value;
public SettingsRequiredValidator(GeneralConfig configuration) =>
_generalConfig = configuration;
public bool AreSettingsValid()
{
foreach (var property in _generalConfig
.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (!Attribute.IsDefined(property.PropertyType, typeof(RequiredSettingsAttribute))) continue;
var value = property.GetValue(_generalConfig);
if (value == null)
return false;
var isConfigured = value as IIsConfigured;
if (!isConfigured!.IsConfigured())
return false;
}
return true;
}
}

View File

@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Mvc;
namespace Mirea.Api.Endpoint.Controllers;
[Produces("application/json")]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
public class BaseController : ControllerBase;

View File

@ -0,0 +1,360 @@
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.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.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Configuration.Validation.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.Linq;
using System.Net.Mail;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
namespace Mirea.Api.Endpoint.Controllers.Configuration;
[ApiVersion("1.0")]
[MaintenanceModeIgnore]
[ApiExplorerSettings(IgnoreApi = true)]
public 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(TokenAuthenticationAttribute.AuthToken, token, new CookieOptions
{
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api",
Domain = HttpContext.GetCurrentDomain(),
HttpOnly = true,
#if !DEBUG
Secure = true
#endif
});
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();
if (connection is SqliteConnection)
SqliteConnection.ClearAllPools();
}
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 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}";
var result = SetDatabase<SqliteConnection, SqliteException>(connectionString, DbSettings.DatabaseEnum.Sqlite);
foreach (var file in Directory.GetFiles(path))
System.IO.File.Delete(file);
return result;
}
[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 (!PasswordHashService.HasPasswordInPolicySecurity(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;
}
}

View File

@ -0,0 +1,125 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
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.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Services;
using System;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, PasswordHashService passwordService) : BaseController
{
private CookieOptionsParameters GetCookieParams() =>
new()
{
Domain = HttpContext.GetCurrentDomain(),
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
};
[HttpPost("Login")]
[BadRequestResponse]
public async Task<ActionResult<AuthenticationStep>> Login([FromBody] LoginRequest request)
{
var userEntity = user.Value;
if (!userEntity.Username.Equals(request.Username, StringComparison.OrdinalIgnoreCase) &&
!userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase))
return BadRequest("Invalid username/email or password");
var tokenResult = await auth.LoginAsync(
GetCookieParams(),
new User
{
Id = 1,
Username = userEntity.Username,
Email = userEntity.Email,
PasswordHash = userEntity.PasswordHash,
Salt = userEntity.Salt,
SecondFactor = userEntity.SecondFactor,
SecondFactorToken = userEntity.Secret
},
HttpContext, request.Password);
return Ok(tokenResult ? AuthenticationStep.None : AuthenticationStep.TotpRequired);
}
[HttpGet("Login")]
[BadRequestResponse]
public async Task<ActionResult<AuthenticationStep>> Login([FromQuery] string code)
{
var tokenResult = await auth.LoginAsync(GetCookieParams(), HttpContext, code);
return Ok(tokenResult ? AuthenticationStep.None : AuthenticationStep.TotpRequired);
}
/// <summary>
/// Refreshes the authentication token using the existing refresh token.
/// </summary>
/// <returns>User's AuthRoles.</returns>
[HttpGet("ReLogin")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AuthRoles>> ReLogin()
{
await auth.RefreshTokenAsync(GetCookieParams(), HttpContext);
return Ok(AuthRoles.Admin);
}
/// <summary>
/// Logs the user out by clearing the refresh token and performing any necessary cleanup.
/// </summary>
/// <returns>An Ok response if the logout was successful.</returns>
[HttpGet("Logout")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Authorize]
public async Task<ActionResult> Logout()
{
await auth.LogoutAsync(GetCookieParams(), HttpContext);
return Ok();
}
/// <summary>
/// Retrieves the role of the authenticated user.
/// </summary>
/// <returns>The role of the authenticated user.</returns>
[HttpGet("GetRole")]
[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)
{
if (string.IsNullOrEmpty(password))
password = string.Empty;
else if (!PasswordHashService.HasPasswordInPolicySecurity(password))
throw new ControllerArgumentException("The password must be at least 8 characters long and contain at least one uppercase letter and one special character.");
while (!PasswordHashService.HasPasswordInPolicySecurity(password))
password = GeneratorKey.GenerateAlphaNumeric(16, includes: "!@#%^");
var (salt, hash) = passwordService.HashPassword(password);
var admin = user.Value;
admin.Salt = salt;
admin.PasswordHash = hash;
admin.SaveSetting();
return Ok(password);
}
}

View File

@ -0,0 +1,60 @@
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;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class CampusController(IMediator mediator) : BaseController
{
/// <summary>
/// Gets basic information about campuses.
/// </summary>
/// <returns>Basic information about campuses.</returns>
[HttpGet]
public async Task<ActionResult<List<CampusBasicInfoResponse>>> Get()
{
var result = await mediator.Send(new GetCampusBasicInfoListQuery());
return Ok(result.Campuses
.Select(c => new CampusBasicInfoResponse()
{
Id = c.Id,
CodeName = c.CodeName,
FullName = c.FullName
})
);
}
/// <summary>
/// Gets details of a specific campus by ID.
/// </summary>
/// <param name="id">Campus ID.</param>
/// <returns>Details of the specified campus.</returns>
[HttpGet("{id:int}")]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<CampusDetailsResponse>> GetDetails(int id)
{
var result = await mediator.Send(new GetCampusDetailsQuery()
{
Id = id
});
return Ok(new CampusDetailsResponse()
{
Id = result.Id,
CodeName = result.CodeName,
FullName = result.FullName,
Address = result.Address
});
}
}

View File

@ -0,0 +1,64 @@
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.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class DisciplineController(IMediator mediator) : BaseController
{
/// <summary>
/// Gets a paginated list of disciplines.
/// </summary>
/// <param name="page">Page number. Start from 0.</param>
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of disciplines.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<DisciplineResponse>>> Get([FromQuery] int? page, [FromQuery] int? pageSize)
{
var result = await mediator.Send(new GetDisciplineListQuery()
{
Page = page,
PageSize = pageSize
});
return Ok(result.Disciplines
.Select(d => new DisciplineResponse()
{
Id = d.Id,
Name = d.Name
})
);
}
/// <summary>
/// Gets details of a specific discipline by ID.
/// </summary>
/// <param name="id">Discipline ID.</param>
/// <returns>Details of the specified discipline.</returns>
[HttpGet("{id:int}")]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<DisciplineResponse>> GetDetails(int id)
{
var result = await mediator.Send(new GetDisciplineInfoQuery()
{
Id = id
});
return Ok(new DisciplineResponse()
{
Id = result.Id,
Name = result.Name
});
}
}

View File

@ -0,0 +1,41 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
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.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class FacultyController(IMediator mediator) : BaseController
{
/// <summary>
/// Gets a paginated list of faculties.
/// </summary>
/// <param name="page">Page number. Start from 0.</param>
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of faculties.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<FacultyResponse>>> Get([FromQuery] int? page, [FromQuery] int? pageSize)
{
var result = await mediator.Send(new GetFacultyListQuery()
{
Page = page,
PageSize = pageSize
});
return Ok(result.Faculties
.Select(f => new FacultyResponse()
{
Id = f.Id,
Name = f.Name
})
);
}
}

View File

@ -0,0 +1,106 @@
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;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System;
using System.Collections.Generic;
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)
{
var current = DateTime.Now;
if (!int.TryParse(groupName[2..], out var yearOfGroup)
&& !int.TryParse(groupName.Split('-')[^1][..2], out yearOfGroup))
return -1;
// Convert a two-digit year to a four-digit one
yearOfGroup += current.Year / 100 * 100;
return current.Year - yearOfGroup + (current.Month < 8 ? 0 : 1);
}
/// <summary>
/// Retrieves a list of groups.
/// </summary>
/// <param name="page">The page number for pagination (optional).</param>
/// <param name="pageSize">The page size for pagination (optional).</param>
/// <returns>A list of groups.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<GroupResponse>>> Get([FromQuery] int? page, [FromQuery] int? pageSize)
{
var result = await mediator.Send(new GetGroupListQuery()
{
Page = page,
PageSize = pageSize
});
return Ok(result.Groups
.Select(g => new GroupResponse()
{
Id = g.Id,
Name = g.Name,
FacultyId = g.FacultyId,
CourseNumber = GetCourseNumber(g.Name)
})
);
}
/// <summary>
/// Retrieves detailed information about a specific group.
/// </summary>
/// <param name="id">The ID of the group to retrieve.</param>
/// <returns>Detailed information about the group.</returns>
[HttpGet("{id:int}")]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<GroupDetailsResponse>> GetDetails(int id)
{
var result = await mediator.Send(new GetGroupInfoQuery()
{
Id = id
});
return Ok(new GroupDetailsResponse()
{
Id = result.Id,
Name = result.Name,
FacultyId = result.FacultyId,
FacultyName = result.Faculty,
CourseNumber = GetCourseNumber(result.Name)
});
}
/// <summary>
/// Retrieves a list of groups by faculty ID.
/// </summary>
/// <param name="id">The ID of the faculty.</param>
/// <returns>A list of groups belonging to the specified faculty.</returns>
[HttpGet("GetByFaculty/{id:int}")]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<List<GroupResponse>>> GetByFaculty(int id)
{
var result = await mediator.Send(new GetGroupListQuery());
return Ok(result.Groups
.Where(g => g.FacultyId == id)
.Select(g => new GroupResponse()
{
Id = g.Id,
Name = g.Name,
CourseNumber = GetCourseNumber(g.Name),
FacultyId = g.FacultyId
}));
}
}

View File

@ -0,0 +1,167 @@
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<IActionResult> 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
})).Schedules;
if (result.Count == 0)
return NoContent();
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
using var package = new ExcelPackage();
var worksheet = package.Workbook.Worksheets.Add("Расписание");
int row = 1;
int 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

@ -0,0 +1,82 @@
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;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class LectureHallController(IMediator mediator) : BaseController
{
/// <summary>
/// Retrieves a list of all lecture halls.
/// </summary>
/// <returns>A list of lecture halls.</returns>
[HttpGet]
public async Task<ActionResult<List<LectureHallResponse>>> Get()
{
var result = await mediator.Send(new GetLectureHallListQuery());
return Ok(result.LectureHalls
.Select(l => new LectureHallResponse()
{
Id = l.Id,
Name = l.Name,
CampusId = l.CampusId
})
);
}
/// <summary>
/// Retrieves details of a specific lecture hall by its ID.
/// </summary>
/// <param name="id">The ID of the lecture hall to retrieve.</param>
/// <returns>The details of the specified lecture hall.</returns>
[HttpGet("{id:int}")]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<LectureHallDetailsResponse>> GetDetails(int id)
{
var result = await mediator.Send(new GetLectureHallInfoQuery()
{
Id = id
});
return Ok(new LectureHallDetailsResponse()
{
Id = result.Id,
Name = result.Name,
CampusId = result.CampusId,
CampusCode = result.CampusCode,
CampusName = result.CampusName
});
}
/// <summary>
/// Retrieves a list of lecture halls by campus ID.
/// </summary>
/// <param name="id">The ID of the campus.</param>
/// <returns>A list of lecture halls in the specified campus.</returns>
[HttpGet("GetByCampus/{id:int}")]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<List<LectureHallResponse>>> GetByCampus(int id)
{
var result = await mediator.Send(new GetLectureHallListQuery());
return Ok(result.LectureHalls.Where(l => l.CampusId == id)
.Select(l => new LectureHallResponse()
{
Id = l.Id,
Name = l.Name,
CampusId = l.CampusId
}));
}
}

View File

@ -0,0 +1,98 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.DataAccess.Application.Cqrs.Professor.Queries.GetProfessorDetails;
using Mirea.Api.DataAccess.Application.Cqrs.Professor.Queries.GetProfessorDetailsBySearch;
using Mirea.Api.DataAccess.Application.Cqrs.Professor.Queries.GetProfessorList;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class ProfessorController(IMediator mediator) : BaseController
{
/// <summary>
/// Retrieves a list of professors.
/// </summary>
/// <param name="page">The page number for pagination (optional).</param>
/// <param name="pageSize">The page size for pagination (optional).</param>
/// <returns>A list of professors.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<ProfessorResponse>>> Get([FromQuery] int? page, [FromQuery] int? pageSize)
{
var result = await mediator.Send(new GetProfessorListQuery()
{
Page = page,
PageSize = pageSize
});
return Ok(result.Professors
.Select(p => new ProfessorResponse()
{
Id = p.Id,
Name = p.Name,
AltName = p.AltName
})
);
}
/// <summary>
/// Retrieves detailed information about a specific professor.
/// </summary>
/// <param name="id">The ID of the professor to retrieve.</param>
/// <returns>Detailed information about the professor.</returns>
[HttpGet("{id:int}")]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<ProfessorResponse>> GetDetails(int id)
{
var result = await mediator.Send(new GetProfessorInfoQuery()
{
Id = id
});
return Ok(new ProfessorResponse()
{
Id = result.Id,
Name = result.Name,
AltName = result.AltName
});
}
/// <summary>
/// Retrieves detailed information about professors based on their name.
/// </summary>
/// <remarks>
/// This method searches for professors whose name matches the provided search term.
/// </remarks>
/// <param name="name">The name of the professor to search for. Must be at least 4 characters long.</param>
/// <returns>
/// A list of <see cref="ProfessorResponse"/> objects containing the details of the matching professors.
/// </returns>
[HttpGet("{name:required}")]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<List<ProfessorResponse>>> GetDetails(string name)
{
if (string.IsNullOrEmpty(name) || name.Length < 4)
return BadRequest($"The minimum number of characters is 4 (current: {name.Length}).");
var result = await mediator.Send(new GetProfessorInfoSearchQuery()
{
Name = name
});
return Ok(result.Details.Select(x => new ProfessorResponse()
{
Id = x.Id,
Name = x.Name,
AltName = x.AltName
}));
}
}

View File

@ -0,0 +1,206 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Mirea.Api.DataAccess.Application.Cqrs.Schedule.Queries.GetScheduleList;
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.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConfig> config) : BaseController
{
/// <summary>
/// Retrieves the start term for the schedule.
/// </summary>
/// <returns>The start term as a <see cref="DateOnly"/> value.</returns>
[CacheMaxAge(1, 0)]
[HttpGet("StartTerm")]
public ActionResult<DateOnly> GetStartTerm() => config.Value.ScheduleSettings!.StartTerm;
/// <summary>
/// Retrieves the pair periods.
/// </summary>
/// <returns>A dictionary of pair periods, where the key is an integer identifier and the value is a <see cref="PairPeriodTime"/> object.</returns>
[CacheMaxAge(1, 0)]
[HttpGet("PairPeriod")]
public ActionResult<Dictionary<int, PairPeriodTime>> GetPairPeriod() => config.Value.ScheduleSettings!.PairPeriod.ConvertToDto();
/// <summary>
/// Retrieves schedules based on various filters.
/// </summary>
/// <param name="request">The request object containing filter criteria.</param>
/// <returns>A list of schedules matching the filter criteria.</returns>
[HttpPost]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[BadRequestResponse]
public async Task<ActionResult<List<ScheduleResponse>>> Get([FromBody] ScheduleRequest request)
{
if ((request.Groups == null || request.Groups.Length == 0) &&
(request.Disciplines == null || request.Disciplines.Length == 0) &&
(request.Professors == null || request.Professors.Length == 0) &&
(request.LectureHalls == null || request.LectureHalls.Length == 0))
{
return BadRequest(new ErrorResponse()
{
Error = "At least one of the arguments must be selected."
+ (request.IsEven.HasValue
? $" \"{nameof(request.IsEven)}\" is not a strong argument"
: string.Empty),
Code = StatusCodes.Status400BadRequest
});
}
var result = (await mediator.Send(new GetScheduleListQuery
{
IsEven = request.IsEven,
DisciplineIds = request.Disciplines,
GroupIds = request.Groups,
LectureHallIds = request.LectureHalls,
ProfessorIds = request.Professors
})).Schedules;
if (result.Count == 0)
NoContent();
return Ok(result.Select(s => new ScheduleResponse
{
DayOfWeek = s.DayOfWeek,
PairNumber = s.PairNumber,
IsEven = s.IsEven,
Discipline = s.Discipline,
DisciplineId = s.DisciplineId,
IsExcludedWeeks = s.IsExcludedWeeks,
Weeks = s.Weeks,
TypeOfOccupations = s.TypeOfOccupations,
Group = s.Group,
GroupId = s.GroupId,
LectureHalls = s.LectureHalls,
LectureHallsId = s.LectureHallsId,
Professors = s.Professors,
ProfessorsId = s.ProfessorsId,
Campus = s.Campus,
CampusId = s.CampusId,
LinkToMeet = s.LinkToMeet
}));
}
/// <summary>
/// Retrieves schedules for a specific group based on various filters.
/// </summary>
/// <param name="id">The ID of the group.</param>
/// <param name="isEven">A value indicating whether to retrieve schedules for even weeks.</param>
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <returns>A response containing schedules for the specified group.</returns>
[HttpGet("GetByGroup/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<List<ScheduleResponse>>> GetByGroup(int id,
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? professors = null,
[FromQuery] int[]? lectureHalls = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = [id],
Professors = professors,
LectureHalls = lectureHalls
});
/// <summary>
/// Retrieves schedules for a specific professor based on various filters.
/// </summary>
/// <param name="id">The ID of the professor.</param>
/// <param name="isEven">A value indicating whether to retrieve schedules for even weeks.</param>
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="groups">An array of group IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <returns>A response containing schedules for the specified professor.</returns>
[HttpGet("GetByProfessor/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<List<ScheduleResponse>>> GetByProfessor(int id,
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? lectureHalls = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = groups,
Professors = [id],
LectureHalls = lectureHalls
});
/// <summary>
/// Retrieves schedules for a specific lecture hall based on various filters.
/// </summary>
/// <param name="id">The ID of the lecture hall.</param>
/// <param name="isEven">A value indicating whether to retrieve schedules for even weeks.</param>
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="groups">An array of group IDs.</param>
/// <returns>A response containing schedules for the specified lecture hall.</returns>
[HttpGet("GetByLectureHall/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<List<ScheduleResponse>>> GetByLectureHall(int id,
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? professors = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = groups,
Professors = professors,
LectureHalls = [id]
});
/// <summary>
/// Retrieves schedules for a specific discipline based on various filters.
/// </summary>
/// <param name="id">The ID of the discipline.</param>
/// <param name="isEven">A value indicating whether to retrieve schedules for even weeks.</param>
/// <param name="groups">An array of group IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <returns>A response containing schedules for the specified discipline.</returns>
[HttpGet("GetByDiscipline/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[BadRequestResponse]
[NotFoundResponse]
public async Task<ActionResult<List<ScheduleResponse>>> GetByDiscipline(int id,
[FromQuery] bool? isEven = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? professors = null,
[FromQuery] int[]? lectureHalls = null) =>
await Get(new ScheduleRequest
{
Disciplines = [id],
IsEven = isEven,
Groups = groups,
Professors = professors,
LectureHalls = lectureHalls
});
}

View File

@ -1,36 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Mirea.Api.Endpoint.Controllers;
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}

View File

@ -5,21 +5,68 @@
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Company>Winsomnia</Company> <Company>Winsomnia</Company>
<Version>1.0.0-a0</Version> <Version>1.0-rc4</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>1.0.2.4</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>1.0.2.4</FileVersion>
<AssemblyName>Mirea.Api.Endpoint</AssemblyName> <AssemblyName>Mirea.Api.Endpoint</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace> <RootNamespace>$(AssemblyName)</RootNamespace>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<InvariantGlobalization>true</InvariantGlobalization> <InvariantGlobalization>false</InvariantGlobalization>
<UserSecretsId>65cea060-88bf-4e35-9cfb-18fc996a8f05</UserSecretsId> <UserSecretsId>65cea060-88bf-4e35-9cfb-18fc996a8f05</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>.</DockerfileContext> <DockerfileContext>.</DockerfileContext>
<SignAssembly>False</SignAssembly>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile>docs.xml</DocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.5" /> <PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
<PackageReference Include="AspNetCore.HealthChecks.System" Version="8.0.1" />
<PackageReference Include="Cronos" Version="0.8.4" />
<PackageReference Include="EPPlus" Version="7.4.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.11.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.11.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="8.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.1.2" />
<PackageReference Include="Mirea.Tools.Schedule.WebParser" Version="1.0.4" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="System.CodeDom" Version="8.0.0" />
<PackageReference Include="System.Composition" Version="8.0.0" />
<PackageReference Include="System.Composition.TypedParts" Version="8.0.0" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.1.2" />
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.0.0" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="Z.EntityFramework.Extensions.EFCore" Version="8.103.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApiDto\ApiDto.csproj" />
<ProjectReference Include="..\SqlData\Domain\Domain.csproj" />
<ProjectReference Include="..\SqlData\Persistence\Persistence.csproj" />
<ProjectReference Include="..\Security\Security.csproj" />
<ProjectReference Include="..\SqlData\Migrations\PsqlMigrations\PsqlMigrations.csproj" />
<ProjectReference Include="..\SqlData\Migrations\SqliteMigrations\SqliteMigrations.csproj" />
<ProjectReference Include="..\SqlData\Migrations\MysqlMigrations\MysqlMigrations.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,35 +1,157 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options;
using Mirea.Api.DataAccess.Application;
using Mirea.Api.DataAccess.Persistence;
using Mirea.Api.DataAccess.Persistence.Common;
using Mirea.Api.Endpoint.Common.Interfaces;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Core.BackgroundTasks;
using Mirea.Api.Endpoint.Configuration.Core.Middleware;
using Mirea.Api.Endpoint.Configuration.Core.Startup;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Validation;
using Mirea.Api.Endpoint.Configuration.Validation.Validators;
using Mirea.Api.Security.Services;
using System;
using System.IO;
namespace Mirea.Api.Endpoint; namespace Mirea.Api.Endpoint;
public class Program public class Program
{ {
public static IServiceCollection AddDatabase(IServiceCollection services, IConfiguration configuration, IHealthChecksBuilder? healthCheckBuilder = null)
{
var dbSettings = configuration.Get<GeneralConfig>()?.DbSettings;
services.AddApplication();
services.AddPersistence(
dbSettings?.DatabaseProvider ?? DatabaseProvider.Sqlite,
dbSettings?.ConnectionStringSql ?? string.Empty);
healthCheckBuilder?.AddDatabaseHealthCheck(
dbSettings?.DatabaseProvider ?? DatabaseProvider.Sqlite,
dbSettings?.ConnectionStringSql ?? string.Empty);
return services;
}
public static void Main(string[] args) public static void Main(string[] args)
{ {
var builder = WebApplication.CreateBuilder(args); Directory.SetCurrentDirectory(AppDomain.CurrentDomain.BaseDirectory);
// Add services to the container. var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddConfiguration(EnvironmentConfiguration.GetEnvironment());
var healthCheckBuilder = builder.Services.AddHealthChecks();
builder.Configuration.AddJsonFile(GeneralConfig.FilePath, optional: true, reloadOnChange: true);
builder.Services.Configure<GeneralConfig>(builder.Configuration);
healthCheckBuilder.AddFile(x => x.AddFile(GeneralConfig.FilePath), name: nameof(GeneralConfig));
builder.Configuration.AddJsonFile(Admin.FilePath, optional: true, reloadOnChange: true);
builder.Services.Configure<Admin>(builder.Configuration);
healthCheckBuilder.AddFile(x => x.AddFile(Admin.FilePath), name: nameof(Admin));
builder.Host.AddCustomSerilog();
AddDatabase(builder.Services, builder.Configuration, healthCheckBuilder);
builder.Services.AddControllers(); builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSingleton<IMaintenanceModeNotConfigureService, MaintenanceModeNotConfigureService>();
builder.Services.AddSwaggerGen(); builder.Services.AddSingleton<IMaintenanceModeService, MaintenanceModeService>();
builder.Services.AddSingleton<ISetupToken, SetupTokenService>();
builder.Services.AddHostedService<ScheduleSyncService>();
builder.Services.AddMemoryCache();
builder.Services.AddCustomRedis(builder.Configuration, healthCheckBuilder);
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyMethod();
policy.AllowAnyHeader();
policy.AllowCredentials();
#if DEBUG
policy.WithOrigins("http://localhost:4200");
#endif
});
});
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(
int.Parse(builder.Configuration.GetValue<string>("INTERNAL_PORT") ?? "8080"));
});
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
var secretForward = builder.Configuration.Get<GeneralConfig>();
if (string.IsNullOrEmpty(secretForward!.SecretForwardToken))
{
secretForward.SecretForwardToken = GeneratorKey.GenerateAlphaNumeric(16);
secretForward.SaveSetting();
Console.WriteLine($"For the reverse proxy server to work correctly, use the header: '{secretForward.SecretForwardToken}-X-Forwarded-For'");
}
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.ForwardedForHeaderName = secretForward.SecretForwardToken + "-X-Forwarded-For";
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddCustomApiVersioning();
builder.Services.AddCustomSwagger();
builder.Services.AddJwtToken(builder.Configuration);
builder.Services.AddSecurity(builder.Configuration);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(PathBuilder.Combine("DataProtection")));
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline. app.UseForwardedHeaders();
if (app.Environment.IsDevelopment()) app.UseStaticFiles(UrlHelper.GetSubPath.TrimEnd('/'));
app.UseCors("AllowAll");
app.UseCustomSerilog();
app.MapHealthChecks("/health");
using (var scope = app.Services.CreateScope())
{ {
app.UseSwagger(); var serviceProvider = scope.ServiceProvider;
app.UseSwaggerUI();
var optionsSnapshot = serviceProvider.GetRequiredService<IOptionsSnapshot<GeneralConfig>>();
var settingsValidator = new SettingsRequiredValidator(optionsSnapshot);
var isDoneConfig = settingsValidator.AreSettingsValid();
if (isDoneConfig)
{
var uberDbContext = serviceProvider.GetRequiredService<UberDbContext>();
var maintenanceModeService = serviceProvider.GetRequiredService<IMaintenanceModeNotConfigureService>();
maintenanceModeService.DisableMaintenanceMode();
DbInitializer.Initialize(uberDbContext);
}
} }
app.UseCustomSwagger(app.Services);
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseMiddleware<CustomExceptionHandlerMiddleware>();
app.UseMiddleware<MaintenanceModeMiddleware>();
app.UseMiddleware<CookieAuthorizationMiddleware>();
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<JwtRevocationMiddleware>();
app.UseMiddleware<CacheMaxAgeMiddleware>();
app.MapControllers(); app.MapControllers();

View File

@ -0,0 +1,42 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Sync.Common;
internal class DataRepository<T> where T : class
{
private readonly ConcurrentBag<T> _data = [];
private readonly object _lock = new();
public IEnumerable<T> GetAll() => _data.ToList();
public DataRepository(List<T> data)
{
foreach (var d in data)
_data.Add(d);
}
public T? Get(Func<T, bool> predicate)
{
var entity = _data.FirstOrDefault(predicate);
return entity;
}
public T Create(Func<T> createEntity)
{
var entity = createEntity();
_data.Add(entity);
return entity;
}
public T GetOrCreate(Func<T, bool> predicate, Func<T> createEntity)
{
lock (_lock)
{
var entity = Get(predicate);
return entity ?? Create(createEntity);
}
}
}

View File

@ -0,0 +1,289 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mirea.Api.DataAccess.Domain.Schedule;
using Mirea.Api.DataAccess.Persistence;
using Mirea.Api.Endpoint.Common.Interfaces;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Sync.Common;
using Mirea.Tools.Schedule.WebParser;
using Mirea.Tools.Schedule.WebParser.Common.Domain;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Group = Mirea.Api.DataAccess.Domain.Schedule.Group;
namespace Mirea.Api.Endpoint.Sync;
internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSnapshot<GeneralConfig> config, ILogger<ScheduleSynchronizer> logger, IMaintenanceModeService maintenanceMode)
{
private readonly DataRepository<Campus> _campuses = new([.. dbContext.Campuses]);
private readonly DataRepository<Discipline> _disciplines = new([.. dbContext.Disciplines]);
private readonly DataRepository<Faculty> _faculties = new([.. dbContext.Faculties]);
private readonly DataRepository<Group> _groups = new([.. dbContext.Groups]);
private readonly DataRepository<LectureHall> _lectureHalls = new([.. dbContext.LectureHalls]);
private readonly DataRepository<Lesson> _lessons = new([]);
private readonly DataRepository<LessonAssociation> _lessonAssociation = new([]);
private readonly DataRepository<Professor> _professors = new([.. dbContext.Professors]);
private readonly DataRepository<TypeOfOccupation> _typeOfOccupations = new([.. dbContext.TypeOfOccupations]);
private readonly DataRepository<SpecificWeek> _specificWeeks = new([]);
// todo: transfer data to storage
private static string GetFaculty(char c) =>
c switch
{
'У' => "ИТУ",
'Б' => "ИКБ",
'Х' => "ИТХТ",
'Э' => "ИПТИП",
'Т' => "ИПТИП",
'Р' => "ИРИ",
'К' => "ИИИ",
'И' => "ИИТ",
'П' => "ИИТ",
_ => throw new ArgumentOutOfRangeException(nameof(c), c, null)
};
private void ParallelSync(GroupResult groupInfo)
{
var facultyName = GetFaculty(groupInfo.Group[0]);
var faculty = _faculties.GetOrCreate(
f => f.Name.Equals(facultyName, StringComparison.OrdinalIgnoreCase),
() => new Faculty
{
Name = facultyName
});
var groupName = OnlyGroupName().Match(groupInfo.Group.ToUpper()).Value;
var group = _groups.GetOrCreate(
g => g.Name.Equals(groupName, StringComparison.OrdinalIgnoreCase),
() => new Group
{
Name = groupName,
Faculty = faculty
});
var typeOfOccupation = _typeOfOccupations.GetOrCreate(
t => t.ShortName.Equals(groupInfo.TypeOfOccupation.Trim(), StringComparison.OrdinalIgnoreCase),
() => new TypeOfOccupation
{
ShortName = groupInfo.TypeOfOccupation.ToUpper()
});
List<Professor>? professor = [];
if (groupInfo.Professor != null)
{
foreach (var prof in groupInfo.Professor)
{
var professorParts = prof.Split(' ').ToList();
string? altName = null;
if (professorParts is { Count: >= 2 })
{
altName = professorParts.ElementAtOrDefault(0);
if (professorParts.ElementAtOrDefault(1) != null)
altName += $" {professorParts.ElementAtOrDefault(1)?[0]}.";
if (professorParts.ElementAtOrDefault(2) != null)
altName += $"{professorParts.ElementAtOrDefault(2)?[0]}.";
}
if (string.IsNullOrEmpty(altName))
continue;
var profDb = _professors.GetOrCreate(x =>
(x.AltName == null || x.AltName.Equals(prof, StringComparison.OrdinalIgnoreCase)) &&
x.Name.Equals(altName, StringComparison.OrdinalIgnoreCase),
() => new Professor
{
AltName = prof,
Name = altName
});
professor.Add(profDb);
}
}
else
professor = null;
List<LectureHall>? hall = null;
List<Campus>? campuses;
if (groupInfo.Campuses != null && groupInfo.Campuses.Length != 0)
{
hall = [];
campuses = [];
for (int i = 0; i < groupInfo.Campuses.Length; i++)
{
var campus = groupInfo.Campuses[i];
campuses.Add(_campuses.GetOrCreate(
c => c.CodeName.Equals(campus, StringComparison.OrdinalIgnoreCase),
() => new Campus
{
CodeName = campus.ToUpper()
}));
if (groupInfo.LectureHalls == null || groupInfo.LectureHalls.Length <= i)
continue;
var lectureHall = groupInfo.LectureHalls[i];
hall.Add(_lectureHalls.GetOrCreate(l =>
l.Name.Equals(lectureHall, StringComparison.OrdinalIgnoreCase) &&
string.Equals(l.Campus?.CodeName, campuses[^1].CodeName, StringComparison.CurrentCultureIgnoreCase),
() => new LectureHall
{
Name = lectureHall,
Campus = campuses[^1]
}));
}
}
var discipline = _disciplines.GetOrCreate(
d => d.Name.Equals(groupInfo.Discipline, StringComparison.OrdinalIgnoreCase),
() => new Discipline
{
Name = groupInfo.Discipline
});
var lesson = _lessons.GetOrCreate(l =>
l.IsEven == groupInfo.IsEven &&
l.DayOfWeek == groupInfo.Day &&
l.PairNumber == groupInfo.Pair &&
l.Discipline?.Name == discipline.Name &&
l.Group?.Name == group.Name,
() =>
{
var lesson = new Lesson
{
IsEven = groupInfo.IsEven,
DayOfWeek = groupInfo.Day,
PairNumber = groupInfo.Pair,
Discipline = discipline,
Group = group,
IsExcludedWeeks = groupInfo.IsExclude
};
if (groupInfo.SpecialWeek == null)
return lesson;
foreach (var week in groupInfo.SpecialWeek)
_specificWeeks.Create(() => new SpecificWeek
{
Lesson = lesson,
WeekNumber = week
});
return lesson;
});
int maxValue = int.Max(int.Max(professor?.Count ?? -1, hall?.Count ?? -1), 1);
for (int i = 0; i < maxValue; i++)
{
var prof = professor?.ElementAtOrDefault(i);
var lectureHall = hall?.ElementAtOrDefault(i);
_lessonAssociation.Create(() => new LessonAssociation
{
Professor = prof,
Lesson = lesson,
LectureHall = lectureHall,
TypeOfOccupation = typeOfOccupation
});
}
}
private async Task SaveChanges(CancellationToken cancellationToken)
{
foreach (var group in _groups.GetAll())
{
var existingGroup = await dbContext.Groups.FirstOrDefaultAsync(g => g.Id == group.Id, cancellationToken);
if (existingGroup != null)
dbContext.Remove(existingGroup);
}
await dbContext.Disciplines.BulkSynchronizeAsync(_disciplines.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
await dbContext.Professors.BulkSynchronizeAsync(_professors.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
await dbContext.TypeOfOccupations.BulkSynchronizeAsync(_typeOfOccupations.GetAll(), bulkOperation => bulkOperation.BatchSize = 100, cancellationToken);
await dbContext.Faculties.BulkSynchronizeAsync(_faculties.GetAll(), bulkOperation => bulkOperation.BatchSize = 100, cancellationToken);
await dbContext.Campuses.BulkSynchronizeAsync(_campuses.GetAll(), bulkOperation => bulkOperation.BatchSize = 10, cancellationToken);
await dbContext.LectureHalls.BulkSynchronizeAsync(_lectureHalls.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
await dbContext.Groups.BulkSynchronizeAsync(_groups.GetAll(), bulkOperation => bulkOperation.BatchSize = 100, cancellationToken);
await dbContext.Lessons.BulkSynchronizeAsync(_lessons.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
await dbContext.SpecificWeeks.BulkSynchronizeAsync(_specificWeeks.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
await dbContext.LessonAssociations.BulkSynchronizeAsync(_lessonAssociation.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
}
public async Task StartSync(CancellationToken cancellationToken)
{
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod;
var startTerm = config.Value.ScheduleSettings?.StartTerm;
if (pairPeriods == null || startTerm == null)
{
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} or {Arg2} variable is not initialized.", nameof(pairPeriods), nameof(startTerm));
return;
}
Stopwatch watch = new();
watch.Start();
var parser = new Parser
{
Pairs = pairPeriods
.ToDictionary(x => x.Key,
x => (x.Value.Start, x.Value.End)),
TermStart = startTerm.Value.ToDateTime(new TimeOnly(0, 0, 0))
};
try
{
logger.LogDebug("Start parsing schedule");
var data = await parser.ParseAsync(cancellationToken);
watch.Stop();
var parsingTime = watch.ElapsedMilliseconds;
watch.Restart();
ParallelOptions options = new()
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = Environment.ProcessorCount
};
logger.LogDebug("Start mapping parsed data");
Parallel.ForEach(data, options, ParallelSync);
watch.Stop();
var mappingTime = watch.ElapsedMilliseconds;
watch.Restart();
maintenanceMode.EnableMaintenanceMode();
logger.LogDebug("Start saving changing");
await SaveChanges(cancellationToken);
maintenanceMode.DisableMaintenanceMode();
watch.Stop();
logger.LogInformation("Parsing time: {ParsingTime}ms Mapping time: {MappingTime}ms Saving time: {SavingTime}ms Total time: {TotalTime}ms",
parsingTime, mappingTime, watch.ElapsedMilliseconds, parsingTime + mappingTime + watch.ElapsedMilliseconds);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
maintenanceMode.DisableMaintenanceMode();
throw;
}
}
[GeneratedRegex(@"\w{4}-\d{2}-\d{2}(?=\s?\d?\s?[Пп]/?[Гг]\s?\d?)?")]
private static partial Regex OnlyGroupName();
}

View File

@ -1,14 +0,0 @@
using System;
namespace Mirea.Api.Endpoint;
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}

File diff suppressed because one or more lines are too long

205
README.md
View File

@ -1 +1,204 @@
# Backend # MIREA schedule by Winsomnia
[![NET Release](https://img.shields.io/badge/v8.0-8?style=flat-square&label=.NET&labelColor=512BD4&color=606060)](https://dotnet.microsoft.com/download/dotnet/8.0)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT)
This project is a backend part of an application developed on ASP.NET , which provides an API for getting schedule data.
The main task is to provide convenient and flexible tools for accessing the schedule in various ways.
## Purpose
The purpose of this project is to provide convenient and flexible tools for obtaining schedule data.
In a situation where existing resources provide limited functionality or an inconvenient interface, this project aims to provide users with a simple and effective tool for accessing information about class schedules.
Developing your own API and using your own tools for downloading and processing data allows you to ensure the reliability, flexibility and extensibility of the application functionality.
## Features
1. **Flexible API**: The API provides a variety of methods for accessing schedule data. Unlike competitors that provide a limited set of endpoints, this application provides a wider range of functionality, allowing you to get data about groups, campuses, faculties, classrooms and teachers. You can get all the data at once or select specific IDs with the details that are needed.
2. **Database Providers**: The application provides the capability of various database providers.
3. **Using self-written packages**: The project uses two proprietary NuGet packages. One of them is designed for parsing schedules, and the other is for downloading Excel spreadsheets from external sites.
## Project status
The project is under development. Further development will be aimed at expanding the functionality and improving the user experience.
# Environment Variables
This table provides information about the environment variables that are used in the application. These variables are stored in the [.env](.env) file.
In addition to these variables, you also need to fill in a file with settings in json format. The web application provided by this project already has everything necessary to configure the file in the Client-Server communication format via the controller. If you need to get the configuration file otherwise, then you need to refer to the classes that provide configuration-related variables.
Please note that the application will not work correctly if you do not fill in the required variables.
| Variable | Default | Description | Required |
|---------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
| PATH_TO_SAVE | Current folder | The path to save the data. Saving logs (if the full path is not specified), databases (if Sqlite), and other data that should be saved in a place other than where the program is launched. | ✔ |
| SECURITY_SIGNING_TOKEN | ❌ | JWT signature token. This token will be used to create and verify the signature of JWT tokens. The token must be equal to 64 characters. | ✔ |
| SECURITY_ENCRYPTION_TOKEN | ❌ | Token for JWT encryption. This token will be used to encrypt and decrypt JWT tokens. The token must be equal to 32 characters. | ✔ |
| SECURITY_LIFE_TIME_RT | 1440 | Time in minutes after which the Refresh Token will become invalid. | ❌ |
| SECURITY_LIFE_TIME_JWT | 15 | Time in minutes after which the JWT token will become invalid. | ❌ |
| SECURITY_LIFE_TIME_1_FA | 15 | Time in minutes after which the token of the first factor will become invalid. | ❌ |
| SECURITY_JWT_ISSUER | ❌ | An identifier that points to the server that created the token. | ✔ |
| SECURITY_JWT_AUDIENCE | ❌ | ID of the audience for which the token is intended. | ✔ |
| SECURITY_HASH_ITERATION | ❌ | The number of iterations used to hash passwords in the Argon2 algorithm. | ✔ |
| SECURITY_HASH_MEMORY | ❌ | The amount of memory used to hash passwords in the Argon2 algorithm. | ✔ |
| SECURITY_HASH_PARALLELISM | ❌ | Parallelism determines how many of the memory fragments divided into strips will be used to generate a hash. | ✔ |
| SECURITY_HASH_SIZE | 32 | The size of the output hash generated by the password hashing algorithm. | ❌ |
| SECURITY_HASH_TOKEN | ❌ | Additional protection for Argon2. We recommend installing a token so that even if the data is compromised, an attacker cannot brute force a password without a token. | ❌ |
| SECURITY_SALT_SIZE | 16 | The size of the salt used to hash passwords. The salt is a random value added to the password before hashing to prevent the use of rainbow hash tables and other attacks. | ❌ |
# Installation
If you want to make a fork of this project or place the Backend application on your hosting yourself, then follow the instructions below.
1. [Docker Installation](#docker-installation)
2. [Docker Self Build](#docker-self-build)
3. [Manual Installation](#manual-installation)
4. [Self Build](#self-build)
## Docker Installation
**Requirements**
- Docker
To launch the application, pull out the application image:
```bash
docker pull winsomnia/mirea-backend:latest
```
Next, you need to fill in the required fields inside.env and pass it when the container is started:
```bash
docker run -d --name mirea-backend -p 8080 \
--restart=on-failure:10 \
-v mirea-data:/data \
-e PATH_TO_SAVE=/data \
-e .env \
winsomnia/mirea-backend:latest
```
Using the `--name` option, you can specify your container name, for example: `--name mirea`.
With the `-p` option, you can specify the port you need: `-p 80:8080`.
It is necessary to tell the application exactly where to save the data so that it does not disappear when the container is deleted.
To do this, replace the `-v` option, where you need to specify the path to the data on the host first, and then using `:` specify the path inside the container. `-v /nas/mirea/backend:/myfolder`.
At the same time, do not forget to replace inside [.env](.env) `PATH_TO_SAVE` with what you specify in the `-v` option. In our case, it will be `PATH_TO_SAVE=/myfolder`.
That's it, the container is running!
## Docker Self Build
- Docker
To build your own application image, run:
```bash
docker build -t my-name/mirea-backend:latest .
```
Where `-t` indicates the name and version of the image. You can specify their `your-name/image-name:version`.
Now the image is ready. To launch the container, refer to [Docker Installation](#docker-installation), do not forget to specify the name of the image that you have built.
## Manual Installation
**Requirements**
- ASP.NET Core runtime 8.0
To install using a pre-built application, follow these steps:
1. [Install ASP.NET](#install-aspnet)
2. [Download Package](#download-package)
3. [Run](#run)
### Install ASP.NET
Installation ASP.NET it depends on the specific platform.
Go to [Microsoft website](https://dotnet.microsoft.com/download/dotnet/8.0 ) and find your platform. Follow the installation instructions.
### Download Package
The latest versions of the packages can be found in [releases](https://git.winsomnia.net/Winsomnia/MireaBackend/releases ). If there is no build for your platform, go [to the Self Build section](#self-build).
### Run
Go to the directory with the application.
Don't forget to set up [required configurations](#environment-variables) for the application to work.
Run the program.
`Debian/Ubuntu`
```bash
dotnet Mirea.Api.Endpoint.dll
```
## Self Build
Requirements
- ASP.NET Core runtime 8.0
- .NET 8.0 sdk (for build)
- git
To build your own version of the program, follow these steps:
1. [Install .NET SDK](#install-net-sdk)
2. [Clone The Repository](#clone-the-repository)
3. [Build Self Release](#build-self-release)
### Install NET SDK
Installation.The NET SDK depends on the specific platform.
Go to [Microsoft website](https://dotnet.microsoft.com/download/dotnet/8.0 ) and find your platform. Follow the installation instructions.
### Clone The Repository
Install git in advance or clone the repository in another way.
> ⚠ It is advisable to clone the `master` branch, the rest of the branches may work unstable.
```bash
git clone https://git.winsomnia.net/Winsomnia/MireaBackend.git \
cd MireaBackend
```
### Build Self Release
Go to the project folder. Restore the dependencies using the command:
```bash
dotnet restore
```
Let's move on to the assembly.
Variables:
- `<platform>` — Platform for which the build will be performed.
- `<arch>` — System architecture. Example: x86 x64.
- `<output directory>` — The directory where the assembly will be saved.
```bash
dotnet publish "./Endpoint/Endpoint.csproj" -c Release -r <platform>-<arch> -framework net8.0 -o <output directory> /p:SelfContained=false /p:UseAppHost=false
```
The release is now in the directory you specified. To run it, look at the [startup instructions](#run).
# Contribution and Feedback
You can contribute to the project by creating pull requests. Any feedback is welcome to improve the project.
# License
This project is licensed under the [MIT License](LICENSE.txt).

View File

@ -0,0 +1,8 @@
namespace Mirea.Api.Security.Common;
public class CookieNames
{
public const string AccessToken = "access_token";
public const string RefreshToken = "refresh_token";
public const string FingerprintToken = "fingerprint";
}

View File

@ -0,0 +1,27 @@
using System;
namespace Mirea.Api.Security.Common.Domain;
internal class AuthToken
{
public AuthToken(RequestContextInfo context)
{
UserAgent = context.UserAgent;
Ip = context.Ip;
Fingerprint = context.Fingerprint;
RefreshToken = context.RefreshToken;
}
public AuthToken()
{
}
public string UserAgent { get; set; } = null!;
public string Ip { get; set; } = null!;
public string Fingerprint { get; set; } = null!;
public string RefreshToken { get; set; } = null!;
public required string UserId { get; set; }
public required string AccessToken { get; set; }
public DateTime CreatedAt { get; set; }
}

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