mostly working!

This commit is contained in:
Nexus 2024-08-10 12:29:20 +01:00
parent 8f2d48c14f
commit ce746acbd4
7 changed files with 442 additions and 96 deletions

View file

@ -8,6 +8,8 @@ fastapi = {extras = ["standard"], version = "*"}
uvicorn = {extras = ["standard"], version = "*"} uvicorn = {extras = ["standard"], version = "*"}
tortoise-orm = "*" tortoise-orm = "*"
argon2-cffi = "*" argon2-cffi = "*"
pillow = "*"
appdirs = "*"
[dev-packages] [dev-packages]

97
Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "38aafa2644708fb4b7e51b8ce82241b43677a4e1406d777357bdc7c3723ea384" "sha256": "770fe68e9f1691b3fd188a1d658007b1ba501c3ebd89a9bf05af39a2a3f2a5c0"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -41,6 +41,14 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.4.0" "version": "==4.4.0"
}, },
"appdirs": {
"hashes": [
"sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
"sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
],
"index": "pypi",
"version": "==1.4.4"
},
"argon2-cffi": { "argon2-cffi": {
"hashes": [ "hashes": [
"sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08", "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08",
@ -378,6 +386,93 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==0.1.2" "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": { "pycparser": {
"hashes": [ "hashes": [
"sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6",

View file

@ -1,7 +1,7 @@
import datetime import datetime
import secrets import secrets
import pydantic
import uuid as _uuid import uuid as _uuid
import hashlib
import argon2 import argon2
from tortoise import Model, fields from tortoise import Model, fields
@ -12,6 +12,9 @@ class User(Model):
class Meta: class Meta:
table = "users" table = "users"
class PydanticMeta:
exclude = ("password",)
uuid: _uuid.UUID = fields.UUIDField(pk=True) uuid: _uuid.UUID = fields.UUIDField(pk=True)
"""The UUID of this user account. Always unique.""" """The UUID of this user account. Always unique."""
username: str = fields.CharField(max_length=64) username: str = fields.CharField(max_length=64)
@ -23,9 +26,6 @@ class User(Model):
members: fields.ReverseRelation["Member"] members: fields.ReverseRelation["Member"]
class PydanticMeta:
exclude = ["password"]
def verify_password(self, password: str) -> bool: def verify_password(self, password: str) -> bool:
""" """
Verifies the given password against the stored hash. 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) avatar_hash: str | None = fields.CharField(max_length=512, null=True)
"""The hash of the avatar image for this member, if any.""" """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 OauthToken(Model):
class Meta: class Meta:
table = "oauth_tokens" 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.""" """The token string. Always unique."""
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
"models.User", related_name="oauth_tokens", on_delete=fields.CASCADE "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) default=lambda: datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=7)
) )
"""The time at which this token expires.""" """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") User_Pydantic = pydantic_model_creator(User, name="User")

View file

@ -1,18 +1,27 @@
import contextlib import contextlib
import os.path
from fastapi import FastAPI from fastapi import FastAPI
from tortoise import generate_config from tortoise import generate_config
from tortoise.contrib.fastapi import RegisterTortoise from tortoise.contrib.fastapi import RegisterTortoise
from fastapi.staticfiles import StaticFiles
from .routes.account import router as account_router 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 from .db import User
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def init_db(app): 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( async with RegisterTortoise(
app=app, app=app,
config=generate_config("sqlite://:memory:", app_modules={"models": ["db"]}), config=generate_config("sqlite://test.db", app_modules={"models": [db]}),
generate_schemas=True, generate_schemas=True,
add_exception_handlers=True, add_exception_handlers=True,
): ):
@ -25,3 +34,15 @@ async def init_db(app):
app = FastAPI(lifespan=init_db, debug=True) app = FastAPI(lifespan=init_db, debug=True)
app.include_router(account_router) 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")

View file

@ -1,91 +1,171 @@
import contextlib import hashlib
import datetime import io
import os
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status import PIL.Image
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from typing import Annotated 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 from fastapi import APIRouter, UploadFile, HTTPException, status
from .oauth2 import session_with_scope
router = APIRouter(prefix="/api/account", tags=["account"]) from ..db import OauthToken, User_Pydantic, Member_Pydantic, Member
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/account/token")
async def get_current_account(token: Annotated[str, Depends(oauth2_scheme)]) -> User: def convert_to(source: bytes, new_format: str = "webp", quality: int = 50, resize: bool = True) -> bytes:
entry = await OauthToken.get_or_none(token=token) """Converts a bytes object to webp."""
if not entry: src = io.BytesIO(source)
raise HTTPException( src.seek(0)
status_code=status.HTTP_401_UNAUTHORIZED, image = PIL.Image.open(src)
detail="Invalid authentication credentials", # Resize the image to 256x256
headers={"WWW-Authenticate": "Bearer"}, if resize:
) for size in (1000, 512, 256):
elif entry.expires_at < datetime.datetime.now(tz=datetime.timezone.utc): if image.width >= size or image.height >= size:
raise HTTPException( break
status_code=status.HTTP_401_UNAUTHORIZED, image.thumbnail((size, size), PIL.Image.Resampling.LANCZOS, 3)
detail="Token has expired", buffer = io.BytesIO()
headers={"WWW-Authenticate": "Bearer"}, if new_format in ["jpg", "jpeg", "gif"]:
) # Convert to RGB
await entry.fetch_related("user") image = image.convert("RGB")
return entry.user image.save(buffer, format=new_format, quality=quality, method=6)
buffer.seek(0)
return buffer.read()
@router.post("/token") router = APIRouter(prefix="/api/account", tags=["Account"])
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.delete("/token") @router.get("")
async def logout_from_access_token(account: Annotated[User, Depends(get_current_account)]): async def get_current_account(token: Annotated[OauthToken, session_with_scope("account:read")]):
"""Delete an access token (log out)""" """Get information about the current account"""
await OauthToken.filter(user=account).delete() return await User_Pydantic.from_tortoise_orm(token.user)
return {"message": "Logged out successfully"}
@router.get("/me") @router.get("/members")
async def read_users_me(account: Annotated[User, Depends(get_current_account)]) -> User_Pydantic: async def get_current_account_members(token: Annotated[OauthToken, session_with_scope("account:read")]):
"""Fetch the current account's details""" """Get a list of members in the current account"""
return await User_Pydantic.from_tortoise_orm(account) return await Member_Pydantic.from_queryset(token.user.members.all())
@router.get("/{guid}") @router.post("/members", status_code=status.HTTP_201_CREATED)
async def read_user(guid: str) -> User_Pydantic: async def create_current_account_member(
"""Fetch an account's details""" token: Annotated[OauthToken, session_with_scope("account:write")],
account = await User.get_or_none(guid=guid) name: str
if not account: ) -> Member_Pydantic:
raise HTTPException(status_code=404, detail="Account not found") """Create a new member in the current account"""
return await User_Pydantic.from_tortoise_orm(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") @router.patch("/members/{member_guid}")
async def read_user_members(guid: str, account: Annotated[User, Depends(get_current_account)]) -> list[Member_Pydantic]: async def update_current_account_member(
"""Fetch an account's members""" token: Annotated[OauthToken, session_with_scope("account:write")],
if guid != account.uuid: member_guid: str,
raise HTTPException(status_code=403, detail="You cannot fetch other user's members.") name: str
members = await Member.filter(user=account) ):
return await Member_Pydantic.from_queryset(members) """Update information about a specific member in the current account"""
name = name.strip()
if len(name) > 64:
@router.post("/{guid}/members") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Name too long")
async def create_user_member(guid: str, member: Member_Pydantic, account: Annotated[User, Depends(get_current_account)]) -> Member_Pydantic: elif len(name) < 1:
"""Create a member for an account""" raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Name too short")
if guid != account.uuid: member = await Member.get_or_none(uuid=member_guid, user=token.user)
raise HTTPException(status_code=403, detail="You cannot create members for other users.") if not member:
member = await Member.create(**member.dict(exclude_unset=True), user=account) 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) return await Member_Pydantic.from_tortoise_orm(member)

65
src/routes/image.py Normal file
View file

@ -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

92
src/routes/oauth2.py Normal file
View file

@ -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"}