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

View File

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

View File

@ -14,42 +14,70 @@ from materia_server.models import database
class Directory(Base): class Directory(Base):
__tablename__ = "directory" __tablename__ = "directory"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
repository_id: Mapped[int] = mapped_column(ForeignKey("repository.id", ondelete = "CASCADE")) repository_id: Mapped[int] = mapped_column(
parent_id: Mapped[int] = mapped_column(ForeignKey("directory.id", ondelete = "CASCADE"), nullable = True) ForeignKey("repository.id", ondelete="CASCADE")
created: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time) )
updated: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time) 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] name: Mapped[str]
path: Mapped[str] = mapped_column(nullable = True) path: Mapped[str] = mapped_column(nullable=True)
is_public: Mapped[bool] = mapped_column(default = False) is_public: Mapped[bool] = mapped_column(default=False)
repository: Mapped["Repository"] = relationship(back_populates = "directories") repository: Mapped["Repository"] = relationship(back_populates="directories")
directories: Mapped[List["Directory"]] = relationship(back_populates = "parent", remote_side = [id]) directories: Mapped[List["Directory"]] = relationship(
parent: Mapped["Directory"] = relationship(back_populates = "directories") back_populates="parent", remote_side=[id]
files: Mapped[List["File"]] = relationship(back_populates = "parent") )
link: Mapped["DirectoryLink"] = relationship(back_populates = "directory") parent: Mapped["Directory"] = relationship(back_populates="directories")
files: Mapped[List["File"]] = relationship(back_populates="parent")
link: Mapped["DirectoryLink"] = relationship(back_populates="directory")
@staticmethod @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: async with db.session() as session:
query_path = Directory.path == str(path) if isinstance(path, Path) else Directory.path.is_(None) query_path = (
return (await session Directory.path == str(path)
.scalars(sa.select(Directory) if isinstance(path, Path)
.where(sa.and_(Directory.repository_id == repository_id, Directory.name == name, query_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() ).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): class DirectoryLink(Base):
__tablename__ = "directory_link" __tablename__ = "directory_link"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
directory_id: Mapped[int] = mapped_column(ForeignKey("directory.id", ondelete = "CASCADE")) directory_id: Mapped[int] = mapped_column(
created: Mapped[int] = mapped_column(BigInteger, default = time) ForeignKey("directory.id", ondelete="CASCADE")
)
created: Mapped[int] = mapped_column(BigInteger, default=time)
url: Mapped[str] url: Mapped[str]
directory: Mapped["Directory"] = relationship(back_populates = "link") directory: Mapped["Directory"] = relationship(back_populates="link")
class DirectoryInfo(BaseModel): class DirectoryInfo(BaseModel):
model_config = ConfigDict(from_attributes = True) model_config = ConfigDict(from_attributes=True)
id: int id: int
repository_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.base import Base
from materia_server.models import database from materia_server.models import database
class File(Base): class File(Base):
__tablename__ = "file" __tablename__ = "file"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
repository_id: Mapped[int] = mapped_column(ForeignKey("repository.id", ondelete = "CASCADE")) repository_id: Mapped[int] = mapped_column(
parent_id: Mapped[int] = mapped_column(ForeignKey("directory.id"), nullable = True) ForeignKey("repository.id", ondelete="CASCADE")
created: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time) )
updated: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time) 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] name: Mapped[str]
path: Mapped[str] = mapped_column(nullable = True) path: Mapped[str] = mapped_column(nullable=True)
is_public: Mapped[bool] = mapped_column(default = False) is_public: Mapped[bool] = mapped_column(default=False)
size: Mapped[int] = mapped_column(BigInteger) size: Mapped[int] = mapped_column(BigInteger)
repository: Mapped["Repository"] = relationship(back_populates = "files") repository: Mapped["Repository"] = relationship(back_populates="files")
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 @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: async with db.session() as session:
query_path = File.path == str(path) if isinstance(path, Path) else File.path.is_(None) query_path = (
return (await session File.path == str(path)
.scalars(sa.select(File) if isinstance(path, Path)
.where(sa.and_(File.repository_id == repository_id, File.name == name, query_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() ).first()
async def remove(self, db: database.Database): 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.execute(sa.delete(File).where(File.id == self.id))
await session.commit() await session.commit()
class FileLink(Base): class FileLink(Base):
__tablename__ = "file_link" __tablename__ = "file_link"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
file_id: Mapped[int] = mapped_column(ForeignKey("file.id", ondelete = "CASCADE")) file_id: Mapped[int] = mapped_column(ForeignKey("file.id", ondelete="CASCADE"))
created: Mapped[int] = mapped_column(BigInteger, default = time) created: Mapped[int] = mapped_column(BigInteger, default=time)
url: Mapped[str] url: Mapped[str]
file: Mapped["File"] = relationship(back_populates = "link") file: Mapped["File"] = relationship(back_populates="link")
class FileInfo(BaseModel): class FileInfo(BaseModel):
model_config = ConfigDict(from_attributes = True) model_config = ConfigDict(from_attributes=True)
id: int id: int
repository_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): class Repository(Base):
__tablename__ = "repository" __tablename__ = "repository"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id")) user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))
capacity: Mapped[int] = mapped_column(BigInteger, nullable = False) capacity: Mapped[int] = mapped_column(BigInteger, nullable=False)
user: Mapped["User"] = relationship(back_populates = "repository") user: Mapped["User"] = relationship(back_populates="repository")
directories: Mapped[List["Directory"]] = relationship(back_populates = "repository") directories: Mapped[List["Directory"]] = relationship(back_populates="repository")
files: Mapped[List["File"]] = relationship(back_populates = "repository") files: Mapped[List["File"]] = relationship(back_populates="repository")
def to_dict(self) -> dict: 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 def create(self, db: database.Database):
async with db.session() as session: async with db.session() as session:
@ -33,19 +37,33 @@ class Repository(Base):
async def update(self, db: database.Database): async def update(self, db: database.Database):
async with db.session() as session: 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() await session.commit()
@staticmethod @staticmethod
async def by_user_id(user_id: UUID, db: database.Database) -> Self | None: async def by_user_id(user_id: UUID, db: database.Database) -> Self | None:
async with db.session() as session: 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): class RepositoryInfo(BaseModel):
capacity: int capacity: int
used: int used: int
from materia_server.models.user import User from materia_server.models.user import User
from materia_server.models.directory import Directory from materia_server.models.directory import Directory
from materia_server.models.file import File 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]*$") valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$")
invalid_username = re.compile(r"[-._]{2,}|[-._]$") invalid_username = re.compile(r"[-._]{2,}|[-._]$")
class User(Base): class User(Base):
__tablename__ = "user" __tablename__ = "user"
id: Mapped[UUID] = mapped_column(primary_key = True, default = uuid4) id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(unique = True) name: Mapped[str] = mapped_column(unique=True)
lower_name: Mapped[str] = mapped_column(unique = True) lower_name: Mapped[str] = mapped_column(unique=True)
full_name: Mapped[Optional[str]] full_name: Mapped[Optional[str]]
email: Mapped[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] 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"] login_type: Mapped["LoginType"]
created: Mapped[int] = mapped_column(BigInteger, default = time.time) created: Mapped[int] = mapped_column(BigInteger, default=time.time)
updated: 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) last_login: Mapped[int] = mapped_column(BigInteger, nullable=True)
is_active: Mapped[bool] = mapped_column(default = False) is_active: Mapped[bool] = mapped_column(default=False)
is_admin: Mapped[bool] = mapped_column(default = False) is_admin: Mapped[bool] = mapped_column(default=False)
avatar: Mapped[Optional[str]] avatar: Mapped[Optional[str]]
repository: Mapped["Repository"] = relationship(back_populates = "user") repository: Mapped["Repository"] = relationship(back_populates="user")
def update_last_login(self): def update_last_login(self):
self.last_login = int(time.time()) self.last_login = int(time.time())
@ -63,31 +64,42 @@ class User(Base):
@staticmethod @staticmethod
async def by_name(name: str, db: database.Database): async def by_name(name: str, db: database.Database):
async with db.session() as session: 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 @staticmethod
async def by_email(email: str, db: database.Database): async def by_email(email: str, db: database.Database):
async with db.session() as session: 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 @staticmethod
async def by_id(id: UUID, db: database.Database): async def by_id(id: UUID, db: database.Database):
async with db.session() as session: async with db.session() as session:
return (await session.scalars(sa.select(User).where(User.id == id))).first() 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): class UserCredentials(BaseModel):
name: str name: str
password: str password: str
email: Optional[EmailStr] email: Optional[EmailStr]
class UserInfo(BaseModel): class UserInfo(BaseModel):
model_config = ConfigDict(from_attributes = True) model_config = ConfigDict(from_attributes=True)
id: UUID id: UUID
name: str name: str
lower_name: str lower_name: str
full_name: Optional[str] full_name: Optional[str]
email: str email: Optional[str]
is_email_private: bool is_email_private: bool
must_change_password: bool must_change_password: bool
@ -102,4 +114,5 @@ class UserInfo(BaseModel):
avatar: Optional[str] avatar: Optional[str]
from materia_server.models.repository import Repository 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.auth import auth, oauth
from materia_server.routers.api import user, repository, directory, file from materia_server.routers.api import user, repository, directory, file
router = APIRouter() router = APIRouter(prefix="/api")
router.include_router(auth.router) router.include_router(auth.router)
router.include_router(oauth.router) router.include_router(oauth.router)
router.include_router(user.router) router.include_router(user.router)

