materia-server: base CRD api, resources routes

This commit is contained in:
L-Nafaryus 2024-07-05 18:34:14 +05:00
parent 4312d5b5d1
commit aef6c2b541
Signed by: L-Nafaryus
GPG Key ID: 553C97999B363D38
14 changed files with 560 additions and 216 deletions

View File

@ -17,7 +17,13 @@ from fastapi.middleware.cors import CORSMiddleware
from materia_server import config as _config
from materia_server.config import Config
from materia_server._logging import make_logger, uvicorn_log_config, Logger
from materia_server.models import Database, DatabaseError, DatabaseMigrationError, Cache, CacheError
from materia_server.models import (
Database,
DatabaseError,
DatabaseMigrationError,
Cache,
CacheError,
)
from materia_server import routers
@ -27,6 +33,7 @@ class AppContext(TypedDict):
database: Database
cache: Cache
def make_lifespan(config: Config, logger: Logger):
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[AppContext]:
@ -50,24 +57,20 @@ def make_lifespan(config: Config, logger: Logger):
logger.error(f"Failed to connect redis: {e}")
sys.exit()
yield AppContext(
config = config,
database = database,
cache = cache,
logger = logger
)
yield AppContext(config=config, database=database, cache=cache, logger=logger)
if database.engine is not None:
await database.dispose()
return lifespan
def make_application(config: Config, logger: Logger):
app = FastAPI(
title="materia",
version="0.1.0",
docs_url="/api/docs",
lifespan = make_lifespan(config, logger)
lifespan=make_lifespan(config, logger),
)
app.add_middleware(
CORSMiddleware,
@ -77,5 +80,6 @@ def make_application(config: Config, logger: Logger):
allow_headers=["*"],
)
app.include_router(routers.api.router)
app.include_router(routers.resources.router)
return app

View File

@ -21,10 +21,12 @@ from materia_server.models import Database, DatabaseError, Cache
from materia_server import routers
from materia_server.app import make_application
@click.group()
def server():
pass
@server.command()
@click.option("--config_path", type=Path)
@from_pydantic("application", _config.Application, prefix="app")
@ -39,7 +41,9 @@ def start(application: _config.Application, config_path: Path, log: _config.Log)
# if group := application.group:
# os.setgid(pwd.getpwnam(user).pw_gid)
# TODO: merge cli options with config
if working_directory := (application.working_directory or config.application.working_directory).resolve():
if working_directory := (
application.working_directory or config.application.working_directory
).resolve():
try:
os.chdir(working_directory)
except FileNotFoundError as e:
@ -78,7 +82,7 @@ def start(application: _config.Application, config_path: Path, log: _config.Log)
config.log.level = log.level
logger = make_logger(config)
if (working_directory := config.application.working_directory.resolve()):
if working_directory := config.application.working_directory.resolve():
logger.debug(f"Change working directory: {working_directory}")
try:
os.chdir(working_directory)
@ -99,13 +103,23 @@ def start(application: _config.Application, config_path: Path, log: _config.Log)
except (KeyboardInterrupt, SystemExit):
pass
@server.group()
def config():
pass
@config.command("create", help="Create a new configuration file.")
@click.option("--path", "-p", type = Path, default = Path.cwd().joinpath("config.toml"), help = "Path to the file.")
@click.option("--force", "-f", is_flag = True, default = False, help = "Overwrite a file if exists.")
@click.option(
"--path",
"-p",
type=Path,
default=Path.cwd().joinpath("config.toml"),
help="Path to the file.",
)
@click.option(
"--force", "-f", is_flag=True, default=False, help="Overwrite a file if exists."
)
def config_create(path: Path, force: bool):
path = path.resolve()
config = Config()
@ -123,8 +137,15 @@ def config_create(path: Path, force: bool):
config.write(path)
logger.info("All done.")
@config.command("check", help="Check the configuration file.")
@click.option("--path", "-p", type = Path, default = Path.cwd().joinpath("config.toml"), help = "Path to the file.")
@click.option(
"--path",
"-p",
type=Path,
default=Path.cwd().joinpath("config.toml"),
help="Path to the file.",
)
def config_check(path: Path):
path = path.resolve()
config = Config()
@ -141,9 +162,6 @@ def config_check(path: Path):
else:
logger.info("OK.")
if __name__ == "__main__":
server()

