From 68d5cfa9bed14ac39c298643e2b40231ba36b758 Mon Sep 17 00:00:00 2001 From: Lord Lumineer Date: Tue, 21 May 2024 20:50:25 -0400 Subject: [PATCH] V1.0.0 --- .dockerignore | 8 + .github/workflows/Continuous Linting.yml | 29 + .github/workflows/FullTest_Master.yml | 57 ++ .gitignore | 0 .vscode/sessions.json | 59 ++ .vscode/settings.json | 16 + README.md | 3 +- SECURITY.md | 23 + app.config.ts | 14 + client/.dockerignore | 22 + client/.gitignore | 25 + client/.vscode/sessions.json | 28 + client/.vscode/settings.json | 17 + client/README.md | 75 ++ client/app.config.ts | 14 + client/app.vue | 6 + client/assets/android-chrome-192x192.png | Bin 0 -> 11784 bytes client/assets/android-chrome-512x512.png | Bin 0 -> 32377 bytes client/assets/apple-touch-icon.png | Bin 0 -> 11113 bytes client/assets/css/tailwind.css | 3 + client/assets/favicon-16x16.png | Bin 0 -> 840 bytes client/assets/favicon-32x32.png | Bin 0 -> 1793 bytes client/assets/favicon.ico | Bin 0 -> 34494 bytes client/components/OgImage/CustomTest.vue | 109 +++ client/components/loginForm.vue | 132 +++ client/components/loginGroup.vue | 25 + client/components/registerForm.vue | 232 +++++ client/components/registerGroup.vue | 25 + client/components/twitchConnectButton.vue | 63 ++ client/components/youtubeConnectButton.vue | 62 ++ client/dockerfile | 23 + client/nuxt.config.ts | 39 + client/package.json | 29 + client/pages/authorize/change-password.vue | 184 ++++ client/pages/authorize/forgot-password.vue | 108 +++ client/pages/authorize/index.vue | 45 + client/pages/authorize/login.vue | 26 + client/pages/authorize/register.vue | 26 + client/pages/authorize/reset-password.vue | 195 ++++ client/pages/favicon.ico | Bin 0 -> 34494 bytes client/pages/index.vue | 8 + client/public/android-chrome-192x192.png | Bin 0 -> 11784 bytes client/public/android-chrome-512x512.png | Bin 0 -> 32377 bytes client/public/apple-touch-icon.png | Bin 0 -> 11113 bytes client/public/favicon copy.ico | Bin 0 -> 34494 bytes client/public/favicon-16x16.png | Bin 0 -> 840 bytes client/public/favicon-32x32.png | Bin 0 -> 1793 bytes client/public/favicon.ico | Bin 0 -> 34494 bytes client/server/tsconfig.json | 3 + client/tailwind.config.ts | 168 ++++ client/tsconfig.json | 4 + docker/BETA_docker-compose.yml | 33 + docker/LATEST_docker-compose.yml | 33 + dockerfile | 29 + ecosystem.config.js | 16 + nginx.conf | 32 + server/.coveragerc | 25 + server/.dockerignore | 22 + server/.gitignore | 15 + server/.pylintrc | 641 +++++++++++++ server/.vscode/sessions.json | 32 + server/.vscode/settings.json | 11 + server/__init__.py | 0 server/api/__init__.py | 0 server/api/main.py | 21 + server/api/routes/__init__.py | 0 server/api/routes/admin.py | 180 ++++ server/api/routes/google.py | 224 +++++ server/api/routes/local.py | 802 ++++++++++++++++ server/api/routes/login.py | 75 ++ server/api/routes/twitch.py | 219 +++++ server/api/routes/users.py | 333 +++++++ server/api/routes/utils.py | 450 +++++++++ server/assets/favicon.ico | Bin 0 -> 34494 bytes server/assets/html/adminLogin.html | 274 ++++++ .../html/email-verification-callback.html | 108 +++ server/assets/html/email/DB_not_running.html | 282 ++++++ server/assets/html/email/new_account.html | 313 +++++++ .../html/email/notification_pwd_change.html | 305 +++++++ server/assets/html/email/reset_password.html | 342 +++++++ server/assets/html/email/test_email.html | 282 ++++++ .../assets/html/email/verification_email.html | 336 +++++++ server/assets/html/embed_example.html | 25 + server/assets/html/google-callback.html | 136 +++ server/assets/html/template/login.html | 262 ++++++ server/assets/html/template/register.html | 392 ++++++++ server/assets/html/twitch-callback.html | 136 +++ server/core/__init__.py | 0 server/core/config.py | 73 ++ server/core/db.py | 855 ++++++++++++++++++ server/core/email.py | 234 +++++ server/core/engine.py | 29 + server/core/google.py | 272 ++++++ server/core/security.py | 123 +++ server/core/twitch.py | 272 ++++++ ...9_pfp_test_user_1008720000_EXAMPLE_pfp.png | Bin 0 -> 34494 bytes .../users_eventkitstream_users_backup.json | 43 + server/db/data/MakeDataVisibleInGit | 1 + server/db/logs/EXAMPLE.log | 37 + server/dockerfile | 9 + server/main.py | 269 ++++++ server/models.py | 60 ++ server/requirements.txt | 17 + server/test.py | 20 + server/test/__init__.py | 0 server/test/requirements.txt | 3 + server/test/test_main.py | 4 + 107 files changed, 10611 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/Continuous Linting.yml create mode 100644 .github/workflows/FullTest_Master.yml create mode 100644 .gitignore create mode 100644 .vscode/sessions.json create mode 100644 .vscode/settings.json create mode 100644 SECURITY.md create mode 100644 app.config.ts create mode 100644 client/.dockerignore create mode 100644 client/.gitignore create mode 100644 client/.vscode/sessions.json create mode 100644 client/.vscode/settings.json create mode 100644 client/README.md create mode 100644 client/app.config.ts create mode 100644 client/app.vue create mode 100644 client/assets/android-chrome-192x192.png create mode 100644 client/assets/android-chrome-512x512.png create mode 100644 client/assets/apple-touch-icon.png create mode 100644 client/assets/css/tailwind.css create mode 100644 client/assets/favicon-16x16.png create mode 100644 client/assets/favicon-32x32.png create mode 100644 client/assets/favicon.ico create mode 100644 client/components/OgImage/CustomTest.vue create mode 100644 client/components/loginForm.vue create mode 100644 client/components/loginGroup.vue create mode 100644 client/components/registerForm.vue create mode 100644 client/components/registerGroup.vue create mode 100644 client/components/twitchConnectButton.vue create mode 100644 client/components/youtubeConnectButton.vue create mode 100644 client/dockerfile create mode 100644 client/nuxt.config.ts create mode 100644 client/package.json create mode 100644 client/pages/authorize/change-password.vue create mode 100644 client/pages/authorize/forgot-password.vue create mode 100644 client/pages/authorize/index.vue create mode 100644 client/pages/authorize/login.vue create mode 100644 client/pages/authorize/register.vue create mode 100644 client/pages/authorize/reset-password.vue create mode 100644 client/pages/favicon.ico create mode 100644 client/pages/index.vue create mode 100644 client/public/android-chrome-192x192.png create mode 100644 client/public/android-chrome-512x512.png create mode 100644 client/public/apple-touch-icon.png create mode 100644 client/public/favicon copy.ico create mode 100644 client/public/favicon-16x16.png create mode 100644 client/public/favicon-32x32.png create mode 100644 client/public/favicon.ico create mode 100644 client/server/tsconfig.json create mode 100644 client/tailwind.config.ts create mode 100644 client/tsconfig.json create mode 100644 docker/BETA_docker-compose.yml create mode 100644 docker/LATEST_docker-compose.yml create mode 100644 dockerfile create mode 100644 ecosystem.config.js create mode 100644 nginx.conf create mode 100644 server/.coveragerc create mode 100644 server/.dockerignore create mode 100644 server/.gitignore create mode 100644 server/.pylintrc create mode 100644 server/.vscode/sessions.json create mode 100644 server/.vscode/settings.json create mode 100644 server/__init__.py create mode 100644 server/api/__init__.py create mode 100644 server/api/main.py create mode 100644 server/api/routes/__init__.py create mode 100644 server/api/routes/admin.py create mode 100644 server/api/routes/google.py create mode 100644 server/api/routes/local.py create mode 100644 server/api/routes/login.py create mode 100644 server/api/routes/twitch.py create mode 100644 server/api/routes/users.py create mode 100644 server/api/routes/utils.py create mode 100644 server/assets/favicon.ico create mode 100644 server/assets/html/adminLogin.html create mode 100644 server/assets/html/email-verification-callback.html create mode 100644 server/assets/html/email/DB_not_running.html create mode 100644 server/assets/html/email/new_account.html create mode 100644 server/assets/html/email/notification_pwd_change.html create mode 100644 server/assets/html/email/reset_password.html create mode 100644 server/assets/html/email/test_email.html create mode 100644 server/assets/html/email/verification_email.html create mode 100644 server/assets/html/embed_example.html create mode 100644 server/assets/html/google-callback.html create mode 100644 server/assets/html/template/login.html create mode 100644 server/assets/html/template/register.html create mode 100644 server/assets/html/twitch-callback.html create mode 100644 server/core/__init__.py create mode 100644 server/core/config.py create mode 100644 server/core/db.py create mode 100644 server/core/email.py create mode 100644 server/core/engine.py create mode 100644 server/core/google.py create mode 100644 server/core/security.py create mode 100644 server/core/twitch.py create mode 100644 server/db/backup/1008720000_EXAMPLE_backup/profile_pictures/123456789123456789_pfp_test_user_1008720000_EXAMPLE_pfp.png create mode 100644 server/db/backup/1008720000_EXAMPLE_backup/users_eventkitstream_users_backup.json create mode 100644 server/db/data/MakeDataVisibleInGit create mode 100644 server/db/logs/EXAMPLE.log create mode 100644 server/dockerfile create mode 100644 server/main.py create mode 100644 server/models.py create mode 100644 server/requirements.txt create mode 100644 server/test.py create mode 100644 server/test/__init__.py create mode 100644 server/test/requirements.txt create mode 100644 server/test/test_main.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..928b72c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.github/ +.git/ +.vscode/ +docker/ +.dockerignore +.gitignore +dockerfile +.md \ No newline at end of file diff --git a/.github/workflows/Continuous Linting.yml b/.github/workflows/Continuous Linting.yml new file mode 100644 index 0000000..8487ec7 --- /dev/null +++ b/.github/workflows/Continuous Linting.yml @@ -0,0 +1,29 @@ +name: Continuous Linting + +on: + push: + branches: + - '*' + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12.3' + cache: 'pip' + + - name: Install Dependencies + run: | + cd server + pip install -r requirements.txt + cd test + pip install -r requirements.txt + + - name: Run Pylint + run: | + cd server + pylint --disable=R,C . diff --git a/.github/workflows/FullTest_Master.yml b/.github/workflows/FullTest_Master.yml new file mode 100644 index 0000000..b044b2c --- /dev/null +++ b/.github/workflows/FullTest_Master.yml @@ -0,0 +1,57 @@ +name: Linting validation and Preliminary Testing + +on: + pull_request: + branches: + - 'master' + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12.3' + cache: 'pip' + + - name: Install Dependencies + run: | + cd server + pip install -r requirements.txt + cd test + pip install -r requirements.txt + + - name: Run Pylint + run: | + cd server + pylint --disable=R,C . + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Test Client + run: | + cd client + # Add commands to run tests for the client application + + - uses: actions/setup-python@v5 + with: + python-version: '3.12.3' + cache: 'pip' + + - name: Install dependencies + run: | + cd server + pip install -r requirements.txt + cd test + pip install -r requirements.txt + + - name: Test Server + run: | + cd server + coverage run -m pytest + coverage report diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.vscode/sessions.json b/.vscode/sessions.json new file mode 100644 index 0000000..9875b2f --- /dev/null +++ b/.vscode/sessions.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://cdn.statically.io/gh/nguyenngoclongdev/cdn/main/schema/v10/terminal-keeper.json", + "theme": "tribe", + "active": "default", + "activateOnStartup": true, + "keepExistingTerminals": false, + "sessions": { + "default": [ + [ + { + "name": ".venv Auth Server", + "autoExecuteCommands": false, + "icon": "server", + "color": "terminal.ansiRed", + "cwd": "./server", + "commands": [ + "& 'd:/Desktop/Codding Space/EventKit/Auth-API-DEV/server/.venv/Scripts/Activate.ps1'", + "uvicorn main:app --port 20001 --host 0.0.0.0 --reload" + ], + "joinOperator": ";" + }, + { + "name": ".venv terminal", + "autoExecuteCommands": true, + "icon": "terminal", + "cwd": "./server", + "commands": [ + "& 'd:/Desktop/Codding Space/EventKit/Auth-API-DEV/server/.venv/Scripts/Activate.ps1'" + ] + } + ], + [ + { + "name": "npm Client Server", + "autoExecuteCommands": false, + "icon": "globe", + "color": "terminal.ansiGreen", + "cwd": "./client", + "commands": [ + "npm run dev" + ] + }, + { + "name": "Client env terminal", + "autoExecuteCommands": true, + "icon": "terminal", + "cwd": "./client", + "commands": [] + } + ], + { + "name": "Auth Terminal", + "icon": "terminal", + "cwd": ".", + "commands": [] + } + ] + } +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..dd84a29 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true + }, + "hide-files.files": [], + "pylint.path": [ + "[\".\"]" + ], + "todo-tree.tree.scanMode": "workspace only", + "todo-tree.tree.showCountsInTree": true +} \ No newline at end of file diff --git a/README.md b/README.md index 43f4c85..97fbc07 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ -# Auth-API \ No newline at end of file +# Auth-API +[![linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](https://github.com/pylint-dev/pylint) \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..899323b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# BLANK PLACE HOLDER + +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/app.config.ts b/app.config.ts new file mode 100644 index 0000000..c36f3bc --- /dev/null +++ b/app.config.ts @@ -0,0 +1,14 @@ +export default defineAppConfig({ + ui: { + icons: { + dynamic: true + }, + primary: 'blue', + colors: ['twitch', 'youtube','discord', 'twitter', 'instagram', 'kofi', 'patreon', 'paypal', 'spotify', 'streamelements', 'streamlabs', 'throne'], + button: { + default: { + loadingIcon: 'i-mingcute-loading-line', + } + } + } +}) diff --git a/client/.dockerignore b/client/.dockerignore new file mode 100644 index 0000000..0703b80 --- /dev/null +++ b/client/.dockerignore @@ -0,0 +1,22 @@ +.output +.data +.nuxt +.nitro +.cache +dist +node_modules +*package-lock.json +logs +*.log +.DS_Store +.fleet +.idea +.env +.env.* +!.env.example + + +.vscode/ +.gitignore +.dockerignore +dockerfile \ No newline at end of file diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..f953b67 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,25 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules +*package-lock.json + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/client/.vscode/sessions.json b/client/.vscode/sessions.json new file mode 100644 index 0000000..9c99032 --- /dev/null +++ b/client/.vscode/sessions.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://cdn.statically.io/gh/nguyenngoclongdev/cdn/main/schema/v10/terminal-keeper.json", + "theme": "tribe", + "active": "default", + "activateOnStartup": true, + "keepExistingTerminals": false, + "sessions": { + "default": [ + [ + { + "name": "npm Client Server", + "autoExecuteCommands": true, + "icon": "globe", + "color": "terminal.ansiGreen", + "commands": [ + "npm run dev" + ] + }, + { + "name": "Client env terminal", + "autoExecuteCommands": true, + "icon": "terminal", + "commands": [] + } + ] + ] + } +} \ No newline at end of file diff --git a/client/.vscode/settings.json b/client/.vscode/settings.json new file mode 100644 index 0000000..e7a2d83 --- /dev/null +++ b/client/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true + }, + "hide-files.files": [], + "files.associations": { + "*.css": "tailwindcss" + }, + "editor.quickSuggestions": { + "strings": true + } +} \ No newline at end of file diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..f5db2a2 --- /dev/null +++ b/client/README.md @@ -0,0 +1,75 @@ +# Nuxt 3 Minimal Starter + +Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. + +## Setup + +Make sure to install the dependencies: + +```bash +# npm +npm install + +# pnpm +pnpm install + +# yarn +yarn install + +# bun +bun install +``` + +## Development Server + +Start the development server on `http://localhost:3000`: + +```bash +# npm +npm run dev + +# pnpm +pnpm run dev + +# yarn +yarn dev + +# bun +bun run dev +``` + +## Production + +Build the application for production: + +```bash +# npm +npm run build + +# pnpm +pnpm run build + +# yarn +yarn build + +# bun +bun run build +``` + +Locally preview production build: + +```bash +# npm +npm run preview + +# pnpm +pnpm run preview + +# yarn +yarn preview + +# bun +bun run preview +``` + +Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. diff --git a/client/app.config.ts b/client/app.config.ts new file mode 100644 index 0000000..c36f3bc --- /dev/null +++ b/client/app.config.ts @@ -0,0 +1,14 @@ +export default defineAppConfig({ + ui: { + icons: { + dynamic: true + }, + primary: 'blue', + colors: ['twitch', 'youtube','discord', 'twitter', 'instagram', 'kofi', 'patreon', 'paypal', 'spotify', 'streamelements', 'streamlabs', 'throne'], + button: { + default: { + loadingIcon: 'i-mingcute-loading-line', + } + } + } +}) diff --git a/client/app.vue b/client/app.vue new file mode 100644 index 0000000..90cb76e --- /dev/null +++ b/client/app.vue @@ -0,0 +1,6 @@ + diff --git a/client/assets/android-chrome-192x192.png b/client/assets/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..dbd46793dffe9fa3a2fce3fde9004a8078888616 GIT binary patch literal 11784 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 P0RE^dX)0F8TLu3=k=d@= literal 0 HcmV?d00001 diff --git a/client/assets/apple-touch-icon.png b/client/assets/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..456a45531d2deceac2906cd663d6424daafd7719 GIT binary patch literal 11113 zcmV-vE0)xWP)v$R*dd zphXZA;2x3k}$YZQYvz&k>wF85!@>|bIWRhlFKL&)-l2g(R(4KG(Nba2)o`G zKCDy_&G`J#zYPzyO_?Se|HzX8%nnyEOvhvo0aS&PxUiO4_%03EQk3M9a0w~ZbAUxLhb%SP$JbrnZP1JSSFjg*>Q zRscV%f)e&se2c~gV$eEjlLzQ7$>gmX|EmPb30g{_l2Zb!|!Yp5^+iT0P`tp zHXdyh9yJ+B!}43&+Wfhmegkw;#^z#!AXa00xthX3_U9mV+7{leOt8O91W{hocdWjJ z&$FdIZ>=cOxAA&N94@{$Yky~+y?&S$GYFI>n?*8Yb>CQA)%kOOuJ3{Yot9m85kR~H z4PEKaf$6+0fY|`=!i0B0$LIRH8lO>i=7u#HvgGPah!xCZ4!$+(56l2G%}g+oKC9>M zl8W{n>N*2QKPJTd++fd-{EGPZtF5a?qtd-_5KvB;jq=UK*ZDeS)VQKX*0K;tdopxd%$C+Rjy9elv5+ZYR`omn8Avhw}Q%q2DmnJN9it9&!3= z44=WML@#3Gy%^9(eC;f9KlWzCnq_JHuK@GMXTV&OUz3$&s8Ygne6!Y}tASWL9UYfX zB*sLoTz1RB5kB{<+^R3g#PmDHT$r>+L9%KS7~xINgz%}+(FUKQAwB^*IlFR+NTlb` zX>kOcK3f10jaWo9Cc2hpCKm4BU+v&6G}RT{m~IgsJYkoJXKI7;X-#|{MUWrhJ9Au= zKquV{ufJ?{aYe^rK7{OL<>z`l;(mO@0cO|xz&vY+2PGn2d7`JT%iCdlZ9GfPttrt= zd;=e%72Y-;xo^e-j0#bRHM?Zd6uqMF=v_zoUk){_x;VzfUUp$~a=P^y(TKvkZ%mXx z#wO$|@Dhl_6++HAXM3i@5R!Ax7t4 zB5sB^CjG!Yh-et`ZZ8m#x-ahSs(*#w(YmYPLz6D~h$UK}Hm5$pEFwIIms~Pnm9~^r zw(sWi-mszC(9$v5f^VyQxL$LBdCHdD{TS-C!bO|gr}hHS)rQpUk-fO_l6fuX;(iDY zgh+9CLWneeV}DPB5_9d@vp#mfZfj353psE#bmwO9IfAc)hz7!QBc5F)Rqb0<7^G*H z|1TBIpOPh;PsjlBpBd^&LS&@+tZ&V80y-_T>@K)DxBJw4`V9BUG&gw5CKkI z;_$A*Vj#R3q$7f?6J3JYgz%gtS|lS5_Qm3g)+hPAb8{<_Cz;$S(3qsT!*kvnn{NkT z*{&b#*`<7FW?!e>p;NQA?n8)f<|NDet`_y3GlU~XU7sK5s(p%Eqpc=Cb%L@ZNwH%5 zs7?t-UVoSkU@ypKqK6Ej%cd)mSCyksP(5ac_ z8>qluTGY_=ee%yE+h#=F%fn(FeK)68P=pk4__*b=i6jih(f^H6D3`%UV+ z5uW=!j`^>iif9DpVlw^2P+!Bov+mH?ou9%7Zsx5#FhkK>t5bv{CR-owKi>Eab#@18 z^PkL06hE(5!V#Y*56|_DdiJf|*71oMFy~g>gNVlV>F}KYcjxJj;CVbf)VuTQGXk0) zZ%4_7K7?KjdV^db=c6RI%J$a=jyGPY_S0OSe^*Al;r9M9B_B|ieshXhq(e6OUnxsd zRxP>q^$9-rjNFPF35|V_$rG5g6fiI=UJYUHNn*^l^-N{@@aug(bV_#F4y2lZ78<#{ zw7@js$kCIYm{icxQjJu9C8F{5REzNQ5oBt6beKZLW6kj~2+vK(ZZX=lqWIQ#6!K7J zPURfP0FIzUsn8%N=8EcL0y#e4HK;b@XXi>8%b zUH2a0rhw1Hy{T<&T9}UTfG}Y-;L_?OXwRj2KrVhQiE^l9Y zyy4?`md^%Y67dfSxW@55KZ8$&&fADaPm zYU<`}VidX~`GIrWniGy>+4k(<@y4rWz+98R2g#os^-4J6^UO#9d(JAoxwfi(X46a0 zEw2W9?hZ-WnM0wwH?;!%G9e4qY4ms(Mzwk(& zk=CO8q~d-1rg*ss&sAo`i?xGR5RP=3LY0JkKtyc%&F1)m6`S^t@ssDw+{$vnkWvTE z3J}FjF((r_px}{Y`sJaXJ0Dj@k(yQZQ=ny2Kzp2b;~1inSkCp)7-w%8=iStT9uy3G z52B`+@`3m6?B`82-CKB7l7&1o4pmUS5{`IAjdeoC7q}^}sIsM#&zo~J+>G51rC1ZA z%ntWDv}oqv4~)Gy*t7HDDL`k3vHy(JC8RJRCgaWSgQ9eKWB>4mgNBhFT?sJU+fUXwOvQF5-R)>xQ8V8rMICrqar`heB_4??ylqGt1903hX`aZV$m-(|{HP`6GQ@wf_jg z2tLr|fx7$y=@zkc(5gR$BOabRlU!Fg*nX}t(6DFMg&Cj^)aL#FtVF{Xk^DKIthl+> zBP{+7(Hou*_!N>`KOYtY@?j7(>?0gu1n8#Pf**oC_nkf+6ONR8K$~>1q_VA4mFw~W zPa(gRhW!a)m;m}feg0R|&EhY4X$ZGY2}g;@2OM!oqp4Bi%G}MZUJ1K|9Kojy0EDD) z4%uO}VF2jE^@ZmfjqEj^t307o&1O}u3+l~liYgB&TGd*-IKwWy3H>1kbW3B=Qn$z5 znGhowtrPkah2bxOGjd(y@|EzL|ENVEt7E3f?mmERkkjwN^9muLJ9d6L%HclJ3WcL{ zhIOcL#KFwvy3*p24>;;6E3N9-8J-~UepD(4dYSi+1o_So0vg=r7jUe7iCzguid+{6 zM?IsS-QZ|!3G&JYq&o`9f4;0(4Vt-u+og#&7nHXu71@Syxq&>l*Xy#m@YJ z?CEfY!(8J+l%s4EA5>ngH9W5n0Qz8U-qy4PLnFdhx+NT?C~{q%4&zeOnxbo4T;aL+ zzK@Fg(4b4vDBm9q(i1@jbW2^q<>pxW`~(O`lR8y6NAq9sgx@6CBxh``w)b#D-Kh{*W{w^p83vwqBKu;SSFM<0}rRiL~5{?j^ zqtK)VM?>CGQq?x2>=b0Y1-!cmpkd+zHTk~*A?MmY9?=N6$%Eg)tbD+qBOma_;Ey19 zJuZ4cH`nBUKO;%}e*Xkp5*_WTdoT!Vm~O^*1qVZ z-4+#~o9ps-WhNLl>$XTdwk{B=2n5z2M}5FAFQ%P&(fQC&IJy;?+Pn2iIN}S+<+}Q; z(p&4Q+CRJ)!OZ8pD0b+BHLL#J6371Jbm^CH#O1o+GY?wK@p)XXD+1_nwZH<&Ne6Zp z<{MmcTTB!bfOJ|U9{dc-<;V=8A%At*;f`=!%3zF3*E{r)J?j(fc56F!uXKnF>XLB8 z<+`FcJJ2OxT2$8lLNGuR&VA?_&`>yfBf}zo1X&s3yaw|c8;@uxa$RPT4BDklB^B)t z%&Q@tJ)~PegFW|aAIaiqv~;r)l_10tePi-^XI@Vd_M>GO1S3Fzkfg70O- z3qJrMhs&($&ehlu(;W?h3ueVE;>q5!6pFrX07QM~S-}BKlpg{%Q1cjrYo`AxUM~5ZV z_+l$^U8B-p5NrN-e=ois+tVzdTj~ogiH)K!OnN{#LX9yTo`k%#m_Gr+T@6L9%jW4c zE-@A9Nv_MEb~?2!O#-^9p`ZYwbFEx7MX!V-F4u)_8iKuNA{~eO963a$Wp&z$w%7O3GTpqLf`ALFmu6HMc{vCyGo>$K2yFCTc*o zwm)qV^0w4`z<8byXp^sn!qIO-J}8Y1Ky!d@0ifMouE7|i5U(ddPvrwn$#wOONZ-Lr z;GG)7QdkT(0_d<iK19Rr~c}GAzvvBLe6!S+Wd8O<#Hyr6Dc~lObtx z7!g2+c-gqCp~!XFq;A4u3QM;)kB4|jn(jUV=nyU+2Q!!JvNlIu&W4*xO) z!_Z}y5kLodSyj2N^aLY{T}nSHt!n#jkcXh_?jnE=^73-RNhajFpsn{?gy*gea`(FL zE&}KvFENB7$>5l@9|tGrsag?8R2Pjlpg!P)d@1~9MXsxLS3&lqOMaJE5#YLX@i79iO|VZERkXdx z|DI8@Elxh+`G!E4Qu75MsV~hYgfGaQ~*M)q*!7+Ao zNmc6}-W~hnr=yw=b+)Kg1o-c!>!FOMo8xnMai43hc|zRx+N)0)3~8T{C3^rzx$ve1 zF&6an6MF`_8g5>AYYS^30_cU=?~G5+mh0*p^VB1`wNkzL?V9{QXC;c4o?1nK^EsgQ z^c(Er|HxwCzlIuE9n_rokAl9C#ihwF6 zW1p0z7&FZ**wn5lBcI$z)iY3qR7eJ^VoiVAGKaZN@(5{nSfa`IoYO1vCvcmvQp)5PXZkLV#TBm?k zcUU((0}rF6#pP|ye5!|c6&B;Iso7|NF!?0cTR6MhfQSJ}kLUbo`4$|F|Ae=Zwd}T4 zl7}54@Kzb)dSKz>dPD%NLqKydn;_Q({W&t?AfGL*Z2beD=0A6?OA}q=M-t-%%cz%{ zd%P{K%r8MX$1T$3rCX+GrJ32~+lXksS5l%L;d2HP0kqBlt;%(!;f1b$mRuLlP;crw z&;0kr-At<(oZa_b&sYR|-C z5z%-iGs$q(d^PuYo}r?gG$wzqq_XXY+-$kI6*=%)x+yil^>Bv;s3U;Z8K9@-x?Tj~ z=#%QynriZEvXTu|eIqD|0smkwzxi|~QQa7mNXf(`Tf3&Jy@}tKmQ#5HK=km`flKIJ zj|iZ30%%pPYuF~eYgl4jrOI_dc>U(oc;P4h(A-m^Ca6I;GA9Cc2eT^I<#w}=7FD(@q#R!4Q*cRbJksG$%{_$^B2sFB zmY;Q;U9zUC?Iqq{#?oz9A-3|2axBgiG2xJ$B1QnMVL;E8>l(4kUn;I_dxB4g7x}c= zZJo(+LTVt2e5&6F)(QDUGEK={QaP0nfT+jAR50HV%2UJ$pfw8UlX6|0qv8H8I2u2i zc?qn||3`YfcwV53d`?9@r2oMw;#YXzD_3rbvO2_LRESEKWoJls=?I{;3+O4it`X0p zh-iFm7MLH;N;G__cPgS0&`TvS4O-cMlvcI=j&~Mn0#gLgzASkdaXC1>6ZwFz;h^I} zbqc5=@B>tI{a*i=QW9fc;W?koFycZ2uRzV)q!198B7pX3 z1qHn6oYaR71vyET>l(JnJ?R!p9w*@QDKypPUyswlM@Gjb0{%w8!K|81B`^(GJ%3tT z)$u9y_Xy>+2{a}@*aW5spfv<&MXoCv;kgM(j*1di7EbdjS{m~&iZ;+!Bniq#?zss^ zzD(JhdsPAxk_T=EsFh5Z%%xSUWl3nEROAFEZ<_JLJ)GblOJsTMzJczB&3OOLy^x{G7`j#PR$2g&?+q8VU@tt zGwS*7x>;ew1vIJYMB8Q;RYo<7nHqkZfx-nA1|AX zlSJZh(Gs{t2j_lDzjs6ceahONOo8&ejf0bO)jv1aO`D3|Lpf+aUeE?-mLx|ajtQ2nYbm+U!`7$e4v zO@ub0;SDfn2uu!nI9W(pq7sbjk*QJ{8o%(_c%~AAkh-w6tMd$nSo&5UGFu5e5cy-y~cQiN+uZFy606lfs zAicDUW&(@(#K26JN`OfdxYzAx&^cvvf{gv z+a;X8y6W)X7rF>s9v6odR|Q&3W})MpE9${D`9QWBOi0dI`k}Aya|W`%V7IMHK&v|u z4o}Fion414j>r6A*eSU$YcDkBV*IIH=Z~@Z+J>UQWR)HT8EZx`$JaK(@qXO_nu8h9 zIm(ZHv6J3`h{g~6Y4`)Rg`Y{Y&_5tHP59xARxh5i#R*KqcKK?EsXwjNd7L_KJpx*h z>*^Wv{Az7w`&R?8P&~H~<=Jk~8WV4}EtG@?oS51{V4|^6nI*Rz90`Q2g59R>06kTW z)$?}{a?THwG1;E=#%8;J$IK>wC@NunAQH8>hiWD z|L`6?5ST=S^Tu7`nzfaOT0&*dh4c$RZ^cOwjao<}0e$uig6FYDAwD5a;KeSV#2fk- z13CsT8gHu2dmSlVAJYSY>BP9yiHz|)FQy%Kx6st=GA_HC5OyO|2XqENFH1GW9~*Wb zD5+>WSFPO_$~kZXlZ#ArL*Ii;gs~vaTsBe(OkgI~!k^wY*Ztx4cbb+}hFW(dBisfy zGoDib{T5=tu@g3z+e2K5I1pGCMh5~d^@TU2Sm?t@Oa*}{Y%O}$fz2i`*(c?HD=KUM zo0eumQ?s@n#SXVDYzKV`pquY6PLd~_-GV{9xTLIgf4GgixhDVF)CBQLF6yY&>!bpc zG!ScIOY^q+PPq-$+_}rqim#c1Pz_+teop~(*MmjLR;#-R#V%i&^VtyG@ZQ5~6RpGJ zz2+DpS`P#!oFYDgjPb9Bz?Adqk^}nhJIE1K000XlNkli>EIt9?d=yIWb zwA2=SHqjzH%?V84PKQ+Ge<5>G2~0x{`E#Y^ZO<&EtzbRNU5=;Va(pgW9s1ml1_90I zh=hPgGUBJym$Z7Rimdht*-2xgv$a5A0-(!&ih+BbG#d29(I}u50+Y?!4Ni8tE(lCk z>5q^>xH9PL?_-B>Ikr}#oX8zMJ{P>(H412MAg;hL_0D5zwK*YlfZ75*R z>j2O#b*qa^Cg~jsDO*}rV45N@DO%dMp^nSF0bBD0G>jou3u##=Y*6-#T|hg5dvqGm zvN(C((6OCicaY-k#1GWu@5o9Lf2<1v6Ob(vHdBYjTN?!9K7Mph!a5es0ZomB5qI8T zcYS9tj1s6mkc;?6szv;W)=FH=w#SSD)41G@s7Kfx$V~w{tV4IaXapZ7dmg@iAAT?f z3UK~ym+Pdncp?Z-_jlDPJnfdoqNPl7|I4Ix0uz=)kwF;s+*4B3aXX*e`t^H^-F=65 zVdu$28D1;)a0Oezx|mG%4)iwuYI=v>`kx5h{ZFtupGQB83ekYI#>Dig0ho57tc82+ z!11~!KJTWwf*aH0g$IXpKwyH-HnF+Lg~es9ukpF1?q11rdUWX-TGE z9_qRCaizz^#PUQ#jC=y&yWovr9;nvhBZ3eEKu;VQINmrTkOSz_fmSb?mXek#1CdOk%Q=EAa$;lSC(P4D~cB zxsbB}9p)#7fx%iZT7^pqg+wF;3DouR-mY3jAhD^T;IpZ*Qv@chDln-c@?3#w$j&}n zTG{#sKDUgV@=FMfd4aJ>d^;)fQUL`uBkcP_Jv*!459rMD4OCz+1!IL5VINGuI{;(O z;aFo*?$M*wia;VF!}X9P`TP_8yA(0b zS)ZJZ#?-9nj<6>CYy6WS!1Z9V?N{)p6^XK`YPGhWgcu>!s)5qWXEVQ|z-0A23&pCd z_%Z}JNCsL&eU_f-(~L?w*_nwLBjMCB#%3j^Kt z3f-WkG5;d?(yxp=wXVQKNh<2s432rej!@nsd~P}CY+vi~(6`}sV9vZ0m;YUl+3v;% z0G*h1>oNlsI}pOtNUT?L2uJua3eq?(SF{+NdF&Tjn`C&XL`&_qfZ2Lngn0TJo?9PYmo-4t3P)wBeM9Pp)HJRKmE7f10m{MKMk!%T%&T@jelYdi2qWCjCC@^sX(}a^; zu&SceEngRaw^B^!?u6Y7eEC@{Fa$kwXvXpMx@812u0 zi${7IUOf|B<~(DMxE{gzq;Ue1EZge_jyF^(u18%_vPX9J#zj*Tvc@$V%2Re_&Yd2U z9zsIEO$wk(w#CWA9uSzK5`7{t$>i?AzQ(i4CeI0ILT-po%}#g@H~L&7J2+6#MK99? zCKCuu6Cf}(Dg>tH`uwX?>K3($!^i&_o z>02*A)uczUTQB#i*T~IYSYV34B$M%P40hE&%pW+|P*4(MAWb}FOZy5;2<2I&DpW74 z<8#Ycx??$$Tz|$U`PG4*y7v)|yfD_xEeirPZ-t*IGJ~NOJM~6iI*!~srv;`6OzcWpsj`4m#DAoi#q);k9?c%OC<;um0y{A--?w&K z$5ww?(rV$3C`$JfRUYxHyqn(vtpZOGuZfm#!nexiNaF2+K8_~|Oad$J@2XKKlg;&o z8`CXxkCq5bc-i|GVe~#?aQ|#w#Ze`Crqw-*tE%fgw!#6b-2;(0=a=TM#Y*+8#VPyju963LlcRzr>TGVI!G z!Jb-$zZK%=_&`ro5AE|)L|Gc&IoMOL@YE0QE=-wpN=HD^OmLv=MW`=;7b1(iNLanjDM75!T-)uD%>*0%Qna1XC&cKGX|U( zOap~cUo!+IN+vh-b=NC-pUt%e*ThAWCmfTEmyiptAX?E~Wh@$(o4(vR>< vFaIYCv=_fSPDP;uIfP!H=mlV)N|XNwyfj!2pyUft00000NkvXXu0mjfv(lb{ literal 0 HcmV?d00001 diff --git a/client/assets/css/tailwind.css b/client/assets/css/tailwind.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/client/assets/css/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/client/assets/favicon-16x16.png b/client/assets/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..fcc43db9fa76de83a8f6ab4e61b50e50a972233e GIT binary patch literal 840 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 bgYOdmv%m~D&GWOE|89vk|ElzVn*#p>)Zza# literal 0 HcmV?d00001 diff --git a/client/components/OgImage/CustomTest.vue b/client/components/OgImage/CustomTest.vue new file mode 100644 index 0000000..bc8e959 --- /dev/null +++ b/client/components/OgImage/CustomTest.vue @@ -0,0 +1,109 @@ + + + \ No newline at end of file diff --git a/client/components/loginForm.vue b/client/components/loginForm.vue new file mode 100644 index 0000000..c66ba17 --- /dev/null +++ b/client/components/loginForm.vue @@ -0,0 +1,132 @@ + + + + + \ No newline at end of file diff --git a/client/components/loginGroup.vue b/client/components/loginGroup.vue new file mode 100644 index 0000000..6ba76bb --- /dev/null +++ b/client/components/loginGroup.vue @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/client/components/registerForm.vue b/client/components/registerForm.vue new file mode 100644 index 0000000..bcd1d37 --- /dev/null +++ b/client/components/registerForm.vue @@ -0,0 +1,232 @@ + + + + + \ No newline at end of file diff --git a/client/components/registerGroup.vue b/client/components/registerGroup.vue new file mode 100644 index 0000000..d34652b --- /dev/null +++ b/client/components/registerGroup.vue @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/client/components/twitchConnectButton.vue b/client/components/twitchConnectButton.vue new file mode 100644 index 0000000..fb8d3fa --- /dev/null +++ b/client/components/twitchConnectButton.vue @@ -0,0 +1,63 @@ + + + + + \ No newline at end of file diff --git a/client/components/youtubeConnectButton.vue b/client/components/youtubeConnectButton.vue new file mode 100644 index 0000000..b0f144c --- /dev/null +++ b/client/components/youtubeConnectButton.vue @@ -0,0 +1,62 @@ + + + + + \ No newline at end of file diff --git a/client/dockerfile b/client/dockerfile new file mode 100644 index 0000000..c47a705 --- /dev/null +++ b/client/dockerfile @@ -0,0 +1,23 @@ +# Stage 1: Build the application +FROM node:20.11.1 AS build-stage +WORKDIR /client +COPY package.json ./ +RUN npm install +COPY . . +RUN npx nuxi cleanup +RUN npx nuxi generate + +# Stage 2: Production stage +#FROM nginx:alpine AS production-stage +#WORKDIR /usr/share/nginx/html +#COPY --from=build-stage /app/.output/public . +#EXPOSE 80 +#CMD ["nginx", "-g", "daemon off;"] + +FROM node:20.11.1-alpine AS production-stage +WORKDIR /clients +RUN npm i npx +RUN npm i serve +COPY --from=build-stage /client/.output/public ./.output/public +EXPOSE 80 +CMD ["npx", "serve", ".output/public", "-p", "80"] \ No newline at end of file diff --git a/client/nuxt.config.ts b/client/nuxt.config.ts new file mode 100644 index 0000000..949ed8d --- /dev/null +++ b/client/nuxt.config.ts @@ -0,0 +1,39 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + devtools: { enabled: false }, + ui: { + icons: ['mdi', 'simple-icons', 'mingcute', 'heroicons'], + }, + modules: [ + "@nuxt/ui", + '@nuxtjs/seo', + 'nuxt-icon', + 'nuxt-og-image', + ], + + pages: true, + components: true, + ssr:true, + // target: 'static', + // render: { + // resourceHints: false, + // }, + // hooks: { + // 'generate:page': page => { + // const doc = cheerio.load(page.html); + // doc(`body script`).remove(); + // page.html = doc.html(); + // }, + // }, + site: { + indexable: false, //NOTE: set to true for the main service + url: 'https://id.eventkit.stream', + name: 'Event Kit', + description: 'Event Kit is a platform dedicated to .....', + defaultLocale: 'en', // not needed if you have @nuxtjs/i18n installed + trailingSlash: false, + }, + seo: { + redirectToCanonicalSiteUrl: true + }, +}) diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..f4d7aca --- /dev/null +++ b/client/package.json @@ -0,0 +1,29 @@ +{ + "name": "nuxt-app", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare" + }, + "dependencies": { + "@iconify/json": "^2.2.202", + "@nuxt/ui": "^2.15.2", + "@nuxtjs/color-mode": "^3.4.0", + "cheerio": "^1.0.0-rc.12", + "nuxt": "^3.11.2", + "nuxt-icon": "^0.6.10", + "valibot": "^0.30.0", + "vue": "^3.4.21", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@nuxtjs/seo": "^2.0.0-rc.10", + "@nuxtjs/tailwindcss": "^6.12.0", + "nuxt-og-image": "^3.0.0-rc.52", + "tailwindcss": "^3.4.3" + } +} diff --git a/client/pages/authorize/change-password.vue b/client/pages/authorize/change-password.vue new file mode 100644 index 0000000..b8269bf --- /dev/null +++ b/client/pages/authorize/change-password.vue @@ -0,0 +1,184 @@ + + + + + \ No newline at end of file diff --git a/client/pages/authorize/forgot-password.vue b/client/pages/authorize/forgot-password.vue new file mode 100644 index 0000000..dcff481 --- /dev/null +++ b/client/pages/authorize/forgot-password.vue @@ -0,0 +1,108 @@ + + + + + \ No newline at end of file diff --git a/client/pages/authorize/index.vue b/client/pages/authorize/index.vue new file mode 100644 index 0000000..507b853 --- /dev/null +++ b/client/pages/authorize/index.vue @@ -0,0 +1,45 @@ + + + + + \ No newline at end of file diff --git a/client/pages/authorize/login.vue b/client/pages/authorize/login.vue new file mode 100644 index 0000000..bc62f36 --- /dev/null +++ b/client/pages/authorize/login.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/client/pages/authorize/register.vue b/client/pages/authorize/register.vue new file mode 100644 index 0000000..8ea8682 --- /dev/null +++ b/client/pages/authorize/register.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/client/pages/authorize/reset-password.vue b/client/pages/authorize/reset-password.vue new file mode 100644 index 0000000..762bb02 --- /dev/null +++ b/client/pages/authorize/reset-password.vue @@ -0,0 +1,195 @@ + + + + + \ No newline at end of file diff --git a/client/pages/favicon.ico b/client/pages/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3fd9c0b2507f85cb251bd44141f89d181e24b327 GIT binary patch literal 34494 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 bgYOdmv%m~D&GWOE|89vk|ElzVn*#p>)Zza# literal 0 HcmV?d00001 diff --git a/client/pages/index.vue b/client/pages/index.vue new file mode 100644 index 0000000..8269f2d --- /dev/null +++ b/client/pages/index.vue @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/client/public/android-chrome-192x192.png b/client/public/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..dbd46793dffe9fa3a2fce3fde9004a8078888616 GIT binary patch literal 11784 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 P0RE^dX)0F8TLu3=k=d@= literal 0 HcmV?d00001 diff --git a/client/public/apple-touch-icon.png b/client/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..456a45531d2deceac2906cd663d6424daafd7719 GIT binary patch literal 11113 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 literal 0 HcmV?d00001 diff --git a/client/public/favicon copy.ico b/client/public/favicon copy.ico new file mode 100644 index 0000000000000000000000000000000000000000..3fd9c0b2507f85cb251bd44141f89d181e24b327 GIT binary patch literal 34494 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 bgYOdmv%m~D&GWOE|89vk|ElzVn*#p>)Zza# literal 0 HcmV?d00001 diff --git a/client/public/favicon-16x16.png b/client/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..fcc43db9fa76de83a8f6ab4e61b50e50a972233e GIT binary patch literal 840 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 bgYOdmv%m~D&GWOE|89vk|ElzVn*#p>)Zza# literal 0 HcmV?d00001 diff --git a/client/server/tsconfig.json b/client/server/tsconfig.json new file mode 100644 index 0000000..b9ed69c --- /dev/null +++ b/client/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts new file mode 100644 index 0000000..61ea776 --- /dev/null +++ b/client/tailwind.config.ts @@ -0,0 +1,168 @@ +import type { Config } from 'tailwindcss' +import defaultTheme from 'tailwindcss/defaultTheme' + +export default > { + content: [], + theme: { + extend: { + colors: { + twitch: { + 50: '#f6f2ff', + 100: '#eee8ff', + 200: '#dfd4ff', + 300: '#cab1ff', + 400: '#b085ff', + 500: '#9146ff', + 600: '#8d30f7', + 700: '#7f1ee3', + 800: '#6a18bf', + 900: '#57169c', + 950: '#370b6a', + }, + youtube: { + 50: '#fff0f0', + 100: '#ffdddd', + 200: '#ffc0c0', + 300: '#ff9494', + 400: '#ff5757', + 500: '#ff2323', + 600: '#ff0000', + 700: '#d70000', + 800: '#b10303', + 900: '#920a0a', + 950: '#500000', + }, + discord: { + 50: '#eef3ff', + 100: '#e0e9ff', + 200: '#c6d6ff', + 300: '#a4b9fd', + 400: '#8093f9', + 500: '#5865f2', + 600: '#4445e7', + 700: '#3836cc', + 800: '#2f2fa4', + 900: '#2d2f82', + 950: '#1a1a4c', + }, + twitter: { + 50: '#f0f8ff', + 100: '#e0effe', + 200: '#bae0fd', + 300: '#7ec7fb', + 400: '#39abf7', + 500: '#1d9bf0', + 600: '#0372c6', + 700: '#045ba0', + 800: '#084e84', + 900: '#0d416d', + 950: '#082949', + }, + instagram: { + 50: '#fff1f2', + 100: '#ffdfe1', + 200: '#ffc5c9', + 300: '#ff9da5', + 500: '#ff3040', + 600: '#ed1526', + 700: '#c80d1b', + 800: '#a50f1b', + 900: '#88141d', + 950: '#4b0409', + }, + kofi: { + 50: '#fff1f1', + 100: '#ffe2e1', + 200: '#ffc8c7', + 300: '#ffa2a0', + 400: '#ff5e5b', + 500: '#f83e3b', + 600: '#e5211d', + 700: '#c11714', + 800: '#a01714', + 900: '#841a18', + 950: '#480807', + }, + patreon: { + 50: '#fff7ec', + 100: '#ffedd3', + 200: '#ffd8a5', + 300: '#ffbb6d', + 400: '#ff9232', + 500: '#ff730a', + 600: '#ff5900', + 700: '#cc3e02', + 800: '#a1310b', + 900: '#822b0c', + 950: '#461204', + }, + paypal: { + 50: '#ecfbff', + 100: '#d4f4ff', + 200: '#b2eeff', + 300: '#7de7ff', + 400: '#40d5ff', + 500: '#14b7ff', + 600: '#0098ff', + 700: '#0080ff', + 800: '#0070e0', + 900: '#0857a0', + 950: '#0a3561', + }, + spotify: { + 50: '#f0fdf4', + 100: '#dbfde7', + 200: '#b9f9ce', + 300: '#82f3aa', + 400: '#45e37d', + 500: '#1ed760', + 600: '#11a847', + 700: '#11843b', + 800: '#136832', + 900: '#12552c', + 950: '#042f16', + }, + streamelements: { + 50: '#f0f1ff', + 100: '#e4e4ff', + 200: '#cdcfff', + 300: '#a6a6ff', + 400: '#7a73ff', + 500: '#503bff', + 600: '#3a14ff', + 700: '#2700ff', + 800: '#2201d6', + 900: '#1d03af', + 950: '#0d0077', + }, + streamlabs: { + 50: '#eafff7', + 100: '#cdfeea', + 200: '#a0fada', + 300: '#80f5d2', + 400: '#25e2af', + 500: '#00c99a', + 600: '#00a47e', + 700: '#008369', + 800: '#006854', + 900: '#005546', + 950: '#003029', + }, + throne: { + 50: '#ebf2ff', + 100: '#dbe7ff', + 200: '#bed1ff', + 300: '#96b2ff', + 400: '#6d86ff', + 500: '#4b5dff', + 600: '#2b2fff', + 700: '#2e2ee5', + 800: '#1c1eb7', + 900: '#20248f', + 950: '#131453', + }, + } + } + }, + plugins: [] +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..a746f2a --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/docker/BETA_docker-compose.yml b/docker/BETA_docker-compose.yml new file mode 100644 index 0000000..503668c --- /dev/null +++ b/docker/BETA_docker-compose.yml @@ -0,0 +1,33 @@ +services: + auth: + build: + context: .. + dockerfile: dockerfile + image: lordlumineer/eventkit-auth:0.5.0 + volumes: + - /app/eventkit/auth/db:/server/db + ports: + - target: 80 + published: "20102" + restart: always + environment: + ADMIN_EMAIL: $ADMIN_EMAIL + + DATABASE_HOST: $DATABASE_HOST # "192.168.56.1" + DATABASE_PORT: $DATABASE_PORT # "20000" + DATABASE_USERNAME: $DATABASE_USERNAME + DATABASE_PASSWORD: $DATABASE_PASSWORD + + EMAIL_SMTP_SERVER: "smtp-mail.outlook.com" + EMAIL_SMTP_PORT: 587 + EMAIL_ADDRESS: "eventkit@outlook.com" + EMAIL_PASSWORD: $EMAIL_PASSWORD + + JWT_SECRET: $JWT_SECRET + JWT_MAIN_SERVICE_SECRET: $JWT_MAIN_SERVICE_SECRET + JWT_ISSUER: "https://id.eventkit.stream/oauth2" + + TWITCH_CLIENT_ID: $TWITCH_CLIENT_ID + TWITCH_CLIENT_SECRET: $TWITCH_CLIENT_SECRET + GOOGLE_CLIENT_ID: $GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET: $GOOGLE_CLIENT_SECRET \ No newline at end of file diff --git a/docker/LATEST_docker-compose.yml b/docker/LATEST_docker-compose.yml new file mode 100644 index 0000000..71984e5 --- /dev/null +++ b/docker/LATEST_docker-compose.yml @@ -0,0 +1,33 @@ +services: + auth-server: + build: + context: .. + dockerfile: dockerfile + image: lordlumineer/eventkit-auth:1.0.0 + volumes: + - /app/eventkit/auth/db:/server/db + ports: + - target: 80 + published: "20101" + restart: always + environment: + ADMIN_EMAIL: $ADMIN_EMAIL + + DATABASE_HOST: $DATABASE_HOST # "192.168.56.1" + DATABASE_PORT: $DATABASE_PORT # "20000" + DATABASE_USERNAME: $DATABASE_USERNAME + DATABASE_PASSWORD: $DATABASE_PASSWORD + + EMAIL_SMTP_SERVER: "smtp-mail.outlook.com" + EMAIL_SMTP_PORT: 587 + EMAIL_ADDRESS: "eventkit@outlook.com" + EMAIL_PASSWORD: $EMAIL_PASSWORD + + JWT_SECRET: $JWT_SECRET + JWT_MAIN_SERVICE_SECRET: $JWT_MAIN_SERVICE_SECRET + JWT_ISSUER: "https://id.eventkit.stream/oauth2" + + TWITCH_CLIENT_ID: $TWITCH_CLIENT_ID + TWITCH_CLIENT_SECRET: $TWITCH_CLIENT_SECRET + GOOGLE_CLIENT_ID: $GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET: $GOOGLE_CLIENT_SECRET \ No newline at end of file diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..6cb7e92 --- /dev/null +++ b/dockerfile @@ -0,0 +1,29 @@ +# Stage 1: Build the application +FROM node:20.13.1-alpine AS build-stage +WORKDIR /client +COPY ./client/package.json ./ +RUN npm install +COPY ./client . +RUN npx nuxi cleanup +RUN npx nuxi generate + +# Stage 2: Serve the application with Nginx using PM2 +FROM python:3.12.3-alpine AS production-stage +RUN apk add nginx +RUN apk add --update npm +RUN pip install fastapi "uvicorn[standard]" gunicorn +RUN npm install -g pm2 +RUN npm install -g npx serve +COPY ./server/requirements.txt ./ +RUN pip install -r requirements.txt + +WORKDIR /server +COPY ./server . +WORKDIR /client +COPY --from=build-stage /client/.output/public ./.output/public +WORKDIR / +COPY nginx.conf /etc/nginx/nginx.conf + +EXPOSE 80 +COPY ./ecosystem.config.js ./ +CMD ["pm2-runtime", "ecosystem.config.js"] diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..32e035a --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,16 @@ +module.exports = { + apps: [ + { + name: "server-app", + script: "gunicorn -w 4 -k uvicorn.workers.UvicornWorker --chdir ./server main:app -b 0.0.0.0:79" + }, + { + name: "client-app", + script: "npx serve ./client/.output/public -p 81" + }, + { + name: "nginx-app", + script: 'nginx -g "daemon off;"', + } + ] +} \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..c748c7e --- /dev/null +++ b/nginx.conf @@ -0,0 +1,32 @@ +events {} + +http { + server { + listen 80; + + location /authorize/ { + proxy_set_header Host $host; + proxy_pass http://localhost:81/authorize/; + } + + location /_payload.json { + proxy_set_header Host $host; + proxy_pass http://localhost:81/_payload.json; + } + + location /favicon.ico { + proxy_set_header Host $host; + proxy_pass http://localhost:81/favicon.ico; + } + + location /_nuxt/ { + proxy_set_header Host $host; + proxy_pass http://localhost:81/_nuxt/; + } + + location / { + proxy_set_header Host $host; + proxy_pass http://localhost:79; + } + } +} diff --git a/server/.coveragerc b/server/.coveragerc new file mode 100644 index 0000000..044f4db --- /dev/null +++ b/server/.coveragerc @@ -0,0 +1,25 @@ +[run] +branch = True + +[report] +; Regexes for lines to exclude from consideration +exclude_also = + ; Don't complain about missing debug-only code: + def __repr__ + if self\.debug + + ; Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + + ; Don't complain if non-runnable code isn't run: + if 0: + if __name__ == .__main__.: + + ; Don't complain about abstract methods, they aren't run: + @(abc\.)?abstractmethod + +ignore_errors = True + +[html] +directory = htmlcov \ No newline at end of file diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..e4582a7 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,22 @@ +.venv/ +__pycache__/ + +.pytest_cache/ +htmlcov/ + +.coverage +coverage.xml + +.log +!EXAMPLE.log +*users_backup/ +*users_backup/ + + +.vscode/ +.gitignore +.dockerignore +dockerfile + +.pylintrc +.coveragerc \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..6abb150 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,15 @@ +.venv/ +__pycache__/ + +.pytest_cache/ +htmlcov/ + +.coverage +coverage.xml + +secrets/ +.env + +.log +!EXAMPLE.log +*users_backup/ \ No newline at end of file diff --git a/server/.pylintrc b/server/.pylintrc new file mode 100644 index 0000000..7c83997 --- /dev/null +++ b/server/.pylintrc @@ -0,0 +1,641 @@ +[MAIN] + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=9.0 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +init-hook='import sys; sys.path.append(".")' + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.12 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. +#variable-rgx= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + asyncSetUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.BaseException #,builtins.Exception + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=150 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=log + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + use-implicit-booleaness-not-comparison-to-string, + use-implicit-booleaness-not-comparison-to-zero, + W0603, + W0621 + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable= + + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +notes-rgx= + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + +# Let 'consider-using-join' be raised when the separator to join on would be +# non-empty (resulting in expected fixes of the type: ``"- " + " - +# ".join(items)``) +suggest-join-with-non-empty-separator=yes + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= + +# Set the output format. Available formats are: text, parseable, colorized, +# json2 (improved json format), json (old json format) and msvs (visual +# studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +#output-format= + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + +# Minimum lines number of a similarity. +min-similarity-lines=10 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io \ No newline at end of file diff --git a/server/.vscode/sessions.json b/server/.vscode/sessions.json new file mode 100644 index 0000000..eb537bf --- /dev/null +++ b/server/.vscode/sessions.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://cdn.statically.io/gh/nguyenngoclongdev/cdn/main/schema/v10/terminal-keeper.json", + "theme": "default", + "active": "default", + "activateOnStartup": true, + "keepExistingTerminals": false, + "sessions": { + "default": [ + [ + { + "name": ".venv Auth Server", + "autoExecuteCommands": false, + "icon": "server", + "color": "terminal.ansiRed", + "commands": [ + "& 'd:/Desktop/Codding Space/EventKit/Auth-API-DEV/server/.venv/Scripts/Activate.ps1'", + "uvicorn main:app --port 20010 --host 0.0.0.0 --reload" + ], + "joinOperator": ";" + }, + { + "name": ".venv terminal", + "autoExecuteCommands": true, + "icon": "terminal", + "commands": [ + "& 'd:/Desktop/Codding Space/EventKit/Auth-API-DEV/server/.venv/Scripts/Activate.ps1'" + ] + } + ] + ] + } +} \ No newline at end of file diff --git a/server/.vscode/settings.json b/server/.vscode/settings.json new file mode 100644 index 0000000..8e1430b --- /dev/null +++ b/server/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true + }, + "hide-files.files": [] +} \ No newline at end of file diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/__init__.py b/server/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/main.py b/server/api/main.py new file mode 100644 index 0000000..0353257 --- /dev/null +++ b/server/api/main.py @@ -0,0 +1,21 @@ +"""api.MAIN +File: main.py +Author: LordLumineer +Date: 2024-04-24 + +Purpose: This file contains the main API Router that includes all the other routers. +""" +# NOTE: This is where we include all the routers that we have created. + +from fastapi import APIRouter + +from api.routes import login, users, utils, local, twitch, google, admin + +api_router = APIRouter() +api_router.include_router(login.router, prefix="/id", tags=["login"]) +api_router.include_router(local.router, prefix="/local", tags=["local"]) +api_router.include_router(twitch.router, prefix="/twitch", tags=["twitch"]) +api_router.include_router(google.router, prefix="/google", tags=["google"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(admin.router, prefix="/admin", tags=["admin"]) +api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) diff --git a/server/api/routes/__init__.py b/server/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/routes/admin.py b/server/api/routes/admin.py new file mode 100644 index 0000000..de53739 --- /dev/null +++ b/server/api/routes/admin.py @@ -0,0 +1,180 @@ +"""api.routes.ADMIN +File: admin.py +Author: LordLumineer +Date: 2024-05-04 + +Purpose: This file contains the handlers for the admin routes +(ONLY: the docs deportation are defined in the main.py). +""" + +from fastapi.responses import HTMLResponse +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm + +from models import AdminUser +from core.config import settings +from core.db import fetch_admin_user +from core.security import ( + TokenData, + verify_password, + decode_access_token, + Token, + create_access_token, +) + + +router = APIRouter() + +oauth2_scheme_admin = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_STR}/admin/login", + scheme_name="admin", + scopes={"admin": "Admin access"}, + description="Admin access token", + auto_error=True, +) + + +async def get_current_admin_user(token: str = Depends(oauth2_scheme_admin)): + """Get the current admin user from the token. + + Args: + token (str): An access token (without the Bearer part). + + Raises: + HTTPException: HTTP_401_UNAUTHORIZED if the token is invalid. + + Returns: + AdminUser: + username: str + hashed_password: str + """ + try: + claims = await decode_access_token(token) + sub: TokenData = claims["sub"] + user = await fetch_admin_user(sub.username) + if user is None: + raise ValueError("User not found") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "Could not validate credentials", + "error_description": f"Invalid token | {str(e)}", + }, + headers={"WWW-Authenticate": "Bearer"}, + ) from e + return user + + +async def authenticate_admin_user(username: str, password: str): + """From a username and password, authenticate an admin user in the database. + + Args: + username (str): Username of the admin user, used the fetch the user. + password (str): Password of the admin user, used to verify the password + (against the hash saved in the database). + + Raises: + HTTPException: HTTP_401_UNAUTHORIZED If the username or password is incorrect. + + Returns: + AdminUser: + username: str + hashed_password: str + """ + user = await fetch_admin_user(username) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "Incorrect username or password", + "error_description": "The username provided does not exist in the database. Please check and try again.", + }, + ) + if not await verify_password(password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "Incorrect username or password", + "error_description": "The password provided is incorrect. Please check and try again.", + }, + ) + return user + + +@router.post("/login", response_model=Token) +async def login_admin_user(form_data: OAuth2PasswordRequestForm = Depends()): + """Endpoint to login an admin user. + + Args: + form_data (OAuth2PasswordRequestForm): + Use ONLY the username and password (they are currently the only fields supported). + + Raises: + HTTPException: Raise an HTTP_401_UNAUTHORIZED if the username or password is incorrect. + + Returns: + Token: + token_type: str, + access_token: str, + """ + user = await authenticate_admin_user(form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "Incorrect username or password", + "error_description": "The username or password provided is incorrect.", + }, + headers={"WWW-Authenticate": "Bearer"}, + ) + return await create_access_token( + subject=TokenData( + uuid="this_is_an_admin_user", + login_method="admin", + platform_uuid="admin_uuid", + username=user.username, + email="this_an_admin", + ), + expires_delta=15, + ) + + +@router.get("/") +async def get_admin_user(user: AdminUser = Depends(get_current_admin_user)): + """Simple endpoint used to get the current admin user information. + + Headers: + Authorization: Bearer {token} + + Returns: + AdminUser: + username: str, + hashed_password: str, + """ + return user + + +@router.get("/validate-token", status_code=status.HTTP_202_ACCEPTED) +async def validate_admin_token(user: AdminUser = Depends(get_current_admin_user)): + """Simple endpoint to validate the token. + + Headers: + Authorization: Bearer {token} + + Returns: + {"message": "Token is valid"}: + Only if it succeeds to validate the token (Ref. get_current_admin_user). + """ + return {"message": f"Token is valid - {user.username}"} + + +@router.get("/login", include_in_schema=False) +async def get_login(): + """HTML page to login as an admin. + + Returns: + HTML: HTMLResponse with the content of the admin login page. + """ + with open("./assets/html/adminLogin.html", "r", encoding="utf-8") as f: + return HTMLResponse(content=f.read()) diff --git a/server/api/routes/google.py b/server/api/routes/google.py new file mode 100644 index 0000000..4af8fb0 --- /dev/null +++ b/server/api/routes/google.py @@ -0,0 +1,224 @@ +"""api.routes.GOOGLE +File: google.py +Author: LordLumineer +Date: 2024-05-04 + +Purpose: This file contains the handlers for the google AUTH. +""" + +import os +from datetime import datetime, timezone +import requests +from fastapi.responses import HTMLResponse +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status + +from models import FetchedUserInDB, UserInDB +from api.routes.users import get_current_active_user +from core.security import Token, TokenData, create_access_token +from core.config import log +from core.google import ( + link_to_local, + link_to_twitch, + validate_id_token, +) +from core.db import ( + fetch_google_user_by_id, + save_pfp, + create_user, + update_user_by_id, + fetch_google_user_by_email, +) + +router = APIRouter() + + +@router.patch("/callback", response_model=Token) +async def google_landing( + code: str, + scope: str, + nonce: str, + redirect_uri: str, + background_tasks: BackgroundTasks, +): + """The Google Callback endpoint that the "blank" page calls to authenticate the user. + + Args: + code (str): Code from Google + scope (str): Scopes from Google + nonce (str): Nonce to validate the ID token + redirect_uri (str): The redirect URI + background_tasks (BackgroundTasks): background_tasks (to be used to send emails, etc.) + + Raises: + HTTPException: HTTP_409_CONFLICT if the Google Email is already used + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR if the user can't be created + + Returns: + Token: {"access_token": str, "token_type": str} + """ + user_info_data = await validate_id_token( + scope.split(" "), code, redirect_uri, nonce + ) + + google_user = await fetch_google_user_by_email(user_info_data["email"]) + if google_user and (google_user.google_id != user_info_data["sub"]): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "error": "Google Email already used", + "error_description": "You can't use two different Google accounts with the same email address.", + }, + ) + + if google_user := await fetch_google_user_by_id(user_info_data["sub"]): + user_save = google_user + + if ( + google_user.google_username + != user_info_data["name"].lower().replace(" ", "_") + or google_user.full_name != user_info_data["name"] + ): + google_user.google_username = ( + user_info_data["name"].lower().replace(" ", "_") + ) + google_user.full_name = user_info_data["name"] + if ( + google_user.twitch_email_verified != user_info_data["email_verified"] + or google_user.google_email != user_info_data["email"] + ): + google_user.google_email = user_info_data["email"] + google_user.twitch_email_verified = user_info_data["email_verified"] + if twitch_user_token := await link_to_twitch( + user_info_data, google_user + ): + return twitch_user_token + if local_user_token := await link_to_local( + user_info_data, background_tasks, google_user + ): + return local_user_token + + google_user.updated_at = str(int(datetime.now(timezone.utc).timestamp())) + if updated_user := await update_user_by_id(google_user): + user_save = updated_user + else: + log.warning("Failed to update user") + + return await create_access_token( + subject=TokenData( + uuid=user_save.uuid, + login_method=user_save.login_method, + platform_uuid=user_save.google_id, + username=user_save.google_username, + email=user_save.google_email, + ) + ) + + if twitch_user_token := await link_to_twitch(user_info_data): + return twitch_user_token + if local_user_token := await link_to_local(user_info_data, background_tasks): + return local_user_token + + new_google_user = UserInDB( + login_method="google", + full_name=user_info_data["name"], + google_id=user_info_data["sub"], + google_username=user_info_data["name"].lower().replace(" ", "_"), + google_email=user_info_data["email"], + google_email_verified=user_info_data["email_verified"], + updated_at=str(int(datetime.now(timezone.utc).timestamp())), + ) + get_pfp = requests.get(url=user_info_data["picture"], timeout=10).content + filename = f"pfp_{new_google_user.google_username}_{ + int(datetime.now(timezone.utc).timestamp())}_{os.path.basename(user_info_data["picture"])}.png" + new_google_user.picture_id = await save_pfp(filename, get_pfp) + new_user = await create_user(new_google_user) + if not new_user: + log.warning("Failed to create user") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to create user", + "error_description": "Failed to create user", + }, + ) + return await create_access_token( + subject=TokenData( + uuid=new_user.uuid, + login_method=new_user.login_method, + platform_uuid=new_user.google_id, + username=new_user.google_username, + email=new_user.google_email, + ) + ) + + +@router.get("/callback", response_class=HTMLResponse) +async def google_landing_html(): + """The Google Callback "blank" page that the user is redirected to after authenticating with Google. + + Returns: + HTML: The HTML content of the page + """ + html_file = os.path.join("assets/html/google-callback.html") + with open(html_file, "r", encoding="utf-8") as file: + html_content = file.read() + return HTMLResponse(content=html_content, status_code=status.HTTP_200_OK) + + +@router.post("/scopes", response_model=list[str]) +async def post_google_scopes( + scopes: list[str], current_user: FetchedUserInDB = Depends(get_current_active_user) +): + """Update the Google scopes of the user. + + Args: + scopes (list[str]): List of scopes to add to the user. + + Headers: + Authorization: Bearer + + Returns: + list[str]: List of scopes of the user. + """ + for scope in scopes: + if scope not in current_user.google_scope: + current_user.google_scope.append(scope) + updated_user = await update_user_by_id(current_user) + return updated_user.google_scope + + +@router.delete("/scopes", response_model=list[str]) +async def delete_google_scopes( + scopes: list[str], current_user: FetchedUserInDB = Depends(get_current_active_user) +): + """Delete the Google scopes of the user. + + Args: + scopes (list[str]): List of scopes to remove from the user. + + Headers: + Authorization: Bearer + + Returns: + list[str]: List of scopes of the user. + """ + for scope in scopes: + if scope in current_user.google_scope: + current_user.google_scope.remove(scope) + updated_user = await update_user_by_id(current_user) + return updated_user.google_scope + + +@router.get("/scopes", response_model=list[str]) +async def get_google_scopes( + current_user: FetchedUserInDB = Depends(get_current_active_user), +): + """Get the Google scopes of the user. + + Headers: + Authorization: Bearer + + Returns: + list[str]: List of scopes of the user. + """ + return current_user.google_scope diff --git a/server/api/routes/local.py b/server/api/routes/local.py new file mode 100644 index 0000000..d591c69 --- /dev/null +++ b/server/api/routes/local.py @@ -0,0 +1,802 @@ +"""api.routes.LOGIN +File: login.py +Author: LordLumineer +Date: 2024-05-04 + +Purpose: This file contains the handlers for the login routes. +""" + +import os +import re +from asyncio import sleep +from datetime import datetime, timezone +from email_validator import validate_email, EmailNotValidError +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + Form, + HTTPException, + status, + Request, +) +from fastapi.responses import HTMLResponse +from fastapi.security import OAuth2PasswordRequestForm + +from models import FetchedUserInDB, User, UserInDB +from api.routes.users import get_current_active_user +from core.config import settings, log +from core.email import ( + send_reset_password_email, + send_notification_change_password_email, + send_verification_email, +) +from core.security import ( + Token, + TokenData, + decode_access_token, + get_password_hash, + verify_password, + create_access_token, +) +from core.db import ( + create_user, + fetch_user_by_id, + remove_user_by_id, + update_user_by_id, + get_new_local_uuid, + fetch_local_user_by_email, + fetch_local_user_by_name, + fetch_twitch_user_by_email, + fetch_google_user_by_email, +) + + +# ~~~~ Helper Functions ~~~~ # + + +async def is_valid_username(username: str): + """Validate the username format. + It must be at least 5 characters long, and can only contain lowercase letters, numbers, and underscores. + + Args: + username (str): The username to validate. + + Returns: + bool: True if the username is valid, False otherwise. + """ + username_pattern = r"^[a-z0-9_]{5,}$" + return re.match(username_pattern, username) is not None + + +async def is_valid_full_name(full_name: str, username: str): + """Validate the full_name format. + It must match the username (capitalization and replacement of '_' with ' ', '-', '|', '.' are allowed). + + Args: + full_name (str): The full name to validate. + username (str): The username to match the full name with. + """ + + def username_to_fullname_pattern( + username, + ): # Switches underscores in the username with the allowed characters for full_name and Lowercase and Uppercase letters + # Escape any regex special characters in the username + escaped_username = re.escape(username) + # Replace underscores in the username with the allowed characters for full_name, + # and also include the underscore itself + pattern = escaped_username.replace("_", "[_ .|\\-]") + # Create a regex that allows both upper and lower case letters, and also numbers + pattern = re.sub( + r"[a-z]", lambda x: f"[{x.group().lower()}{x.group().upper()}]", pattern + ) + # Full regex that wraps the transformed username pattern + return f"^{pattern}$" + + pattern = username_to_fullname_pattern(username) + return re.match(pattern, full_name) is not None + + +async def email_validation(email: str): + """Email validation function. + + Args: + email (str): The email to validate. + + Returns: + bool: True if the email is valid, False otherwise. + """ + try: + email_info = validate_email(email, check_deliverability=True) + return email_info.normalized + except EmailNotValidError: + return None + + +async def is_password_strong(password: str): + """Password validation function. + + Args: + password (str): The password to validate. + + Raises: + HTTPException: HTTP_404_NOT_FOUND - User not found + HTTPException: HTTP_405_METHOD_NOT_ALLOWED - Email not verified (for login with email) + HTTPException: HTTP_401_UNAUTHORIZED - Incorrect password + + Returns: + bool: True if the password is strong, False otherwise. + """ + password_pattern = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\d\s]).{8,}$" + return re.match(password_pattern, password) is not None + + +async def authenticate_user(username: str, password: str): + """Authenticate the user with the username or email and password. + + Args: + username (str): The username or email of the user. + password (str): The password of the user. + + Raises: + HTTPException: HTTP_404_NOT_FOUND - User not found + HTTPException: HTTP_405_METHOD_NOT_ALLOWED - Email not verified (for login with email) + HTTPException: HTTP_401_UNAUTHORIZED - Incorrect password + + Returns: + FetchedUserInDB: The FULL user object if the authentication is successful. + """ + user = await fetch_local_user_by_name(username) + if not user: + user = await fetch_local_user_by_email(username) + if not user: + log.debug(f"Failed login attempt for {username}, waiting for 2 seconds.") + await sleep(2) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "Unauthorized", + "error_description": str( + { + "error": "User not found", + "error_description": "The user does not exist (not found with username or email).", + } + ), + }, + headers={"WWW-Authenticate": "Bearer"}, + ) + if not user.local_email_verified: + log.debug(f"Failed login attempt for {username}, waiting for 2 seconds.") + await sleep(2) + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail={ + "error": "Unauthorized", + "error_description": str( + { + "error": "Email not verified", + "error_description": "You have to verify your email to be able to login with it.", + } + ), + }, + headers={"WWW-Authenticate": "Bearer"}, + ) + if not await verify_password(password, user.hashed_password): + log.debug(f"Failed login attempt for {username}, waiting for 2 seconds.") + await sleep(2) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "Unauthorized", + "error_description": str( + { + "error": "Incorrect password", + "error_description": "The password is incorrect.", + } + ), + }, + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user + + +last_X_minutes_IPs = [] + + +async def last_x_minutes_ip_check(host_ip: str): + """Check if the IP has made too many requests in the last minutes. + - If the IP has made more than 5 requests in the last 10 minutes, + -> wait for 2 seconds before continuing. + - If the IP has made more than 20 requests in the last 10 minutes, + -> raise an HTTPException with status code 429. + + Args: + host_ip (str): The IP address of the host. + + Raises: + HTTPException: HTTP_429_TOO_MANY_REQUESTS - Too many requests + """ + exception_ip = [ + "127.0.0.1", + "localhost", + "192.168.11.253", + "192.168.11.254", + ] # NOTE remove or Update the exception IP + if host_ip in exception_ip: + log.debug(f"IP {host_ip} is in the exception list. No rate limiting.") + return + + now = datetime.now(timezone.utc) + time_limit = settings.LOGIN_ATTEMPTS_TIME + wait_limit = settings.LOGIN_ATTEMPTS_WAIT + error_limit = settings.LOGIN_ATTEMPTS_LIMIT + for i, ip in enumerate(last_X_minutes_IPs): + if (now - ip["time"]).seconds > time_limit * 60: + last_X_minutes_IPs.pop(i) + counter = 0 + for ip in last_X_minutes_IPs: + if ip["ip"] == host_ip: + counter += 1 + if counter > error_limit: + log.error( + f"Too many requests from {host_ip} in the last {time_limit} minutes. Blocking IP temporarily." + ) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail={ + "error": "Too many requests", + "error_description": f"Too many requests, please try again later (in {time_limit} minutes).", + }, + ) + if counter > wait_limit: + log.warning( + f"Too many requests from {host_ip} in the last {time_limit} minutes. Waiting for 2 seconds." + ) + await sleep(2) + last_X_minutes_IPs.append({"ip": host_ip, "time": now}) + return + + +# ~~~~ ROUTE ~~~~ # + +router = APIRouter() + + +@router.post("/register", response_model=Token) +async def register_user( + request: Request, + background_tasks: BackgroundTasks, + username: str = Form(...), + email: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + """Register a new user. + + Args: + username (str): Username of the user. + password (str): Password of the user. + confirm_password (str): Password confirmation. + email (str): Email of the user. + + Raises: + HTTPException: HTTP_400_BAD_REQUEST - Passwords do not match or Invalid email + HTTPException: HTTP_412_PRECONDITION_FAILED - Invalid username or password format + HTTPException: HTTP_406_NOT_ACCEPTABLE - Username or email already taken + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - Failed to create user + + Returns: + Token: {"access_token": str, "token_type": str} + """ + await last_x_minutes_ip_check(request.client.host) + + if password != confirm_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Passwords do not match", + "error_description": "The passwords do not match.", + }, + ) + + if not await is_valid_username(username): + raise HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail={ + "error": "Invalid username", + "error_description": "The username must be all lowercases and at least 5 characters long ('_' is accepted).", + }, + ) + if not await is_password_strong(password): + raise HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail={ + "error": "Invalid password", + "error_description": "The password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.", + }, + ) + + email = await email_validation(email) + if not email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Invalid email", + "error_description": "The email is not valid. Please provide a valid email address.", + }, + ) + + if await fetch_local_user_by_name(username): + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, + detail={ + "error": "Invalid username", + "error_description": "The username is already taken. Please choose another one.", + }, + ) + + if await fetch_local_user_by_email(email): + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, + detail={ + "error": "Invalid email", + "error_description": "The email is already taken. Please choose another one.", + }, + ) + + new_local_id = await get_new_local_uuid() + new_user = UserInDB( + disabled=False, + updated_at=str(int(datetime.now(timezone.utc).timestamp())), + login_method="local", + full_name=username, + local_id=new_local_id, + local_username=username, + local_email=email, + local_email_verified=False, + hashed_password=await get_password_hash(password), + ) + new_user = await create_user(new_user) + if not new_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to create user", + "error_description": "Failed to create user.", + }, + ) + background_tasks.add_task(send_verification_email, new_user) + # background_tasks.add_task(send_new_account_email, newUser) # NOTE: Uncomment this line to send an email to the user + return await create_access_token( + subject=TokenData( + uuid=new_user.uuid, + login_method=new_user.login_method, + platform_uuid=new_user.local_id, + username=new_user.local_username, + email=new_user.local_email, + ) + ) + + +@router.get("/verify-email", response_class=HTMLResponse) +async def verify_email_html(): + """Return the HTML content for the email verification callback. + + Returns: + HTMLResponse: HTML content for the email verification callback. + """ + html_file = os.path.join("assets/html/email-verification-callback.html") + with open(html_file, "r", encoding="utf-8") as file: + html_content = file.read() + return HTMLResponse(content=html_content, status_code=status.HTTP_200_OK) + + +@router.patch("/verify-email") +async def verify_email(token: str): + """Verify the email of the user. It's called from the "black" email verification page. + It checks if the user is a Twitch or Google user, and if so, checks to link them together. + + Args: + token (str): The token to verify the email. + + Raises: + HTTPException: HTTP_404_NOT_FOUND - User not found + HTTPException: HTTP_412_PRECONDITION_FAILED - Email verification failed (you need to have emails verified on both sides of the accounts you want to link) + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - Failed to update user + + Returns: + Response: {"message": ""} + """ + claims = await decode_access_token(token) + sub: TokenData = claims["sub"] + user = await fetch_user_by_id(sub.uuid) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "User not found", + "error_description": "The user does not exist.", + }, + ) + is_twitch_user = await fetch_twitch_user_by_email(user.local_email) + if is_twitch_user: + if not is_twitch_user.twitch_email_verified: + raise HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail={ + "error": "Email not verified", + "error_description": "You need to have your email verified on twitch also.", + }, + ) + is_twitch_user.local_id = user.local_id + is_twitch_user.local_username = user.local_username + is_twitch_user.local_email = user.local_email + is_twitch_user.local_email_verified = True + is_twitch_user.hashed_password = user.hashed_password + is_twitch_user.updated_at = str(int(datetime.now(timezone.utc).timestamp())) + updated_user = await update_user_by_id(is_twitch_user) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to update user", + "error_description": "Failed to update user.", + }, + ) + if not await remove_user_by_id(user.uuid): + log.warning(f"Failed to remove user {user.uuid} after linking with Twitch.") + return {"message": "Account successfully linked with Twitch."} + + is_google_user = await fetch_google_user_by_email(user.local_email) + if is_google_user: + if not is_google_user.google_email_verified: + raise HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail={ + "error": "Email not verified", + "error_description": "You need to have your email verified on google also.", + }, + ) + is_google_user.local_id = user.local_id + is_google_user.local_username = user.local_username + is_google_user.local_email = user.local_email + is_google_user.local_email_verified = True + is_google_user.hashed_password = user.hashed_password + is_google_user.updated_at = str(int(datetime.now(timezone.utc).timestamp())) + updated_user = await update_user_by_id(is_google_user) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to update user", + "error_description": "Failed to update user.", + }, + ) + if not await remove_user_by_id(user.uuid): + log.warning(f"Failed to remove user {user.uuid} after linking with Google.") + return {"message": "Account successfully linked with Google."} + + user.local_email_verified = True + updated_user = await update_user_by_id(user) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to update user", + "error_description": "Failed to update user.", + }, + ) + return {"message": "Email verified successfully."} + + +@router.post("/login", response_model=Token) +async def login_for_access_token( + request: Request, form_data: OAuth2PasswordRequestForm = Depends() +): + """Login the user and return an access token. + + Args: + form_data (OAuth2PasswordRequestForm): + Use ONLY the username and password (they are currently the only fields supported). + + Returns: + Token: {"access_token": str, "token_type": str} + """ + await last_x_minutes_ip_check(request.client.host) + + user = await authenticate_user(form_data.username, form_data.password) + + return await create_access_token( + subject=TokenData( + uuid=user.uuid, + login_method=user.login_method, + platform_uuid=user.local_id, + username=user.local_username, + email=user.local_email, + ) + ) + + +@router.patch("/me/password") +async def patch_password( + background_tasks: BackgroundTasks, + current_user: FetchedUserInDB = Depends(get_current_active_user), + current_pwd: str = Form(...), + new_pwd: str = Form(...), + confirm_pwd: str = Form(...), +): + """Update the password of the user. + + Args: + current_pwd (str): The current password of the user. + new_pwd (str): The new password of the user. + confirm_pwd (str): The confirmation of the new password. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_400_BAD_REQUEST - Incorrect Password(s) + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - Failed to update password + + Returns: + Response: {"message": "Password updated successfully"} + """ + try: + assert await is_password_strong(new_pwd), "Password is not strong enough" + assert await verify_password( + current_pwd, current_user.hashed_password + ), "Incorrect Password" + assert ( + new_pwd != current_pwd + ), "New password cannot be the same as the current password" + assert new_pwd == confirm_pwd, "Passwords do not match" + except AssertionError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "Failed to update password", "error_description": str(e)}, + ) from e + + current_user.hashed_password = await get_password_hash(new_pwd) + current_user.updated_at = str(int(datetime.now(timezone.utc).timestamp())) + updated_user = await update_user_by_id(current_user) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to update password", + "error_description": "Unable to update the database", + }, + ) + background_tasks.add_task(send_notification_change_password_email, current_user) + return {"message": "Password updated successfully"} + + +@router.patch("/me", response_model=User) +async def patch_user( + background_tasks: BackgroundTasks, + current_user: FetchedUserInDB = Depends(get_current_active_user), + full_name: str = Form(None), + username: str = Form(None), + email: str = Form(None), +): + """_summary_ + + Args: + full_name (str, optional): The new full name of the user. (will be checked against the username format) + username (str, optional): The new username of the user. (if no new full name is provided, it will reset it to the username) + email (str, optional): The new email of the user. (a verification email will be sent if the email is different from the current one) + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_423_LOCKED - You cannot update the user information if you are connected through a third-party service. + HTTPException: HTTP_412_PRECONDITION_FAILED - Invalid username or fullname format + HTTPException: HTTP_406_NOT_ACCEPTABLE - Username already exists + HTTPException: HTTP_400_BAD_REQUEST - Invalid email + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - Failed to update user + + Returns: + User: The full user object after the update. + """ + if not current_user.login_method == "local": + log.debug(f"User {current_user.username} is not a local user") + raise HTTPException( + status_code=status.HTTP_423_LOCKED, + detail={ + "error": "Failed to update user", + "error_description": "You cannot update the user information if you are connected through a third-party service.", + }, + ) + + if username: + if not await is_valid_username(username): + raise HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail={ + "error": "Failed to update user", + "error_description": "Invalid username, must be alphanumeric and contain at least 5 characters ('_' is accepted).", + }, + ) + existing_user = await fetch_local_user_by_name(username) + if existing_user and (existing_user.uuid != current_user.uuid): + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, + detail={ + "error": "Failed to update user", + "error_description": "Username already exists", + }, + ) + current_user.local_username = username + current_user.full_name = username + + if full_name: + if not await is_valid_full_name(full_name, current_user.local_username): + raise HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail={ + "error": "Failed to update user", + "error_description": "Invalid Full Name, it must match the username (capitalization and replacement of '_' with ' ', '-', '|', '.' are allowed)", + }, + ) + current_user.full_name = full_name + + if email: + try: + email_info = validate_email(email, check_deliverability=True) + email = email_info.normalized + except EmailNotValidError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Failed to update user", + "error_description": str(e), + }, + ) from e + if email != current_user.local_email: + current_user.local_email_verified = False + background_tasks.add_task(send_verification_email, current_user) + current_user.local_email = email + + current_user.updated_at = str(int(datetime.now(timezone.utc).timestamp())) + + updated_user = await update_user_by_id(current_user) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to update user", + "error_description": "Failed to update user in the database.", + }, + ) + return updated_user + + +@router.post("/recover-password") +async def recover_password( + background_tasks: BackgroundTasks, username: str = Form(...), email: str = Form(...) +): + """Request for a password recovery email. + + Args: + username (str): The username of the user. + email (str): The email of the user. + + Raises: + HTTPException: HTTP_404_NOT_FOUND - User not found + HTTPException: HTTP_401_UNAUTHORIZED - Invalid username or email (or unverified email) + + Returns: + Response: {"message": "Password recovery email sent"} + """ + user = await fetch_local_user_by_name(username) + if not user: + user = await fetch_local_user_by_email(email) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "User not found", + "error_description": "The user does not exist (not found with username or email).", + }, + ) + if user.local_username != username: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "Invalid username", + "error_description": "The usernames do not match.", + }, + ) + if user.local_email != email: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "Invalid email", + "error_description": "The emails do not match.", + }, + ) + if not user.local_email_verified: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "Email not verified", + "error_description": "You have to verify your email to be able to recover your password.", + }, + ) + background_tasks.add_task(send_reset_password_email, user) + return {"message": "Password recovery email sent"} + + +@router.post("/reset-password") +async def reset_password( + background_tasks: BackgroundTasks, + token: str = Form(...), + username: str = Form(...), + email: str = Form(...), + new_password: str = Form(...), + confirm_password: str = Form(...), +): + """Reset the password of the user. + + Args: + token (str): the token to reset the password + username (str): the username of the user + email (str): the email of the user + new_password (str): the new password + confirm_password (str): the confirmation of the new password + background_tasks (): + + Raises: + HTTPException: HTTP_404_NOT_FOUND - User not found + HTTPException: HTTP_400_BAD_REQUEST - Failed to reset password + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - Failed to reset password + + Returns: + Response: {"message": "Password reset successful"} + """ + try: + claims = await decode_access_token(token) + sub: TokenData = claims["sub"] + assert sub.username == username, "Invalid username" + assert sub.email == email, "Invalid email" + user = await fetch_user_by_id(sub.uuid) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "User not found", + "error_description": "The user does not exist.", + }, + ) + assert user.local_username == username, "Invalid username" + assert user.local_email == email, "Invalid email" + assert await is_password_strong(new_password), "Password is not strong enough" + assert new_password == confirm_password, "Passwords do not match" + assert await verify_password( + new_password, user.hashed_password + ), "New password cannot be the same as the current password" + except AssertionError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Failed to reset password", + "error_description": str(e), + }, + ) from e + + user.hashed_password = await get_password_hash(new_password) + user.updated_at = str(int(datetime.now(timezone.utc).timestamp())) + updated_user = await update_user_by_id(user) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to reset password", + "error_description": "Failed to update the database", + }, + ) + background_tasks.add_task(send_notification_change_password_email, user) + return {"message": "Password reset successful"} diff --git a/server/api/routes/login.py b/server/api/routes/login.py new file mode 100644 index 0000000..a9d167c --- /dev/null +++ b/server/api/routes/login.py @@ -0,0 +1,75 @@ +"""api.routes.LOGIN +File: login.py +Author: LordLumineer +Date: 2024-05-04 + +Purpose: This file contains the handlers for the login routes. + +NOTE: OPTIONAL: /authorize (fused login and signup page) +""" + +from jinja2 import Template +from fastapi import APIRouter, Depends, status +from fastapi.responses import HTMLResponse + +from core.config import settings +from models import FetchedUserInDB +from api.routes.users import get_current_active_user + + +router = APIRouter() + + +@router.get("/validate-token", status_code=status.HTTP_202_ACCEPTED) +async def validate_token( + current_user: FetchedUserInDB = Depends(get_current_active_user), +): + """Simple endpoint to validate the token. + + Headers: + Authorization: Bearer {token} + + Returns: + Response: {"message": "Token is valid"} if the token is valid. + """ + return {"message": f"Token is valid - {current_user.uuid}"} + + +@router.get("/authorize/login") +async def login_page(redirect_uri: str = None, state: str = None): + """Renders the login page. + + Args: + redirect_uri (str, optional): Page to redirect once logged Up. + state (str, optional): State to pass to the redirect_uri. + + Returns: + HTMLResponse: The login page. + """ + with open("./assets/html/template/login.html", encoding="utf-8") as f: + template = Template(f.read()) + context = { + "API_STR": settings.API_STR, + } + html_content = template.render(context) + return HTMLResponse(content=html_content, status_code=status.HTTP_200_OK) + + +@router.get("/authorize/register") +async def register_page(redirect_uri: str = None, state: str = None): + """Renders the register page. + + Args: + redirect_uri (str, optional): Page to redirect once Signed Up. + state (str, optional): State to pass to the redirect_uri. + + Returns: + HTMLResponse: The register page. + """ + with open("./assets/html/template/register.html", encoding="utf-8") as f: + template = Template(f.read()) + context = { + "API_STR": settings.API_STR, + } + html_content = template.render(context) + return HTMLResponse(content=html_content, status_code=status.HTTP_200_OK) diff --git a/server/api/routes/twitch.py b/server/api/routes/twitch.py new file mode 100644 index 0000000..c8366af --- /dev/null +++ b/server/api/routes/twitch.py @@ -0,0 +1,219 @@ +"""api.routes.TWITCH +File: twitch.py +Author: LordLumineer +Date: 2024-05-03 + +Purpose: This file contains the handlers for the twitch AUTH. +""" + +import os +from datetime import datetime, timezone +import requests +from fastapi.responses import HTMLResponse +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status + +from models import FetchedUserInDB, UserInDB +from api.routes.users import get_current_active_user +from core.config import log +from core.twitch import link_to_google, link_to_local, validate_id_token +from core.security import Token, TokenData, create_access_token +from core.db import ( + fetch_twitch_user_by_id, + save_pfp, + create_user, + update_user_by_id, + fetch_twitch_user_by_email, +) + +router = APIRouter() + + +@router.patch("/callback", response_model=Token) +async def twitch_landing( + code: str, + scope: str, + nonce: str, + redirect_uri: str, + background_tasks: BackgroundTasks, +): + """The Twitch Callback endpoint that the "blank" page calls to authenticate the user. + + Args: + code (str): Code returned by Twitch + scope (str): scope returned by Twitch + nonce (str): String to verify the ID token + redirect_uri (str): the redirect uri used to authenticate with Twitch + background_tasks (BackgroundTasks): _description_ + + + Raises: + HTTPException: HTTP_409_CONFLICT - if the Google Email is already used. + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - if the user can't be created. + + Returns: + Token: {"access_token": str, "token_type": str} + """ + + user_info_data = await validate_id_token( + scope.split(" "), code, redirect_uri, nonce + ) + + twitch_user = await fetch_twitch_user_by_email(user_info_data["email"]) + if twitch_user and (twitch_user.twitch_id != user_info_data["sub"]): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "error": "Twitch Email already used", + "error_description": "You can't use two different Twitch accounts with the same email address.", + }, + ) + + if twitch_user := await fetch_twitch_user_by_id(user_info_data["sub"]): + user_save = twitch_user + + if ( + twitch_user.twitch_username != user_info_data["preferred_username"].lower() + or twitch_user.full_name != user_info_data["preferred_username"] + ): + twitch_user.twitch_username = user_info_data["preferred_username"].lower() + twitch_user.full_name = user_info_data["preferred_username"] + if ( + twitch_user.twitch_email_verified != user_info_data["email_verified"] + or twitch_user.twitch_email != user_info_data["email"] + ): + twitch_user.twitch_email_verified = user_info_data["email_verified"] + twitch_user.twitch_email = user_info_data["email"] + if google_user_token := await link_to_google( + user_info_data, twitch_user + ): + return google_user_token + if local_user_token := await link_to_local( + user_info_data, background_tasks, twitch_user + ): + return local_user_token + + twitch_user.updated_at = str(int(datetime.now(timezone.utc).timestamp())) + if updated_user := await update_user_by_id(twitch_user): + user_save = updated_user + else: + log.warning("Failed to update user") + + return await create_access_token( + subject=TokenData( + uuid=user_save.uuid, + login_method=user_save.login_method, + platform_uuid=user_save.twitch_id, + username=user_save.twitch_username, + email=user_save.twitch_email, + ) + ) + + if google_user_token := await link_to_google(user_info_data): + return google_user_token + + if local_user_token := await link_to_local(user_info_data, background_tasks): + return local_user_token + + new_twitch_user = UserInDB( + login_method="twitch", + full_name=user_info_data["preferred_username"], + twitch_id=user_info_data["sub"], + twitch_username=user_info_data["preferred_username"].lower(), + twitch_email=user_info_data["email"], + twitch_email_verified=user_info_data["email_verified"], + updated_at=str(int(datetime.now(timezone.utc).timestamp())), + ) + get_pfp = requests.get(url=user_info_data["picture"], timeout=10).content + filename = f"pfp_{new_twitch_user.twitch_username}_{int(datetime.now(timezone.utc).timestamp())}_{os.path.basename(user_info_data["picture"])}" + new_twitch_user.picture_id = await save_pfp(filename, get_pfp) + new_user = await create_user(new_twitch_user) + if not new_user: + log.warning("Failed to create user") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to create user", + "error_description": "Failed to create user", + }, + ) + return await create_access_token( + subject=TokenData( + uuid=new_user.uuid, + login_method=new_user.login_method, + platform_uuid=new_user.twitch_id, + username=new_user.twitch_username, + email=new_user.twitch_email, + ) + ) + + +@router.get("/callback", response_class=HTMLResponse) +async def twitch_landing_html(): + """The Twitch Callback "blank" page that the user is redirected to after authenticating with Twitch. + + Returns: + HTML: The HTML content of the page + """ + html_file = os.path.join("assets/html/twitch-callback.html") + with open(html_file, "r", encoding="utf-8") as file: + html_content = file.read() + return HTMLResponse(content=html_content, status_code=status.HTTP_200_OK) + + +@router.post("/scopes", response_model=list[str]) +async def post_twitch_scopes( + scopes: list[str], current_user: FetchedUserInDB = Depends(get_current_active_user) +): + """Update the Twitch scopes of the user. + + Args: + scopes (list[str]): List of scopes to add to the user. + + Headers: + Authorization: Bearer + + Returns: + list[str]: List of scopes of the user. + """ + for scope in scopes: + if scope not in current_user.twitch_scope: + current_user.twitch_scope.append(scope) + updated_user = await update_user_by_id(current_user) + return updated_user.twitch_scope + + +@router.delete("/scopes", response_model=list[str]) +async def delete_twitch_scopes( + scopes: list[str], current_user: FetchedUserInDB = Depends(get_current_active_user) +): + """Delete the Twitch scopes of the user. + + Args: + scopes (list[str]): List of scopes to remove from the user. + + Headers: + Authorization: Bearer + + Returns: + list[str]: List of scopes of the user. + """ + for scope in scopes: + if scope in current_user.twitch_scope: + current_user.twitch_scope.remove(scope) + updated_user = await update_user_by_id(current_user) + return updated_user.twitch_scope + + +@router.get("/scopes", response_model=list[str]) +async def get_twitch_scopes( + current_user: FetchedUserInDB = Depends(get_current_active_user), +): + """Get the Twitch scopes of the user. + + Headers: + Authorization: Bearer + + Returns: + list[str]: List of scopes of the user. + """ + return current_user.twitch_scope diff --git a/server/api/routes/users.py b/server/api/routes/users.py new file mode 100644 index 0000000..c18b43f --- /dev/null +++ b/server/api/routes/users.py @@ -0,0 +1,333 @@ +"""api.routes.USERS +File: users.py +Author: LordLumineer +Date: 2024-05-04 + +Purpose: This file contains the handlers for the user routes. +""" + +import io +from datetime import datetime, timezone +from PIL import Image +from fastapi import ( + APIRouter, + Depends, + File, + HTTPException, + Response, + UploadFile, + status, +) + +from models import FetchedUserInDB, User +from core.config import settings +from core.security import oauth2_scheme_local, decode_access_token, TokenData +from core.db import ( + fetch_user_by_id, + remove_user_by_id, + update_user_by_id, + save_pfp, + fetch_pfp, + remove_pfp, +) + + +# ~~~~ Helper Functions ~~~~ # + + +async def get_current_user(token: str = Depends(oauth2_scheme_local)): + """Get the current user from the token. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_401_UNAUTHORIZED - if the token is invalid. + + Returns: + FetchedUserInDB: The full user object. + """ + try: + claims = await decode_access_token(token) + sub: TokenData = claims["sub"] + user = await fetch_user_by_id(sub.uuid) + if user is None: + raise Exception("User not found") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "Could not validate credentials", + "error_description": f"Invalid token: {e}", + }, + headers={"WWW-Authenticate": "Bearer"}, + ) from e + return user + + +async def get_current_active_user(current_user: User = Depends(get_current_user)): + """Filter the current user to check if it is active (if not disabled). + + Args: + current_user (User): The current user object. + + Raises: + HTTPException: HTTP_423_LOCKED - if the user is disabled. + + Returns: + User: The small user object. + """ + if current_user.disabled: + raise HTTPException( + status_code=status.HTTP_423_LOCKED, + detail={"error": "Inactive user", "error_description": "User is Disabled"}, + ) + return current_user + + +# ~~~~ ROUTE ~~~~ # + +router = APIRouter() + + +@router.get("/me", response_model=User) +async def get_user( + current_user: FetchedUserInDB = Depends(get_current_active_user), +): + """Simple GET request to get the current user. + + Headers: + Authorization: Bearer {token} + + Returns: + User: The current user object. (small object) + """ + return current_user + + +@router.delete("/me") +async def delete_user( + current_user: FetchedUserInDB = Depends(get_current_active_user), +): + """Delete the current user. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - if the user could not be deleted. + + Returns: + Response: {"message": "User deleted successfully"} + """ + if not await remove_user_by_id(current_user.uuid): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to delete user", + "error_description": "Failed to delete user from database", + }, + ) + return {"message": "User deleted successfully"} + + +@router.post("/pfp") +async def upload_profile_picture( + file: UploadFile = File(...), + current_user: FetchedUserInDB = Depends(get_current_active_user), +): + """Upload a profile picture for the user. + + Args: + file (UploadFile): The image file to upload. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_413_REQUEST_ENTITY_TOO_LARGE - if the image is too large. + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - if the image could not be saved. + + Returns: + User: Small user object. + """ + if file.size > settings.MAX_IMAGE_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail={ + "error": "Image too large", + "error_description": "Image size must be less than 512KB", + }, + ) + name_to_use = "pfp" + match current_user.login_method: + case "google": + name_to_use = current_user.google_username + case "twitch": + name_to_use = current_user.twitch_username + case "local": + name_to_use = current_user.full_name + case _: + name_to_use = current_user.full_name.lower().replace(" ", "_").replace(".", "_").replace("-", "_").replace("|", "_") + file.filename = f"pfp_{name_to_use}_{int(datetime.now(timezone.utc).timestamp())}_{file.filename}" + image_data = await file.read() + image = Image.open(io.BytesIO(image_data)) + + # Convert image to the format that can be stored in MongoDB + img_byte_arr = io.BytesIO() + image.save(img_byte_arr, format=image.format) + img_byte_arr = img_byte_arr.getvalue() + # Store in MongoDB + if current_user.picture_id: + image_id = await save_pfp(file.filename, img_byte_arr, current_user.picture_id) + if image_id: + return current_user + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to update image", + "error_description": "Failed to update image in database", + }, + ) + image_id = await save_pfp(file.filename, img_byte_arr) + if not image_id: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to save image", + "error_description": "Failed to save image in database", + }, + ) + + current_user.picture_id = image_id + updated_user = await update_user_by_id(current_user) + updated_user = User(**updated_user.model_dump()) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to update user", + "error_description": "Failed to update user in database", + }, + ) + return updated_user + + +@router.patch("/pfp") +async def update_profile_picture( + file: UploadFile = File(...), current_user: User = Depends(get_current_active_user) +): + """Update the profile picture of the user. + + Args: + file (UploadFile): The image file to upload. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - if the image could not be updated. + + Returns: + User: Small user object. + """ + name_to_use = "pfp" + match current_user.login_method: + case "google": + name_to_use = current_user.google_username + case "twitch": + name_to_use = current_user.twitch_username + case "local": + name_to_use = current_user.full_name + case _: + name_to_use = current_user.full_name.lower().replace(" ", "_").replace(".", "_").replace("-", "_").replace("|", "_") + file.filename = f"pfp_{name_to_use}_{int(datetime.now(timezone.utc).timestamp())}_{file.filename}" + image_data = await file.read() + image = Image.open(io.BytesIO(image_data)) + + # Convert image to the format that can be stored in MongoDB + img_byte_arr = io.BytesIO() + image.save(img_byte_arr, format=image.format) + img_byte_arr = img_byte_arr.getvalue() + + # Update in MongoDB + image_id = await save_pfp(file.filename, img_byte_arr, current_user.picture_id) + if image_id: + return current_user + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to update image", + "error_description": "Failed to update image in database", + }, + ) + + +@router.get("/pfp") +async def get_profile_picture( + current_user: FetchedUserInDB = Depends(get_current_active_user), +): + """Get the profile picture of the user. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_404_NOT_FOUND - if the image is not found. + + Returns: + image/png: The image file. + """ + image_data = await fetch_pfp(current_user.picture_id) + if image_data is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "Image not found", + "error_description": "Image not found.", + }, + ) + + img = Image.open(io.BytesIO(image_data["image"])) + img_byte_arr = io.BytesIO() + img.save(img_byte_arr, format="PNG") # Convert to PNG or appropriate format + img_byte_arr = img_byte_arr.getvalue() + + return Response( + content=img_byte_arr, media_type="image/png", status_code=status.HTTP_200_OK + ) + + +@router.delete("/pfp") +async def delete_profile_picture(current_user: User = Depends(get_current_active_user)): + """Delete the profile picture of the user. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - if the image could not be deleted. or Update the user. + + Returns: + User: Small user object. + """ + result = await remove_pfp(current_user.picture_id) + if not result: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to delete image", + "error_description": "Failed to delete image from database", + }, + ) + current_user.picture_id = None + updated_user = await update_user_by_id(current_user) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to update user", + "error_description": "Failed to update user in database", + }, + ) + return updated_user diff --git a/server/api/routes/utils.py b/server/api/routes/utils.py new file mode 100644 index 0000000..550dfd0 --- /dev/null +++ b/server/api/routes/utils.py @@ -0,0 +1,450 @@ +"""api.routes.UTILS +File: utils.py +Author: LordLumineer +Date: 2024-05-04 + +Purpose: This file contains the handlers for the utility routes (these are endpoints that require to be an administrator to use). +""" + +import io +from PIL import Image +from fastapi import ( + APIRouter, + Depends, + File, + HTTPException, + Response, + UploadFile, + status, +) + +from api.routes.admin import get_current_admin_user +from models import AdminUser, FetchedUserInDB, UserInDB +from core.config import settings, log +from core.email import send_email_all, send_test_email +from core.security import ( + TokenData, + create_access_token, + decode_access_token, + get_password_hash, +) +from core.db import ( + create_user, + fetch_all_emails, + fetch_user_by_id, + remove_user_by_id, + save_pfp, + fetch_pfp, + remove_pfp, + update_user_by_id, +) + +router = APIRouter() + + +@router.get("/sent-test-email") +async def sent_email(admin: AdminUser = Depends(get_current_admin_user)): + """Endpoint to call to send a test email to the admin email. + + Headers: + Authorization: Bearer {token} + + Returns: + {"message": "Test email sent."} + """ + await send_test_email(settings.ADMIN_EMAIL) + return {"message": f"Test email sent - {admin.username} -> {settings.ADMIN_EMAIL}"} + + +@router.post("/send-global-email") +async def send_global_email( + subject: str, + content_html_file: UploadFile = File(...), + admin: AdminUser = Depends(get_current_admin_user), +): + """Endpoint to use to send a global email to all users. + + Args: + subject (str): Subject of the email. + content_html_file (UploadFile): It's the body of the email. + + Headers: + Authorization: Bearer {token} + + Returns: + list: Returns a list of all the emails that the email was sent to. + """ + content = await content_html_file.read() + content = content.decode("utf-8") + log.critical(f"ADMIN: {admin} - Sending global email.") + emails = await fetch_all_emails() + email_str = "+".join(emails) + await send_email_all(emails, subject, content) + return email_str + + +@router.get("/pwd-hash") +async def pwd_user(pwd: str, admin: AdminUser = Depends(get_current_admin_user)): + """Endpoint to create a password hash, especially useful when forcefully changing a password in the database is needed. + + Args: + pwd (str): The password to hash. + + Headers: + Authorization: Bearer {token} + + Returns: + str: The hashed password. + """ + log.warning(f"ADMIN: {admin} - Creating Password Hash.") + return await get_password_hash(pwd) + + +@router.post("/jwt-token") +async def jwt_token( + subject: TokenData, + expires_delta: int, + secret: str, + admin: AdminUser = Depends(get_current_admin_user), +): + """Similarity to the password hash, this endpoint is used to create a JWT token. + + Args: + subject (TokenData): a Pydantic model that contains the data to be stored in the token. + expires_delta (int): the expiration time of the token (in minutes). + secret (str): The secret to use to sign the token. + + Headers: + Authorization: Bearer {token} + + Returns: + Token: {"token_type": str, "access_token": str} + """ + log.warning(f"ADMIN: {admin} - Creating Password Hash.") + return await create_access_token(subject, expires_delta, secret) + + +# ~~ User Handling ~~ + + +async def validation_of_user(uuid: str, token: str): + """Function to validate the user data from a uuid and a token. + + Args: + uuid (str): Id of the user. + token (str): access token related to the user. + + Raises: + HTTPException: HTTP_404_NOT_FOUND - if the user is not found. + + Returns: + FetchedUserInDB: The most COMPLETE user data (ID, default user data, local, twitch and google user data). + """ + claims = await decode_access_token(token, settings.JWT_MAIN_SERVICE_SECRET) + sub: TokenData = claims["sub"] + if sub.uuid != uuid: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "User not found", + "error_description": "Invalid token data. Incorrect ID.", + }, + ) + user = await fetch_user_by_id(sub.uuid) + try: + assert user is not None, "User not found." + assert user.uuid == sub.uuid, "User ID mismatch." + assert user.login_method == sub.login_method, "Login method mismatch." + match sub.login_method: + case "local": + assert user.local_id == sub.platform_uuid, "Platform UUID mismatch." + assert user.local_email == sub.email, "Email mismatch." + assert user.local_username == sub.username, "Username mismatch." + case "twitch": + assert user.twitch_id == sub.platform_uuid, "Platform UUID mismatch." + assert user.twitch_email == sub.email, "Email mismatch." + assert user.twitch_username == sub.username, "Username mismatch." + case "google": + assert user.google_id == sub.platform_uuid, "Platform UUID mismatch." + assert user.google_email == sub.email, "Email mismatch." + assert user.google_username == sub.username, "Username mismatch." + case _: + raise AssertionError("Invalid login method.") + except AssertionError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "User not found", + "error_description": str(e), + }, + ) from e + return user + + +@router.post("/new-user") +async def new_user( + shared_token: str, + user: UserInDB, + admin: AdminUser = Depends(get_current_admin_user), +): + """Endpoint to create a new user. This endpoint will be used by the external approved/registered services to create new users. + + Args: + shared_token (str): The token to validate the integrity of the parameters. + user (UserInDB): The user structure to be created. + + Headers: + Authorization: Bearer {token} + + Returns: + FetchedUserInDB: The most COMPLETE user data (ID, default user data, local, twitch and google user data). + """ + log.warning(f"ADMIN: {admin} - Creating new user: {user}") + await decode_access_token(shared_token, settings.JWT_MAIN_SERVICE_SECRET) + created_user = await create_user(user) + return created_user + + +@router.get("/fetch-user/{user_id}") +async def fetch_user( + shared_token: str, user_id: str, admin: AdminUser = Depends(get_current_admin_user) +): + """Get the user data from the user_id. This endpoint is used to fetch the user data for the admin panel. + + Args: + shared_token (str): The token to validate the integrity of the parameters. + user_id (str): The ID of the user to fetch. + + Headers: + Authorization: Bearer {token} + + Returns: + FetchedUserInDB: Ref. api.routes.utils.new_user() + """ + log.warning(f"ADMIN: {admin} - Fetching user: {user_id}") + user = await validation_of_user(user_id, shared_token) + return user + + +@router.patch("/update-user/{user_id}") +async def update_user( + shared_token: str, + user_id: str, + user: UserInDB, + admin: AdminUser = Depends(get_current_admin_user), +): + """Update the user data from the user_id. This endpoint is used to update the user data for the admin panel. + + Args: + shared_token (str): The token to validate the integrity of the parameters. + user_id (str): ID of the user to update. + user (UserInDB): The user structure to use to update the user. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - if the user was not updated. + + Returns: + FetchedUserInDB: Ref. api.routes.utils.new_user() + """ + log.warning(f"ADMIN: {admin} - Updating user: {user}") + await validation_of_user(user_id, shared_token) + user = FetchedUserInDB(**user.model_dump(), uuid=user_id) + updated_user = await update_user_by_id(user) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to update user.", + "error_description": "User data was not updated.", + }, + ) + return updated_user + + +@router.delete("/delete-user/{user_id}") +async def delete_user( + shared_token: str, user_id: str, admin: AdminUser = Depends(get_current_admin_user) +): + """Delete the user data from the user_id. This endpoint is used to delete the user data for the admin panel. + + Args: + shared_token (str): The token to validate the integrity of the parameters. + user_id (str): ID of the user to delete. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - if the user was not deleted. + + Returns: + {"message": "User deleted successfully"} + """ + log.warning(f"ADMIN: {admin} - Deleting user: {user_id}") + await validation_of_user(user_id, shared_token) + result = await remove_user_by_id(user_id) + if result: + return {"message": "User deleted successfully"} + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to delete user.", + "error_description": "User data was not deleted.", + }, + ) + + +# ~~ Image Handling ~~ + + +@router.post("/upload-image") +async def upload_image( + file: UploadFile = File(...), admin: AdminUser = Depends(get_current_admin_user) +): + """Manually upload an image to the database. + + Args: + file (UploadFile): The image file to upload to the database. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - if the image was not saved. + + Returns: + {"filename": file.filename, "id": str(image_id)} + """ + image_data = await file.read() + image = Image.open(io.BytesIO(image_data)) + + # Convert image to the format that can be stored in MongoDB + img_byte_arr = io.BytesIO() + image.save(img_byte_arr, format=image.format) + img_byte_arr = img_byte_arr.getvalue() + + # Store in MongoDB + image_id = await save_pfp(file.filename, img_byte_arr) + if not image_id: + return HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to save image.", + "error_description": f"Failed to save image. - {admin.username}", + }, + ) + + return {"filename": file.filename, "id": str(image_id)} + + +@router.patch("/update-image/{image_id}") +async def update_image( + image_id: str, + file: UploadFile = File(...), + admin: AdminUser = Depends(get_current_admin_user), +): + """Manually update an image in the database. + + Args: + image_id (str): ID of the image to update. + file (UploadFile): The image file to update in the database. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - if the image was not updated. + + Returns: + {"filename": file.filename, "id": str(image_id)} + """ + image_data = await file.read() + image = Image.open(io.BytesIO(image_data)) + + # Convert image to the format that can be stored in MongoDB + img_byte_arr = io.BytesIO() + image.save(img_byte_arr, format=image.format) + img_byte_arr = img_byte_arr.getvalue() + + # Update in MongoDB + image_id = await save_pfp(file.filename, img_byte_arr, image_id) + if not image_id: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to update image.", + "error_description": f"Failed to update image. - {admin.username}", + }, + ) + return {"filename": file.filename, "id": str(image_id)} + + +@router.get("/retrieve-image/{image_id}") +async def retrieve_image( + image_id: str, admin: AdminUser = Depends(get_current_admin_user) +): + """Manually retrieve an image from the database. + + Args: + image_id (str): ID of the image to retrieve. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_404_NOT_FOUND - if the image was not found. + + Returns: + PNG: Response(content=img_byte_arr, media_type="image/png") + """ + image_data = await fetch_pfp(image_id) + if image_data is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "Image not found.", + "error_description": f"Image not found. - {admin.username}", + }, + ) + + img = Image.open(io.BytesIO(image_data["image"])) + img_byte_arr = io.BytesIO() + # Convert to PNG or appropriate format + img.save(img_byte_arr, format="PNG") + img_byte_arr = img_byte_arr.getvalue() + + return Response(content=img_byte_arr, media_type="image/png") + + +@router.delete("/delete-image/{image_id}") +async def delete_image( + image_id: str, admin: AdminUser = Depends(get_current_admin_user) +): + """Manually delete an image from the database. + + Args: + image_id (str): ID of the image to delete. + + Headers: + Authorization: Bearer {token} + + Raises: + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - if the image was not deleted. + + Returns: + {"message": "Image deleted."} + """ + result = await remove_pfp(image_id) + if not result: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to delete image.", + "error_description": f"Failed to delete image. - {admin.username}", + }, + ) + return {"message": "Image deleted."} diff --git a/server/assets/favicon.ico b/server/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3fd9c0b2507f85cb251bd44141f89d181e24b327 GIT binary patch literal 34494 zcmeHQ2Xq|OxgH;5;wD*bi?*rnuH-^-NPpN65=ck_Mq(IM%%HySsNa8qMs^O0r+xJDGDvcV_m^ zz5oB;|K9u8k)n)J#wbpwf-+mVcbua9hoUGcDdGD$=PAlwJiGA1@coYp6y@{Dijtij zxktT|(-mdr%5 z(aUBp`>(83hgE;mw*K}TLTdinW9mfL?($dP39JBJKo3E8l)=t}YJT(f?RaP6os*QDl3*~i z7U$i;V7eam8qi>lzwk*#RmW(@h}XF*_C8g(`Y_<$8it)TZ{4W|R_s;-_k{xb`n=b- zUu92LXBSO-#=GjspZuL~XUKQ_EumYB)*T1j-TicF45@tPZ`xie>*-6Q_UGniJ(0C? z{|5yv+jg@4)b{S&z_M3Vo?5yTu6j-NH+F5&x2e4!)V?EGy)8HQ$5|Z*_ZB_$mKtd2 zZo^nEFIsnEK-&TEW?t)#4?)MV+V=FF#d`z3Xr4UYw8qvf6rIpP%J<;%-Nh%656)o9$9d zUAEP6+Xw$h`WLW$t}Wn0@}R$=>$RNr-Ag}O^OGAEj#C;+yw0^HZhL#N+p)6L<#?pj zW53Qy`?u!j|1^8$fg_|(bnW6i!dUnlx&=?^+cCS>k8iazRjqKQuOt5lFfXJ07+E7Z zzx<8eF?mwkm%jZPk3+4QIHuLRqt8{)+#}}Zz&<#qPJnMi4{6&myEbYcbQJFvF57bj z*A}h;(LO+@0`QZV56z)^vv%e4Snab;u%-L9qP189nz4_t?Z|kOZws+T7dG{9J2X9v z+As0i)@onybsoMa2%7k@-e|`=u>4ifZx80kwoA12wbz5zXV4$?c|xwS_uFjFXRuc1 zEPr)l0CP6leguyo_AIPctY>+|2Z+q?3>Nq_5( z0kq=S#EbLAx9Zq4*IaSSRJO%;Nel4I8LYEv75J2AHO@tL$W%VhvsvxEy9>bc zq)9I3)+Gxz{IN1+%<08$$4trR;2H2w`s?MkVj^C8IGM-&{zOsJu-J74w!@g0_ z*wq(UxexrdXJ?tqes8&b;ufsG4RRc+A=})Ro&Dch{A@p0uOk0o-4pv!aOHk=a?76v z%sKgfrH~({?D4KX_9)x%ZB$e9fiLGO;Vy){5NztcQ`R1LmTo;BOIDHaS9!>5=RN6qLj9DV`=OYpVcPkdLcP;F2g{9G`jO{2NBJyk&4KR( zxwdm2V*V9E2F4hQ-z46$l>L)mOFhot$>;9%rVAGd`1&!dC7Z$XOO56aVOa=$q|BN6d~VCm zt@)s%*rTvFWrH@?eRT#Q6QqFvreGbN9&3w0g3+TCbzRUKW9G4-K zeKpW!YLk=~ASX}9i8ZT7%;D&ojQO0ubnAQI(Y^VNTeqNG6KLw1<6pWn$12?Oytcgg znHdia0lugnXYj7ruNK&o)Vn53m3veot4SX9hbo`_ zCL`D_?D_HFH2b};7JO4TPT4sA8TF!qPUvu2+0bkZ06XTU%Jn8?W8Y18DN234H!KGc zRxJ+X_2Nw1yCY0@;?q}wHKROz{D)Q3ukc}w=ncv>!whcu9Pk&eI;6VSo%(sJqD-%W zjK#f}ZE_w;S&Mj-xNSc=8?cwS>}tI`U2Rd6FF77N-Bd_}4*Ow>UH7w0s_;?4uk5In&ZvihA|no5klV>&8B-&Y&zV z_nnyV3%=Z^7eYDcCh&C9|o^3|^cV(QO=T7s?%giW=0kf8;`zTv$ZBVhU zxz{X6A9v)MAGCS1SL{0_^mL1~4`Of2T7B#hDDT%S!T2p84;u7c0oY^d8xk+-9h9ZZ zytZ4)ea@oFJn!UqXO-K1fr|Amr+x26VMDSAdswdzoesRvmxT2yZ@w>cx>=v(of3D( zP6?-3DdpdKSH?x;Ijcw3>LcH#E-GwP@!-er+YDyy^Dz9dV@{cVbyoX6wVKNKKG3l3c46Cuoi{H0 zcSQX8!Y=PT-JSs(8VIUUHm;ZZ0j#*4%H_{k$&ud&wr zqF_bO$A$e*>oYK0FZmwlD{S<7coMxggIW9B5dO%T4cNmzljiQb;8Xdm7?T$0Re;fO z4Dej|MZi1I|0gC2ufDz(PP1!M_#-%k_syefQOoulgRR}OBm2-`8~{6QMwHEbt54hn zUr149j{S2B-0sV?XPM9SS>9{yA$qvwy&25f=P}`zIJ96cV%-cjcYiX_(A6Z?#ON4B z{GsF<*!!Iizqf9MqFh?)3a^;N&7KnW!kmP;FizLVAejj1b+kvv3E6u zj$E?7*9rcAm3sx|$QkmOANWjsR{b>WJU^@hjnPL`AJO(lIi2_C9R3In(ogxZRR=wQ z{qM9jaGlD0!bWZu7w3jD%9el5b<(g1^u;t|&vR56ra-*W8&dYay!b~5L zIuY!+Q+d5<@nRFpzU2OXJGGYo^w*a8U&0^ZBic#0mIj+b-|FviWUutF8r@6yzklI{ zU(9abf50#OxLSCPuDRY*#=#seZ0!Ex$U+`&%?MuoGYP+tpWFAnVObZCj058{DDz#= z)H8XcanQqRbWivz5$Cadf-=?f;5+l8aTnxsBf1ZI275nbYWU>d`NZP~qCSd|z{Ln& z{WAeS)`U7+(x=#GpfU8LuznTRTP@)LT*I*}T|LwjMgn%Wp@-GzJ`6wiME7N~U&@XH z_SZo)*Q`eoJ|doBxgzun+0JlFBY5@CB>d%B?wPW`s~&zmnff;MC+@FS$AGpJp(iwi zu$2w}(8FqUZv;Pk4zz~?)N^TbN(6`Ky4uh^d$<@F!K;5}20!~QYTbU7komDrMrAb% zyu|g0V^_3nCn5xge&}H}x{n1vdj?O;Ctq8#&qi?zouORBvAAf(uKb~5VAXy_`CQBY z`u;%sb`b*uxxw-{&Kq>bu-hU452V`~)$u1T;dA~xuai#zsZBKBh&{r2FC0lKpE z-x2J9Rplg2L`Z^BmE~Kny6$eo-g!rrzYTA$0#c&RMt}Zj}oeIP_ zgrPP5JHqiBB6grQS=sVY#H;5m+xbqws_w~k2{=$Dp}jHnSL$b~5Z7r8_28!?89%cS z%EmbpXEaVL1vfE{ z#aUS?BJLoXFJhjbquI~JnpBmnoVx704YLssdq%|3X!!^EI{A%VKZWd3V%B{)CN*Q9 zrd{pYZFM=UI1Ak7h1W{S6r&DNLf8@WS_Lq7D; z+NZvgBXWTZ<}YxkF5rIP&EHlhD~sz9??aj)esCZAH;Xf2d@p4$kq3bNL0{6&z0fvZ zo$H_cUC+AXdj#FZ+N_~H=?;A`3;A1LpYhcNOAy0Y54lCQRqM_)jT51*8F50tF3YqR zm%E*%gU|G;dllsi@H_vQvRyQnC`xm&&WbqRoc4WlQTNJHSH_}J^eNja&dj`0#&c@N zp0ut-E=j4=UQQaB9gO3lUW0s;!Rsb)7xwms(2Hj8^SQQPdCmT7GSk%AQ!nnYZBj2Z zdjEozue*hfVHx6;4d_mvqmX--?}+K^k$q}Q_FoOTV9u0_+I*{Cd(GdnXFv=!;h|n; zst>OA;nK~s01zu~q19IvMC>aeX*lV>5vopcwmGF>pM z$3JAB+EVsEH!tsgZ|9+1MGu^$4!47?O>TVU0ud8pWEVl-!gkOQ`ha$<_`REte>Lz& z{?JnIs@Hb;A^V48mV^3__|Ex~n8fvT2ikeL*k#{a>auUI_W2BRF|_^4Ys3DDEBlGS0>8mcxIYkp zPR4oWYu~S4`MvwTSv_ggd$m|ErQel4SFU9>`A(a}SzF3JwWVqQ5(dTyH-?VVuMJ!2 z2Z4v*RtuN@wGn#ZUv5W#GADy2*J;8sV*N*8h{VGDHPEuX+_&=J?-yiD_&RbqUM+Du z|5)O&zZgB;_N^su#~-S4a}8x&E#C6FAAdji@9=K&zn<55&-E2+!+;zREp4q_%RaTG z=%4w4`GH38|1nd)5^Qpum*B^Tt@LG7N87KwX8&COt5TDzJnP^1iHId3pOg05Q}BMo z?%llgE~ED2J(GQEOVK~}zxt%}%9+25weLi9u4(({HDDnBlMcbgmkndTV!@#8S6++$ z!Mmky#~pmPaOJ^*s6E|;4xE=_U2f&lq+aKOd2 z0PE6#*em(IS*h(;UK{n#_c8YKggx6VP8~1^7?FQ_{o{Qxvhe$%k$q}Q*?+0mv82Cd z_zF2jE28#jJugJ~LF`pnpY`t=-J8o_v`IJ?7`@|JedGyYPd4gbJPYThB9G`nqxZGX z%sQ=z$Bp=Y!rw&BoCys44RzkT`lh;XK>v6%VlRXKVT;B_ z4nhCwtETQVP67af{PdA7x9(dj|+Oa?)QE>yQp-!?|Ek2fVpu z%G%ejOOyzup46!r?;UYq?Vm4LyGEd{@K;r1?!T-knf6EDFOSBI7~mF{23Q*y^PAPS zdo%GbPn%d|g$@zF!rw*!pM+^bzQ28}Ql|9vxdT}DD4S6ZjEk$DK6r<68+?n586I7m zG`geC>-?LUA41Y4l0TX-+#^6Mk!xCRn;_z|bC9 z%1?-2nKR`B=92+0;SVH^CBD=Fc^IfR%1U0=*`N(&Ao@#SN4!xvsEXLrL3VB(5+$H?;-L_YVGwbjef}E7-7@O6Hzsrfd!Ld&hj{N4A-wuXt z5Q)!u&_l?^&=<$`s2^u8A?Zr`O1!ltab}-9kVF5X_P29%4gUt_r6Uz=j}NP?I`U8v zb32AQ=Yf9_aCfhHy&VmXE_bGhm}KBGzz;5QBhKvC;QK0u^pfvIO3@~DnW3JHk71rF z{LPn7cwYUi(7Tw6W5n0-o`GhJ0f4{sIB}{-o49R>FEcJ|i}6jCq4?1wJcZZ=+X46z zU=y_OPyDpKRWoT!8}_GBd64l;bsY=1(@qkEK9;gsz2F{2xsGFG2{xx~7QV&tfNpH# zjz9f3&?Wx1e#VTkr1x-+{HE<&guQJze2=}3-#H^3k9$0p>=P2^q0D`Wf9Le+V`Mu+ zDeBLpDJM-|(gnbt@Z_PGp+w2+S)BD1@&0t@XrS+eD58Vd-(QdOo ztUlM{X60VQ^#T8{e|>gFF8!npp<|0Tsc~~>2-DQY&?ji~9)X)~l zd?>;`Jl%%h(+#Qq&VHYLV%QF@t;fyEy&iw*yJhZ|$g^Jd^3~d&7%LBce_woqX^88+ z)NTCDU0>6-J=|+6`0DEz;vbPm(=0_ zmw&jX7Bp^;xvv=EW%ZdAeD!s#;xFuw>t84DSCDtPhVgr~miB(|y_iS%K8jUbhEh+D zo0WU(_(x(G7|Yk(^M!)uZG}Si6LC*5bROU?;u0Vy(H6dFyz;#gk8Kma5&bi>v)E(% zCB7T^lvzBE8EvfKtFL1j|HxV(Vkm&i)b{o0a>7@Ru}T3czo<9e;Y_D_$01V73#_~z5I91nb3cdL-+G3H_X8FO`9O?z6s4uM~n2)_+@ z|NjmCVw_>O0Pi#2hcp1*Z-#AHp);g~ul<0?6)$zByf0%qtl&?cWX>Am0slGqU&G_# z`}3qhQ_mde@To#?0PghLOjp(Ml_}#k--Z7-AmiDsl>8qBTqEoJjvUg}>S2Yp`Z`0J z{}TVmJmCH&WWp6am-74d{KlK_p;Q0fi_eWCp8gShpL!Ygyk@IsvCFv}w)|GJcw94j z=y9`hKWzLXG@#u;=u4fwLB@ZZ!FV>GTftXfXGHLi&>$S&5jrz%>5&O%6Hl|Y^|)EN zA2IwT4ft&~*zmu#zIWLE6f-={o?F3JUq|A9!woeK*gxMijbkQ$7EkVrkO@SriW#hD zHlM!SsUKH zkUHIv{g;wIsPjNSq|O{{3?X&(+#Gt`tlUfdmpU9`&0@ZU$PF@!6_q%~D@g;fFCzE4 zdD~ad1qH0&tFI&RUom6GLzs`DJ^>(XA5_>YXx6@9W(sdURb9YUL1>{31}eV z0vbZQ7jN2R$+yP-6Fu~}S-Fpe|Mbp1li=ezCB8E?oOuwL8^T}I*!7!4@v>Uq3cmU} zvG8ZV{?6_oee;xUhqV@j=YrT1@XaBiFIvURYCSz}R_^2BFK7UJpYTx|<#QUXduGqT zL-d&mJ8=@%@M4%mvZ_bQC#ApsQ8ftkfH7`~&5m z6*C5||3>}6U%4`7UX$6l>z{)LVc*&g^}^OyEyZ#h!J~gR)c6;x__IIwK%O0z-&@iG zGzhP&U2oBDqQ_+@_g22=HB|nW@X!y4b#Q;=8{9*RZH>wapnpQTL^mm z;@uA={w;Hpno-ac`iE&=nH*z_CD#?$5Pvot-1%;?E4?KizWU!K{)xsvPW-J}#C&oY zFAyD5i#SK?g9n6f4Z0FzsffpVMHxG5)~wOIPUHyZHCeMUOFASP|B6`DL$K+cgkpQ~ zr03Mjgbf@qtHY8FK!b38@G;f>=zG`0S>ndGNAN$9?8g&`$^FW?iht%e$tsY0jo9o- zw#WN!3-1Xdpew;&5K1hhVt>DLC;TP!7g>qRX8bdG#VA)+j`awZ$gOk;y9xXB ztUb1B#B?R(4CFQUoPp2iyhUS`#r%eyS=_(Ghd2`s9{B&nSIOsRx$I*;Mn%SiKe6BJ z_B~q$_ItQ$E-q3&N|)gQ^<2wP3^`46z~ zyq2BEMnqQv4VXWX)wW;6Aug~@3^BI^a1ZvIW@h{uT}Sg@2uF!8<6_8y!}))KG3l+P zA9<5JdS^J-Z36SuMnqQ<|6AjP>RtN=Qp1tgV^4is?>i=r$X}h#-}T2(av`*MC2%cr z9qHd}!FTqQJ?hjTzJW>^f7p9M#D#h;gmH3@1>#K@j^(Uh|Hx#K6(gPv;{4opBz_S0q62 bgYOdmv%m~D&GWOE|89vk|ElzVn*#p>)Zza# literal 0 HcmV?d00001 diff --git a/server/assets/html/adminLogin.html b/server/assets/html/adminLogin.html new file mode 100644 index 0000000..2967393 --- /dev/null +++ b/server/assets/html/adminLogin.html @@ -0,0 +1,274 @@ + + + + + + + + Admin Login Page + + + + + + + + + + + + \ No newline at end of file diff --git a/server/assets/html/email-verification-callback.html b/server/assets/html/email-verification-callback.html new file mode 100644 index 0000000..bffe693 --- /dev/null +++ b/server/assets/html/email-verification-callback.html @@ -0,0 +1,108 @@ + + + + + + + EventKit - Redirect Application + + + + + Redirecting you automatically, click here if your browser does not redirect you. + + + + + + \ No newline at end of file diff --git a/server/assets/html/email/DB_not_running.html b/server/assets/html/email/DB_not_running.html new file mode 100644 index 0000000..a7a369d --- /dev/null +++ b/server/assets/html/email/DB_not_running.html @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/server/assets/html/email/new_account.html b/server/assets/html/email/new_account.html new file mode 100644 index 0000000..8e956a3 --- /dev/null +++ b/server/assets/html/email/new_account.html @@ -0,0 +1,313 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/server/assets/html/email/notification_pwd_change.html b/server/assets/html/email/notification_pwd_change.html new file mode 100644 index 0000000..bddf1ec --- /dev/null +++ b/server/assets/html/email/notification_pwd_change.html @@ -0,0 +1,305 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/server/assets/html/email/reset_password.html b/server/assets/html/email/reset_password.html new file mode 100644 index 0000000..e87ddc1 --- /dev/null +++ b/server/assets/html/email/reset_password.html @@ -0,0 +1,342 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/server/assets/html/email/test_email.html b/server/assets/html/email/test_email.html new file mode 100644 index 0000000..90cc1f2 --- /dev/null +++ b/server/assets/html/email/test_email.html @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/server/assets/html/email/verification_email.html b/server/assets/html/email/verification_email.html new file mode 100644 index 0000000..cf1515d --- /dev/null +++ b/server/assets/html/email/verification_email.html @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/server/assets/html/embed_example.html b/server/assets/html/embed_example.html new file mode 100644 index 0000000..f205091 --- /dev/null +++ b/server/assets/html/embed_example.html @@ -0,0 +1,25 @@ + + + + + + Embedded Webpage + + + + + + diff --git a/server/assets/html/google-callback.html b/server/assets/html/google-callback.html new file mode 100644 index 0000000..2e84d82 --- /dev/null +++ b/server/assets/html/google-callback.html @@ -0,0 +1,136 @@ + + + + + + + EventKit - Redirect Application + + + + + Redirecting you automatically, click here if your browser does not redirect you. + + + + + \ No newline at end of file diff --git a/server/assets/html/template/login.html b/server/assets/html/template/login.html new file mode 100644 index 0000000..9b6bac1 --- /dev/null +++ b/server/assets/html/template/login.html @@ -0,0 +1,262 @@ + + + + + + + + Login Page + + + + + + + + + + + + \ No newline at end of file diff --git a/server/assets/html/template/register.html b/server/assets/html/template/register.html new file mode 100644 index 0000000..236a2b7 --- /dev/null +++ b/server/assets/html/template/register.html @@ -0,0 +1,392 @@ + + + + + + + + Signup Page + + + + + + + + + + + \ No newline at end of file diff --git a/server/assets/html/twitch-callback.html b/server/assets/html/twitch-callback.html new file mode 100644 index 0000000..0e1ec61 --- /dev/null +++ b/server/assets/html/twitch-callback.html @@ -0,0 +1,136 @@ + + + + + + + EventKit - Redirect Application + + + + + Redirecting you automatically, click here if your browser does not redirect you. + + + + + \ No newline at end of file diff --git a/server/core/__init__.py b/server/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/core/config.py b/server/core/config.py new file mode 100644 index 0000000..ddd3f5d --- /dev/null +++ b/server/core/config.py @@ -0,0 +1,73 @@ +"""core.CONFIG +File: config.py +Author: LordLumineer +Date: 2024-05-04 + +Purpose: This file contains the Settings Configuration for the API. + It loads the sensitive information from the .ENV file (if declared in both the ones in the .ENV file are used). + Be Careful: any variable declared in the .ENV file must be declared in the Settings class. (e.g. SECRET: str) +""" + +import os +from typing import List +from datetime import timedelta, time +from loguru import logger +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Settings Configuration for the API.""" + + model_config = SettingsConfigDict(env_file="./.env", env_file_encoding="utf-8") + PROJECT_NAME: str = "Event Kit Stream Authentication API" + VERSION: str = "1.0.0" + API_URI: str = "https://id.eventkit.stream" + API_STR: str = "/v1" + ADMIN_EMAIL: str + + LOGS_LEVEL: str | int = "DEBUG" + LOGS_PATH: str = "./db/logs" + + DATABASE_BACKUP_PATH: str = "./db/backup" + DATABASE_BACKUP_TIME: List[int] = [0, 0] + DATABASE_HOST: str + DATABASE_PORT: str + DATABASE_USERNAME: str + DATABASE_PASSWORD: str + DATABASE_BACKUP_RETENTION: int = 7 + MAX_IMAGE_SIZE: int = 524288 + + EMAIL_SMTP_SERVER: str + EMAIL_SMTP_PORT: int + EMAIL_ADDRESS: str + EMAIL_PASSWORD: str + EMAIL_VERIFICATION_EXPIRE_MINUTES: int = 1440 + + JWT_ALGORITHM: str = "HS256" + JWT_SECRET: str + JWT_MAIN_SERVICE_SECRET: str + JWT_ISSUER: str + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + LOGIN_ATTEMPTS_TIME: int = 10 + LOGIN_ATTEMPTS_WAIT: int = 5 + LOGIN_ATTEMPTS_LIMIT: int = 20 + + TWITCH_CLIENT_ID: str + TWITCH_CLIENT_SECRET: str + GOOGLE_CLIENT_ID: str + GOOGLE_CLIENT_SECRET: str + + +settings = Settings() + +logger.add( + os.path.join(settings.LOGS_PATH, "{time}.log"), + rotation=time(0, 0), + compression="zip", + serialize=True, + retention=timedelta(days=7), + level=settings.LOGS_LEVEL, + enqueue=True, +) +log = logger diff --git a/server/core/db.py b/server/core/db.py new file mode 100644 index 0000000..5bce268 --- /dev/null +++ b/server/core/db.py @@ -0,0 +1,855 @@ +"""core.DB +File: db.py +Author: LordLumineer +Date: 2024-05-03 + +Purpose: This file handles the database communication and connection. +""" + +import os +import io +import json +import socket +import shutil +from uuid import uuid4 +from datetime import datetime, timedelta, timezone +from PIL import Image +from bson import ObjectId +from pymongo import MongoClient, errors + +from models import FetchedUserInDB, UserInDB, AdminUser +from core.engine import engine +from core.config import settings, log +from core.email import send_db_alert_email + +client: MongoClient +UsersDB: list +collection: list + +# ~~~~ Utils Functions ~~~~ # + + +async def startup_auth_db(): # bool + """Handles the startup of the MongoDB connection and database creation. + If the database needs to be created, it will create the collections and load the backup data. + It initiates the backup job for the database. + And defines the global variables for the MongoDB connection and collections. + + Returns: + bool: Returns True if the connection was successful, False otherwise (Is used in the startup of the main program to detect if it is running). + """ + log.info("Connecting to MongoDB") + global client + global UsersDB + global collection + db_host = settings.DATABASE_HOST + db_port = settings.DATABASE_PORT + db_root_user = settings.DATABASE_USERNAME + db_root_pwd = settings.DATABASE_PASSWORD + + connection_uri = f"mongodb://{db_root_user}:{db_root_pwd}@{db_host}:{db_port}" + client = MongoClient(connection_uri) + if not await is_mongodb_running(): + log.error(f"Failed to connect to MongoDB with the provided Host {db_host}") + db_host = socket.gethostbyname(socket.gethostname()) + log.info( + f"Trying to connect to MongoDB with the automated fetched Host {db_host}" + ) + connection_uri = f"mongodb://{db_root_user}:{db_root_pwd}@{db_host}:{db_port}" + client = MongoClient(connection_uri) + if not await is_mongodb_running(): + log.error( + f"Failed to connect to MongoDB with the automated fetched Host {db_host}" + ) + return False + + log.success("Connected to MongoDB") + + engine.add_job( + func=backup_database, + args=["users"], + trigger="cron", + hour=settings.DATABASE_BACKUP_TIME[0], + minute=settings.DATABASE_BACKUP_TIME[1], + name="Database Backup Job", + id="database_backup_job", + replace_existing=True, + ) + + latest_backup = None + if await create_database_if_not_exists("users"): + if not await create_collection_if_not_exists("users", "eventkitstream_users"): + if not latest_backup: + latest_backup = await latest_backup_folder() + await load_collection_backup("users", "eventkitstream_users", latest_backup) + if not await create_collection_if_not_exists("users", "profile_pictures"): + if not latest_backup: + latest_backup = await latest_backup_folder() + await load_collection_backup("users", "profile_pictures", latest_backup) + if not await create_collection_if_not_exists("users", "admin_users"): + if not latest_backup: + latest_backup = await latest_backup_folder() + await load_collection_backup("users", "admin_users", latest_backup) + else: + if not latest_backup: + latest_backup = await latest_backup_folder() + await load_backup("users", latest_backup) + UsersDB = client["users"] + collection = UsersDB["eventkitstream_users"] + return True + + +async def disconnect(): # bool + """Function to disconnect from the MongoDB cleanly. + It will also backup the database before disconnecting and stopping the backup job. + + Returns: + bool: Returns True if the disconnection was successful, False otherwise. + """ + log.info("Disconnecting from MongoDB") + try: + if "database_backup_job" in engine.get_jobs(): + engine.remove_job(job_id="database_backup_job") + await backup_database("users") + client.close() + except (errors.PyMongoError, errors.ServerSelectionTimeoutError): + log.error("Failed to disconnect from MongoDB, it may be already disconnected") + return False + + log.success("Disconnected from MongoDB") + return True + + +async def is_mongodb_running(): # bool + """Simple function to check if the MongoDB is running. + If it is not running, it will send an email alert to the admin. + + Returns: + bool: Returns True if the MongoDB is running, False otherwise. + """ + try: + client.server_info() + return True + except errors.ServerSelectionTimeoutError: + log.critical("Failed to connect to MongoDB, please check if it's running") + await send_db_alert_email() + return False + + +async def latest_backup_folder(): # str | None + """Simple helper function to get the latest backup folder. + + Returns: + str | None: Returns the path to the latest backup folder, None if no backup folders are found. + """ + backup_dir = settings.DATABASE_BACKUP_PATH + if not os.path.exists(backup_dir): + os.makedirs(backup_dir) + backup_folders = os.listdir(backup_dir) + now = datetime.now(timezone.utc) + oldest = now - timedelta(days=settings.DATABASE_BACKUP_RETENTION) + for folder in backup_folders: + timestamp = int(folder.split("_")[0]) + date = datetime.fromtimestamp(timestamp, timezone.utc) + if date < oldest and (folder != "1008720000_EXAMPLE_backup"): + log.info(f"Removing old backup folder '{folder}'") + shutil.rmtree(os.path.join(backup_dir, folder)) + + backup_folders = os.listdir(backup_dir) + if backup_folders: + return os.path.join(backup_dir, sorted(backup_folders)[-1]) + log.warning("No backup folders found") + return None + + +# Database Functions + + +async def create_database_if_not_exists(db_name: str): # bool + """Checks if a database exists in MongoDB, if it does not exist, it will create it. + + Args: + db_name (str): The name of the database to check. + + Returns: + bool: Returns True if the database exists, False otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + if db_name not in client.list_database_names(): + log.warning(f"Database '{db_name}' does not exist. Creating...") + new_db = client[db_name] + log.success(f"Database '{new_db.name}' created successfully") + return False + log.success(f"Database '{db_name}' already exists") + return True + except Exception: + log.error(f"Failed to check the existence of the database '{db_name}'") + return None + + +async def backup_database(db_name: str): # str | None + """Backup the database to a JSON file (the profile pictures are saved as a file for better readability). + + Args: + db_name (str): The name of the database to backup. + + Returns: + str | None: Returns the path to the backup file if successful, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + now = int(datetime.now(timezone.utc).timestamp()) + backup_path = os.path.join( + settings.DATABASE_BACKUP_PATH, f"{now}_{db_name}_backup" + ) + if not os.path.exists(backup_path): + os.makedirs(backup_path) + + db = client[db_name] + backup_data = {} + for collection_name in db.list_collection_names(): + log.info(f"Backing up collection '{collection_name}'") + backup_data[collection_name] = [] + if collection_name == "profile_pictures": + folder_path = os.path.join(backup_path, f"{collection_name}") + if not os.path.exists(folder_path): + os.makedirs(folder_path) + for document in db[collection_name].find(): + metadata = { + "_id": str(document["_id"]), + "name": document["name"], + } + image = Image.open(io.BytesIO(document["image"])) + file_path = os.path.join( + folder_path, f"{metadata['_id']}_{metadata['name']}" + ).replace("\\", "/") + image.save(file_path, format=image.format, **metadata) + backup_data[collection_name].append( + {"metadata": metadata, "file_path": file_path} + ) + continue + + for document in db[collection_name].find(): + document["_id"] = str(document["_id"]) + backup_data[collection_name].append(document) + backup_file = os.path.join( + backup_path, f"{db_name}_{'eventkitstream_users'}_backup.json" + ) + + try: + with open(backup_file, "x", encoding="utf-8") as file: + json.dump(backup_data, file, indent=2) + except ( + FileExistsError, + FileNotFoundError, + json.JSONDecodeError, + ) as e: + errors_msg = ( + f"Failed to create backup file '{backup_file}' | With error: {e}" + ) + log.error(errors_msg) + return None + log.success( + f"Backup of database '{db_name}' completed successfully to '{backup_file}'" + ) + return backup_file + + except Exception: + log.error(f"Failed to backup the database '{db_name}'") + return None + + +async def load_backup(db_name: str, backup_folder: str): # True | None + """Load the backup data into the database. + + Args: + db_name (str): The name of the database to load the backup data into. + backup_folder (str): The path to the backup folder to use. + + Returns: + bool: Returns True if the backup data was loaded successfully, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + db = client[db_name] + try: + backup_file = os.path.join( + backup_folder, + f"{db_name}_{'eventkitstream_users'}_backup.json", + ) + with open(backup_file, "r", encoding="utf-8") as file: + backup_data = json.load(file) + for collection_name in backup_data.keys(): + for document in backup_data[f"{collection_name}"]: + if collection_name == "profile_pictures": + image = Image.open(document["file_path"]) + img_byte_arr = io.BytesIO() + image.save(img_byte_arr, format=image.format) + img_byte_arr = img_byte_arr.getvalue() + document["_id"] = ObjectId(document["metadata"]["_id"]) + document["name"] = document["metadata"]["name"] + document["image"] = img_byte_arr + document.pop("file_path") + document.pop("metadata") + continue + document["_id"] = ObjectId(document["_id"]) + except FileNotFoundError: + log.error(f"Backup file '{backup_file}' not found") + return None + + for collection_name, documents in backup_data.items(): + collection = db[collection_name] + collection.insert_many(documents) + log.success( + f"Backup data from '{backup_file}' loaded into database '{db_name}' successfully" + ) + return True + + except Exception: + log.error(f"Failed to load backup data into database '{db_name}'") + return None + + +# Collection Functions + + +async def create_collection_if_not_exists( + db_name: str, collection_name: str +): # bool | None + """Checks if a collection exists in a database, if it does not exist, it will create it. + + Args: + db_name (str): The name of the database to check. + collection_name (str): The name of the collection to check. + + Returns: + bool: Returns True if the collection exists, False otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + if collection_name not in client[db_name].list_collection_names(): + log.warning(f"Collection '{collection_name}' does not exist. Creating...") + new_collection = client[db_name][collection_name] + log.success(f"Collection '{new_collection.name}' created successfully") + return False + log.success(f"Collection '{collection_name}' already exists") + return True + except Exception: + log.error( + f"Failed to check the existence of the collection '{collection_name}'" + ) + return None + + +async def load_collection_backup( + db_name: str, collection_name: str, backup_folder: str +): # True | None + """Load the part of the backed-up data into the collection. + + Args: + db_name (str): The name of the database to load the backup data into. + collection_name (str): The name of the collection to load the backup data into. + backup_folder (str): The path to the backup folder to use. + + Returns: + bool: Returns True if the backup data was loaded successfully, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + backup_file = os.path.join( + backup_folder, + f"{db_name}_{'eventkitstream_users'}_backup.json", + ) + db = client[db_name] + collection = db[collection_name] + try: + with open(backup_file, "r", encoding="utf-8") as file: + backup_data = json.load(file) + for document in backup_data[f"{collection_name}"]: + if collection_name == "profile_pictures": + image = Image.open(document["file_path"]) + img_byte_arr = io.BytesIO() + image.save(img_byte_arr, format=image.format) + img_byte_arr = img_byte_arr.getvalue() + document["_id"] = ObjectId(document["metadata"]["_id"]) + document["name"] = document["metadata"]["name"] + document["image"] = img_byte_arr + document.pop("file_path") + document.pop("metadata") + continue + document["_id"] = ObjectId(document["_id"]) + except FileNotFoundError: + log.error(f"Backup file '{backup_file}' not found") + return None + if backup_data[f"{collection_name}"] == []: + log.warning(f"Backup data {collection_name} from '{backup_file}' is empty") + return None + collection.insert_many(backup_data[f"{collection_name}"]) + log.success( + f"Backup data from '{backup_file}' loaded into collection '{collection_name}' successfully" + ) + return True + except Exception: + log.error( + f"Failed to load backup data into collection '{collection_name}' in database '{db_name}'" + ) + return None + + +# ~~~~ User Functions ~~~~ # + + +async def get_new_local_uuid(): # str + """Generates a new UUID for a local user. + + Returns: + str: Returns the new UUID for the local user. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + is_unique_uuid = False + while not is_unique_uuid: + user_id = str(uuid4()) + is_user = collection.find_one({"local_id": user_id}) + if not is_user: + is_unique_uuid = True + return user_id + return None + except Exception: + log.error("Failed to generate a new UUID") + return None + + +async def fetch_local_user_by_name(username: str): # UserInDB | None + """Fetches a local user by their username. + + Args: + username (str): The username of the local user to fetch. + + Returns: + UserInDB: Returns the full user minus the UUID if the user is found, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + user = collection.find_one({"local_username": username}) + if user: + cleaned_user = FetchedUserInDB(**user) + cleaned_user.uuid = str(user["_id"]) + return cleaned_user + log.debug(f"Local User with username [{username}] not found") + return None + except Exception: + log.error(f"Failed to fetch local user by username [{username}]") + return None + + +async def fetch_local_user_by_email(email: str): # UserInDB | None + """Fetches a local user by their email. + + Args: + email (str): The email of the local user to fetch. + + Returns: + UserInDB: Returns the full user minus the UUID if the user is found, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + user = collection.find_one({"local_email": email}) + if user: + cleaned_user = FetchedUserInDB(**user) + cleaned_user.uuid = str(user["_id"]) + return cleaned_user + log.debug(f"Local User with email [{email}] not found") + return None + except Exception: + log.error(f"Failed to fetch local user by email [{email}]") + return None + + +async def fetch_twitch_user_by_email(email: str): # UserInDB | None + """Fetches a Twitch user by their email. + + Args: + email (str): The email of the Twitch user to fetch. + + Returns: + UserInDB: Returns the full user minus the UUID if the user is found, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + user = collection.find_one({"twitch_email": email}) + if user: + cleaned_user = FetchedUserInDB(**user) + cleaned_user.uuid = str(user["_id"]) + return cleaned_user + log.debug(f"Twitch User with email [{email}] not found") + return None + except Exception: + log.error(f"Failed to fetch Twitch user by email [{email}]") + return None + + +async def fetch_twitch_user_by_id(twitch_id: str): # UserInDB | None + """Fetches a Twitch user by their ID. + + Args: + twitch_id (str): The ID of the Twitch user to fetch. + + Returns: + UserInDB: Returns the full user minus the UUID if the user is found, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + user = collection.find_one({"twitch_id": twitch_id}) + if user: + cleaned_user = FetchedUserInDB(**user) + cleaned_user.uuid = str(user["_id"]) + return cleaned_user + log.debug(f"Twitch User with ID [{twitch_id}] not found") + return None + except Exception: + log.error(f"Failed to fetch Twitch user by ID [{twitch_id}]") + return None + + +async def fetch_google_user_by_email(email: str): # UserInDB | None + """Fetches a Google user by their email. + + Args: + email (str): The email of the Google user to fetch. + + Returns: + UserInDB: Returns the full user minus the UUID if the user is found, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + user = collection.find_one({"google_email": email}) + if user: + cleaned_user = FetchedUserInDB(**user) + cleaned_user.uuid = str(user["_id"]) + return cleaned_user + log.debug(f"Google User with email [{email}] not found") + return None + except Exception: + log.error(f"Failed to fetch Google user by email [{email}]") + return None + + +async def fetch_google_user_by_id(google_id: str): # UserInDB | None + """Fetches a Google user by their ID. + + Args: + google_id (str): The ID of the Google user to fetch. + + Returns: + UserInDB: Returns the full user minus the UUID if the user is found, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + user = collection.find_one({"google_id": google_id}) + if user: + cleaned_user = FetchedUserInDB(**user) + cleaned_user.uuid = str(user["_id"]) + return cleaned_user + log.debug(f"Google User with ID [{google_id}] not found") + return None + except Exception: + log.error(f"Failed to fetch Google user by ID [{google_id}]") + return None + + +async def create_user(user: UserInDB): # FetchedUserInDB | None + """Creates a new user in the database. + + Args: + user (UserInDB): The user to create in the database. + + Returns: + UserInDB: Returns the full user created WITH the UUID, if the user is created, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + db_id = collection.insert_one(user.model_dump()).inserted_id + if not db_id: + log.error(f"Failed to create user [{user.local_username}]") + return None + cleaned_user = FetchedUserInDB(**user.model_dump()) + cleaned_user.uuid = str(db_id) + log.success(f"User [{user.full_name}] created successfully") + # TODO: Create EventKitStream user database with default collections with values -> BackgroundTask -> http request to eventkit + return cleaned_user + except Exception: + log.error(f"Failed to create user: {user.full_name}") + return None + + +async def fetch_user_by_id(uuid: str): # UserInDB | None + """Fetches a user by their ID. + + Args: + uuid (str): The ID of the user to fetch. + + Returns: + UserInDB: Returns the full user minus the UUID if the user is found, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + user = collection.find_one({"_id": ObjectId(uuid)}) + if user: + cleaned_user = FetchedUserInDB(**user) + cleaned_user.uuid = str(user["_id"]) + return cleaned_user + log.debug(f"User with ID [{uuid}] not found") + return None + except Exception: + log.error(f"Failed to fetch user by ID [{uuid}]") + return None + + +async def update_user_by_id(user: FetchedUserInDB): # FetchedUserInDB | None + """Updates a user in the database. + + Args: + user (FetchedUserInDB): The user to update in the database. + + Returns: + UserInDB: Returns the full user WITH the UUID, if the user is updated, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + if not user.uuid: + log.warning("The user does not have an ID") + return None + user_for_db = UserInDB(**user.model_dump()) + acknowledged = collection.update_one( + {"_id": ObjectId(user.uuid)}, {"$set": user_for_db.model_dump()} + ).acknowledged + if not acknowledged: + log.error(f"Failed to update user [{user.full_name}]") + return None + log.success(f"User [{user.full_name}] updated successfully") + # TODO: Update EventKitStream user database + return user + except Exception: + log.error(f"Failed to update user [{user.full_name}]") + return None + + +async def remove_user_by_id(uuid: str, is_disabled=False): # bool + """Removes a user from the database. + + Args: + uuid (str): The ID of the user to remove. + is_disabled (bool, optional): If the user is disabled, defaults to False. + + Returns: + bool: Returns True if the user is removed, False otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + if not is_disabled: + user = await fetch_user_by_id(uuid) + if not user: + log.error(f"User with ID [{uuid}] not found") + else: + user.disabled = True + user = await update_user_by_id(user) + if not user: + log.error(f"Failed to disable user [{user.full_name}]") + else: + is_disabled = True + acknowledged = collection.delete_one({"_id": ObjectId(uuid)}).acknowledged + if acknowledged and is_disabled: + log.success(f"User with ID [{uuid}] removed successfully") + # TODO: Remove EventKitStream user database + return True + log.error(f"Failed to remove user with ID [{uuid}]") + return False + except Exception: + log.error(f"Failed to remove user by ID [{uuid}]") + return False + + +# ~~~~ Profile Picture Functions ~~~~ # +async def save_pfp(file_name: str, image: bytes, image_id: str = None): # str | None + """Saves a profile picture to the database. + + Args: + file_name (str): filename of the image. + image (bytes): image data. + image_id (str, optional): ID of the image to update, defaults to None. + + Returns: + str: ID of the image saved/updated, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + collection = UsersDB["profile_pictures"] + if not image_id: + image_id = collection.insert_one( + {"name": file_name, "image": image} + ).inserted_id + return str(image_id) + result = collection.update_one( + {"_id": ObjectId(image_id)}, + {"$set": {"name": file_name, "image": image}}, + ) + if result.raw_result.get("updatedExisting"): + log.success( + f"Profile Picture [{image_id}:{file_name}] saved/updated successfully" + ) + return str(image_id) + return None + except Exception: + log.error("Failed to save profile picture") + return None + + +async def fetch_pfp(image_id: str): # dict | None + """Fetches a profile picture from the database. + + Args: + image_id (str): ID of the image to fetch. + Returns: + bytes: Image data, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + collection = UsersDB["profile_pictures"] + return collection.find_one({"_id": ObjectId(image_id)}) + except Exception: + log.error(f"Failed to fetch profile picture with ID [{image_id}]") + return None + + +async def remove_pfp(image_id: str): # bool + """Removes a profile picture from the database. + + Args: + image_id (str): ID of the image to remove. + + Returns: + bool: Returns True if the image is removed, False otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + collection = UsersDB["profile_pictures"] + if collection.delete_one({"_id": ObjectId(image_id)}).acknowledged: + log.success(f"Profile Picture [{image_id}] removed successfully") + return True + log.warning(f"Profile Picture [{image_id}] not found") + except Exception: + log.error(f"Failed to remove profile picture with ID [{image_id}]") + return False + + +# ~~~~ Admin Functions ~~~~ # +async def create_admin_user(user: AdminUser): # AdminUser | None + """Creates a new admin user in the database. (DO NOT USE YET) + + Args: + user (AdminUser): _description_ + + Raises: + Exception: "DO NO USE" + + Returns: + AdminUser: { + "username": str, + "hashed_password": str, + } + """ + # try: + # if not await is_mongodb_running(): + # raise Exception("MongoDB is not running") + # collection = UsersDB["admin_users"] + # collection.insert_one(user.model_dump()) + # log.success(f"Admin User [{user.username}] created successfully") + # return user + # except Exception: + # log.error(f"Failed to create admin user: {user}") + # return None + raise NotImplementedError("DO NOT USE") + + +async def fetch_admin_user(username: str): # AdminUser | None + """Fetches an admin user by their username. + + Args: + username (str): The username of the admin user to fetch. + + Returns: + AdminUser: { + "username": str, + "hashed_password": str, + } + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + collection = UsersDB["admin_users"] + user = collection.find_one({"username": username}) + if user: + return AdminUser(**user) + log.debug(f"Admin User with username [{user.username}] not found") + return None + except Exception: + log.error(f"Failed to fetch admin user by username [{user.username}]") + return None + + +async def fetch_all_emails(): # list | None + """Fetches all emails from the database. + + Returns: + str[]: Returns a list of all emails if successful, None otherwise. + """ + try: + if not await is_mongodb_running(): + raise Exception("MongoDB is not running") + collection = UsersDB["eventkitstream_users"] + fields_to_include = [ + "login_method", + "local_email", + "twitch_email", + "google_email", + ] + projection = {field: 1 for field in fields_to_include} + projection["_id"] = 0 + result = collection.find({}, projection) + emails = [] + for user in result: + match user["login_method"]: + case "local": + emails.append(user["local_email"]) + case "twitch": + emails.append(user["twitch_email"]) + case "google": + emails.append(user["google_email"]) + case _: + log.error(f"Unknown login method {user['login_method']}") + return None + return emails + except Exception: + log.error("Failed to fetch all emails") + return None diff --git a/server/core/email.py b/server/core/email.py new file mode 100644 index 0000000..4472e09 --- /dev/null +++ b/server/core/email.py @@ -0,0 +1,234 @@ +"""core.EMAIL +File: email.py +Author: LordLumineer +Date: 2024-04-24 + +Purpose: This contains the email sending functions for the API. +""" + +import smtplib +from email.mime.text import MIMEText +from jinja2 import Template + +from models import FetchedUserInDB +from core.config import settings, log +from core.security import Token, TokenData, create_access_token + +sender = settings.EMAIL_ADDRESS +sender_pwd = settings.EMAIL_PASSWORD +smtp_server = settings.EMAIL_SMTP_SERVER +smtp_port = settings.EMAIL_SMTP_PORT + + +async def send_email(receiver: str, subject: str, html_content: str): + """Send a Single Email to a Receiver. + + Args: + receiver (str): Email Address of the Receiver. + subject (str): Subject of the Email. + html_content (str): HTML Content of the Email. + + Returns: + bool: True if Email is Sent Successfully. + """ + html_message = MIMEText(html_content, "html") + html_message["Subject"] = subject + html_message["From"] = sender + html_message["To"] = receiver + + with smtplib.SMTP(host=smtp_server, port=smtp_port) as server: + server.starttls() + server.login(sender, sender_pwd) + server.sendmail(sender, receiver, html_message.as_string()) + server.quit() + log.success(f"Email Sent to {receiver}") + return True + + +async def send_email_all(emails: list[str], subject: str, html_content: str): + """Send a Single Email to Multiple Receivers. (Bulk Emailing) + + Args: + emails (list[str]): List of Email Addresses of the Receivers. + subject (str): Subject of the Email. + html_content (str): HTML Content of the Email. + + Returns: + bool: True if Email is Sent Successfully. + """ + with smtplib.SMTP(host=smtp_server, port=smtp_port) as server: + server.starttls() + server.login(sender, sender_pwd) + for email in emails: + html_message = MIMEText(html_content, "html") + html_message["Subject"] = subject + html_message["From"] = f"EventKit <{sender}>" + html_message["To"] = f"me <{email}>" + html_message["Reply-To"] = sender + server.send_message(html_message) + server.quit() + log.success(f"Email Sent to {emails}") + return True + + +async def send_db_alert_email(receiver: str = settings.ADMIN_EMAIL): + """Send an Email to the Admin when the Database is not Running. + + Args: + receiver (str, optional): Email Address of the Receiver. Defaults to settings.ADMIN_EMAIL. + + Returns: + bool: True if Email is Sent Successfully. + """ + with open("./assets/html/email/DB_not_running.html", "r", encoding="utf-8") as f: + template = Template(f.read()) + context = {"project_name": settings.PROJECT_NAME} + html = template.render(context) + log.info(f"DB not running Email {sender} to {receiver}") + await send_email(receiver, "URGENT - DB not running", html) + return True + + +async def send_test_email(receiver: str): + """Send a Test Email to the Receiver. + + Args: + receiver (str): Email Address of the Receiver. + + Returns: + bool: True if Email is Sent Successfully. + """ + with open("./assets/html/email/test_email.html", "r", encoding="utf-8") as f: + template = Template(f.read()) + context = {"project_name": settings.PROJECT_NAME, "email": sender} + html = template.render(context) + log.info(f"Testing Email {sender} to {receiver}") + await send_email(receiver, "Test Email", html) + return True + + +async def send_reset_password_email(user: FetchedUserInDB): + """Send a Reset Password Email to the User. + + Args: + user (FetchedUserInDB): User that needs to Reset the Password. + + Returns: + bool: True if Email is Sent Successfully. + """ + token: Token = await create_access_token( + subject=TokenData( + uuid=user.uuid, + login_method=user.login_method, + platform_uuid=user.local_id, + username=user.local_username, + ), + expires_delta=15, + ) + + with open("./assets/html/email/reset_password.html", "r", encoding="utf-8") as f: + template = Template(f.read()) + context = { + "project_name": settings.PROJECT_NAME, + "username": user.full_name, + "link": f"{settings.API_URI}/authorize/reset-password?token={token.access_token}", + "valid_minutes": "15", + } + html = template.render(context) + log.info(f"Sending Reset Password Email to {user.local_email}") + await send_email(user.local_email, "Reset Password", html) + return True + + +async def send_verification_email(user: FetchedUserInDB): + """Send a Verification Email to the User. + + Args: + user (FetchedUserInDB): User that needs to Verify the Email. + + Returns: + bool: True if Email is Sent Successfully. + """ + token: Token = await create_access_token( + subject=TokenData( + uuid=user.uuid, + email=user.local_email, + login_method=user.login_method, + platform_uuid=user.local_id, + username=user.local_username, + ), + expires_delta=settings.EMAIL_VERIFICATION_EXPIRE_MINUTES, + ) + expire_hours = int(settings.EMAIL_VERIFICATION_EXPIRE_MINUTES / 60) + with open( + "./assets/html/email/verification_email.html", "r", encoding="utf-8" + ) as f: + template = Template(f.read()) + context = { + "project_name": settings.PROJECT_NAME, + "username": user.full_name, + "email": user.local_email, + "link": f"{settings.API_URI}{settings.API_STR}/local/verify-email?token={token.access_token}", + "valid_hours": str(expire_hours), + } + html = template.render(context) + log.info(f"Sending Verification Email to {user.local_email}") + await send_email(user.local_email, "Verification Email", html) + return True + + +async def send_new_account_email(user: FetchedUserInDB): + """Send a New Account Email to the User. + + Args: + user (FetchedUserInDB): User that has Created a New Account. + + Returns: + bool: True if Email is Sent Successfully. + """ + with open("./assets/html/email/new_account.html", "r", encoding="utf-8") as f: + template = Template(f.read()) + context = { + "project_name": settings.PROJECT_NAME, + "username": user.full_name, + "link": "https://eventkit.stream/dashboard", + } + html = template.render(context) + email = "" + match user.login_method: + case "local": + email = user.local_email + case "google": + email = user.google_email + case "twitch": + email = user.twitch_email + case _: + log.error(f"Unknown login method {user.login_method}") + return False + + log.info(f"Sending New Account Email to {email}") + await send_email(email, "New Account", html) + return True + + +async def send_notification_change_password_email(user: FetchedUserInDB): + """Send a Notification Email to the User when Password is Changed. + + Args: + user (FetchedUserInDB): User that has Changed the Password. + + Returns: + bool: True if Email is Sent Successfully. + """ + with open( + "./assets/html/email/notification_pwd_change.html", "r", encoding="utf-8" + ) as f: + template = Template(f.read()) + context = { + "project_name": settings.PROJECT_NAME, + "username": user.full_name, + } + html = template.render(context) + log.info(f"Sending Change Password Email to {user.local_email}") + await send_email(user.local_email, "Change Password", html) + return True diff --git a/server/core/engine.py b/server/core/engine.py new file mode 100644 index 0000000..a9fc55f --- /dev/null +++ b/server/core/engine.py @@ -0,0 +1,29 @@ +"""core.ENGINE +File: engine.py +Author: LordLumineer +Date: 2024-04-24 + +Purpose: This file contains the Engine Configuration for the API. runs the scheduler for the API. +""" + +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from core.config import log + +engine = AsyncIOScheduler() + + +def start_engine(): + """Start the Engine.""" + log.info("Starting Engine...") + engine.start() + + +def stop_engine(): + """Stop the Engine.""" + current_jobs = engine.get_jobs() + for job in current_jobs: + log.warning(f"Removing Job: {job.id} | {job.name}") + job.remove() + log.warning("Stopping Engine...") + engine.shutdown() diff --git a/server/core/google.py b/server/core/google.py new file mode 100644 index 0000000..81172c4 --- /dev/null +++ b/server/core/google.py @@ -0,0 +1,272 @@ +"""core.GOOGLE +File: google.py +Author: LordLumineer +Date: 2024-05-04 + +Purpose: This file functions and the routines related to twitch. +""" + +from datetime import datetime, timezone +import requests +from authlib.jose import jwt +from fastapi import BackgroundTasks, HTTPException, status + +from models import FetchedUserInDB +from core.db import ( + fetch_local_user_by_email, + fetch_twitch_user_by_email, + remove_user_by_id, + update_user_by_id, +) +from core.config import settings, log +from core.email import send_verification_email +from core.security import TokenData, create_access_token + + +async def decode_id_token(id_token: str): + """Decode the Google ID Token and return the Decoded Data and the User Info Endpoint. + + Args: + id_token (str): The Google ID Token to Decode. + + Returns: + dict: The Decoded ID Token Data. + """ + oidc_server = "accounts.google.com" + oidc_config = requests.get( + url=f"https://{oidc_server}/.well-known/openid-configuration", timeout=10 + ).json() + jwks = requests.get(oidc_config["jwks_uri"], timeout=10).json() + return jwt.decode(id_token, key=jwks), oidc_config["userinfo_endpoint"] + + +async def validate_id_token(scopes: list, code: str, redirect_uri: str, nonce: str): + """Validate the ID Token and return the User Info Data. + + Args: + scopes (list): List of Scopes from Google. + code (str): Code from Google. + redirect_uri (str): Redirect URI from Google. + nonce (str): Nonce to verify the ID Token. + + Raises: + HTTPException: HTTP_400_BAD_REQUEST - Missing openid scope or Missing data. + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - Google API Error. + + Returns: + dict: The User Info Data. + """ + if "openid" not in scopes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Missing openid scope", + "error_description": "The openid scope is required to authenticate with Twitch.", + }, + ) + if "email" not in scopes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Missing email scope", + "error_description": "The email scope is required to authenticate with Twitch.", + }, + ) + if "profile" not in scopes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Missing profile scope", + "error_description": "The profile scope is required to authenticate with Twitch.", + }, + ) + + response = requests.post( + url="https://oauth2.googleapis.com/token", + params={ + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + "nonce": nonce, + }, + timeout=10, + ) + response_data = response.json() + if response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Google API Error", + "error_description": response_data["message"], + }, + ) + + if not response_data["access_token"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Google API Error", + "error_description": "No access token was returned.", + }, + ) + + decoded_id, userinfo_endpoint = await decode_id_token(response_data["id_token"]) + + user_info = requests.get( + url=userinfo_endpoint, + headers={"Authorization": f"Bearer {response_data['access_token']}"}, + timeout=10, + ) + user_info_data = user_info.json() + + try: + assert ( + decoded_id["aud"] == decoded_id["azp"] + ), "The ID token AUD and AZP do not match." + assert ( + decoded_id["iss"] == "https://accounts.google.com" + ), "The ID token and user info do not match. ISS" + assert ( + decoded_id["sub"] == user_info_data["sub"] + ), "The ID token and user info do not match. SUB" + assert ( + decoded_id["email"] == user_info_data["email"] + ), "The ID token and user info do not match. EMAIL" + assert ( + decoded_id["email_verified"] == user_info_data["email_verified"] + ), "The ID token and user info do not match. EMAIL_VERIFIED" + assert ( + decoded_id["name"] == user_info_data["name"] + ), "The ID token and user info do not match. name" + assert ( + decoded_id["given_name"] == user_info_data["given_name"] + ), "The ID token and user info do not match. given_name" + assert ( + decoded_id["family_name"] == user_info_data["family_name"] + ), "The ID token and user info do not match. family_name" + assert ( + decoded_id["nonce"] == nonce + ), "The ID token and user info do not match. NONCE" + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Google API Error", + "error_description": e, + }, + ) from e + + return user_info_data + + +async def link_to_twitch( + user_info_data: dict, existing_user: FetchedUserInDB | None = None +): + """Check if the User is already in the Database and Link the Google Account with the Twitch Account. + + Args: + user_info_data (dict): The User Info Data from Google. + existing_user (FetchedUserInDB, optional): The Existing User in the Database to remove once the new user is created. + + Raises: + HTTPException: HTTP_400_BAD_REQUEST - Twitch Email not verified. + + Returns: + Token: {"access_token": str, "token_type": str} + """ + twitch_user = await fetch_twitch_user_by_email(user_info_data["email"]) + if twitch_user: + if not (twitch_user.twitch_email_verified and user_info_data["email_verified"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Twitch Email not verified", + "error_description": "You need to have a verified Twitch and a verified Google email address account to link this account.", + }, + ) + save_user = twitch_user + twitch_user.updated_at = str(int(datetime.now(timezone.utc).timestamp())) + twitch_user.google_id = user_info_data["sub"] + twitch_user.google_username = user_info_data["name"].lower().replace(" ", "_") + twitch_user.google_email = user_info_data["email"] + twitch_user.google_email_verified = user_info_data["email_verified"] + updated_user = await update_user_by_id(twitch_user) + if not updated_user: + log.warning("Failed to update user") + else: + save_user = updated_user + if existing_user: + if not await remove_user_by_id(existing_user): + log.warning( + f"Failed to remove user {existing_user.uuid} after linking Google with Twitch." + ) + return await create_access_token( + subject=TokenData( + uuid=save_user.uuid, + login_method=save_user.login_method, + platform_uuid=save_user.twitch_id, + username=save_user.twitch_username, + email=save_user.twitch_email, + ) + ) + return None + + +async def link_to_local( + user_info_data: dict, + background_tasks: BackgroundTasks, + existing_user: FetchedUserInDB | None = None, +): + """Check if the User is already in the Database and Link the Google Account with the Local Account. + + Args: + user_info_data (dict): Google User Info Data. + background_tasks (BackgroundTasks): Background Task to Send Verification Email. + existing_user (FetchedUserInDB, optional): The Existing User in the Database to remove once the new user is created. + + Raises: + HTTPException: HTTP_400_BAD_REQUEST - Local Email not verified. + + Returns: + Token: {"access_token": str, "token_type": str} + """ + local_user = await fetch_local_user_by_email(user_info_data["email"]) + if local_user: + if not (local_user.local_email_verified and user_info_data["email_verified"]): + background_tasks.add_task(send_verification_email, local_user) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Local Email not verified", + "error_description": "You need to have a verified Local and a verified Google email address account to link this account.", + }, + ) + save_user = local_user + local_user.updated_at = str(int(datetime.now(timezone.utc).timestamp())) + local_user.google_id = user_info_data["sub"] + local_user.google_username = user_info_data["name"].lower().replace(" ", "_") + local_user.google_email = user_info_data["email"] + local_user.google_email_verified = user_info_data["email_verified"] + updated_user = await update_user_by_id(local_user) + if not updated_user: + log.warning("Failed to update user") + else: + save_user = updated_user + if existing_user: + if not await remove_user_by_id(existing_user): + log.warning( + f"Failed to remove user {existing_user.uuid} after linking Google with Local." + ) + return await create_access_token( + subject=TokenData( + uuid=save_user.uuid, + login_method=save_user.login_method, + platform_uuid=save_user.local_id, + username=save_user.local_username, + email=save_user.local_email, + ) + ) + return None diff --git a/server/core/security.py b/server/core/security.py new file mode 100644 index 0000000..883a06a --- /dev/null +++ b/server/core/security.py @@ -0,0 +1,123 @@ +"""core.SECURITY +File: security.py +Author: LordLumineer +Date: 2024-04-24 + +Purpose: This file handles the security of the API (creation, and validation of JWT, and hash and verification of passwords). +""" + +from datetime import datetime, timedelta, timezone +import bcrypt +from authlib.jose import jwt +from pydantic import BaseModel +from fastapi.security import OAuth2PasswordBearer + +from core.config import settings, log + + +ALGORITHM = settings.JWT_ALGORITHM +SECRET_KEY = settings.JWT_SECRET +ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES + +oauth2_scheme_local = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_STR}/local/login", + scheme_name="local", + scopes={"local": "Local access"}, + description="Local access token", + auto_error=True, +) + + +class TokenData(BaseModel): + """Token Data Model""" + + uuid: str + login_method: str + platform_uuid: str + username: str + email: str + + +class Token(BaseModel): + """Token Model""" + + access_token: str + token_type: str + + +async def create_access_token( + subject: TokenData, + expires_delta: timedelta = ACCESS_TOKEN_EXPIRE_MINUTES, + secret_key: str = SECRET_KEY, +): + """Create a new access token for the user + + Args: + subject (TokenData): Subject to be encoded in the token. + expires_delta (timedelta, optional): expiration time of the token in minutes (default: 30 minutes) + secret_key (str, optional): Secret key to use to encode the token. Defaults to SECRET_KEY. + + Returns: + Token: {"access_token": str, "token_type": str} + """ + header = {"alg": ALGORITHM, "token_type": "bearer"} + payload = { + "iss": settings.JWT_ISSUER, + "sub": str(subject), + "exp": str(int(timedelta(minutes=expires_delta).total_seconds())), + "iat": str(int(datetime.now(timezone.utc).timestamp())), + } + encoded_jwt = jwt.encode(header, payload, secret_key) + return Token(access_token=encoded_jwt, token_type="bearer") + + +async def decode_access_token(token: str, secret_key: str = SECRET_KEY): + """Decode the access token and validate it + + Args: + token (str): Token to decode. + secret_key (str, optional): Secret key to use to decode the token. Defaults to SECRET_KEY. + + Raises: + Exception: Exception - Invalid token | {e} (to be cached by the caller) + + Returns: + TokenData: {"uuid": str, "login_method": str, "platform_uuid": str, "username": str, "email": str} + """ + try: + claims = jwt.decode(s=token, key=secret_key) + except (Exception, not claims) as e: + log.warning(f"Invalid token | {e}") + raise Exception(f"Invalid token | {e}") from e + if claims["iss"] != settings.JWT_ISSUER: + log.warning(f"Invalid issuer | {claims['iss']}") + raise Exception(f"Invalid issuer | {claims['iss']}") + iat = datetime.fromtimestamp(int(claims["iat"]), timezone.utc) + if iat > datetime.now(timezone.utc): + log.debug( + f"Token issued in the future | {iat} | {datetime.now(timezone.utc).timestamp()}" + ) + raise Exception("Token issued in the future") + exp = timedelta(seconds=int(claims["exp"])) + if iat + exp < datetime.now(timezone.utc): + log.debug( + f"Token has expired | {iat+exp} | {datetime.now(timezone.utc).timestamp()}" + ) + raise Exception("Token has expired") + claims["sub"] = TokenData( + **dict(item.split("=") for item in claims["sub"].replace("'", "").split()) + ) + return claims + + +async def verify_password(plain_password: str, hashed_password: str) -> bool: + """Check that an unencrypted password matches one that has previously been hashed""" + return bcrypt.checkpw( + plain_password.encode("utf-8"), hashed_password.encode("utf-8") + ) + + +async def get_password_hash(password: str) -> str: + """Hash a password for the first time, with a randomly-generated salt""" + hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + return hashed.decode("utf-8") diff --git a/server/core/twitch.py b/server/core/twitch.py new file mode 100644 index 0000000..02cf486 --- /dev/null +++ b/server/core/twitch.py @@ -0,0 +1,272 @@ +"""core.TWITCH +File: twitch.py +Author: LordLumineer +Date: 2024-05-04 + +Purpose: This file functions and the routines related to twitch. +""" + +from datetime import datetime, timezone +import requests +from authlib.jose import jwt +from fastapi import BackgroundTasks, HTTPException, status + +from core.config import settings, log +from core.db import ( + fetch_google_user_by_email, + fetch_local_user_by_email, + remove_user_by_id, + update_user_by_id, +) +from core.email import send_verification_email +from core.security import TokenData, create_access_token +from models import FetchedUserInDB + + +async def decode_id_token(id_token: str): + """Decode the Twitch ID Token and return the Decoded Data and the User Info Endpoint. + + Args: + id_token (str): The Google ID Token to Decode. + + Returns: + dict: The Decoded ID Token Data. + """ + oidc_server = "id.twitch.tv/oauth2" + oidc_config = requests.get( + url=f"https://{oidc_server}/.well-known/openid-configuration", timeout=10 + ).json() + jwks = requests.get(oidc_config["jwks_uri"], timeout=10).json() + return jwt.decode(id_token, key=jwks) + + +async def validate_id_token(scopes: list, code: str, redirect_uri: str, nonce: str): + """Validate the ID Token and return the User Info Data. + + Args: + scopes (list): List of Scopes from Twitch. + code (str): Code from Twitch. + redirect_uri (str): Redirect URI from Twitch. + nonce (str): Nonce to verify the ID Token. + + Raises: + HTTPException: HTTP_400_BAD_REQUEST - Missing openid scope or Missing data. + HTTPException: HTTP_500_INTERNAL_SERVER_ERROR - Twitch API Error. + + Returns: + dict: The User Info Data. + """ + if "openid" not in scopes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Missing openid scope", + "error_description": "The openid scope is required to authenticate with Twitch.", + }, + ) + if "user:read:email" not in scopes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Missing user:read:email scope", + "error_description": "The user:read:email scope is required to authenticate with Twitch.", + }, + ) + claims = { + "id_token": {"email": None, "preferred_username": None, "email_verified": None}, + "userinfo": { + "email": None, + "email_verified": None, + "picture": None, + "preferred_username": None, + "updated_at": None, + }, + } + + response = requests.post( + url="https://id.twitch.tv/oauth2/token", + params={ + "client_id": settings.TWITCH_CLIENT_ID, + "client_secret": settings.TWITCH_CLIENT_SECRET, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + "claims": claims, + "nonce": nonce, + }, + timeout=10, + ) + response_data = response.json() + + if response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Twitch API Error", + "error_description": response_data["message"], + }, + ) + if not response_data["access_token"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Twitch API Error", + "error_description": "No access token was returned.", + }, + ) + + decoded_id = await decode_id_token(response_data["id_token"]) + + user_info = requests.get( + url="https://id.twitch.tv/oauth2/userinfo", + headers={"Authorization": f"Bearer {response_data['access_token']}"}, + timeout=10, + ) + user_info_data = user_info.json() + # validate_id_token + + try: + assert ( + decoded_id["aud"] == user_info_data["aud"] + ), "The ID token and user info do not match. AUD" + assert ( + decoded_id["iss"] == user_info_data["iss"] + ), "The ID token and user info do not match. ISS" + assert ( + decoded_id["sub"] == user_info_data["sub"] + ), "The ID token and user info do not match. SUB" + assert ( + decoded_id["email"] == user_info_data["email"] + ), "The ID token and user info do not match. EMAIL" + assert ( + decoded_id["email_verified"] == user_info_data["email_verified"] + ), "The ID token and user info do not match. EMAIL_VERIFIED" + assert ( + decoded_id["preferred_username"] == user_info_data["preferred_username"] + ), "The ID token and user info do not match. PREFERRED_USERNAME" + assert ( + decoded_id["nonce"] == nonce + ), "The ID token and user info do not match. NONCE" + + except AssertionError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Twitch API Error", + "error_description": e, + }, + ) from e + + return user_info_data + + +async def link_to_google( + user_info_data: dict, existing_user: FetchedUserInDB | None = None +): + """Check if the User is already in the Database and Link the Twitch Account with the Google Account. + + Args: + user_info_data (dict): The User Info Data from Twitch. + existing_user (FetchedUserInDB, optional): The Existing User in the Database to remove once the new user is created. + + Raises: + HTTPException: HTTP_400_BAD_REQUEST - Google Email not verified. + + Returns: + Token: {"access_token": str, "token_type": str} + """ + google_user = await fetch_google_user_by_email(user_info_data["email"]) + if google_user: + if not (google_user.google_email_verified and user_info_data["email_verified"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Google Email not verified", + "error_description": "You need to have a verified Google and a verified Twitch email address account to link this account.", + }, + ) + save_user = google_user + google_user.updated_at = str(int(datetime.now(timezone.utc).timestamp())) + google_user.twitch_id = user_info_data["sub"] + google_user.twitch_username = user_info_data["preferred_username"].lower() + google_user.twitch_email = user_info_data["email"] + google_user.twitch_email_verified = user_info_data["email_verified"] + + updated_user = await update_user_by_id(google_user) + if not updated_user: + log.warning("Failed to update user") + else: + save_user = updated_user + if existing_user: + if not await remove_user_by_id(existing_user): + log.warning( + f"Failed to remove user {existing_user.uuid} after linking Twitch with Google." + ) + + return await create_access_token( + subject=TokenData( + uuid=save_user.uuid, + login_method=save_user.login_method, + platform_uuid=save_user.google_id, + username=save_user.google_username, + email=save_user.google_email, + ) + ) + return None + + +async def link_to_local( + user_info_data: dict, + background_tasks: BackgroundTasks, + existing_user: FetchedUserInDB | None = None, +): + """Check if the User is already in the Database and Link the Twitch Account with the Local Account. + + Args: + user_info_data (dict): Twitch User Info Data. + background_tasks (BackgroundTasks): Background Task to Send Verification Email. + existing_user (FetchedUserInDB, optional): The Existing User in the Database to remove once the new user is created. + + Raises: + HTTPException: HTTP_400_BAD_REQUEST - Local Email not verified. + + Returns: + Token: {"access_token": str, "token_type": str} + """ + local_user = await fetch_local_user_by_email(user_info_data["email"]) + if local_user: + if not (local_user.local_email_verified and user_info_data["email_verified"]): + background_tasks.add_task(send_verification_email, local_user) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Local Email not verified", + "error_description": "You need to have a verified Local and a verified Twitch email address account to link this account.", + }, + ) + save_user = local_user + local_user.updated_at = str(int(datetime.now(timezone.utc).timestamp())) + local_user.twitch_id = user_info_data["sub"] + local_user.twitch_username = user_info_data["preferred_username"].lower() + local_user.twitch_email = user_info_data["email"] + local_user.twitch_email_verified = user_info_data["email_verified"] + updated_user = await update_user_by_id(local_user) + if not updated_user: + log.warning("Failed to update user") + else: + save_user = updated_user + if existing_user: + if not await remove_user_by_id(existing_user): + log.warning( + f"Failed to remove user {existing_user.uuid} after linking Twitch with Local." + ) + return await create_access_token( + subject=TokenData( + uuid=save_user.uuid, + login_method=save_user.login_method, + platform_uuid=save_user.local_id, + username=save_user.local_username, + email=save_user.local_email, + ) + ) + return None diff --git a/server/db/backup/1008720000_EXAMPLE_backup/profile_pictures/123456789123456789_pfp_test_user_1008720000_EXAMPLE_pfp.png b/server/db/backup/1008720000_EXAMPLE_backup/profile_pictures/123456789123456789_pfp_test_user_1008720000_EXAMPLE_pfp.png new file mode 100644 index 0000000000000000000000000000000000000000..3fd9c0b2507f85cb251bd44141f89d181e24b327 GIT binary patch literal 34494 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 bgYOdmv%m~D&GWOE|89vk|ElzVn*#p>)Zza# literal 0 HcmV?d00001 diff --git a/server/db/backup/1008720000_EXAMPLE_backup/users_eventkitstream_users_backup.json b/server/db/backup/1008720000_EXAMPLE_backup/users_eventkitstream_users_backup.json new file mode 100644 index 0000000..27f62f3 --- /dev/null +++ b/server/db/backup/1008720000_EXAMPLE_backup/users_eventkitstream_users_backup.json @@ -0,0 +1,43 @@ +{ + "eventkitstream_users": [ + { + "_id": "000000000000111111111111", + "disabled": true, + "updated_at": "1008720000", + "login_method": "local", + "full_name": "Test useR", + "picture_id": "123456789123456789", + "local_id": "112233445566778899", + "local_username": "test_user", + "local_email": "user@example.com", + "local_email_verified": false, + "hashed_password": "$2b$12$T/P/5ZWxbwb2DemnMfgyM.o.TKElOQG88JEJ7.GnrobltFW0LQ4ze", + "twitch_id": null, + "twitch_username": null, + "twitch_email": null, + "twitch_email_verified": null, + "twitch_scope": null, + "google_id": null, + "google_username": null, + "google_email": null, + "google_email_verified": null, + "google_scope": null + } + ], + "admin_users": [ + { + "_id": "662aa03389912ec8203e642b", + "username": "admin@example.com", + "hashed_password": "$2b$12$T/P/5ZWxbwb2DemnMfgyM.o.TKElOQG88JEJ7.GnrobltFW0LQ4ze" + } + ], + "profile_pictures": [ + { + "metadata": { + "_id": "123456789123456789", + "name": "pfp_test_user_1008720000_EXAMPLE_pfp.png" + }, + "file_path": "./db/backup/1008720000_users_backup/profile_pictures/123456789123456789_pfp_test_user_1008720000_EXAMPLE_pfp.png" + } + ] +} \ No newline at end of file diff --git a/server/db/data/MakeDataVisibleInGit b/server/db/data/MakeDataVisibleInGit new file mode 100644 index 0000000..5fbfd4b --- /dev/null +++ b/server/db/data/MakeDataVisibleInGit @@ -0,0 +1 @@ +# This Directory is ONLY for the live data of the Database (when linked with docker-compose). diff --git a/server/db/logs/EXAMPLE.log b/server/db/logs/EXAMPLE.log new file mode 100644 index 0000000..dc9b27b --- /dev/null +++ b/server/db/logs/EXAMPLE.log @@ -0,0 +1,37 @@ +{ + "text": "DATE TIME | LEVEL | File:Function:Line - Text", + "record": { + "elapsed": { + "repr": "0:00:04.127693", + "seconds": 4.127693 + }, + "exception": null, + "extra": {}, + "file": { + "name": "File.py", + "path": "path to the file/File.py" + }, + "function": "Function", + "level": { + "icon": "ℹ️", + "name": "INFO", + "no": 20 + }, + "line": Line, + "message": "Text", + "module": "Module", + "name": "File", + "process": { + "id": 25740, + "name": "SpawnProcess-1" + }, + "thread": { + "id": 16432, + "name": "MainThread" + }, + "time": { + "repr": "DATE TIME", + "timestamp": timestamp + } + } +} diff --git a/server/dockerfile b/server/dockerfile new file mode 100644 index 0000000..0238bff --- /dev/null +++ b/server/dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12.3-alpine AS production-stage +RUN python -m pip install --upgrade pip +WORKDIR /server +COPY requirements.txt ./ +RUN pip install -r requirements.txt +COPY . . + +EXPOSE 81 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "81"] diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..21bca9c --- /dev/null +++ b/server/main.py @@ -0,0 +1,269 @@ +"""MAIN +File: main.py +Author: LordLumineer +Date: 2024-04-24 + +Purpose: This file is the main entry point for the FastAPI application. + It creates the FastAPI instance, sets up the CORS middleware, and includes the API router. + It also defines the custom_generate_unique_id function to generate unique IDs for routes. +""" + +import os +import signal +from datetime import datetime +from contextlib import asynccontextmanager +from fastapi import FastAPI, HTTPException, Request, status +from fastapi.routing import APIRoute +from fastapi.responses import FileResponse, RedirectResponse +from fastapi.openapi.docs import ( + get_redoc_html, + get_swagger_ui_html, + get_swagger_ui_oauth2_redirect_html, +) +from starlette.middleware.cors import CORSMiddleware + +from api.main import api_router +from api.routes.admin import get_current_admin_user +from core.config import settings, log +from core.db import disconnect, startup_auth_db +from core.engine import start_engine, engine, stop_engine + +# [ ]: Implement Logging +# TODO: CI/CD Pipeline Testing +# TODO: Write the Unit Tests. + + +# from asyncio import sleep +# from tqdm import tqdm, trange +# delay_seconds = 15 +# progress_step = 0.1 +# pbar = tqdm(total=delay_seconds, +# desc=f"Starting in {delay_seconds} seconds...", +# position=0, +# leave=True, +# bar_format="{desc}: {remaining}s remaining |{percentage:3.0f}% {bar}" +# ) +# for i in range(delay_seconds*int(1/progress_step)): +# await sleep(progress_step) +# pbar.update(progress_step) +# pbar.close() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """This function is called when the application starts and stops. It is used to start and stop the engine.""" + log.info(f"{app.title} - is starting...") + + def tick(): + log.debug(f"Tick! The time is: {datetime.now()}") + + start_engine() + engine.add_job( + func=tick, + trigger="interval", + hours=1, + name="Tick Job", + id="tick_job", + replace_existing=True, + ) + if not await startup_auth_db(): + log.critical("Database is not running...") + os.kill(os.getpid(), signal.SIGTERM) + os._exit(1) + yield # This is when the application code will run + log.info(f"{app.title} - Shutting down...") + stop_engine() + await disconnect() + + +def custom_generate_unique_id(route: APIRoute) -> str: + """Custom function to generate unique IDs for routes.""" + return f"{route.tags[0]}-{route.name}" + + +tags_metadata = [ + { + "name": "users", + "description": "Operations with the user(s), regarding the Non-Critical Information.", + }, + { + "name": "login", + "description": """Handles the backup login/register pages (local users ONLY). +
It can be used to validate the token.""", + "externalDocs": { + "description": "OAuth2", + "url": "https://datatracker.ietf.org/doc/html/rfc6749", + }, + }, + { + "name": "local", + "description": """Handles sensitive endpoints for Local Users. (authentication/registration/removal/password-updates) +
It supports OAuth2 with password flow.""", + "externalDocs": { + "description": "OAuth2", + "url": "https://datatracker.ietf.org/doc/html/rfc6749", + }, + }, + { + "name": "twitch", + "description": "Operations regarding Twitch API.", + "externalDocs": { + "description": "OIDC authorization code grant flow", + "url": "https://dev.twitch.tv/docs/authentication/getting-tokens-oidc/#oidc-authorization-code-grant-flow", + }, + }, + { + "name": "google", + "description": "Operations regarding Google API.", + "externalDocs": { + "description": "Using OAuth 2.0 to Access Google APIs", + "url": "https://developers.google.com/identity/protocols/oauth2", + }, + }, + {"name": "admin", "description": "Connection for the Admins"}, + { + "name": "utils", + "description": "Utility functions for the API. Only accessible by the Admins.", + }, +] + +# Create an instance of FastAPI +app = FastAPI( + title=settings.PROJECT_NAME, + summary="Event Kit Stream Auth API - FastAPI Implementation", + description=""" + This api is used to authenticate users and manage user accounts for the Event Kit Stream application. + The API provides endpoints for user registration, login, and account management. + User data related to the Events is not managed by this API. refer to the Event Kit Stream API for that. + """, + version=settings.VERSION, + openapi_tags=tags_metadata, + docs_url=None, # "/documentation", + redoc_url=None, # "/redocumentation", + terms_of_service="https://legal.eventkit.stream/terms", # TODO: Create the website + contact={ + "name": "Support", + "url": "https://github.com/EventKit-Stream/Auth-API/issues", + "email": "support@evnentkit.stream", + }, + license_info={ + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html", + "identifier": "Apache-2.0", + }, + generate_unique_id_function=custom_generate_unique_id, + lifespan=lifespan, +) +# Set all CORS enabled origins +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router, prefix=settings.API_STR) + + +@app.get("/", include_in_schema=False, tags=["misc"]) +async def root(request: Request): + """Root endpoint for the API.""" + url_components = request.url.components + return RedirectResponse( + url=f"{url_components.scheme}://{url_components.hostname}/authorize", + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) + + +@app.get("/favicon.ico", include_in_schema=False, tags=["misc"]) +async def favicon(): + """Return the favicon for the API.""" + return FileResponse( + path="./assets/favicon.ico", + media_type="image/x-icon", + status_code=status.HTTP_200_OK, + ) + + +@app.get(f"{settings.API_STR}/admin/docs", include_in_schema=False, tags=["admin"]) +async def custom_swagger_ui_html(token: str | None = None): + """Custom Swagger UI HTML page for the Admins.""" + if not token: + return RedirectResponse( + url=f"{settings.API_STR}/admin/login", + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) + token_type = token.split(" ")[0] + if token_type.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "Invalid token type.", + "error_description": "Expected: 'bearer '.", + }, + ) + access_token = token.split(" ")[1] + try: + if await get_current_admin_user(access_token): + return get_swagger_ui_html( + openapi_url=app.openapi_url, + title=app.title + " - Swagger UI", + oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, + swagger_js_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js", + swagger_css_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css", + ) + except HTTPException: + return RedirectResponse( + url=f"{settings.API_STR}/admin/login", + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) + +@app.get("/api_str", include_in_schema=False, tags=["misc"]) +async def api_str(): + """Return the API_STR.""" + return {'api_str': settings.API_STR} + +@app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False, tags=["admin"]) +async def swagger_ui_redirect(): + """Redirect to the Swagger UI OAuth2 redirect page.""" + return get_swagger_ui_oauth2_redirect_html() + + +@app.get(f"{settings.API_STR}/admin/redoc", include_in_schema=False, tags=["admin"]) +async def redoc_html(token: str | None = None): + """Custom ReDoc HTML page for the Admins.""" + if not token: + return RedirectResponse( + url=f"{settings.API_STR}/admin/login?redoc=true", + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) + token_type = token.split(" ")[0] + if token_type.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "Invalid token type.", + "error_description": "Expected: 'bearer '.", + }, + ) + access_token = token.split(" ")[1] + try: + if await get_current_admin_user(access_token): + return get_redoc_html( + openapi_url=app.openapi_url, + title=app.title + " - ReDoc", + redoc_js_url="https://unpkg.com/redoc@next/bundles/redoc.standalone.js", + ) + except HTTPException: + return RedirectResponse( + url=f"{settings.API_STR}/admin/login", + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) + + +# Run the app using uvicorn +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=20) diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..2586903 --- /dev/null +++ b/server/models.py @@ -0,0 +1,60 @@ +"""MODELS +File: models.py +Author: LordLumineer +Date: 2024-04-24 + +Purpose: This file contains the Pydantic models for the API and the DataBase. +""" + +from pydantic import BaseModel, EmailStr + + +# ~~ Users ~~ # +class User(BaseModel): + """Common parts of the User Pydantic model for the API.""" + disabled: bool | None = None + updated_at: str | None = None + login_method: str | None = None + full_name: str | None = None + picture_id: str | None = None + + +class LocalUser(BaseModel): + """Local parts of the User Pydantic model for the API.""" + local_id: str | None = None + local_username: str | None = None + local_email: EmailStr | None = None + local_email_verified: bool | None = None + hashed_password: str | None = None + + +class TwitchUser(BaseModel): + """Twitch parts of the User Pydantic model for the API.""" + twitch_id: str | None = None + twitch_username: str | None = None + twitch_email: EmailStr | None = None + twitch_email_verified: bool | None = None + twitch_scope: list[str] | None = ["user:read:email", "openid"] + + +class GoogleUser(BaseModel): + """Google parts of the User Pydantic model for the API.""" + google_id: str | None = None + google_username: str | None = None + google_email: EmailStr | None = None + google_email_verified: bool | None = None + google_scope: list[str] | None = None + + +class UserInDB(User, LocalUser, TwitchUser, GoogleUser): + """User Pydantic model for the DataBase.""" + +class FetchedUserInDB(UserInDB): + """User Pydantic model for the DataBase with the UUID.""" + uuid: str | None = None + + +class AdminUser(BaseModel): + """Admin User Pydantic model for the API.""" + username: str | None = None + hashed_password: str | None = None diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..9035601 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,17 @@ +APScheduler==3.10.4 +asyncio==3.4.3 +Authlib==1.3.0 +bcrypt==4.1.3 +DateTime==5.5 +email_validator==2.1.1 +Jinja2==3.1.3 +fastapi==0.110.3 +loguru==0.7.2 +pillow==10.3.0 +pydantic-settings==2.2.1 +pymongo==4.7.1 +python-multipart==0.0.9 +requests==2.31.0 +# tqdm==4.66.4 +uuid==1.30 +uvicorn==0.28.1 \ No newline at end of file diff --git a/server/test.py b/server/test.py new file mode 100644 index 0000000..864eab6 --- /dev/null +++ b/server/test.py @@ -0,0 +1,20 @@ +from fastapi import FastAPI +from fastapi.responses import HTMLResponse +import requests + + +app = FastAPI( + root_path="/api" +) + + +@app.get("/testtest", tags=["admin"]) +async def testtest(): + return {"message": "Hello World"} + return HTMLResponse(requests.get(url='http://localhost:26969/authorize').content) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=20) diff --git a/server/test/__init__.py b/server/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/test/requirements.txt b/server/test/requirements.txt new file mode 100644 index 0000000..c153dfb --- /dev/null +++ b/server/test/requirements.txt @@ -0,0 +1,3 @@ +coverage==7.5.1 +pylint==3.1.0 +pytest==8.2.0 diff --git a/server/test/test_main.py b/server/test/test_main.py new file mode 100644 index 0000000..efbf682 --- /dev/null +++ b/server/test/test_main.py @@ -0,0 +1,4 @@ +from main import app +def test_main(): + """Test the main function""" + assert True \ No newline at end of file