new: reconstruct project

new: pdm package manager (python)
new: workspace for three subprojects
new: dream2nix module for packaging
new: postgresql and redis images
more: and more
This commit is contained in:
L-Nafaryus 2024-06-17 19:52:24 +05:00
parent e67fcc2216
commit 997f37d5ee
Signed by: L-Nafaryus
GPG Key ID: 553C97999B363D38
121 changed files with 11378 additions and 2541 deletions

11
.gitignore vendored
View File

@ -1,5 +1,12 @@
/result*
/repl-result*
temp/
dist/
/.venv
__pycache__/
/temp
/dist
*.egg-info
.pdm.toml
.pdm-python
.pdm-build

1002
flake.lock

File diff suppressed because it is too large Load Diff

247
flake.nix
View File

@ -1,74 +1,225 @@
{
description = "Materia is a file server";
nixConfig = {
extra-substituters = [ "https://bonfire.cachix.org" ];
extra-trusted-public-keys = [ "bonfire.cachix.org-1:mzAGBy/Crdf8NhKail5ciK7ZrGRbPJJobW6TwFb7WYM=" ];
};
description = "Materia";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
poetry2nix = {
url = "github:nix-community/poetry2nix";
dream2nix = {
url = "github:nix-community/dream2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
bonfire.url = "github:L-Nafaryus/bonfire";
};
outputs = { self, nixpkgs, poetry2nix, ... }:
outputs = { self, nixpkgs, dream2nix, bonfire, ... }:
let
forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" ];
nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
bonpkgs = bonfire.packages.${system};
bonlib = bonfire.lib;
dreamBuildPackage = { module, meta ? {}, extraModules ? [], extraArgs ? {} }: (
nixpkgs.lib.evalModules {
modules = [ module ] ++ extraModules;
specialArgs = {
inherit dream2nix;
packageSets.nixpkgs = pkgs;
} // extraArgs;
}
).config.public // { inherit meta; };
in
{
packages = forAllSystems (system: let
pkgs = nixpkgsFor.${system};
inherit (poetry2nix.lib.mkPoetry2Nix { inherit pkgs; }) mkPoetryApplication;
in {
materia = mkPoetryApplication {
projectDir = ./.;
packages.x86_64-linux = {
materia-frontend = dreamBuildPackage {
module = { lib, config, dream2nix, ... }: {
name = "materia-frontend";
version = "0.0.1";
imports = [
dream2nix.modules.dream2nix.WIP-nodejs-builder-v3
];
mkDerivation = {
src = ./materia-web-client/src/materia-frontend;
};
deps = {nixpkgs, ...}: {
inherit
(nixpkgs)
fetchFromGitHub
stdenv
;
};
WIP-nodejs-builder-v3 = {
packageLockFile = "${config.mkDerivation.src}/package-lock.json";
};
};
meta = with nixpkgs.lib; {
description = "Materia frontend";
license = licenses.mit;
maintainers = with bonlib.maintainers; [ L-Nafaryus ];
broken = false;
};
};
default = self.packages.${system}.materia;
});
materia-web-client = dreamBuildPackage {
extraArgs = {
inherit (self.packages.x86_64-linux) materia-frontend;
};
module = {config, lib, dream2nix, materia-frontend, ...}: {
imports = [ dream2nix.modules.dream2nix.WIP-python-pdm ];
apps = forAllSystems (system: {
materia = {
type = "app";
program = "${self.packages.${system}.materia}/bin/materia";
pdm.lockfile = ./materia-web-client/pdm.lock;
pdm.pyproject = ./materia-web-client/pyproject.toml;
deps = _ : {
python = pkgs.python3;
};
mkDerivation = {
src = ./materia-web-client;
buildInputs = [
pkgs.python3.pkgs.pdm-backend
];
configurePhase = ''
cp -rv ${materia-frontend}/dist ./src/materia-frontend/
'';
};
};
meta = with nixpkgs.lib; {
description = "Materia web client";
license = licenses.mit;
maintainers = with bonlib.maintainers; [ L-Nafaryus ];
broken = false;
};
};
default = self.apps.${system}.materia;
});
materia-server = dreamBuildPackage {
module = {config, lib, dream2nix, materia-frontend, ...}: {
imports = [ dream2nix.modules.dream2nix.WIP-python-pdm ];
devShells = forAllSystems (system: let
pkgs = nixpkgsFor.${system};
db_name = "materia";
db_user = "materia";
db_path = "temp/materia-db";
in {
default = pkgs.mkShell {
buildInputs = with pkgs; [
nil
nodejs
ripgrep
pdm.lockfile = ./materia-server/pdm.lock;
pdm.pyproject = ./materia-server/pyproject.toml;
postgresql
deps = _ : {
python = pkgs.python3;
};
poetry
];
mkDerivation = {
src = ./materia-server;
buildInputs = [
pkgs.python3.pkgs.pdm-backend
];
nativeBuildInputs = [
pkgs.python3.pkgs.wrapPython
];
LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
};
};
meta = with nixpkgs.lib; {
description = "Materia";
license = licenses.mit;
maintainers = with bonlib.maintainers; [ L-Nafaryus ];
broken = false;
mainProgram = "materia-server";
};
};
shellHook = ''
trap "pg_ctl -D ${db_path} stop" EXIT
[ ! -d $(pwd)/${db_path} ] && initdb -D $(pwd)/${db_path} -U ${db_user}
pg_ctl -D $(pwd)/${db_path} -l $(pwd)/${db_path}/db.log -o "--unix_socket_directories=$(pwd)/${db_path}" start
[ ! "$(psql -h $(pwd)/${db_path} -U ${db_user} -l | rg '^ ${db_name}')" ] && createdb -h $(pwd)/${db_path} -U ${db_user} ${db_name}
postgresql = let
user = "postgres";
database = "postgres";
dataDir = "/var/lib/postgresql";
entryPoint = pkgs.writeTextDir "entrypoint.sh" ''
initdb -U ${user}
postgres -k ${dataDir}
'';
in pkgs.dockerTools.buildImage {
name = "postgresql";
tag = "devel";
copyToRoot = pkgs.buildEnv {
name = "image-root";
pathsToLink = [ "/bin" "/etc" "/" ];
paths = with pkgs; [
bash
postgresql
entryPoint
];
};
runAsRoot = with pkgs; ''
#!${runtimeShell}
${dockerTools.shadowSetup}
groupadd -r ${user}
useradd -r -g ${user} --home-dir=${dataDir} ${user}
mkdir -p ${dataDir}
chown -R ${user}:${user} ${dataDir}
'';
config = {
Entrypoint = [ "bash" "/entrypoint.sh" ];
StopSignal = "SIGINT";
User = "${user}:${user}";
Env = [ "PGDATA=${dataDir}" ];
WorkingDir = dataDir;
ExposedPorts = {
"5432/tcp" = {};
};
};
};
});
redis = let
user = "redis";
dataDir = "/var/lib/redis";
entryPoint = pkgs.writeTextDir "entrypoint.sh" ''
redis-server \
--daemonize no \
--dir "${dataDir}"
'';
in pkgs.dockerTools.buildImage {
name = "redis";
tag = "devel";
copyToRoot = pkgs.buildEnv {
name = "image-root";
pathsToLink = [ "/bin" "/etc" "/" ];
paths = with pkgs; [
bash
redis
entryPoint
];
};
runAsRoot = with pkgs; ''
#!${runtimeShell}
${dockerTools.shadowSetup}
groupadd -r ${user}
useradd -r -g ${user} --home-dir=${dataDir} ${user}
mkdir -p ${dataDir}
chown -R ${user}:${user} ${dataDir}
'';
config = {
Entrypoint = [ "bash" "/entrypoint.sh" ];
StopSignal = "SIGINT";
User = "${user}:${user}";
WorkingDir = dataDir;
ExposedPorts = {
"6379/tcp" = {};
};
};
};
};
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 ];
};
};
}

162
materia-client/.gitignore vendored Normal file
View File

@ -0,0 +1,162 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm-project.org/#use-with-ide
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

1
materia-client/README.md Normal file
View File

@ -0,0 +1 @@
# materia-client

View File

@ -0,0 +1,20 @@
[project]
name = "materia-client"
version = "0.1.0"
description = "Default template for PDM package"
authors = [
{name = "L-Nafaryus", email = "l.nafaryus@gmail.com"},
]
dependencies = [
"httpx>=0.27.0",
]
requires-python = ">=3.11"
readme = "README.md"
license = {text = "MIT"}
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"
[tool.pdm]
distribution = true

View File

@ -0,0 +1,13 @@
import click
@click.group()
def client():
click.echo("Hola!")
@client.command()
def test():
pass
if __name__ == "__main__":
client()

View File

162
materia-server/.gitignore vendored Normal file
View File

@ -0,0 +1,162 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm-project.org/#use-with-ide
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

1
materia-server/README.md Normal file
View File

@ -0,0 +1 @@
# materia-server

1771
materia-server/pdm.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,76 @@
[project]
name = "materia-server"
version = "0.1.1"
description = "Materia is a file server"
authors = [
{name = "L-Nafaryus", email = "l.nafaryus@gmail.com"},
]
dependencies = [
"fastapi<1.0.0,>=0.111.0",
"uvicorn[standard]<1.0.0,>=0.29.0",
"psycopg2-binary<3.0.0,>=2.9.9",
"toml<1.0.0,>=0.10.2",
"sqlalchemy[asyncio]<3.0.0,>=2.0.30",
"asyncpg<1.0.0,>=0.29.0",
"eventlet<1.0.0,>=0.36.1",
"bcrypt==4.1.2",
"pyjwt<3.0.0,>=2.8.0",
"requests<3.0.0,>=2.31.0",
"pillow<11.0.0,>=10.3.0",
"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",
"pydantic-settings<3.0.0,>=2.2.1",
"email-validator<3.0.0,>=2.1.1",
"pydanclick<1.0.0,>=0.2.0",
"loguru<1.0.0,>=0.7.2",
"alembic-postgresql-enum<2.0.0,>=1.2.0",
]
requires-python = "<3.12,>=3.10"
readme = "README.md"
license = {text = "MIT"}
[tool.pdm.build]
includes = ["src/materia_server", "src/materia_server/alembic.ini"]
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"
[project.scripts]
materia-server = "materia_server.main:server"
[tool.pdm.scripts]
start-server.cmd = "python ./src/materia_server/main.py {args:start --app-mode development --log-level debug}"
db-upgrade.cmd = "alembic upgrade {args:head}"
db-downgrade.cmd = "alembic downgrade {args:base}"
db-revision.cmd = "alembic revision {args:--autogenerate}"
remove-revisions.shell = "rm -v ./src/materia_server/models/migrations/versions/*.py"
[tool.pyright]
reportGeneralTypeIssues = false
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
[tool.pdm]
distribution = true
[tool.pdm.dev-dependencies]
dev = [
"black<24.0.0,>=23.3.0",
"pytest<8.0.0,>=7.3.2",
"pyflakes<4.0.0,>=3.0.1",
"pyright<2.0.0,>=1.1.314",
]

View File

@ -0,0 +1,83 @@
import sys
from typing import Sequence
from loguru import logger
from loguru._logger import Logger
import logging
import inspect
from materia_server.config import Config
class InterceptHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
level: str | int
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame, depth = inspect.currentframe(), 2
while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
frame = frame.f_back
depth += 1
logger.opt(depth = depth, exception = record.exc_info).log(level, record.getMessage())
def make_logger(config: Config, interceptions: Sequence[str] = ["uvicorn", "uvicorn.access", "uvicorn.error", "uvicorn.asgi", "fastapi"]) -> Logger:
logger.remove()
if config.log.mode in ["console", "all"]:
logger.add(
sys.stdout,
enqueue = True,
backtrace = True,
level = config.log.level.upper(),
format = config.log.console_format,
filter = lambda record: record["level"].name in ["INFO", "WARNING", "DEBUG", "TRACE"]
)
logger.add(
sys.stderr,
enqueue = True,
backtrace = True,
level = config.log.level.upper(),
format = config.log.console_format,
filter = lambda record: record["level"].name in ["ERROR", "CRITICAL"]
)
if config.log.mode in ["file", "all"]:
logger.add(
str(config.log.file),
rotation = config.log.file_rotation,
retention = config.log.file_retention,
enqueue = True,
backtrace = True,
level = config.log.level.upper(),
format = config.log.file_format
)
logging.basicConfig(handlers = [InterceptHandler()], level = logging.NOTSET, force = True)
for external_logger in interceptions:
logging.getLogger(external_logger).handlers = [InterceptHandler()]
return logger # type: ignore
def uvicorn_log_config(config: Config) -> dict:
return {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"default": {
"class": "materia_server._logging.InterceptHandler"
},
"access": {
"class": "materia_server._logging.InterceptHandler"
},
},
"loggers": {
"uvicorn": {"handlers": ["default"], "level": config.log.level.upper(), "propagate": False},
"uvicorn.error": {"level": config.log.level.upper()},
"uvicorn.access": {"handlers": ["access"], "level": config.log.level.upper(), "propagate": False},
},
}

View File

@ -2,7 +2,7 @@
[alembic]
# path to migration scripts
script_location = materia/db/migrations
script_location = materia_server:models/migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time

View File

@ -0,0 +1,179 @@
from os import environ
from pathlib import Path
import sys
from typing import Any, Literal, Optional, Self, Union
from pydantic import BaseModel, Field, HttpUrl, model_validator, TypeAdapter, PostgresDsn, NameEmail
from pydantic_settings import BaseSettings
from pydantic.networks import IPvAnyAddress
import toml
class Application(BaseModel):
user: str = "materia"
group: str = "materia"
mode: Literal["production", "development"] = "production"
working_directory: Optional[Path] = Path.cwd()
class Log(BaseModel):
mode: Literal["console", "file", "all"] = "console"
level: Literal["info", "warning", "error", "critical", "debug", "trace"] = "info"
console_format: str = "<level>{level: <8}</level> <green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> - {message}"
file_format: str = "<level>{level: <8}</level>: <green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> - {message}"
file: Optional[Path] = None
file_rotation: str = "3 days"
file_retention: str = "1 week"
class Server(BaseModel):
scheme: Literal["http", "https"] = "http"
address: IPvAnyAddress = Field(default = "127.0.0.1")
port: int = 54601
domain: str = "localhost"
class Database(BaseModel):
backend: Literal["postgresql"] = "postgresql"
scheme: Literal["postgresql+asyncpg"] = "postgresql+asyncpg"
address: IPvAnyAddress = Field(default = "127.0.0.1")
port: int = 5432
name: str = "materia"
user: str = "materia"
password: Optional[Union[str, Path]] = None
# ssl: bool = False
def url(self) -> str:
if self.backend in ["postgresql"]:
return "{}://{}:{}@{}:{}/{}".format(
self.scheme,
self.user,
self.password,
self.address,
self.port,
self.name
)
else:
raise NotImplemented()
class Cache(BaseModel):
backend: Literal["redis"] = "redis" # add: memory
# gc_interval: Optional[int] = 60 # for: memory
scheme: Literal["redis", "rediss"] = "redis"
address: Optional[IPvAnyAddress] = Field(default = "127.0.0.1")
port: Optional[int] = 6379
user: Optional[str] = None
password: Optional[Union[str, Path]] = None
database: Optional[int] = 0 # for: redis
def url(self) -> str:
if self.backend in ["redis"]:
if self.user and self.password:
return "{}://{}:{}@{}:{}/{}".format(
self.scheme,
self.user,
self.password,
self.address,
self.port,
self.database
)
else:
return "{}://{}:{}/{}".format(
self.scheme,
self.address,
self.port,
self.database
)
else:
raise NotImplemented()
class Security(BaseModel):
secret_key: Optional[Union[str, Path]] = None
password_min_length: int = 8
password_hash_algo: Literal["bcrypt"] = "bcrypt"
cookie_http_only: bool = True
cookie_access_token_name: str = "materia_at"
cookie_refresh_token_name: str = "materia_rt"
class OAuth2(BaseModel):
enabled: bool = True
jwt_signing_algo: Literal["HS256"] = "HS256"
# check if signing algo need a key or generate it | HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512, EdDSA
jwt_signing_key: Optional[Union[str, Path]] = None
jwt_secret: Optional[Union[str, Path]] = None # only for HS256, HS384, HS512 | generate
access_token_lifetime: int = 3600
refresh_token_lifetime: int = 730 * 60
refresh_token_validation: bool = False
#@model_validator(mode = "after")
#def check(self) -> Self:
# if self.jwt_signing_algo in ["HS256", "HS384", "HS512"]:
# assert self.jwt_secret is not None, "JWT secret must be set for HS256, HS384, HS512 algorithms"
class Mailer(BaseModel):
enabled: bool = False
scheme: Optional[Literal["smtp", "smtps", "smtp+starttls"]] = None
address: Optional[IPvAnyAddress] = None
port: Optional[int] = None
helo: bool = True
cert_file: Optional[Path] = None
key_file: Optional[Path] = None
from_: Optional[NameEmail] = None
user: Optional[str] = None
password: Optional[str] = None
plain_text: bool = False
class Cron(BaseModel):
pass
class Repository(BaseModel):
capacity: int = 41943040
class Config(BaseSettings, env_prefix = "materia_", env_nested_delimiter = "_"):
application: Application = Application()
log: Log = Log()
server: Server = Server()
database: Database = Database()
cache: Cache = Cache()
security: Security = Security()
oauth2: OAuth2 = OAuth2()
mailer: Mailer = Mailer()
cron: Cron = Cron()
repository: Repository = Repository()
@staticmethod
def open(path: Path) -> Self | None:
try:
data: dict = toml.load(path)
except Exception as e:
raise e
#return None
else:
return Config(**data)
def write(self, path: Path):
dump = self.model_dump()
# TODO: make normal filter or check model_dump abilities
for key_first in dump.keys():
for key_second in dump[key_first].keys():
if isinstance(dump[key_first][key_second], Path):
dump[key_first][key_second] = str(dump[key_first][key_second])
with open(path, "w") as file:
toml.dump(dump, file)
@staticmethod
def data_dir() -> Path:
cwd = Path.cwd()
if environ.get("MATERIA_DEBUG"):
return cwd / "temp"
else:
return cwd
@staticmethod
def create(path: Path, config: Self | None = None):
config = config or Config()
pass

View File

@ -0,0 +1,187 @@
from contextlib import _AsyncGeneratorContextManager, asynccontextmanager
from os import environ
import os
from pathlib import Path
import pwd
import sys
from typing import AsyncIterator, TypedDict
import click
from pydantic import BaseModel
from pydanclick import from_pydantic
import pydantic
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from materia_server import config as _config
from materia_server.config import Config
from materia_server._logging import make_logger, uvicorn_log_config, Logger
from materia_server.models.database import Database, Cache
from materia_server import routers
# TODO: add cache
class AppContext(TypedDict):
config: Config
database: Database
cache: Cache
logger: Logger
def create_lifespan(config: Config, logger):
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[AppContext]:
database = Database.new(config.database.url()) # type: ignore
try:
cache = await Cache.new(config.cache.url()) # type: ignore
except:
logger.error("Failed to connect redis {}", config.cache.url())
sys.exit()
async with database.connection() as connection:
await connection.run_sync(database.run_migrations) # type: ignore
yield AppContext(
config = config,
database = database,
cache = cache,
logger = logger
)
if database.engine is not None:
await database.dispose()
return lifespan
@click.group()
def server():
pass
@server.command()
@click.option("--config_path", type = Path)
@from_pydantic("application", _config.Application, prefix = "app")
@from_pydantic("log", _config.Log, prefix = "log")
def start(application: _config.Application, config_path: Path, log: _config.Log):
config = Config()
logger = make_logger(config)
#if user := application.user:
# os.setuid(pwd.getpwnam(user).pw_uid)
#if group := application.group:
# os.setgid(pwd.getpwnam(user).pw_gid)
# TODO: merge cli options with config
if working_directory := (application.working_directory or config.application.working_directory):
os.chdir(working_directory.resolve())
logger.debug(f"Current working directory: {working_directory}")
# check the configuration file or use default
if config_path is not None:
config_path = config_path.resolve()
try:
logger.debug("Reading configuration file at {}", config_path)
if not config_path.exists():
logger.error("Configuration file was not found at {}.", config_path)
sys.exit(1)
else:
config = Config.open(config_path.resolve())
except Exception as e:
logger.error("Failed to read configuration file: {}", e)
sys.exit(1)
else:
# trying to find configuration file in the current working directory
config_path = Config.data_dir().joinpath("config.toml")
if config_path.exists():
logger.info("Found configuration file in the current working directory.")
try:
config = Config.open(config_path)
except Exception as e:
logger.error("Failed to read configuration file: {}", e)
else:
logger.info("Using the default configuration.")
config = Config()
else:
logger.info("Using the default configuration.")
config = Config()
config.log.level = log.level
logger = make_logger(config)
config.application.mode = application.mode
app = FastAPI(
title = "materia",
version = "0.1.0",
docs_url = "/api/docs",
lifespan = create_lifespan(config, logger)
)
app.add_middleware(
CORSMiddleware,
allow_origins = [ "http://localhost", "http://localhost:5173" ],
allow_credentials = True,
allow_methods = ["*"],
allow_headers = ["*"],
)
app.include_router(routers.api.router)
try:
uvicorn.run(
app,
port = config.server.port,
host = str(config.server.address),
# reload = config.application.mode == "development",
log_config = uvicorn_log_config(config)
)
except (KeyboardInterrupt, SystemExit):
pass
@server.group()
def config():
pass
@config.command("create", help = "Create a new configuration file.")
@click.option("--path", "-p", type = Path, default = Path.cwd().joinpath("config.toml"), help = "Path to the file.")
@click.option("--force", "-f", is_flag = True, default = False, help = "Overwrite a file if exists.")
def config_create(path: Path, force: bool):
path = path.resolve()
config = Config()
logger = make_logger(config)
if path.exists() and not force:
logger.warning("File already exists at the given path. Exit.")
sys.exit(1)
if not path.parent.exists():
logger.info("Creating directory at {}", path)
path.mkdir(parents = True)
logger.info("Writing configuration file at {}", path)
config.write(path)
logger.info("All done.")
@config.command("check", help = "Check the configuration file.")
@click.option("--path", "-p", type = Path, default = Path.cwd().joinpath("config.toml"), help = "Path to the file.")
def config_check(path: Path):
path = path.resolve()
config = Config()
logger = make_logger(config)
if not path.exists():
logger.error("Configuration file was not found at the given path. Exit.")
sys.exit(1)
try:
Config.open(path)
except Exception as e:
logger.error("{}", e)
else:
logger.info("OK.")
if __name__ == "__main__":
server()

View File

@ -0,0 +1,9 @@
#from materia_server.models.base import Base
#from materia_server.models.auth import LoginType, LoginSource, OAuth2Application, OAuth2Grant, OAuth2AuthorizationCode
#from materia_server.models.user import User
#from materia_server.models.repository import Repository
#from materia_server.models.directory import Directory, DirectoryLink
#from materia_server.models.file import File, FileLink

View File

@ -0,0 +1,3 @@
from materia_server.models.auth.source import LoginType, LoginSource
from materia_server.models.auth.oauth2 import OAuth2Application, OAuth2Grant, OAuth2AuthorizationCode

View File

@ -0,0 +1,134 @@
from time import time
from typing import List, Optional, Self, Union
from uuid import UUID, uuid4
import bcrypt
import httpx
from sqlalchemy import BigInteger, ExceptionContext, ForeignKey, JSON, and_, delete, select, update
from sqlalchemy.orm import mapped_column, Mapped, relationship
from pydantic import BaseModel, HttpUrl
from materia_server.models.base import Base
from materia_server.models.database import Database, Cache
from materia_server import security
from materia_server.models import user
class OAuth2Application(Base):
__tablename__ = "oauth2_application"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id", ondelete = "CASCADE"))
name: Mapped[str]
client_id: Mapped[UUID] = mapped_column(default = uuid4)
hashed_client_secret: Mapped[str]
redirect_uris: Mapped[List[str]] = mapped_column(JSON)
confidential_client: Mapped[bool] = mapped_column(default = True)
created: Mapped[int] = mapped_column(BigInteger, default = time)
updated: Mapped[int] = mapped_column(BigInteger, default = time)
#user: Mapped["user.User"] = relationship(back_populates = "oauth2_applications")
grants: Mapped[List["OAuth2Grant"]] = relationship(back_populates = "application")
def contains_redirect_uri(self, uri: HttpUrl) -> bool:
if not self.confidential_client:
if uri.scheme == "http" and uri.host in ["127.0.0.1", "[::1]"]:
return uri in self.redirect_uris
else:
if uri.scheme == "https" and uri.port == 443:
return uri in self.redirect_uris
return False
async def generate_client_secret(self, db: Database) -> str:
client_secret = security.generate_key()
hashed_secret = bcrypt.hashpw(client_secret, bcrypt.gensalt())
self.hashed_client_secret = str(hashed_secret)
async with db.session() as session:
session.add(self)
await session.commit()
return str(client_secret)
def validate_client_secret(self, secret: bytes) -> bool:
return bcrypt.checkpw(secret, self.hashed_client_secret.encode())
@staticmethod
async def update(db: Database, app: "OAuth2Application"):
async with db.session() as session:
session.add(app)
await session.commit()
@staticmethod
async def delete(db: Database, id: int, user_id: int):
async with db.session() as session:
if not (application := (await session.scalars(
select(OAuth2Application)
.where(and_(OAuth2Application.id == id, OAuth2Application.user_id == user_id))
)).first()):
raise Exception("OAuth2Application not found")
#await session.refresh(application, attribute_names = [ "grants" ])
await session.delete(application)
@staticmethod
async def by_client_id(client_id: str, db: Database) -> Union[Self, None]:
async with db.session() as session:
return await session.scalar(select(OAuth2Application).where(OAuth2Application.client_id == client_id))
async def grant_by_user_id(self, user_id: UUID, db: Database) -> Union["OAuth2Grant", None]:
async with db.session() as session:
return (await session.scalars(select(OAuth2Grant).where(and_(OAuth2Grant.application_id == self.id, OAuth2Grant.user_id == user_id)))).first()
class OAuth2AuthorizationCode(BaseModel):
grant: "OAuth2Grant"
code: str
redirect_uri: HttpUrl
created: int
lifetime: int
def generate_redirect_uri(self, state: Optional[str] = None) -> httpx.URL:
redirect = httpx.URL(str(self.redirect_uri))
if state:
redirect = redirect.copy_add_param("state", state)
redirect = redirect.copy_add_param("code", self.code)
return redirect
class OAuth2Grant(Base):
__tablename__ = "oauth2_grant"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id", ondelete = "CASCADE"))
application_id: Mapped[int] = mapped_column(ForeignKey("oauth2_application.id", ondelete = "CASCADE"))
scope: Mapped[str]
created: Mapped[int] = mapped_column(default = time)
updated: Mapped[int] = mapped_column(default = time)
application: Mapped[OAuth2Application] = relationship(back_populates = "grants")
async def generate_authorization_code(self, redirect_uri: HttpUrl, cache: Cache) -> OAuth2AuthorizationCode:
code = OAuth2AuthorizationCode(
grant = self,
redirect_uri = redirect_uri,
code = security.generate_key().decode(),
created = int(time()),
lifetime = 3000
)
async with cache.client() as client:
client.set("oauth2_authorization_code_{}".format(code.created), code.code, ex = code.lifetime)
return code
def scope_contains(self, scope: str) -> bool:
return scope in self.scope.split(" ")

View File

@ -0,0 +1,33 @@
import enum
from time import time
from sqlalchemy import BigInteger, Enum
from sqlalchemy.orm import Mapped, mapped_column
from materia_server.models.base import Base
class LoginType(enum.Enum):
Plain = enum.auto()
OAuth2 = enum.auto()
Smtp = enum.auto()
class LoginSource(Base):
__tablename__ = "login_source"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
type: Mapped[LoginType]
created: Mapped[int] = mapped_column(default = time)
updated: Mapped[int] = mapped_column(default = time)
def is_plain(self) -> bool:
return self.type == LoginType.Plain
def is_oauth2(self) -> bool:
return self.type == LoginType.OAuth2
def is_smtp(self) -> bool:
return self.type == LoginType.Smtp

View File

@ -1,3 +1,4 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

View File

@ -0,0 +1,2 @@
from materia_server.models.database.database import Database
from materia_server.models.database.cache import Cache

View File

@ -0,0 +1,45 @@
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Self
from pydantic import BaseModel, RedisDsn
from redis import asyncio as aioredis
from redis.asyncio.client import Pipeline
class Cache:
def __init__(self, url: RedisDsn, pool: aioredis.ConnectionPool):
self.url: RedisDsn = url
self.pool: aioredis.ConnectionPool = pool
@staticmethod
async def new(url: RedisDsn, encoding: str = "utf-8", decode_responses: bool = True) -> Self:
pool = aioredis.ConnectionPool.from_url(str(url), encoding = encoding, decode_responses = decode_responses)
try:
connection = pool.make_connection()
await connection.connect()
except ConnectionError as e:
raise e
else:
await connection.disconnect()
return Cache(
url = url,
pool = pool
)
@asynccontextmanager
async def client(self) -> AsyncGenerator[aioredis.Redis, Any]:
try:
yield aioredis.Redis(connection_pool = self.pool)
except Exception as e:
raise e
@asynccontextmanager
async def pipeline(self, transaction: bool = True) -> AsyncGenerator[Pipeline, Any]:
client = await aioredis.Redis(connection_pool = self.pool)
try:
yield client.pipeline(transaction = transaction)
except Exception as e:
raise e

View File

@ -0,0 +1,79 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator, Self
from pathlib import Path
from pydantic import BaseModel, PostgresDsn
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from asyncpg import Connection
from alembic.config import Config as AlembicConfig
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from alembic.script.base import ScriptDirectory
from materia_server.models.base import Base
__all__ = [ "Database" ]
class Database:
def __init__(self, url: PostgresDsn, engine: AsyncEngine, sessionmaker: async_sessionmaker[AsyncSession]):
self.url: PostgresDsn = url
self.engine: AsyncEngine = engine
self.sessionmaker: async_sessionmaker[AsyncSession] = sessionmaker
@staticmethod
def new(url: PostgresDsn, pool_size: int = 100, autocommit: bool = False, autoflush: bool = False, expire_on_commit: bool = False) -> Self:
engine = create_async_engine(str(url), pool_size = pool_size)
sessionmaker = async_sessionmaker(
bind = engine,
autocommit = autocommit,
autoflush = autoflush,
expire_on_commit = expire_on_commit
)
return Database(
url = url,
engine = engine,
sessionmaker = sessionmaker
)
async def dispose(self):
await self.engine.dispose()
@asynccontextmanager
async def connection(self) -> AsyncIterator[AsyncConnection]:
async with self.engine.begin() as connection:
try:
yield connection
except Exception as e:
await connection.rollback()
raise e
@asynccontextmanager
async def session(self) -> AsyncIterator[AsyncSession]:
session = self.sessionmaker();
try:
yield session
except Exception as e:
await session.rollback()
raise e
finally:
await session.close()
def run_migrations(self, connection: Connection):
config = AlembicConfig(Path(__file__).parent.parent.parent / "alembic.ini")
config.set_main_option("sqlalchemy.url", self.url) # type: ignore
context = MigrationContext.configure(
connection = connection, # type: ignore
opts = {
"target_metadata": Base.metadata,
"fn": lambda rev, _: ScriptDirectory.from_config(config)._upgrade_revs("head", rev)
}
)
with context.begin_transaction():
with Operations.context(context):
context.run_migrations()

View File

@ -0,0 +1 @@
from materia_server.models.directory.directory import Directory, DirectoryLink

View File

@ -4,7 +4,7 @@ from typing import List
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship
from materia.db.base import Base
from materia_server.models.base import Base
class Directory(Base):
@ -13,8 +13,8 @@ class Directory(Base):
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)
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)
@ -23,7 +23,18 @@ class Directory(Base):
directories: Mapped[List["Directory"]] = relationship(back_populates = "parent", remote_side = [id])
parent: Mapped["Directory"] = relationship(back_populates = "directories")
files: Mapped[List["File"]] = relationship(back_populates = "parent")
link: Mapped["DirectoryLink"] = relationship(back_populates = "directory")
from materia.db.repository import Repository
from materia.db.file import File
class DirectoryLink(Base):
__tablename__ = "directory_link"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
directory_id: Mapped[int] = mapped_column(ForeignKey("directory.id", ondelete = "CASCADE"))
created: Mapped[int] = mapped_column(BigInteger, default = time)
url: Mapped[str]
directory: Mapped["Directory"] = relationship(back_populates = "link")
from materia_server.models.repository.repository import Repository
from materia_server.models.file.file import File

