209 Commits

Author SHA1 Message Date
GitBluub
e4a8eba699 feat: gen cover in populate wip 2023-11-15 13:08:16 +01:00
bfb6cf5958 Disable jwt auth for images routes 2023-10-12 13:34:56 +02:00
a92ca75760 Fix dev nginx 2023-10-12 12:47:48 +02:00
8d8323e382 Cleanup inports 2023-10-12 12:47:48 +02:00
76d7e69d19 Add includable fields for all ressources 2023-10-12 12:47:48 +02:00
be58e932a9 Run prettier 2023-10-12 12:47:48 +02:00
38bbe56e9b Add robot tests 2023-10-12 12:47:48 +02:00
a65ce6595a Add a generic include system and implement it for songs 2023-10-12 12:47:48 +02:00
Arthur Jamet
90f7890e5f Update README (#314)
* Update README

* README: Fixes cause me dumb
2023-10-09 16:46:35 +02:00
Arthur Jamet
911e174aef Front: Update splashscreen (#312) 2023-10-08 06:56:16 +02:00
Arthur Jamet
6d7f46c425 Merge pull request #308 from Chroma-Case/front/fix-oops 2023-10-05 12:09:56 +02:00
Arthur Jamet
b72e7a54e5 Front: Fix Oops page 2023-10-05 10:25:51 +02:00
Arthur Jamet
d99d134382 Front: Fix Web Build + Improve CI (#302)
Co-authored-by: Clément Le Bihan <clement.lebihan773@gmail.com>
2023-10-03 14:56:09 +02:00
Arthur Jamet
576675411a Merge pull request #292 from Chroma-Case/front/fix-expo 2023-10-02 18:01:43 +02:00
Arthur Jamet
d214558bc4 Front: Remove unused import 2023-10-02 17:06:02 +02:00
Arthur Jamet
4299a93afe Front: Fix env var 2023-10-02 16:56:46 +02:00
Arthur Jamet
920126a392 Front: Set Env Vars 2023-10-02 14:09:17 +02:00
Arthur Jamet
16e6a5e21b Front: Fix Icon dimensions 2023-10-01 11:40:55 +02:00
Arthur Jamet
9539018b64 .env.example: add new env var 2023-10-01 11:22:06 +02:00
Arthur Jamet
0081eb2acd Front: EAS: Fix project slug 2023-10-01 11:21:20 +02:00
Arthur Jamet
bcb0825f5a Front: remove duplicate deps 2023-09-30 14:28:29 +02:00
Arthur Jamet
18a3fa518c Front: Add missing dependency 2023-09-30 14:07:14 +02:00
Arthur Jamet
0407f5c29e Front: Add missing dependency 2023-09-30 12:07:53 +02:00
Arthur Jamet
6dafe2a8e9 Front: try a custom fork 2023-09-30 11:52:10 +02:00
Arthur Jamet
4a8f0aa1af Front: Add missing eslint deps 2023-09-30 11:17:43 +02:00
Arthur Jamet
745b20358d Front: Add eslint in dev deps 2023-09-30 11:14:04 +02:00
Arthur Jamet
0f544b31f3 Front: Add prettier in dev deps 2023-09-30 11:08:56 +02:00
Arthur Jamet
76d70f3edd Front: Typecheck 2023-09-30 11:05:08 +02:00
Arthur Jamet
1c17ac8b13 Front: Fix missing dependencies 2023-09-30 10:45:23 +02:00
Arthur Jamet
232579e75b Front: Add Dependencies 2023-09-30 10:23:02 +02:00
Arthur Jamet
01221eda00 Front: Add dependencies 2023-09-29 18:34:46 +02:00
Arthur Jamet
b73c2fef58 Front: Add dependencies 2023-09-29 18:17:55 +02:00
Arthur Jamet
3c9c1b5ff7 Front: Add dependencies 2023-09-29 18:00:11 +02:00
Arthur Jamet
e50b1c1344 Front: Install Dev client 2023-09-29 16:03:50 +02:00
Arthur Jamet
6dfc531891 Front: Install Jest 2023-09-29 15:53:20 +02:00
Arthur Jamet
b4f268dee0 Front: Redump Expo 2023-09-29 15:47:16 +02:00
Arthur Jamet
e366fa4b32 Merge pull request #282 from Chroma-Case/feature/adc/retour-utilisateur
Feature/adc/retour utilisateur
2023-09-26 07:31:19 +02:00
Arthur Jamet
cd87451208 Front: Typechecking 2023-09-25 17:55:46 +02:00
danis
845c473ed5 removed useless function 2023-09-25 17:17:05 +02:00
danis
f4d75eef73 css whatever + pretty 2023-09-25 14:51:20 +02:00
danis
5395bbb03a Duration component 2023-09-25 14:24:08 +02:00
danis
2d90c6eec1 pretty 2023-09-22 15:50:26 +02:00
danis
0b0fd0585d added DurationInfo 2023-09-22 15:49:12 +02:00
danis
6cf72dfcca Merge branch 'main' into feature/adc/retour-utilisateur 2023-09-22 15:11:33 +02:00
danis
a81c0b83bb song length SongRow 2023-09-22 15:08:28 +02:00
danis
b2fb497ecf populate.py updated with midi length 2023-09-22 14:53:36 +02:00
445816dfad Fix log error for images 2023-09-21 17:03:18 +02:00
GitBluub
4a4f9e2a55 fix: email template 2023-09-21 15:23:07 +02:00
3860c9f72a Fix prettier 2023-09-21 15:11:21 +02:00
b02b23a978 Fix signup mismatch 2023-09-21 15:06:38 +02:00
5b0c1f8992 Fix verify mail 2023-09-21 14:38:51 +02:00
Bluub
8155549031 feat: back password reset email (#277) 2023-09-21 14:29:09 +02:00
Amaury
1ca4633360 Merge pull request #271 from Chroma-Case/feature/adc/#242-liked-songs
Feature/adc/#242 liked songs
2023-09-21 12:05:16 +02:00
danis
bb304fa8cd merge main into liked songs 2023-09-21 12:04:30 +02:00
Bluub
9a1f1f78cb Merge pull request #275 from Chroma-Case/logs 2023-09-21 00:49:24 +02:00
GitBluub
96bb830600 fix: try to fix scoro tests 2023-09-21 00:43:45 +02:00
Bluub
1333b74001 Merge branch 'main' into logs 2023-09-21 00:35:37 +02:00
GitBluub
ece87dbdb9 fix: try to fix scoro tests 2023-09-21 00:26:23 +02:00
GitBluub
e82a6b1dd6 fix: try to fix scoro tests 2023-09-21 00:17:02 +02:00
GitBluub
cd2e119dc6 fix: separate file for logging containers 2023-09-21 00:09:41 +02:00
Clément Le Bihan
c9928f1cce Merge pull request #276 from Chroma-Case/redesign-settings 2023-09-21 00:01:13 +02:00
Clément Le Bihan
7aac3922d6 Merge remote-tracking branch 'origin' into redesign-settings 2023-09-20 23:59:51 +02:00
GitBluub
82403c811e fix: format 2023-09-20 23:54:04 +02:00
GitBluub
230c60bcd0 fix: ok instead of created 201 -> 200 2023-09-20 23:46:38 +02:00
GitBluub
177e903b07 fix: ok instead of created 201 -> 200 2023-09-20 23:46:38 +02:00
GitBluub
a11c236753 fix: ok instead of created 201 -> 200 2023-09-20 23:46:38 +02:00
GitBluub
29ef585410 doc: genre, lesson and history controller 2023-09-20 23:46:38 +02:00
GitBluub
f8be2c2462 doc: artist and album controller 2023-09-20 23:46:38 +02:00
GitBluub
7d27af1e2d doc: search controller 2023-09-20 23:46:38 +02:00
GitBluub
258fe91ae7 doc: song controller 2023-09-20 23:46:38 +02:00
GitBluub
711b5d583b doc: users controller 2023-09-20 23:46:38 +02:00
GitBluub
4416808056 doc: auth controller 2023-09-20 23:46:38 +02:00
GitBluub
979c27c087 feat: doc for app controller 2023-09-20 23:46:38 +02:00
GitBluub
b3117886cf fix: class gen folder in gitignore 2023-09-20 23:46:38 +02:00
GitBluub
1c248fa479 fix: model for the plagination in swagger 2023-09-20 23:46:38 +02:00
GitBluub
ec62f4b085 feat: prisma class generator and models in the swagger 2023-09-20 23:46:38 +02:00
GitBluub
04bad30aaa feat: install prisma class generator 2023-09-20 23:46:38 +02:00
mathysPaul
e5a52d0f94 fix checkbox Profile off-screen 2023-09-20 18:34:20 +02:00
mathysPaul
68c6c6fa11 fixing error from CI 2023-09-20 17:59:36 +02:00
mathysPaul
94a64d16e6 Redesign profil with datafake for skills 2023-09-20 17:40:52 +02:00
7aa7f50ecb Fix prod nginx 2023-09-20 14:58:09 +02:00
ee8e0e26db Fix eslint and bad reverify mail issue 2023-09-20 14:58:09 +02:00
31b965e8f6 Add volume/enable state and follow the music's bpm for the metronome 2023-09-20 14:58:09 +02:00
94658d4379 Add static assets to nginx 2023-09-20 14:58:09 +02:00
Clément Le Bihan
49a735631a prettied 2023-09-20 13:39:26 +02:00
Clément Le Bihan
1905daec60 MainHomeCard is now displaying the first 4 songs 2023-09-20 13:39:26 +02:00
Clément Le Bihan
7a1f4fb787 Fix to really allow guest accounts empty strings are transformed to null values 2023-09-20 13:39:26 +02:00
Clément Le Bihan
f3cdba34fb Now usign real play history for the TabNavigator Desktop 2023-09-20 13:39:26 +02:00
Clément Le Bihan
5b7cb6746d Added specific fontSizes for each card 2023-09-20 13:39:26 +02:00
Clément Le Bihan
6e3e73982f Added callback for onPress for the SongCardInfos and replaced the button to have the play icon more centered but some state issue 2023-09-20 13:39:26 +02:00
Clément Le Bihan
8e5c65e6f2 Added SongCardInfo for the V2 design and type fixes 2023-09-20 13:39:26 +02:00
Clément Le Bihan
94875d4c7f trying golden ratio 2023-09-20 13:39:26 +02:00
Clément Le Bihan
e817021ede fix type errors 2023-09-20 13:39:26 +02:00
Clément Le Bihan
dcca1b1f1c Added phone and responsive support on the tabnavigation added callapsables fixed colorscheme and setting background color 2023-09-20 13:39:26 +02:00
Clément Le Bihan
c0c2918e72 Started navigation 2023-09-20 13:39:26 +02:00
mathysPaul
973f9bf5b3 redesign AuthenticationView 2023-09-20 10:27:24 +02:00
GitBluub
162fc9148f grafana: auto setup of dashboard 2023-09-20 00:26:15 +02:00
GitBluub
57d646f6eb scoro: direct log to loki not working 2023-09-20 00:25:26 +02:00
mathysPaul
6768b0b2a6 merge main 2023-09-19 19:22:25 +02:00
mathysPaul
fa14d1f979 Fixing error prettier redesign CI 2023-09-19 18:43:38 +02:00
mathysPaul
c4ca2e509e Fixing redesign-settings prettier & lint => CI 2023-09-19 18:23:31 +02:00
mathysPaul
1abfbf391f Fixing error prettier redesign CI 2023-09-19 17:36:19 +02:00
mathysPaul
073ff033f3 Fixing error redesign CI 2023-09-19 17:12:49 +02:00
GitBluub
23e5941700 scoro: log directly to loki 2023-09-19 17:11:42 +02:00
Clément Le Bihan
027d450579 Forgot a merge conflict 2023-09-19 15:19:42 +02:00
Clément Le Bihan
ad9bbbc2b9 Cleanup random 2023-09-19 15:19:42 +02:00
Clément Le Bihan
58af78b1d3 prettied phaserCanvas.ts 2023-09-19 15:19:42 +02:00
Clément Le Bihan
09d2da8eec Fixed scaling issue with the cursor position texture size is still a concern 2023-09-19 15:19:42 +02:00
Clément Le Bihan
8abaaf6624 style the scaling not working to fix 2023-09-19 15:19:42 +02:00
Clément Le Bihan
3c3697be61 fix test back for duplicated user 2023-09-19 15:19:42 +02:00
Clément Le Bihan
073c00a35e Fixed a bug when current streak is 0 and Linter fix 2023-09-19 15:19:42 +02:00
Clément Le Bihan
58d761c359 prettier cleanup 2023-09-19 15:19:42 +02:00
Clément Le Bihan
aaaf73f632 PR cleanup 2023-09-19 15:19:42 +02:00
Clément Le Bihan
f83043a9c9 Handling in satisfactory manner scoro messages 2023-09-19 15:19:42 +02:00
Clément Le Bihan
cea6d8d0bc Added the message pinao system reusing a react context for simplicity and emitting note timing messages when scoro gives the result 2023-09-19 15:19:42 +02:00
Clément Le Bihan
607c35b621 Added first effect of particules 2023-09-19 15:19:42 +02:00
Clément Le Bihan
13d0be4586 Small QoL fixes thare were really needed 2023-09-19 15:19:42 +02:00
danis
3e1e41f117 pretty 2023-09-19 09:39:54 +02:00
danis
8f9d7e4a85 typo 2023-09-19 09:37:00 +02:00
mathysPaul
1e504c8982 Redesign settings 2023-09-19 03:54:12 +02:00
danis
e56436db3a merging main into feature/adc/#242-liked-songs 2023-09-18 16:49:47 +02:00
danis
bc227fb0ea pretty + better handling + handling in artist detail view 2023-09-18 16:45:03 +02:00
Clément Le Bihan
49bc4f9f45 Update front/views/StartPageView.tsx 2023-09-18 15:37:58 +02:00
Arthur Jamet
73076c4b28 Front: Recover package.json 2023-09-18 15:37:58 +02:00
Arthur Jamet
8732972b3f Front: Recover yarn.lock 2023-09-18 15:37:58 +02:00
Arthur Jamet
cd9d64e501 Front: Prettier 2023-09-18 15:37:58 +02:00
Arthur Jamet
62bf7ec035 Front: Apply New Color, Button and Link Style 2023-09-18 15:37:58 +02:00
Arthur Jamet
659f5d5d84 Front: Setup New Font 2023-09-18 15:37:58 +02:00
Arthur Jamet
bbc53f04de Front: Get Rid of external image, load local assets 2023-09-18 15:37:58 +02:00
danis
431427d7ad fixed mirgation + back-end + front end filter, heart shaped button and special FavSongRow 2023-09-17 20:57:10 +02:00
GitBluub
611ab57c5d scoro: game uuid for logging and bug fixing 2023-09-16 16:55:55 +02:00
bc13c10f1a Fix ci 2023-09-15 17:57:03 +02:00
91c9e2b295 Update .env.example to use dummy values for the ci 2023-09-15 17:57:03 +02:00
585be2aa19 Fix prettier warnings 2023-09-15 17:57:03 +02:00
654022b48a Update .env.example 2023-09-15 17:57:03 +02:00
afab03baf8 Add a button to resend verified mail 2023-09-15 17:57:03 +02:00
a52c10fc2c Add verified badge and page on the front 2023-09-15 17:57:03 +02:00
f2ed598865 Use a fixed python version for the scorometer 2023-09-15 17:57:03 +02:00
02fc8175f4 Send mails on account creation 2023-09-15 17:57:03 +02:00
Arthur Jamet
628e50a48d Merge pull request #257 from Chroma-Case/feature/adc/#224-genre-view
Feature/adc/#224 genre view
2023-09-14 15:33:40 +02:00
Arthur Jamet
70ab56ce3a Front: Remove unused value 2023-09-14 11:41:38 +02:00
Arthur Jamet
1fefe7912d Front: Run Pretty 2023-09-14 11:37:50 +02:00
danis
c21f5f0659 Merge branch 'feature/adc/#224-genre-view' into feature/adc/#242-liked-songs 2023-09-13 13:23:16 +02:00
danis
46ef0a7f1b remove expo-linear-gradient 2023-09-12 22:05:31 +02:00
danis
b43c64962a favorites search view filter + song query from favorites data 2023-09-10 14:48:39 +02:00
danis
64640eda55 lints fix 2023-09-09 19:18:30 +02:00
danis
a6d9cb3b40 run prettier 2023-09-09 18:55:32 +02:00
danis
b61541f7b8 fix PR III 2023-09-09 17:52:22 +02:00
danis
3ff523560b fix PR II 2023-09-09 17:51:18 +02:00
danis
b61968706d fix PR I 2023-09-09 14:25:43 +02:00
Arthur Jamet
2f27278d3a Front: Pretty 2023-09-08 17:53:23 +02:00
Arthur Jamet
e1ab9fe118 Front: Fix an error that occured on prod, caused by the avatar's url 2023-09-08 17:53:23 +02:00
Arthur Jamet
b1d0415ba0 Front: Fix genre view 2023-09-07 17:10:18 +02:00
Arthur Jamet
8ab85ab689 Front: remove file64 dependency 2023-09-07 17:06:27 +02:00
danis
16cd794e3b trial for artist name 2023-09-07 10:31:03 +02:00
danis
f85c30a53b clean code VI 2023-09-06 17:07:16 +02:00
danis
6da96ed886 clean code V 2023-09-06 17:00:36 +02:00
danis
852fbd5c87 clean code IV 2023-09-06 16:39:38 +02:00
danis
5cec62d1b1 search view update 2023-09-06 16:38:44 +02:00
danis
7e866f9826 clean code III 2023-09-06 15:59:50 +02:00
danis
2f50f694f3 clean code 2023-09-06 15:57:38 +02:00
Clément Le Bihan
e0f2674811 fix pr 2023-09-06 15:09:54 +02:00
Clément Le Bihan
b84ee11f45 Fix de arthur 2023-09-06 15:09:54 +02:00
Clément Le Bihan
a2494ce498 prettied 2023-09-06 15:09:54 +02:00
Clément Le Bihan
b76d496034 fix ts issues 2 2023-09-06 15:09:54 +02:00
Clément Le Bihan
a81d3ee34d fixed ts type issue 2023-09-06 15:09:54 +02:00
Clément Le Bihan
85473ae492 Removed old commented react useState 2023-09-06 15:09:54 +02:00
Clément Le Bihan
9655e986ff Removed old code from HomeView and auto format some files 2023-09-06 15:09:54 +02:00
Clément Le Bihan
101ea8498b removing old code commented and unused dependancies 2023-09-06 15:09:54 +02:00
Clément Le Bihan
7d33f85cbc Cleanup parition view 2023-09-06 15:09:54 +02:00
Clément Le Bihan
66d792715e Removed Parition context declaration/init 2023-09-06 15:09:54 +02:00
Clément Le Bihan
40581f4a45 Removed the timestamp partition context to reuse normal props clean up console logs and now displaying a toast to tell is the scorometer crashed 2023-09-06 15:09:54 +02:00
Clément Le Bihan
2ca3fcb81a reactivating websocket connection but error view appear when it shouldn't 2023-09-06 15:09:54 +02:00
Clément Le Bihan
30fcacbec6 Now using redux to not create sound player every time the phaser is also implicitely cached 2023-09-06 15:09:54 +02:00
Clément Le Bihan
7c3289ccec now phasercanvas makes sounds used the same stack as previously and ram issue spotted 2023-09-06 15:09:54 +02:00
Clément Le Bihan
7438986bcd Cursor is controlled by partition timestamps provided by playview and can thus be paused and onEndReached is now called 2023-09-06 15:09:54 +02:00
Clément Le Bihan
3ac017a5f0 Cursor with cam follow is moving to correct notes, timing is fake 2023-09-06 15:09:54 +02:00
Clément Le Bihan
8e5cc1bc44 Added sliding to the partition but some issues 2023-09-06 15:09:54 +02:00
Clément Le Bihan
125a7faf02 early Experiment working 2023-09-06 15:09:54 +02:00
danis
c9d3ef88e7 clean code + search history handler fix 2023-09-05 13:44:30 +02:00
danis
0ba3bec5aa Merge branch 'main' into feature/adc/#224-genre-view 2023-09-05 09:41:20 +02:00
danis
539c35c903 song cards routing fix 2023-09-05 09:36:11 +02:00
danis
e1463d41b9 actual data from db tho needs better design care 2023-09-05 09:33:31 +02:00
danis
c81f8df61c prisma migration + back auth/me/likes + front API add and get methods for liked song 2023-08-30 13:06:25 +02:00
Arthur Jamet
a3676fabf8 Front: Update User Avatar (#250)
* Front: Update User Avatar

* Front: Fix expo-image-picker version
2023-08-07 10:28:55 +02:00
GitBluub
dc398d6e06 rm useless file 2023-07-26 22:22:03 +09:00
GitBluub
d5da112a01 scorometer create uuid 2023-07-26 22:21:36 +09:00
GitBluub
96048bd671 back logging every request 2023-07-26 22:21:21 +09:00
GitBluub
dcdc6b196d grafana setup and dashboard json 2023-07-26 22:21:00 +09:00
Arthur Jamet
9f542fc9dd Front: User Avatar 2023-07-26 21:00:41 +09:00
930191569f Fix upload file issue 2023-07-26 21:00:41 +09:00
74cd9c0df2 Remove a usless validator 2023-07-26 21:00:41 +09:00
d2642b4fb8 Fixing gravatar 2023-07-26 21:00:41 +09:00
ebcc48cc57 Upgrade back packages 2023-07-26 21:00:41 +09:00
95b08935cc Add file upload 2023-07-26 21:00:41 +09:00
04487c9b24 Add get profile route that supports gravatar 2023-07-26 21:00:41 +09:00
Arthur Jamet
20eb62d19b Front: Graphes de Score (#248) 2023-07-26 12:00:06 +01:00
Zoe Roux
567d3250e2 Merge pull request #234 from Chroma-Case/feat/google 2023-07-24 19:40:15 +09:00
4207d5ee50 Try to fix the CI 2023-07-24 19:33:25 +09:00
GitBluub
509cc5b9f8 rename musics 2023-07-24 14:51:19 +09:00
GitBluub
1b22dba9cd rename musics 2023-07-24 14:44:55 +09:00
GitBluub
2ec95dd3c3 wip 2023-07-23 18:14:11 +09:00
Arthur Jamet
c0d9ee7ca6 Front: Merge 2023-07-16 18:11:34 +01:00
Arthur Jamet
27f7945289 Front: Use React-Native feature to handle Google Redirections 2023-06-29 15:02:06 +01:00
GitBluub
5a190f3b96 wip 2023-06-28 22:03:59 +09:00
Arthur Jamet
3d76834f45 Front: Add Missing Translation + Prettier 2023-06-26 15:00:35 +01:00
ccc86895e2 Add an indicator of the google account on the front 2023-06-26 22:41:07 +09:00
279d16d59a Add google things on the front 2023-06-26 22:38:59 +09:00
04d288b844 Add google signin/signup 2023-06-26 22:38:59 +09:00
251 changed files with 27482 additions and 19672 deletions

View File

@@ -8,6 +8,6 @@ insert_final_newline = true
indent_style = tab
indent_size = tab
[{*.yaml,*.yml}]
[{*.yaml,*.yml,*.nix}]
indent_style = space
indent_size = 2

View File

@@ -7,4 +7,12 @@ JWT_SECRET=wow
POSTGRES_DB=chromacase
API_URL=http://localhost:80/api
SCORO_URL=ws://localhost:6543
MINIO_ROOT_PASSWORD=12345678
EXPO_PUBLIC_API_URL=http://localhost:80/api
EXPO_PUBLIC_SCORO_URL=ws://localhost:6543
GOOGLE_CLIENT_ID=toto
GOOGLE_SECRET=tata
GOOGLE_CALLBACK_URL=http://localhost:19006/logged/google
SMTP_TRANSPORT=smtps://toto:tata@relay
MAIL_AUTHOR='"Chromacase" <chromacase@octohub.app>'
IGNORE_MAILS=true

5
.envrc
View File

@@ -1,4 +1 @@
if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs="
fi
use flake
use nix

View File

@@ -27,6 +27,25 @@ jobs:
## Build App ##
Check_Front:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./front
environment: Staging
steps:
- uses: actions/checkout@v3
- name: Install Yarn
run: npm install -g yarn
- name: Install dependencies
run: yarn install
- name: Type Check
run: yarn tsc
- name: Check Prettier
run: yarn pretty:check .
- name: Run Linter
run: yarn lint
Build_Front:
runs-on: ubuntu-latest
defaults:
@@ -42,13 +61,6 @@ jobs:
- name: Install dependencies
run: yarn install
- name: Type Check
run: yarn tsc
- name: Check Prettier
run: yarn pretty:check .
- name: Run Linter
run: yarn lint
- name: 🏗 Setup Expo
uses: expo/expo-github-action@v7
@@ -56,6 +68,15 @@ jobs:
expo-version: latest
eas-version: 3.3.1
token: ${{ secrets.EXPO_TOKEN }}
- name: Build Web App
uses: docker/build-push-action@v3
with:
context: ./front
push: false
tags: ${{steps.meta_front.outputs.tags}}
build-args: |
API_URL=${{secrets.API_URL}}
SCORO_URL=${{secrets.SCORO_URL}}
- name: Build Android APK
run: |
@@ -84,16 +105,7 @@ jobs:
fetch-depth: 0
- name: Copy env file to github secret env file
run: |
touch .env
echo "POSTGRES_USER=user" >> .env
echo "POSTGRES_PASSWORD=eip" >> .env
echo "POSTGRES_NAME=chromacase" >> .env
echo "POSTGRES_HOST=db" >> .env
echo "DATABASE_URL=postgresql://user:eip@db:5432/chromacase" >> .env
echo "JWT_SECRET=wow" >> .env
echo "POSTGRES_DB=chromacase" >> .env
echo "API_URL=http://localhost:80/api" >> .env
run: cp .env.example .env
- name: Start the service
run: docker-compose up -d back db
@@ -101,7 +113,8 @@ jobs:
- name: Perform healthchecks
run: |
docker-compose ps -a
wget --retry-connrefused http://localhost:3000 # /healthcheck
docker-compose logs
wget --retry-connrefused http://localhost:3000 || (docker-compose logs && exit 1)
- name: Run scorometer tests
run: |

3
.gitignore vendored
View File

@@ -13,3 +13,6 @@ log.html
node_modules/
./front/coverage
.venv
.data
.DS_Store
_gen

View File

@@ -1,9 +1,39 @@
# ![Chromacase](./assets/graphical/title.png)
# ![Chromacase](./assets/graphical/banner.png)
La principale raison pour laquelle on arrête de jouer d'un instrument est la perte de motivation. C'est un apprentissage long et vraiment demandant. ChromaCase propose d'accompagner les joueurs de piano grâce à une application mobile avec une expérience personnalisée. Celle-ci, générée par une IA, cible les goûts et identifie les difficultés du joueur.
Ça vous interesse? Rendez-vous sur notre [site](https://chromacase.studio/) pour prendre contact
Ça vous interesse? Rendez-vous sur notre [site](http://eip.epitech.eu/2024/chromacase) pour prendre contact
## Structure du Projet
## Comment lancer le projet
![Schéma Fonctionnel](./assets/docs/structure.png)
Pensez à remplir un `.env` (à la racine du projet), en se basant sur le `.env.example`.
### Development
```bash
docker-compose -f docker-compose.dev.yml up --build
```
### Production
```bash
docker-compose up --build
```
## Liens Utiles
- Site de Production: [Lien](http://chroma.octohub.app/)
- Site du Nightly: [Lien](http://nightly.chroma.octohub.app/)
- Site vitrine: [Lien](http://eip.epitech.eu/2024/chromacase)
- Documentation: [Github](https://github.com/Chroma-Case/DAteX)
## Membres du Projet
| Nom | Role | Contact |
|--------------------------|--------------------------------------|----------------------------------------------------|
| Zoé Roux | CEO, Responsable Back-end | [GitHub](https://github.com/zoriya) |
| Clément Le-Bihan | CTO, Responsable Front-end | [GitHub](https://github.com/Octopus773) |
| Arthur Jamet | Manager, Développeur Front-end | [GitHub](https://github.com/Arthi-chaud) |
| Louis Auzuret | Développeur Back-end, Responsable CI | [Github](https://github.com/GitBluub) |
| Aumaury Danis-Cousandier | Développeur Front-end | [Github](https://github.com/AmauryDanisCousandier) |
| Mathys Paul | Développeur Front-end, Designer | [GitHub](https://github.com/mathysPaul) |

BIN
assets/graphical/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

View File

Before

Width:  |  Height:  |  Size: 376 KiB

After

Width:  |  Height:  |  Size: 376 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1,9 +1,10 @@
#!/bin/env python3
#!/usr/bin/env python3
import sys
import os
import requests
import glob
from mido import MidiFile
from configparser import ConfigParser
url = os.environ.get("API_URL")
@@ -20,16 +21,16 @@ def getOrCreateAlbum(name, artistId):
return out["id"]
def getOrCreateGenre(names):
ids = []
for name in names.split(","):
res = requests.post(f"{url}/genre", json={
"name": name,
})
out = res.json()
print(out)
ids += [out["id"]]
#TODO handle multiple genres
return ids[0]
ids = []
for name in names.split(","):
res = requests.post(f"{url}/genre", json={
"name": name,
})
out = res.json()
print(out)
ids += [out["id"]]
#TODO handle multiple genres
return ids[0]
def getOrCreateArtist(name):
res = requests.post(f"{url}/artist", json={
@@ -39,11 +40,19 @@ def getOrCreateArtist(name):
print(out)
return out["id"]
def gen_cover():
def populateFile(path, midi, mxl):
config = ConfigParser()
config.read(path)
mid = MidiFile(midi)
common = os.path.commonpath([midi, mxl])
png_path = f"{common}/illustration.png"
if not os.path.exists(png_path):
gen_cover(common)
metadata = config["Metadata"];
difficulties = dict(config["Difficulties"])
difficulties["length"] = round((mid.length), 2)
artistId = getOrCreateArtist(metadata["Artist"])
print(f"Populating {metadata['Name']}")
res = requests.post(f"{url}/song", json={
@@ -54,11 +63,10 @@ def populateFile(path, midi, mxl):
"artist": artistId,
"album": getOrCreateAlbum(metadata["Album"], artistId),
"genre": getOrCreateGenre(metadata["Genre"]),
"illustrationPath": f"/assets/{os.path.commonpath([midi, mxl])}/illustration.png"
"illustrationPath": f"/assets/{png_path}"
})
print(res.json())
def main():
global url
if url == None:

View File

@@ -5,4 +5,4 @@ RUN npm install --frozen-lockfile
COPY . .
RUN npx prisma generate
RUN npm run build
CMD npx prisma migrate dev; npm run start:prod
CMD npx prisma migrate deploy; npm run start:prod

19482
back/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,8 @@
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:dev": "nest start --watch --preserveWatchOutput",
"start:debug": "nest start --debug --watch --preserveWatchOutput",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
@@ -21,51 +21,61 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^8.0.0",
"@nestjs/config": "^2.1.0",
"@nestjs/core": "^8.0.0",
"@nestjs/jwt": "^8.0.1",
"@nestjs-modules/mailer": "^1.9.1",
"@nestjs/common": "^10.1.0",
"@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.1.0",
"@nestjs/jwt": "^10.1.0",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^8.2.2",
"@nestjs/platform-express": "^8.0.0",
"@nestjs/swagger": "^5.2.1",
"@prisma/client": "^4.4.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^10.1.0",
"@nestjs/swagger": "^7.1.2",
"@prisma/client": "^5.0.0",
"@types/bcrypt": "^5.0.0",
"@types/bcryptjs": "^2.4.2",
"@types/passport": "^1.0.9",
"@types/passport": "^1.0.12",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"passport-jwt": "^4.0.0",
"json-logger-service": "^9.0.1",
"class-validator": "^0.14.0",
"node-fetch": "^2.6.12",
"nodemailer": "^6.9.5",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"prisma-class-generator": "^0.2.7",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"swagger-ui-express": "^4.5.0"
"rimraf": "^5.0.1",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0"
},
"devDependencies": {
"@nestjs/cli": "^8.0.0",
"@nestjs/schematics": "^8.0.0",
"@nestjs/testing": "^8.0.0",
"@types/express": "^4.17.13",
"@types/jest": "27.4.1",
"@types/node": "^16.0.0",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "^27.2.5",
"prettier": "^2.3.2",
"prisma": "^4.4.0",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "^27.0.3",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "^3.10.1",
"typescript": "^4.3.5"
"@nestjs/cli": "^10.1.10",
"@nestjs/schematics": "^10.0.1",
"@nestjs/testing": "^10.1.0",
"@types/express": "^4.17.17",
"@types/jest": "29.5.3",
"@types/multer": "^1.4.7",
"@types/node": "^20.4.4",
"@types/nodemailer": "^6.4.9",
"@types/passport-google-oauth20": "^2.0.11",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"eslint": "^8.45.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.6.1",
"prettier": "^3.0.0",
"prisma": "^5.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.4.4",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.6"
},
"jest": {
"moduleFileExtensions": [

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[googleID]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "googleID" TEXT,
ALTER COLUMN "password" DROP NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "User_googleID_key" ON "User"("googleID");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "LikedSongs" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"songId" INTEGER NOT NULL,
"addedDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LikedSongs_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "LikedSongs" ADD CONSTRAINT "LikedSongs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LikedSongs" ADD CONSTRAINT "LikedSongs_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL;

View File

@@ -4,6 +4,12 @@ generator client {
provider = "prisma-client-js"
}
generator prismaClassGenerator {
provider = "prisma-class-generator"
dryRun = false
separateRelationFields = true
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
@@ -12,14 +18,26 @@ datasource db {
model User {
id Int @id @default(autoincrement())
username String @unique
password String
email String
password String?
email String? @unique
emailVerified Boolean @default(false)
googleID String? @unique
isGuest Boolean @default(false)
partyPlayed Int @default(0)
LessonHistory LessonHistory[]
SongHistory SongHistory[]
searchHistory SearchHistory[]
settings UserSettings?
likedSongs LikedSongs[]
}
model LikedSongs {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
songId Int
addedDate DateTime @default(now())
}
model UserSettings {
@@ -59,6 +77,7 @@ model Song {
genre Genre? @relation(fields: [genreId], references: [id])
difficulties Json
SongHistory SongHistory[]
likedByUsers LikedSongs[]
}
model SongHistory {

View File

@@ -1,5 +1,4 @@
import {
BadRequestException,
Body,
ConflictException,
Controller,
@@ -12,23 +11,35 @@ import {
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateAlbumDto } from './dto/create-album.dto';
import { AlbumService } from './album.service';
import { Request } from 'express';
import { Prisma, Album } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Album as _Album } from 'src/_gen/prisma-class/album';
import { IncludeMap, mapInclude } from 'src/utils/include';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
@Controller('album')
@ApiTags('album')
@UseGuards(JwtAuthGuard)
export class AlbumController {
static filterableFields: string[] = ['+id', 'name', '+artistId'];
static includableFields: IncludeMap<Prisma.AlbumInclude> = {
artist: true,
Song: true,
};
constructor(private readonly albumService: AlbumService) {}
@Post()
@ApiOperation({
description: 'Register a new album, should not be used by frontend',
})
async create(@Body() createAlbumDto: CreateAlbumDto) {
try {
return await this.albumService.createAlbum({
@@ -45,6 +56,7 @@ export class AlbumController {
}
@Delete(':id')
@ApiOperation({ description: 'Delete an album by id' })
async remove(@Param('id', ParseIntPipe) id: number) {
try {
return await this.albumService.deleteAlbum({ id });
@@ -54,10 +66,13 @@ export class AlbumController {
}
@Get()
@ApiOkResponsePlaginated(_Album)
@ApiOperation({ description: 'Get all albums paginated' })
async findAll(
@Req() req: Request,
@FilterQuery(AlbumController.filterableFields)
where: Prisma.AlbumWhereInput,
@Query('include') include: string,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Album>> {
@@ -65,13 +80,23 @@ export class AlbumController {
skip,
take,
where,
include: mapInclude(include, req, AlbumController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.albumService.album({ id });
@ApiOperation({ description: 'Get an album by id' })
@ApiOkResponse({ type: _Album })
async findOne(
@Req() req: Request,
@Query('include') include: string,
@Param('id', ParseIntPipe) id: number,
) {
const res = await this.albumService.album(
{ id },
mapInclude(include, req, AlbumController.includableFields),
);
if (res === null) throw new NotFoundException('Album not found');
return res;

View File

@@ -14,9 +14,11 @@ export class AlbumService {
async album(
albumWhereUniqueInput: Prisma.AlbumWhereUniqueInput,
include?: Prisma.AlbumInclude,
): Promise<Album | null> {
return this.prisma.album.findUnique({
where: albumWhereUniqueInput,
include,
});
}
@@ -26,14 +28,16 @@ export class AlbumService {
cursor?: Prisma.AlbumWhereUniqueInput;
where?: Prisma.AlbumWhereInput;
orderBy?: Prisma.AlbumOrderByWithRelationInput;
include?: Prisma.AlbumInclude;
}): Promise<Album[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.album.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}

View File

@@ -1,11 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { ApiOkResponse } from '@nestjs/swagger';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@ApiOkResponse({
description: 'Return a hello world message, used as a health route',
})
getHello(): string {
return this.appService.getHello();
}

View File

@@ -14,6 +14,7 @@ import { ArtistModule } from './artist/artist.module';
import { AlbumModule } from './album/album.module';
import { SearchModule } from './search/search.module';
import { HistoryModule } from './history/history.module';
import { MailerModule } from '@nestjs-modules/mailer';
@Module({
imports: [
@@ -28,6 +29,12 @@ import { HistoryModule } from './history/history.module';
SearchModule,
SettingsModule,
HistoryModule,
MailerModule.forRoot({
transport: process.env.SMTP_TRANSPORT,
defaults: {
from: process.env.MAIL_AUTHOR,
},
}),
],
controllers: [AppController],
providers: [AppService, PrismaService, ArtistService],

View File

@@ -1,5 +1,4 @@
import {
BadRequestException,
Body,
ConflictException,
Controller,
@@ -14,24 +13,42 @@ import {
Query,
Req,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateArtistDto } from './dto/create-artist.dto';
import { Request } from 'express';
import { ArtistService } from './artist.service';
import { Prisma, Artist } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import {
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { createReadStream, existsSync } from 'fs';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Artist as _Artist } from 'src/_gen/prisma-class/artist';
import { IncludeMap, mapInclude } from 'src/utils/include';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { Public } from 'src/auth/public';
@Controller('artist')
@ApiTags('artist')
@UseGuards(JwtAuthGuard)
export class ArtistController {
static filterableFields = ['+id', 'name'];
static includableFields: IncludeMap<Prisma.ArtistInclude> = {
Song: true,
Album: true,
};
constructor(private readonly service: ArtistService) {}
@Post()
@ApiOperation({
description: 'Register a new artist, should not be used by frontend',
})
async create(@Body() dto: CreateArtistDto) {
try {
return await this.service.create(dto);
@@ -41,6 +58,7 @@ export class ArtistController {
}
@Delete(':id')
@ApiOperation({ description: 'Delete an artist by id' })
async remove(@Param('id', ParseIntPipe) id: number) {
try {
return await this.service.delete({ id });
@@ -50,6 +68,9 @@ export class ArtistController {
}
@Get(':id/illustration')
@ApiOperation({ description: "Get an artist's illustration" })
@ApiNotFoundResponse({ description: 'Artist or illustration not found' })
@Public()
async getIllustration(@Param('id', ParseIntPipe) id: number) {
const artist = await this.service.get({ id });
if (!artist) throw new NotFoundException('Artist not found');
@@ -66,10 +87,13 @@ export class ArtistController {
}
@Get()
@ApiOperation({ description: 'Get all artists paginated' })
@ApiOkResponsePlaginated(_Artist)
async findAll(
@Req() req: Request,
@FilterQuery(ArtistController.filterableFields)
where: Prisma.ArtistWhereInput,
@Query('include') include: string,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Artist>> {
@@ -77,13 +101,23 @@ export class ArtistController {
skip,
take,
where,
include: mapInclude(include, req, ArtistController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.service.get({ id });
@ApiOperation({ description: 'Get an artist by id' })
@ApiOkResponse({ type: _Artist })
async findOne(
@Req() req: Request,
@Query('include') include: string,
@Param('id', ParseIntPipe) id: number,
) {
const res = await this.service.get(
{ id },
mapInclude(include, req, ArtistController.includableFields),
);
if (res === null) throw new NotFoundException('Artist not found');
return res;

View File

@@ -12,9 +12,13 @@ export class ArtistService {
});
}
async get(where: Prisma.ArtistWhereUniqueInput): Promise<Artist | null> {
async get(
where: Prisma.ArtistWhereUniqueInput,
include?: Prisma.ArtistInclude,
): Promise<Artist | null> {
return this.prisma.artist.findUnique({
where,
include,
});
}
@@ -24,14 +28,16 @@ export class ArtistService {
cursor?: Prisma.ArtistWhereUniqueInput;
where?: Prisma.ArtistWhereInput;
orderBy?: Prisma.ArtistOrderByWithRelationInput;
include?: Prisma.ArtistInclude;
}): Promise<Artist[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.artist.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}

View File

@@ -7,11 +7,20 @@ import {
Body,
Delete,
BadRequestException,
ConflictException,
HttpCode,
Put,
InternalServerErrorException,
Patch,
NotFoundException,
Req,
UseInterceptors,
UploadedFile,
HttpStatus,
ParseFilePipeBuilder,
Response,
Query,
Param,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@@ -19,9 +28,12 @@ import { LocalAuthGuard } from './local-auth.guard';
import { RegisterDto } from './dto/register.dto';
import { UsersService } from 'src/users/users.service';
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
ApiConflictResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
@@ -32,6 +44,10 @@ import { Profile } from './dto/profile.dto';
import { Setting } from 'src/models/setting';
import { UpdateSettingDto } from 'src/settings/dto/update-setting.dto';
import { SettingsService } from 'src/settings/settings.service';
import { AuthGuard } from '@nestjs/passport';
import { FileInterceptor } from '@nestjs/platform-express';
import { writeFile } from 'fs';
import { PasswordResetDto } from './dto/password_reset.dto ';
@ApiTags('auth')
@Controller('auth')
@@ -42,38 +58,155 @@ export class AuthController {
private settingsService: SettingsService,
) {}
@Get('login/google')
@UseGuards(AuthGuard('google'))
@ApiOperation({ description: 'Redirect to google login page' })
googleLogin() {}
@Get('logged/google')
@ApiOperation({
description:
'Redirect to the front page after connecting to the google account',
})
@UseGuards(AuthGuard('google'))
async googleLoginCallbakc(@Req() req: any) {
let user = await this.usersService.user({ googleID: req.user.googleID });
if (!user) {
user = await this.usersService.createUser(req.user);
await this.settingsService.createUserSetting(user.id);
}
return this.authService.login(user);
}
@Post('register')
@ApiOperation({ description: 'Register a new user' })
@ApiConflictResponse({ description: 'Username or email already taken' })
@ApiOkResponse({
description: 'Successfully registered, email sent to verify',
})
@ApiBadRequestResponse({ description: 'Invalid data or database error' })
async register(@Body() registerDto: RegisterDto): Promise<void> {
try {
const user = await this.usersService.createUser(registerDto)
const user = await this.usersService.createUser(registerDto);
await this.settingsService.createUserSetting(user.id);
} catch(e) {
await this.authService.sendVerifyMail(user);
} catch (e) {
// check if the error is a duplicate key error
if (e.code === 'P2002') {
throw new ConflictException('Username or email already taken');
}
console.error(e);
throw new BadRequestException();
}
}
@Put('verify')
@HttpCode(200)
@UseGuards(JwtAuthGuard)
@ApiOperation({ description: 'Verify the email of the user' })
@ApiOkResponse({ description: 'Successfully verified' })
@ApiBadRequestResponse({ description: 'Invalid or expired token' })
async verify(
@Request() req: any,
@Query('token') token: string,
): Promise<void> {
if (await this.authService.verifyMail(req.user.id, token)) return;
throw new BadRequestException('Invalid token. Expired or invalid.');
}
@Put('reverify')
@UseGuards(JwtAuthGuard)
@HttpCode(200)
@ApiOperation({ description: 'Resend the verification email' })
async reverify(@Request() req: any): Promise<void> {
const user = await this.usersService.user({ id: req.user.id });
if (!user) throw new BadRequestException('Invalid user');
await this.authService.sendVerifyMail(user);
}
@HttpCode(200)
@Put('password-reset')
async password_reset(
@Body() resetDto: PasswordResetDto,
@Query('token') token: string,
): Promise<void> {
if (await this.authService.changePassword(resetDto.password, token)) return;
throw new BadRequestException('Invalid token. Expired or invalid.');
}
@HttpCode(200)
@Put('forgot-password')
async forgot_password(@Query('email') email: string): Promise<void> {
console.log(email);
const user = await this.usersService.user({ email: email });
if (!user) throw new BadRequestException('Invalid user');
await this.authService.sendPasswordResetMail(user);
}
@Post('login')
@ApiBody({ type: LoginDto })
@HttpCode(200)
@UseGuards(LocalAuthGuard)
@Post('login')
@ApiBody({ type: LoginDto })
@ApiOperation({ description: 'Login with username and password' })
@ApiOkResponse({ description: 'Successfully logged in', type: JwtToken })
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
async login(@Request() req: any): Promise<JwtToken> {
return this.authService.login(req.user);
}
@HttpCode(200)
@Post('guest')
@HttpCode(200)
@ApiOperation({ description: 'Login as a guest account' })
@ApiOkResponse({ description: 'Successfully logged in', type: JwtToken })
async guest(): Promise<JwtToken> {
const user = await this.usersService.createGuest();
await this.settingsService.createUserSetting(user.id);
return this.authService.login(user);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ description: 'Get the profile picture of connected user' })
@ApiOkResponse({ description: 'The user profile picture' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Get('me/picture')
async getProfilePicture(@Request() req: any, @Response() res: any) {
return await this.usersService.getProfilePicture(req.user.id, res);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'The user profile picture' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Post('me/picture')
@ApiOperation({ description: 'Upload a new profile picture' })
@UseInterceptors(FileInterceptor('file'))
async postProfilePicture(
@Request() req: any,
@UploadedFile(
new ParseFilePipeBuilder()
.addFileTypeValidator({
fileType: 'jpeg',
})
.build({
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
}),
)
file: Express.Multer.File,
) {
const path = `/data/${req.user.id}.jpg`;
writeFile(path, file.buffer, (err) => {
if (err) throw err;
});
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully logged in', type: User })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Get('me')
@ApiOperation({ description: 'Get the user info of connected user' })
async getProfile(@Request() req: any): Promise<User> {
const user = await this.usersService.user({ id: req.user.id });
if (!user) throw new InternalServerErrorException();
@@ -85,6 +218,7 @@ export class AuthController {
@ApiOkResponse({ description: 'Successfully edited profile', type: User })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Put('me')
@ApiOperation({ description: 'Edit the profile of connected user' })
editProfile(
@Request() req: any,
@Body() profile: Partial<Profile>,
@@ -110,32 +244,65 @@ export class AuthController {
@ApiOkResponse({ description: 'Successfully deleted', type: User })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Delete('me')
@ApiOperation({ description: 'Delete the profile of connected user' })
deleteSelf(@Request() req: any): Promise<User> {
return this.usersService.deleteUser({ id: req.user.id });
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({description: 'Successfully edited settings', type: Setting})
@ApiUnauthorizedResponse({description: 'Invalid token'})
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Patch('me/settings')
@ApiOperation({ description: 'Edit the settings of connected user' })
udpateSettings(
@Request() req: any,
@Body() settingUserDto: UpdateSettingDto): Promise<Setting> {
@Body() settingUserDto: UpdateSettingDto,
): Promise<Setting> {
return this.settingsService.updateUserSettings({
where: { userId: +req.user.id},
where: { userId: +req.user.id },
data: settingUserDto,
});
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({description: 'Successfully edited settings', type: Setting})
@ApiUnauthorizedResponse({description: 'Invalid token'})
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Get('me/settings')
@ApiOperation({ description: 'Get the settings of connected user' })
async getSettings(@Request() req: any): Promise<Setting> {
const result = await this.settingsService.getUserSetting({ userId: +req.user.id });
const result = await this.settingsService.getUserSetting({
userId: +req.user.id,
});
if (!result) throw new NotFoundException();
return result;
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully added liked song' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Post('me/likes/:id')
addLikedSong(@Request() req: any, @Param('id') songId: number) {
return this.usersService.addLikedSong(+req.user.id, +songId);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully removed liked song' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Delete('me/likes/:id')
removeLikedSong(@Request() req: any, @Param('id') songId: number) {
return this.usersService.removeLikedSong(+req.user.id, +songId);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully retrieved liked song' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Get('me/likes')
getLikedSongs(@Request() req: any) {
return this.usersService.getLikedSongs(+req.user.id);
}
}

View File

@@ -9,6 +9,7 @@ import { ConfigModule } from '@nestjs/config';
import { ConfigService } from '@nestjs/config';
import { JwtStrategy } from './jwt.strategy';
import { SettingsModule } from 'src/settings/settings.module';
import { GoogleStrategy } from './google.strategy';
@Module({
imports: [
@@ -20,12 +21,12 @@ import { SettingsModule } from 'src/settings/settings.module';
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: { expiresIn: '1h' },
signOptions: { expiresIn: '365d' },
}),
inject: [ConfigService],
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
providers: [AuthService, LocalStrategy, JwtStrategy, GoogleStrategy],
controllers: [AuthController],
})
export class AuthModule {}

View File

@@ -1,13 +1,16 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import PayloadInterface from './interface/payload.interface';
import { User } from 'src/models/user';
import { MailerService } from '@nestjs-modules/mailer';
@Injectable()
export class AuthService {
constructor(
private userService: UsersService,
private jwtService: JwtService,
private emailService: MailerService,
) {}
async validateUser(
@@ -15,7 +18,7 @@ export class AuthService {
password: string,
): Promise<PayloadInterface | null> {
const user = await this.userService.user({ username });
if (user && bcrypt.compareSync(password, user.password)) {
if (user && user.password && bcrypt.compareSync(password, user.password)) {
return {
username: user.username,
id: user.id,
@@ -31,4 +34,70 @@ export class AuthService {
access_token,
};
}
async sendVerifyMail(user: User) {
if (process.env.IGNORE_MAILS === 'true') return;
if (user.email == null) return;
console.log('Sending verification mail to', user.email);
const token = await this.jwtService.signAsync(
{
userId: user.id,
},
{ expiresIn: '10h' },
);
await this.emailService.sendMail({
to: user.email,
from: 'chromacase@octohub.app',
subject: 'Mail verification for Chromacase',
html: `To verify your mail, please click on this <a href="${process.env.PUBLIC_URL}/verify?token=${token}">link</a>.`,
});
}
async sendPasswordResetMail(user: User) {
if (process.env.IGNORE_MAILS === 'true') return;
if (user.email == null) return;
console.log('Sending password reset mail to', user.email);
const token = await this.jwtService.signAsync(
{
userId: user.id,
},
{ expiresIn: '10h' },
);
await this.emailService.sendMail({
to: user.email,
from: 'chromacase@octohub.app',
subject: 'Password reset for Chromacase',
html: `To reset your password, please click on this <a href="${process.env.PUBLIC_URL}/password_reset?token=${token}">link</a>.`,
});
}
async changePassword(new_password: string, token: string): Promise<boolean> {
let verified;
try {
verified = await this.jwtService.verifyAsync(token);
} catch (e) {
console.log('Password reset token failure', e);
return false;
}
console.log(verified);
await this.userService.updateUser({
where: { id: verified.userId },
data: { password: new_password },
});
return true;
}
async verifyMail(userId: number, token: string): Promise<boolean> {
try {
await this.jwtService.verifyAsync(token);
} catch (e) {
console.log('Verify mail token failure', e);
return false;
}
await this.userService.updateUser({
where: { id: userId },
data: { emailVerified: true },
});
return true;
}
}

View File

@@ -0,0 +1,8 @@
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class PasswordResetDto {
@ApiProperty()
@IsNotEmpty()
password: string;
}

View File

@@ -0,0 +1,35 @@
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
scope: ['email', 'profile'],
});
}
async validate(
_accessToken: string,
_refreshToken: string,
profile: any,
done: VerifyCallback,
): Promise<any> {
const user = {
email: profile.emails[0].value,
username: profile.displayName,
password: null,
googleID: profile.id,
// firstName: name.givenName,
// lastName: name.familyName,
// picture: photos[0].value,
};
done(null, user);
return user;
}
}

View File

@@ -1,5 +1,24 @@
import { Injectable } from '@nestjs/common';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from './public';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
console.log(context);
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
console.log(isPublic);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

4
back/src/auth/public.ts Normal file
View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -13,8 +13,9 @@ import {
Query,
Req,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateGenreDto } from './dto/create-genre.dto';
import { Request } from 'express';
import { GenreService } from './genre.service';
@@ -22,11 +23,19 @@ import { Prisma, Genre } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { createReadStream, existsSync } from 'fs';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Genre as _Genre } from 'src/_gen/prisma-class/genre';
import { IncludeMap, mapInclude } from 'src/utils/include';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { Public } from 'src/auth/public';
@Controller('genre')
@ApiTags('genre')
@UseGuards(JwtAuthGuard)
export class GenreController {
static filterableFields: string[] = ['+id', 'name'];
static includableFields: IncludeMap<Prisma.GenreInclude> = {
Song: true,
};
constructor(private readonly service: GenreService) {}
@@ -49,6 +58,7 @@ export class GenreController {
}
@Get(':id/illustration')
@Public()
async getIllustration(@Param('id', ParseIntPipe) id: number) {
const genre = await this.service.get({ id });
if (!genre) throw new NotFoundException('Genre not found');
@@ -65,10 +75,12 @@ export class GenreController {
}
@Get()
@ApiOkResponsePlaginated(_Genre)
async findAll(
@Req() req: Request,
@FilterQuery(GenreController.filterableFields)
where: Prisma.GenreWhereInput,
@Query('include') include: string,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Genre>> {
@@ -76,13 +88,21 @@ export class GenreController {
skip,
take,
where,
include: mapInclude(include, req, GenreController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.service.get({ id });
async findOne(
@Req() req: Request,
@Query('include') include: string,
@Param('id', ParseIntPipe) id: number,
) {
const res = await this.service.get(
{ id },
mapInclude(include, req, GenreController.includableFields),
);
if (res === null) throw new NotFoundException('Genre not found');
return res;

View File

@@ -12,9 +12,13 @@ export class GenreService {
});
}
async get(where: Prisma.GenreWhereUniqueInput): Promise<Genre | null> {
async get(
where: Prisma.GenreWhereUniqueInput,
include?: Prisma.GenreInclude,
): Promise<Genre | null> {
return this.prisma.genre.findUnique({
where,
include,
});
}
@@ -24,14 +28,16 @@ export class GenreService {
cursor?: Prisma.GenreWhereUniqueInput;
where?: Prisma.GenreWhereInput;
orderBy?: Prisma.GenreOrderByWithRelationInput;
include?: Prisma.GenreInclude;
}): Promise<Genre[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.genre.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}

View File

@@ -1,9 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty } from '@nestjs/swagger';
export class SearchHistoryDto {
@ApiProperty()
query: string;
@ApiProperty()
type: "song" | "artist" | "album" | "genre";
type: 'song' | 'artist' | 'album' | 'genre';
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNumber } from "class-validator";
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber } from 'class-validator';
export class SongHistoryDto {
@ApiProperty()
@@ -15,8 +15,8 @@ export class SongHistoryDto {
score: number;
@ApiProperty()
difficulties: Record<string, number>
difficulties: Record<string, number>;
@ApiProperty()
info: Record<string, number>
info: Record<string, number>;
}

View File

@@ -10,21 +10,31 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
import {
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { SearchHistory, SongHistory } from '@prisma/client';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { SongHistoryDto } from './dto/SongHistoryDto';
import { HistoryService } from './history.service';
import { SearchHistoryDto } from './dto/SearchHistoryDto';
import { SongHistory as _SongHistory } from 'src/_gen/prisma-class/song_history';
import { SearchHistory as _SearchHistory } from 'src/_gen/prisma-class/search_history';
@Controller('history')
@ApiTags('history')
export class HistoryController {
constructor(private readonly historyService: HistoryService) { }
constructor(private readonly historyService: HistoryService) {}
@Get()
@HttpCode(200)
@ApiOperation({ description: 'Get song history of connected user' })
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _SongHistory, isArray: true })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
async getHistory(
@Request() req: any,
@@ -36,7 +46,9 @@ export class HistoryController {
@Get('search')
@HttpCode(200)
@ApiOperation({ description: 'Get search history of connected user' })
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _SearchHistory, isArray: true })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
async getSearchHistory(
@Request() req: any,
@@ -48,18 +60,24 @@ export class HistoryController {
@Post()
@HttpCode(201)
@ApiOperation({ description: 'Create a record of a song played by a user' })
@ApiCreatedResponse({ description: 'Succesfully created a record' })
async create(@Body() record: SongHistoryDto): Promise<SongHistory> {
return this.historyService.createSongHistoryRecord(record);
}
@Post("search")
@Post('search')
@HttpCode(201)
@ApiOperation({ description: 'Creates a search record in the users history' })
@UseGuards(JwtAuthGuard)
@ApiUnauthorizedResponse({description: "Invalid token"})
@ApiUnauthorizedResponse({ description: 'Invalid token' })
async createSearchHistory(
@Request() req: any,
@Body() record: SearchHistoryDto
): Promise<void> {
await this.historyService.createSearchHistoryRecord(req.user.id, { query: record.query, type: record.type });
}
@Body() record: SearchHistoryDto,
): Promise<void> {
await this.historyService.createSearchHistoryRecord(req.user.id, {
query: record.query,
type: record.type,
});
}
}

View File

@@ -2,17 +2,17 @@ import { Test, TestingModule } from '@nestjs/testing';
import { HistoryService } from './history.service';
describe('HistoryService', () => {
let service: HistoryService;
let service: HistoryService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HistoryService],
}).compile();
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HistoryService],
}).compile();
service = module.get<HistoryService>(HistoryService);
});
service = module.get<HistoryService>(HistoryService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -6,7 +6,7 @@ import { SongHistoryDto } from './dto/SongHistoryDto';
@Injectable()
export class HistoryService {
constructor(private prisma: PrismaService) { }
constructor(private prisma: PrismaService) {}
async createSongHistoryRecord({
songID,
@@ -74,7 +74,7 @@ export class HistoryService {
async createSearchHistoryRecord(
userID: number,
{ query, type }: SearchHistoryDto
{ query, type }: SearchHistoryDto,
): Promise<SearchHistory> {
return this.prisma.searchHistory.create({
data: {

View File

@@ -3,7 +3,6 @@ import {
Get,
Query,
Req,
Request,
Param,
ParseIntPipe,
DefaultValuePipe,
@@ -12,12 +11,17 @@ import {
Body,
Delete,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { LessonService } from './lesson.service';
import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
import { Prisma, Skill } from '@prisma/client';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Lesson as _Lesson } from 'src/_gen/prisma-class/lesson';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { IncludeMap, mapInclude } from 'src/utils/include';
import { Request } from 'express';
export class Lesson {
@ApiProperty()
@@ -34,6 +38,7 @@ export class Lesson {
@ApiTags('lessons')
@Controller('lesson')
@UseGuards(JwtAuthGuard)
export class LessonController {
static filterableFields: string[] = [
'+id',
@@ -41,6 +46,9 @@ export class LessonController {
'+requiredLevel',
'mainSkill',
];
static includableFields: IncludeMap<Prisma.LessonInclude> = {
LessonHistory: true,
};
constructor(private lessonService: LessonService) {}
@@ -48,10 +56,12 @@ export class LessonController {
summary: 'Get all lessons',
})
@Get()
@ApiOkResponsePlaginated(_Lesson)
async getAll(
@Req() request: Request,
@FilterQuery(LessonController.filterableFields)
where: Prisma.LessonWhereInput,
@Query('include') include: string,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Lesson>> {
@@ -59,6 +69,7 @@ export class LessonController {
skip,
take,
where,
include: mapInclude(include, request, LessonController.includableFields),
});
return new Plage(ret, request);
}
@@ -67,8 +78,15 @@ export class LessonController {
summary: 'Get a particular lessons',
})
@Get(':id')
async get(@Param('id', ParseIntPipe) id: number): Promise<Lesson> {
const ret = await this.lessonService.get(id);
async get(
@Req() req: Request,
@Query('include') include: string,
@Param('id', ParseIntPipe) id: number,
): Promise<Lesson> {
const ret = await this.lessonService.get(
id,
mapInclude(include, req, LessonController.includableFields),
);
if (!ret) throw new NotFoundException();
return ret;
}

View File

@@ -12,22 +12,28 @@ export class LessonService {
cursor?: Prisma.LessonWhereUniqueInput;
where?: Prisma.LessonWhereInput;
orderBy?: Prisma.LessonOrderByWithRelationInput;
include?: Prisma.LessonInclude;
}): Promise<Lesson[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.lesson.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}
async get(id: number): Promise<Lesson | null> {
async get(
id: number,
include?: Prisma.LessonInclude,
): Promise<Lesson | null> {
return this.prisma.lesson.findFirst({
where: {
id: id,
},
include,
});
}

View File

@@ -1,24 +1,72 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PrismaService } from './prisma/prisma.service';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
ValidationPipe,
} from '@nestjs/common';
import { RequestLogger, RequestLoggerOptions } from 'json-logger-service';
import { tap } from 'rxjs';
import { PrismaModel } from './_gen/prisma-class';
import { PrismaService } from './prisma/prisma.service';
@Injectable()
export class AspectLogger implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
const res = context.switchToHttp().getResponse();
const { statusCode } = context.switchToHttp().getResponse();
const { originalUrl, method, params, query, body, user } = req;
const toPrint = {
originalUrl,
method,
params,
query,
body,
userId: user?.id ?? 'not logged in',
username: user?.username ?? 'not logged in',
};
return next.handle().pipe(
tap((/* data */) =>
console.log(
JSON.stringify({
...toPrint,
statusCode,
//data, //TODO: Data crashed with images
}),
),),
);
}
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const prismaService = app.get(PrismaService);
await prismaService.enableShutdownHooks(app);
app.use(
RequestLogger.buildExpressRequestLogger({
doNotLogPaths: ['/health'],
} as RequestLoggerOptions),
);
app.enableShutdownHooks();
const config = new DocumentBuilder()
.setTitle('Chromacase')
.setDescription('The chromacase API')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
const document = SwaggerModule.createDocument(app, config, {
extraModels: [...PrismaModel.extraModels],
});
SwaggerModule.setup('api', app, document);
app.useGlobalPipes(new ValidationPipe());
app.enableCors();
app.useGlobalInterceptors(new AspectLogger());
await app.listen(3000);
}
bootstrap();

View File

@@ -2,16 +2,35 @@
* Thanks to https://github.com/Arthi-chaud/Meelo/blob/master/src/pagination/models/paginated-response.ts
*/
import { ApiProperty } from '@nestjs/swagger';
import { Type, applyDecorators } from '@nestjs/common';
import {
ApiExtraModels,
ApiOkResponse,
ApiProperty,
getSchemaPath,
} from '@nestjs/swagger';
export class Plage<T> {
export class PlageMetadata {
@ApiProperty()
metadata: {
this: string;
next: string | null;
previous: string | null;
};
this: string;
@ApiProperty({
type: 'string',
nullable: true,
description: "null if there is no next page, couldn't set it in swagger",
})
next: string | null;
@ApiProperty({
type: 'string',
nullable: true,
description:
"null if there is no previous page, couldn't set it in swagger",
})
previous: string | null;
}
export class Plage<T extends object> {
@ApiProperty()
metadata: PlageMetadata;
data: T[];
constructor(data: T[], request: Request | any) {
@@ -49,3 +68,25 @@ export class Plage<T> {
return route;
}
}
export const ApiOkResponsePlaginated = <DataDto extends Type<unknown>>(
dataDto: DataDto,
) =>
applyDecorators(
ApiExtraModels(Plage, dataDto),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(Plage) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(dataDto) },
},
},
},
],
},
}),
);

View File

@@ -6,7 +6,7 @@ export class User {
@ApiProperty()
username: string;
@ApiProperty()
email: string;
email: string | null;
@ApiProperty()
isGuest: boolean;
@ApiProperty()

View File

@@ -1,4 +1,4 @@
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
@@ -6,10 +6,4 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}

View File

@@ -1,33 +1,51 @@
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
InternalServerErrorException,
NotFoundException,
Param,
ParseIntPipe,
Post,
Query,
Request,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import {
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { Artist, Genre, Song } from '@prisma/client';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { SearchSongDto } from './dto/search-song.dto';
import { SearchService } from './search.service';
import { Song as _Song } from 'src/_gen/prisma-class/song';
import { Genre as _Genre } from 'src/_gen/prisma-class/genre';
import { Artist as _Artist } from 'src/_gen/prisma-class/artist';
import { mapInclude } from 'src/utils/include';
import { SongController } from 'src/song/song.controller';
import { GenreController } from 'src/genre/genre.controller';
import { ArtistController } from 'src/artist/artist.controller';
@ApiTags('search')
@Controller('search')
export class SearchController {
constructor(private readonly searchService: SearchService) { }
constructor(private readonly searchService: SearchService) {}
@Get('songs/:query')
@ApiOkResponse({ type: _Song, isArray: true })
@ApiOperation({ description: 'Search a song' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@UseGuards(JwtAuthGuard)
async searchSong(@Request() req: any, @Param('query') query: string): Promise<Song[] | null> {
async searchSong(
@Request() req: any,
@Query('include') include: string,
@Param('query') query: string,
): Promise<Song[] | null> {
try {
const ret = await this.searchService.songByGuess(query, req.user?.id);
const ret = await this.searchService.songByGuess(
query,
req.user?.id,
mapInclude(include, req, SongController.includableFields),
);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {
@@ -37,9 +55,20 @@ export class SearchController {
@Get('genres/:query')
@UseGuards(JwtAuthGuard)
async searchGenre(@Request() req: any, @Param('query') query: string): Promise<Genre[] | null> {
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@ApiOkResponse({ type: _Genre, isArray: true })
@ApiOperation({ description: 'Search a genre' })
async searchGenre(
@Request() req: any,
@Query('include') include: string,
@Param('query') query: string,
): Promise<Genre[] | null> {
try {
const ret = await this.searchService.genreByGuess(query, req.user?.id);
const ret = await this.searchService.genreByGuess(
query,
req.user?.id,
mapInclude(include, req, GenreController.includableFields),
);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {
@@ -49,13 +78,24 @@ export class SearchController {
@Get('artists/:query')
@UseGuards(JwtAuthGuard)
async searchArtists(@Request() req: any, @Param('query') query: string): Promise<Artist[] | null> {
@ApiOkResponse({ type: _Artist, isArray: true })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@ApiOperation({ description: 'Search an artist' })
async searchArtists(
@Request() req: any,
@Query('include') include: string,
@Param('query') query: string,
): Promise<Artist[] | null> {
try {
const ret = await this.searchService.artistByGuess(query, req.user?.id);
const ret = await this.searchService.artistByGuess(
query,
req.user?.id,
mapInclude(include, req, ArtistController.includableFields),
);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {
throw new InternalServerErrorException();
}
}
}
}

View File

@@ -1,33 +1,51 @@
import { Injectable } from '@nestjs/common';
import { Album, Artist, Prisma, Song, Genre } from '@prisma/client';
import { Artist, Prisma, Song, Genre } from '@prisma/client';
import { HistoryService } from 'src/history/history.service';
import { PrismaService } from 'src/prisma/prisma.service';
@Injectable()
export class SearchService {
constructor(private prisma: PrismaService, private history: HistoryService) { }
constructor(
private prisma: PrismaService,
private history: HistoryService,
) {}
async songByGuess(query: string, userID: number): Promise<Song[]> {
async songByGuess(
query: string,
userID: number,
include?: Prisma.SongInclude,
): Promise<Song[]> {
return this.prisma.song.findMany({
where: {
name: { contains: query, mode: 'insensitive' },
},
include,
});
}
async genreByGuess(query: string, userID: number): Promise<Genre[]> {
async genreByGuess(
query: string,
userID: number,
include?: Prisma.GenreInclude,
): Promise<Genre[]> {
return this.prisma.genre.findMany({
where: {
name: { contains: query, mode: 'insensitive' },
},
include,
});
}
async artistByGuess(query: string, userID: number): Promise<Artist[]> {
async artistByGuess(
query: string,
userID: number,
include?: Prisma.ArtistInclude,
): Promise<Artist[]> {
return this.prisma.artist.findMany({
where: {
name: { contains: query, mode: 'insensitive' },
},
include,
});
}
}

View File

@@ -3,8 +3,8 @@ import { SettingsService } from './settings.service';
import { PrismaModule } from 'src/prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [SettingsService],
exports: [SettingsService],
imports: [PrismaModule],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@@ -20,10 +20,10 @@ export class SettingsService {
user: {
connect: {
id: userId,
}
}
}
})
},
},
},
});
}
async updateUserSettings(params: {
@@ -37,7 +37,9 @@ export class SettingsService {
});
}
async deleteUserSettings(where: Prisma.UserSettingsWhereUniqueInput): Promise<UserSettings> {
async deleteUserSettings(
where: Prisma.UserSettingsWhereUniqueInput,
): Promise<UserSettings> {
return this.prisma.userSettings.delete({
where,
});

View File

@@ -16,19 +16,38 @@ import {
StreamableFile,
UseGuards,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateSongDto } from './dto/create-song.dto';
import { SongService } from './song.service';
import { Request } from 'express';
import { Prisma, Song } from '@prisma/client';
import { createReadStream, existsSync } from 'fs';
import { ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
import {
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiProperty,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { HistoryService } from 'src/history/history.service';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Song as _Song } from 'src/_gen/prisma-class/song';
import { SongHistory } from 'src/_gen/prisma-class/song_history';
import { IncludeMap, mapInclude } from 'src/utils/include';
import { Public } from 'src/auth/public';
class SongHistoryResult {
@ApiProperty()
best: number;
@ApiProperty({ type: SongHistory, isArray: true })
history: SongHistory[];
}
@Controller('song')
@ApiTags('song')
@UseGuards(JwtAuthGuard)
export class SongController {
static filterableFields: string[] = [
'+id',
@@ -37,6 +56,13 @@ export class SongController {
'+albumId',
'+genreId',
];
static includableFields: IncludeMap<Prisma.SongInclude> = {
artist: true,
album: true,
genre: true,
SongHistory: ({ user }) => ({ where: { userID: user.id } }),
likedByUsers: ({ user }) => ({ where: { userId: user.id } }),
};
constructor(
private readonly songService: SongService,
@@ -44,6 +70,9 @@ export class SongController {
) {}
@Get(':id/midi')
@ApiOperation({ description: 'Streams the midi file of the requested song' })
@ApiNotFoundResponse({ description: 'Song not found' })
@ApiOkResponse({ description: 'Returns the midi file succesfully' })
async getMidi(@Param('id', ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException('Song not found');
@@ -57,6 +86,12 @@ export class SongController {
}
@Get(':id/illustration')
@ApiOperation({
description: 'Streams the illustration of the requested song',
})
@ApiNotFoundResponse({ description: 'Song not found' })
@ApiOkResponse({ description: 'Returns the illustration succesfully' })
@Public()
async getIllustration(@Param('id', ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException('Song not found');
@@ -74,6 +109,11 @@ export class SongController {
}
@Get(':id/musicXml')
@ApiOperation({
description: 'Streams the musicXML file of the requested song',
})
@ApiNotFoundResponse({ description: 'Song not found' })
@ApiOkResponse({ description: 'Returns the musicXML file succesfully' })
async getMusicXml(@Param('id', ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException('Song not found');
@@ -83,6 +123,10 @@ export class SongController {
}
@Post()
@ApiOperation({
description:
'register a new song in the database, should not be used by the frontend',
})
async create(@Body() createSongDto: CreateSongDto) {
try {
return await this.songService.createSong({
@@ -105,6 +149,7 @@ export class SongController {
}
@Delete(':id')
@ApiOperation({ description: 'delete a song by id' })
async remove(@Param('id', ParseIntPipe) id: number) {
try {
return await this.songService.deleteSong({ id });
@@ -114,9 +159,11 @@ export class SongController {
}
@Get()
@ApiOkResponsePlaginated(_Song)
async findAll(
@Req() req: Request,
@FilterQuery(SongController.filterableFields) where: Prisma.SongWhereInput,
@Query('include') include: string,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Song>> {
@@ -124,13 +171,26 @@ export class SongController {
skip,
take,
where,
include: mapInclude(include, req, SongController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.songService.song({ id });
@ApiOperation({ description: 'Get a specific song data' })
@ApiNotFoundResponse({ description: 'Song not found' })
@ApiOkResponse({ type: _Song, description: 'Requested song' })
async findOne(
@Req() req: Request,
@Param('id', ParseIntPipe) id: number,
@Query('include') include: string,
) {
const res = await this.songService.song(
{
id,
},
mapInclude(include, req, SongController.includableFields),
);
if (res === null) throw new NotFoundException('Song not found');
return res;
@@ -138,7 +198,13 @@ export class SongController {
@Get(':id/history')
@HttpCode(200)
@UseGuards(JwtAuthGuard)
@ApiOperation({
description: 'get the history of the connected user on a specific song',
})
@ApiOkResponse({
type: SongHistoryResult,
description: 'Records of previous games of the user',
})
@ApiUnauthorizedResponse({ description: 'Invalid token' })
async getHistory(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.historyService.getForSong({
@@ -146,10 +212,4 @@ export class SongController {
songId: id,
});
}
@Get('/artist/:artistId')
async getSongByArtist(@Param('artistId', ParseIntPipe) artistId: number) {
const res = await this.songService.songByArtist(artistId)
return res;
}
}

View File

@@ -9,7 +9,7 @@ export class SongService {
async songByArtist(data: number): Promise<Song[]> {
return this.prisma.song.findMany({
where: {
artistId: {equals: data},
artistId: { equals: data },
},
});
}
@@ -22,9 +22,11 @@ export class SongService {
async song(
songWhereUniqueInput: Prisma.SongWhereUniqueInput,
include?: Prisma.SongInclude,
): Promise<Song | null> {
return this.prisma.song.findUnique({
where: songWhereUniqueInput,
include,
});
}
@@ -34,14 +36,16 @@ export class SongService {
cursor?: Prisma.SongWhereUniqueInput;
where?: Prisma.SongWhereInput;
orderBy?: Prisma.SongOrderByWithRelationInput;
include?: Prisma.SongInclude;
}): Promise<Song[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.song.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}

View File

@@ -1,6 +1,13 @@
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
import {
Controller,
Get,
Post,
Param,
NotFoundException,
Response,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { User } from 'src/models/user';
@ApiTags('users')
@@ -20,4 +27,12 @@ export class UsersController {
if (!ret) throw new NotFoundException();
return ret;
}
@Get(':id/picture')
@ApiOkResponse({
description: 'Return the profile picture of the requested user',
})
async getPicture(@Response() res: any, @Param('id') id: number) {
return await this.usersService.getProfilePicture(+id, res);
}
}

View File

@@ -1,8 +1,14 @@
import { Injectable } from '@nestjs/common';
import {
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { User, Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import * as bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
import { createHash, randomUUID } from 'crypto';
import { createReadStream, existsSync } from 'fs';
import fetch from 'node-fetch';
@Injectable()
export class UsersService {
@@ -34,7 +40,7 @@ export class UsersService {
}
async createUser(data: Prisma.UserCreateInput): Promise<User> {
data.password = await bcrypt.hash(data.password, 8);
if (data.password) data.password = await bcrypt.hash(data.password, 8);
return this.prisma.user.create({
data,
});
@@ -46,7 +52,7 @@ export class UsersService {
username: `Guest ${randomUUID()}`,
isGuest: true,
// Not realyl clean but better than a separate table or breaking the api by adding nulls.
email: '',
email: null,
password: '',
},
});
@@ -72,4 +78,42 @@ export class UsersService {
where,
});
}
async getProfilePicture(userId: number, res: any) {
const path = `/data/${userId}.jpg`;
if (existsSync(path)) {
const file = createReadStream(path);
return file.pipe(res);
}
// We could not find a profile icon locally, using gravatar instead.
const user = await this.user({ id: userId });
if (!user) throw new InternalServerErrorException();
if (!user.email) throw new NotFoundException();
const hash = createHash('md5')
.update(user.email.trim().toLowerCase())
.digest('hex');
const resp = await fetch(
`https://www.gravatar.com/avatar/${hash}.jpg?d=404&s=200`,
);
for (const [k, v] of resp.headers) resp.headers.set(k, v);
resp.body!.pipe(res);
}
async addLikedSong(userId: number, songId: number) {
return this.prisma.likedSongs.create({
data: { songId: songId, userId: userId },
});
}
async getLikedSongs(userId: number) {
return this.prisma.likedSongs.findMany({
where: { userId: userId },
});
}
async removeLikedSong(userId: number, songId: number) {
return this.prisma.likedSongs.deleteMany({
where: { userId: userId, songId: songId },
});
}
}

33
back/src/utils/include.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Request } from 'express';
import { BadRequestException } from '@nestjs/common';
export type IncludeMap<IncludeType> = {
[key in keyof IncludeType]:
| boolean
| ((ctx: { user: { id: number; username: string } }) => IncludeType[key]);
};
export function mapInclude<IncludeType>(
include: string | undefined,
req: Request,
fields: IncludeMap<IncludeType>,
): IncludeType | undefined {
if (!include) return undefined;
const ret: IncludeType = {} as IncludeType;
for (const key of include.split(',')) {
const value =
typeof fields[key] === 'function'
? fields[key]({ user: req.user })
: fields[key];
if (value !== false && value !== undefined) ret[key] = value;
else {
throw new BadRequestException(
`Invalid include, ${key} is not valid. Valid includes are: ${Object.keys(
fields,
).join(', ')}.`,
);
}
}
return ret;
}

View File

@@ -30,7 +30,7 @@ Register Duplicates
# We can't use the `Register` keyword because it assert for success
POST /auth/register {"username": "user-duplicate", "password": "pass", "email": "mail@kyoo.moe"}
Output
Integer response status 400
Integer response status 409
Login user-duplicate
[Teardown] DELETE /auth/me

View File

@@ -3,6 +3,7 @@ Documentation Tests of the /song route.
... Ensures that the songs CRUD works corectly.
Resource ../rest.resource
Resource ../auth/auth.resource
*** Test Cases ***
@@ -133,5 +134,47 @@ Get midi file
Integer response status 201
GET /song/${res.body.id}/midi
Integer response status 200
#Output
# Output
[Teardown] DELETE /song/${res.body.id}
Find a song with artist
[Documentation] Create a song and find it with it's artist
&{res2}= POST /artist { "name": "Tghjmk"}
Output
Integer response status 201
&{res}= POST
... /song
... {"name": "Mama miaeyi", "artistId": ${res2.body.id}, "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
&{get}= GET /song/${res.body.id}?include=artist
Output
Integer response status 200
Should Be Equal ${res2.body} ${get.body.artist}
[Teardown] Run Keywords DELETE /song/${res.body.id}
... AND DELETE /artist/${res2.body.id}
Find a song with artist and history
[Documentation] Create a song and find it with it's artist
${userID}= RegisterLogin wowusersfkj
&{res2}= POST /artist { "name": "Tghjmk"}
Output
Integer response status 201
&{res}= POST
... /song
... {"name": "Mama miaeyi", "artistId": ${res2.body.id}, "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
Output
Integer response status 201
&{res3}= POST
... /history
... { "songID": ${res.body.id}, "userID": ${userID}, "score": 12, "difficulties": {}, "info": {} }
Output
Integer response status 201
&{get}= GET /song/${res.body.id}?include=artist,SongHistory
Output
Integer response status 200
Should Be Equal ${res2.body} ${get.body.artist}
Should Be Equal ${res3.body} ${get.body.SongHistory[0]}
[Teardown] Run Keywords DELETE /auth/me
... AND DELETE /song/${res.body.id}
... AND DELETE /artist/${res2.body.id}

37
config/logs_nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
user nginx;
worker_processes 5; ## Default: 1
events {
worker_connections 1000;
}
http {
resolver 127.0.0.11;
server {
listen 3100;
location = / {
return 200 'OK';
auth_basic off;
}
location = /api/prom/push {
proxy_pass http://write:3100\$$request_uri;
}
location = /api/prom/tail {
proxy_pass http://read:3100\$$request_uri;
proxy_set_header Upgrade \$$http_upgrade;
proxy_set_header Connection "upgrade";
}
location ~ /api/prom/.* {
proxy_pass http://read:3100\$$request_uri;
}
location = /loki/api/v1/push {
proxy_pass http://write:3100\$$request_uri;
}
location = /loki/api/v1/tail {
proxy_pass http://read:3100\$$request_uri;
proxy_set_header Upgrade \$$http_upgrade;
proxy_set_header Connection "upgrade";
}
location ~ /loki/api/.* {
proxy_pass http://read:3100\$$request_uri;
}
}
}

36
config/loki-config.yaml Normal file
View File

@@ -0,0 +1,36 @@
---
auth_enabled: false
server:
http_listen_port: 3100
memberlist:
join_members:
- loki:7946
schema_config:
configs:
- from: 2021-08-01
store: boltdb-shipper
object_store: s3
schema: v11
index:
prefix: index_
period: 24h
common:
path_prefix: /loki
replication_factor: 1
storage:
s3:
endpoint: minio:9000
insecure: true
bucketnames: loki-data
access_key_id: loki
secret_access_key: 12345678
s3forcepathstyle: true
ring:
kvstore:
store: memberlist
query_range:
parallelise_shardable_queries: false
ruler:
storage:
s3:
bucketnames: loki-ruler

View File

@@ -0,0 +1,22 @@
---
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://gateway:3100/loki/api/v1/push
tenant_id: tenant1
scrape_configs:
- job_name: flog_scrape
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: 'container'

1
data/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*

View File

@@ -1,3 +1,10 @@
networks:
loki:
volumes:
scoro_logs:
services:
back:
build:
@@ -9,6 +16,7 @@ services:
volumes:
- ./back:/app
- ./assets:/assets
- ./data:/data
depends_on:
db:
condition: service_healthy
@@ -24,6 +32,9 @@ services:
volumes:
- ./scorometer:/app
- ./assets:/assets
- scoro_logs:/logs
networks:
- loki
db:
container_name: db
@@ -39,13 +50,14 @@ services:
retries: 5
ports:
- "5432:5432"
front:
build:
context: ./front
dockerfile: Dockerfile.dev
environment:
- SCOROMETER_URL=http://scorometer:6543/
- NGINX_PORT=80
- NGINX_PORT=4567
ports:
- "19006:19006"
volumes:
@@ -54,3 +66,20 @@ services:
- "back"
env_file:
- .env
nginx:
image: nginx
environment:
- API_URL=${API_URL:-http://back:3000}
- SCOROMETER_URL=${SCOROMETER_URL:-http://scorometer:6543}
- FRONT_URL=${FRONT_URL:-http://front:19006}
- PORT=4567
depends_on:
- back
- front
- scorometer
volumes:
- "./front/assets:/assets:ro"
- "./front/nginx.conf.template.dev:/etc/nginx/templates/default.conf.template:ro"
ports:
- "4567:4567"

185
docker-compose.log.yml Normal file
View File

@@ -0,0 +1,185 @@
services:
read:
image: grafana/loki:2.8.2
command: "-config.file=/etc/loki/config.yaml -target=read"
ports:
- 3101:3100
- 7946
- 9095
volumes:
- ./config/loki-config.yaml:/etc/loki/config.yaml
depends_on:
- minio
healthcheck:
test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1" ]
interval: 10s
timeout: 5s
retries: 5
networks: &loki-dns
loki:
aliases:
- loki
write:
image: grafana/loki:2.8.2
command: "-config.file=/etc/loki/config.yaml -target=write"
ports:
- 3102:3100
- 7946
- 9095
volumes:
- ./config/loki-config.yaml:/etc/loki/config.yaml
healthcheck:
test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1" ]
interval: 10s
timeout: 5s
retries: 5
depends_on:
- minio
networks:
<<: *loki-dns
promtail:
image: grafana/promtail:2.8.2
volumes:
- ./config/promtail-local-config.yaml:/etc/promtail/config.yaml:ro
- /var/run/docker.sock:/var/run/docker.sock
command: -config.file=/etc/promtail/config.yaml
depends_on:
- gateway
networks:
- loki
minio:
image: minio/minio:RELEASE.2023-07-21T21-12-44Z
entrypoint:
- sh
- -euc
- |
mkdir -p /data/loki-data && \
mkdir -p /data/loki-ruler && \
minio server /data
environment:
- MINIO_ROOT_USER=loki
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
- MINIO_PROMETHEUS_AUTH_TYPE=public
- MINIO_UPDATE=off
ports:
- 9000
volumes:
- ./.data/minio:/data
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
interval: 15s
timeout: 20s
retries: 5
networks:
- loki
grafana:
image: grafana/grafana:9.5.6
environment:
- GF_PATHS_PROVISIONING=/etc/grafana/provisioning
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
depends_on:
- gateway
entrypoint:
- sh
- -euc
- |
mkdir -p /etc/grafana/provisioning/datasources
cat <<EOF > /etc/grafana/provisioning/datasources/ds.yaml
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://gateway:3100
jsonData:
httpHeaderName1: "X-Scope-OrgID"
secureJsonData:
httpHeaderValue1: "tenant1"
EOF
/run.sh
ports:
- "3001:3000"
volumes:
- ./grafana/dashboard.yaml:/etc/grafana/provisioning/dashboards/main.yaml
- ./grafana/dashboards:/var/lib/grafana/dashboards
healthcheck:
test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1" ]
interval: 10s
timeout: 5s
retries: 5
networks:
- loki
gateway:
image: nginx:1.25.1
depends_on:
- read
- write
entrypoint:
- sh
- -euc
- |
cat <<EOF > /etc/nginx/nginx.conf
user nginx;
worker_processes 5; ## Default: 1
events {
worker_connections 1000;
}
http {
resolver 127.0.0.11;
server {
listen 3100;
location = / {
return 200 'OK';
auth_basic off;
}
location = /api/prom/push {
proxy_pass http://write:3100\$$request_uri;
}
location = /api/prom/tail {
proxy_pass http://read:3100\$$request_uri;
proxy_set_header Upgrade \$$http_upgrade;
proxy_set_header Connection "upgrade";
}
location ~ /api/prom/.* {
proxy_pass http://read:3100\$$request_uri;
}
location = /loki/api/v1/push {
proxy_pass http://write:3100\$$request_uri;
}
location = /loki/api/v1/tail {
proxy_pass http://read:3100\$$request_uri;
proxy_set_header Upgrade \$$http_upgrade;
proxy_set_header Connection "upgrade";
}
location ~ /loki/api/.* {
proxy_pass http://read:3100\$$request_uri;
}
}
}
EOF
/docker-entrypoint.sh nginx -g "daemon off;"
ports:
- "3100:3100"
healthcheck:
test: ["CMD", "service", "nginx", "status"]
interval: 10s
timeout: 5s
retries: 5
networks:
- loki

View File

@@ -1,3 +1,9 @@
networks:
loki:
volumes:
scoro_logs:
services:
back:
image: ghcr.io/chroma-case/back:main
@@ -10,18 +16,20 @@ services:
- .env
volumes:
- ./assets:/assets
- ./data:/data
scorometer:
image: ghcr.io/chroma-case/scorometer:main
ports:
- "6543:6543"
volumes:
- scoro_logs:/logs
- ./assets:/assets
db:
container_name: db
image: postgres:alpine3.14
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORDgrafana}
- POSTGRES_DB=${POSTGRES_DB}
ports:
- "5432:5432"
@@ -42,4 +50,4 @@ services:
depends_on:
- "back"
env_file:
- .env
- .env

View File

@@ -1,3 +1,12 @@
networks:
loki:
volumes:
db:
scoro_logs:
services:
back:
build: ./back
@@ -10,12 +19,14 @@ services:
- .env
volumes:
- ./assets:/assets
- ./data:/data
scorometer:
build: ./scorometer
ports:
- "6543:6543"
volumes:
- ./assets:/assets
- scoro_logs:/logs
db:
container_name: db
image: postgres:alpine3.14
@@ -34,11 +45,7 @@ services:
retries: 5
front:
build:
context: ./front
args:
- API_URL=${API_URL}
- SCORO_URL=${SCORO_URL}
build: ./front
environment:
- API_URL=http://back:3000/
- SCOROMETER_URL=http://scorometer:6543/
@@ -48,7 +55,4 @@ services:
depends_on:
- "back"
env_file:
- .env
volumes:
db:
- .env

43
flake.lock generated
View File

@@ -1,43 +0,0 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1659877975,
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1665573177,
"narHash": "sha256-Arkrf3zmi3lXYpbSe9H+HQxswQ6jxsAmeQVq5Sr/OZc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d2afb051ffd904af5a825f58abee3c63b148c5f2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "master",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,31 +0,0 @@
{
description = "A prisma test project";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/master";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
devShell = pkgs.mkShell {
nativeBuildInputs = [ pkgs.bashInteractive ];
buildInputs = with pkgs; [
nodePackages.prisma
nodePackages."@nestjs/cli"
nodePackages.npm
nodejs-slim
yarn
python3
pkg-config
];
shellHook = with pkgs; ''
export PRISMA_MIGRATION_ENGINE_BINARY="${prisma-engines}/bin/migration-engine"
export PRISMA_QUERY_ENGINE_BINARY="${prisma-engines}/bin/query-engine"
export PRISMA_QUERY_ENGINE_LIBRARY="${prisma-engines}/lib/libquery_engine.node"
export PRISMA_INTROSPECTION_ENGINE_BINARY="${prisma-engines}/bin/introspection-engine"
export PRISMA_FMT_BINARY="${prisma-engines}/bin/prisma-fmt"
export DATABASE_URL=postgresql://user:eip@localhost:5432/chromacase
'';
};
});
}

View File

@@ -1,4 +1,7 @@
node_modules/
.expo/
.idea/
.vscode/
.vscode/
.dockerignore
Dockerfile
Dockerfile.dev

31
front/.gitignore vendored
View File

@@ -1,18 +1,35 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
npm-debug.*
web-build/
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
*.apk
yarn.error*
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
.idea/
.expo
# local env files
.env*.local
# typescript
*.tsbuildinfo

View File

@@ -4,10 +4,10 @@ import Chapter from './models/Chapter';
import Lesson from './models/Lesson';
import Genre, { GenreHandler } from './models/Genre';
import LessonHistory from './models/LessonHistory';
import likedSong, { LikedSongHandler } from './models/LikedSong';
import Song, { SongHandler } from './models/Song';
import { SongHistoryHandler, SongHistoryItem, SongHistoryItemHandler } from './models/SongHistory';
import User, { UserHandler } from './models/User';
import Constants from 'expo-constants';
import store from './state/Store';
import { Platform } from 'react-native';
import { en } from './i18n/Translations';
@@ -21,6 +21,8 @@ import { PlageHandler } from './models/Plage';
import { ListHandler } from './models/List';
import { AccessTokenResponseHandler } from './models/AccessTokenResponse';
import * as yup from 'yup';
import { base64ToBlob } from './utils/base64ToBlob';
import { ImagePickerAsset } from 'expo-image-picker';
type AuthenticationInput = { username: string; password: string };
type RegistrationInput = AuthenticationInput & { email: string };
@@ -30,6 +32,7 @@ export type AccessToken = string;
type FetchParams = {
route: string;
body?: object;
formData?: FormData;
method?: 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT';
};
@@ -63,9 +66,7 @@ export class ValidationError extends Error {
export default class API {
public static readonly baseUrl =
process.env.NODE_ENV != 'development' && Platform.OS === 'web'
? '/api'
: Constants.manifest?.extra?.apiUrl;
Platform.OS === 'web' ? '/api' : process.env.EXPO_PUBLIC_API_URL!;
public static async fetch(
params: FetchParams,
handle: Pick<Required<HandleParams>, 'raw'>
@@ -81,17 +82,30 @@ export default class API {
public static async fetch(params: FetchParams): Promise<void>;
public static async fetch(params: FetchParams, handle?: HandleParams) {
const jwtToken = store.getState().user.accessToken;
const header = {
'Content-Type': 'application/json',
const headers = {
...(params.formData == undefined && { 'Content-Type': 'application/json' }),
...(jwtToken && { Authorization: `Bearer ${jwtToken}` }),
};
const response = await fetch(`${API.baseUrl}${params.route}`, {
headers: (jwtToken && { ...header, Authorization: `Bearer ${jwtToken}` }) || header,
body: JSON.stringify(params.body),
headers: headers,
body: params.formData ?? JSON.stringify(params.body),
method: params.method ?? 'GET',
}).catch(() => {
throw new Error('Error while fetching API: ' + API.baseUrl);
});
if (!handle || handle.emptyResponse) {
if (!response.ok) {
let responseMessage = response.statusText;
try {
const responseData = await response.json();
console.log(responseData);
if (responseData.message) responseMessage = responseData.message;
} catch (e) {
console.log(e);
throw new APIError(response.statusText, response.status, 'unknownError');
}
throw new APIError(responseMessage, response.status, 'unknownError');
}
return;
}
if (handle.raw) {
@@ -102,7 +116,7 @@ export default class API {
try {
const jsonResponse = JSON.parse(body);
if (!response.ok) {
throw new APIError(response.statusText ?? body, response.status);
throw new APIError(response.statusText ?? body, response.status, 'unknownError');
}
const validated = await handler.validator.validate(jsonResponse).catch((e) => {
if (e instanceof yup.ValidationError) {
@@ -164,6 +178,7 @@ export default class API {
{
route: '/auth/guest',
method: 'POST',
body: undefined,
},
{ handler: AccessTokenResponseHandler }
)
@@ -297,6 +312,24 @@ export default class API {
};
}
/**
* Retrieves all songs corresponding to the given genre ID
* @param genreId the id of the genre we're aiming
* @returns a promise of an array of Songs
*/
public static getSongsByGenre(genreId: number): Query<Song[]> {
return {
key: ['genre', genreId, 'songs'],
exec: () =>
API.fetch(
{
route: `/song?genreId=${genreId}`,
},
{ handler: PlageHandler(SongHandler) }
).then(({ data }) => data),
};
}
/**
* Retrive a song's midi partition
* @param songId the id to find the song
@@ -332,15 +365,22 @@ export default class API {
return `${API.baseUrl}/genre/${genreId}/illustration`;
}
// public static getGenre(genreId: number): Query<Genre> {
// return {
// key: ['genre', genreId],
// exec: () =>
// API.fetch({
// route: `/genre/${genreId}`,
// }),
// }
// }
/**
* Retrieves a genre
* @param genreId the id of the aimed genre
*/
public static getGenre(genreId: number): Query<Genre> {
return {
key: ['genre', genreId],
exec: () =>
API.fetch(
{
route: `/genre/${genreId}`,
},
{ handler: GenreHandler }
),
};
}
/**
* Retrive a song's musicXML partition
@@ -508,16 +548,6 @@ export default class API {
};
}
// public static getFavorites(): Query<Song[]> {
// return {
// key: 'favorites',
// exec: () =>
// API.fetch({
// route: '/search/songs/o',
// }),
// };
// }
/**
* Retrieve the authenticated user's search history
* @param skip number of entries skipped before returning
@@ -626,4 +656,43 @@ export default class API {
{ handler: UserHandler }
);
}
public static async updateProfileAvatar(image: ImagePickerAsset): Promise<void> {
const data = await base64ToBlob(image.uri);
const formData = new FormData();
formData.append('file', data);
return API.fetch({
route: '/auth/me/picture',
method: 'POST',
formData,
});
}
public static async addLikedSong(songId: number): Promise<void> {
await API.fetch({
route: `/auth/me/likes/${songId}`,
method: 'POST',
});
}
public static async removeLikedSong(songId: number): Promise<void> {
await API.fetch({
route: `/auth/me/likes/${songId}`,
method: 'DELETE',
});
}
public static getLikedSongs(): Query<likedSong[]> {
return {
key: ['liked songs'],
exec: () =>
API.fetch(
{
route: '/auth/me/likes',
},
{ handler: ListHandler(LikedSongHandler) }
),
};
}
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import store, { persistor } from './state/Store';
@@ -10,12 +10,22 @@ import LanguageGate from './i18n/LanguageGate';
import ThemeProvider, { ColorSchemeProvider } from './Theme';
import 'react-native-url-polyfill/auto';
import { QueryRules } from './Queries';
import { useFonts } from 'expo-font';
const queryClient = new QueryClient(QueryRules);
export default function App() {
SplashScreen.preventAutoHideAsync();
setTimeout(SplashScreen.hideAsync, 500);
const [fontsLoaded] = useFonts({
Lexend: require('./assets/fonts/lexend.ttf'),
});
useEffect(() => {
if (fontsLoaded) {
SplashScreen.hideAsync();
}
}, [fontsLoaded]);
return (
<Provider store={store}>

View File

@@ -4,24 +4,24 @@
FROM node:16-alpine as build
WORKDIR /app
# install expo cli
RUN yarn global add expo-cli@6.0.5
RUN yarn global add expo-cli
# add sharp-cli (^2.1.0) for faster image processing
RUN yarn global add sharp-cli@^2.1.0
RUN yarn global add sharp-cli
COPY package.json yarn.lock ./
RUN yarn install
RUN yarn install --immutable
RUN expo install
COPY . .
ARG API_URL
ENV API_URL=$API_URL
ENV EXPO_PUBLIC_API_URL=$API_URL
ARG SCORO_URL
ENV SCORO_URL=$SCORO_URL
ENV EXPO_PUBLIC_API_URL=$SCORO_URL
RUN yarn tsc && expo build:web
RUN yarn tsc && npx expo export:web
# Serve the app
FROM nginx:1.21-alpine
COPY --from=build /app/web-build /usr/share/nginx/html
COPY nginx.conf.template /etc/nginx/conf.d/default.conf.template
CMD envsubst '$API_URL $SCOROMETER_URL $NGINX_PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'
COPY ./assets/ /usr/share/nginx/html/assets/
COPY nginx.conf.template /etc/nginx/templates/default.conf.template

View File

@@ -11,7 +11,6 @@ import { RootState, useSelector } from './state/Store';
import { useDispatch } from 'react-redux';
import { Translate, translate } from './i18n/i18n';
import SongLobbyView from './views/SongLobbyView';
import AuthenticationView from './views/AuthenticationView';
import StartPageView from './views/StartPageView';
import HomeView from './views/HomeView';
import SearchView from './views/SearchView';
@@ -29,6 +28,13 @@ import { unsetAccessToken } from './state/UserSlice';
import TextButton from './components/TextButton';
import ErrorView from './views/ErrorView';
import GenreDetailsView from './views/GenreDetailsView';
import GoogleView from './views/GoogleView';
import VerifiedView from './views/VerifiedView';
import SigninView from './views/SigninView';
import SignupView from './views/SignupView';
import TabNavigation from './components/V2/TabNavigation';
import PasswordResetView from './views/PasswordResetView';
import ForgotPasswordView from './views/ForgotPasswordView';
// Util function to hide route props in URL
const removeMe = () => '';
@@ -40,6 +46,11 @@ const protectedRoutes = () =>
options: { title: translate('welcome'), headerLeft: null },
link: '/',
},
HomeNew: {
component: TabNavigation,
options: { headerShown: false },
link: '/V2',
},
Play: { component: PlayView, options: { title: translate('play') }, link: '/play/:songId' },
Settings: {
component: SetttingsNavigator,
@@ -61,7 +72,7 @@ const protectedRoutes = () =>
},
Genre: {
component: GenreDetailsView,
options: { title: translate('genreFilter')},
options: { title: translate('genreFilter') },
link: '/genre/:genreId',
},
Score: {
@@ -80,7 +91,12 @@ const protectedRoutes = () =>
link: undefined,
},
User: { component: ProfileView, options: { title: translate('user') }, link: '/user' },
} as const);
Verified: {
component: VerifiedView,
options: { title: 'Verify email', headerShown: false },
link: '/verify',
},
}) as const;
const publicRoutes = () =>
({
@@ -90,23 +106,31 @@ const publicRoutes = () =>
link: '/',
},
Login: {
component: (params: RouteProps<{}>) =>
AuthenticationView({ isSignup: false, ...params }),
options: { title: translate('signInBtn') },
component: SigninView,
options: { title: translate('signInBtn'), headerShown: false },
link: '/login',
},
Signup: {
component: (params: RouteProps<{}>) =>
AuthenticationView({ isSignup: true, ...params }),
options: { title: translate('signUpBtn') },
component: SignupView,
options: { title: translate('signUpBtn'), headerShown: false },
link: '/signup',
},
Oops: {
component: ProfileErrorView,
options: { title: 'Oops', headerShown: false },
link: undefined,
Google: {
component: GoogleView,
options: { title: 'Google signin', headerShown: false },
link: '/logged/google',
},
} as const);
PasswordReset: {
component: PasswordResetView,
options: { title: 'Password reset form', headerShown: false },
link: '/password_reset',
},
ForgotPassword: {
component: ForgotPasswordView,
options: { title: 'Password reset form', headerShown: false },
link: '/forgot_password',
},
}) as const;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Route<Props = any> = {
@@ -127,19 +151,18 @@ type PrivateRoutesParams = RouteParams<ReturnType<typeof protectedRoutes>>;
type PublicRoutesParams = RouteParams<ReturnType<typeof publicRoutes>>;
type AppRouteParams = PrivateRoutesParams & PublicRoutesParams;
const Stack = createNativeStackNavigator<AppRouteParams & { Loading: never }>();
const Stack = createNativeStackNavigator<AppRouteParams & { Loading: never; Oops: never }>();
const RouteToScreen =
<T extends {}>(component: Route<T>['component']) =>
// eslint-disable-next-line react/display-name
(props: NativeStackScreenProps<T & ParamListBase>) =>
(
<>
{component({ ...props.route.params, route: props.route } as Parameters<
Route<T>['component']
>[0])}
</>
);
(props: NativeStackScreenProps<T & ParamListBase>) => (
<>
{component({ ...props.route.params, route: props.route } as Parameters<
Route<T>['component']
>[0])}
</>
);
const routesToScreens = (routes: Partial<Record<keyof AppRouteParams, Route>>) =>
Object.entries(routes).map(([name, route], routeIndex) => (
@@ -176,6 +199,8 @@ const routesToLinkingConfig = (
const ProfileErrorView = (props: { onTryAgain: () => void }) => {
const dispatch = useDispatch();
const navigation = useNavigation();
return (
<Center style={{ flexGrow: 1 }}>
<VStack space={3}>
@@ -184,7 +209,10 @@ const ProfileErrorView = (props: { onTryAgain: () => void }) => {
<Translate translationKey="tryAgain" />
</Button>
<TextButton
onPress={() => dispatch(unsetAccessToken())}
onPress={() => {
dispatch(unsetAccessToken());
navigation.navigate('Start');
}}
colorScheme="error"
variant="outline"
translate={{ translationKey: 'signOutBtn' }}
@@ -245,12 +273,15 @@ export const Router = () => {
>
<Stack.Navigator>
{authStatus == 'error' ? (
<Stack.Screen
name="Oops"
component={RouteToScreen(() => (
<ProfileErrorView onTryAgain={() => userProfile.refetch()} />
))}
/>
<>
<Stack.Screen
name="Oops"
component={RouteToScreen(() => (
<ProfileErrorView onTryAgain={() => userProfile.refetch()} />
))}
/>
{routesToScreens(publicRoutes())}
</>
) : (
routesToScreens(routes)
)}

View File

@@ -4,77 +4,126 @@ import { useEffect } from 'react';
const ThemeProvider = ({ children }: { children: JSX.Element }) => {
const colorScheme = useColorScheme();
const config = {
dependencies: {
"linear-gradient": require("expo-linear-gradient").LinearGradient,
},
};
return (
<NativeBaseProvider
config={config}
theme={extendTheme({
config: {
useSystemColorMode: false,
initialColorMode: colorScheme,
},
fonts: {
heading: 'Lexend',
body: 'Lexend',
mono: 'Lexend',
},
colors: {
primary: {
50: '#e6faea',
100: '#c8e7d0',
200: '#a7d6b5',
300: '#86c498',
400: '#65b47c',
500: '#4b9a62',
600: '#3a784b',
700: '#275635',
800: '#14341f',
900: '#001405',
50: '#eff1fe',
100: '#e7eafe',
200: '#cdd4fd',
300: '#5f74f7',
400: '#5668de',
500: '#4c5dc6',
600: '#4757b9',
700: '#394694',
800: '#2b346f',
900: '#212956',
},
secondary: {
50: '#d8ffff',
100: '#acffff',
200: '#7dffff',
300: '#4dffff',
400: '#28ffff',
500: '#18e5e6',
600: '#00b2b3',
700: '#007f80',
800: '#004d4e',
900: '#001b1d',
50: '#f7f3ff',
100: '#f3edfe',
200: '#e6d9fe',
300: '#ae84fb',
400: '#9d77e2',
500: '#8b6ac9',
600: '#8363bc',
700: '#684f97',
800: '#4e3b71',
900: '#3d2e58',
},
error: {
50: '#ffe2e9',
100: '#ffb1bf',
200: '#ff7f97',
300: '#ff4d6d',
400: '#fe1d43',
500: '#e5062b',
600: '#b30020',
700: '#810017',
800: '#4f000c',
900: '#200004',
50: '#f7f3ff',
100: '#f3edfe',
200: '#e6d9fe',
300: '#ae84fb',
400: '#9d77e2',
500: '#8b6ac9',
600: '#8363bc',
700: '#684f97',
800: '#4e3b71',
900: '#3d2e58',
},
alert: {
50: '#fff2f1',
100: '#ffebea',
200: '#ffd6d3',
300: '#ff7a72',
400: '#e66e67',
500: '#cc625b',
600: '#bf5c56',
700: '#994944',
800: '#733733',
900: '#592b28',
},
notification: {
50: '#ffe1e1',
100: '#ffb1b1',
200: '#ff7f7f',
300: '#ff4c4c',
400: '#ff1a1a',
500: '#e60000',
600: '#b40000',
700: '#810000',
800: '#500000',
900: '#210000',
50: '#fdfbec',
100: '#fcf9e2',
200: '#f8f3c3',
300: '#ead93c',
400: '#d3c336',
500: '#bbae30',
600: '#b0a32d',
700: '#8c8224',
800: '#69621b',
900: '#524c15',
},
black: {
50: '#e7e7e8',
100: '#dbdbdc',
200: '#b5b5b6',
300: '#101014',
400: '#0e0e12',
500: '#0d0d10',
600: '#0c0c0f',
700: '#0a0a0c',
800: '#070709',
900: '#060607',
},
red: {
50: '#fdedee',
100: '#fce4e5',
200: '#f9c7c9',
300: '#ed4a51',
400: '#d54349',
500: '#be3b41',
600: '#b2383d',
700: '#8e2c31',
800: '#6b2124',
900: '#531a1c',
},
},
components: {
Button: {
variants: {
solid: () => ({
rounded: 'full',
}),
baseStyle: () => ({
borderRadius: 'md',
}),
},
Link: {
defaultProps: {
isUnderlined: false,
},
baseStyle: () => ({
_text: {
color: 'secondary.300',
},
_hover: {
isUnderlined: true,
_text: {
color: 'secondary.400',
},
},
}),
},
},
})}

15
front/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
# Bundle artifacts
*.jsbundle

View File

@@ -0,0 +1,180 @@
apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli')"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '..'
// root = file("../")
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
// codegenDir = file("../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'org.webkit:android-jsc:+'
android {
ndkVersion rootProject.ext.ndkVersion
compileSdkVersion rootProject.ext.compileSdkVersion
namespace 'build.apk'
defaultConfig {
applicationId 'build.apk'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
buildConfigField("boolean", "REACT_NATIVE_UNSTABLE_USE_RUNTIME_SCHEDULER_ALWAYS", (findProperty("reactNative.unstable_useRuntimeSchedulerAlways") ?: true).toString())
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
def frescoVersion = rootProject.ext.frescoVersion
// If your app supports Android versions before Ice Cream Sandwich (API level 14)
if (isGifEnabled || isWebpEnabled) {
implementation("com.facebook.fresco:fresco:${frescoVersion}")
implementation("com.facebook.fresco:imagepipeline-okhttp3:${frescoVersion}")
}
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${frescoVersion}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${frescoVersion}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${frescoVersion}")
}
}
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
exclude group:'com.squareup.okhttp3', module:'okhttp'
}
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}
apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json')"].execute(null, rootDir).text.trim(), "../native_modules.gradle");
applyNativeModulesAppBuildGradle(project)

Binary file not shown.

14
front/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,14 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -0,0 +1,33 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:allowBackup="true" android:theme="@style/AppTheme">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_SDK_VERSION" android:value="49.0.0"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="build.apk"/>
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" android:exported="false"/>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

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