mostly working!
This commit is contained in:
parent
8f2d48c14f
commit
ce746acbd4
7 changed files with 442 additions and 96 deletions
2
Pipfile
2
Pipfile
|
@ -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
97
Pipfile.lock
generated
|
@ -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",
|
||||||
|
|
27
src/db.py
27
src/db.py
|
@ -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")
|
||||||
|
|
23
src/main.py
23
src/main.py
|
@ -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")
|
||||||
|
|
|
@ -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
65
src/routes/image.py
Normal 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
92
src/routes/oauth2.py
Normal 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"}
|
Loading…
Reference in a new issue