new: filesystem hierarchy, file upload

This commit is contained in:
L-Nafaryus 2024-05-17 01:16:30 +05:00
parent aa12f90f51
commit e67fcc2216
Signed by: L-Nafaryus
GPG Key ID: 582F8B0866B294A1
15 changed files with 247 additions and 47 deletions

View File

@ -1,9 +1,10 @@
from fastapi import APIRouter from fastapi import APIRouter
from materia.api import user from materia.api import user, filesystem
def routes() -> APIRouter: def routes() -> APIRouter:
router = APIRouter(prefix = "/api") router = APIRouter(prefix = "/api")
router.include_router(user.router) router.include_router(user.router)
router.include_router(filesystem.router)
return router return router

86
materia/api/filesystem.py Normal file
View File

@ -0,0 +1,86 @@
import os
import time
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
from sqlalchemy import and_, insert, select, update
from materia import db
from materia.api.depends import DatabaseState
from materia.api.middleware import JwtMiddleware
from materia.config import Config
router = APIRouter(tags = ["filesystem"])
@router.put("/file/upload", dependencies = [Depends(JwtMiddleware())])
async def upload(request: Request, file: UploadFile, database: DatabaseState = Depends(), directory_path: Path = Path()):
print("hi")
user = request.state.user
repository_path = Config.data_dir() / "repository" / user.login_name.lower()
blacklist = [os.sep, ".", "..", "*"]
directory_path = Path(os.sep.join(filter(lambda part: part not in blacklist, directory_path.parts)))
async with database.session() as session:
session.add(user)
await session.refresh(user, attribute_names = ["repository"])
if not (repository := user.repository):
repository = db.Repository(
owner_id = user.id,
capacity = 10 * 1024 * 1024 * 1024
)
session.add(repository)
try:
repository_path.mkdir(parents = True, exist_ok = True)
except OSError:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a repository")
await session.commit()
async with database.session() as session:
current_directory = None
current_path = Path()
directory = None
for part in directory_path.parts:
if not (directory := (await session.scalars(select(db.Directory).where(and_(db.Directory.name == part, db.Directory.path == str(current_path)))))).first():
directory = db.Directory(
repository_id = repository.id,
parent_id = current_directory.id if current_directory else None,
name = part,
path = str(current_path)
)
session.add(directory)
current_directory = directory
current_path /= part
try:
(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")
await session.commit()
async with database.session() as session:
if file_ := (await session.scalars(select(db.File).where(and_(db.File.name == file.filename, db.File.path == str(directory_path))))).first():
print(file_.__dict__)
await session.execute(update(db.File).where(db.File.id == file_.id).values(updated_unix = time.time(), size = file.size))
else:
file_ = db.File(
repository_id = repository.id,
parent_id = directory.id if directory else None,
name = file.filename,
path = str(directory_path),
size = file.size
)
session.add(file_)
try:
(repository_path / directory_path / file.filename).write_bytes(await file.read())
except OSError:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to write a file")
await session.commit()

View File

@ -6,22 +6,25 @@ from sqlalchemy import select
from pydantic import BaseModel from pydantic import BaseModel
from enum import StrEnum from enum import StrEnum
from http import HTTPMethod as HttpMethod from http import HTTPMethod as HttpMethod
from fastapi.security import HTTPBearer
from materia.api.depends import ConfigState, DatabaseState from materia.api.depends import ConfigState, DatabaseState
from materia.api.token import TokenClaims from materia.api.token import TokenClaims
from materia import db from materia import db
class JwtMiddleware: class JwtMiddleware(HTTPBearer):
def __init__(self): def __init__(self, auto_error: bool = True):
super().__init__(auto_error = auto_error)
self.claims: Optional[TokenClaims] = None self.claims: Optional[TokenClaims] = None
async def __call__(self, request: Request, config: ConfigState = Depends(), database: DatabaseState = Depends()): async def __call__(self, request: Request, config: ConfigState = Depends(), database: DatabaseState = Depends()):
if token := request.cookies.get("token"): if token := request.cookies.get("token"):
pass pass
elif token := request.headers.get("Authorization"): elif (credentials := await super().__call__(request)) and credentials.scheme == "Bearer":
token = token.strip("Bearer ") token = credentials.credentials
else:
if not token:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing token") raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing token")
try: try:

View File

@ -1 +1,2 @@
from materia.api.schema.user import NewUser, User, RemoveUser, LoginUser from materia.api.schema.user import NewUser, User, RemoveUser, LoginUser
from materia.api.schema.token import Token