View File

@ -15,8 +15,12 @@ class Directory(Base):
__tablename__ = "directory"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
repository_id: Mapped[int] = mapped_column(ForeignKey("repository.id", ondelete = "CASCADE"))
parent_id: Mapped[int] = mapped_column(ForeignKey("directory.id", ondelete = "CASCADE"), nullable = True)
repository_id: Mapped[int] = mapped_column(
ForeignKey("repository.id", ondelete="CASCADE")
)
parent_id: Mapped[int] = mapped_column(
ForeignKey("directory.id", ondelete="CASCADE"), nullable=True
)
created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
name: Mapped[str]
@ -24,30 +28,54 @@ class Directory(Base):
is_public: Mapped[bool] = mapped_column(default=False)
repository: Mapped["Repository"] = relationship(back_populates="directories")
directories: Mapped[List["Directory"]] = relationship(back_populates = "parent", remote_side = [id])
directories: Mapped[List["Directory"]] = relationship(
back_populates="parent", remote_side=[id]
)
parent: Mapped["Directory"] = relationship(back_populates="directories")
files: Mapped[List["File"]] = relationship(back_populates="parent")
link: Mapped["DirectoryLink"] = relationship(back_populates="directory")
@staticmethod
async def by_path(repository_id: int, path: Path | None, name: str, db: database.Database) -> Self | None:
async def by_path(
repository_id: int, path: Path | None, name: str, db: database.Database
) -> Self | None:
async with db.session() as session:
query_path = Directory.path == str(path) if isinstance(path, Path) else Directory.path.is_(None)
return (await session
.scalars(sa.select(Directory)
.where(sa.and_(Directory.repository_id == repository_id, Directory.name == name, query_path)))
query_path = (
Directory.path == str(path)
if isinstance(path, Path)
else Directory.path.is_(None)
)
return (
await session.scalars(
sa.select(Directory).where(
sa.and_(
Directory.repository_id == repository_id,
Directory.name == name,
query_path,
)
)
)
).first()
async def remove(self, db: database.Database):
async with db.session() as session:
await session.execute(sa.delete(Directory).where(Directory.id == self.id))
await session.commit()
class DirectoryLink(Base):
__tablename__ = "directory_link"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
directory_id: Mapped[int] = mapped_column(ForeignKey("directory.id", ondelete = "CASCADE"))
directory_id: Mapped[int] = mapped_column(
ForeignKey("directory.id", ondelete="CASCADE")
)
created: Mapped[int] = mapped_column(BigInteger, default=time)
url: Mapped[str]
directory: Mapped["Directory"] = relationship(back_populates="link")
class DirectoryInfo(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@ -10,12 +10,17 @@ from pydantic import BaseModel, ConfigDict
from materia_server.models.base import Base
from materia_server.models import database
class File(Base):
__tablename__ = "file"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
repository_id: Mapped[int] = mapped_column(ForeignKey("repository.id", ondelete = "CASCADE"))
parent_id: Mapped[int] = mapped_column(ForeignKey("directory.id"), nullable = True)
repository_id: Mapped[int] = mapped_column(
ForeignKey("repository.id", ondelete="CASCADE")
)
parent_id: Mapped[int] = mapped_column(
ForeignKey("directory.id", ondelete="CASCADE"), nullable=True
)
created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
name: Mapped[str]
@ -28,12 +33,25 @@ class File(Base):
link: Mapped["FileLink"] = relationship(back_populates="file")
@staticmethod
async def by_path(repository_id: int, path: Path | None, name: str, db: database.Database) -> Self | None:
async def by_path(
repository_id: int, path: Path | None, name: str, db: database.Database
) -> Self | None:
async with db.session() as session:
query_path = File.path == str(path) if isinstance(path, Path) else File.path.is_(None)
return (await session
.scalars(sa.select(File)
.where(sa.and_(File.repository_id == repository_id, File.name == name, query_path)))
query_path = (
File.path == str(path)
if isinstance(path, Path)
else File.path.is_(None)
)
return (
await session.scalars(
sa.select(File).where(
sa.and_(
File.repository_id == repository_id,
File.name == name,
query_path,
)
)
)
).first()
async def remove(self, db: database.Database):
@ -41,6 +59,7 @@ class File(Base):
await session.execute(sa.delete(File).where(File.id == self.id))
await session.commit()
class FileLink(Base):
__tablename__ = "file_link"
@ -51,6 +70,7 @@ class FileLink(Base):
file: Mapped["File"] = relationship(back_populates="link")
class FileInfo(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,36 @@
"""empty message
Revision ID: 86dd738cbd40
Revises: 939b37d98be0
Create Date: 2024-07-05 16:42:31.645410
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '86dd738cbd40'
down_revision: Union[str, None] = '939b37d98be0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('file_parent_id_fkey', 'file', type_='foreignkey')
op.create_foreign_key(None, 'file', 'directory', ['parent_id'], ['id'], ondelete='CASCADE')
op.drop_constraint('repository_user_id_fkey', 'repository', type_='foreignkey')
op.create_foreign_key(None, 'repository', 'user', ['user_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'repository', type_='foreignkey')
op.create_foreign_key('repository_user_id_fkey', 'repository', 'user', ['user_id'], ['id'])
op.drop_constraint(None, 'file', type_='foreignkey')
op.create_foreign_key('file_parent_id_fkey', 'file', 'directory', ['parent_id'], ['id'])
# ### end Alembic commands ###

View File

@ -16,7 +16,7 @@ class Repository(Base):
__tablename__ = "repository"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id"))
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))
capacity: Mapped[int] = mapped_column(BigInteger, nullable=False)
user: Mapped["User"] = relationship(back_populates="repository")
@ -24,7 +24,11 @@ class Repository(Base):
files: Mapped[List["File"]] = relationship(back_populates="repository")
def to_dict(self) -> dict:
return { k: getattr(self, k) for k, v in Repository.__dict__.items() if isinstance(v, InstrumentedAttribute) }
return {
k: getattr(self, k)
for k, v in Repository.__dict__.items()
if isinstance(v, InstrumentedAttribute)
}
async def create(self, db: database.Database):
async with db.session() as session:
@ -33,19 +37,33 @@ class Repository(Base):
async def update(self, db: database.Database):
async with db.session() as session:
await session.execute(sa.update(Repository).where(Repository.id == self.id).values(self.to_dict()))
await session.execute(
sa.update(Repository)
.where(Repository.id == self.id)
.values(self.to_dict())
)
await session.commit()
@staticmethod
async def by_user_id(user_id: UUID, db: database.Database) -> Self | None:
async with db.session() as session:
return (await session.scalars(sa.select(Repository).where(Repository.user_id == user_id))).first()
return (
await session.scalars(
sa.select(Repository).where(Repository.user_id == user_id)
)
).first()
async def remove(self, db: database.Database):
async with db.session() as session:
await session.execute(sa.delete(Repository).where(Repository.id == self.id))
await session.commit()
class RepositoryInfo(BaseModel):
capacity: int
used: int
from materia_server.models.user import User
from materia_server.models.directory import Directory
from materia_server.models.file import File

View File

@ -17,6 +17,7 @@ from loguru import logger
valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$")
invalid_username = re.compile(r"[-._]{2,}|[-._]$")
class User(Base):
__tablename__ = "user"
@ -63,23 +64,34 @@ class User(Base):
@staticmethod
async def by_name(name: str, db: database.Database):
async with db.session() as session:
return (await session.scalars(sa.select(User).where(User.name == name))).first()
return (
await session.scalars(sa.select(User).where(User.name == name))
).first()
@staticmethod
async def by_email(email: str, db: database.Database):
async with db.session() as session:
return (await session.scalars(sa.select(User).where(User.email == email))).first()
return (
await session.scalars(sa.select(User).where(User.email == email))
).first()
@staticmethod
async def by_id(id: UUID, db: database.Database):
async with db.session() as session:
return (await session.scalars(sa.select(User).where(User.id == id))).first()
async def remove(self, db: database.Database):
async with db.session() as session:
await session.execute(sa.delete(User).where(User.id == self.id))
await session.commit()
class UserCredentials(BaseModel):
name: str
password: str
email: Optional[EmailStr]
class UserInfo(BaseModel):
model_config = ConfigDict(from_attributes=True)
@ -87,7 +99,7 @@ class UserInfo(BaseModel):
name: str
lower_name: str
full_name: Optional[str]
email: str
email: Optional[str]
is_email_private: bool
must_change_password: bool
@ -102,4 +114,5 @@ class UserInfo(BaseModel):
avatar: Optional[str]
from materia_server.models.repository import Repository

View File

@ -1 +1 @@
from materia_server.routers import middleware, api
from materia_server.routers import middleware, api, resources

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter
from materia_server.routers.api.auth import auth, oauth
from materia_server.routers.api import user, repository, directory, file
router = APIRouter()
router = APIRouter(prefix="/api")
router.include_router(auth.router)
router.include_router(oauth.router)
router.include_router(user.router)

View File

@ -1,5 +1,6 @@
import os
from pathlib import Path
import shutil
from fastapi import APIRouter, Depends, HTTPException, status
@ -10,30 +11,39 @@ from materia_server.config import Config
router = APIRouter(tags=["directory"])
@router.post("/directory")
async def create(path: Path = Path(), user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def create(
path: Path = Path(),
user: User = Depends(middleware.user),
ctx: middleware.Context = Depends(),
):
repository_path = Config.data_dir() / "repository" / user.lower_name
blacklist = [os.sep, ".", "..", "*"]
directory_path = Path(os.sep.join(filter(lambda part: part not in blacklist, path.parts)))
directory_path = Path(
os.sep.join(filter(lambda part: part not in blacklist, path.parts))
)
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found")
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
current_directory = None
current_path = Path()
directory = None
for part in directory_path.parts:
if not await Directory.by_path(user.repository.id, current_path, part, ctx.database):
if not await Directory.by_path(
user.repository.id, current_path, part, ctx.database
):
directory = Directory(
repository_id=user.repository.id,
parent_id=current_directory.id if current_directory else None,
name=part,
path = None if current_path == Path() else str(current_path)
path=None if current_path == Path() else str(current_path),
)
session.add(directory)
@ -43,22 +53,37 @@ async def create(path: Path = Path(), user: User = Depends(middleware.user), ctx
try:
(repository_path / directory_path).mkdir(parents=True, exist_ok=True)
except OSError:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a directory")
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a directory"
)
await session.commit()
@router.get("/directory")
async def info(path: Path, user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def info(
path: Path,
user: User = Depends(middleware.user),
ctx: middleware.Context = Depends(),
):
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found")
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
if not(directory := await Directory.by_path(user.repository.id, None if path.parent == Path() else path.parent, path.name, ctx.database)):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory is not found")
if not (
directory := await Directory.by_path(
user.repository.id,
None if path.parent == Path() else path.parent,
path.name,
ctx.database,
)
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory not found")
async with ctx.database.session() as session:
session.add(directory)
await session.refresh(directory, attribute_names=["files"])
@ -67,3 +92,40 @@ async def info(path: Path, user: User = Depends(middleware.user), ctx: middlewar
return info
@router.delete("/directory")
async def remove(
path: Path,
user: User = Depends(middleware.user),
ctx: middleware.Context = Depends(),
):
repository_path = Config.data_dir() / "repository" / user.lower_name
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
if not (
directory := await Directory.by_path(
user.repository.id,
None if path.parent == Path() else path.parent,
path.name,
ctx.database,
)
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory not found")
directory_path = repository_path / path
try:
if directory_path.is_dir():
shutil.rmtree(str(directory_path))
except OSError:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to remove directory"
)
await directory.remove(ctx.database)

View File

@ -10,32 +10,42 @@ from materia_server.config import Config
router = APIRouter(tags=["file"])
@router.post("/file")
async def create(upload_file: UploadFile, path: Path = Path(), user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def create(
upload_file: UploadFile,
path: Path = Path(),
user: User = Depends(middleware.user),
ctx: middleware.Context = Depends(),
):
if not upload_file.filename:
raise HTTPException(status.HTTP_417_EXPECTATION_FAILED, "Cannot upload file without name")
raise HTTPException(
status.HTTP_417_EXPECTATION_FAILED, "Cannot upload file without name"
)
repository_path = Config.data_dir() / "repository" / user.lower_name
blacklist = [os.sep, ".", "..", "*"]
directory_path = Path(os.sep.join(filter(lambda part: part not in blacklist, path.parts)))
directory_path = Path(
os.sep.join(filter(lambda part: part not in blacklist, path.parts))
)
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found")
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
if not directory_path == Path():
directory = await Directory.by_path(
user.repository.id,
None if directory_path.parent == Path() else directory_path.parent,
directory_path.name,
ctx.database
ctx.database,
)
if not directory:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory is not found")
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory not found")
else:
directory = None
@ -44,25 +54,34 @@ async def create(upload_file: UploadFile, path: Path = Path(), user: User = Depe
parent_id=directory.id if directory else None,
name=upload_file.filename,
path=None if directory_path == Path() else str(directory_path),
size = upload_file.size
size=upload_file.size,
)
try:
file_path = repository_path.joinpath(directory_path, upload_file.filename)
if file_path.exists():
raise HTTPException(status.HTTP_409_CONFLICT, "File with given name already exists")
raise HTTPException(
status.HTTP_409_CONFLICT, "File with given name already exists"
)
file_path.write_bytes(await upload_file.read())
except OSError:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to write a file")
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to write a file"
)
async with ctx.database.session() as session:
session.add(file)
await session.commit()
@router.get("/file")
async def info(path: Path, user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def info(
path: Path,
user: User = Depends(middleware.user),
ctx: middleware.Context = Depends(),
):
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
@ -70,15 +89,27 @@ async def info(path: Path, user: User = Depends(middleware.user), ctx: middlewar
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
if not(file := await File.by_path(user.repository.id, None if path.parent == Path() else path.parent, path.name, ctx.database)):
if not (
file := await File.by_path(
user.repository.id,
None if path.parent == Path() else path.parent,
path.name,
ctx.database,
)
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found")
info = FileInfo.model_validate(file)
return info
@router.delete("/file")
async def remove(path: Path, user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def remove(
path: Path,
user: User = Depends(middleware.user),
ctx: middleware.Context = Depends(),
):
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
@ -86,7 +117,14 @@ async def remove(path: Path, user: User = Depends(middleware.user), ctx: middlew
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
if not(file := await File.by_path(user.repository.id, None if path.parent == Path() else path.parent, path.name, ctx.database)):
if not (
file := await File.by_path(
user.repository.id,
None if path.parent == Path() else path.parent,
path.name,
ctx.database,
)
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found")
repository_path = Config.data_dir() / "repository" / user.lower_name
@ -97,6 +135,8 @@ async def remove(path: Path, user: User = Depends(middleware.user), ctx: middlew
if file_path.exists():
file_path.unlink(missing_ok=True)
except OSError:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to remove a file")
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to remove a file"
)
await file.remove(ctx.database)

View File

@ -1,3 +1,4 @@
import shutil
from fastapi import APIRouter, Depends, HTTPException, status
from materia_server.models import User, Repository, RepositoryInfo
@ -7,39 +8,65 @@ from materia_server.config import Config
router = APIRouter(tags=["repository"])
@router.post("/repository")
async def create(user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def create(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
):
repository_path = Config.data_dir() / "repository" / user.lower_name
if await Repository.by_user_id(user.id, ctx.database):
raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists")
repository = Repository(
user_id = user.id,
capacity = ctx.config.repository.capacity
)
repository = Repository(user_id=user.id, capacity=ctx.config.repository.capacity)
try:
repository_path.mkdir(parents=True, exist_ok=True)
except OSError:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a repository")
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a repository"
)
await repository.create(ctx.database)
@router.get("/repository", response_model=RepositoryInfo)
async def info(user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def info(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
):
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
if not (repository := user.repository):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found")
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
async with ctx.database.session() as session:
session.add(repository)
await session.refresh(repository, attribute_names=["files"])
return RepositoryInfo(
capacity=repository.capacity,
used = sum([ file.size for file in repository.files ])
used=sum([file.size for file in repository.files]),
)
@router.delete("/repository")
async def remove(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
):
repository_path = Config.data_dir() / "repository" / user.lower_name
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
try:
if repository_path.exists():
shutil.rmtree(str(repository_path))
except OSError:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to remove repository"
)
await user.repository.remove(ctx.database)

View File

@ -1,6 +1,6 @@
import uuid
import io
import shutil
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile
import sqlalchemy as sa
@ -14,15 +14,48 @@ from materia_server.routers import middleware
router = APIRouter(tags=["user"])
@router.get("/user", response_model=UserInfo)
async def info(claims = Depends(middleware.jwt_cookie), ctx: middleware.Context = Depends()):
async def info(
claims=Depends(middleware.jwt_cookie), ctx: middleware.Context = Depends()
):
if not (current_user := await User.by_id(uuid.UUID(claims.sub), ctx.database)):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing user")
return UserInfo.model_validate(current_user)
info = UserInfo.model_validate(current_user)
if current_user.is_email_private:
info.email = None
return info
@router.delete("/user")
async def remove(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
):
repository_path = Config.data_dir() / "repository" / user.lower_name
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
try:
if repository_path.exists():
shutil.rmtree(str(repository_path))
except OSError:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to remove user"
)
await user.repository.remove(ctx.database)
@router.post("/user/avatar")
async def avatar(file: UploadFile, user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def avatar(
file: UploadFile,
user: User = Depends(middleware.user),
ctx: middleware.Context = Depends(),
):
async with ctx.database.session() as session:
avatars: list[str] = (await session.scalars(sa.select(User.avatar))).all()
avatars = list(filter(lambda avatar_hash: avatar_hash, avatars))
@ -32,19 +65,25 @@ async def avatar(file: UploadFile, user: User = Depends(middleware.user), ctx: m
try:
img = Image.open(io.BytesIO(await file.read()))
except OSError as _:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Failed to read file data")
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "Failed to read file data"
)
try:
if not (avatars_dir := Config.data_dir() / "avatars").exists():
avatars_dir.mkdir()
img.save(avatars_dir / avatar_id, format=img.format)
except OSError as _:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to save avatar")
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to save avatar"
)
if old_avatar := user.avatar:
if (old_file := Config.data_dir() / "avatars" / old_avatar).exists():
old_file.unlink()
async with ctx.database.session() as session:
await session.execute(sa.update(user.User).where(user.User.id == user.id).values(avatar = avatar_id))
await session.execute(
sa.update(User).where(User.id == user.id).values(avatar=avatar_id)
)
await session.commit()

View File

@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.responses import FileResponse
from PIL import Image
import io
from materia_server.routers import middleware
from materia_server.config import Config
router = APIRouter(tags=["resources"], prefix="/resources")
@router.get("/avatars/{avatar_id}")
async def avatar(
avatar_id: str, format: str = "png", ctx: middleware.Context = Depends()
):
avatar_path = Config.data_dir() / "avatars" / avatar_id
format = format.upper()
if not avatar_path.exists():
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Failed to find the given avatar"
)
try:
img = Image.open(avatar_path)
buffer = io.BytesIO()
if format == "JPEG":
img.convert("RGB")
img.save(buffer, format=format)
except OSError:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "Failed to process image file"
)
return Response(content=buffer.getvalue(), media_type=Image.MIME[format])