diff --git a/materia-server/pdm.lock b/materia-server/pdm.lock index 4f36256..a3bede7 100644 --- a/materia-server/pdm.lock +++ b/materia-server/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:d5fe3a4bd117d325c6b8084f3edc08b2f6c67f4ab08566cdf455c4349a06d571" +content_hash = "sha256:4d8864659da597f26a1c544eaaba475fa1deb061210a05bf509dd0f6cc5fb11c" [[package]] name = "aiosmtplib" @@ -538,6 +538,20 @@ files = [ {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"}, ] +[[package]] +name = "gunicorn" +version = "22.0.0" +requires_python = ">=3.7" +summary = "WSGI HTTP Server for UNIX" +groups = ["default"] +dependencies = [ + "packaging", +] +files = [ + {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, + {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, +] + [[package]] name = "h11" version = "0.14.0" @@ -928,7 +942,7 @@ name = "packaging" version = "24.0" requires_python = ">=3.7" summary = "Core utilities for Python packages" -groups = ["dev"] +groups = ["default", "dev"] files = [ {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, @@ -1615,6 +1629,21 @@ files = [ {file = "uvicorn-0.30.1.tar.gz", hash = "sha256:d46cd8e0fd80240baffbcd9ec1012a712938754afcf81bce56c024c1656aece8"}, ] +[[package]] +name = "uvicorn-worker" +version = "0.2.0" +requires_python = ">=3.8" +summary = "Uvicorn worker for Gunicorn! ✨" +groups = ["default"] +dependencies = [ + "gunicorn>=20.1.0", + "uvicorn>=0.14.0", +] +files = [ + {file = "uvicorn_worker-0.2.0-py3-none-any.whl", hash = "sha256:65dcef25ab80a62e0919640f9582216ee05b3bb1dc2f0e58b354ca0511c398fb"}, + {file = "uvicorn_worker-0.2.0.tar.gz", hash = "sha256:f6894544391796be6eeed37d48cae9d7739e5a105f7e37061eccef2eac5a0295"}, +] + [[package]] name = "uvicorn" version = "0.30.1" diff --git a/materia-server/pyproject.toml b/materia-server/pyproject.toml index f5398e4..d191f01 100644 --- a/materia-server/pyproject.toml +++ b/materia-server/pyproject.toml @@ -29,6 +29,8 @@ dependencies = [ "pydanclick<1.0.0,>=0.2.0", "loguru<1.0.0,>=0.7.2", "alembic-postgresql-enum<2.0.0,>=1.2.0", + "gunicorn>=22.0.0", + "uvicorn-worker>=0.2.0", ] requires-python = "<3.12,>=3.10" readme = "README.md" diff --git a/materia-server/src/materia_server/app/__init__.py b/materia-server/src/materia_server/app/__init__.py new file mode 100644 index 0000000..944069f --- /dev/null +++ b/materia-server/src/materia_server/app/__init__.py @@ -0,0 +1 @@ +from materia_server.app.app import AppContext, make_lifespan, make_application diff --git a/materia-server/src/materia_server/app.py b/materia-server/src/materia_server/app/app.py similarity index 100% rename from materia-server/src/materia_server/app.py rename to materia-server/src/materia_server/app/app.py diff --git a/materia-server/src/materia_server/app/asgi.py b/materia-server/src/materia_server/app/asgi.py new file mode 100644 index 0000000..7819376 --- /dev/null +++ b/materia-server/src/materia_server/app/asgi.py @@ -0,0 +1,12 @@ +from os import environ +from pathlib import Path +from uvicorn.workers import UvicornWorker +from materia_server.config import Config +from materia_server._logging import uvicorn_log_config + + +class MateriaWorker(UvicornWorker): + CONFIG_KWARGS = { + "loop": "uvloop", + "log_config": uvicorn_log_config(Config.open(Path(environ["MATERIA_CONFIG"]).resolve())) + } diff --git a/materia-server/src/materia_server/app/wsgi.py b/materia-server/src/materia_server/app/wsgi.py new file mode 100644 index 0000000..ca71085 --- /dev/null +++ b/materia-server/src/materia_server/app/wsgi.py @@ -0,0 +1,29 @@ +from gunicorn.app.wsgiapp import WSGIApplication +import multiprocessing + + +class MateriaProcessManager(WSGIApplication): + def __init__(self, app: str, options: dict | None = None): + self.app_uri = app + self.options = options or {} + super().__init__() + + def load_config(self): + config = { + key: value + for key, value in self.options.items() + if key in self.cfg.settings and value is not None + } + for key, value in config.items(): + self.cfg.set(key.lower(), value) + +def run(): + options = { + "bind": "0.0.0.0:8000", + "workers": (multiprocessing.cpu_count() * 2) + 1, + "worker_class": "materia_server.app.wsgi.MateriaWorker", + "raw_env": ["FOO=1"], + "user": None, + "group": None + } + MateriaProcessManager("materia_server.app.app:run", options).run() diff --git a/materia-server/src/materia_server/models/__init__.py b/materia-server/src/materia_server/models/__init__.py index 15d0397..067843f 100644 --- a/materia-server/src/materia_server/models/__init__.py +++ b/materia-server/src/materia_server/models/__init__.py @@ -9,4 +9,4 @@ from materia_server.models.repository import Repository, RepositoryInfo from materia_server.models.directory import Directory, DirectoryLink, DirectoryInfo -from materia_server.models.file import File, FileLink +from materia_server.models.file import File, FileLink, FileInfo diff --git a/materia-server/src/materia_server/models/file.py b/materia-server/src/materia_server/models/file.py index 1caf20e..ae18987 100644 --- a/materia-server/src/materia_server/models/file.py +++ b/materia-server/src/materia_server/models/file.py @@ -1,10 +1,14 @@ from time import time +from typing import Optional, Self +from pathlib import Path from sqlalchemy import BigInteger, ForeignKey from sqlalchemy.orm import mapped_column, Mapped, relationship +import sqlalchemy as sa +from pydantic import BaseModel, ConfigDict from materia_server.models.base import Base - +from materia_server.models import database class File(Base): __tablename__ = "file" @@ -23,6 +27,14 @@ class File(Base): 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 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))) + ).first() class FileLink(Base): __tablename__ = "file_link" @@ -34,6 +46,19 @@ class FileLink(Base): file: Mapped["File"] = relationship(back_populates = "link") +class FileInfo(BaseModel): + model_config = ConfigDict(from_attributes = True) + + id: int + repository_id: int + parent_id: Optional[int] + created: int + updated: int + name: str + path: Optional[str] + is_public: bool + size: int + from materia_server.models.repository import Repository from materia_server.models.directory import Directory diff --git a/materia-server/src/materia_server/routers/api/__init__.py b/materia-server/src/materia_server/routers/api/__init__.py index 23db42a..c8825cf 100644 --- a/materia-server/src/materia_server/routers/api/__init__.py +++ b/materia-server/src/materia_server/routers/api/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter from materia_server.routers.api.auth import auth, oauth -from materia_server.routers.api import user, repository, directory +from materia_server.routers.api import user, repository, directory, file router = APIRouter() router.include_router(auth.router) @@ -8,3 +8,4 @@ router.include_router(oauth.router) router.include_router(user.router) router.include_router(repository.router) router.include_router(directory.router) +router.include_router(file.router) diff --git a/materia-server/src/materia_server/routers/api/directory.py b/materia-server/src/materia_server/routers/api/directory.py index 3a8cb6d..72f7c82 100644 --- a/materia-server/src/materia_server/routers/api/directory.py +++ b/materia-server/src/materia_server/routers/api/directory.py @@ -4,7 +4,6 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, status from materia_server.models import User, Directory, DirectoryInfo -from materia_server.models.directory import DirectoryInfo from materia_server.routers import middleware from materia_server.config import Config @@ -54,6 +53,9 @@ async def info(path: Path, user: User = Depends(middleware.user), ctx: middlewar 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") + 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") diff --git a/materia-server/src/materia_server/routers/api/file.py b/materia-server/src/materia_server/routers/api/file.py new file mode 100644 index 0000000..bfb4f98 --- /dev/null +++ b/materia-server/src/materia_server/routers/api/file.py @@ -0,0 +1,78 @@ +import os +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile + +from materia_server.models import User, File, FileInfo, Directory +from materia_server.routers import middleware +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()): + if not upload_file.filename: + 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))) + + 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") + + 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 + ) + + if not directory: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory is 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 + ) + + 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") + + file_path.write_bytes(await upload_file.read()) + except OSError: + 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 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") + + 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 is not found") + + info = FileInfo.model_validate(file) + + return info