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:
parent
e67fcc2216
commit
997f37d5ee
11
.gitignore
vendored
11
.gitignore
vendored
@ -1,5 +1,12 @@
|
||||
/result*
|
||||
/repl-result*
|
||||
temp/
|
||||
|
||||
dist/
|
||||
/.venv
|
||||
__pycache__/
|
||||
/temp
|
||||
/dist
|
||||
*.egg-info
|
||||
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build
|
||||
|
1002
flake.lock
generated
1002
flake.lock
generated
File diff suppressed because it is too large
Load Diff
251
flake.nix
251
flake.nix
@ -1,74 +1,225 @@
|
||||
{
|
||||
description = "Materia is a file server";
|
||||
description = "Materia";
|
||||
|
||||
nixConfig = {
|
||||
extra-substituters = [ "https://bonfire.cachix.org" ];
|
||||
extra-trusted-public-keys = [ "bonfire.cachix.org-1:mzAGBy/Crdf8NhKail5ciK7ZrGRbPJJobW6TwFb7WYM=" ];
|
||||
};
|
||||
|
||||
inputs = {
|
||||
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
|
||||
];
|
||||
|
||||
};
|
||||
};
|
||||
meta = with nixpkgs.lib; {
|
||||
description = "Materia";
|
||||
license = licenses.mit;
|
||||
maintainers = with bonlib.maintainers; [ L-Nafaryus ];
|
||||
broken = false;
|
||||
mainProgram = "materia-server";
|
||||
};
|
||||
};
|
||||
|
||||
LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
|
||||
|
||||
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
162
materia-client/.gitignore
vendored
Normal 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
1
materia-client/README.md
Normal file
@ -0,0 +1 @@
|
||||
# materia-client
|
20
materia-client/pyproject.toml
Normal file
20
materia-client/pyproject.toml
Normal 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
|
13
materia-client/src/materia_client/main.py
Normal file
13
materia-client/src/materia_client/main.py
Normal file
@ -0,0 +1,13 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.group()
|
||||
def client():
|
||||
click.echo("Hola!")
|
||||
|
||||
@client.command()
|
||||
def test():
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
client()
|
0
materia-client/tests/__init__.py
Normal file
0
materia-client/tests/__init__.py
Normal file
162
materia-server/.gitignore
vendored
Normal file
162
materia-server/.gitignore
vendored
Normal 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
1
materia-server/README.md
Normal file
@ -0,0 +1 @@
|
||||
# materia-server
|
1771
materia-server/pdm.lock
generated
Normal file
1771
materia-server/pdm.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
76
materia-server/pyproject.toml
Normal file
76
materia-server/pyproject.toml
Normal 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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
|
83
materia-server/src/materia_server/_logging.py
Normal file
83
materia-server/src/materia_server/_logging.py
Normal 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},
|
||||
},
|
||||
}
|
@ -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
|
179
materia-server/src/materia_server/config.py
Normal file
179
materia-server/src/materia_server/config.py
Normal 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
|
||||
|
||||
|
187
materia-server/src/materia_server/main.py
Normal file
187
materia-server/src/materia_server/main.py
Normal 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()
|
||||
|
||||
|
||||
|
||||
|
9
materia-server/src/materia_server/models/__init__.py
Normal file
9
materia-server/src/materia_server/models/__init__.py
Normal 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
|
||||
|
||||
|
@ -0,0 +1,3 @@
|
||||
from materia_server.models.auth.source import LoginType, LoginSource
|
||||
from materia_server.models.auth.oauth2 import OAuth2Application, OAuth2Grant, OAuth2AuthorizationCode
|
||||
|
134
materia-server/src/materia_server/models/auth/oauth2.py
Normal file
134
materia-server/src/materia_server/models/auth/oauth2.py
Normal 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(" ")
|
||||
|
||||
|
||||
|
33
materia-server/src/materia_server/models/auth/source.py
Normal file
33
materia-server/src/materia_server/models/auth/source.py
Normal 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
|
@ -1,3 +1,4 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
@ -0,0 +1,2 @@
|
||||
from materia_server.models.database.database import Database
|
||||
from materia_server.models.database.cache import Cache
|
45
materia-server/src/materia_server/models/database/cache.py
Normal file
45
materia-server/src/materia_server/models/database/cache.py
Normal 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
|
||||
|
@ -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()
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
from materia_server.models.directory.directory import Directory, DirectoryLink
|
@ -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
|
@ -0,0 +1 @@
|
||||
from materia_server.models.file.file import File, FileLink
|
39
materia-server/src/materia_server/models/file/file.py
Normal file
39
materia-server/src/materia_server/models/file/file.py
Normal 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
|
@ -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.
|
@ -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 ###
|
@ -0,0 +1 @@
|
||||
from materia_server.models.repository.repository import Repository
|
@ -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
|
@ -0,0 +1 @@
|
||||
from materia_server.models.user.user import User, UserCredentials
|
84
materia-server/src/materia_server/models/user/user.py
Normal file
84
materia-server/src/materia_server/models/user/user.py
Normal 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
|
1
materia-server/src/materia_server/routers/__init__.py
Normal file
1
materia-server/src/materia_server/routers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from materia_server.routers import api
|
@ -0,0 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
from materia_server.routers.api import auth
|
||||
|
||||
router = APIRouter(prefix = "/api")
|
||||
|
||||
router.include_router(auth.router)
|
@ -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)
|
||||
|
83
materia-server/src/materia_server/routers/api/auth/auth.py
Normal file
83
materia-server/src/materia_server/routers/api/auth/auth.py
Normal 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)
|
83
materia-server/src/materia_server/routers/api/auth/oauth.py
Normal file
83
materia-server/src/materia_server/routers/api/auth/oauth.py
Normal 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
|
143
materia-server/src/materia_server/routers/api/auth/oauthb.py
Normal file
143
materia-server/src/materia_server/routers/api/auth/oauthb.py
Normal 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)
|
||||
|
74
materia-server/src/materia_server/routers/api/directory.py
Normal file
74
materia-server/src/materia_server/routers/api/directory.py
Normal 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")
|
51
materia-server/src/materia_server/routers/api/file.py
Normal file
51
materia-server/src/materia_server/routers/api/file.py
Normal 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()
|
@ -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),
|
60
materia-server/src/materia_server/routers/api/repository.py
Normal file
60
materia-server/src/materia_server/routers/api/repository.py
Normal 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")
|
@ -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
|
25
materia-server/src/materia_server/routers/api/schema/auth.py
Normal file
25
materia-server/src/materia_server/routers/api/schema/auth.py
Normal 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]
|
@ -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
|
@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RepositoryInfo(BaseModel):
|
||||
capacity: int
|
||||
used: int
|
@ -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
|
||||
|
||||
|
15
materia-server/src/materia_server/routers/context.py
Normal file
15
materia-server/src/materia_server/routers/context.py
Normal 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
|
||||
|
||||
|
@ -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
|
||||
|
3
materia-server/src/materia_server/security/__init__.py
Normal file
3
materia-server/src/materia_server/security/__init__.py
Normal 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
|
16
materia-server/src/materia_server/security/password.py
Normal file
16
materia-server/src/materia_server/security/password.py
Normal 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)
|
17
materia-server/src/materia_server/security/secret_key.py
Normal file
17
materia-server/src/materia_server/security/secret_key.py
Normal 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
|
||||
|
26
materia-server/src/materia_server/security/token.py
Normal file
26
materia-server/src/materia_server/security/token.py
Normal 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)
|
0
materia-server/tests/__init__.py
Normal file
0
materia-server/tests/__init__.py
Normal file
11
materia-web-client/.gitignore
vendored
Normal file
11
materia-web-client/.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
dist/
|
||||
/.venv
|
||||
__pycache__/
|
||||
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
*.mjs
|
0
materia-web-client/README.md
Normal file
0
materia-web-client/README.md
Normal file
246
materia-web-client/pdm.lock
generated
Normal file
246
materia-web-client/pdm.lock
generated
Normal 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"},
|
||||
]
|
44
materia-web-client/pyproject.toml
Normal file
44
materia-web-client/pyproject.toml
Normal 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"]
|
||||
|
13
materia-web-client/src/materia-frontend/index.html
Normal file
13
materia-web-client/src/materia-frontend/index.html
Normal 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>
|
3425
materia-web-client/src/materia-frontend/package-lock.json
generated
Normal file
3425
materia-web-client/src/materia-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
materia-web-client/src/materia-frontend/package.json
Normal file
33
materia-web-client/src/materia-frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
7
materia-web-client/src/materia-frontend/src/App.vue
Normal file
7
materia-web-client/src/materia-frontend/src/App.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
47
materia-web-client/src/materia-frontend/src/api/client.ts
Normal file
47
materia-web-client/src/materia-frontend/src/api/client.ts
Normal 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;
|
1
materia-web-client/src/materia-frontend/src/api/index.ts
Normal file
1
materia-web-client/src/materia-frontend/src/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * as user from "@/api/user";
|
82
materia-web-client/src/materia-frontend/src/api/user.ts
Normal file
82
materia-web-client/src/materia-frontend/src/api/user.ts
Normal 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);
|
||||
}
|
43
materia-web-client/src/materia-frontend/src/assets/style.css
Normal file
43
materia-web-client/src/materia-frontend/src/assets/style.css
Normal 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%];
|
||||
}
|
||||
}
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
15
materia-web-client/src/materia-frontend/src/main.ts
Normal file
15
materia-web-client/src/materia-frontend/src/main.ts
Normal 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');
|
||||
|
86
materia-web-client/src/materia-frontend/src/router.ts
Normal file
86
materia-web-client/src/materia-frontend/src/router.ts
Normal 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;
|
23
materia-web-client/src/materia-frontend/src/stores/index.ts
Normal file
23
materia-web-client/src/materia-frontend/src/stores/index.ts
Normal 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 };
|
||||
});
|
81
materia-web-client/src/materia-frontend/src/views/Base.vue
Normal file
81
materia-web-client/src/materia-frontend/src/views/Base.vue
Normal 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>
|
@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import Base from '@/views/Base.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Base>
|
||||
Home
|
||||
</Base>
|
||||
</template>
|
@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import Base from "@/views/Base.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Base>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</Base>
|
||||
</template>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
27
materia-web-client/src/materia-frontend/tailwind.config.js
Normal file
27
materia-web-client/src/materia-frontend/tailwind.config.js
Normal 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
Loading…
Reference in New Issue
Block a user