Compare commits

..

No commits in common. "69a1aa247192d52fa044159fd181e270e681e63f" and "6ad7c29a4851754e86a9ddf15acac69ba0b8528e" have entirely different histories.

22 changed files with 1614 additions and 1012 deletions

View File

@ -16,37 +16,6 @@ alembic upgrade head
# Rollback the migration
alembic downgrade head
```
## Setup tests
```sh
nix build .#postgresql-devel
podman load < result
podman run -p 54320:5432 --name database -dt postgresql:latest
nix build .#redis-devel
podman load < result
podman run -p 63790:63790 --name cache -dt redis:latest
nix develop
pdm install --dev
eval $(pdm venv activate)
pytest
```
## Side notes
```
/var
/lib
/materia <-- data directory
/repository <-- repository directory
/rick <-- user name
/default <--| default repository name
... | possible features: external cloud drives?
/first <-- first level directories counts as root because no parent
/nested
/hello.txt
```
# License
**materia** is licensed under the [MIT License](LICENSE).

File diff suppressed because it is too large Load Diff

View File

@ -51,7 +51,7 @@
...
}: {
name = "materia-frontend";
version = "0.0.5";
version = "0.0.1";
imports = [
dream2nix.modules.dream2nix.WIP-nodejs-builder-v3
@ -94,20 +94,20 @@
}: {
imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
pdm.lockfile = ./workspaces/frontend/pdm.lock;
pdm.pyproject = ./workspaces/frontend/pyproject.toml;
pdm.lockfile = ./materia-web-client/pdm.lock;
pdm.pyproject = ./materia-web-client/pyproject.toml;
deps = _: {
python = pkgs.python312;
python = pkgs.python3;
};
mkDerivation = {
src = ./workspaces/frontend;
buildInputs = [
pkgs.python312.pkgs.pdm-backend
pkgs.python3.pkgs.pdm-backend
];
configurePhase = ''
cp -rv ${materia-frontend-nodejs}/dist ./src/materia_frontend/
cp -rv ${materia-frontend-nodejs}/dist ./src/materia-frontend/
'';
};
};
@ -119,10 +119,7 @@
};
};
materia = dreamBuildPackage {
extraArgs = {
inherit (self.packages.x86_64-linux) materia-frontend;
};
materia-server = dreamBuildPackage {
module = {
config,
lib,
@ -132,23 +129,20 @@
}: {
imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
pdm.lockfile = ./pdm.lock;
pdm.pyproject = ./pyproject.toml;
pdm.lockfile = ./materia-server/pdm.lock;
pdm.pyproject = ./materia-server/pyproject.toml;
deps = _: {
python = pkgs.python312;
python = pkgs.python3;
};
mkDerivation = {
src = ./.;
src = ./materia-server;
buildInputs = [
pkgs.python312.pkgs.pdm-backend
pkgs.python3.pkgs.pdm-backend
];
nativeBuildInputs = [
pkgs.python312.pkgs.wrapPython
];
propagatedBuildInputs = [
materia-frontend
pkgs.python3.pkgs.wrapPython
];
};
};
@ -157,7 +151,7 @@
license = licenses.mit;
maintainers = with bonLib.maintainers; [L-Nafaryus];
broken = false;
mainProgram = "materia";
mainProgram = "materia-server";
};
};
@ -166,8 +160,15 @@
redis-devel = bonfire.packages.x86_64-linux.redis;
};
apps.x86_64-linux = {
materia-server = {
type = "app";
program = "${self.packages.x86_64-linux.materia-server}/bin/materia-server";
};
};
devShells.x86_64-linux.default = pkgs.mkShell {
buildInputs = with pkgs; [postgresql redis pdm nodejs python312];
buildInputs = with pkgs; [postgresql redis pdm nodejs];
# greenlet requires libstdc++
LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [pkgs.stdenv.cc.cc];
};

807
pdm.lock

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,7 @@ dependencies = [
"sqids<1.0.0,>=0.4.1",
"alembic<2.0.0,>=1.13.1",
"authlib<2.0.0,>=1.3.0",
"cryptography<43.0.0,>=42.0.7",
"redis[hiredis]<6.0.0,>=5.0.4",
"aiosmtplib<4.0.0,>=3.0.1",
"emails<1.0,>=0.6",
@ -30,12 +31,9 @@ dependencies = [
"alembic-postgresql-enum<2.0.0,>=1.2.0",
"gunicorn>=22.0.0",
"uvicorn-worker>=0.2.0",
"httpx>=0.27.0",
"cryptography>=43.0.0",
"python-multipart>=0.0.9",
"jinja2>=3.1.4",
"httpx>=0.27.0"
]
requires-python = ">=3.12,<3.13"
requires-python = "<3.12,>=3.10"
readme = "README.md"
license = {text = "MIT"}
@ -71,9 +69,10 @@ includes = ["src/materia"]
[tool.pdm.scripts]
start.cmd = "python ./src/materia/main.py {args:start --app-mode development --log-level debug}"
setup.cmd = "psql -U postgres -h 127.0.0.1 -p 54320 -d postgres -c 'create role materia login;' -c 'create database materia owner materia;'"
teardown.cmd = "psql -U postgres -h 127.0.0.1 -p 54320 -d postgres -c 'drop database materia;' -c 'drop role materia;'"
upgrade.cmd = "alembic -c ./src/materia/alembic.ini upgrade {args:head}"
downgrade.shell = "alembic -c ./src/materia/alembic.ini downgrade {args:base}"
rev.cmd = "alembic revision {args:--autogenerate}"
upgrade.cmd = "alembic upgrade {args:head}"
downgrade.cmd = "alembic downgrade {args:base}"
remove-revs.shell = "rm -v ./src/materia/models/migrations/versions/*.py"
rm-revs.shell = "rm -v ./src/materia/models/migrations/versions/*.py"

View File

@ -66,13 +66,6 @@ def make_lifespan(config: Config, logger: Logger):
def make_application(config: Config, logger: Logger):
try:
import materia_frontend
except ModuleNotFoundError:
logger.warning(
"`materia_frontend` is not installed. No user interface will be served."
)
app = FastAPI(
title="materia",
version="0.1.0",

View File

@ -20,7 +20,6 @@ from materia.models.repository import (
Repository,
RepositoryInfo,
RepositoryContent,
RepositoryError,
)
from materia.models.directory import Directory, DirectoryLink, DirectoryInfo

View File

@ -1,9 +1,2 @@
from materia.models.database.database import (
DatabaseError,
DatabaseMigrationError,
Database,
SessionMaker,
SessionContext,
ConnectionContext,
)
from materia.models.database.database import DatabaseError, DatabaseMigrationError, Database
from materia.models.database.cache import Cache, CacheError

View File

@ -1,6 +1,6 @@
from contextlib import asynccontextmanager
import os
from typing import AsyncIterator, Self, TypeAlias
from typing import AsyncIterator, Self
from pathlib import Path
from pydantic import BaseModel, PostgresDsn
@ -17,8 +17,6 @@ from alembic.config import Config as AlembicConfig
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from alembic.script.base import ScriptDirectory
import alembic_postgresql_enum
from fastapi import HTTPException
from materia.config import Config
from materia.models.base import Base
@ -34,21 +32,16 @@ class DatabaseMigrationError(Exception):
pass
SessionContext: TypeAlias = AsyncIterator[AsyncSession]
SessionMaker: TypeAlias = async_sessionmaker[AsyncSession]
ConnectionContext: TypeAlias = AsyncIterator[AsyncConnection]
class Database:
def __init__(
self,
url: PostgresDsn,
engine: AsyncEngine,
sessionmaker: SessionMaker,
sessionmaker: async_sessionmaker[AsyncSession],
):
self.url: PostgresDsn = url
self.engine: AsyncEngine = engine
self.sessionmaker: SessionMaker = sessionmaker
self.sessionmaker: async_sessionmaker[AsyncSession] = sessionmaker
@staticmethod
async def new(
@ -88,7 +81,7 @@ class Database:
await self.engine.dispose()
@asynccontextmanager
async def connection(self) -> ConnectionContext:
async def connection(self) -> AsyncIterator[AsyncConnection]:
async with self.engine.connect() as connection:
try:
yield connection
@ -97,7 +90,7 @@ class Database:
raise DatabaseError(f"{e}")
@asynccontextmanager
async def session(self) -> SessionContext:
async def session(self) -> AsyncIterator[AsyncSession]:
session = self.sessionmaker()
try:
@ -105,9 +98,6 @@ class Database:
except Exception as e:
await session.rollback()
raise DatabaseError(f"{e}")
except HTTPException:
# if the initial exception reaches HTTPException, then everything is handled fine (should be)
await session.rollback()
finally:
await session.close()

View File

@ -1,7 +1,6 @@
from time import time
from typing import List, Optional, Self
from pathlib import Path
import shutil
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship
@ -10,11 +9,6 @@ from pydantic import BaseModel, ConfigDict
from materia.models.base import Base
from materia.models import database
from materia.models.database import SessionContext
class DirectoryError(Exception):
pass
class Directory(Base):
@ -30,6 +24,7 @@ class Directory(Base):
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)
repository: Mapped["Repository"] = relationship(back_populates="directories")
@ -40,198 +35,32 @@ class Directory(Base):
files: Mapped[List["File"]] = relationship(back_populates="parent")
link: Mapped["DirectoryLink"] = relationship(back_populates="directory")
async def new(
self,
session: SessionContext,
path: Optional[Path] = None,
with_parents: bool = False,
) -> Optional[Self]:
session.add(self)
await session.flush()
await session.refresh(self, attribute_names=["repository", "parent"])
repository_path: Path = await self.repository.path(session)
current_path: Path = repository_path
current_directory: Optional[Directory] = None
for part in path.parts:
current_path /= part
relative_path = current_path.relative_to(repository_path)
if current_path.exists() and current_path.is_dir():
# Find record
current_directory = await Directory.find(
self.repository, self.parent, self.name, session
)
if not current_directory:
# TODO: recreate record
raise DirectoryError(
f"No directory was found in the records: {relative_path}"
)
current_directory.updated = time()
await session.flush()
continue
if not with_parents:
raise DirectoryError(f"Directory not exists at /{relative_path}")
# Create an ancestor directory from scratch
current_directory = await Directory(
repository_id=self.repository.id,
parent_id=current_directory.id if current_directory else None,
name=part,
).new(
session,
path=relative_path,
with_parents=False,
)
try:
current_path.mkdir()
except OSError as e:
raise DirectoryError(
f"Failed to create directory at /{relative_path}:", *e.args
)
# Create directory
current_path /= self.name
relative_path = current_path.relative_to(repository_path)
try:
current_path.mkdir()
except OSError as e:
raise DirectoryError(
f"Failed to create directory at /{relative_path}:", *e.args
)
# Update information
self.parent = current_directory
await session.commit()
return self
async def remove(self, session: SessionContext):
session.add(self)
current_path: Path = self.repository.path(session) / self.path(session)
try:
shutil.tmtree(str(current_path))
except OSError as e:
raise DirectoryError("Failed to remove directory:", *e.args)
await session.refresh(self, attribute_names=["parent"])
current_directory: Directory = self.parent
while current_directory:
current_directory.updated = time()
session.add(current_directory)
await session.refresh(self, attribute_names=["parent"])
current_directory = current_directory.parent
await session.delete(self)
await session.commit()
async def is_root(self) -> bool:
return self.parent_id is None
@staticmethod
async def find(
repository: "Repository",
directory: "Directory",
name: str,
session: SessionContext,
) -> Optional[Self]:
return (
await session.scalars(
sa.select(Directory).where(
sa.and_(
Directory.repository_id == repository.id,
Directory.name == name,
Directory.parent_id == directory.parent_id,
)
)
)
).first()
async def find_nested(self, session: SessionContext):
pass
async def find_by_descend(
self, path: Path | str, db: database.Database, need_create: bool = False
) -> Optional[Self]:
"""Find a nested directory from current"""
repository_id = self.repository_id
path = Path(path)
current_directory = self
async def by_path(
repository_id: int, path: Path | None, name: str, db: database.Database
) -> Self | None:
async with db.session() as session:
for part in path.parts:
directory = (
await session.scalars(
sa.select(Directory).where(
sa.and_(
Directory.repository_id == repository_id,
Directory.name == part,
Directory.parent_id == current_directory.id,
)
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()
)
).first()
if directory is None:
if not need_create:
return None
directory = Directory(
repository_id=repository_id,
parent_id=current_directory.id,
name=part,
)
session.add(directory)
await session.flush()
current_directory = directory
if need_create:
await session.commit()
return current_directory
@staticmethod
async def find_by_path(
repository_id: int, path: Path | str, db: database.Database
) -> Optional[Self]:
"""Find a directory by given absolute path"""
path = Path(path)
assert path == Path(), "The path cannot be empty"
root = await Directory.find_by_descend(repository_id, path.parts[0], db)
return root.descend(Path().joinpath(*path.parts[1:]), db)
async def path(self, session: SessionContext) -> Optional[Path]:
"""Get relative path of the current directory"""
parts = []
current_directory = self
while True:
parts.append(current_directory.name)
session.add(current_directory)
await session.refresh(current_directory, attribute_names=["parent"])
if current_directory.parent is None:
break
current_directory = current_directory.parent
return Path().joinpath(*reversed(parts))
async def info(self) -> "DirectoryInfo":
return DirectoryInfo.model_validate(self)
async def remove(self, db: database.Database):
async with db.session() as session:
await session.delete(self)
await session.commit()
class DirectoryLink(Base):

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

@ -1,8 +1,8 @@
"""empty message
Revision ID: bf2ef6c7ab70
Revision ID: 939b37d98be0
Revises:
Create Date: 2024-08-02 18:37:01.697075
Create Date: 2024-06-24 15:39:38.380581
"""
from typing import Sequence, Union
@ -12,7 +12,7 @@ import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'bf2ef6c7ab70'
revision: str = '939b37d98be0'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@ -65,7 +65,7 @@ def upgrade() -> None:
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('capacity', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('directory',
@ -75,6 +75,7 @@ def upgrade() -> None:
sa.Column('created', sa.BigInteger(), nullable=False),
sa.Column('updated', 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.ForeignKeyConstraint(['parent_id'], ['directory.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'),
@ -109,7 +110,7 @@ def upgrade() -> None:
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'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)

View File

@ -1,8 +1,6 @@
from time import time
from typing import List, Self, Optional
from uuid import UUID, uuid4
from pathlib import Path
import shutil
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship
@ -12,12 +10,6 @@ from pydantic import BaseModel, ConfigDict
from materia.models.base import Base
from materia.models import database
from materia.models.database import SessionContext
from materia.config import Config
class RepositoryError(Exception):
pass
class Repository(Base):
@ -31,57 +23,6 @@ class Repository(Base):
directories: Mapped[List["Directory"]] = relationship(back_populates="repository")
files: Mapped[List["File"]] = relationship(back_populates="repository")
async def new(self, session: SessionContext, config: Config) -> Optional[Self]:
session.add(self)
await session.flush()
repository_path = await self.path(session, config)
try:
repository_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise RepositoryError(
f"Failed to create repository at /{repository_path.relative_to(config.application.working_directory)}:",
*e.args,
)
await session.flush()
return self
async def path(self, session: SessionContext, config: Config) -> Path:
session.add(self)
await session.refresh(self, attribute_names=["user"])
repository_path = config.application.working_directory.joinpath(
"repository", self.user.lower_name, "default"
)
return repository_path
async def remove(self, session: SessionContext, config: Config):
session.add(self)
await session.refresh(self, attribute_names=["directories", "files"])
for directory in self.directories:
if directory.is_root():
await directory.remove(session)
for file in self.files:
await file.remove(session)
repository_path = await self.path(session, config)
try:
shutil.rmtree(str(repository_path))
except OSError as e:
raise RepositoryError(
f"Failed to remove repository at /{repository_path.relative_to(config.application.working_directory)}:",
*e.args,
)
await session.delete(self)
await session.flush()
def to_dict(self) -> dict:
return {
k: getattr(self, k)
@ -89,20 +30,33 @@ class Repository(Base):
if isinstance(v, InstrumentedAttribute)
}
async def update(self, session: SessionContext):
await session.execute(
sa.update(Repository).values(self.to_dict()).where(Repository.id == self.id)
)
await session.flush()
async def create(self, db: database.Database):
async with db.session() as session:
session.add(self)
await session.commit()
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.commit()
@staticmethod
async def from_user(user: "User", session: SessionContext) -> Optional[Self]:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
return user.repository
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()
async def info(self) -> "RepositoryInfo":
return RepositoryInfo.model_validate(self)
async def remove(self, db: database.Database):
async with db.session() as session:
await session.delete(self)
await session.commit()
class RepositoryInfo(BaseModel):

View File

@ -1,5 +1,5 @@
from uuid import UUID, uuid4
from typing import Optional, Self
from typing import Optional
import time
import re
@ -9,22 +9,15 @@ from sqlalchemy import BigInteger, Enum
from sqlalchemy.orm import mapped_column, Mapped, relationship
import sqlalchemy as sa
from materia import security
from materia.models.base import Base
from materia.models.auth.source import LoginType
from materia.models import database
from materia.models.database import SessionContext
from materia.config import Config
from loguru import logger
valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$")
invalid_username = re.compile(r"[-._]{2,}|[-._]$")
class UserError(Exception):
pass
class User(Base):
__tablename__ = "user"
@ -50,23 +43,6 @@ class User(Base):
repository: Mapped["Repository"] = relationship(back_populates="user")
async def new(self, session: SessionContext, config: Config) -> Optional[Self]:
# Provide checks outer
session.add(self)
await session.flush()
return self
async def remove(self, session: SessionContext):
session.add(self)
await session.refresh(self, attribute_names=["repository"])
if self.repository:
await self.repository.remove()
await session.delete(self)
await session.flush()
def update_last_login(self):
self.last_login = int(time.time())
@ -77,74 +53,37 @@ class User(Base):
return self.login_type == LoginType.OAuth2
@staticmethod
def check_username(name: str) -> bool:
def is_valid_username(name: str) -> bool:
return bool(valid_username.match(name) and not invalid_username.match(name))
@staticmethod
def check_password(password: str, config: Config) -> bool:
if len(password) < config.security.password_min_length:
return False
async def count(db: database.Database):
async with db.session() as session:
return await session.scalar(sa.select(sa.func.count(User.id)))
@staticmethod
async def count(session: SessionContext) -> Optional[int]:
return await session.scalar(sa.select(sa.func.count(User.id)))
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()
@staticmethod
async def by_name(
name: str, session: SessionContext, with_lower: bool = False
) -> Optional[Self]:
if with_lower:
query = User.lower_name == name.lower()
else:
query = User.name == name
return (await session.scalars(sa.select(User).where(query))).first()
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()
@staticmethod
async def by_email(email: str, session: SessionContext) -> Optional[Self]:
return (
await session.scalars(sa.select(User).where(User.email == email))
).first()
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()
@staticmethod
async def by_id(id: UUID, session: SessionContext) -> Optional[Self]:
return (await session.scalars(sa.select(User).where(User.id == id))).first()
async def edit_name(self, name: str, session: SessionContext) -> Self:
if not User.check_username(name):
raise UserError(f"Invalid username: {name}")
self.name = name
self.lower_name = name.lower()
session.add(self)
await session.flush()
return self
async def edit_password(
self, password: str, session: SessionContext, config: Config
) -> Self:
if not User.check_password(password, config):
raise UserError("Invalid password")
self.hashed_password = security.hash_password(
password, algo=config.security.password_hash_algo
)
session.add(self)
await session.flush()
return self
async def edit_email(self):
pass
def info(self) -> "UserInfo":
user_info = UserInfo.model_validate(self)
if user_info.is_email_private:
user_info.email = None
return user_info
async def remove(self, db: database.Database):
async with db.session() as session:
await session.delete(self)
await session.commit()
class UserCredentials(BaseModel):

View File

@ -1,3 +1,4 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response, status
@ -5,92 +6,77 @@ from materia import security
from materia.routers.middleware import Context
from materia.models import LoginType, User, UserCredentials
router = APIRouter(tags=["auth"])
router = APIRouter(tags = ["auth"])
@router.post("/auth/signup")
async def signup(body: UserCredentials, ctx: Context = Depends()):
if not User.is_valid_username(body.name):
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = "Invalid username")
if await User.by_name(body.name, ctx.database) is not None:
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = "User already exists")
if await User.by_email(body.email, ctx.database) is not None: # type: ignore
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = "Email already used")
if len(body.password) < ctx.config.security.password_min_length:
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = f"Password is too short (minimum length {ctx.config.security.password_min_length})")
count: Optional[int] = await User.count(ctx.database)
new_user = User(
name = body.name,
lower_name = body.name.lower(),
full_name = body.name,
email = body.email,
hashed_password = security.hash_password(body.password, algo = ctx.config.security.password_hash_algo),
login_type = LoginType.Plain,
# first registered user is admin
is_admin = count == 0
)
async with ctx.database.session() as session:
if not User.check_username(body.name):
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid username"
)
if not User.check_password(body.password, ctx.config):
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Password is too short (minimum length {ctx.config.security.password_min_length})",
)
if await User.by_name(body.name, session, with_lower=True):
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User already exists"
)
if await User.by_email(body.email, session): # type: ignore
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Email already used"
)
count: Optional[int] = await User.count(session)
await User(
name=body.name,
lower_name=body.name.lower(),
full_name=body.name,
email=body.email,
hashed_password=security.hash_password(
body.password, algo=ctx.config.security.password_hash_algo
),
login_type=LoginType.Plain,
# first registered user is admin
is_admin=count == 0,
).new(session)
session.add(new_user)
await session.commit()
@router.post("/auth/signin")
async def signin(body: UserCredentials, response: Response, ctx: Context = Depends()):
if (current_user := await User.by_name(body.name, ctx.database)) is None:
if (current_user := await User.by_email(str(body.email), ctx.database)) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid email")
if not security.validate_password(
body.password,
current_user.hashed_password,
algo=ctx.config.security.password_hash_algo,
):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid email")
if not security.validate_password(body.password, current_user.hashed_password, algo = ctx.config.security.password_hash_algo):
raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid password")
issuer = "{}://{}".format(ctx.config.server.scheme, ctx.config.server.domain)
secret = (
ctx.config.oauth2.jwt_secret
if ctx.config.oauth2.jwt_signing_algo in ["HS256", "HS384", "HS512"]
else ctx.config.oauth2.jwt_signing_key
)
secret = ctx.config.oauth2.jwt_secret if ctx.config.oauth2.jwt_signing_algo in ["HS256", "HS384", "HS512"] else ctx.config.oauth2.jwt_signing_key
access_token = security.generate_token(
str(current_user.id),
str(current_user.id),
str(secret),
ctx.config.oauth2.access_token_lifetime,
issuer,
issuer
)
refresh_token = security.generate_token(
"", str(secret), ctx.config.oauth2.refresh_token_lifetime, issuer
"",
str(secret),
ctx.config.oauth2.refresh_token_lifetime,
issuer
)
response.set_cookie(
ctx.config.security.cookie_access_token_name,
value=access_token,
max_age=ctx.config.oauth2.access_token_lifetime,
secure=True,
httponly=ctx.config.security.cookie_http_only,
samesite="lax",
ctx.config.security.cookie_access_token_name,
value = access_token,
max_age = ctx.config.oauth2.access_token_lifetime,
secure = True,
httponly = ctx.config.security.cookie_http_only,
samesite = "lax"
)
response.set_cookie(
ctx.config.security.cookie_refresh_token_name,
value=refresh_token,
max_age=ctx.config.oauth2.refresh_token_lifetime,
secure=True,
httponly=ctx.config.security.cookie_http_only,
samesite="lax",
ctx.config.security.cookie_refresh_token_name,
value = refresh_token,
max_age = ctx.config.oauth2.refresh_token_lifetime,
secure = True,
httponly = ctx.config.security.cookie_http_only,
samesite = "lax"
)
@router.get("/auth/signout")
async def signout(response: Response, ctx: Context = Depends()):
response.delete_cookie(ctx.config.security.cookie_access_token_name)

View File

@ -43,6 +43,7 @@ async def create(
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),
)
try:

View File

@ -21,19 +21,21 @@ router = APIRouter(tags=["repository"])
async def create(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
):
async with ctx.database.session() as session:
if await Repository.by_user(user, session):
raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists")
repository_path = Config.data_dir() / "repository" / user.lower_name
async with ctx.database.session() as session:
try:
await Repository(
user_id=user.id, capacity=ctx.config.repository.capacity
).new(session)
except Exception as e:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=" ".join(e.args)
)
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)
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 repository.create(ctx.database)
@router.get("/repository", response_model=RepositoryInfo)

View File

@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.staticfiles import StaticFiles
from PIL import Image
import io
from pathlib import Path
@ -6,6 +7,7 @@ import mimetypes
from materia.routers import middleware
from materia.config import Config
import materia_frontend
router = APIRouter(tags=["resources"], prefix="/resources")
@ -39,22 +41,16 @@ async def avatar(
return Response(content=buffer.getvalue(), media_type=Image.MIME[format])
try:
import materia_frontend
except ModuleNotFoundError:
pass
else:
@router.get("/assets/{filename}")
async def assets(filename: str):
path = Path(materia_frontend.__path__[0]).joinpath(
"dist", "resources", "assets", filename
)
@router.get("/assets/{filename}")
async def assets(filename: str):
path = Path(materia_frontend.__path__[0]).joinpath(
"dist", "resources", "assets", filename
)
if not path.exists():
return Response(status_code=status.HTTP_404_NOT_FOUND)
if not path.exists():
return Response(status_code=status.HTTP_404_NOT_FOUND)
content = path.read_bytes()
mime = mimetypes.guess_type(path)[0]
content = path.read_bytes()
mime = mimetypes.guess_type(path)[0]
return Response(content, media_type=mime)
return Response(content, media_type=mime)

View File

@ -2,19 +2,13 @@ from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import materia_frontend
router = APIRouter(tags=["root"])
templates = Jinja2Templates(directory=Path(materia_frontend.__path__[0]) / "dist")
try:
import materia_frontend
except ModuleNotFoundError:
pass
else:
templates = Jinja2Templates(directory=Path(materia_frontend.__path__[0]) / "dist")
@router.get("/{spa:path}", response_class=HTMLResponse)
async def root(request: Request):
return templates.TemplateResponse(
"base.html", {"request": request, "view": "app"}
)
@router.get("/{spa:path}", response_class=HTMLResponse)
async def root(request: Request):
return templates.TemplateResponse("base.html", {"request": request, "view": "app"})

View File

@ -1,22 +1,11 @@
import pytest_asyncio
import pytest
import os
from pathlib import Path
from materia.config import Config
from materia.models import (
Database,
User,
LoginType,
Repository,
Directory,
RepositoryError,
)
from materia.models.base import Base
from materia.models.database import SessionContext
from materia.models import Database, User, LoginType, Repository, Directory
from materia import security
import sqlalchemy as sa
from sqlalchemy.pool import NullPool
from sqlalchemy.orm.session import make_transient
from dataclasses import dataclass
@ -57,6 +46,7 @@ async def db(config: Config, request) -> Database:
await database.dispose()
# database_postgres = await Database.new(config_postgres.database.url())
async with database_postgres.connection() as connection:
await connection.execution_options(isolation_level="AUTOCOMMIT")
await connection.execute(sa.text("drop database pytest")),
@ -65,23 +55,11 @@ async def db(config: Config, request) -> Database:
await database_postgres.dispose()
"""
@pytest.mark.asyncio
async def test_migrations(db):
await db.run_migrations()
await db.rollback_migrations()
"""
@pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_db(db: Database, request):
async with db.connection() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.commit()
await db.run_migrations()
yield
async with db.connection() as connection:
await connection.run_sync(Base.metadata.drop_all)
await connection.commit()
# await db.rollback_migrations()
@pytest_asyncio.fixture(autouse=True)
@ -92,7 +70,6 @@ async def session(db: Database, request):
await session.close()
"""
@pytest_asyncio.fixture(scope="session")
async def user(config: Config, session) -> User:
test_user = User(
@ -116,14 +93,13 @@ async def user(config: Config, session) -> User:
async with db.session() as session:
await session.delete(test_user)
await session.flush()
"""
@pytest_asyncio.fixture(scope="function")
@pytest_asyncio.fixture
async def data(config: Config):
class TestData:
user = User(
name="PyTest",
name="pytest",
lower_name="pytest",
email="pytest@example.com",
hashed_password=security.hash_password(
@ -137,58 +113,16 @@ async def data(config: Config):
@pytest.mark.asyncio
async def test_user(data, session: SessionContext, config: Config):
# simple
async def test_user(data, session):
session.add(data.user)
await session.flush()
assert data.user.id is not None
assert security.validate_password("iampytest", data.user.hashed_password)
await session.rollback()
# methods
await data.user.new(session, config)
assert data.user.id is not None
assert await data.user.count(session) == 1
assert await User.by_name("PyTest", session) == data.user
assert await User.by_email("pytest@example.com", session) == data.user
await data.user.edit_name("AsyncPyTest", session)
assert await User.by_name("asyncpytest", session, with_lower=True) == data.user
await data.user.remove(session)
@pytest.mark.asyncio
async def test_repository(data, tmpdir, session: SessionContext, config: Config):
config.application.working_directory = Path(tmpdir)
session.add(data.user)
await session.flush()
repository = await Repository(
user_id=data.user.id, capacity=config.repository.capacity
).new(session, config)
assert repository
assert repository.id is not None
assert (await repository.path(session, config)).exists()
assert await Repository.from_user(data.user, session) == repository
await repository.remove(session, config)
make_transient(repository)
session.add(repository)
await session.flush()
with pytest.raises(RepositoryError):
await repository.remove(session, config)
assert not (await repository.path(session, config)).exists()
@pytest.mark.asyncio
async def test_directory(data, tmpdir, session: SessionContext, config: Config):
# setup
async def test_repository(data, session, config):
session.add(data.user)
await session.flush()
@ -196,11 +130,24 @@ async def test_directory(data, tmpdir, session: SessionContext, config: Config):
session.add(repository)
await session.flush()
directory = Directory(repository_id=repository.id, parent_id=None, name="test1")
assert repository.id is not None
@pytest.mark.asyncio
async def test_directory(data, session, config):
session.add(data.user)
await session.flush()
repository = Repository(user_id=data.user.id, capacity=config.repository.capacity)
session.add(repository)
await session.flush()
directory = Directory(
repository_id=repository.id, parent_id=None, name="test1", path=None
)
session.add(directory)
await session.flush()
# simple
assert directory.id is not None
assert (
await session.scalars(
@ -208,16 +155,17 @@ async def test_directory(data, tmpdir, session: SessionContext, config: Config):
sa.and_(
Directory.repository_id == repository.id,
Directory.name == "test1",
Directory.path.is_(None),
)
)
)
).first() == directory
# nested simple
nested_directory = Directory(
repository_id=repository.id,
parent_id=directory.id,
name="test_nested",
path="test1",
)
session.add(nested_directory)
await session.flush()
@ -229,6 +177,7 @@ async def test_directory(data, tmpdir, session: SessionContext, config: Config):
sa.and_(
Directory.repository_id == repository.id,
Directory.name == "test_nested",
Directory.path == "test1",
)
)
)

View File

@ -5,7 +5,7 @@
groups = ["default", "dev"]
strategy = ["cross_platform", "inherit_metadata"]
lock_version = "4.4.1"
content_hash = "sha256:16bedb3de70622af531e01dee2c2773d108a005caf9fa9d2fbe9042267602ef6"
content_hash = "sha256:4122146bc4848501b79e4e97551c7835f5559cc7294aa8ec3161a9c2fd86985f"
[[package]]
name = "black"
@ -19,12 +19,18 @@ dependencies = [
"packaging>=22.0",
"pathspec>=0.9.0",
"platformdirs>=2",
"tomli>=1.1.0; python_version < \"3.11\"",
"typing-extensions>=4.0.1; python_version < \"3.11\"",
]
files = [
{file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"},
{file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"},
{file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"},
{file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"},
{file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"},
{file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"},
{file = "black-23.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0"},
{file = "black-23.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3"},
{file = "black-23.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba"},
{file = "black-23.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b"},
{file = "black-23.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59"},
{file = "black-23.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50"},
{file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"},
{file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"},
]
@ -55,6 +61,18 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "exceptiongroup"
version = "1.2.2"
requires_python = ">=3.7"
summary = "Backport of PEP 654 (exception groups)"
groups = ["dev"]
marker = "python_version < \"3.11\""
files = [
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
{file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
]
[[package]]
name = "iniconfig"
version = "2.0.0"
@ -155,15 +173,41 @@ summary = "pytest: simple powerful testing with Python"
groups = ["dev"]
dependencies = [
"colorama; sys_platform == \"win32\"",
"exceptiongroup>=1.0.0rc8; python_version < \"3.11\"",
"iniconfig",
"packaging",
"pluggy<2.0,>=0.12",
"tomli>=1.0.0; python_version < \"3.11\"",
]
files = [
{file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
{file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
]
[[package]]
name = "tomli"
version = "2.0.1"
requires_python = ">=3.7"
summary = "A lil' TOML parser"
groups = ["dev"]
marker = "python_version < \"3.11\""
files = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
requires_python = ">=3.8"
summary = "Backported and Experimental Type Hints for Python 3.8+"
groups = ["dev"]
marker = "python_version < \"3.11\""
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[[package]]
name = "win32-setctime"
version = "1.1.0"

View File

@ -8,7 +8,7 @@ authors = [
dependencies = [
"loguru<1.0.0,>=0.7.2",
]
requires-python = ">=3.12,<3.13"
requires-python = "<3.12,>=3.10"
readme = "README.md"
license = {text = "MIT"}