View File

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

View File

@ -1,3 +1,4 @@
import shutil
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from materia_server.models import User, Repository, RepositoryInfo from materia_server.models import User, Repository, RepositoryInfo
@ -5,41 +6,67 @@ from materia_server.routers import middleware
from materia_server.config import Config from materia_server.config import Config
router = APIRouter(tags = ["repository"]) router = APIRouter(tags=["repository"])
@router.post("/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 repository_path = Config.data_dir() / "repository" / user.lower_name
if await Repository.by_user_id(user.id, ctx.database): if await Repository.by_user_id(user.id, ctx.database):
raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists") raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists")
repository = Repository( repository = Repository(user_id=user.id, capacity=ctx.config.repository.capacity)
user_id = user.id,
capacity = ctx.config.repository.capacity
)
try: try:
repository_path.mkdir(parents = True, exist_ok = True) repository_path.mkdir(parents=True, exist_ok=True)
except OSError: 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) await repository.create(ctx.database)
@router.get("/repository", response_model = RepositoryInfo) @router.get("/repository", response_model=RepositoryInfo)
async def info(user: User = Depends(middleware.user), ctx: middleware.Context = Depends()): async def info(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
):
async with ctx.database.session() as session: async with ctx.database.session() as session:
session.add(user) session.add(user)
await session.refresh(user, attribute_names = ["repository"]) await session.refresh(user, attribute_names=["repository"])
if not (repository := user.repository): if not (repository := user.repository):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found") 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( return RepositoryInfo(
capacity = repository.capacity, capacity=repository.capacity,
used = sum([ file.size for file in repository.files ]) 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 uuid
import io import io
import shutil
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile from fastapi import APIRouter, Depends, HTTPException, status, UploadFile
import sqlalchemy as sa import sqlalchemy as sa
@ -12,39 +12,78 @@ from materia_server.models import User, UserInfo
from materia_server.routers import middleware 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)): if not (current_user := await User.by_id(uuid.UUID(claims.sub), ctx.database)):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing user") 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") @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: async with ctx.database.session() as session:
avatars: list[str] = (await session.scalars(sa.select(User.avatar))).all() avatars: list[str] = (await session.scalars(sa.select(User.avatar))).all()
avatars = list(filter(lambda avatar_hash: avatar_hash, avatars)) 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: try:
img = Image.open(io.BytesIO(await file.read())) img = Image.open(io.BytesIO(await file.read()))
except OSError as _: 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: try:
if not (avatars_dir := Config.data_dir() / "avatars").exists(): if not (avatars_dir := Config.data_dir() / "avatars").exists():
avatars_dir.mkdir() avatars_dir.mkdir()
img.save(avatars_dir / avatar_id, format = img.format) img.save(avatars_dir / avatar_id, format=img.format)
except OSError as _: 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_avatar := user.avatar:
if (old_file := Config.data_dir() / "avatars" / old_avatar).exists(): if (old_file := Config.data_dir() / "avatars" / old_avatar).exists():
old_file.unlink() old_file.unlink()
async with ctx.database.session() as session: 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() 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])