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"]
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"

View File

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

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.file import File, FileLink
from materia_server.models.file import File, FileLink, FileInfo

View File

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

View File

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

View File

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

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