materia-server: file api
This commit is contained in:
parent
f7bac07837
commit
1877554bb2
33
materia-server/pdm.lock
generated
33
materia-server/pdm.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
1
materia-server/src/materia_server/app/__init__.py
Normal file
1
materia-server/src/materia_server/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from materia_server.app.app import AppContext, make_lifespan, make_application
|
12
materia-server/src/materia_server/app/asgi.py
Normal file
12
materia-server/src/materia_server/app/asgi.py
Normal 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()))
|
||||
}
|
29
materia-server/src/materia_server/app/wsgi.py
Normal file
29
materia-server/src/materia_server/app/wsgi.py
Normal 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()
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
||||
|
78
materia-server/src/materia_server/routers/api/file.py
Normal file
78
materia-server/src/materia_server/routers/api/file.py
Normal 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
|
Loading…
Reference in New Issue
Block a user