materia-server: base CRD api, resources routes

This commit is contained in:
L-Nafaryus 2024-07-05 18:34:14 +05:00
parent 4312d5b5d1
commit aef6c2b541
Signed by: L-Nafaryus
GPG Key ID: 553C97999B363D38
14 changed files with 560 additions and 216 deletions

View File

@ -17,7 +17,13 @@ from fastapi.middleware.cors import CORSMiddleware
from materia_server import config as _config
from materia_server.config import Config
from materia_server._logging import make_logger, uvicorn_log_config, Logger
from materia_server.models import Database, DatabaseError, DatabaseMigrationError, Cache, CacheError
from materia_server.models import (
Database,
DatabaseError,
DatabaseMigrationError,
Cache,
CacheError,
)
from materia_server import routers
@ -27,6 +33,7 @@ class AppContext(TypedDict):
database: Database
cache: Cache
def make_lifespan(config: Config, logger: Logger):
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[AppContext]:
@ -39,7 +46,7 @@ def make_lifespan(config: Config, logger: Logger):
await database.run_migrations()
logger.info("Connecting to cache {}", config.cache.url())
cache = await Cache.new(config.cache.url()) # type: ignore
cache = await Cache.new(config.cache.url()) # type: ignore
except DatabaseError as e:
logger.error(f"Failed to connect postgres: {e}")
sys.exit()
@ -50,32 +57,29 @@ def make_lifespan(config: Config, logger: Logger):
logger.error(f"Failed to connect redis: {e}")
sys.exit()
yield AppContext(
config = config,
database = database,
cache = cache,
logger = logger
)
yield AppContext(config=config, database=database, cache=cache, logger=logger)
if database.engine is not None:
await database.dispose()
return lifespan
def make_application(config: Config, logger: Logger):
app = FastAPI(
title = "materia",
version = "0.1.0",
docs_url = "/api/docs",
lifespan = make_lifespan(config, logger)
title="materia",
version="0.1.0",
docs_url="/api/docs",
lifespan=make_lifespan(config, logger),
)
app.add_middleware(
CORSMiddleware,
allow_origins = [ "http://localhost", "http://localhost:5173" ],
allow_credentials = True,
allow_methods = ["*"],
allow_headers = ["*"],
allow_origins=["http://localhost", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(routers.api.router)
app.include_router(routers.resources.router)
return app

View File

@ -21,25 +21,29 @@ from materia_server.models import Database, DatabaseError, Cache
from materia_server import routers
from materia_server.app import make_application
@click.group()
def server():
pass
@server.command()
@click.option("--config_path", type = Path)
@from_pydantic("application", _config.Application, prefix = "app")
@from_pydantic("log", _config.Log, prefix = "log")
@click.option("--config_path", type=Path)
@from_pydantic("application", _config.Application, prefix="app")
@from_pydantic("log", _config.Log, prefix="log")
def start(application: _config.Application, config_path: Path, log: _config.Log):
config = Config()
config.log = log
logger = make_logger(config)
#if user := application.user:
# if user := application.user:
# os.setuid(pwd.getpwnam(user).pw_uid)
#if group := application.group:
# if group := application.group:
# os.setgid(pwd.getpwnam(user).pw_gid)
# TODO: merge cli options with config
if working_directory := (application.working_directory or config.application.working_directory).resolve():
if working_directory := (
application.working_directory or config.application.working_directory
).resolve():
try:
os.chdir(working_directory)
except FileNotFoundError as e:
@ -78,7 +82,7 @@ def start(application: _config.Application, config_path: Path, log: _config.Log)
config.log.level = log.level
logger = make_logger(config)
if (working_directory := config.application.working_directory.resolve()):
if working_directory := config.application.working_directory.resolve():
logger.debug(f"Change working directory: {working_directory}")
try:
os.chdir(working_directory)
@ -91,21 +95,31 @@ def start(application: _config.Application, config_path: Path, log: _config.Log)
try:
uvicorn.run(
make_application(config, logger),
port = config.server.port,
host = str(config.server.address),
port=config.server.port,
host=str(config.server.address),
# reload = config.application.mode == "development",
log_config = uvicorn_log_config(config),
log_config=uvicorn_log_config(config),
)
except (KeyboardInterrupt, SystemExit):
pass
@server.group()
def config():
pass
@config.command("create", help = "Create a new configuration file.")
@click.option("--path", "-p", type = Path, default = Path.cwd().joinpath("config.toml"), help = "Path to the file.")
@click.option("--force", "-f", is_flag = True, default = False, help = "Overwrite a file if exists.")
@config.command("create", help="Create a new configuration file.")
@click.option(
"--path",
"-p",
type=Path,
default=Path.cwd().joinpath("config.toml"),
help="Path to the file.",
)
@click.option(
"--force", "-f", is_flag=True, default=False, help="Overwrite a file if exists."
)
def config_create(path: Path, force: bool):
path = path.resolve()
config = Config()
@ -117,14 +131,21 @@ def config_create(path: Path, force: bool):
if not path.parent.exists():
logger.info("Creating directory at {}", path)
path.mkdir(parents = True)
path.mkdir(parents=True)
logger.info("Writing configuration file at {}", path)
config.write(path)
logger.info("All done.")
@config.command("check", help = "Check the configuration file.")
@click.option("--path", "-p", type = Path, default = Path.cwd().joinpath("config.toml"), help = "Path to the file.")
@config.command("check", help="Check the configuration file.")
@click.option(
"--path",
"-p",
type=Path,
default=Path.cwd().joinpath("config.toml"),
help="Path to the file.",
)
def config_check(path: Path):
path = path.resolve()
config = Config()
@ -141,9 +162,6 @@ def config_check(path: Path):
else:
logger.info("OK.")
if __name__ == "__main__":
server()

View File

@ -14,42 +14,70 @@ from materia_server.models import database
class Directory(Base):
__tablename__ = "directory"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
repository_id: Mapped[int] = mapped_column(ForeignKey("repository.id", ondelete = "CASCADE"))
parent_id: Mapped[int] = mapped_column(ForeignKey("directory.id", ondelete = "CASCADE"), nullable = True)
created: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time)
updated: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
repository_id: Mapped[int] = mapped_column(
ForeignKey("repository.id", ondelete="CASCADE")
)
parent_id: Mapped[int] = mapped_column(
ForeignKey("directory.id", ondelete="CASCADE"), nullable=True
)
created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
name: Mapped[str]
path: Mapped[str] = mapped_column(nullable = True)
is_public: Mapped[bool] = mapped_column(default = False)
path: Mapped[str] = mapped_column(nullable=True)
is_public: Mapped[bool] = mapped_column(default=False)
repository: Mapped["Repository"] = relationship(back_populates = "directories")
directories: Mapped[List["Directory"]] = relationship(back_populates = "parent", remote_side = [id])
parent: Mapped["Directory"] = relationship(back_populates = "directories")
files: Mapped[List["File"]] = relationship(back_populates = "parent")
link: Mapped["DirectoryLink"] = relationship(back_populates = "directory")
repository: Mapped["Repository"] = relationship(back_populates="directories")
directories: Mapped[List["Directory"]] = relationship(
back_populates="parent", remote_side=[id]
)
parent: Mapped["Directory"] = relationship(back_populates="directories")
files: Mapped[List["File"]] = relationship(back_populates="parent")
link: Mapped["DirectoryLink"] = relationship(back_populates="directory")
@staticmethod
async def by_path(repository_id: int, path: Path | None, name: str, db: database.Database) -> Self | None:
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 = Directory.path == str(path) if isinstance(path, Path) else Directory.path.is_(None)
return (await session
.scalars(sa.select(Directory)
.where(sa.and_(Directory.repository_id == repository_id, Directory.name == name, query_path)))
query_path = (
Directory.path == str(path)
if isinstance(path, Path)
else Directory.path.is_(None)
)
return (
await session.scalars(
sa.select(Directory).where(
sa.and_(
Directory.repository_id == repository_id,
Directory.name == name,
query_path,
)
)
)
).first()
async def remove(self, db: database.Database):
async with db.session() as session:
await session.execute(sa.delete(Directory).where(Directory.id == self.id))
await session.commit()
class DirectoryLink(Base):
__tablename__ = "directory_link"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
directory_id: Mapped[int] = mapped_column(ForeignKey("directory.id", ondelete = "CASCADE"))
created: Mapped[int] = mapped_column(BigInteger, default = time)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
directory_id: Mapped[int] = mapped_column(
ForeignKey("directory.id", ondelete="CASCADE")
)
created: Mapped[int] = mapped_column(BigInteger, default=time)
url: Mapped[str]
directory: Mapped["Directory"] = relationship(back_populates = "link")
directory: Mapped["Directory"] = relationship(back_populates="link")
class DirectoryInfo(BaseModel):
model_config = ConfigDict(from_attributes = True)
model_config = ConfigDict(from_attributes=True)
id: int
repository_id: int

View File

@ -10,30 +10,48 @@ from pydantic import BaseModel, ConfigDict
from materia_server.models.base import Base
from materia_server.models import database
class File(Base):
__tablename__ = "file"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
repository_id: Mapped[int] = mapped_column(ForeignKey("repository.id", ondelete = "CASCADE"))
parent_id: Mapped[int] = mapped_column(ForeignKey("directory.id"), nullable = True)
created: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time)
updated: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
repository_id: Mapped[int] = mapped_column(
ForeignKey("repository.id", ondelete="CASCADE")
)
parent_id: Mapped[int] = mapped_column(
ForeignKey("directory.id", ondelete="CASCADE"), nullable=True
)
created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
name: Mapped[str]
path: Mapped[str] = mapped_column(nullable = True)
is_public: Mapped[bool] = mapped_column(default = False)
path: Mapped[str] = mapped_column(nullable=True)
is_public: Mapped[bool] = mapped_column(default=False)
size: Mapped[int] = mapped_column(BigInteger)
repository: Mapped["Repository"] = relationship(back_populates = "files")
parent: Mapped["Directory"] = relationship(back_populates = "files")
link: Mapped["FileLink"] = relationship(back_populates = "file")
repository: Mapped["Repository"] = relationship(back_populates="files")
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 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)))
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()
async def remove(self, db: database.Database):
@ -41,18 +59,20 @@ class File(Base):
await session.execute(sa.delete(File).where(File.id == self.id))
await session.commit()
class FileLink(Base):
__tablename__ = "file_link"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
file_id: Mapped[int] = mapped_column(ForeignKey("file.id", ondelete = "CASCADE"))
created: Mapped[int] = mapped_column(BigInteger, default = time)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
file_id: Mapped[int] = mapped_column(ForeignKey("file.id", ondelete="CASCADE"))
created: Mapped[int] = mapped_column(BigInteger, default=time)
url: Mapped[str]
file: Mapped["File"] = relationship(back_populates = "link")
file: Mapped["File"] = relationship(back_populates="link")
class FileInfo(BaseModel):
model_config = ConfigDict(from_attributes = True)
model_config = ConfigDict(from_attributes=True)
id: int
repository_id: int

