materia-server: file api

This commit is contained in:
L-Nafaryus 2024-06-25 22:30:05 +05:00
parent f7bac07837
commit 1877554bb2
Signed by: L-Nafaryus
GPG Key ID: 553C97999B363D38
11 changed files with 185 additions and 6 deletions

View File

@ -5,7 +5,7 @@
groups = ["default", "dev"] groups = ["default", "dev"]
strategy = ["cross_platform", "inherit_metadata"] strategy = ["cross_platform", "inherit_metadata"]
lock_version = "4.4.1" lock_version = "4.4.1"
content_hash = "sha256:d5fe3a4bd117d325c6b8084f3edc08b2f6c67f4ab08566cdf455c4349a06d571" content_hash = "sha256:4d8864659da597f26a1c544eaaba475fa1deb061210a05bf509dd0f6cc5fb11c"
[[package]] [[package]]
name = "aiosmtplib" name = "aiosmtplib"
@ -538,6 +538,20 @@ files = [
{file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"}, {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]] [[package]]
name = "h11" name = "h11"
version = "0.14.0" version = "0.14.0"
@ -928,7 +942,7 @@ name = "packaging"
version = "24.0" version = "24.0"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "Core utilities for Python packages" summary = "Core utilities for Python packages"
groups = ["dev"] groups = ["default", "dev"]
files = [ files = [
{file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"},
{file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"},
@ -1615,6 +1629,21 @@ files = [
{file = "uvicorn-0.30.1.tar.gz", hash = "sha256:d46cd8e0fd80240baffbcd9ec1012a712938754afcf81bce56c024c1656aece8"}, {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]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.30.1" version = "0.30.1"

View File

@ -29,6 +29,8 @@ dependencies = [
"pydanclick<1.0.0,>=0.2.0", "pydanclick<1.0.0,>=0.2.0",
"loguru<1.0.0,>=0.7.2", "loguru<1.0.0,>=0.7.2",
"alembic-postgresql-enum<2.0.0,>=1.2.0", "alembic-postgresql-enum<2.0.0,>=1.2.0",
"gunicorn>=22.0.0",
"uvicorn-worker>=0.2.0",
] ]
requires-python = "<3.12,>=3.10" requires-python = "<3.12,>=3.10"
readme = "README.md" readme = "README.md"

View File

@ -0,0 +1 @@
from materia_server.app.app import AppContext, make_lifespan, make_application

View File

@ -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()))
}

View File

@ -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()

View File

@ -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.directory import Directory, DirectoryLink, DirectoryInfo
from materia_server.models.file import File, FileLink from materia_server.models.file import File, FileLink, FileInfo

View File

@ -1,10 +1,14 @@
from time import time from time import time
from typing import Optional, Self
from pathlib import Path
from sqlalchemy import BigInteger, ForeignKey from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship 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.base import Base
from materia_server.models import database
class File(Base): class File(Base):
__tablename__ = "file" __tablename__ = "file"
@ -23,6 +27,14 @@ class File(Base):
parent: Mapped["Directory"] = relationship(back_populates = "files") parent: Mapped["Directory"] = relationship(back_populates = "files")
link: Mapped["FileLink"] = relationship(back_populates = "file") 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): class FileLink(Base):
__tablename__ = "file_link" __tablename__ = "file_link"
@ -34,6 +46,19 @@ class FileLink(Base):
file: Mapped["File"] = relationship(back_populates = "link") 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.repository import Repository
from materia_server.models.directory import Directory from materia_server.models.directory import Directory

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from materia_server.routers.api.auth import auth, oauth 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 = APIRouter()
router.include_router(auth.router) router.include_router(auth.router)
@ -8,3 +8,4 @@ router.include_router(oauth.router)
router.include_router(user.router) router.include_router(user.router)
router.include_router(repository.router) router.include_router(repository.router)
router.include_router(directory.router) router.include_router(directory.router)
router.include_router(file.router)

View File

@ -4,7 +4,6 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from materia_server.models import User, Directory, DirectoryInfo from materia_server.models import User, Directory, DirectoryInfo
from materia_server.models.directory import DirectoryInfo
from materia_server.routers import middleware from materia_server.routers import middleware
from materia_server.config import Config 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) 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(directory := await Directory.by_path(user.repository.id, None if path.parent == Path() else path.parent, path.name, ctx.database)): 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") raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory is not found")

View File

@ -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