diff --git a/config.py.example b/config.py.example
index 87fe7aa..f378a1d 100644
--- a/config.py.example
+++ b/config.py.example
@@ -71,12 +71,20 @@ VALIDATE_USERNAME_REGEX = (
# You can change the repetitions to enforce stricter/laxer rules or empty
# this field to disable weakness validation
VALIDATE_PASSWORD_REGEX = r"^(?=.*[A-Z]){2,}(?=.*[!@%#$&*_^\?\\\/(\)\+\-])+(?=.*[0-9]){2,}(?=.*[a-z]){3,}.{10,72}$"
+FORCE_EMAIL_VERIFICATION = False
+EMAIL_VERIFICATION_EXPIRATION = 3600 # In seconds
+PLATFORM_NAME = "PySimpleSocial" # Used in emails
+HAS_HTTPS = False
class NotAuthenticated(Exception):
pass
+class AdminNotAuthenticated(Exception):
+ pass
+
+
if __name__ != "__main__":
LOGGER: logging.Logger = logging.getLogger("socialMedia")
LOGGER.setLevel(LOG_LEVEL)
@@ -101,12 +109,20 @@ if __name__ != "__main__":
)
MANAGER = LoginManager(
LOGIN_SECRET_KEY,
- "/login",
+ "/user",
use_cookie=True,
cookie_name=SESSION_COOKIE_NAME,
use_header=USE_BEARER_HEADER,
custom_exception=NotAuthenticated,
)
+ ADMIN_MANAGER = LoginManager(
+ LOGIN_SECRET_KEY,
+ "/admin",
+ use_cookie=True,
+ cookie_name=f"{SESSION_COOKIE_NAME}_admin",
+ use_header=USE_BEARER_HEADER,
+ custom_exception=AdminNotAuthenticated
+ )
# Uvicorn config
diff --git a/endpoints/users.py b/endpoints/users.py
index f4f6f45..479b905 100644
--- a/endpoints/users.py
+++ b/endpoints/users.py
@@ -1,5 +1,7 @@
import base64
+from datetime import timezone, datetime
import hashlib
+import json
import re
import imghdr
import uuid
@@ -33,17 +35,37 @@ from config import (
ZLIB_COMPRESSION_LEVEL,
MAX_MEDIA_SIZE,
STORAGE_ENGINE,
- STORAGE_FOLDER
+ STORAGE_FOLDER,
+ SMTP_HOST,
+ SMTP_PORT,
+ SMTP_USER,
+ SMTP_PASSWORD,
+ SMTP_USE_TLS,
+ SMTP_TIMEOUT,
+ SMTP_FROM_USER,
+ SMTP_TEMPLATES_DIRECTORY,
+ PLATFORM_NAME,
+ HAS_HTTPS,
+ HOST,
+ PORT,
+ EMAIL_VERIFICATION_EXPIRATION,
+ FORCE_EMAIL_VERIFICATION,
+ UNVERIFIED_MANAGER,
)
from orm.users import UserModel, User
from orm.media import Media, MediaType
+from orm.email_verification import EmailVerification
+from util.email import send_email
+
router = FastAPI()
async def get_user_by_id(
- public_id: UUID, include_secrets: bool = False, restricted_ok: bool = False,
- deleted_ok: bool = False
+ public_id: UUID,
+ include_secrets: bool = False,
+ restricted_ok: bool = False,
+ deleted_ok: bool = False,
) -> dict | None:
"""
Retrieves a user by its public ID
@@ -61,20 +83,32 @@ async def get_user_by_id(
if user:
# Performs validation
UserModel(**user)
- if (user["deleted"] and not deleted_ok) or (user["restricted"] and not restricted_ok):
+ if (user["deleted"] and not deleted_ok) or (
+ user["restricted"] and not restricted_ok
+ ):
return
return user
return
@MANAGER.user_loader()
-async def get_self_by_id(public_id: UUID) -> dict:
- return await get_user_by_id(public_id, include_secrets=True, restricted_ok=True)
+async def get_self_by_id(public_id: UUID, requires_verified: bool = True) -> dict:
+ user = await get_user_by_id(public_id, include_secrets=True, restricted_ok=True)
+ if FORCE_EMAIL_VERIFICATION and requires_verified and not user["email_verified"]:
+ raise HTTPException(status_code=401, detail="Email verification is required")
+ return user
+
+
+@UNVERIFIED_MANAGER.user_loader()
+async def get_self_by_id_unverified(public_id: UUID):
+ return await get_self_by_id(public_id, False)
async def get_user_by_username(
- username: str, include_secrets: bool = False, restricted_ok: bool = False,
- deleted_ok: bool = False
+ username: str,
+ include_secrets: bool = False,
+ restricted_ok: bool = False,
+ deleted_ok: bool = False,
) -> dict | None:
"""
Retrieves a user by its public username
@@ -92,15 +126,19 @@ async def get_user_by_username(
if user:
# Performs validation
UserModel(**user)
- if (user["deleted"] and not deleted_ok) or (user["restricted"] and not restricted_ok):
+ if (user["deleted"] and not deleted_ok) or (
+ user["restricted"] and not restricted_ok
+ ):
return
return user
return
async def get_user_by_email(
- email: str, include_secrets: bool = False, restricted_ok: bool = False,
- deleted_ok: bool = False
+ email: str,
+ include_secrets: bool = False,
+ restricted_ok: bool = False,
+ deleted_ok: bool = False,
) -> dict | None:
"""
Retrieves a user by its email address (meant to
@@ -119,7 +157,9 @@ async def get_user_by_email(
if user:
# Performs validation
UserModel(**user)
- if (user["deleted"] and not deleted_ok) or (user["restricted"] and not restricted_ok):
+ if (user["deleted"] and not deleted_ok) or (
+ user["restricted"] and not restricted_ok
+ ):
return
return user
return
@@ -184,7 +224,7 @@ async def login(
@router.get("/user/logout")
@LIMITER.limit("5/minute")
async def logout(
- request: Request, response: Response, user: dict = Depends(MANAGER)
+ request: Request, response: Response, _user: dict = Depends(UNVERIFIED_MANAGER)
) -> dict:
"""
Deletes a user's session cookie, logging them
@@ -204,7 +244,7 @@ async def logout(
@router.get("/user/me")
@LIMITER.limit("2/second")
-async def get_self(request: Request, user: dict = Depends(MANAGER)) -> dict:
+async def get_self(request: Request, user: dict = Depends(UNVERIFIED_MANAGER)) -> dict:
"""
Fetches a user's own info. This returns some
extra data such as email address, account
@@ -256,7 +296,12 @@ async def get_user_by_public_id(
async def validate_user(
- first_name: str, last_name: str, username: str, email: str, password: str | None
+ first_name: str,
+ last_name: str,
+ username: str,
+ email: str,
+ password: str | None,
+ bio: str | None,
) -> tuple[bool, str]:
"""
Performs some validation upon user creation. Returns
@@ -276,24 +321,43 @@ async def validate_user(
return False, "username is too short"
if username and len(username) > 32:
return False, "username is too long"
- if username and VALIDATE_USERNAME_REGEX and not re.match(VALIDATE_USERNAME_REGEX, username):
+ if (
+ username
+ and VALIDATE_USERNAME_REGEX
+ and not re.match(VALIDATE_USERNAME_REGEX, username)
+ ):
return False, "username is invalid"
if email and not validators.email(email):
return False, "email is not valid"
if password and len(password) > 72:
return False, "password is too long"
- if password and VALIDATE_PASSWORD_REGEX and not re.match(VALIDATE_PASSWORD_REGEX, password):
+ if (
+ password
+ and VALIDATE_PASSWORD_REGEX
+ and not re.match(VALIDATE_PASSWORD_REGEX, password)
+ ):
return False, "password is too weak"
- if username and await get_user_by_username(username, deleted_ok=True, restricted_ok=True):
+ if username and await get_user_by_username(
+ username, deleted_ok=True, restricted_ok=True
+ ):
return False, "username is already taken"
if email and await get_user_by_email(email, deleted_ok=True, restricted_ok=True):
return False, "email is already registered"
+ if bio and len(bio) > 4096:
+ return False, "bio is too long"
+ if bio:
+ try:
+ bio.encode("utf-8")
+ except UnicodeDecodeError:
+ return False, "bio contains invalid characters"
return True, ""
@router.delete("/user")
@LIMITER.limit("1/minute")
-async def delete(request: Request, response: Response, user: dict = Depends(MANAGER)) -> dict:
+async def delete(
+ request: Request, response: Response, user: dict = Depends(UNVERIFIED_MANAGER)
+) -> dict:
"""
Sets the user's deleted flag in the database,
without actually deleting the associated
@@ -307,10 +371,166 @@ async def delete(request: Request, response: Response, user: dict = Depends(MANA
httponly=COOKIE_HTTPONLY,
samesite=COOKIE_SAMESITE_POLICY,
domain=COOKIE_DOMAIN or None,
- path=COOKIE_PATH or "/",)
+ path=COOKIE_PATH or "/",
+ )
return {"status_code": 200, "msg": "Success"}
+@router.get("/user/verifyEmail/{verification_id}")
+@LIMITER.limit("3/second")
+async def verify_email(
+ request: Request, verification_id: str, user: dict = Depends(UNVERIFIED_MANAGER)
+) -> dict:
+ """
+ Verifies a user's email address
+ """
+
+ if not (
+ verification := await EmailVerification.select(*EmailVerification.all_columns())
+ .where(EmailVerification.id == verification_id)
+ .first()
+ ):
+ raise HTTPException(status_code=404, detail="Verification ID is invalid")
+ elif not verification["pending"]:
+ raise HTTPException(status_code=400, detail="Email is already verified")
+ elif datetime.now().astimezone(timezone.utc) - verification[
+ "creation_date"
+ ].astimezone(timezone.utc) > timedelta(seconds=EMAIL_VERIFICATION_EXPIRATION):
+ raise HTTPException(
+ status_code=400,
+ detail="Verification window has expired. Try again",
+ )
+ else:
+ await EmailVerification.update({EmailVerification.pending: False}).where(
+ EmailVerification.user == user["id"]
+ )
+ await User.update({User.email_verified: True}).where(
+ User.public_id == user["id"]
+ )
+ return {"status_code": 200, "msg": "Verification successful"}
+
+
+@router.get("/user/resetPassword/{verification_id}")
+@LIMITER.limit("3/second")
+async def reset_password(
+ request: Request, verification_id: str, user: dict = Depends(UNVERIFIED_MANAGER)
+) -> dict:
+ """
+ Modifies a user's password
+ """
+
+ if not (
+ verification := await EmailVerification.select(*EmailVerification.all_columns())
+ .where(EmailVerification.id == verification_id)
+ .first()
+ ):
+ raise HTTPException(status_code=404, detail="Request ID is invalid")
+ elif not verification["pending"]:
+ raise HTTPException(status_code=400, detail="This link has already been used")
+ elif datetime.now().astimezone(timezone.utc) - verification[
+ "creation_date"
+ ].astimezone(timezone.utc) > timedelta(seconds=EMAIL_VERIFICATION_EXPIRATION):
+ raise HTTPException(
+ status_code=400,
+ detail="Verification window has expired. Try again",
+ )
+ else:
+ await EmailVerification.update({EmailVerification.pending: False}).where(
+ EmailVerification.user == user["id"]
+ )
+ await User.update({User.password_hash: verification["data"]}).where(
+ User.public_id == user["id"]
+ )
+ return {"status_code": 200, "msg": "Password updated"}
+
+
+@router.get("/user/changeEmail/{verification_id}")
+@LIMITER.limit("3/second")
+async def change_email(
+ request: Request, verification_id: str, user: dict = Depends(UNVERIFIED_MANAGER)
+) -> dict:
+ """
+ Modifies a user's email
+ """
+
+ if not (
+ verification := await EmailVerification.select(*EmailVerification.all_columns())
+ .where(EmailVerification.id == verification_id)
+ .first()
+ ):
+ raise HTTPException(status_code=404, detail="Request ID is invalid")
+ elif not verification["pending"]:
+ raise HTTPException(status_code=400, detail="This link has already been used")
+ elif datetime.now().astimezone(timezone.utc) - verification[
+ "creation_date"
+ ].astimezone(timezone.utc) > timedelta(seconds=EMAIL_VERIFICATION_EXPIRATION):
+ raise HTTPException(
+ status_code=400,
+ detail="Verification window has expired. Try again",
+ )
+ else:
+ await EmailVerification.update({EmailVerification.pending: False}).where(
+ EmailVerification.user == user["id"]
+ )
+ await User.update({User.email_address: verification["data"].decode(),
+ User.email_verified: False}).where(
+ User.public_id == user["id"]
+ )
+ return {"status_code": 200, "msg": "Email updated"}
+
+
+@router.put("user/resendMail")
+@LIMITER.limit("6/minute")
+async def resend_email(request: Request, user: dict = Depends(UNVERIFIED_MANAGER)) -> dict:
+ """
+ Resends the verification email to the user if the previous has expired
+ """
+
+ if user["email_verified"]:
+ raise HTTPException(status_code=400, detail="Email is already verified")
+ email_template = SMTP_TEMPLATES_DIRECTORY / f"{user['locale']}.json"
+ try:
+ email_template.resolve(strict=True)
+ except FileNotFoundError:
+ email_template = SMTP_TEMPLATES_DIRECTORY / "en_US.json"
+ with email_template.open() as f:
+ email_message = json.load(f)["signup"]
+ verification_id = uuid.uuid4()
+ if await send_email(
+ SMTP_HOST,
+ SMTP_PORT,
+ email_message["content"].format(
+ first_name=user["first_name"],
+ last_name=user["last_name"],
+ username=user["username"],
+ email=user["email_address"],
+ link=f"http{'s' if HAS_HTTPS else ''}://{HOST}"
+ f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/verifyEmail/{verification_id}",
+ platformName=PLATFORM_NAME,
+ ),
+ SMTP_TIMEOUT,
+ SMTP_FROM_USER,
+ user["email_address"],
+ email_message["subject"].format(
+ first_name=user["first_name"],
+ last_name=user["last_name"],
+ username=user["username"],
+ email=user["email_address"],
+ platformName=PLATFORM_NAME,
+ ),
+ SMTP_USER,
+ SMTP_PASSWORD,
+ use_tls=SMTP_USE_TLS,
+ ):
+ await EmailVerification.update(
+ {
+ EmailVerification.id: verification_id,
+ EmailVerification.creation_date: datetime.now()
+ }
+ )
+ return {"status_code": 200, "msg": "Success"}
+
+
@router.put("/user")
@LIMITER.limit("2/minute")
async def signup(
@@ -320,6 +540,8 @@ async def signup(
username: str,
email: str,
password: str,
+ bio: str | None = None,
+ locale: str = "en_US",
) -> dict:
"""
Endpoint used to create new users
@@ -329,25 +551,78 @@ async def signup(
raise HTTPException(status_code=400, detail="Please logout first")
# We don't use FastAPI's validation because we want custom error
# messages
- result, msg = await validate_user(first_name, last_name, username, email, password)
+ result, msg = await validate_user(
+ first_name, last_name, username, email, password, bio
+ )
if not result:
return {"status_code": 413, "msg": f"Signup failed: {msg}"}
else:
- await User.insert(
- User(
+ salt = bcrypt.gensalt(BCRYPT_ROUNDS)
+ user = User(
+ first_name=first_name,
+ last_name=last_name,
+ username=username,
+ email_address=email,
+ password_hash=bcrypt.hashpw(
+ password.encode(), salt
+ ),
+ bio=bio,
+ )
+ email_template = SMTP_TEMPLATES_DIRECTORY / f"{locale}.json"
+ try:
+ email_template.resolve(strict=True)
+ except FileNotFoundError:
+ email_template = SMTP_TEMPLATES_DIRECTORY / "en_US.json"
+ with email_template.open() as f:
+ email_message = json.load(f)["signup"]
+ verification_id = uuid.uuid4()
+ if await send_email(
+ SMTP_HOST,
+ SMTP_PORT,
+ email_message["content"].format(
first_name=first_name,
last_name=last_name,
username=username,
- email_address=email,
- password_hash=bcrypt.hashpw(
- password.encode(), bcrypt.gensalt(BCRYPT_ROUNDS)
- ),
+ email=email,
+ link=f"http{'s' if HAS_HTTPS else ''}://{HOST}"
+ f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/verifyEmail/{verification_id}",
+ platformName=PLATFORM_NAME,
+ ),
+ SMTP_TIMEOUT,
+ SMTP_FROM_USER,
+ email,
+ email_message["subject"].format(
+ first_name=first_name,
+ last_name=last_name,
+ username=username,
+ email=email,
+ platformName=PLATFORM_NAME,
+ ),
+ SMTP_USER,
+ SMTP_PASSWORD,
+ use_tls=SMTP_USE_TLS,
+ ):
+ await User.insert(user)
+ await EmailVerification.insert(
+ EmailVerification(
+ {
+ EmailVerification.id: verification_id,
+ EmailVerification.user: user,
+ }
+ )
+ )
+ return {"status_code": 200, "msg": "Success"}
+ else:
+ raise HTTPException(
+ status_code=500,
+ detail="An error occurred while sending verification email, please"
+ " try again later",
)
- )
- return {"status_code": 200, "msg": "Success"}
-async def validate_profile_picture(file: UploadFile) -> tuple[bool | None, str, bytes, str]:
+async def validate_profile_picture(
+ file: UploadFile,
+) -> tuple[bool | None, str, bytes, str]:
"""
Validates a profile picture's size and content to see if it fits
our criteria and returns a tuple result, ext, data where result is a
@@ -365,69 +640,216 @@ async def validate_profile_picture(file: UploadFile) -> tuple[bool | None, str,
return False, "", b"", ""
if not (ext := imghdr.what(content.decode())) in ALLOWED_MEDIA_TYPES:
return None, "", b"", ""
- return True, ext, zlib.compress(content, ZLIB_COMPRESSION_LEVEL), hashlib.sha256(content).hexdigest()
+ return (
+ True,
+ ext,
+ zlib.compress(content, ZLIB_COMPRESSION_LEVEL),
+ hashlib.sha256(content).hexdigest(),
+ )
except (UnicodeDecodeError, zlib.error):
return None, "", b"", ""
@router.patch("/user")
-async def update(request: Request, user: dict = Depends(MANAGER),
- first_name: str | None = None, last_name: str | None = None,
- username: str | None = None, profile_picture: UploadFile | None = None,
- email_address: str | None = None, bio: str | None = None):
+async def update(
+ request: Request,
+ user: dict = Depends(UNVERIFIED_MANAGER),
+ first_name: str | None = None,
+ last_name: str | None = None,
+ username: str | None = None,
+ profile_picture: UploadFile | None = None,
+ email_address: str | None = None,
+ password: str | None = None,
+ bio: str | None = None,
+ delete: bool = False,
+):
"""
- Updates a user's profile information. Parameters that are not specified are left unchanged.
- At least one parameter has to be non-null. Setting a new email address is only allowed if the
- old one is verified and will require the user to click a link sent to the current email address
- to authorize the operation, after which the address is modified.
+ Updates a user's profile information. Parameters that are not specified are left unchanged unless
+ the delete option is set to True, in which case a value of null for a parameter indicates that it
+ is to be reset or removed. If delete equals False, the default, at least one parameter has to be non-null.
+ Setting a new email address is only allowed if the old one is verified and will require the user to click a
+ link sent to the current email address to authorize the operation, after which the address is modified.
+ A similar procedure is required for resetting the password, requiring an email confirmation before said
+ change is registered. Please also note that changing the email address undoes the account's email verification,
+ which needs to be carried out again. When delete equals True, only the bio and profile_picture fields are considered
+ since they're the only ones that can be set to a null value
"""
- if not any((first_name, last_name, username, profile_picture, email_address, bio)):
- raise HTTPException(status_code=400, detail="At least one value has to be specified")
- result, msg = await validate_user(first_name, last_name, username, email_address, None)
+ if not delete and not any(
+ (first_name, last_name, username, profile_picture, email_address, bio, password)
+ ):
+ raise HTTPException(
+ status_code=400, detail="At least one value has to be specified"
+ )
+ result, msg = (
+ await validate_user(
+ first_name, last_name, username, email_address, password, bio
+ )
+ )
if not result:
raise HTTPException(status_code=413, detail=f"Update failed: {msg}")
orig_user = user.copy()
- if first_name:
- user["first_name"] = first_name
- if last_name:
- user["last_name"] = last_name
- if username:
- user["username"] = username
- if profile_picture:
- result, ext, media, digest = validate_profile_picture(profile_picture)
- if result is False:
- raise HTTPException(status_code=415, detail="The file type is unsupported")
- elif result is None:
- raise HTTPException(status_code=413, detail="The file is too large")
- elif await (old_media := Media.select(Media.media_id).where(Media.media_id == digest).first()) is None:
- # This media hasn't been already uploaded (either by this user or by someone
- # else), so we save it now. If it has been already uploaded, there's no need
- # to do it again (that's what the hash is for)
- match STORAGE_ENGINE:
- case "database":
- await Media.insert(Media(media_id=digest, media_type=MediaType.BLOB,
- content_type=ext, content=base64.b64encode(media)))
- case "local":
- file = Path(STORAGE_FOLDER).resolve(strict=True) / str(digest)
- file.touch(mode=0o644)
- with file.open("wb") as f:
- f.write(media)
- await Media.insert(Media(media_id=digest, media_type=MediaType.FILE,
- content_type=ext, content=file.as_posix()))
- case "url":
- pass # TODO: Use/implement CDN uploading
- else:
- user["media"] = old_media
- if email_address:
- if not user["email_verified"]:
- raise HTTPException(status_code=403, detail="The email address needs to be verified first")
- pass # TODO: Requires email verification
+ if not delete:
+ if first_name:
+ user["first_name"] = first_name
+ if last_name:
+ user["last_name"] = last_name
+ if username:
+ user["username"] = username
+ if bio:
+ user["bio"] = bio
+ if profile_picture:
+ result, ext, media, digest = validate_profile_picture(profile_picture)
+ if result is False:
+ raise HTTPException(
+ status_code=415, detail="The file type is unsupported"
+ )
+ elif result is None:
+ raise HTTPException(status_code=413, detail="The file is too large")
+ elif (
+ await (
+ old_media := Media.select(Media.media_id)
+ .where(Media.media_id == digest)
+ .first()
+ )
+ is None
+ ):
+ # This media hasn't been already uploaded (either by this user or by someone
+ # else), so we save it now. If it has been already uploaded, there's no need
+ # to do it again (that's what the hash is for)
+ match STORAGE_ENGINE:
+ case "database":
+ await Media.insert(
+ Media(
+ media_id=digest,
+ media_type=MediaType.BLOB,
+ content_type=ext,
+ content=base64.b64encode(media),
+ )
+ )
+ case "local":
+ file = Path(STORAGE_FOLDER).resolve(strict=True) / str(digest)
+ file.touch(mode=0o644)
+ with file.open("wb") as f:
+ f.write(media)
+ await Media.insert(
+ Media(
+ media_id=digest,
+ media_type=MediaType.FILE,
+ content_type=ext,
+ content=file.as_posix(),
+ )
+ )
+ case "url":
+ pass # TODO: Use/implement CDN uploading
+ else:
+ user["media"] = old_media
+ if password and not bcrypt.checkpw(password.encode(), user["password_hash"]):
+ email_template = SMTP_TEMPLATES_DIRECTORY / f"{user['locale']}.json"
+ try:
+ email_template.resolve(strict=True)
+ except FileNotFoundError:
+ email_template = SMTP_TEMPLATES_DIRECTORY / "en_US.json"
+ with email_template.open() as f:
+ email_message = json.load(f)["password_change"]
+ verification_id = uuid.uuid4()
+ if not await send_email(
+ SMTP_HOST,
+ SMTP_PORT,
+ email_message["content"].format(
+ first_name=user["first_name"],
+ last_name=user["last_name"],
+ username=user["username"],
+ email=user["email_address"],
+ link=f"http{'s' if HAS_HTTPS else ''}://{HOST}"
+ f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/resetPassword/{verification_id}",
+ platformName=PLATFORM_NAME,
+ ),
+ SMTP_TIMEOUT,
+ SMTP_FROM_USER,
+ user["email_address"],
+ email_message["subject"].format(
+ first_name=user["first_name"],
+ last_name=user["last_name"],
+ username=user["username"],
+ email=user["email_address"],
+ platformName=PLATFORM_NAME,
+ ),
+ SMTP_USER,
+ SMTP_PASSWORD,
+ use_tls=SMTP_USE_TLS,
+ ):
+ raise HTTPException(500, detail="An error occurred while trying to send mail, please try again later")
+ else:
+ await EmailVerification.insert(
+ EmailVerification(
+ {
+ EmailVerification.id: verification_id,
+ EmailVerification.user: User(public_id=user["id"]),
+ EmailVerification.data: bcrypt.hashpw(password.encode(), user["password_hash"][:29])
+ }
+ )
+ )
+ if email_address and user["email_address"] != email_address:
+ if not user["email_verified"]:
+ raise HTTPException(
+ status_code=403,
+ detail="The email address needs to be verified first",
+ )
+ email_template = SMTP_TEMPLATES_DIRECTORY / f"{user['locale']}.json"
+ try:
+ email_template.resolve(strict=True)
+ except FileNotFoundError:
+ email_template = SMTP_TEMPLATES_DIRECTORY / "en_US.json"
+ with email_template.open() as f:
+ email_message = json.load(f)["email_change"]
+ verification_id = uuid.uuid4()
+ if not await send_email(
+ SMTP_HOST,
+ SMTP_PORT,
+ email_message["content"].format(
+ first_name=user["first_name"],
+ last_name=user["last_name"],
+ username=user["username"],
+ email=user["email_address"],
+ platformName=PLATFORM_NAME,
+ link=f"http{'s' if HAS_HTTPS else ''}://{HOST}"
+ f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/changeEmail/{verification_id}",
+ newMail=email_address,
+ ),
+ SMTP_TIMEOUT,
+ SMTP_FROM_USER,
+ user["email_address"],
+ email_message["subject"].format(
+ first_name=user["first_name"],
+ last_name=user["last_name"],
+ username=user["username"],
+ email=user["email_address"],
+ platformName=PLATFORM_NAME,
+ ),
+ SMTP_USER,
+ SMTP_PASSWORD,
+ use_tls=SMTP_USE_TLS,
+ ):
+ raise HTTPException(500, detail="An error occurred while trying to send mail, please try again later")
+ else:
+ await EmailVerification.insert(EmailVerification({
+ EmailVerification.id: verification_id,
+ EmailVerification.user: User(public_id=user["id"]),
+ EmailVerification.data: email_address.encode()
+ }))
+ else:
+ if not bio:
+ user["bio"] = None
+ if not profile_picture:
+ user["profile_picture"] = None
fields = []
for field in user:
- if field != "email_address" and orig_user[field] != user[field]:
+ if field not in ["email_address", "password"] and orig_user[field] != user[field]:
fields.append((field, user[field]))
if fields:
# If anything has changed, we update our info
- await User.update({field: value for field, value in fields}).where(User.public_id == user["id"])
+ await User.update({field: value for field, value in fields}).where(
+ User.public_id == user["id"]
+ )
return {"status_code": 200, "msg": "Changes saved successfully"}
diff --git a/main.py b/main.py
index 20cff00..8bb9483 100644
--- a/main.py
+++ b/main.py
@@ -32,7 +32,7 @@ from util.exception_handlers import (
rate_limited,
request_invalid,
not_authenticated,
- generic_error
+ generic_error,
)
from util.email import test_smtp
diff --git a/orm/__init__.py b/orm/__init__.py
index ab5efaf..f0427fd 100644
--- a/orm/__init__.py
+++ b/orm/__init__.py
@@ -2,6 +2,7 @@ import asyncio
from piccolo.table import create_db_tables
from .users import User
from .media import Media
+from .email_verification import EmailVerification
async def create_tables():
@@ -9,6 +10,6 @@ async def create_tables():
Initializes the database
"""
- await create_db_tables(User, Media, if_not_exists=True)
+ await create_db_tables(User, Media, EmailVerification, if_not_exists=True)
await User.create_index([User.public_id], if_not_exists=True)
await User.create_index([User.username], if_not_exists=True)
diff --git a/orm/email_verification.py b/orm/email_verification.py
new file mode 100644
index 0000000..b401cdf
--- /dev/null
+++ b/orm/email_verification.py
@@ -0,0 +1,14 @@
+from piccolo.table import Table
+from piccolo.columns import ForeignKey, Timestamptz, Boolean, UUID, Bytea
+from piccolo.columns.defaults.timestamptz import TimestamptzNow
+
+
+from .users import User
+
+
+class EmailVerification(Table, tablename="email_verifications"):
+ id = UUID(primary_key=True, null=False)
+ user = ForeignKey(references=User, null=False)
+ creation_date = Timestamptz(default=TimestamptzNow(), null=False)
+ pending = Boolean(default=True, null=False)
+ data = Bytea(default=None, null=True)
diff --git a/orm/users.py b/orm/users.py
index 42deb61..d5521a8 100644
--- a/orm/users.py
+++ b/orm/users.py
@@ -42,6 +42,7 @@ class User(Table, tablename="users"):
email_verified = Boolean(default=False, null=False, secret=True)
verified_account = Boolean(default=False, null=False)
deleted = Boolean(default=False, null=False)
+ locale = Varchar(length=12, default="en_US", null=False, secret=True)
UserModel = create_pydantic_model(User)
diff --git a/templates/email/en_US.json b/templates/email/en_US.json
index 64966a3..9960f60 100644
--- a/templates/email/en_US.json
+++ b/templates/email/en_US.json
@@ -1,3 +1,15 @@
-[{
-
-}]
\ No newline at end of file
+{
+ "signup": {
+ "subject": "Welcome to {platformName}, {username}!",
+ "content": "Hi {first_name},
Welcome to {platformName}! We're glad you decided to sign up with us.
To complete the verification process, please click on this link.
{platformName} team"
+ },
+ "password_change": {
+ "subject": "Password reset request for {platformName}",
+ "content": "Hi {first_name},
We received a request to change your account's password. If you requested this change, please click on this link, otherwise you can simply ignore this message: the request will expire automatically.
If you did not authorize this operation, please contact our support team immediately.
{platformName} team"
+
+ },
+ "email_change": {
+ "subject": "Email change request for {platformName}",
+ "content": "Hi {first_name},
We received a request to change your account's email address to
{newMail}. If you requested this change, please click on this link, otherwise you can simply ignore this message: the request will expire automatically.