View File

@ -0,0 +1,10 @@
from typing import Optional, Self
from uuid import UUID
from pydantic import BaseModel
from materia.api.token import TokenClaims
class Token(BaseModel):
access_token: str

View File

@ -24,3 +24,4 @@ class TokenClaims(BaseModel):
data = jwt.decode(token, secret, algorithms = ["HS256"]) data = jwt.decode(token, secret, algorithms = ["HS256"])
return TokenClaims(**data) return TokenClaims(**data)

View File

@ -54,7 +54,7 @@ async def remove(body: schema.RemoveUser, database: DatabaseState = Depends()):
await session.execute(delete(db.User).where(db.User.id == body.id)) await session.execute(delete(db.User).where(db.User.id == body.id))
await session.commit() await session.commit()
@router.post("/user/login", status_code = 200) @router.post("/user/login", status_code = 200, response_model = schema.Token)
async def login(body: schema.LoginUser, response: Response, database: DatabaseState = Depends(), config: ConfigState = Depends()) -> Any: async def login(body: schema.LoginUser, response: Response, database: DatabaseState = Depends(), config: ConfigState = Depends()) -> Any:
query = select(db.User) query = select(db.User)
if login := body.login: if login := body.login:
@ -86,6 +86,8 @@ async def login(body: schema.LoginUser, response: Response, database: DatabaseSt
samesite = "none" samesite = "none"
) )
return schema.Token(access_token = token)
@router.get("/user/logout", status_code = 200) @router.get("/user/logout", status_code = 200)
async def logout(response: Response): async def logout(response: Response):
response.set_cookie( response.set_cookie(

View File

@ -11,7 +11,9 @@ from asyncpg import Connection
from materia.db.base import Base from materia.db.base import Base
from materia.db.user import User from materia.db.user import User
from materia.db.fs_entity import FsEntity from materia.db.repository import Repository
from materia.db.directory import Directory
from materia.db.file import File
from materia.db.link import Link from materia.db.link import Link

29
materia/db/directory.py Normal file
View File

@ -0,0 +1,29 @@
from time import time
from typing import List
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship
from materia.db.base import Base
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"), nullable = True)
created_unix: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time)
updated_unix: 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)
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")
from materia.db.repository import Repository
from materia.db.file import File

27
materia/db/file.py Normal file
View File

@ -0,0 +1,27 @@
from time import time
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship
from materia.db.base import Base
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_unix: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time)
updated_unix: 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)
size: Mapped[int] = mapped_column(BigInteger)
repository: Mapped["Repository"] = relationship(back_populates = "files")
parent: Mapped["Directory"] = relationship(back_populates = "files")
from materia.db.directory import Directory
from materia.db.repository import Repository

View File

@ -1,23 +0,0 @@
from typing import List
from uuid import UUID, uuid4
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship
from materia.db.base import Base
class FsEntity(Base):
__tablename__ = "fs_entity"
id = mapped_column(BigInteger, primary_key = True)
owner_id: Mapped[UUID] = mapped_column(ForeignKey("user.id", ondelete = "CASCADE"), default = uuid4)
parent_id: Mapped[int] = mapped_column(BigInteger, nullable = False)
created_unix: Mapped[int] = mapped_column(BigInteger, nullable = False)
updated_unix: Mapped[int] = mapped_column(BigInteger, nullable = False)
name: Mapped[str]
is_private: Mapped[bool]
is_root: Mapped[bool]
is_file: Mapped[bool]
children: Mapped[List["FsEntity"]] = relationship(back_populates = "parent")
parent: Mapped["FsEntity"] = relationship(back_populates = "children")

View File

@ -8,6 +8,12 @@ class Link(Base):
__tablename__ = "link" __tablename__ = "link"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True) id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
entity_id: Mapped[int] = mapped_column(ForeignKey("fs_entity.id", ondelete = "CASCADE")) directory_id: Mapped[int] = mapped_column(ForeignKey("directory.id", ondelete = "CASCADE"), nullable = True)
file_id: Mapped[int] = mapped_column(ForeignKey("directory.id", ondelete = "CASCADE"), nullable = True)
created_unix: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time.time) created_unix: Mapped[int] = mapped_column(BigInteger, nullable = False, default = time.time)
is_public: Mapped[bool] is_file: Mapped[bool]
url: Mapped[str]
from materia.db.directory import Directory
from materia.db.file import File

View File

