From aef6c2b5412def7902b95eab6717a40e769bfa4e Mon Sep 17 00:00:00 2001 From: L-Nafaryus Date: Fri, 5 Jul 2024 18:34:14 +0500 Subject: [PATCH] materia-server: base CRD api, resources routes --- materia-server/src/materia_server/app/app.py | 42 ++++--- materia-server/src/materia_server/main.py | 68 +++++++---- .../src/materia_server/models/directory.py | 84 +++++++++----- .../src/materia_server/models/file.py | 70 ++++++++---- .../migrations/versions/86dd738cbd40_.py | 36 ++++++ .../src/materia_server/models/repository.py | 40 +++++-- .../src/materia_server/models/user.py | 61 ++++++---- .../src/materia_server/routers/__init__.py | 2 +- .../materia_server/routers/api/__init__.py | 2 +- .../materia_server/routers/api/directory.py | 108 ++++++++++++++---- .../src/materia_server/routers/api/file.py | 92 ++++++++++----- .../materia_server/routers/api/repository.py | 67 +++++++---- .../src/materia_server/routers/api/user.py | 65 ++++++++--- .../src/materia_server/routers/resources.py | 39 +++++++ 14 files changed, 560 insertions(+), 216 deletions(-) create mode 100644 materia-server/src/materia_server/models/migrations/versions/86dd738cbd40_.py create mode 100644 materia-server/src/materia_server/routers/resources.py diff --git a/materia-server/src/materia_server/app/app.py b/materia-server/src/materia_server/app/app.py index 11bdf28..b3d0e88 100644 --- a/materia-server/src/materia_server/app/app.py +++ b/materia-server/src/materia_server/app/app.py @@ -1,5 +1,5 @@ from contextlib import _AsyncGeneratorContextManager, asynccontextmanager -from os import environ +from os import environ import os from pathlib import Path import pwd @@ -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,10 +33,11 @@ class AppContext(TypedDict): database: Database cache: Cache + def make_lifespan(config: Config, logger: Logger): - @asynccontextmanager + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[AppContext]: - + try: logger.info("Connecting to database {}", config.database.url()) database = await Database.new(config.database.url()) # type: ignore @@ -39,7 +46,7 @@ def make_lifespan(config: Config, logger: Logger): await database.run_migrations() logger.info("Connecting to cache {}", config.cache.url()) - cache = await Cache.new(config.cache.url()) # type: ignore + cache = await Cache.new(config.cache.url()) # type: ignore except DatabaseError as e: logger.error(f"Failed to connect postgres: {e}") sys.exit() @@ -50,32 +57,29 @@ 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) + title="materia", + version="0.1.0", + docs_url="/api/docs", + lifespan=make_lifespan(config, logger), ) app.add_middleware( CORSMiddleware, - allow_origins = [ "http://localhost", "http://localhost:5173" ], - allow_credentials = True, - allow_methods = ["*"], - allow_headers = ["*"], + allow_origins=["http://localhost", "http://localhost:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], ) app.include_router(routers.api.router) + app.include_router(routers.resources.router) return app diff --git a/materia-server/src/materia_server/main.py b/materia-server/src/materia_server/main.py index ba9ab89..4c20664 100644 --- a/materia-server/src/materia_server/main.py +++ b/materia-server/src/materia_server/main.py @@ -1,5 +1,5 @@ from contextlib import _AsyncGeneratorContextManager, asynccontextmanager -from os import environ +from os import environ import os from pathlib import Path import pwd @@ -21,25 +21,29 @@ from materia_server.models import Database, DatabaseError, Cache from materia_server import routers from materia_server.app import make_application -@click.group() + +@click.group() def server(): - pass + pass + @server.command() -@click.option("--config_path", type = Path) -@from_pydantic("application", _config.Application, prefix = "app") -@from_pydantic("log", _config.Log, prefix = "log") +@click.option("--config_path", type=Path) +@from_pydantic("application", _config.Application, prefix="app") +@from_pydantic("log", _config.Log, prefix="log") def start(application: _config.Application, config_path: Path, log: _config.Log): config = Config() config.log = log logger = make_logger(config) - #if user := application.user: + # if user := application.user: # os.setuid(pwd.getpwnam(user).pw_uid) - #if group := application.group: + # 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: @@ -75,10 +79,10 @@ def start(application: _config.Application, config_path: Path, log: _config.Log) else: logger.info("Using the default configuration.") config = Config() - + 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) @@ -91,21 +95,31 @@ def start(application: _config.Application, config_path: Path, log: _config.Log) try: uvicorn.run( make_application(config, logger), - port = config.server.port, - host = str(config.server.address), + port=config.server.port, + host=str(config.server.address), # reload = config.application.mode == "development", - log_config = uvicorn_log_config(config), + log_config=uvicorn_log_config(config), ) except (KeyboardInterrupt, SystemExit): pass + @server.group() def config(): - pass + 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.") + +@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." +) def config_create(path: Path, force: bool): path = path.resolve() config = Config() @@ -117,14 +131,21 @@ def config_create(path: Path, force: bool): if not path.parent.exists(): logger.info("Creating directory at {}", path) - path.mkdir(parents = True) + path.mkdir(parents=True) logger.info("Writing configuration file at {}", path) 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.") + +@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.", +) 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() - - - - diff --git a/materia-server/src/materia_server/models/directory.py b/materia-server/src/materia_server/models/directory.py index 47f9bd6..3872cd0 100644 --- a/materia-server/src/materia_server/models/directory.py +++ b/materia-server/src/materia_server/models/directory.py @@ -14,51 +14,79 @@ from materia_server.models import database 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) - created: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time) - updated: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time) + 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 + ) + created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time) + updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time) name: Mapped[str] - path: Mapped[str] = mapped_column(nullable = True) - is_public: Mapped[bool] = mapped_column(default = False) + path: Mapped[str] = mapped_column(nullable=True) + 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]) - parent: Mapped["Directory"] = relationship(back_populates = "directories") - files: Mapped[List["File"]] = relationship(back_populates = "parent") - link: Mapped["DirectoryLink"] = relationship(back_populates = "directory") + repository: Mapped["Repository"] = relationship(back_populates="directories") + 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")) - created: Mapped[int] = mapped_column(BigInteger, default = time) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + 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") + directory: Mapped["Directory"] = relationship(back_populates="link") + class DirectoryInfo(BaseModel): - model_config = ConfigDict(from_attributes = True) + model_config = ConfigDict(from_attributes=True) - id: int + id: int repository_id: int parent_id: Optional[int] - created: int - updated: int - name: str - path: Optional[str] - is_public: bool + created: int + updated: int + name: str + path: Optional[str] + is_public: bool used: Optional[int] = None diff --git a/materia-server/src/materia_server/models/file.py b/materia-server/src/materia_server/models/file.py index ec64109..18173bf 100644 --- a/materia-server/src/materia_server/models/file.py +++ b/materia-server/src/materia_server/models/file.py @@ -10,30 +10,48 @@ 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) - created: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time) - updated: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time) + 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 + ) + created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time) + updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time) name: Mapped[str] - path: Mapped[str] = mapped_column(nullable = True) - is_public: Mapped[bool] = mapped_column(default = False) + path: Mapped[str] = mapped_column(nullable=True) + is_public: Mapped[bool] = mapped_column(default=False) size: Mapped[int] = mapped_column(BigInteger) - repository: Mapped["Repository"] = relationship(back_populates = "files") - parent: Mapped["Directory"] = relationship(back_populates = "files") - link: Mapped["FileLink"] = relationship(back_populates = "file") + repository: Mapped["Repository"] = relationship(back_populates="files") + parent: Mapped["Directory"] = relationship(back_populates="files") + 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,26 +59,28 @@ class File(Base): await session.execute(sa.delete(File).where(File.id == self.id)) await session.commit() + class FileLink(Base): __tablename__ = "file_link" - id: Mapped[int] = mapped_column(BigInteger, primary_key = True) - file_id: Mapped[int] = mapped_column(ForeignKey("file.id", ondelete = "CASCADE")) - created: Mapped[int] = mapped_column(BigInteger, default = time) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + file_id: Mapped[int] = mapped_column(ForeignKey("file.id", ondelete="CASCADE")) + created: Mapped[int] = mapped_column(BigInteger, default=time) url: Mapped[str] - file: Mapped["File"] = relationship(back_populates = "link") + file: Mapped["File"] = relationship(back_populates="link") + class FileInfo(BaseModel): - model_config = ConfigDict(from_attributes = True) + model_config = ConfigDict(from_attributes=True) - id: int + id: int repository_id: int parent_id: Optional[int] - created: int - updated: int - name: str - path: Optional[str] + created: int + updated: int + name: str + path: Optional[str] is_public: bool size: int diff --git a/materia-server/src/materia_server/models/migrations/versions/86dd738cbd40_.py b/materia-server/src/materia_server/models/migrations/versions/86dd738cbd40_.py new file mode 100644 index 0000000..26aaf8b --- /dev/null +++ b/materia-server/src/materia_server/models/migrations/versions/86dd738cbd40_.py @@ -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 ### diff --git a/materia-server/src/materia_server/models/repository.py b/materia-server/src/materia_server/models/repository.py index 5bb042f..e250e2d 100644 --- a/materia-server/src/materia_server/models/repository.py +++ b/materia-server/src/materia_server/models/repository.py @@ -1,6 +1,6 @@ from time import time from typing import List, Self -from uuid import UUID, uuid4 +from uuid import UUID, uuid4 from sqlalchemy import BigInteger, ForeignKey from sqlalchemy.orm import mapped_column, Mapped, relationship @@ -15,16 +15,20 @@ from materia_server.models import database class Repository(Base): __tablename__ = "repository" - id: Mapped[int] = mapped_column(BigInteger, primary_key = True) - user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id")) - capacity: Mapped[int] = mapped_column(BigInteger, nullable = False) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + 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") - directories: Mapped[List["Directory"]] = relationship(back_populates = "repository") - files: Mapped[List["File"]] = relationship(back_populates = "repository") + user: Mapped["User"] = relationship(back_populates="repository") + directories: Mapped[List["Directory"]] = relationship(back_populates="repository") + 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 + 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 diff --git a/materia-server/src/materia_server/models/user.py b/materia-server/src/materia_server/models/user.py index e679578..e69b721 100644 --- a/materia-server/src/materia_server/models/user.py +++ b/materia-server/src/materia_server/models/user.py @@ -1,4 +1,4 @@ -from uuid import UUID, uuid4 +from uuid import UUID, uuid4 from typing import Optional import time import re @@ -17,38 +17,39 @@ from loguru import logger valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$") invalid_username = re.compile(r"[-._]{2,}|[-._]$") + class User(Base): __tablename__ = "user" - id: Mapped[UUID] = mapped_column(primary_key = True, default = uuid4) - name: Mapped[str] = mapped_column(unique = True) - lower_name: Mapped[str] = mapped_column(unique = True) + id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) + name: Mapped[str] = mapped_column(unique=True) + lower_name: Mapped[str] = mapped_column(unique=True) full_name: Mapped[Optional[str]] email: Mapped[str] - is_email_private: Mapped[bool] = mapped_column(default = True) + is_email_private: Mapped[bool] = mapped_column(default=True) hashed_password: Mapped[str] - must_change_password: Mapped[bool] = mapped_column(default = False) + must_change_password: Mapped[bool] = mapped_column(default=False) login_type: Mapped["LoginType"] - created: Mapped[int] = mapped_column(BigInteger, default = time.time) - updated: Mapped[int] = mapped_column(BigInteger, default = time.time) - last_login: Mapped[int] = mapped_column(BigInteger, nullable = True) - - is_active: Mapped[bool] = mapped_column(default = False) - is_admin: Mapped[bool] = mapped_column(default = False) + created: Mapped[int] = mapped_column(BigInteger, default=time.time) + updated: Mapped[int] = mapped_column(BigInteger, default=time.time) + last_login: Mapped[int] = mapped_column(BigInteger, nullable=True) + + is_active: Mapped[bool] = mapped_column(default=False) + is_admin: Mapped[bool] = mapped_column(default=False) avatar: Mapped[Optional[str]] - repository: Mapped["Repository"] = relationship(back_populates = "user") - + repository: Mapped["Repository"] = relationship(back_populates="user") + def update_last_login(self): self.last_login = int(time.time()) def is_local(self) -> bool: - return self.login_type == LoginType.Plain + return self.login_type == LoginType.Plain - def is_oauth2(self) -> bool: + def is_oauth2(self) -> bool: return self.login_type == LoginType.OAuth2 @staticmethod @@ -63,31 +64,42 @@ 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 + name: str + password: str email: Optional[EmailStr] + class UserInfo(BaseModel): - model_config = ConfigDict(from_attributes = True) + model_config = ConfigDict(from_attributes=True) id: UUID name: str - lower_name: str + lower_name: str full_name: Optional[str] - email: str + email: Optional[str] is_email_private: bool must_change_password: bool @@ -96,10 +108,11 @@ class UserInfo(BaseModel): created: int updated: int last_login: Optional[int] - + is_active: bool is_admin: bool avatar: Optional[str] + from materia_server.models.repository import Repository diff --git a/materia-server/src/materia_server/routers/__init__.py b/materia-server/src/materia_server/routers/__init__.py index 86c56f2..e250277 100644 --- a/materia-server/src/materia_server/routers/__init__.py +++ b/materia-server/src/materia_server/routers/__init__.py @@ -1 +1 @@ -from materia_server.routers import middleware, api +from materia_server.routers import middleware, api, resources diff --git a/materia-server/src/materia_server/routers/api/__init__.py b/materia-server/src/materia_server/routers/api/__init__.py index c8825cf..80af910 100644 --- a/materia-server/src/materia_server/routers/api/__init__.py +++ b/materia-server/src/materia_server/routers/api/__init__.py @@ -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) diff --git a/materia-server/src/materia_server/routers/api/directory.py b/materia-server/src/materia_server/routers/api/directory.py index 72f7c82..4a7b7f6 100644 --- a/materia-server/src/materia_server/routers/api/directory.py +++ b/materia-server/src/materia_server/routers/api/directory.py @@ -1,5 +1,6 @@ import os from pathlib import Path +import shutil from fastapi import APIRouter, Depends, HTTPException, status @@ -8,62 +9,123 @@ from materia_server.routers import middleware from materia_server.config import Config -router = APIRouter(tags = ["directory"]) +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"]) + 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) + 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), ) session.add(directory) - current_directory = directory + current_directory = directory current_path /= part try: - (repository_path / directory_path).mkdir(parents = True, exist_ok = True) + (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"]) + await session.refresh(user, attribute_names=["repository"]) - if not user.repository: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found") + 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 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"]) + await session.refresh(directory, attribute_names=["files"]) - info = DirectoryInfo.model_validate(directory) - info.used = sum([ file.size for file in directory.files ]) + info = DirectoryInfo.model_validate(directory) + info.used = sum([file.size for file in directory.files]) - return info + 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) diff --git a/materia-server/src/materia_server/routers/api/file.py b/materia-server/src/materia_server/routers/api/file.py index 079c94a..ead8bf5 100644 --- a/materia-server/src/materia_server/routers/api/file.py +++ b/materia-server/src/materia_server/routers/api/file.py @@ -8,85 +8,123 @@ from materia_server.routers import middleware from materia_server.config import Config -router = APIRouter(tags = ["file"]) +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"]) + 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 + user.repository.id, + None if directory_path.parent == Path() else directory_path.parent, + directory_path.name, + 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 file = File( - repository_id = user.repository.id, - 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 + repository_id=user.repository.id, + 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, ) 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"]) + await session.refresh(user, attribute_names=["repository"]) 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"]) + await session.refresh(user, attribute_names=["repository"]) 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 @@ -95,8 +133,10 @@ async def remove(path: Path, user: User = Depends(middleware.user), ctx: middlew file_path = repository_path.joinpath(path) if file_path.exists(): - file_path.unlink(missing_ok = True) + 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) diff --git a/materia-server/src/materia_server/routers/api/repository.py b/materia-server/src/materia_server/routers/api/repository.py index b3d0f20..13d3a2c 100644 --- a/materia-server/src/materia_server/routers/api/repository.py +++ b/materia-server/src/materia_server/routers/api/repository.py @@ -1,3 +1,4 @@ +import shutil from fastapi import APIRouter, Depends, HTTPException, status from materia_server.models import User, Repository, RepositoryInfo @@ -5,41 +6,67 @@ from materia_server.routers import middleware from materia_server.config import Config -router = APIRouter(tags = ["repository"]) +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) + 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()): +@router.get("/repository", response_model=RepositoryInfo) +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"]) + await session.refresh(user, attribute_names=["repository"]) - if not (repository := user.repository): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found") + if not (repository := user.repository): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found") - await session.refresh(repository, attribute_names = ["files"]) - - return RepositoryInfo( - capacity = repository.capacity, - used = sum([ file.size for file in repository.files ]) + 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]), + ) + + +@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) diff --git a/materia-server/src/materia_server/routers/api/user.py b/materia-server/src/materia_server/routers/api/user.py index 97315d6..3ecba82 100644 --- a/materia-server/src/materia_server/routers/api/user.py +++ b/materia-server/src/materia_server/routers/api/user.py @@ -1,6 +1,6 @@ - import uuid -import io +import io +import shutil from fastapi import APIRouter, Depends, HTTPException, status, UploadFile import sqlalchemy as sa @@ -12,39 +12,78 @@ from materia_server.models import User, UserInfo from materia_server.routers import middleware -router = APIRouter(tags = ["user"]) +router = APIRouter(tags=["user"]) -@router.get("/user", response_model = UserInfo) -async def info(claims = Depends(middleware.jwt_cookie), ctx: middleware.Context = Depends()): + +@router.get("/user", response_model=UserInfo) +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)) - avatar_id = Sqids(min_length = 10, blocklist = avatars).encode([len(avatars)]) + avatar_id = Sqids(min_length=10, blocklist=avatars).encode([len(avatars)]) try: - img = Image.open(io.BytesIO(await file.read())) + 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) + 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() diff --git a/materia-server/src/materia_server/routers/resources.py b/materia-server/src/materia_server/routers/resources.py new file mode 100644 index 0000000..6617fef --- /dev/null +++ b/materia-server/src/materia_server/routers/resources.py @@ -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])