diff --git a/src/materia/models/__init__.py b/src/materia/models/__init__.py index c72d7a7..06a3d88 100644 --- a/src/materia/models/__init__.py +++ b/src/materia/models/__init__.py @@ -25,6 +25,11 @@ from materia.models.repository import ( RepositoryError, ) -from materia.models.directory import Directory, DirectoryLink, DirectoryInfo +from materia.models.directory import ( + Directory, + DirectoryPath, + DirectoryLink, + DirectoryInfo, +) from materia.models.file import File, FileLink, FileInfo diff --git a/src/materia/models/database/database.py b/src/materia/models/database/database.py index 4c75378..2bb902c 100644 --- a/src/materia/models/database/database.py +++ b/src/materia/models/database/database.py @@ -3,7 +3,7 @@ import os from typing import AsyncIterator, Self, TypeAlias from pathlib import Path -from pydantic import BaseModel, PostgresDsn +from pydantic import BaseModel, PostgresDsn, ValidationError from sqlalchemy.ext.asyncio import ( AsyncConnection, AsyncEngine, @@ -102,7 +102,7 @@ class Database: try: yield session - except HTTPException as e: + except (HTTPException, ValidationError) as e: await session.rollback() raise e from None except Exception as e: diff --git a/src/materia/models/directory.py b/src/materia/models/directory.py index 21a510c..85d7d44 100644 --- a/src/materia/models/directory.py +++ b/src/materia/models/directory.py @@ -3,11 +3,12 @@ from typing import List, Optional, Self from pathlib import Path import shutil import aiofiles +import re from sqlalchemy import BigInteger, ForeignKey, inspect from sqlalchemy.orm import mapped_column, Mapped, relationship import sqlalchemy as sa -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, ValidationError from materia.models.base import Base from materia.models import database @@ -202,8 +203,17 @@ class Directory(Base): await session.flush() return self - async def info(self) -> "DirectoryInfo": - return DirectoryInfo.model_validate(self) + async def info(self, session: SessionContext) -> "DirectoryInfo": + info = DirectoryInfo.model_validate(self) + session.add(self) + await session.refresh(self, attribute_names=["files"]) + info.used = sum([file.size for file in self.files]) + + return info + + +class DirectoryPath(BaseModel): + path: Path class DirectoryLink(Base): @@ -228,7 +238,6 @@ class DirectoryInfo(BaseModel): created: int updated: int name: str - path: Optional[str] is_public: bool used: Optional[int] = None diff --git a/src/materia/models/filesystem.py b/src/materia/models/filesystem.py index 909dcce..ebd7243 100644 --- a/src/materia/models/filesystem.py +++ b/src/materia/models/filesystem.py @@ -6,6 +6,8 @@ from aiofiles import ospath as async_path import aioshutil import re +valid_path = re.compile(r"^/(.*/)*([^/]*)$") + class FileSystemError(Exception): pass @@ -184,3 +186,14 @@ class FileSystem: f"Failed to write file to /{self.relative_path}:", *e.args, ) + + @staticmethod + def check_path(path: Path) -> bool: + return bool(valid_path.match(str(path))) + + @staticmethod + def normalize(path: Path) -> Path: + if not path.is_absolute(): + path = Path("/").joinpath(path) + + return Path(*path.resolve().parts[1:]) diff --git a/src/materia/routers/api/directory.py b/src/materia/routers/api/directory.py index 7206e95..239cdf4 100644 --- a/src/materia/routers/api/directory.py +++ b/src/materia/routers/api/directory.py @@ -4,130 +4,111 @@ import shutil from fastapi import APIRouter, Depends, HTTPException, status -from materia.models import User, Directory, DirectoryInfo +from materia.models import User, Directory, DirectoryPath, DirectoryInfo, FileSystem from materia.routers import middleware from materia.config import Config +from pydantic import BaseModel router = APIRouter(tags=["directory"]) @router.post("/directory") async def create( - path: Path = Path(), - user: User = Depends(middleware.user), + path: DirectoryPath, + repository=Depends(middleware.repository), 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)) - ) + if not FileSystem.check_path(path.path): + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path") + + path = FileSystem.normalize(path.path) async with ctx.database.session() as session: - session.add(user) - await session.refresh(user, attribute_names=["repository"]) + current_directory = None + current_path = Path() + directory = None - if not user.repository: - 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 - ): - directory = Directory( - repository_id=user.repository.id, - parent_id=current_directory.id if current_directory else None, - name=part, - ) - - try: - (repository_path / current_path / part).mkdir(exist_ok=True) - except OSError: - raise HTTPException( - status.HTTP_500_INTERNAL_SERVER_ERROR, - f"Failed to create a directory {current_path / part}", + for part in path.parts: + if not ( + directory := await Directory.by_path( + repository, current_path.joinpath(part), session, ctx.config ) + ): + directory = await Directory( + repository_id=repository.id, + parent_id=current_directory.id if current_directory else None, + name=part, + ).new(session, ctx.config) - async with ctx.database.session() as session: - session.add(directory) - await session.commit() - await session.refresh(directory) + current_directory = directory + current_path /= part - current_directory = directory - current_path /= part + await session.commit() @router.get("/directory") async def info( path: Path, - user: User = Depends(middleware.user), + repository=Depends(middleware.repository), ctx: middleware.Context = Depends(), ): + if not FileSystem.check_path(path): + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path") + + path = FileSystem.normalize(path) + ctx.logger.info(path) 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") - - async with ctx.database.session() as session: - session.add(directory) - await session.refresh(directory, attribute_names=["files"]) - - info = DirectoryInfo.model_validate(directory) - info.used = sum([file.size for file in directory.files]) - - return info + if not ( + directory := await Directory.by_path( + repository, + path, + session, + ctx.config, + ) + ): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory not found") + ctx.logger.info(directory) + info = await directory.info(session) + ctx.logger.info(info) + return info @router.delete("/directory") async def remove( path: Path, - user: User = Depends(middleware.user), + repository=Depends(middleware.repository), ctx: middleware.Context = Depends(), ): - repository_path = Config.data_dir() / "repository" / user.lower_name + if not FileSystem.check_path(path): + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path") + + path = FileSystem.normalize(path) async with ctx.database.session() as session: - session.add(user) - await session.refresh(user, attribute_names=["repository"]) + if not ( + directory := await Directory.by_path( + repository, + path, + session, + ctx.config, + ) + ): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory not found") - if not user.repository: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found") + await directory.remove(session, ctx.config) - 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 +@router.patch("/directory/rename") +async def rename(): + pass - 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) +@router.patch("/directory/move") +async def move(): + pass + + +@router.post("/directory/copy") +async def copy(): + pass diff --git a/src/materia/routers/api/repository.py b/src/materia/routers/api/repository.py index c53676b..e7bd3e0 100644 --- a/src/materia/routers/api/repository.py +++ b/src/materia/routers/api/repository.py @@ -48,18 +48,14 @@ async def info( @router.delete("/repository") async def remove( repository=Depends(middleware.repository), - repository_path=Depends(middleware.repository_path), ctx: middleware.Context = Depends(), ): 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 repository.remove(ctx.database) + async with ctx.database.session() as session: + await repository.remove(session, ctx.config) + await session.commit() + except Exception as e: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, f"{e}") @router.get("/repository/content", response_model=RepositoryContent) diff --git a/tests/test_api.py b/tests/test_api.py index 6e46696..929ec66 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -82,5 +82,44 @@ async def test_repository(auth_client: AsyncClient, api_config: Config): create = await auth_client.post("/api/repository") assert create.status_code == 409, create.text + assert api_config.application.working_directory.joinpath( + "repository", "PyTest".lower(), "default" + ).exists() + info = await auth_client.get("/api/repository") assert info.status_code == 200, info.text + + delete = await auth_client.delete("/api/repository") + assert delete.status_code == 200, delete.text + + info = await auth_client.get("/api/repository") + assert info.status_code == 404, info.text + + # TODO: content + + +@pytest.mark.asyncio +async def test_directory(auth_client: AsyncClient, api_config: Config): + create = await auth_client.post("/api/repository") + assert create.status_code == 200, create.text + + create = await auth_client.post("/api/directory", json={"path": "first_dir"}) + assert create.status_code == 500, create.text + + create = await auth_client.post("/api/directory", json={"path": "/first_dir"}) + assert create.status_code == 200, create.text + + assert api_config.application.working_directory.joinpath( + "repository", "PyTest".lower(), "default", "first_dir" + ).exists() + + info = await auth_client.get("/api/directory", params=[("path", "/first_dir")]) + assert info.status_code == 200, info.text + assert info.json()["used"] == 0 + + delete = await auth_client.delete("/api/directory", params=[("path", "/first_dir")]) + assert delete.status_code == 200, delete.text + + assert not api_config.application.working_directory.joinpath( + "repository", "PyTest".lower(), "default", "first_dir" + ).exists()