@ -1,8 +1,8 @@
"""Initial creation """Initial creation
Revision ID: 047a8383cdf1 Revision ID: aa2ad59fa7d3
Revises: Revises:
Create Date: 2024-05-15 22:16:29.684146 Create Date: 2024-05-17 01:14:50.277521
""" """
from typing import Sequence, Union from typing import Sequence, Union
@ -12,7 +12,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '047a8383cdf1' revision: str = 'aa2ad59fa7d3'
down_revision: Union[str, None] = None down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -33,25 +33,49 @@ def upgrade() -> None:
sa.Column('created_unix', sa.BigInteger(), nullable=False), sa.Column('created_unix', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_table('fs_entity', op.create_table('repository',
sa.Column('id', sa.BigInteger(), nullable=False), sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('owner_id', sa.Uuid(), nullable=False), sa.Column('owner_id', sa.Uuid(), nullable=False),
sa.Column('parent_id', sa.BigInteger(), nullable=False), sa.Column('capacity', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('directory',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('repository_id', sa.BigInteger(), nullable=False),
sa.Column('parent_id', sa.BigInteger(), nullable=True),
sa.Column('created_unix', sa.BigInteger(), nullable=False), sa.Column('created_unix', sa.BigInteger(), nullable=False),
sa.Column('updated_unix', sa.BigInteger(), nullable=False), sa.Column('updated_unix', sa.BigInteger(), nullable=False),
sa.Column('name', sa.String(), nullable=False), sa.Column('name', sa.String(), nullable=False),
sa.Column('is_private', sa.Boolean(), nullable=False), sa.Column('path', sa.String(), nullable=True),
sa.Column('is_root', sa.Boolean(), nullable=False), sa.Column('is_public', sa.Boolean(), nullable=False),
sa.Column('is_file', sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ),
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('file',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('repository_id', sa.BigInteger(), nullable=False),
sa.Column('parent_id', sa.BigInteger(), nullable=True),
sa.Column('created_unix', sa.BigInteger(), nullable=False),
sa.Column('updated_unix', sa.BigInteger(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('path', sa.String(), nullable=True),
sa.Column('is_public', sa.Boolean(), nullable=False),
sa.Column('size', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_table('link', op.create_table('link',
sa.Column('id', sa.BigInteger(), nullable=False), sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('entity_id', sa.BigInteger(), nullable=False), sa.Column('directory_id', sa.BigInteger(), nullable=True),
sa.Column('file_id', sa.BigInteger(), nullable=True),
sa.Column('created_unix', sa.BigInteger(), nullable=False), sa.Column('created_unix', sa.BigInteger(), nullable=False),
sa.Column('is_public', sa.Boolean(), nullable=False), sa.Column('is_file', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['entity_id'], ['fs_entity.id'], ondelete='CASCADE'), sa.Column('url', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['directory_id'], ['directory.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['file_id'], ['directory.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
# ### end Alembic commands ### # ### end Alembic commands ###
@ -60,6 +84,8 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('link') op.drop_table('link')
op.drop_table('fs_entity') op.drop_table('file')
op.drop_table('directory')
op.drop_table('repository')
op.drop_table('user') op.drop_table('user')
# ### end Alembic commands ### # ### end Alembic commands ###

25
materia/db/repository.py Normal file
View File

@ -0,0 +1,25 @@
from time import time
from typing import List
from uuid import UUID, uuid4
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship
from materia.db.base import Base
class Repository(Base):
__tablename__ = "repository"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
owner_id: Mapped[UUID] = mapped_column(ForeignKey("user.id"))
capacity: Mapped[int] = mapped_column(BigInteger, nullable = False)
owner: Mapped["User"] = relationship(back_populates = "repository")
directories: Mapped[List["Directory"]] = relationship(back_populates = "repository")
files: Mapped[List["File"]] = relationship(back_populates = "repository")
from materia.db.user import User
from materia.db.directory import Directory
from materia.db.file import File

View File

@ -3,7 +3,7 @@ from typing import Optional
import time import time
from sqlalchemy import BigInteger from sqlalchemy import BigInteger
from sqlalchemy.orm import mapped_column, Mapped from sqlalchemy.orm import mapped_column, Mapped, relationship
from materia.db.base import Base from materia.db.base import Base
@ -21,3 +21,7 @@ class User(Base):
avatar: Mapped[Optional[str]] avatar: Mapped[Optional[str]]
created_unix: Mapped[int] = mapped_column(BigInteger, default = time.time) created_unix: Mapped[int] = mapped_column(BigInteger, default = time.time)
repository: Mapped["Repository"] = relationship(back_populates = "owner")
from materia.db.repository import Repository