View File

@ -0,0 +1 @@
from materia_server.models.file.file import File, FileLink

View File

@ -0,0 +1,39 @@
from time import time
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship
from materia_server.models.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: 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)
size: Mapped[int] = mapped_column(BigInteger)
repository: Mapped["Repository"] = relationship(back_populates = "files")
parent: Mapped["Directory"] = relationship(back_populates = "files")
link: Mapped["FileLink"] = relationship(back_populates = "file")
class FileLink(Base):
__tablename__ = "file_link"
id: Mapped[int] = mapped_column(BigInteger, primary_key = True)
file_id: Mapped[int] = mapped_column(ForeignKey("file.id", ondelete = "CASCADE"))
created: Mapped[int] = mapped_column(BigInteger, default = time)
url: Mapped[str]
file: Mapped["File"] = relationship(back_populates = "link")
from materia_server.models.repository.repository import Repository
from materia_server.models.directory.directory import Directory

View File

@ -6,15 +6,21 @@ from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
import alembic_postgresql_enum
from materia.config import config as materia_config
from materia.db import Base
from materia_server.config import Config
from materia_server.models.base import Base
import materia_server.models.user
import materia_server.models.auth
import materia_server.models.repository
import materia_server.models.directory
import materia_server.models.file
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
config.set_main_option("sqlalchemy.url", materia_config.database_url())
config.set_main_option("sqlalchemy.url", Config().database.url())
# Interpret the config file for Python logging.
# This line sets up loggers basically.

