import base64 import json import uuid import bcrypt from uuid import UUID from pathlib import Path from fastapi.exceptions import HTTPException from fastapi import APIRouter as FastAPI, Depends, Response, Request, UploadFile from config import ( BCRYPT_ROUNDS, COOKIE_SAMESITE_POLICY, COOKIE_DOMAIN, MANAGER, SESSION_COOKIE_NAME, LIMITER, SECURE_COOKIE, COOKIE_PATH, COOKIE_HTTPONLY, STORAGE_ENGINE, 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, UNVERIFIED_MANAGER, ) from responses import ( Response as APIResponse, UnprocessableEntity, BadRequest, NotFound, MediaTypeNotAcceptable, PayloadTooLarge, InternalServerError, ) from responses.users import ( PrivateUserResponse, PublicUserResponse, ) from orm.users import ( User, UserModel, PublicUserModel, PrivateUserModel, get_user_by_username, get_user_by_id, ) from orm.media import Media, MediaType, PublicMediaModel from orm.email_verification import EmailVerification from util.email import send_email from util.users import validate_user, validate_profile_picture router = FastAPI() # Here follow our *beautifully* documented path operations @router.get( "/user/me", tags=["Users"], status_code=200, responses={ 200: { "model": PrivateUserResponse, "exclude": {"password_hash", "internal_id", "deleted"}, }, 422: {"model": UnprocessableEntity}, }, ) @LIMITER.limit("2/second") async def get_self(request: Request, user: UserModel = Depends(UNVERIFIED_MANAGER)): """ Fetches a user's own info. This returns some extra data such as email address, account creation date and email verification status, which is not available from the regular endpoint. Endpoint is limited to 2 hits per second """ return PrivateUserResponse(status_code=200, msg="Success", data=user) @router.get( "/user/username/{username}", tags=["Users"], status_code=200, responses={ 200: { "model": PublicUserResponse, "exclude": {"password_hash", "internal_id", "deleted"}, }, 404: {"model": NotFound}, 422: {"model": UnprocessableEntity}, }, ) @LIMITER.limit("30/second") async def get_user_by_name( request: Request, username: str, _auth: UserModel = Depends(MANAGER) ): """ Fetches a single user by its public username. Endpoint is limited to 30 hits per second """ if not (user := await get_user_by_username(username)): return NotFound(msg="Lookup failed: the user does not exist") user: PrivateUserModel return PublicUserResponse( data=PublicUserModel( public_id=user.public_id, first_name=user.first_name, last_name=user.last_name, username=user.username, bio=user.bio, verified_account=user.verified_account, profile_picture=PublicMediaModel( media_id=user.profile_picture.media_id, content=user.profile_picture.content, content_type=user.profile_picture.content_type, creation_date=user.profile_picture.creation_date, ) if user.profile_picture else None, ) ) @router.get( "/user/id/{public_id}", tags=["Users"], status_code=200, responses={ 200: {"model": PublicUserResponse}, 404: {"model": NotFound}, 422: {"model": UnprocessableEntity}, }, ) @LIMITER.limit("30/second") async def get_user_by_public_id( request: Request, public_id: str, _auth: UserModel = Depends(MANAGER) ): """ Fetches a single user by its public ID. Endpoint is limited to 30 hits per second """ if not (user := await get_user_by_id(UUID(public_id))): raise HTTPException( status_code=404, detail="Lookup failed: the user does not exist" ) user: PrivateUserModel return PublicUserResponse( data=PublicUserModel( public_id=user.public_id, first_name=user.first_name, last_name=user.last_name, username=user.username, bio=user.bio, verified_account=user.verified_account, profile_picture=PublicMediaModel( media_id=user.profile_picture.media_id, content=user.profile_picture.content, content_type=user.profile_picture.content_type, creation_date=user.profile_picture.creation_date, ) if user.profile_picture else None, ) ) @router.delete( "/user", tags=["Users"], status_code=200, responses={200: {"model": APIResponse}, 422: {"model": UnprocessableEntity}}, ) @LIMITER.limit("1/minute") async def delete_user( request: Request, response: Response, user: UserModel = Depends(UNVERIFIED_MANAGER) ): """ Sets the user's deleted flag in the database, without actually deleting the associated data. Note that calling this method will also log you out, preventing any further action permanently. Endpoint is limited to 1 hit per minute """ await User.update({User.deleted: True}).where(User.public_id == user.public_id) response.delete_cookie( secure=SECURE_COOKIE, key=SESSION_COOKIE_NAME, httponly=COOKIE_HTTPONLY, samesite=COOKIE_SAMESITE_POLICY, domain=COOKIE_DOMAIN or None, path=COOKIE_PATH or "/", ) return APIResponse(status_code=200, msg="Success") @router.put( "/user", tags=["Users"], status_code=200, responses={ 200: {"model": APIResponse}, 400: {"model": BadRequest}, 422: {"model": UnprocessableEntity}, 500: {"model": InternalServerError}, 413: {"model": PayloadTooLarge}, }, ) @LIMITER.limit("2/minute") async def signup( request: Request, first_name: str, last_name: str, username: str, email: str, password: str, bio: str | None = None, locale: str = "en_US", ): """ Registers a new user. Endpoint is limited to 2 hits per minute """ if request.cookies.get(SESSION_COOKIE_NAME): 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, bio ) if not result: return APIResponse(status_code=413, msg=f"Signup failed: {msg}") else: 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=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 APIResponse(status_code=200, msg="Success") else: raise HTTPException( status_code=500, detail="An error occurred while sending verification email, please" " try again later", ) @router.patch( "/user", tags=["Users"], status_code=200, responses={ 200: {"model": APIResponse}, 400: {"model": BadRequest}, 422: {"model": UnprocessableEntity}, 500: {"model": InternalServerError}, 413: {"model": PayloadTooLarge}, 415: {"model": MediaTypeNotAcceptable}, }, ) @LIMITER.limit("6/minute") async def update_user( request: Request, user: UserModel = 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 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. Endpoint is limited to 6 hits per minute """ 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 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 ( m := await 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": m = Media( media_id=digest, media_type=MediaType.BLOB, content_type=ext, content=base64.b64encode(media), ) await Media.insert(m) user.profile_picture = m case "local": file = Path(STORAGE_FOLDER).resolve(strict=True) / str(digest) file.touch(mode=0o644) with file.open("wb") as f: f.write(media) m = Media( media_id=digest, media_type=MediaType.FILE, content_type=ext, content=file.as_posix(), ) await Media.insert(m) user.profile_picture = m case "url": pass # TODO: Use/implement CDN uploading else: user.media = m 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.public_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.public_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 not in ["email_address", "password"] and getattr( orig_user, field ) != getattr(user, field): fields.append((field, getattr(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.public_id ) return APIResponse(status_code=200, msg="Changes saved successfully")