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 = "*"}
tortoise-orm = "*"
argon2-cffi = "*"
pillow = "*"
appdirs = "*"
[dev-packages]

97
Pipfile.lock generated
View file

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

View file

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

View file

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

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