Compare commits
10 Commits
69debdb213
...
fe23b07e4e
Author | SHA1 | Date | |
---|---|---|---|
fe23b07e4e | |||
|
3c78fe9133 | ||
|
026be1328e | ||
|
0c42cf5620 | ||
|
8a695f2efb | ||
|
c641ddd47d | ||
|
ad5596c61c | ||
|
8ef0ccf32e | ||
|
574a9d17dd | ||
|
61bac977ff |
37
README.md
37
README.md
@ -2,37 +2,46 @@
|
||||
|
||||
## Frontend
|
||||
|
||||
### Development dependencies
|
||||
### Development Workflow
|
||||
|
||||
- `prettier`: Formatter
|
||||
- Format, lint and test with `npm run {format, lint, test}`.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- React
|
||||
- React helment async: manage changes to document head.
|
||||
- Material UI
|
||||
- Roboto font
|
||||
|
||||
## Backend
|
||||
|
||||
### Development workflow
|
||||
### Development Workflow
|
||||
|
||||
- Run `eval (pdm venv activate in-project)` (if you are using Fish shell) or `eval $(pdm venv activate in-project)` (if you are using bash/zsh) at the `backend` folder root.
|
||||
- Run `eval (pdm venv activate in-project)` (if you are using Fish shell) or `eval $(pdm venv activate in-project)` (if you are using bash/zsh) at the `backend` folder root to get into the backend python virtual environment.
|
||||
- Run tests, lints, formats with `pdm run {test, lint, format}`.
|
||||
|
||||
### Dependencies
|
||||
|
||||
#### Python dependencies
|
||||
|
||||
- `quart`: a micro-webframework, async version of Flask.
|
||||
- `black`: Code formatter
|
||||
- `isort`: Import formatter
|
||||
- `mypy`: Type checking
|
||||
- `flake8`: General Python bugs
|
||||
- `vulture`: Find unused code in Python programs
|
||||
- `pytest`: For testing (turbocharged with `async`)
|
||||
- `black`: Code formatter.
|
||||
- `isort`: Import formatter.
|
||||
- `mypy`: Type checking.
|
||||
- `flake8`: General Python bugs.
|
||||
- `vulture`: Find unused code in Python programs.
|
||||
- `pytest`: For testing (turbocharged with `async`).
|
||||
- `bcrypt`: Hashing and salting password.
|
||||
- `zxcvbn`: Test password strength.
|
||||
- `freezegun`: Check for expired token.
|
||||
- `quart-rate-limiter`: Rate limiting
|
||||
- `pydantic` and `quart-schema`: Request/Response validation
|
||||
- `quart-rate-limiter`: Rate limiting.
|
||||
- `pydantic` and `quart-schema`: Request/Response validation.
|
||||
- `httpx`: send HTTP POST requests from our app.
|
||||
|
||||
#### SQL Dev-deps
|
||||
|
||||
- `bandit`: Check for SQL injection vulnerabilities
|
||||
- `bandit`: Check for SQL injection vulnerabilities.
|
||||
|
||||
#### Miscs Dev-deps
|
||||
|
||||
- `djhtml`: Generate jinja templates html for emails
|
||||
- `djhtml`: Generate jinja templates html for emails.
|
||||
|
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@ -14,7 +14,6 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
36
backend/README.md
Normal file
36
backend/README.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Backend Technical Write-up
|
||||
|
||||
## Steps
|
||||
|
||||
- Initial infrastructure
|
||||
- Rate Limiting
|
||||
- JSON error handling
|
||||
- Req/Res schema validation
|
||||
- Setup database
|
||||
- Database schema/models
|
||||
- Usersessions
|
||||
- CRUD routes functionalities
|
||||
|
||||
## Quart specific terminologies
|
||||
|
||||
`blueprint`: a collection of route handlers/API functionalities.
|
||||
|
||||
## API route trailing slashes
|
||||
|
||||
API paths should end with a slash i.e: `/sessions/` rather than `/session`.
|
||||
This is because requests sent to `/sessions` will be redirected to `/sessions/` whereas `/sessions/` won't get redirected.
|
||||
|
||||
## Difference between database schema and database model
|
||||
|
||||
- A schema defines the structure of data within the database.
|
||||
- A model is a class that can be represented as rows in the database, i.e ID row, age row as class member.
|
||||
|
||||
## Managing user's sessions (Authentication)
|
||||
|
||||
- Login should results in a cookie being set in the user's browser, which is being sent in every subsequent request.
|
||||
The presence and value of this cookie are used to determine whether the member is logged in, and which member made the request.
|
||||
- Logout results in the cookie being deleted.
|
||||
|
||||
## Idempotent routes
|
||||
|
||||
Idempotence is a property of a route where the final state is achieved no matter how many times the route is called, that is, calling the route once or 10 times has the same effect. This is a useful property as it means the route can be safely retried if the request fails. For RESTful and HTTP APIs, the routes using GET, PUT, and DELETE verbs are expected to be idempotent.
|
@ -2,6 +2,7 @@ TODO_BASE_URL="localhost:5050"
|
||||
TODO_DEBUG=true
|
||||
TODO_SECRET_KEY="secret key"
|
||||
TODO_QUART_DB_DATABASE_URL="postgresql://todo:todo@0.0.0.0:5432/todo"
|
||||
TODO_QUART_DB_DATA_PATH="migrations/data.py"
|
||||
|
||||
# disable for when we not using HTTPS
|
||||
TODO_QUART_AUTH_COOKIE_SECURE=false
|
||||
|
95
backend/pdm.lock
generated
95
backend/pdm.lock
generated
@ -4,6 +4,16 @@ version = "22.1.0"
|
||||
requires_python = ">=3.7,<4.0"
|
||||
summary = "File support for asyncio."
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "3.6.2"
|
||||
requires_python = ">=3.6.2"
|
||||
summary = "High level compatibility layer for multiple asynchronous event loop implementations"
|
||||
dependencies = [
|
||||
"idna>=2.8",
|
||||
"sniffio>=1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncpg"
|
||||
version = "0.27.0"
|
||||
@ -59,6 +69,12 @@ version = "0.4"
|
||||
requires_python = ">=3.6"
|
||||
summary = "Query building for the postgresql prepared statements and asyncpg."
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2022.12.7"
|
||||
requires_python = ">=3.6"
|
||||
summary = "Python package for providing Mozilla's CA Bundle."
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.3"
|
||||
@ -161,6 +177,30 @@ version = "4.0.0"
|
||||
requires_python = ">=3.6.1"
|
||||
summary = "Pure-Python HPACK header compression"
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "0.16.2"
|
||||
requires_python = ">=3.7"
|
||||
summary = "A minimal low-level HTTP client."
|
||||
dependencies = [
|
||||
"anyio<5.0,>=3.0",
|
||||
"certifi",
|
||||
"h11<0.15,>=0.13",
|
||||
"sniffio==1.*",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.23.1"
|
||||
requires_python = ">=3.7"
|
||||
summary = "The next generation HTTP client."
|
||||
dependencies = [
|
||||
"certifi",
|
||||
"httpcore<0.17.0,>=0.15.0",
|
||||
"rfc3986[idna2008]<2,>=1.3",
|
||||
"sniffio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hypercorn"
|
||||
version = "0.14.3"
|
||||
@ -427,6 +467,21 @@ dependencies = [
|
||||
"quart>=0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc3986"
|
||||
version = "1.5.0"
|
||||
summary = "Validating URI References per RFC 3986"
|
||||
|
||||
[[package]]
|
||||
name = "rfc3986"
|
||||
version = "1.5.0"
|
||||
extras = ["idna2008"]
|
||||
summary = "Validating URI References per RFC 3986"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"rfc3986==1.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.16.0"
|
||||
@ -439,6 +494,12 @@ version = "5.0.0"
|
||||
requires_python = ">=3.6"
|
||||
summary = "A pure Python implementation of a sliding window memory map manager"
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.0"
|
||||
requires_python = ">=3.7"
|
||||
summary = "Sniff out which async library your code is running under"
|
||||
|
||||
[[package]]
|
||||
name = "stevedore"
|
||||
version = "4.1.1"
|
||||
@ -493,15 +554,24 @@ dependencies = [
|
||||
"h11<1,>=0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zxcvbn"
|
||||
version = "4.4.28"
|
||||
summary = ""
|
||||
|
||||
[metadata]
|
||||
lock_version = "4.1"
|
||||
content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c2474b52"
|
||||
content_hash = "sha256:6d55ad044722dfe8cdb3f4b69623fec92653fe73f9ee7ae12717c06912aa3f1e"
|
||||
|
||||
[metadata.files]
|
||||
"aiofiles 22.1.0" = [
|
||||
{url = "https://files.pythonhosted.org/packages/86/26/6e5060a159a6131c430e8a01ec8327405a19a449a506224b394e36f2ebc9/aiofiles-22.1.0.tar.gz", hash = "sha256:9107f1ca0b2a5553987a94a3c9959fe5b491fdf731389aa5b7b1bd0733e32de6"},
|
||||
{url = "https://files.pythonhosted.org/packages/a0/48/d5d1ab7cfe46e573c3694fa1365442a7d7cadc3abb03d8507e58a3755bb2/aiofiles-22.1.0-py3-none-any.whl", hash = "sha256:1142fa8e80dbae46bb6339573ad4c8c0841358f79c6eb50a493dceca14621bad"},
|
||||
]
|
||||
"anyio 3.6.2" = [
|
||||
{url = "https://files.pythonhosted.org/packages/77/2b/b4c0b7a3f3d61adb1a1e0b78f90a94e2b6162a043880704b7437ef297cad/anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"},
|
||||
{url = "https://files.pythonhosted.org/packages/8b/94/6928d4345f2bc1beecbff03325cad43d320717f51ab74ab5a571324f4f5a/anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},
|
||||
]
|
||||
"asyncpg 0.27.0" = [
|
||||
{url = "https://files.pythonhosted.org/packages/04/78/06b4979eb2b553a450fe38008353f5cba152a66de83c64b1639046e9ca0e/asyncpg-0.27.0.tar.gz", hash = "sha256:720986d9a4705dd8a40fdf172036f5ae787225036a7eb46e704c45aa8f62c054"},
|
||||
{url = "https://files.pythonhosted.org/packages/05/46/250c9e277b77ccbfd1b1746c529fc61250280f854d686b2775f8e4d59be5/asyncpg-0.27.0-cp39-cp39-win32.whl", hash = "sha256:8934577e1ed13f7d2d9cea3cc016cc6f95c19faedea2c2b56a6f94f257cea672"},
|
||||
@ -593,6 +663,10 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c
|
||||
{url = "https://files.pythonhosted.org/packages/31/5a/c5ecd08a0c9b4dfece3b41aeefc3770968b4a2da1784941c9c8dd1c65347/buildpg-0.4-py3-none-any.whl", hash = "sha256:20d539976c81ea6f5529d3930016b0482ed0ff06def3d6da79d0fc0a3bbaeeb1"},
|
||||
{url = "https://files.pythonhosted.org/packages/48/f2/ff0e51a3c2390538da6eb4f85e30d87aafbcc6d057c6c9bb9fa222c8f2fc/buildpg-0.4.tar.gz", hash = "sha256:3a6c1f40fb6c826caa819d84727e36a1372f7013ba696637b492e5935916d479"},
|
||||
]
|
||||
"certifi 2022.12.7" = [
|
||||
{url = "https://files.pythonhosted.org/packages/37/f7/2b1b0ec44fdc30a3d31dfebe52226be9ddc40cd6c0f34ffc8923ba423b69/certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"},
|
||||
{url = "https://files.pythonhosted.org/packages/71/4c/3db2b8021bd6f2f0ceb0e088d6b2d49147671f25832fb17970e9b583d742/certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"},
|
||||
]
|
||||
"click 8.1.3" = [
|
||||
{url = "https://files.pythonhosted.org/packages/59/87/84326af34517fca8c58418d148f2403df25303e02736832403587318e9e8/click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
|
||||
{url = "https://files.pythonhosted.org/packages/c2/f1/df59e28c642d583f7dacffb1e0965d0e00b218e0186d7858ac5233dce840/click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
|
||||
@ -644,6 +718,14 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c
|
||||
{url = "https://files.pythonhosted.org/packages/3e/9b/fda93fb4d957db19b0f6b370e79d586b3e8528b20252c729c476a2c02954/hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"},
|
||||
{url = "https://files.pythonhosted.org/packages/d5/34/e8b383f35b77c402d28563d2b8f83159319b509bc5f760b15d60b0abf165/hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"},
|
||||
]
|
||||
"httpcore 0.16.2" = [
|
||||
{url = "https://files.pythonhosted.org/packages/91/52/93f22e5441539256c0d113faf17e45284aee16eebdd95089e3ca6f480b18/httpcore-0.16.2-py3-none-any.whl", hash = "sha256:52c79095197178856724541e845f2db86d5f1527640d9254b5b8f6f6cebfdee6"},
|
||||
{url = "https://files.pythonhosted.org/packages/9b/20/26f6cc4fd00391f8f1c57b0020f5c6eec23904723db04b6f7608e222d815/httpcore-0.16.2.tar.gz", hash = "sha256:c35c5176dc82db732acfd90b581a3062c999a72305df30c0fc8fafd8e4aca068"},
|
||||
]
|
||||
"httpx 0.23.1" = [
|
||||
{url = "https://files.pythonhosted.org/packages/8a/df/a3e8b91dfb452e645ef110985a30f0915276a1a2144004c7671c07bb203c/httpx-0.23.1.tar.gz", hash = "sha256:202ae15319be24efe9a8bd4ed4360e68fde7b38bcc2ce87088d416f026667d19"},
|
||||
{url = "https://files.pythonhosted.org/packages/e1/74/cdce73069e021ad5913451b86c2707b027975cf302016ca557686d87eb41/httpx-0.23.1-py3-none-any.whl", hash = "sha256:0b9b1f0ee18b9978d637b0776bfd7f54e2ca278e063e3586d8f01cda89e042a8"},
|
||||
]
|
||||
"hypercorn 0.14.3" = [
|
||||
{url = "https://files.pythonhosted.org/packages/2e/e8/2a927f26a0cb994451b07622bf778ac6b3cb9fe45216e13feaeebb27c9b0/Hypercorn-0.14.3.tar.gz", hash = "sha256:4a87a0b7bbe9dc75fab06dbe4b301b9b90416e9866c23a377df21a969d6ab8dd"},
|
||||
{url = "https://files.pythonhosted.org/packages/be/8b/e856965857137bc594e8fd52fef1d00b79913a6a01033cba772276aff10b/Hypercorn-0.14.3-py3-none-any.whl", hash = "sha256:7c491d5184f28ee960dcdc14ab45d14633ca79d72ddd13cf4fcb4cb854d679ab"},
|
||||
@ -906,6 +988,10 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c
|
||||
{url = "https://files.pythonhosted.org/packages/dd/f3/f245c3bb75d0e166a87ff458ebc89489c210d235de5c50db4ef397295c79/quart-schema-0.14.3.tar.gz", hash = "sha256:c3105d61bf4fe9d8db30dc4fd9bdcc163fa739bf818c5d22537a89590b442fc7"},
|
||||
{url = "https://files.pythonhosted.org/packages/ff/4d/8f646a8b7becb325311029fbc7b9d6bfd2dec7aab0ef675dffbcdba8efd1/quart_schema-0.14.3-py3-none-any.whl", hash = "sha256:538e4d2b7da93ad73cd0fa6301415c907f23ffb00d5772a8f802a18ba62cfc33"},
|
||||
]
|
||||
"rfc3986 1.5.0" = [
|
||||
{url = "https://files.pythonhosted.org/packages/79/30/5b1b6c28c105629cc12b33bdcbb0b11b5bb1880c6cfbd955f9e792921aa8/rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
|
||||
{url = "https://files.pythonhosted.org/packages/c4/e5/63ca2c4edf4e00657584608bee1001302bbf8c5f569340b78304f2f446cb/rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
|
||||
]
|
||||
"six 1.16.0" = [
|
||||
{url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
||||
{url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
||||
@ -914,6 +1000,10 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c
|
||||
{url = "https://files.pythonhosted.org/packages/21/2d/39c6c57032f786f1965022563eec60623bb3e1409ade6ad834ff703724f3/smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"},
|
||||
{url = "https://files.pythonhosted.org/packages/6d/01/7caa71608bc29952ae09b0be63a539e50d2484bc37747797a66a60679856/smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"},
|
||||
]
|
||||
"sniffio 1.3.0" = [
|
||||
{url = "https://files.pythonhosted.org/packages/c3/a0/5dba8ed157b0136607c7f2151db695885606968d1fae123dc3391e0cfdbf/sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
|
||||
{url = "https://files.pythonhosted.org/packages/cd/50/d49c388cae4ec10e8109b1b833fd265511840706808576df3ada99ecb0ac/sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
|
||||
]
|
||||
"stevedore 4.1.1" = [
|
||||
{url = "https://files.pythonhosted.org/packages/66/c0/26afabea111a642f33cfd15f54b3dbe9334679294ad5c0423c556b75eba2/stevedore-4.1.1.tar.gz", hash = "sha256:7f8aeb6e3f90f96832c301bff21a7eb5eefbe894c88c506483d355565d88cc1a"},
|
||||
{url = "https://files.pythonhosted.org/packages/74/a3/72792236f981aee8bb1ef15e72a6f65444150834830f6a97178fe1e2cdf4/stevedore-4.1.1-py3-none-any.whl", hash = "sha256:aa6436565c069b2946fe4ebff07f5041e0c8bf18c7376dd29edf80cf7d524e4e"},
|
||||
@ -942,3 +1032,6 @@ content_hash = "sha256:79bba26f32608f765b40874b42c2340957f8241ef3cc7260f145eb26c
|
||||
{url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"},
|
||||
{url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"},
|
||||
]
|
||||
"zxcvbn 4.4.28" = [
|
||||
{url = "https://files.pythonhosted.org/packages/54/67/c6712608c99e7720598e769b8fb09ebd202119785adad0bbce25d330243c/zxcvbn-4.4.28.tar.gz", hash = "sha256:151bd816817e645e9064c354b13544f85137ea3320ca3be1fb6873ea75ef7dc1"},
|
||||
]
|
||||
|
@ -16,6 +16,8 @@ dependencies = [
|
||||
"pydantic[email]>=1.10.2",
|
||||
"quart-schema>=0.14.3",
|
||||
"quart-db[postgresql]>=0.4.1",
|
||||
"httpx>=0.23.1",
|
||||
"zxcvbn>=4.4.28",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
|
||||
@ -52,6 +54,10 @@ addopts = "--showlocals"
|
||||
asyncio_mode = "auto"
|
||||
pythonpath = ["src"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module =["h11"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pdm.scripts]
|
||||
format-black = "black src/ tests/"
|
||||
format-djhtml = "djhtml src/backend/templates -t 2 --in-place"
|
||||
@ -72,3 +78,5 @@ start = {cmd = "quart --app src/backend/run.py run --port 5050", env_file = "dev
|
||||
recreate-db-base = "quart --app src/backend/run.py recreate_db"
|
||||
recreate-db = {composite = ["recreate-db-base"], env_file = "development.env"}
|
||||
test = {composite = ["recreate-db-base", "pytest tests/"], env_file = "testing.env"}
|
||||
|
||||
all = {composite = ["format", "lint", "test"]}
|
||||
|
@ -1,7 +1,6 @@
|
||||
from quart import Blueprint, ResponseReturnValue
|
||||
from quart_rate_limiter import rate_exempt
|
||||
|
||||
|
||||
blueprint = Blueprint("control", __name__)
|
||||
|
||||
|
||||
|
217
backend/src/backend/blueprints/members.py
Normal file
217
backend/src/backend/blueprints/members.py
Normal file
@ -0,0 +1,217 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import cast
|
||||
|
||||
import asyncpg # type: ignore
|
||||
import bcrypt
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
from pydantic import EmailStr
|
||||
from quart import Blueprint, ResponseReturnValue, current_app, g
|
||||
from quart_auth import current_user, login_required
|
||||
from quart_rate_limiter import rate_limit
|
||||
from quart_schema import validate_request
|
||||
from zxcvbn import zxcvbn # type: ignore
|
||||
|
||||
from backend.lib.api_error import APIError
|
||||
from backend.lib.email import send_email
|
||||
from backend.models.member import (
|
||||
insert_member,
|
||||
select_member_by_email,
|
||||
select_member_by_id,
|
||||
update_member_email_verified,
|
||||
update_member_password,
|
||||
)
|
||||
|
||||
blueprint = Blueprint("members", __name__)
|
||||
|
||||
MINIMUM_STRENGTH = 3
|
||||
EMAIL_VERIFICATION_SALT = "email verify"
|
||||
ONE_MONTH = int(timedelta(days=30).total_seconds())
|
||||
FORGOTTEN_PASSWORD_SALT = "forgotten password" # nosec
|
||||
ONE_DAY = int(timedelta(hours=24).total_seconds())
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemberData:
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
@blueprint.post("/members/")
|
||||
@rate_limit(10, timedelta(seconds=10))
|
||||
@validate_request(MemberData)
|
||||
async def register(data: MemberData) -> ResponseReturnValue:
|
||||
"""Create a new Member.
|
||||
This allows a Member to be created.
|
||||
"""
|
||||
strength = zxcvbn(data.password)
|
||||
if strength["score"] < MINIMUM_STRENGTH:
|
||||
raise APIError(400, "WEAK_PASSWORD")
|
||||
|
||||
hashed_password = bcrypt.hashpw(
|
||||
data.password.encode("utf-8"),
|
||||
bcrypt.gensalt(14),
|
||||
)
|
||||
|
||||
try:
|
||||
member = await insert_member(
|
||||
g.connection,
|
||||
data.email,
|
||||
hashed_password.decode(),
|
||||
)
|
||||
except asyncpg.exceptions.UniqueViolationError:
|
||||
pass
|
||||
else:
|
||||
serializer = URLSafeTimedSerializer(
|
||||
current_app.secret_key, salt=EMAIL_VERIFICATION_SALT
|
||||
)
|
||||
token = serializer.dumps(member.id)
|
||||
await send_email(
|
||||
member.email,
|
||||
"Welcome",
|
||||
"welcome.html",
|
||||
{"token": token},
|
||||
)
|
||||
|
||||
return {}, 201
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenData:
|
||||
token: str
|
||||
|
||||
|
||||
@blueprint.put("/members/email/")
|
||||
@rate_limit(5, timedelta(minutes=1))
|
||||
@validate_request(TokenData)
|
||||
async def verify_email(data: TokenData) -> ResponseReturnValue:
|
||||
"""Call to verify an email.
|
||||
This requires the user to supply a valid token.
|
||||
"""
|
||||
serializer = URLSafeTimedSerializer(
|
||||
current_app.secret_key, salt=EMAIL_VERIFICATION_SALT
|
||||
)
|
||||
try:
|
||||
member_id = serializer.loads(data.token, max_age=ONE_MONTH)
|
||||
except SignatureExpired:
|
||||
raise APIError(403, "TOKEN_EXPIRED")
|
||||
except BadSignature:
|
||||
raise APIError(400, "TOKEN_INVALID")
|
||||
else:
|
||||
await update_member_email_verified(g.connection, member_id)
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PasswordData:
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@blueprint.put("/members/password/")
|
||||
@rate_limit(5, timedelta(minutes=1))
|
||||
@login_required
|
||||
@validate_request(PasswordData)
|
||||
async def change_password(data: PasswordData) -> ResponseReturnValue:
|
||||
"""Update the members password.
|
||||
This allows the user to update their password.
|
||||
"""
|
||||
strength = zxcvbn(data.new_password)
|
||||
if strength["score"] < MINIMUM_STRENGTH:
|
||||
raise APIError(400, "WEAK_PASSWORD")
|
||||
|
||||
member_id = int(cast(str, current_user.auth_id))
|
||||
member = await select_member_by_id(g.connection, member_id)
|
||||
assert member is not None # nosec
|
||||
passwords_match = bcrypt.checkpw(
|
||||
data.current_password.encode("utf-8"),
|
||||
member.password_hash.encode("utf-8"),
|
||||
)
|
||||
if not passwords_match:
|
||||
raise APIError(401, "INVALID_PASSWORD")
|
||||
|
||||
hashed_password = bcrypt.hashpw(
|
||||
data.new_password.encode("utf-8"),
|
||||
bcrypt.gensalt(14),
|
||||
)
|
||||
await update_member_password(g.connection, member_id, hashed_password.decode())
|
||||
await send_email(
|
||||
member.email,
|
||||
"Password changed",
|
||||
"password_changed.html",
|
||||
{},
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForgottenPasswordData:
|
||||
email: EmailStr
|
||||
|
||||
|
||||
@blueprint.put("/members/forgotten-password/")
|
||||
@rate_limit(5, timedelta(minutes=1))
|
||||
@validate_request(ForgottenPasswordData)
|
||||
async def forgotten_password(data: ForgottenPasswordData) -> ResponseReturnValue:
|
||||
"""Call to trigger a forgotten password email.
|
||||
This requires a valid member email.
|
||||
"""
|
||||
member = await select_member_by_email(g.connection, data.email)
|
||||
if member is not None:
|
||||
serializer = URLSafeTimedSerializer(
|
||||
current_app.secret_key, salt=FORGOTTEN_PASSWORD_SALT
|
||||
)
|
||||
token = serializer.dumps(member.id)
|
||||
await send_email(
|
||||
member.email,
|
||||
"Forgotten password",
|
||||
"forgotten_password.html",
|
||||
{"token": token},
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResetPasswordData:
|
||||
password: str
|
||||
token: str
|
||||
|
||||
|
||||
@blueprint.put("/members/reset-password/")
|
||||
@rate_limit(5, timedelta(minutes=1))
|
||||
@validate_request(ResetPasswordData)
|
||||
async def reset_password(data: ResetPasswordData) -> ResponseReturnValue:
|
||||
"""Call to reset a password using a token.
|
||||
This requires the user to supply a valid token and a
|
||||
new password.
|
||||
"""
|
||||
serializer = URLSafeTimedSerializer(
|
||||
current_app.secret_key, salt=FORGOTTEN_PASSWORD_SALT
|
||||
)
|
||||
try:
|
||||
member_id = serializer.loads(data.token, max_age=ONE_DAY)
|
||||
except SignatureExpired:
|
||||
raise APIError(403, "TOKEN_EXPIRED")
|
||||
except BadSignature:
|
||||
raise APIError(400, "TOKEN_INVALID")
|
||||
else:
|
||||
strength = zxcvbn(data.password)
|
||||
if strength["score"] < MINIMUM_STRENGTH:
|
||||
raise APIError(400, "WEAK_PASSWORD")
|
||||
|
||||
hashed_password = bcrypt.hashpw(
|
||||
data.password.encode("utf-8"),
|
||||
bcrypt.gensalt(14),
|
||||
)
|
||||
await update_member_password(g.connection, member_id, hashed_password.decode())
|
||||
member = await select_member_by_id(
|
||||
g.connection, int(cast(str, current_user.auth_id))
|
||||
)
|
||||
assert member is not None # nosec
|
||||
await send_email(
|
||||
member.email,
|
||||
"Password changed",
|
||||
"password_changed.html",
|
||||
{},
|
||||
)
|
||||
return {}
|
73
backend/src/backend/blueprints/sessions.py
Normal file
73
backend/src/backend/blueprints/sessions.py
Normal file
@ -0,0 +1,73 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from bcrypt import checkpw
|
||||
from pydantic import EmailStr
|
||||
from quart import Blueprint, ResponseReturnValue, g
|
||||
from quart_auth import AuthUser, current_user, login_required, login_user, logout_user
|
||||
from quart_rate_limiter import rate_exempt, rate_limit
|
||||
from quart_schema import validate_request, validate_response
|
||||
|
||||
from backend.lib.api_error import APIError
|
||||
from backend.models.member import select_member_by_email
|
||||
|
||||
# Blueprint for user session (authentication) route handlers
|
||||
blueprint = Blueprint("sessions", __name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginData:
|
||||
email: EmailStr
|
||||
password: str
|
||||
remember: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Status:
|
||||
member_id: int
|
||||
|
||||
|
||||
@blueprint.post("/sessions/")
|
||||
@rate_limit(5, timedelta(minutes=1))
|
||||
@validate_request(LoginData)
|
||||
async def login(data: LoginData) -> ResponseReturnValue:
|
||||
"""
|
||||
Login to the app.
|
||||
|
||||
By providing credentials and then saving the returned cookie.
|
||||
"""
|
||||
result = await select_member_by_email(g.connection, data.email)
|
||||
|
||||
if result is None:
|
||||
raise APIError(401, "INVALID_CREDENTIALS")
|
||||
|
||||
passwords_match = checkpw(
|
||||
data.password.encode("utf_8"),
|
||||
result.password_hash.encode("utf_8"),
|
||||
)
|
||||
|
||||
if passwords_match:
|
||||
login_user(AuthUser(str(result.id)), data.remember)
|
||||
return {}, 200
|
||||
|
||||
else:
|
||||
raise APIError(401, "INVALID_CREDENTIALS")
|
||||
|
||||
|
||||
@blueprint.delete("/sessions/")
|
||||
@rate_exempt
|
||||
async def logout() -> ResponseReturnValue:
|
||||
"""Logout from the app.
|
||||
Deletes the session cookie.
|
||||
"""
|
||||
logout_user()
|
||||
return {}
|
||||
|
||||
|
||||
@blueprint.get("/sessions/")
|
||||
@rate_limit(10, timedelta(minutes=1))
|
||||
@login_required
|
||||
@validate_response(Status)
|
||||
async def status() -> ResponseReturnValue:
|
||||
assert current_user.auth_id is not None # nosec
|
||||
return Status(member_id=int(current_user.auth_id))
|
0
backend/src/backend/lib/__init__.py
Normal file
0
backend/src/backend/lib/__init__.py
Normal file
4
backend/src/backend/lib/api_error.py
Normal file
4
backend/src/backend/lib/api_error.py
Normal file
@ -0,0 +1,4 @@
|
||||
class APIError(Exception):
|
||||
def __init__(self, status_code: int, code: str) -> None:
|
||||
self.status_code = status_code
|
||||
self.code = code
|
40
backend/src/backend/lib/email.py
Normal file
40
backend/src/backend/lib/email.py
Normal file
@ -0,0 +1,40 @@
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
from quart import current_app, render_template
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostmarkError(Exception):
|
||||
def __init__(self, error_code: int, message: str) -> None:
|
||||
self.error_code = error_code
|
||||
self.message = message
|
||||
|
||||
|
||||
async def send_email(
|
||||
to: str,
|
||||
subject: str,
|
||||
template: str,
|
||||
ctx: dict[str, Any],
|
||||
) -> None:
|
||||
content = await render_template(template, **ctx)
|
||||
log.info("Sending %s to %s\n%s", template, to, content)
|
||||
token = current_app.config.get("POSTMARK_TOKEN")
|
||||
if token is not None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"https://api.postmarkapp.com/email",
|
||||
json={
|
||||
"From": "Todo <todo@minhtrannhat.com>",
|
||||
"To": to,
|
||||
"Subject": subject,
|
||||
"Tag": template,
|
||||
"HtmlBody": content,
|
||||
},
|
||||
headers={"X-Postmark-Server-Token": token},
|
||||
)
|
||||
data = cast(dict, response.json())
|
||||
if response.status_code != 200:
|
||||
raise PostmarkError(data["ErrorCode"], data["Message"])
|
31
backend/src/backend/migrations/0.py
Normal file
31
backend/src/backend/migrations/0.py
Normal file
@ -0,0 +1,31 @@
|
||||
from quart_db import Connection
|
||||
|
||||
|
||||
async def migrate(connection: Connection) -> None:
|
||||
await connection.execute(
|
||||
"""CREATE TABLE members (
|
||||
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
created TIMESTAMP NOT NULL DEFAULT now(),
|
||||
email TEXT NOT NULL,
|
||||
email_verified TIMESTAMP,
|
||||
password_hash TEXT NOT NULL
|
||||
)""",
|
||||
)
|
||||
await connection.execute(
|
||||
"""CREATE UNIQUE INDEX members_unique_email_idx
|
||||
ON members (LOWER(email)
|
||||
)"""
|
||||
)
|
||||
await connection.execute(
|
||||
"""CREATE TABLE todos (
|
||||
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
complete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
due TIMESTAMPTZ,
|
||||
member_id INT NOT NULL REFERENCES members(id),
|
||||
task TEXT NOT NULL
|
||||
)""",
|
||||
)
|
||||
|
||||
|
||||
async def valid_migration(connection: Connection) -> bool:
|
||||
return True
|
0
backend/src/backend/migrations/__init__.py
Normal file
0
backend/src/backend/migrations/__init__.py
Normal file
14
backend/src/backend/migrations/data.py
Normal file
14
backend/src/backend/migrations/data.py
Normal file
@ -0,0 +1,14 @@
|
||||
from quart_db import Connection
|
||||
|
||||
|
||||
async def execute(connection: Connection) -> None:
|
||||
await connection.execute(
|
||||
"""INSERT INTO members (email, password_hash)
|
||||
VALUES ('member@todo.minhtrannhat.com',
|
||||
'$2b$14$6yXjNza30kPCg3LhzZJfqeCWOLM.zyTiQFD4rdWlFHBTfYzzKJMJe'
|
||||
)"""
|
||||
)
|
||||
await connection.execute(
|
||||
"""INSERT INTO todos (member_id, task)
|
||||
VALUES (1, 'Test Task')"""
|
||||
)
|
0
backend/src/backend/models/__init__.py
Normal file
0
backend/src/backend/models/__init__.py
Normal file
60
backend/src/backend/models/member.py
Normal file
60
backend/src/backend/models/member.py
Normal file
@ -0,0 +1,60 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from quart_db import Connection
|
||||
|
||||
|
||||
@dataclass
|
||||
class Member:
|
||||
id: int
|
||||
email: str
|
||||
password_hash: str
|
||||
created: datetime
|
||||
email_verified: datetime | None
|
||||
|
||||
|
||||
async def select_member_by_email(db: Connection, email: str) -> Member | None:
|
||||
result = await db.fetch_one(
|
||||
"""SELECT id, email, password_hash, created, email_verified
|
||||
FROM members
|
||||
WHERE LOWER(email) = LOWER(:email)""",
|
||||
{"email": email},
|
||||
)
|
||||
return None if result is None else Member(**result)
|
||||
|
||||
|
||||
async def select_member_by_id(db: Connection, id: int) -> Member | None:
|
||||
result = await db.fetch_one(
|
||||
"""SELECT id, email, password_hash, created, email_verified
|
||||
FROM members
|
||||
WHERE id = :id""",
|
||||
{"id": id},
|
||||
)
|
||||
return None if result is None else Member(**result)
|
||||
|
||||
|
||||
async def insert_member(db: Connection, email: str, password_hash: str) -> Member:
|
||||
result = await db.fetch_one(
|
||||
"""INSERT INTO members (email, password_hash)
|
||||
VALUES (:email, :password_hash)
|
||||
RETURNING id, email, password_hash, created,
|
||||
email_verified""",
|
||||
{"email": email, "password_hash": password_hash},
|
||||
)
|
||||
return Member(**result) # type: ignore
|
||||
|
||||
|
||||
async def update_member_password(db: Connection, id: int, password_hash: str) -> None:
|
||||
await db.execute(
|
||||
"""UPDATE members
|
||||
SET password_hash = :password_hash
|
||||
WHERE id = :id""",
|
||||
{"id": id, "password_hash": password_hash},
|
||||
)
|
||||
|
||||
|
||||
async def update_member_email_verified(db: Connection, id: int) -> None:
|
||||
await db.execute(
|
||||
"UPDATE members SET email_verified = now() WHERE id = :id",
|
||||
{"id": id},
|
||||
)
|
102
backend/src/backend/models/todo.py
Normal file
102
backend/src/backend/models/todo.py
Normal file
@ -0,0 +1,102 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import constr
|
||||
from quart_db import Connection
|
||||
|
||||
|
||||
@dataclass
|
||||
class Todo:
|
||||
complete: bool
|
||||
due: datetime | None
|
||||
id: int
|
||||
task: constr(strip_whitespace=True, min_length=1) # type: ignore
|
||||
|
||||
|
||||
async def select_todos(
|
||||
connection: Connection,
|
||||
member_id: int,
|
||||
complete: bool | None = None,
|
||||
) -> list[Todo]:
|
||||
if complete is None:
|
||||
query = """SELECT id, complete, due, task
|
||||
FROM todos
|
||||
WHERE member_id = :member_id"""
|
||||
values = {"member_id": member_id}
|
||||
else:
|
||||
query = """SELECT id, complete, due, task
|
||||
FROM todos
|
||||
WHERE member_id = :member_id
|
||||
AND complete = :complete"""
|
||||
values = {"member_id": member_id, "complete": complete}
|
||||
return [Todo(**row) async for row in await connection.iterate(query, values)]
|
||||
|
||||
|
||||
async def select_todo(
|
||||
connection: Connection,
|
||||
id: int,
|
||||
member_id: int,
|
||||
) -> Todo | None:
|
||||
result = await connection.fetch_one(
|
||||
"""SELECT id, complete, due, task
|
||||
FROM todos
|
||||
WHERE id = :id AND member_id = :member_id""",
|
||||
{"id": id, "member_id": member_id},
|
||||
)
|
||||
return None if result is None else Todo(**result)
|
||||
|
||||
|
||||
async def insert_todo(
|
||||
connection: Connection,
|
||||
member_id: int,
|
||||
task: str,
|
||||
complete: bool,
|
||||
due: datetime | None,
|
||||
) -> Todo:
|
||||
result = await connection.fetch_one(
|
||||
"""INSERT INTO todos (complete, due, member_id, task)
|
||||
VALUES (:complete, :due, :member_id, :task)
|
||||
RETURNING id, complete, due, task""",
|
||||
{
|
||||
"member_id": member_id,
|
||||
"task": task,
|
||||
"complete": complete,
|
||||
"due": due,
|
||||
},
|
||||
)
|
||||
return Todo(**result) # type: ignore
|
||||
|
||||
|
||||
async def update_todo(
|
||||
connection: Connection,
|
||||
id: int,
|
||||
member_id: int,
|
||||
task: str,
|
||||
complete: bool,
|
||||
due: datetime | None,
|
||||
) -> Todo | None:
|
||||
result = await connection.fetch_one(
|
||||
"""UPDATE todos
|
||||
SET complete = :complete, due = :due, task = :task
|
||||
WHERE id = :id AND member_id = :member_id
|
||||
RETURNING id, complete, due, task""",
|
||||
{
|
||||
"id": id,
|
||||
"member_id": member_id,
|
||||
"task": task,
|
||||
"complete": complete,
|
||||
"due": due,
|
||||
},
|
||||
)
|
||||
return None if result is None else Todo(**result)
|
||||
|
||||
|
||||
async def delete_todo(
|
||||
connection: Connection,
|
||||
id: int,
|
||||
member_id: int,
|
||||
) -> None:
|
||||
await connection.execute(
|
||||
"DELETE FROM todos WHERE id = :id AND member_id = :member_id",
|
||||
{"id": id, "member_id": member_id},
|
||||
)
|
@ -1,28 +1,30 @@
|
||||
from quart import Quart, ResponseReturnValue
|
||||
|
||||
# logging
|
||||
import logging
|
||||
import os
|
||||
from subprocess import call # no sec
|
||||
from subprocess import call # nosec
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Each blueprint is a logical collection of features in our web app
|
||||
from backend.blueprints.control import blueprint as control_blueprint
|
||||
|
||||
# For making sure error responses are in JSON format
|
||||
from backend.lib.api_error import APIError
|
||||
|
||||
# Rate limiting
|
||||
from quart_rate_limiter import RateLimiter
|
||||
from quart_rate_limiter import RateLimitExceeded
|
||||
from quart import Quart, ResponseReturnValue
|
||||
|
||||
# Authentication
|
||||
from quart_auth import AuthManager
|
||||
|
||||
# Request/Response validation
|
||||
from quart_schema import QuartSchema, RequestSchemaValidationError
|
||||
|
||||
# PostgreSQL database driver
|
||||
from quart_db import QuartDB
|
||||
|
||||
# Rate limiting
|
||||
from quart_rate_limiter import RateLimiter, RateLimitExceeded
|
||||
|
||||
# Request/Response validation
|
||||
from quart_schema import QuartSchema, RequestSchemaValidationError
|
||||
|
||||
# Each blueprint is a logical collection of features in our web app
|
||||
from backend.blueprints.control import blueprint as control_blueprint
|
||||
from backend.blueprints.members import blueprint as members_blueprint
|
||||
from backend.blueprints.sessions import blueprint as sessions_blueprint
|
||||
|
||||
# For making sure error responses are in JSON format
|
||||
from backend.lib.api_error import APIError
|
||||
|
||||
app: Quart = Quart(__name__)
|
||||
|
||||
@ -34,9 +36,12 @@ auth_manager: AuthManager = AuthManager(app)
|
||||
quart_db = QuartDB(app)
|
||||
rate_limiter: RateLimiter = RateLimiter(app)
|
||||
schema = QuartSchema(app, convert_casing=True)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
# registers these groups of routes handlers
|
||||
app.register_blueprint(control_blueprint)
|
||||
app.register_blueprint(sessions_blueprint)
|
||||
app.register_blueprint(members_blueprint)
|
||||
|
||||
|
||||
# rate limiting
|
||||
|
51
backend/src/backend/templates/email.html
Normal file
51
backend/src/backend/templates/email.html
Normal file
@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Todo - email</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
|
||||
<body
|
||||
style="
|
||||
font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
height="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
border="0"
|
||||
>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table
|
||||
height="100%"
|
||||
cellpadding="20"
|
||||
cellspacing="0"
|
||||
border="0"
|
||||
style="max-width: 540px"
|
||||
>
|
||||
<tr>
|
||||
<td align="left" width="540">
|
||||
{% block welcome %} Hello, {% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" width="540">
|
||||
{% block content %} Example content {% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="540">The Todo team</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
9
backend/src/backend/templates/forgotten_password.html
Normal file
9
backend/src/backend/templates/forgotten_password.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% extends "email.html" %}
|
||||
|
||||
{% block content %}
|
||||
You can use this
|
||||
<a href="{{ config['BASE_URL'] }}/reset-password/{{ token }}/">
|
||||
link
|
||||
</a>
|
||||
to reset your password.
|
||||
{% endblock %}
|
6
backend/src/backend/templates/password_changed.html
Normal file
6
backend/src/backend/templates/password_changed.html
Normal file
@ -0,0 +1,6 @@
|
||||
|
||||
{% extends "email.html" %}
|
||||
|
||||
{% block content %}
|
||||
Your Todo password has been successfully changed.
|
||||
{% endblock %}
|
12
backend/src/backend/templates/welcome.html
Normal file
12
backend/src/backend/templates/welcome.html
Normal file
@ -0,0 +1,12 @@
|
||||
{% extends "email.html" %}
|
||||
|
||||
{% block welcome %}
|
||||
Hello and welcome to tozo!
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
Please confirm you signed up by following this
|
||||
<a href="{{ config['BASE_URL'] }}/confirm-email/{{ token }}/">
|
||||
link
|
||||
</a>.
|
||||
{% endblock %}
|
@ -3,6 +3,7 @@ TODO_DEBUG=true
|
||||
TODO_SECRET_KEY="secret key"
|
||||
TODO_TESTING=true
|
||||
TODO_QUART_DB_DATABASE_URL="postgresql://todo_test:todo_test@0.0.0.0:5432/todo_test"
|
||||
TODO_QUART_DB_DATA_PATH="migrations/data.py"
|
||||
|
||||
# disable for when we not using HTTPS
|
||||
TODO_QUART_AUTH_COOKIE_SECURE=false
|
||||
|
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
66
backend/tests/blueprints/test_members.py
Normal file
66
backend/tests/blueprints/test_members.py
Normal file
@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
from quart import Quart
|
||||
|
||||
from backend.blueprints.members import EMAIL_VERIFICATION_SALT
|
||||
|
||||
|
||||
async def test_register(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
|
||||
test_client = app.test_client()
|
||||
data = {
|
||||
"email": "new@todo.minhtrannhat.com",
|
||||
"password": "testPassword2$",
|
||||
}
|
||||
await test_client.post("/members/", json=data)
|
||||
response = await test_client.post("/sessions/", json=data)
|
||||
assert response.status_code == 200
|
||||
assert "Sending welcome.html to new@todo.minhtrannhat.com" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"time, expected",
|
||||
[("2022-01-01", 403), (None, 200)],
|
||||
)
|
||||
async def test_verify_email(app: Quart, time: str | None, expected: int) -> None:
|
||||
with freeze_time(time):
|
||||
signer = URLSafeTimedSerializer(app.secret_key, salt=EMAIL_VERIFICATION_SALT)
|
||||
token = signer.dumps(1)
|
||||
test_client = app.test_client()
|
||||
response = await test_client.put("/members/email/", json={"token": token})
|
||||
assert response.status_code == expected
|
||||
|
||||
|
||||
async def test_verify_email_invalid_token(app: Quart) -> None:
|
||||
test_client = app.test_client()
|
||||
response = await test_client.put("/members/email/", json={"token": "invalid"})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
async def test_change_password(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
|
||||
test_client = app.test_client()
|
||||
data = {
|
||||
"email": "new_password@todo.minhtrannhat.com",
|
||||
"password": "testPassword2$",
|
||||
}
|
||||
response = await test_client.post("/members/", json=data)
|
||||
async with test_client.authenticated("2"): # type: ignore
|
||||
response = await test_client.put(
|
||||
"/members/password/",
|
||||
json={
|
||||
"currentPassword": data["password"],
|
||||
"newPassword": "testPassword3$",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Sending password_changed.html to new@todo.minhtrannhat.com" in caplog.text
|
||||
|
||||
|
||||
async def test_forgotten_password(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
|
||||
test_client = app.test_client()
|
||||
data = {"email": "member@todo.minhtrannhat.com"}
|
||||
response = await test_client.put("/members/forgotten-password/", json=data)
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
"Sending forgotten_password.html to member@todo.minhtrannhat.com" in caplog.text
|
||||
)
|
24
backend/tests/blueprints/test_sessions.py
Normal file
24
backend/tests/blueprints/test_sessions.py
Normal file
@ -0,0 +1,24 @@
|
||||
from quart import Quart
|
||||
|
||||
|
||||
async def test_session_flow(app: Quart) -> None:
|
||||
test_client = app.test_client()
|
||||
await test_client.post(
|
||||
"/sessions/",
|
||||
json={"email": "member@todo.minhtrannhat.com", "password": "password"},
|
||||
)
|
||||
response = await test_client.get("/sessions/")
|
||||
assert (await response.get_json())["memberId"] == 1
|
||||
await test_client.delete("/sessions/")
|
||||
response = await test_client.get("/sessions/")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_login_invalid_password(app: Quart) -> None:
|
||||
test_client = app.test_client()
|
||||
await test_client.post(
|
||||
"/sessions/",
|
||||
json={"email": "member@todo.minhtrannhat.com", "password": "incorrect"},
|
||||
)
|
||||
response = await test_client.get("/sessions/")
|
||||
assert response.status_code == 401
|
0
backend/tests/lib/__init__.py
Normal file
0
backend/tests/lib/__init__.py
Normal file
10
backend/tests/lib/test_email.py
Normal file
10
backend/tests/lib/test_email.py
Normal file
@ -0,0 +1,10 @@
|
||||
from pytest import LogCaptureFixture
|
||||
from quart import Quart
|
||||
|
||||
from backend.lib.email import send_email
|
||||
|
||||
|
||||
async def test_send_email(app: Quart, caplog: LogCaptureFixture) -> None:
|
||||
async with app.app_context():
|
||||
await send_email("member@minhtrannhat.com", "Welcome", "email.html", {})
|
||||
assert "Sending email.html to member@minhtrannhat.com" in caplog.text
|
0
backend/tests/models/__init__.py
Normal file
0
backend/tests/models/__init__.py
Normal file
17
backend/tests/models/test_member.py
Normal file
17
backend/tests/models/test_member.py
Normal file
@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
from asyncpg.exceptions import UniqueViolationError # type: ignore
|
||||
from quart_db import Connection
|
||||
|
||||
from backend.models.member import insert_member, select_member_by_email
|
||||
|
||||
|
||||
async def test_insert_member(connection: Connection) -> None:
|
||||
await insert_member(connection, "casing@todo.minhtrannhat.com", "")
|
||||
with pytest.raises(UniqueViolationError):
|
||||
await insert_member(connection, "Casing@todo.minhtrannhat.com", "")
|
||||
|
||||
|
||||
async def test_select_member_by_email(connection: Connection) -> None:
|
||||
await insert_member(connection, "casing@todo.minhtrannhat.com", "")
|
||||
member = await select_member_by_email(connection, "Casing@todo.minhtrannhat.com")
|
||||
assert member is not None
|
31
backend/tests/models/test_todo.py
Normal file
31
backend/tests/models/test_todo.py
Normal file
@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
from quart_db import Connection
|
||||
|
||||
from backend.models.todo import delete_todo, insert_todo, select_todo, update_todo
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_id, deleted",
|
||||
[(1, True), (2, False)],
|
||||
)
|
||||
async def test_delete_todo(
|
||||
connection: Connection, member_id: int, deleted: bool
|
||||
) -> None:
|
||||
todo = await insert_todo(connection, 1, "Task", False, None)
|
||||
await delete_todo(connection, todo.id, member_id)
|
||||
new_todo = await select_todo(connection, todo.id, 1)
|
||||
assert (new_todo is None) is deleted
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_id, complete",
|
||||
[(1, True), (2, False)],
|
||||
)
|
||||
async def test_update_todo(
|
||||
connection: Connection, member_id: int, complete: bool
|
||||
) -> None:
|
||||
todo = await insert_todo(connection, 1, "Task", False, None)
|
||||
await update_todo(connection, todo.id, member_id, "Task", True, None)
|
||||
new_todo = await select_todo(connection, todo.id, 1)
|
||||
assert new_todo is not None
|
||||
assert new_todo.complete is complete
|
38807
frontend/package-lock.json
generated
38807
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,9 +12,10 @@
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "^2.1.3",
|
||||
"react-scripts": "^5.0.1",
|
||||
"typescript": "^4.7.4",
|
||||
"web-vitals": "^2.1.4"
|
||||
"web-vitals": "^2.1.4",
|
||||
"nth-check": ">=2.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"analyze": "npm run build && source-map-explorer \"build/static/js/*.js\"",
|
||||
@ -48,7 +49,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.20.0",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"prettier": "^2.7.1",
|
||||
|
Loading…
x
Reference in New Issue
Block a user