View File

@ -0,0 +1,36 @@
"""empty message
Revision ID: 86dd738cbd40
Revises: 939b37d98be0
Create Date: 2024-07-05 16:42:31.645410
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '86dd738cbd40'
down_revision: Union[str, None] = '939b37d98be0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('file_parent_id_fkey', 'file', type_='foreignkey')
op.create_foreign_key(None, 'file', 'directory', ['parent_id'], ['id'], ondelete='CASCADE')
op.drop_constraint('repository_user_id_fkey', 'repository', type_='foreignkey')
op.create_foreign_key(None, 'repository', 'user', ['user_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'repository', type_='foreignkey')
op.create_foreign_key('repository_user_id_fkey', 'repository', 'user', ['user_id'], ['id'])
op.drop_constraint(None, 'file', type_='foreignkey')
op.create_foreign_key('file_parent_id_fkey', 'file', 'directory', ['parent_id'], ['id'])
# ### end Alembic commands ###

View File

@ -15,16 +15,20 @@ from materia_server.models import database
class Repository(Base):
__tablename__ = "repository"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id"))
capacity: Mapped[int] = mapped_column(BigInteger, nullable = False)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))
capacity: Mapped[int] = mapped_column(BigInteger, nullable=False)
user: Mapped["User"] = relationship(back_populates = "repository")
directories: Mapped[List["Directory"]] = relationship(back_populates = "repository")
files: Mapped[List["File"]] = relationship(back_populates = "repository")
user: Mapped["User"] = relationship(back_populates="repository")
directories: Mapped[List["Directory"]] = relationship(back_populates="repository")
files: Mapped[List["File"]] = relationship(back_populates="repository")
def to_dict(self) -> dict:
return { k: getattr(self, k) for k, v in Repository.__dict__.items() if isinstance(v, InstrumentedAttribute) }
return {
k: getattr(self, k)
for k, v in Repository.__dict__.items()
if isinstance(v, InstrumentedAttribute)
}
async def create(self, db: database.Database):
async with db.session() as session:
@ -33,19 +37,33 @@ class Repository(Base):
async def update(self, db: database.Database):
async with db.session() as session:
await session.execute(sa.update(Repository).where(Repository.id == self.id).values(self.to_dict()))
await session.execute(
sa.update(Repository)
.where(Repository.id == self.id)
.values(self.to_dict())
)
await session.commit()
@staticmethod
async def by_user_id(user_id: UUID, db: database.Database) -> Self | None:
async with db.session() as session:
return (await session.scalars(sa.select(Repository).where(Repository.user_id == user_id))).first()
return (
await session.scalars(
sa.select(Repository).where(Repository.user_id == user_id)
)
).first()
async def remove(self, db: database.Database):
async with db.session() as session:
await session.execute(sa.delete(Repository).where(Repository.id == self.id))
await session.commit()
class RepositoryInfo(BaseModel):
capacity: int
used: int
from materia_server.models.user import User
from materia_server.models.directory import Directory
from materia_server.models.file import File

View File

@ -17,30 +17,31 @@ from loguru import logger
valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$")
invalid_username = re.compile(r"[-._]{2,}|[-._]$")
class User(Base):
__tablename__ = "user"
id: Mapped[UUID] = mapped_column(primary_key = True, default = uuid4)
name: Mapped[str] = mapped_column(unique = True)
lower_name: Mapped[str] = mapped_column(unique = True)
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(unique=True)
lower_name: Mapped[str] = mapped_column(unique=True)
full_name: Mapped[Optional[str]]
email: Mapped[str]
is_email_private: Mapped[bool] = mapped_column(default = True)
is_email_private: Mapped[bool] = mapped_column(default=True)
hashed_password: Mapped[str]
must_change_password: Mapped[bool] = mapped_column(default = False)
must_change_password: Mapped[bool] = mapped_column(default=False)
login_type: Mapped["LoginType"]
created: Mapped[int] = mapped_column(BigInteger, default = time.time)
updated: Mapped[int] = mapped_column(BigInteger, default = time.time)
last_login: Mapped[int] = mapped_column(BigInteger, nullable = True)
created: Mapped[int] = mapped_column(BigInteger, default=time.time)
updated: Mapped[int] = mapped_column(BigInteger, default=time.time)
last_login: Mapped[int] = mapped_column(BigInteger, nullable=True)
is_active: Mapped[bool] = mapped_column(default = False)
is_admin: Mapped[bool] = mapped_column(default = False)
is_active: Mapped[bool] = mapped_column(default=False)
is_admin: Mapped[bool] = mapped_column(default=False)
avatar: Mapped[Optional[str]]
repository: Mapped["Repository"] = relationship(back_populates = "user")
repository: Mapped["Repository"] = relationship(back_populates="user")
def update_last_login(self):
self.last_login = int(time.time())
@ -63,31 +64,42 @@ class User(Base):
@staticmethod
async def by_name(name: str, db: database.Database):
async with db.session() as session:
return (await session.scalars(sa.select(User).where(User.name == name))).first()
return (
await session.scalars(sa.select(User).where(User.name == name))
).first()
@staticmethod
async def by_email(email: str, db: database.Database):
async with db.session() as session:
return (await session.scalars(sa.select(User).where(User.email == email))).first()
return (
await session.scalars(sa.select(User).where(User.email == email))
).first()
@staticmethod
async def by_id(id: UUID, db: database.Database):
async with db.session() as session:
return (await session.scalars(sa.select(User).where(User.id == id))).first()
async def remove(self, db: database.Database):
async with db.session() as session:
await session.execute(sa.delete(User).where(User.id == self.id))
await session.commit()
class UserCredentials(BaseModel):
name: str
password: str
email: Optional[EmailStr]
class UserInfo(BaseModel):
model_config = ConfigDict(from_attributes = True)
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
lower_name: str
full_name: Optional[str]
email: str
email: Optional[str]
is_email_private: bool
must_change_password: bool
@ -102,4 +114,5 @@ class UserInfo(BaseModel):
avatar: Optional[str]
from materia_server.models.repository import Repository

View File

@ -1 +1 @@
from materia_server.routers import middleware, api
from materia_server.routers import middleware, api, resources

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter
from materia_server.routers.api.auth import auth, oauth
from materia_server.routers.api import user, repository, directory, file
router = APIRouter()
router = APIRouter(prefix="/api")
router.include_router(auth.router)
router.include_router(oauth.router)
router.include_router(user.router)

View File

@ -1,5 +1,6 @@
import os
from pathlib import Path
import shutil
from fastapi import APIRouter, Depends, HTTPException, status
@ -8,32 +9,41 @@ from materia_server.routers import middleware
from materia_server.config import Config
router = APIRouter(tags = ["directory"])
router = APIRouter(tags=["directory"])
@router.post("/directory")
async def create(path: Path = Path(), user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def create(
path: Path = Path(),
user: User = Depends(middleware.user),
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)))
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"])
await session.refresh(user, attribute_names=["repository"])
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found")
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):
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,
path = None if current_path == Path() else str(current_path)
repository_id=user.repository.id,
parent_id=current_directory.id if current_directory else None,
name=part,
path=None if current_path == Path() else str(current_path),
)
session.add(directory)
@ -41,29 +51,81 @@ async def create(path: Path = Path(), user: User = Depends(middleware.user), ctx
current_path /= part
try:
(repository_path / directory_path).mkdir(parents = True, exist_ok = True)
(repository_path / directory_path).mkdir(parents=True, exist_ok=True)
except OSError:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a directory")
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a directory"
)
await session.commit()
@router.get("/directory")
async def info(path: Path, user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
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"])
await session.refresh(user, attribute_names=["repository"])
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found")
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 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 not found")
async with ctx.database.session() as session:
session.add(directory)
await session.refresh(directory, attribute_names = ["files"])
await session.refresh(directory, attribute_names=["files"])
info = DirectoryInfo.model_validate(directory)
info.used = sum([ file.size for file in directory.files ])
info = DirectoryInfo.model_validate(directory)
info.used = sum([file.size for file in directory.files])
return info
return info
@router.delete("/directory")
async def remove(
path: Path,
user: User = Depends(middleware.user),
ctx: middleware.Context = Depends(),
):
repository_path = Config.data_dir() / "repository" / user.lower_name
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")
directory_path = repository_path / path
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)

View File

@ -8,85 +8,123 @@ from materia_server.routers import middleware
from materia_server.config import Config
router = APIRouter(tags = ["file"])
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()):
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")
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)))
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"])
await session.refresh(user, attribute_names=["repository"])
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found")
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository 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
ctx.database,
)
if not directory:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory is not found")
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory 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
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")
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")
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 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"])
await session.refresh(user, attribute_names=["repository"])
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
if not(file := await File.by_path(user.repository.id, None if path.parent == Path() else path.parent, path.name, ctx.database)):
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 not found")
info = FileInfo.model_validate(file)
return info
@router.delete("/file")
async def remove(path: Path, user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def remove(
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"])
await session.refresh(user, attribute_names=["repository"])
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
if not(file := await File.by_path(user.repository.id, None if path.parent == Path() else path.parent, path.name, ctx.database)):
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 not found")
repository_path = Config.data_dir() / "repository" / user.lower_name
@ -95,8 +133,10 @@ async def remove(path: Path, user: User = Depends(middleware.user), ctx: middlew
file_path = repository_path.joinpath(path)
if file_path.exists():
file_path.unlink(missing_ok = True)
file_path.unlink(missing_ok=True)
except OSError:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to remove a file")
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to remove a file"
)
await file.remove(ctx.database)

View File

@ -1,3 +1,4 @@
import shutil
from fastapi import APIRouter, Depends, HTTPException, status
from materia_server.models import User, Repository, RepositoryInfo
@ -5,41 +6,67 @@ from materia_server.routers import middleware
from materia_server.config import Config
router = APIRouter(tags = ["repository"])
router = APIRouter(tags=["repository"])
@router.post("/repository")
async def create(user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def create(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
):
repository_path = Config.data_dir() / "repository" / user.lower_name
if await Repository.by_user_id(user.id, ctx.database):
raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists")
repository = Repository(
user_id = user.id,
capacity = ctx.config.repository.capacity
)
repository = Repository(user_id=user.id, capacity=ctx.config.repository.capacity)
try:
repository_path.mkdir(parents = True, exist_ok = True)
repository_path.mkdir(parents=True, exist_ok=True)
except OSError:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a repository")
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a repository"
)
await repository.create(ctx.database)
@router.get("/repository", response_model = RepositoryInfo)
async def info(user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
@router.get("/repository", response_model=RepositoryInfo)
async def info(
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"])
await session.refresh(user, attribute_names=["repository"])
if not (repository := user.repository):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found")
if not (repository := user.repository):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
await session.refresh(repository, attribute_names = ["files"])
async with ctx.database.session() as session:
session.add(repository)
await session.refresh(repository, attribute_names=["files"])
return RepositoryInfo(
capacity = repository.capacity,
used = sum([ file.size for file in repository.files ])
return RepositoryInfo(
capacity=repository.capacity,
used=sum([file.size for file in repository.files]),
)
@router.delete("/repository")
async def remove(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
):
repository_path = Config.data_dir() / "repository" / user.lower_name
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
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 user.repository.remove(ctx.database)

View File

@ -1,6 +1,6 @@
import uuid
import io
import shutil
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile
import sqlalchemy as sa
@ -12,39 +12,78 @@ from materia_server.models import User, UserInfo
from materia_server.routers import middleware
router = APIRouter(tags = ["user"])
router = APIRouter(tags=["user"])
@router.get("/user", response_model = UserInfo)
async def info(claims = Depends(middleware.jwt_cookie), ctx: middleware.Context = Depends()):
@router.get("/user", response_model=UserInfo)
async def info(
claims=Depends(middleware.jwt_cookie), ctx: middleware.Context = Depends()
):
if not (current_user := await User.by_id(uuid.UUID(claims.sub), ctx.database)):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing user")
return UserInfo.model_validate(current_user)
info = UserInfo.model_validate(current_user)
if current_user.is_email_private:
info.email = None
return info
@router.delete("/user")
async def remove(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
):
repository_path = Config.data_dir() / "repository" / user.lower_name
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
try:
if repository_path.exists():
shutil.rmtree(str(repository_path))
except OSError:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to remove user"
)
await user.repository.remove(ctx.database)
@router.post("/user/avatar")
async def avatar(file: UploadFile, user: User = Depends(middleware.user), ctx: middleware.Context = Depends()):
async def avatar(
file: UploadFile,
user: User = Depends(middleware.user),
ctx: middleware.Context = Depends(),
):
async with ctx.database.session() as session:
avatars: list[str] = (await session.scalars(sa.select(User.avatar))).all()
avatars = list(filter(lambda avatar_hash: avatar_hash, avatars))
avatar_id = Sqids(min_length = 10, blocklist = avatars).encode([len(avatars)])
avatar_id = Sqids(min_length=10, blocklist=avatars).encode([len(avatars)])
try:
img = Image.open(io.BytesIO(await file.read()))
except OSError as _:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Failed to read file data")
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "Failed to read file data"
)
try:
if not (avatars_dir := Config.data_dir() / "avatars").exists():
avatars_dir.mkdir()
img.save(avatars_dir / avatar_id, format = img.format)
img.save(avatars_dir / avatar_id, format=img.format)
except OSError as _:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to save avatar")
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to save avatar"
)
if old_avatar := user.avatar:
if (old_file := Config.data_dir() / "avatars" / old_avatar).exists():
old_file.unlink()
async with ctx.database.session() as session:
await session.execute(sa.update(user.User).where(user.User.id == user.id).values(avatar = avatar_id))
await session.execute(
sa.update(User).where(User.id == user.id).values(avatar=avatar_id)
)
await session.commit()

View File

@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.responses import FileResponse
from PIL import Image
import io
from materia_server.routers import middleware
from materia_server.config import Config
router = APIRouter(tags=["resources"], prefix="/resources")
@router.get("/avatars/{avatar_id}")
async def avatar(
avatar_id: str, format: str = "png", ctx: middleware.Context = Depends()
):
avatar_path = Config.data_dir() / "avatars" / avatar_id
format = format.upper()
if not avatar_path.exists():
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Failed to find the given avatar"
)
try:
img = Image.open(avatar_path)
buffer = io.BytesIO()
if format == "JPEG":
img.convert("RGB")
img.save(buffer, format=format)
except OSError:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "Failed to process image file"
)
return Response(content=buffer.getvalue(), media_type=Image.MIME[format])