From ce746acbd47c513b0aaf039d9947c642836e88a8 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 10 Aug 2024 12:29:20 +0100 Subject: [PATCH] mostly working! --- Pipfile | 2 + Pipfile.lock | 97 +++++++++++++++++- src/db.py | 27 ++--- src/main.py | 25 ++++- src/routes/account.py | 230 ++++++++++++++++++++++++++++-------------- src/routes/image.py | 65 ++++++++++++ src/routes/oauth2.py | 92 +++++++++++++++++ 7 files changed, 442 insertions(+), 96 deletions(-) create mode 100644 src/routes/image.py create mode 100644 src/routes/oauth2.py diff --git a/Pipfile b/Pipfile index 0c681a3..383b6c1 100644 --- a/Pipfile +++ b/Pipfile @@ -8,6 +8,8 @@ fastapi = {extras = ["standard"], version = "*"} uvicorn = {extras = ["standard"], version = "*"} tortoise-orm = "*" argon2-cffi = "*" +pillow = "*" +appdirs = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 939c97b..e54117f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "38aafa2644708fb4b7e51b8ce82241b43677a4e1406d777357bdc7c3723ea384" + "sha256": "770fe68e9f1691b3fd188a1d658007b1ba501c3ebd89a9bf05af39a2a3f2a5c0" }, "pipfile-spec": 6, "requires": { @@ -41,6 +41,14 @@ "markers": "python_version >= '3.8'", "version": "==4.4.0" }, + "appdirs": { + "hashes": [ + "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", + "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" + ], + "index": "pypi", + "version": "==1.4.4" + }, "argon2-cffi": { "hashes": [ "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08", @@ -378,6 +386,93 @@ "markers": "python_version >= '3.7'", "version": "==0.1.2" }, + "pillow": { + "hashes": [ + "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885", + "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", + "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df", + "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5", + "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", + "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d", + "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd", + "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", + "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908", + "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", + "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", + "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", + "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b", + "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", + "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a", + "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e", + "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", + "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", + "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b", + "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", + "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e", + "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab", + "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d", + "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", + "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", + "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", + "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4", + "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", + "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", + "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", + "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", + "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b", + "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", + "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0", + "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", + "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", + "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856", + "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef", + "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680", + "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b", + "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", + "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e", + "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", + "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", + "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8", + "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", + "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736", + "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da", + "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126", + "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd", + "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5", + "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b", + "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026", + "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b", + "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc", + "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46", + "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2", + "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c", + "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", + "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984", + "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", + "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", + "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", + "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b", + "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", + "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3", + "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84", + "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1", + "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5", + "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", + "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f", + "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", + "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", + "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e", + "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141", + "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", + "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", + "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27", + "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e", + "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==10.4.0" + }, "pycparser": { "hashes": [ "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", diff --git a/src/db.py b/src/db.py index a1b5e8a..2a99c24 100644 --- a/src/db.py +++ b/src/db.py @@ -1,7 +1,7 @@ import datetime import secrets +import pydantic import uuid as _uuid -import hashlib import argon2 from tortoise import Model, fields @@ -12,6 +12,9 @@ class User(Model): class Meta: table = "users" + class PydanticMeta: + exclude = ("password",) + uuid: _uuid.UUID = fields.UUIDField(pk=True) """The UUID of this user account. Always unique.""" username: str = fields.CharField(max_length=64) @@ -23,9 +26,6 @@ class User(Model): members: fields.ReverseRelation["Member"] - class PydanticMeta: - exclude = ["password"] - def verify_password(self, password: str) -> bool: """ Verifies the given password against the stored hash. @@ -58,25 +58,12 @@ class Member(Model): avatar_hash: str | None = fields.CharField(max_length=512, null=True) """The hash of the avatar image for this member, if any.""" - def current_file(self) -> str | None: - """ - Gets the current file for this member. - """ - if not self.avatar_hash: - return - fh = hashlib.sha1(str(self.uuid).encode()) - fh.update(self.avatar_hash.encode()) - return fh.hexdigest() - - class PydanticMeta: - computed = ["current_file"] - class OauthToken(Model): class Meta: table = "oauth_tokens" - token: str = fields.CharField(pk=True, max_length=512, default=secrets.token_urlsafe) + access_token: str = fields.CharField(pk=True, max_length=512, default=secrets.token_urlsafe) """The token string. Always unique.""" user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( "models.User", related_name="oauth_tokens", on_delete=fields.CASCADE @@ -86,6 +73,10 @@ class OauthToken(Model): default=lambda: datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=7) ) """The time at which this token expires.""" + scope: str = fields.TextField() + """The scopes for this token, space separated.""" + refresh_token: str = fields.CharField(max_length=512, null=True) + """The refresh token for this token, if any.""" User_Pydantic = pydantic_model_creator(User, name="User") diff --git a/src/main.py b/src/main.py index 148caaf..c8566b2 100644 --- a/src/main.py +++ b/src/main.py @@ -1,18 +1,27 @@ import contextlib +import os.path from fastapi import FastAPI from tortoise import generate_config from tortoise.contrib.fastapi import RegisterTortoise +from fastapi.staticfiles import StaticFiles from .routes.account import router as account_router +from .routes.image import router as image_router +from .routes.oauth2 import router as oauth2_router +from . import db from .db import User @contextlib.asynccontextmanager async def init_db(app): + if not os.path.exists("static"): + os.mkdir("static") + if not os.path.exists("static/avatars"): + os.mkdir("static/avatars") async with RegisterTortoise( app=app, - config=generate_config("sqlite://:memory:", app_modules={"models": ["db"]}), + config=generate_config("sqlite://test.db", app_modules={"models": [db]}), generate_schemas=True, add_exception_handlers=True, ): @@ -24,4 +33,16 @@ async def init_db(app): yield app = FastAPI(lifespan=init_db, debug=True) -app.include_router(account_router) \ No newline at end of file +app.include_router(account_router) +app.include_router(image_router) +app.include_router(oauth2_router) + +if not os.path.exists("static"): + os.mkdir("static") +if os.name == "nt": + # See: https://github.com/encode/starlette/issues/829#issuecomment-587163696 + import mimetypes + mimetypes.init() + mimetypes.add_type("image/webp", ".webp") + +app.mount("/static", StaticFiles(directory="static", html=True), name="static") diff --git a/src/routes/account.py b/src/routes/account.py index c2bb949..ad6106f 100644 --- a/src/routes/account.py +++ b/src/routes/account.py @@ -1,91 +1,171 @@ -import contextlib -import datetime +import hashlib +import io +import os +from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +import PIL.Image from typing import Annotated -from tortoise import generate_config -from tortoise.contrib.fastapi import RegisterTortoise -from ..db import User, OauthToken, User_Pydantic, Member, Member_Pydantic - -router = APIRouter(prefix="/api/account", tags=["account"]) -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/account/token") +from fastapi import APIRouter, UploadFile, HTTPException, status +from .oauth2 import session_with_scope +from ..db import OauthToken, User_Pydantic, Member_Pydantic, Member -async def get_current_account(token: Annotated[str, Depends(oauth2_scheme)]) -> User: - entry = await OauthToken.get_or_none(token=token) - if not entry: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - elif entry.expires_at < datetime.datetime.now(tz=datetime.timezone.utc): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token has expired", - headers={"WWW-Authenticate": "Bearer"}, - ) - await entry.fetch_related("user") - return entry.user +def convert_to(source: bytes, new_format: str = "webp", quality: int = 50, resize: bool = True) -> bytes: + """Converts a bytes object to webp.""" + src = io.BytesIO(source) + src.seek(0) + image = PIL.Image.open(src) + # Resize the image to 256x256 + if resize: + for size in (1000, 512, 256): + if image.width >= size or image.height >= size: + break + image.thumbnail((size, size), PIL.Image.Resampling.LANCZOS, 3) + buffer = io.BytesIO() + if new_format in ["jpg", "jpeg", "gif"]: + # Convert to RGB + image = image.convert("RGB") + image.save(buffer, format=new_format, quality=quality, method=6) + buffer.seek(0) + return buffer.read() -@router.post("/token") -async def login_for_access_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): - """Log in to an account and get an access token""" - user = await User.get_or_none(username=form_data.username) - if not user: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Incorrect username or password", - ) - elif not user.verify_password(form_data.password): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - token = OauthToken(user=user) - await token.save() - return {"access_token": token.token, "token_type": "bearer", "expires": token.expires_at} +router = APIRouter(prefix="/api/account", tags=["Account"]) -@router.delete("/token") -async def logout_from_access_token(account: Annotated[User, Depends(get_current_account)]): - """Delete an access token (log out)""" - await OauthToken.filter(user=account).delete() - return {"message": "Logged out successfully"} +@router.get("") +async def get_current_account(token: Annotated[OauthToken, session_with_scope("account:read")]): + """Get information about the current account""" + return await User_Pydantic.from_tortoise_orm(token.user) -@router.get("/me") -async def read_users_me(account: Annotated[User, Depends(get_current_account)]) -> User_Pydantic: - """Fetch the current account's details""" - return await User_Pydantic.from_tortoise_orm(account) +@router.get("/members") +async def get_current_account_members(token: Annotated[OauthToken, session_with_scope("account:read")]): + """Get a list of members in the current account""" + return await Member_Pydantic.from_queryset(token.user.members.all()) -@router.get("/{guid}") -async def read_user(guid: str) -> User_Pydantic: - """Fetch an account's details""" - account = await User.get_or_none(guid=guid) - if not account: - raise HTTPException(status_code=404, detail="Account not found") - return await User_Pydantic.from_tortoise_orm(account) +@router.post("/members", status_code=status.HTTP_201_CREATED) +async def create_current_account_member( + token: Annotated[OauthToken, session_with_scope("account:write")], + name: str +) -> Member_Pydantic: + """Create a new member in the current account""" + name = name.strip() + if len(name) > 64: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Name too long") + elif len(name) < 1: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Name too short") + existing = await Member.get_or_none(user=token.user, name__iexact=name) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Member already exists") + member_obj = await Member.create(user=token.user, name=name) + return await Member_Pydantic.from_tortoise_orm(member_obj) -@router.get("/{guid}/members") -async def read_user_members(guid: str, account: Annotated[User, Depends(get_current_account)]) -> list[Member_Pydantic]: - """Fetch an account's members""" - if guid != account.uuid: - raise HTTPException(status_code=403, detail="You cannot fetch other user's members.") - members = await Member.filter(user=account) - return await Member_Pydantic.from_queryset(members) - - -@router.post("/{guid}/members") -async def create_user_member(guid: str, member: Member_Pydantic, account: Annotated[User, Depends(get_current_account)]) -> Member_Pydantic: - """Create a member for an account""" - if guid != account.uuid: - raise HTTPException(status_code=403, detail="You cannot create members for other users.") - member = await Member.create(**member.dict(exclude_unset=True), user=account) +@router.patch("/members/{member_guid}") +async def update_current_account_member( + token: Annotated[OauthToken, session_with_scope("account:write")], + member_guid: str, + name: str +): + """Update information about a specific member in the current account""" + name = name.strip() + if len(name) > 64: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Name too long") + elif len(name) < 1: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Name too short") + member = await Member.get_or_none(uuid=member_guid, user=token.user) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + member.name = name + await member.save() + return await Member_Pydantic.from_tortoise_orm(member) + + +@router.delete("/members/{member_guid}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_current_account_member(token: Annotated[OauthToken, session_with_scope("account:write")], member_guid: str): + """Delete a specific member in the current account""" + member = await Member.get_or_none(uuid=member_guid, user=token.user) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + await member.delete() + return + + +@router.get("/members/{member_guid}") +async def get_current_account_member(token: Annotated[OauthToken, session_with_scope("account:read")], member_guid: str): + """Get information about a specific member in the current account""" + member = await Member.get_or_none(uuid=member_guid, user=token.user) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + return await Member_Pydantic.from_tortoise_orm(member) + + +@router.get("/members/{member_guid}/avatar") +async def get_current_account_member_avatar_hash( + token: Annotated[OauthToken, session_with_scope("account:read")], + member_guid: str +): + """Get the avatar hash of a specific member in the current account""" + member = await Member.get_or_none(uuid=member_guid, user=token.user) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + return {"sha1": member.avatar_hash} + + +@router.post("/members/{member_guid}/avatar") +async def update_current_account_member_avatar_hash( + token: Annotated[OauthToken, session_with_scope("account:write")], + member_guid: str, + file: UploadFile, +): + """Update the avatar hash of a specific member in the current account""" + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid file type") + elif file.size > 1024 * 1024 * 10: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File too large") + + member = await Member.get_or_none(uuid=member_guid, user=token.user) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + + avatar_data = await file.read() + compressed_avatar_data = convert_to(avatar_data) + sha1 = hashlib.sha1(compressed_avatar_data).hexdigest() + + path = Path("static/avatars") / f"{sha1}.webp" + path.write_bytes(compressed_avatar_data) + if hashlib.sha1(path.read_bytes()).hexdigest() != sha1: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to write file (file hash mismatch)" + ) + if member.avatar_hash is not None: + try: + os.remove("static/avatars/%s.webp" % member.avatar_hash) + except FileNotFoundError: + pass + member.avatar_hash = sha1 + await member.save() + return await Member_Pydantic.from_tortoise_orm(member) + + +@router.delete("/members/{member_guid}/avatar") +async def delete_current_account_member_avatar_hash( + token: Annotated[OauthToken, session_with_scope("account:write")], + member_guid: str +): + """Delete the avatar hash of a specific member in the current account""" + member = await Member.get_or_none(uuid=member_guid, user=token.user) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + if member.avatar_hash is not None: + try: + os.remove("static/avatars/%s.webp" % member.avatar_hash) + except FileNotFoundError: + pass + member.avatar_hash = None + await member.save() return await Member_Pydantic.from_tortoise_orm(member) diff --git a/src/routes/image.py b/src/routes/image.py new file mode 100644 index 0000000..b079618 --- /dev/null +++ b/src/routes/image.py @@ -0,0 +1,65 @@ +import datetime +import os.path +import io +import tempfile +import appdirs +from pathlib import Path + +from fastapi import APIRouter, Path as URLPath, HTTPException, Request, Query +from typing import Annotated +from fastapi.responses import StreamingResponse, FileResponse, Response +from .account import convert_to + + +router = APIRouter(prefix="/img", tags=["Image"]) +CACHE: dict[tuple[str, str, int], tuple[Path, str]] = {} + + +@router.get("/a/{file_hash}.{file_format}", response_class=StreamingResponse) +async def get_avatar( + req: Request, + file_hash: Annotated[str, URLPath(description="File hash")], + file_format: Annotated[str, URLPath(description="File format", pattern=r"^png|jp(e)?g|webp|gif$")], + quality: int = Query( + 50, + description="Quality of the image, from 1 to 100. Only for JPEG.", + ge=1, le=100 + ) +): + """Fetch an avatar, in the requested format. WebP is fastest.""" + if not os.path.exists(f"static/avatars/{file_hash}.webp"): + raise HTTPException(status_code=404, detail="Avatar not found") + + if file_format == "webp": + return FileResponse(f"static/avatars/{file_hash}.webp", media_type="image/webp") + # with open(f"static/avatars/{file_hash}.webp", "rb") as fd: + # return StreamingResponse(io.BytesIO(fd.read()), media_type="image/webp") + + key = (file_hash, file_format, quality) + res = None + if key in CACHE: + file, _etag = CACHE[key] + file: Path + stat = file.stat() + mtime_dt = datetime.datetime.fromtimestamp(stat.st_mtime) + if req.headers.get("if-modified-since") == mtime_dt.strftime("%a, %d %b %Y %H:%M:%S GMT"): + return Response(status_code=304) + if req.headers.get("if-none-match") == _etag: + return Response(status_code=304) + if file.exists(): + res = FileResponse(file, media_type=f"image/{file_format}") + res.set_stat_headers(stat) + CACHE[key] = (file, res.headers["etag"]) + + if res is None: # cache miss + with open(f"static/avatars/{file_hash}.webp", "rb") as fd: + data = fd.read() + converted_data = convert_to(data, file_format, quality) + cache_file = Path(appdirs.user_cache_dir("avaserve")) / f"{file_hash}.{file_format}" + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_bytes(converted_data) + res = FileResponse(cache_file, media_type=f"image/{file_format}") + res.set_stat_headers(cache_file.stat()) + res.headers["Cache-Control"] = "public,max-age=31536000,immutable" + CACHE[key] = (cache_file, res.headers["etag"]) + return res diff --git a/src/routes/oauth2.py b/src/routes/oauth2.py new file mode 100644 index 0000000..f122e50 --- /dev/null +++ b/src/routes/oauth2.py @@ -0,0 +1,92 @@ +import datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from typing import Annotated, Iterable, TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi.params import Depends as DependsType + +from ..db import User, OauthToken + +SCOPES = { + "account:read": "Read account information, including all members", + "account:write": "Write account information, including adding and removing members", + "account:avatar:write": "create/edit/delete avatars for members", + "account:delete": "Delete the account", + "admin": "Administrator access. Requires an administrator account." +} +router = APIRouter(prefix="/api/oauth2", tags=["Oauth2"]) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/oauth2/token", scopes=SCOPES) + + +async def get_current_account(token: Annotated[str, Depends(oauth2_scheme)]) -> OauthToken: + entry = await OauthToken.get_or_none(access_token=token) + if not entry: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + elif entry.expires_at < datetime.datetime.now(tz=datetime.timezone.utc): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + headers={"WWW-Authenticate": "Bearer"}, + ) + await entry.fetch_related("user") + return entry + + +def session_with_scope(*scopes: str) -> "DependsType": + scopes = list(set(scopes)) + scopes.sort() + async def _session_with_scope(token: Annotated[OauthToken, Depends(get_current_account)]) -> OauthToken: + if not all(scope in token.scope.split() for scope in scopes): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions. Need scopes: " + ", ".join(scopes), + ) + return token + + return Depends(_session_with_scope) + + +@router.post("/token") +async def login_for_access_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): + """Log in to an account and get an access token""" + user = await User.get_or_none(username=form_data.username) + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect username or password", + ) + elif not user.verify_password(form_data.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + if "admin" in form_data.scopes and not user.admin: + form_data.scopes.remove("admin") + token = OauthToken(user=user, scope=" ".join(form_data.scopes)) + await token.save() + return { + "access_token": token.access_token, + "token_type": "bearer", + "expires": token.expires_at, + "scope": token.scope + } + + +@router.post("/token/revoke") +async def revoke_access_token(token: Annotated[str, Depends(oauth2_scheme)]): + """Revoke an access token""" + entry = await OauthToken.get_or_none(access_token=token) + if not entry: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid token", + ) + await entry.delete() + return {"status": "ok"}