View File

@ -0,0 +1,140 @@
"""empty message
Revision ID: 76191498b728
Revises:
Create Date: 2024-06-03 18:44:07.044588
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '76191498b728'
down_revision: Union[str, None] = None
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! ###
sa.Enum('Plain', 'OAuth2', 'Smtp', name='logintype').create(op.get_bind())
op.create_table('login_source',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('type', postgresql.ENUM('Plain', 'OAuth2', 'Smtp', name='logintype', create_type=False), nullable=False),
sa.Column('created', sa.Integer(), nullable=False),
sa.Column('updated', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('lower_name', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('is_email_private', sa.Boolean(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('must_change_password', sa.Boolean(), nullable=False),
sa.Column('login_type', postgresql.ENUM('Plain', 'OAuth2', 'Smtp', name='logintype', create_type=False), nullable=False),
sa.Column('created', sa.BigInteger(), nullable=False),
sa.Column('updated', sa.BigInteger(), nullable=False),
sa.Column('last_login', sa.BigInteger(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_admin', sa.Boolean(), nullable=False),
sa.Column('avatar', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('lower_name'),
sa.UniqueConstraint('name')
)
op.create_table('oauth2_application',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('client_id', sa.Uuid(), nullable=False),
sa.Column('hashed_client_secret', sa.String(), nullable=False),
sa.Column('redirect_uris', sa.JSON(), nullable=False),
sa.Column('confidential_client', sa.Boolean(), nullable=False),
sa.Column('created', sa.BigInteger(), nullable=False),
sa.Column('updated', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('repository',
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'], ),
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', 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'], ),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('oauth2_grant',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('application_id', sa.BigInteger(), nullable=False),
sa.Column('scope', sa.String(), nullable=False),
sa.Column('created', sa.Integer(), nullable=False),
sa.Column('updated', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['application_id'], ['oauth2_application.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('directory_link',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('directory_id', sa.BigInteger(), nullable=False),
sa.Column('created', sa.BigInteger(), nullable=False),
sa.Column('url', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['directory_id'], ['directory.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', 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.Column('size', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('file_link',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('file_id', sa.BigInteger(), nullable=False),
sa.Column('created', sa.BigInteger(), nullable=False),
sa.Column('url', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['file_id'], ['file.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('file_link')
op.drop_table('file')
op.drop_table('directory_link')
op.drop_table('oauth2_grant')
op.drop_table('directory')
op.drop_table('repository')
op.drop_table('oauth2_application')
op.drop_table('user')
op.drop_table('login_source')
sa.Enum('Plain', 'OAuth2', 'Smtp', name='logintype').drop(op.get_bind())
# ### end Alembic commands ###

View File

@ -0,0 +1 @@
from materia_server.models.repository.repository import Repository

View File

@ -5,21 +5,21 @@ from uuid import UUID, uuid4
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship
from materia.db.base import Base
from materia_server.models.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"))
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id"))
capacity: Mapped[int] = mapped_column(BigInteger, nullable = False)
owner: Mapped["User"] = relationship(back_populates = "repository")
user: Mapped["User"] = relationship(back_populates = "repository")
directories: Mapped[List["Directory"]] = relationship(back_populates = "repository")
files: Mapped[List["File"]] = relationship(back_populates = "repository")
from materia.db.user import User
from materia.db.directory import Directory
from materia.db.file import File
from materia_server.models.user.user import User
from materia_server.models.directory.directory import Directory
from materia_server.models.file.file import File

View File

@ -0,0 +1 @@
from materia_server.models.user.user import User, UserCredentials

View File

@ -0,0 +1,84 @@
from uuid import UUID, uuid4
from typing import Optional
import time
import re
from pydantic import BaseModel, EmailStr
import pydantic
from sqlalchemy import BigInteger, Enum
from sqlalchemy.orm import mapped_column, Mapped, relationship
import sqlalchemy as sa
from materia_server.models.base import Base
from materia_server.models.auth.source import LoginType
from materia_server.models import database
from loguru import logger
valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$")
invalid_username = re.compile(r"[-._]{2,}|[-._]$")
class User(Base):
__tablename__ = "user"
id: Mapped[UUID] = mapped_column(primary_key = True, default = uuid4)
name: Mapped[str] = mapped_column(unique = True)
lower_name: Mapped[str] = mapped_column(unique = True)
full_name: Mapped[str]
email: Mapped[str]
is_email_private: Mapped[bool] = mapped_column(default = True)
hashed_password: Mapped[str]
must_change_password: Mapped[bool] = mapped_column(default = False)
login_type: Mapped["LoginType"]
created: Mapped[int] = mapped_column(BigInteger, default = time.time)
updated: Mapped[int] = mapped_column(BigInteger, default = time.time)
last_login: Mapped[int] = mapped_column(BigInteger, nullable = True)
is_active: Mapped[bool] = mapped_column(default = False)
is_admin: Mapped[bool] = mapped_column(default = False)
avatar: Mapped[Optional[str]]
repository: Mapped["Repository"] = relationship(back_populates = "user")
def update_last_login(self):
self.last_login = int(time.time())
def is_local(self) -> bool:
return self.login_type == LoginType.Plain
def is_oauth2(self) -> bool:
return self.login_type == LoginType.OAuth2
@staticmethod
def is_valid_username(name: str) -> bool:
return bool(valid_username.match(name) and not invalid_username.match(name))
@staticmethod
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 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_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_id(id: UUID, db: database.Database):
async with db.session() as session:
return (await session.scalars(sa.select(User).where(User.id == id))).first()
class UserCredentials(BaseModel):
name: str
password: str
email: Optional[EmailStr]
from materia_server.models.repository.repository import Repository

View File

@ -0,0 +1 @@
from materia_server.routers import api

View File

@ -0,0 +1,6 @@
from fastapi import APIRouter
from materia_server.routers.api import auth
router = APIRouter(prefix = "/api")
router.include_router(auth.router)

View File

@ -0,0 +1,8 @@
from fastapi import APIRouter
from materia_server.routers.api.auth import auth
from materia_server.routers.api.auth import oauth
router = APIRouter()
router.include_router(auth.router)
router.include_router(oauth.router)

View File

@ -0,0 +1,83 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response, status
from materia_server import security
from materia_server.routers import context
from materia_server.models import user
from materia_server.models import auth
router = APIRouter(tags = ["auth"])
@router.post("/auth/signup")
async def signup(body: user.UserCredentials, ctx: context.Context = Depends()):
if not user.User.is_valid_username(body.name):
raise HTTPException(status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, detail = "Invalid username")
if await user.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.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.User.count(ctx.database)
new_user = 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 = auth.LoginType.Plain,
# first registered user is admin
is_admin = count == 0
)
async with ctx.database.session() as session:
session.add(new_user)
await session.commit()
@router.post("/auth/signin")
async def signin(body: user.UserCredentials, response: Response, ctx: context.Context = Depends()):
if (current_user := await user.User.by_name(body.name, ctx.database) or await user.User.by_email(body.email, ctx.database) if body.email else None) is None:
raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid credentials")
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
access_token = security.generate_token(
str(current_user.id),
str(secret),
ctx.config.oauth2.access_token_lifetime,
issuer
)
refresh_token = security.generate_token(
"",
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"
)
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"
)
@router.get("/auth/signout")
async def signout(response: Response, ctx: context.Context = Depends()):
response.delete_cookie(ctx.config.security.cookie_access_token_name)
response.delete_cookie(ctx.config.security.cookie_refresh_token_name)

View File

@ -0,0 +1,83 @@
from typing import Annotated, Optional, Union
from fastapi import APIRouter, Depends, Form, HTTPException
from fastapi.security import OAuth2PasswordRequestFormStrict, SecurityScopes
from fastapi.security.oauth2 import OAuth2PasswordRequestForm
from pydantic import BaseModel, HttpUrl
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
from materia_server.models import auth
from materia_server.models.user import user
from materia_server.routers import context
router = APIRouter(tags = ["oauth2"])
class OAuth2AuthorizationCodeRequestForm:
def __init__(
self,
redirect_uri: Annotated[HttpUrl, Form()],
client_id: Annotated[str, Form()],
scope: Annotated[Union[str, None], Form()] = None,
state: Annotated[Union[str, None], Form()] = None,
response_type: Annotated[str, Form()] = "code",
grant_type: Annotated[str, Form(pattern = "password")] = "authorization_code"
) -> None:
self.redirect_uri = redirect_uri
self.client_id = client_id
self.scope = scope
self.state = state
self.response_type = response_type
self.grant_type = grant_type
class AuthorizationCodeResponse(BaseModel):
code: str
@router.post("/oauth2/authorize")
async def authorize(form: Annotated[OAuth2AuthorizationCodeRequestForm, Depends()], ctx: context.Context = Depends()):
# grant_type: authorization_code, password_credentials, client_credentials, authorization_code (pkce)
ctx.logger.debug(form)
if form.grant_type == "authorization_code":
# TODO: form validation
if not (app := await auth.OAuth2Application.by_client_id(form.client_id, ctx.database)):
raise HTTPException(status_code = HTTP_500_INTERNAL_SERVER_ERROR, detail = "Client ID not registered")
if not (owner := user.User.by_id(app.user_id, ctx.database)):
raise HTTPException(status_code = HTTP_500_INTERNAL_SERVER_ERROR, detail = "User not found")
if not app.contains_redirect_uri(form.redirect_uri):
raise HTTPException(status_code = HTTP_500_INTERNAL_SERVER_ERROR, detail = "Unregistered redirect URI")
if not form.response_type == "code":
raise HTTPException(status_code = HTTP_500_INTERNAL_SERVER_ERROR, detail = "Unsupported response type")
# TODO: code challenge (S256, plain, ...)
# None: if not app.confidential_client: raise ...
grant = await app.grant_by_user_id(owner.id, ctx.database)
if app.confidential_client and grant is not None:
code = await grant.generate_authorization_code(form.redirect_uri, ctx.cache)
# TODO: include state to redirect_uri
# return redirect
# redirect to grant page
else:
raise HTTPException(status_code = HTTP_500_INTERNAL_SERVER_ERROR, detail = "Unsupported grant type")
pass
class AccessTokenResponse(BaseModel):
access_token: str
token_type: str
expires_in: int
refresh_token: str
scope: Optional[str]
@router.post("/oauth2/access_token")
async def token(ctx: context.Context = Depends()):
pass

View File

@ -0,0 +1,143 @@
import os
import time
from pathlib import Path
from typing import Annotated
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, Request, Response, UploadFile, status
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordRequestFormStrict
import httpx
from sqlalchemy import and_, insert, select, update
from authlib.integrations.starlette_client import OAuth, OAuthError
import base64
from cryptography.fernet import Fernet
import json
from materia import db
from materia.api import schema
from materia.api.state import ConfigState, DatabaseState
from materia.api.middleware import JwtMiddleware
from materia.api.token import TokenClaims
from materia.config import Config
oauth = OAuth()
oauth.register(
"materia",
authorize_url = "http://127.0.0.1:54601/api/auth/authorize",
access_token_url = "http://127.0.0.1:54601/api/auth/token",
scope = "user:read",
client_id = "",
client_secret = ""
)
class OAuth2Provider:
pass
router = APIRouter(tags = ["auth"])
@router.get("/user/signin")
async def signin(request: Request, provider: str = None):
if not provider:
return RedirectResponse("/api/auth/authorize")
else:
return RedirectResponse(request.url_for(provider.authorize_url))
@router.post("/auth/test_auth")
async def test_auth(database: DatabaseState = Depends()):
async with httpx.AsyncClient() as client:
response = await client.post("https://vcs.elnafo.ru/login/oauth/authorize", data = {
"client_id": "1edfe-0bbe-4f53-bab6-7e24f0b842e3",
"client_secret": "gto_7ecfnqg2c6kbe2qf25wjee237mmkxvbkb7arjacyvtypi24hqv4q",
"response_type": "code",
"redirect_uri": "http://127.0.0.1:54601"
})
return response.content, response.status_code
@router.post("/auth/provider")
async def provider(form: Annotated[OAuth2PasswordRequestForm, Depends()], database: DatabaseState = Depends()):
async with httpx.AsyncClient() as client:
response = await client.post("https://vcs.elnafo.ru/login/oauth/access_token", data = {
"client_id": "1edfec03-0bbe-4f53-bab6-7e24f0b842e3",
"client_secret": "gto_7ecfnqg2c6kbe2qf25wjee237mmkxvbkb7arjacyvtypi24hqv4q",
"grant_type": "authorization_code",
"code": "gta_63l6zogw5wlnkeng4gf3buqtoekkaxk7zhr67zlkyrv2ukwfeava"
})
return response.content, response.status_code
@router.post("/auth/authorize")
async def authorize(form: Annotated[OAuth2PasswordRequestForm, Depends()], database: DatabaseState = Depends()):
if form.client_id:
async with database.session() as session:
if not (user := (await session.scalars(select(db.User).where(db.User.login_name == form.username))).first()):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid user")
await session.refresh(user, attribute_names = ["oauth2_apps"])
oauth2_app = None
for app in user.oauth2_apps:
if form.client_id == app.client_id and bcrypt.checkpw(form.client_secret.encode(), app.client_secret):
oauth2_app = app
if not oauth2_app:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid client id")
data = json.dumps({"client_id": form.client_id}).encode()
else:
async with database.session() as session:
if not (user := (await session.scalars(select(db.User).where(db.User.login_name == form.username))).first()):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid user credentials")
if not bcrypt.checkpw(form.password.encode(), user.hashed_password.encode()):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid password")
data = json.dumps({"username": form.username}).encode()
key = b'sGEuUeKrooiNAy7L9sf6IFIjpv86TC9iYU_sbWqA-1c=' # Fernet.generate_key()
f = Fernet(key)
code = base64.b64encode(f.encrypt(data), b"-_").decode().replace("=", "")
global storage
storage = code
return code
storage = None
@router.post("/auth/token")
async def token(exchange: schema.Exchange, response: Response, config: ConfigState = Depends()):
if exchange.grant_type == "authorization_code":
if not exchange.code:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Missing authorization code")
# expiration
if exchange.code != storage:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid authorization code")
token = TokenClaims.create(
"asd",
config.jwt.secret,
config.jwt.maxage
)
response.set_cookie(
"token",
value = token,
max_age = config.jwt.maxage,
secure = True,
httponly = True,
samesite = "none"
)
return schema.AccessToken(
access_token = token,
token_type = "Bearer",
expires_in = config.jwt.maxage,
refresh_token = token,
scope = "identify"
)
elif exchange.grant_type == "refresh_token":
pass
else:
raise HTTPException(status.HTTP_400_BAD_REQUEST)

View File

@ -0,0 +1,74 @@
import os
import time
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import and_, insert, select, update
from materia import db
from materia.api.state import ConfigState, DatabaseState
from materia.api.middleware import JwtMiddleware
from materia.config import Config
from materia.api import schema
router = APIRouter(tags = ["directory"])
@router.post("/directory", dependencies = [Depends(JwtMiddleware())])
async def create(request: Request, path: Path = Path(), config: ConfigState = Depends(), database: DatabaseState = Depends()):
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, path.parts)))
async with database.session() as session:
session.add(user)
await session.refresh(user, attribute_names = ["repository"])
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 = user.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()
@router.get("/directory", dependencies = [Depends(JwtMiddleware())])
async def info(request: Request, repository_id: int, path: Path, config: ConfigState = Depends(), database: DatabaseState = Depends()):
async with database.session() as session:
if directory := (await session
.scalars(select(db.Directory)
.where(and_(db.Directory.repository_id == repository_id, db.Directory.name == path.name, db.Directory.path == path.parent))
)).first():
await session.refresh(directory, attribute_names = ["files"])
return schema.DirectoryInfo(
id = directory.id,
created_at = directory.created_unix,
updated_at = directory.updated_unix,
name = directory.name,
path = directory.path,
is_public = directory.is_public,
used = sum([ file.size for file in directory.files ])
)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found")

View File

@ -0,0 +1,51 @@
import os
import time
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import and_, insert, select, update
from materia import db
from materia.api import schema
from materia.api.state import ConfigState, DatabaseState
from materia.api.middleware import JwtMiddleware
from materia.config import Config
from materia.api import repository, directory
router = APIRouter(tags = ["file"])
@router.put("/file", dependencies = [Depends(JwtMiddleware())])
async def upload(request: Request, file: UploadFile, directory_path: Path = Path(), config: ConfigState = Depends(), database: DatabaseState = Depends()):
user = request.state.user
try:
await repository.create(request, config = config, database = database)
except:
pass
#try:
# directory_info = directory.info
# await directory.create(request, path = directory_path, config = config, database = database)
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():
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 = user.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

@ -2,51 +2,54 @@ import os
import time
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import and_, insert, select, update
from materia import db
from materia.api.depends import DatabaseState
from materia.api.state import ConfigState, DatabaseState
from materia.api.middleware import JwtMiddleware
from materia.config import Config
from materia.api import repository
router = APIRouter(tags = ["filesystem"])
@router.get("/play")
async def play():
def iterfile():
with open(Config.data_dir() / ".." / "bfg.mp3", mode="rb") as file_like: #
yield from file_like #
return StreamingResponse(iterfile(), media_type="audio/mp3")
@router.put("/file/upload", dependencies = [Depends(JwtMiddleware())])
async def upload(request: Request, file: UploadFile, database: DatabaseState = Depends(), directory_path: Path = Path()):
print("hi")
async def upload(request: Request, file: UploadFile, config: ConfigState = Depends(), database: DatabaseState = Depends(), directory_path: Path = Path()):
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)))
try:
await repository.create(request, config = config, database = database)
except:
pass
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():
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,
repository_id = user.repository.id,
parent_id = current_directory.id if current_directory else None,
name = part,
path = str(current_path)
@ -64,12 +67,14 @@ async def upload(request: Request, file: UploadFile, database: DatabaseState = D
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__)
if file_ := (await session
.scalars(select(db.File)
.where(and_(db.File.name == file.filename, db.File.path == str(directory_path))))
).first():
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,
repository_id = user.repository.id,
parent_id = directory.id if directory else None,
name = file.filename,
path = str(directory_path),

View File

@ -0,0 +1,60 @@
import os
import time
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import and_, insert, select, update
from materia import db
from materia.api import schema
from materia.api.state import ConfigState, DatabaseState
from materia.api.middleware import JwtMiddleware
from materia.config import Config
router = APIRouter(tags = ["repository"])
@router.post("/repository", dependencies = [Depends(JwtMiddleware())])
async def create(request: Request, config: ConfigState = Depends(), database: DatabaseState = Depends()):
user = request.state.user
repository_path = Config.data_dir() / "repository" / user.login_name.lower()
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 = config.repository.capacity
)
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()
else:
raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists")
@router.get("/repository", dependencies = [Depends(JwtMiddleware())])
async def info(request: Request, database: DatabaseState = Depends()):
user = request.state.user
async with database.session() as session:
session.add(user)
await session.refresh(user, attribute_names = ["repository"])
if repository := user.repository:
await session.refresh(repository, attribute_names = ["files"])
return schema.RepositoryInfo(
capacity = repository.capacity,
used = sum([ file.size for file in repository.files ])
)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository is not found")

View File

@ -0,0 +1,5 @@
from materia.api.schema.user import NewUser, User, RemoveUser, LoginUser
from materia.api.schema.token import Token
from materia.api.schema.repository import RepositoryInfo
from materia.api.schema.directory import DirectoryInfo
from materia.api.schema.auth import AccessToken, Exchange

View File

@ -0,0 +1,25 @@
from typing import Optional
from pydantic import BaseModel
class AuthCode(BaseModel):
client_id: str
response_type: str
state: str
redirect_uri: Optional[str]
scope: Optional[str]
class Exchange(BaseModel):
grant_type: str
client_id: Optional[str] = None
client_secret: Optional[str] = None
redirect_uri: Optional[str] = None
code: Optional[str] = None
refresh_token: Optional[str] = None
class AccessToken(BaseModel):
access_token: str
token_type: str
expires_in: int
refresh_token: str
scope: Optional[str]

View File

@ -0,0 +1,11 @@
from pydantic import BaseModel
class DirectoryInfo(BaseModel):
id: int
created_at: int
updated_at: int
name: str
path: str
is_public: bool
used: int

View File

@ -0,0 +1,6 @@
from pydantic import BaseModel
class RepositoryInfo(BaseModel):
capacity: int
used: int

View File

@ -3,7 +3,7 @@ from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response, UploadFile, status
from sqlalchemy import delete, select, insert, func, or_, update
import bcrypt
from sqids import Sqids
from sqids.sqids import Sqids
from PIL import Image
@ -11,7 +11,7 @@ from materia.config import Config
from materia.api.middleware import JwtMiddleware
from materia import db
from materia.api import schema
from materia.api.depends import ConfigState, DatabaseState
from materia.api.state import ConfigState, DatabaseState
from materia.api.token import TokenClaims

View File

@ -0,0 +1,15 @@
from fastapi import Request
from materia_server.config import Config
from materia_server.models.database import Database, Cache
from materia_server._logging import Logger
class Context:
def __init__(self, request: Request):
self.config = request.state.config
self.database = request.state.database
#self.cache = request.state.cache
self.logger = request.state.logger

View File

@ -6,9 +6,9 @@ from sqlalchemy import select
from pydantic import BaseModel
from enum import StrEnum
from http import HTTPMethod as HttpMethod
from fastapi.security import HTTPBearer
from fastapi.security import HTTPBearer, OAuth2PasswordBearer, OAuth2PasswordRequestForm, APIKeyQuery, APIKeyCookie, APIKeyHeader
from materia.api.depends import ConfigState, DatabaseState
from materia.api.state import ConfigState, DatabaseState
from materia.api.token import TokenClaims
from materia import db

View File

@ -0,0 +1,3 @@
from materia_server.security.secret_key import generate_key, encrypt_payload
from materia_server.security.token import TokenClaims, generate_token, validate_token
from materia_server.security.password import hash_password, validate_password

View File

@ -0,0 +1,16 @@
from typing import Literal
import bcrypt
def hash_password(password: str, algo: Literal["bcrypt"] = "bcrypt") -> str:
if algo == "bcrypt":
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
else:
raise NotImplemented(algo)
def validate_password(password: str, hash: str, algo: Literal["bcrypt"] = "bcrypt") -> bool:
if algo == "bcrypt":
return bcrypt.checkpw(password.encode(), hash.encode())
else:
raise NotImplemented(algo)

View File

@ -0,0 +1,17 @@
import base64
from cryptography.fernet import Fernet
def generate_key() -> bytes:
return Fernet.generate_key()
def encrypt_payload(payload: bytes, key: bytes, valid_base64: bool = True) -> bytes:
func = Fernet(key)
data = func.encrypt(payload)
if valid_base64:
data = base64.b64encode(data, b"-_").decode().replace("=", "").encode()
return data

View File

@ -0,0 +1,26 @@
from typing import Optional
import datetime
from pydantic import BaseModel
import jwt
class TokenClaims(BaseModel):
sub: str
exp: int
iat: int
iss: Optional[str] = None
def generate_token(sub: str, secret: str, duration: int, iss: Optional[str] = None) -> str:
now = datetime.datetime.now()
iat = now.timestamp()
exp = (now + datetime.timedelta(seconds = duration)).timestamp()
claims = TokenClaims(sub = sub, exp = int(exp), iat = int(iat), iss = iss)
return jwt.encode(claims.model_dump(), secret)
def validate_token(token: str, secret: str) -> TokenClaims:
payload = jwt.decode(token, secret, algorithms = [ "HS256" ])
return TokenClaims(**payload)

View File

11
materia-web-client/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
dist/
/.venv
__pycache__/
.pdm.toml
.pdm-python
.pdm-build/
node_modules/
*.tsbuildinfo
*.mjs

View File

246
materia-web-client/pdm.lock Normal file
View File

@ -0,0 +1,246 @@
# This file is @generated by PDM.
# It is not intended for manual editing.
[metadata]
groups = ["default", "dev"]
strategy = ["cross_platform", "inherit_metadata"]
lock_version = "4.4.1"
content_hash = "sha256:a13078a66bba4903a78f4696c35b0e4119ad088dccf81088aa3bf8803e650b85"
[[package]]
name = "black"
version = "23.12.1"
requires_python = ">=3.8"
summary = "The uncompromising code formatter."
groups = ["dev"]
dependencies = [
"click>=8.0.0",
"mypy-extensions>=0.4.3",
"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-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"},
]
[[package]]
name = "click"
version = "8.1.7"
requires_python = ">=3.7"
summary = "Composable command line interface toolkit"
groups = ["dev"]
dependencies = [
"colorama; platform_system == \"Windows\"",
]
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[[package]]
name = "colorama"
version = "0.4.6"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
summary = "Cross-platform colored terminal text."
groups = ["default", "dev"]
marker = "sys_platform == \"win32\" or platform_system == \"Windows\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "exceptiongroup"
version = "1.2.1"
requires_python = ">=3.7"
summary = "Backport of PEP 654 (exception groups)"
groups = ["dev"]
marker = "python_version < \"3.11\""
files = [
{file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
{file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
]
[[package]]
name = "iniconfig"
version = "2.0.0"
requires_python = ">=3.7"
summary = "brain-dead simple config-ini parsing"
groups = ["dev"]
files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "loguru"
version = "0.7.2"
requires_python = ">=3.5"
summary = "Python logging made (stupidly) simple"
groups = ["default"]
dependencies = [
"colorama>=0.3.4; sys_platform == \"win32\"",
"win32-setctime>=1.0.0; sys_platform == \"win32\"",
]
files = [
{file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"},
{file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"},
]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
requires_python = ">=3.5"
summary = "Type system extensions for programs checked with the mypy type checker."
groups = ["dev"]
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
[[package]]
name = "nodeenv"
version = "1.9.0"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
summary = "Node.js virtual environment builder"
groups = ["dev"]
files = [
{file = "nodeenv-1.9.0-py2.py3-none-any.whl", hash = "sha256:508ecec98f9f3330b636d4448c0f1a56fc68017c68f1e7857ebc52acf0eb879a"},
{file = "nodeenv-1.9.0.tar.gz", hash = "sha256:07f144e90dae547bf0d4ee8da0ee42664a42a04e02ed68e06324348dafe4bdb1"},
]
[[package]]
name = "packaging"
version = "24.0"
requires_python = ">=3.7"
summary = "Core utilities for Python packages"
groups = ["dev"]
files = [
{file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"},
{file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"},
]
[[package]]
name = "pathspec"
version = "0.12.1"
requires_python = ">=3.8"
summary = "Utility library for gitignore style pattern matching of file paths."
groups = ["dev"]
files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
]
[[package]]
name = "platformdirs"
version = "4.2.2"
requires_python = ">=3.8"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["dev"]
files = [
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
]
[[package]]
name = "pluggy"
version = "1.5.0"
requires_python = ">=3.8"
summary = "plugin and hook calling mechanisms for python"
groups = ["dev"]
files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
]
[[package]]
name = "pyflakes"
version = "3.2.0"
requires_python = ">=3.8"
summary = "passive checker of Python programs"
groups = ["dev"]
files = [
{file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"},
{file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"},
]
[[package]]
name = "pyright"
version = "1.1.365"
requires_python = ">=3.7"
summary = "Command line wrapper for pyright"
groups = ["dev"]
dependencies = [
"nodeenv>=1.6.0",
]
files = [
{file = "pyright-1.1.365-py3-none-any.whl", hash = "sha256:194d767a039f9034376b7ec8423841880ac6efdd061f3e283b4ad9fcd484a659"},
{file = "pyright-1.1.365.tar.gz", hash = "sha256:d7e69000939aed4bf823707086c30c84c005bdd39fac2dfb370f0e5be16c2ef2"},
]
[[package]]
name = "pytest"
version = "7.4.4"
requires_python = ">=3.7"
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.1"
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.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"},
{file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"},
]
[[package]]
name = "win32-setctime"
version = "1.1.0"
requires_python = ">=3.5"
summary = "A small Python utility to set file creation time on Windows"
groups = ["default"]
marker = "sys_platform == \"win32\""
files = [
{file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
{file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
]

View File

@ -0,0 +1,44 @@
[project]
name = "materia-web-client"
version = "0.1.1"
description = "Materia web client"
authors = [
{name = "L-Nafaryus", email = "l.nafaryus@gmail.com"},
]
dependencies = [
"loguru<1.0.0,>=0.7.2",
]
requires-python = "<3.12,>=3.10"
readme = "README.md"
license = {text = "MIT"}
[tool.pdm]
distribution = true
[tool.pdm.dev-dependencies]
dev = [
"black<24.0.0,>=23.3.0",
"pytest<8.0.0,>=7.3.2",
"pyflakes<4.0.0,>=3.0.1",
]
[tool.pdm.build]
includes = ["src/materia_web_client", "src/materia-frontend/dist"]
[tool.pdm.scripts]
npm-install.cmd = "npm install --prefix ./src/materia-frontend"
npm-run-build.cmd = "npm run build-only --prefix ./src/materia-frontend"
pre_build.composite = [ "npm-install", "npm-run-build" ]
materia-web-client.call = "materia_web_client.main:client"
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"
[tool.pyright]
reportGeneralTypeIssues = false
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/resources/assets/logo.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Materia Dev</title>
</head>
<body class="h-full text-zinc-200 font-sans ">
<div id="app" class="flex flex-col h-full"></div>
<script type="module" src="src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
{
"name": "materia-frontend",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build-check": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build": "vite build",
"type-check": "vue-tsc --build --force"
},
"dependencies": {
"autoprefixer": "^10.4.18",
"axios": "^1.6.8",
"pinia": "^2.1.7",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vue": "^3.3.11",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@tsconfig/node18": "^18.2.2",
"@types/node": "^18.19.3",
"@vitejs/plugin-vue": "^4.5.2",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/tsconfig": "^0.5.0",
"npm-run-all2": "^6.1.1",
"typescript": "~5.3.0",
"vite": "^5.0.10",
"vue-tsc": "^1.8.25"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
</script>
<template>
<RouterView />
</template>

View File

@ -0,0 +1,47 @@
import axios, { type AxiosInstance, AxiosError } from "axios";
export class HttpError extends Error {
status_code: number;
constructor(status_code: number, message: string) {
super(JSON.stringify({ status_code: status_code, message: message }));
Object.setPrototypeOf(this, new.target.prototype);
this.name = Error.name;
this.status_code = status_code;
}
}
export interface ResponseError {
status_code: number,
message: string
}
export function handle_error(error: AxiosError): Promise<ResponseError> {
return Promise.reject<ResponseError>({ status_code: error.response?.status, message: error.response?.data });
}
const debug = import.meta.hot;
export const client: AxiosInstance = axios.create({
baseURL: debug ? "http://localhost:54601/api" : "/api",
headers: {
"Content-Type": "application/json"
},
withCredentials: true,
});
export const upload_client: AxiosInstance = axios.create({
baseURL: debug ? "http://localhost:54601/api" : "/api",
headers: {
"Content-Type": "multipart/form-data"
},
withCredentials: true,
});
export const resources_client: AxiosInstance = axios.create({
baseURL: debug ? "http://localhost:54601/resources" : "/resources",
responseType: "blob"
});
export default client;

View File

@ -0,0 +1 @@
export * as user from "@/api/user";

View File

@ -0,0 +1,82 @@
import { client, upload_client, resources_client, type ResponseError, handle_error } from "@/api/client";
export interface UserCredentials {
name: string,
password: string,
email?: string
}
export async function signup(body: UserCredentials): Promise<null | ResponseError> {
return await client.post("/auth/signup", JSON.stringify(body))
.catch(handle_error);
}
export async function signin(body: UserCredentials): Promise<null | ResponseError> {
return await client.post("/auth/signin", JSON.stringify(body))
.catch(handle_error);
}
export type Image = string | ArrayBuffer;
export async function register(body: NewUser): Promise<User | ResponseError> {
return await client.post("/user/register", JSON.stringify(body))
.then(async response => { return Promise.resolve<User>(response.data); })
.catch(handle_error);
}
export async function login(body: LoginUser): Promise<User | ResponseError> {
return await client.post("/user/login", JSON.stringify(body))
.then(async response => { return Promise.resolve<User>(response.data); })
.catch(handle_error);
}
export async function remove(body: RemoveUser): Promise<null | ResponseError> {
return await client.post("/user/remove", JSON.stringify(body))
.then(async () => { return Promise.resolve(null); })
.catch(handle_error);
}
export async function logout(): Promise<null | ResponseError> {
return await client.get("/user/logout")
.then(async () => { return Promise.resolve(null); })
.catch(handle_error);
}
export async function current(): Promise<User | ResponseError> {
return await client.get("/user/current")
.then(async response => { return Promise.resolve<User>(response.data); })
.catch(handle_error);
}
export async function avatar(file: FormData, progress?: any): Promise<null | ResponseError> {
return await upload_client.post("/user/avatar", file, {
onUploadProgress: progress ?? null,
//headers: { "Accept-Encoding": "gzip" }
})
.then(async () => { return Promise.resolve(null); })
.catch(handle_error);
}
export async function get_avatar(avatar: string): Promise<Image | null | ResponseError> {
return await resources_client.get("/avatars/".concat(avatar))
.then(async response => {
return new Promise<Image | null>((resolve, reject) => {
let reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = (e) => {
reject(e);
};
reader.readAsDataURL(response.data);
})
})
.catch(handle_error);
}
export async function profile(login: string): Promise<User | ResponseError> {
return await client.get("/user/".concat(login))
.then(async response => { return Promise.resolve<User>(response.data); })
.catch(handle_error);
}

View File

@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
background-color: rgba(40, 30, 30, 1); /*linear-gradient(rgba(36, 14, 84, 1) 80%, rgba(55, 22, 130, 1)); */
/*background-image: url("./background.svg");*/
background-position: left top;
background-repeat: repeat-x;
}
a {
@apply text-green-500 hover:text-green-400;
}
h1 {
font-family: BioRhyme,serif;
font-weight: 700;
}
label {
font-family: Space Mono,monospace;
font-weight: 500;
}
}
@layer utilities {
.bg-grid {
background:
linear-gradient(180deg, rgba(0, 0, 0, 0) 0px, rgba(187, 65, 143, 1) 10%,
rgba(187, 65, 143, 1) 2px, rgba(0, 0, 0, 0) 0px),
linear-gradient(90deg, rgba(0, 0, 0, 0) 0px, rgba(187, 65, 143, 1) 10%,
rgba(187, 65, 143, 1) 2px, rgba(0, 0, 0, 0) 0px);
background-size: 2em 4em, 6em 2em;
transform: perspective(500px) rotateX(60deg) scale(0.5);
transform-origin: 50% 0%;
z-index: -1;
@apply absolute w-[250%] -left-[75%] h-[200%];
}
}

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import { ref } from "vue";
const active = ref<bool>(false);
function activate() {
active.value = !active.value;
}
function deactivate() {
active.value = false;
}
</script>
<template>
<div @click="activate" v-click-outside="deactivate">
<slot name="button"></slot>
<div v-if="active">
<slot name="content"></slot>
</div>
</div>
</template>

View File

@ -0,0 +1,13 @@
<template>
<div class="relative h-12 border-b border-b-zinc-500">
<nav
class="absolute w-full h-[calc(100%-1px)] flex justify-between items-center m-0 pl-3 pr-3 bg-gradient-to-t from-zinc-800 to-zinc-900">
<div class="items-center m-0 flex">
<slot name="left"></slot>
</div>
<div class="items-center m-0 flex">
<slot name="right"></slot>
</div>
</nav>
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<h1 class="text-center pt-3 pb-3 bg-orange-900 rounded border border-orange-700">
<slot></slot>
</h1>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@ -0,0 +1,14 @@
export const click_outside = {
beforeMount: function(element: any, binding: any) {
element.clickOutsideEvent = function(event: any) {
if (!(element == event.target || element.contains(event.target))) {
binding.value(event);
}
};
document.body.addEventListener("click", element.clickOutsideEvent);
},
unmounted: function(element: any) {
document.body.removeEventListener("click", element.clickOutsideEvent);
}
}

View File

@ -0,0 +1,15 @@
import App from "@/App.vue";
import { createApp } from "vue";
import { createPinia } from "pinia";
import router from "@/router";
import { click_outside } from "@/directives/click-outside";
import "@/assets/style.css";
createApp(App)
.use(createPinia())
.use(router)
.directive("click-outside", click_outside)
.mount('#app');

View File

@ -0,0 +1,86 @@
import { createRouter, createWebHistory } from "vue-router";
import { user } from "@/api";
import { useUserStore } from "@/stores";
async function check_authorized(): Promise<boolean> {
const userStore = useUserStore();
// TODO: add timer
return await user.current()
.then(async user => { userStore.current = user; })
.then(async () => {
if (userStore.current.avatar?.length) {
await user.get_avatar(userStore.current.avatar)
.then(async avatar => { userStore.avatar = avatar; })
}
})
.then(async () => { return true; })
.catch(() => {
return false;
});
}
async function bypass_auth(to: any, from: any) {
if (await check_authorized() && (to.name === "signin" || to.name === "signup")) {
return from;
}
}
async function required_auth(to: any, from: any) {
if (!await check_authorized()) {
return { name: "signin" };
}
}
async function required_admin(to: any, from: any) {
const userStore = useUserStore();
return userStore.current.is_admin;
}
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/", name: "home", beforeEnter: [bypass_auth],
component: () => import("@/views/Home.vue"),
},
{
path: "/user/login", name: "signin", beforeEnter: [bypass_auth],
component: () => import("@/views/user/SignIn.vue")
},
{
path: "/user/register", name: "signup", //beforeEnter: [bypass_auth],
component: () => import("@/views/user/SignUp.vue")
},
{
path: "/user/preferencies", name: "prefs", redirect: { name: "prefs-profile" }, beforeEnter: [required_auth],
component: () => import("@/views/user/Preferencies.vue"),
children: [
{
path: "profile", name: "prefs-profile", beforeEnter: [required_auth],
component: () => import("@/views/user/preferencies/Profile.vue")
},
{
path: "account", name: "prefs-account", beforeEnter: [required_auth],
component: () => import("@/views/user/preferencies/Account.vue")
},
]
},
{
path: "/:user", name: "profile", beforeEnter: [bypass_auth],
component: () => import("@/views/user/Profile.vue")
},
{
path: "/admin/settings", name: "settings", beforeEnter: [required_auth, required_admin],
component: () => import("@/views/admin/Settings.vue")
},
{
path: "/:pathMatch(.*)*", name: "not-found", beforeEnter: [bypass_auth],
component: () => import("@/views/error/NotFound.vue")
}
]
});
export default router;

View File

@ -0,0 +1,23 @@
import { defineStore } from "pinia";
import { ref, type Ref } from "vue";
import { user } from "@/api";
export const useUserStore = defineStore("user", () => {
const current: Ref<user.User | null> = ref(null);
const avatar: Ref<Blob | null> = ref(null);
function clear() {
current.value = null;
avatar.value = null;
}
return { current, avatar, clear };
});
export const useMiscStore = defineStore("misc", () => {
// preferencies current tab
const p_current_tab: Ref<number> = ref(0);
return { p_current_tab };
});

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
import NavBar from "@/components/NavBar.vue";
import DropdownMenu from "@/components/DropdownMenu.vue";
import { ref, onMounted } from "vue";
import router from "@/router";
import { user } from "@/api";
import { useUserStore } from "@/stores";
const userStore = useUserStore();
const error = ref(null);
async function signout() {
await user.logout()
.then(async () => {
userStore.clear();
router.push({ path: "/" });
})
.catch(error => { error.value = error; });
}
</script>
<template>
<div class="flex-grow pb-20">
<NavBar>
<template #left>
<!-- TODO: logo -->
</template>
<template #right>
<DropdownMenu v-if="userStore.current">
<template #button>
<div class="pl-3 pr-3 flex gap-2 items-center rounded hover:bg-zinc-600 cursor-pointer">
<div class="max-w-8" v-if="userStore.avatar"><img :src="userStore.avatar"></div>
<span class="flex min-w-9 min-h-9 items-center">{{userStore.current.login }}</span>
</div>
</template>
<template #content>
<div
class="absolute z-20 flex flex-col left-auto right-0 mt-4 bg-zinc-700 border rounded border-zinc-500 mr-3">
<RouterLink :to="{ name: 'profile', params: { user: userStore.current.login } }"
class="flex min-w-7 pl-5 pr-5 pt-1 pb-1 hover:bg-zinc-600">
Profile</RouterLink>
<RouterLink :to="{ name: 'prefs' }"
class="flex min-w-7 pl-5 pr-5 pt-1 pb-1 hover:bg-zinc-600">
Preferencies</RouterLink>
<div class="border-t border-zinc-500 ml-0 mr-0"></div>
<RouterLink v-if="userStore.current.is_admin" :to="{ name: 'settings' }"
class="flex min-w-7 pl-5 pr-5 pt-1 pb-1 hover:bg-zinc-600">
Settings</RouterLink>
<div class="border-t border-zinc-500 ml-0 mr-0"></div>
<div @click="signout"
class="flex min-w-7 pl-5 pr-5 pt-1 pb-1 hover:bg-zinc-600 cursor-pointer">
Sign Out</div>
</div>
</template>
</DropdownMenu>
<RouterLink v-if="!userStore.current"
class="flex min-w-9 min-h-9 pt-1 pb-1 pl-3 pr-3 rounded hover:bg-zinc-600" to="/user/login">
Sign In</RouterLink>
</template>
</NavBar>
<main>
<slot></slot>
</main>
</div>
<div class="relative overflow-hidden h-full ">
</div>
<footer
class="flex justify-between pb-2 pt-2 pl-5 pr-5 bg-gradient-to-b from-zinc-800 to-zinc-900 border-t border-t-zinc-500">
<a href="/">Made with glove</a>
<a href="/api/docs">API</a>
</footer>
</template>
<style></style>

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
import Base from '@/views/Base.vue';
</script>
<template>
<Base>
Home
</Base>
</template>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import Base from "@/views/Base.vue";
</script>
<template>
<Base>
<div>
</div>
</Base>
</template>

View File

@ -0,0 +1,10 @@
<script setup lang="ts">
import Base from "@/views/Base.vue";
import Error from "@/components/error/Error.vue";
</script>
<template>
<Base>
<Error>404 Not Found</Error>
</Base>
</template>

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import Base from "@/views/Base.vue";
import { ref, onMounted, watch, getCurrentInstance } from "vue";
import { useMiscStore } from "@/stores";
const miscStore = useMiscStore();
</script>
<template>
<Base>
<div class="flex gap-4 mt-4 ml-auto mr-auto content ">
<Router-View />
<div>
<div class="border rounded border-zinc-500 flex-col w-64 side-nav">
<h1 class="pl-5 pr-5 pt-2 pb-2">User Preferencies</h1>
<RouterLink :to="{ name: 'prefs-profile' }" :class="{ 'bg-zinc-600': miscStore.p_current_tab === 0 }"
class="flex min-w-7 pl-5 pr-5 pt-2 pb-2 hover:bg-zinc-600 border-t border-zinc-500">
Profile</RouterLink>
<RouterLink :to="{ name: 'prefs-account' }" :class="{ 'bg-zinc-600': miscStore.p_current_tab === 1 }"
class="flex min-w-7 pl-5 pr-5 pt-2 pb-2 hover:bg-zinc-600 border-t border-zinc-500">
Account</RouterLink>
</div>
</div>
</div>
</Base>
</template>
<style>
.content {
width: 1280px;
max-width: calc(100% - 64px);
}
.side-nav {
transform: perspective(300px) rotateY(-8deg) scaleY(1.05);
}
</style>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import Base from "@/views/Base.vue";
import Error from "@/components/error/Error.vue";
import { ref, onMounted, watch, getCurrentInstance } from "vue";
import { onBeforeRouteUpdate, useRoute } from "vue-router"
import { user } from "@/api";
import { useUserStore } from "@/stores";
const route = useRoute();
const userStore = useUserStore();
const error = ref<string>(null);
const person = ref<user.User>(null);
const avatar = ref<user.Image>(null);
async function profile(login: string) {
await user.profile(login)
.then(async user => { person.value = user; })
.then(async () => {
if (person.value.avatar?.length) {
await user.get_avatar(person.value.avatar)
.then(async _avatar => { avatar.value = _avatar; })
}
})
.catch(error => { error.value = error; });
};
onMounted(async () => {
await profile(route.params.user);
});
watch(route, async (to, from) => {
await profile(to.params.user);
});
</script>
<template>
<Base>
<div class="ml-auto mr-auto w-1/2 pt-5 pb-5">
<Error v-if="error">{{ error }}</Error>
<p v-if="person">{{ person.name }}</p>
<div class="max-w-8" v-if="avatar"><img :src="avatar"></div>
</div>
</Base>
</template>

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import Base from "@/views/Base.vue";
import Error from "@/components/error/Error.vue";
import { ref, onMounted } from "vue";
import router from "@/router";
import { user } from "@/api";
import { useUserStore } from "@/stores";
const email_or_username = defineModel("email_or_username");
const password = defineModel("password");
const userStore = useUserStore();
const error = ref(null);
onMounted(async () => {
if (userStore.current) {
router.replace({ path: "/" });
}
});
async function signin() {
const body: user.UserCredentials = {
name: null,
password: password.value,
email: null
};
if (/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(email_or_username.value)) {
body.name = "";
body.email = email_or_username.value;
} else {
body.name = email_or_username.value;
}
await user.signin(body)
.then(async () => {
//userStore.current = user;
router.push({ path: "/" });
})
.catch(error => { error.value = error; });
};
</script>
<template>
<Base>
<div class="ml-auto mr-auto w-1/2 pt-5 pb-5">
<h1 class="text-center pt-5 pb-5 border-b border-zinc-500">Sign In</h1>
<form @submit.prevent class="m-auto pt-5 pb-5">
<div class="mb-5 ml-auto mr-auto">
<label for="email_or_login" class="text-right w-64 inline-block mr-5">Email or Username</label>
<input v-model="email_or_username" placeholder="" name="email_or_login" required
class="w-1/2 bg-zinc-800 pl-3 pr-3 pt-2 pb-2 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div class="mb-5 ml-auto mr-auto">
<label for="password" class="text-right w-64 inline-block mr-5">Password</label>
<input v-model="password" placeholder="" type="password" name="password" required
class="w-1/2 bg-zinc-800 pl-3 pr-3 pt-2 pb-2 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div class="mb-5 ml-auto mr-auto">
<label class="text-right w-64 inline-block mr-5"></label>
<div class="flex justify-between items-center w-1/2 m-auto">
<button @click="signin" class="rounded bg-zinc-500 hover:bg-zinc-400 pb-2 pt-2 pl-5 pr-5">Sign
In</button>
<p>or</p>
<button @click="$router.push('/user/register')"
class="rounded bg-zinc-500 hover:bg-zinc-400 pb-2 pt-2 pl-5 pr-5">Sign
Up</button>
</div>
</div>
</form>
<Error v-if="error">{{ error }}</Error>
</div>
</Base>
</template>

View File

@ -0,0 +1,52 @@
<script setup lang="ts">
import Base from "@/views/Base.vue";
import Error from "@/components/error/Error.vue";
import { ref } from "vue";
import router from "@/router";
import { user } from "@/api";
const login = defineModel("login");
const email = defineModel("email");
const password = defineModel("password");
const error = ref(null);
async function signup() {
await user.register({ login: login.value, password: password.value, email: email.value })
.then(async user => { router.push({ path: "/user/login" }); })
.catch(error => { error.value = error; });
};
</script>
<template>
<Base>
<div class="ml-auto mr-auto w-1/2 pt-5 pb-5">
<h4 class="text-center pt-5 pb-5 border-b border-zinc-500">Sign Up</h4>
<form @submit.prevent class="m-auto pt-5 pb-5">
<div class="mb-5 ml-auto mr-auto">
<label for="login" class="text-right w-64 inline-block mr-5">Login</label>
<input v-model="login" type="" placeholder="" name="login" required
class="w-1/2 bg-zinc-800 pl-3 pr-3 pt-2 pb-2 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div class="mb-5 ml-auto mr-auto">
<label for="email" class="text-right w-64 inline-block mr-5">Email Address</label>
<input v-model="email" type="email" placeholder="" name="email" required
class="w-1/2 bg-zinc-800 pl-3 pr-3 pt-2 pb-2 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div class="mb-5 ml-auto mr-auto">
<label for="password" class="text-right w-64 inline-block mr-5">Password</label>
<input v-model="password" placeholder="" type="password" name="password" required
class="w-1/2 bg-zinc-800 pl-3 pr-3 pt-2 pb-2 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div class="mb-5 ml-auto mr-auto">
<label class="text-right w-64 inline-block mr-5"></label>
<button @click="signup" class="rounded bg-zinc-500 hover:bg-zinc-400 pb-2 pt-2 pl-5 pr-5">Sign
Up</button>
</div>
</form>
<Error v-if="error">{{ error.message }}</Error>
</div>
</Base>
</template>

View File

@ -0,0 +1,92 @@
<script setup lang="ts">
import Base from "@/views/Base.vue";
import { ref, onMounted, watch, getCurrentInstance } from "vue";
import router from "@/router";
import { useUserStore, useMiscStore } from "@/stores";
const password = defineModel("password");
const new_password = defineModel("new-password");
const confirm_new_password = defineModel("confirm-new-password");
const email = defineModel("email");
const new_email = defineModel("new-email");
const confirm_password = defineModel("confirm-password");
const error = ref(null);
const userStore = useUserStore();
const miscStore = useMiscStore();
onMounted(async () => {
miscStore.p_current_tab = 1;
});
</script>
<template>
<div class="flex flex-col gap-4 ml-auto mr-auto w-full">
<div class="border rounded border-zinc-500 w-full flex-col bg-zinc-800 bg-opacity-95">
<h1 class="pl-5 pr-5 pt-2 pb-2">Password</h1>
<div class="border-t border-zinc-500 p-5">
<form @submit.prevent class="">
<div>
<label class="block mb-2" for="password">Current password</label>
<input v-model="password" name="password" type="password"
class="w-full bg-zinc-800 pl-3 pr-3 pt-2 pb-2 mb-4 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div>
<label class="block mb-2" for="new-password">New password</label>
<input v-model="new_password" name="new-password" type="password"
class="w-full bg-zinc-800 pl-3 pr-3 pt-2 pb-2 mb-4 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div>
<label class="block mb-2" for="confirm-new-password">Confirm new password</label>
<input v-model="confirm_new_password" name="confirm-new-password" type="password"
class="w-full bg-zinc-800 pl-3 pr-3 pt-2 pb-2 mb-4 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div class="border-t border-zinc-500 ml-0 mr-0 mt-3 mb-3"></div>
<button class="rounded bg-zinc-500 hover:bg-zinc-400 pb-2 pt-2 pl-5 pr-5 ml-auto mr-0 block">Update
password</button>
</form>
</div>
</div>
<div class="border rounded border-zinc-500 w-full flex-col bg-zinc-800 bg-opacity-95">
<h1 class="pl-5 pr-5 pt-2 pb-2">Email</h1>
<div class="border-t border-zinc-500 p-5">
<form @submit.prevent class="">
<div>
<label class="block mb-2" for="email">Email</label>
<strong class="block w-full mb-4">{{ email }}</strong>
</div>
<div>
<label class="block mb-2" for="new-email">New email</label>
<input v-model="new_email" name="new-email" type="email"
class="w-full bg-zinc-800 pl-3 pr-3 pt-2 pb-2 mb-4 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div class="border-t border-zinc-500 ml-0 mr-0 mt-3 mb-3"></div>
<button class="rounded bg-zinc-500 hover:bg-zinc-400 pb-2 pt-2 pl-5 pr-5 ml-auto mr-0 block">Update
email</button>
</form>
</div>
</div>
<div class="border rounded border-red-500 w-full flex-col bg-zinc-800 bg-opacity-95">
<h1 class="pl-5 pr-5 pt-2 pb-2">Delete account</h1>
<div class="border-t border-red-500 p-5">
<form @submit.prevent class="">
<div>
<label class="block mb-2" for="confirm-password">Password</label>
<input v-model="confirm_password" name="confirm-password" type="password"
class="w-full bg-zinc-800 pl-3 pr-3 pt-2 pb-2 mb-4 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div class="border-t border-zinc-500 ml-0 mr-0 mt-3 mb-3"></div>
<button
class="rounded bg-red-500 hover:bg-red-400 pb-2 pt-2 pl-5 pr-5 ml-auto mr-0 block">Confirm</button>
</form>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,96 @@
<script setup lang="ts">
import Base from "@/views/Base.vue";
import { ref, onMounted, watch, getCurrentInstance } from "vue";
import router from "@/router";
import { user } from "@/api";
import { useUserStore, useMiscStore } from "@/stores";
const error = ref(null);
const userStore = useUserStore();
const miscStore = useMiscStore();
const login = defineModel("login");
const name = defineModel("name");
const email = defineModel("email");
const image_file = ref(null);
const progress = ref(0);
const avatar_preview = ref(null);
onMounted(async () => {
miscStore.p_current_tab = 0;
login.value = userStore.current.login;
});
function uploadFile(event) {
image_file.value = event.target.files.item(0);
avatar_preview.value = URL.createObjectURL(image_file.value);
progress.value = 0;
}
async function submitFile() {
await user.avatar(image_file.value, (event) => {
progress.value = Math.round((100 * event.loaded) / event.total);
})
.catch(error => { error.value = error });
}
</script>
<template>
<div class="flex flex-col gap-4 ml-auto mr-auto w-full">
<div class="border rounded border-zinc-500 w-full flex-col">
<h1 class="pl-5 pr-5 pt-2 pb-2">Profile Info</h1>
<div class="border-t border-zinc-500 p-5">
<form @submit.prevent class="">
<div>
<label class="block mb-2" for="login">Login</label>
<input v-model="login" name="login"
class="w-full bg-zinc-800 pl-3 pr-3 pt-2 pb-2 mb-4 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div>
<label class="block mb-2" for="name">Username</label>
<input v-model="name" name="name"
class="w-full bg-zinc-800 pl-3 pr-3 pt-2 pb-2 mb-4 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div>
<label class="block mb-2 " for="email">Email</label>
<input v-model="email" email="email" disabled
class="w-full bg-zinc-800 pl-3 pr-3 pt-2 pb-2 mb-4 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div class="border-t border-zinc-500 ml-0 mr-0 mt-3 mb-3"></div>
<button
class="rounded bg-zinc-500 hover:bg-zinc-400 pb-2 pt-2 pl-5 pr-5 ml-auto mr-0 block">Update</button>
</form>
</div>
</div>
<div class="border rounded border-zinc-500 w-full flex-col">
<h1 class="pl-5 pr-5 pt-2 pb-2">User avatar</h1>
<div class="border-t border-zinc-500 p-5">
<form @submit.prevent class="" enctype="multipart/form-data">
<div>
<label class="block mb-2 " for="avatar">New avatar</label>
<input name="avatar" type="file" ref="file" accept="image/png,image/jpeg,image/jpg"
@change="uploadFile"
class="w-full bg-zinc-800 pl-3 pr-3 pt-2 pb-2 mb-4 outline-none rounded border border-zinc-500 hover:border-zinc-400 focus:border-green-800">
</div>
<div class="flex flex-row gap-8 items-center">
<div class="max-w-64"><img :src="avatar_preview"></div>
<div class="max-w-32"><img :src="avatar_preview"></div>
<div class="max-w-16"><img :src="avatar_preview"></div>
</div>
<div class="border-t border-zinc-500 ml-0 mr-0 mt-3 mb-3"></div>
<button @click="submitFile" :disabled="!image_file"
class="rounded bg-zinc-500 hover:bg-zinc-400 pb-2 pt-2 pl-5 pr-5 ml-auto mr-0 block">Update
avatar</button>
<p>{{ progress }}</p>
</form>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{vue,ts,js}"],
theme: {
extend: {
keyframes: {
"border-spin": {
"100%": {
transform: "rotate(-360deg)",
}
},
"border-roll": {
"100%": {
"background-position": "200% 0",
}
}
},
animation: {
"border-spin": "border-spin 7s linear infinite",
"border-roll": "border-roll 5s linear infinite"
}
},
},
plugins: [],
}

Some files were not shown because too many files have changed in this diff Show More