Compare commits
31 Commits
Author | SHA1 | Date | |
---|---|---|---|
8b1ffb202e | |||
dcdfcec05f | |||
1f2e1ec6e4 | |||
714a9d0879 | |||
8d03a3e3b0 | |||
adc4a59932 | |||
1b1142a0b0 | |||
3637ea99a8 | |||
b3be3d25ee | |||
680b0172f0 | |||
58e7175d45 | |||
aefedfe187 | |||
9986429bdf | |||
383d7c57ab | |||
69a1aa2471 | |||
727f1b51ee | |||
6ad7c29a48 | |||
d60ff09dad | |||
850bb89346 | |||
577f6f3ddf | |||
b89e8f3393 | |||
ec41110e0b | |||
aef6c2b541 | |||
4312d5b5d1 | |||
1877554bb2 | |||
f7bac07837 | |||
317085fc04 | |||
d8b19da646 | |||
997f37d5ee | |||
e67fcc2216 | |||
aa12f90f51 |
16
.gitignore
vendored
16
.gitignore
vendored
@ -1,4 +1,18 @@
|
|||||||
/result*
|
/result*
|
||||||
|
/repl-result*
|
||||||
|
temp/
|
||||||
|
|
||||||
|
dist/
|
||||||
/.venv
|
/.venv
|
||||||
__pycache__/
|
__pycache__/
|
||||||
/temp
|
*.egg-info
|
||||||
|
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build
|
||||||
|
|
||||||
|
.pytest_cache
|
||||||
|
.coverage
|
||||||
|
|
||||||
|
/site
|
||||||
|
src/materia/docs
|
||||||
|
31
README.md
31
README.md
@ -16,6 +16,37 @@ alembic upgrade head
|
|||||||
# Rollback the migration
|
# Rollback the migration
|
||||||
alembic downgrade head
|
alembic downgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Setup tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build .#postgresql-devel
|
||||||
|
podman load < result
|
||||||
|
podman run -p 54320:5432 --name database -dt postgresql:latest
|
||||||
|
nix build .#redis-devel
|
||||||
|
podman load < result
|
||||||
|
podman run -p 63790:63790 --name cache -dt redis:latest
|
||||||
|
nix develop
|
||||||
|
pdm install --dev
|
||||||
|
eval $(pdm venv activate)
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Side notes
|
||||||
|
|
||||||
|
```
|
||||||
|
/var
|
||||||
|
/lib
|
||||||
|
/materia <-- data directory
|
||||||
|
/repository <-- repository directory
|
||||||
|
/rick <-- user name
|
||||||
|
/default <--| default repository name
|
||||||
|
... | possible features: external cloud drives?
|
||||||
|
/first <-- first level directories counts as root because no parent
|
||||||
|
/nested
|
||||||
|
/hello.txt
|
||||||
|
```
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
**materia** is licensed under the [MIT License](LICENSE).
|
**materia** is licensed under the [MIT License](LICENSE).
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[alembic]
|
[alembic]
|
||||||
# path to migration scripts
|
# path to migration scripts
|
||||||
script_location = src/db/migrations
|
script_location = ./src/materia/models/migrations
|
||||||
|
|
||||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
# 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
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
@ -60,7 +60,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
|
|||||||
# are written from script.py.mako
|
# are written from script.py.mako
|
||||||
# output_encoding = utf-8
|
# output_encoding = utf-8
|
||||||
|
|
||||||
#sqlalchemy.url = driver://user:pass@localhost/dbname
|
sqlalchemy.url = postgresql+asyncpg://materia:materia@127.0.0.1:54320/materia
|
||||||
|
|
||||||
|
|
||||||
[post_write_hooks]
|
[post_write_hooks]
|
||||||
@ -91,7 +91,7 @@ keys = console
|
|||||||
keys = generic
|
keys = generic
|
||||||
|
|
||||||
[logger_root]
|
[logger_root]
|
||||||
level = WARN
|
level = INFO
|
||||||
handlers = console
|
handlers = console
|
||||||
qualname =
|
qualname =
|
||||||
|
|
||||||
|
52
docs/api.md
Normal file
52
docs/api.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
hide:
|
||||||
|
- navigation
|
||||||
|
- toc
|
||||||
|
---
|
||||||
|
<style>
|
||||||
|
.md-typeset h1,
|
||||||
|
.md-content__button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.md-main__inner {
|
||||||
|
max-width: 100%; /* or 100%, if you want to stretch to full-width */
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.md-content__inner {
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.md-content__inner > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.md-content__inner::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.md-footer__inner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.md-footer__inner:not([hidden]) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<rapi-doc
|
||||||
|
spec-url="/api/openapi.json"
|
||||||
|
theme = "dark"
|
||||||
|
show-header = "false"
|
||||||
|
show-info = "false"
|
||||||
|
allow-authentication = "true"
|
||||||
|
allow-server-selection = "true"
|
||||||
|
allow-api-list-style-selection = "true"
|
||||||
|
theme = "dark"
|
||||||
|
render-style = "focused"
|
||||||
|
bg-color="#1e2129"
|
||||||
|
primary-color="#a47bea"
|
||||||
|
regular-font="Roboto"
|
||||||
|
mono-font="Roboto Mono"
|
||||||
|
show-method-in-nav-bar="as-colored-text">
|
||||||
|
<img slot="logo" style="display: none"/>
|
||||||
|
</rapi-doc>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"
|
||||||
|
></script>
|
BIN
docs/img/favicon.png
Normal file
BIN
docs/img/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 129 KiB |
310
docs/img/logo-black.svg
Normal file
310
docs/img/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 78 KiB |
BIN
docs/img/logo-full.png
Normal file
BIN
docs/img/logo-full.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 81 KiB |
310
docs/img/logo-white.svg
Normal file
310
docs/img/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 78 KiB |
BIN
docs/img/logo.png
Normal file
BIN
docs/img/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 130 KiB |
528
docs/img/logo.svg
Normal file
528
docs/img/logo.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 122 KiB |
12
docs/index.md
Normal file
12
docs/index.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Materia
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.md-content .md-typeset h1 { display: none; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://materia.elnafo.ru"><img src="img/logo-full.png" alt="Materia"></a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<em>Materia is easy and fast cloud storage</em>
|
||||||
|
</p>
|
1
docs/reference/app.md
Normal file
1
docs/reference/app.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
::: materia.app
|
1
docs/reference/core.md
Normal file
1
docs/reference/core.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
::: materia.core
|
5
docs/reference/index.md
Normal file
5
docs/reference/index.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Reference
|
||||||
|
|
||||||
|
Here's the reference or code API, the classes, functions, parameters, attributes, and
|
||||||
|
all the Materia parts.
|
||||||
|
|
1
docs/reference/models.md
Normal file
1
docs/reference/models.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
::: materia.models
|
1
docs/reference/routers.md
Normal file
1
docs/reference/routers.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
::: materia.routers
|
1
docs/reference/security.md
Normal file
1
docs/reference/security.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
::: materia.security
|
1
docs/reference/tasks.md
Normal file
1
docs/reference/tasks.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
::: materia.tasks
|
500
flake.lock
500
flake.lock
@ -1,51 +1,243 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-utils": {
|
"ags": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"bonfire",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1710146030,
|
"lastModified": 1721306136,
|
||||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
"narHash": "sha256-VKPsIGf3/a+RONBipx4lEE4LXG2sdMNkWQu22LNQItg=",
|
||||||
"owner": "numtide",
|
"owner": "Aylur",
|
||||||
"repo": "flake-utils",
|
"repo": "ags",
|
||||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
"rev": "344ea72cd3b8d4911f362fec34bce7d8fb37028c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "numtide",
|
"owner": "Aylur",
|
||||||
"repo": "flake-utils",
|
"repo": "ags",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nix-github-actions": {
|
"blobs": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1604995301,
|
||||||
|
"narHash": "sha256-wcLzgLec6SGJA8fx1OEN1yV/Py5b+U5iyYpksUY/yLw=",
|
||||||
|
"owner": "simple-nixos-mailserver",
|
||||||
|
"repo": "blobs",
|
||||||
|
"rev": "2cccdf1ca48316f2cfd1c9a0017e8de5a7156265",
|
||||||
|
"type": "gitlab"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "simple-nixos-mailserver",
|
||||||
|
"repo": "blobs",
|
||||||
|
"type": "gitlab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bonfire": {
|
||||||
|
"inputs": {
|
||||||
|
"ags": "ags",
|
||||||
|
"catppuccin": "catppuccin",
|
||||||
|
"crane": "crane",
|
||||||
|
"fenix": "fenix",
|
||||||
|
"home-manager": "home-manager",
|
||||||
|
"nixos-mailserver": "nixos-mailserver",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"nixvim": "nixvim",
|
||||||
|
"obs-image-reaction": "obs-image-reaction",
|
||||||
|
"oscuro": "oscuro",
|
||||||
|
"sops-nix": "sops-nix"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1721891744,
|
||||||
|
"narHash": "sha256-1ZYNhS1WWcd6Md5kPlX7iuUKKGs8CSxo4QMmw/grnRA=",
|
||||||
|
"owner": "L-Nafaryus",
|
||||||
|
"repo": "bonfire",
|
||||||
|
"rev": "79340a0b933ff2b96070f1aadaf6dd70f867e75f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "L-Nafaryus",
|
||||||
|
"repo": "bonfire",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"catppuccin": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1720472194,
|
||||||
|
"narHash": "sha256-CYscFEts6tyvosc1T29nxhzIYJAj/1CCEkV3ZMzSN/c=",
|
||||||
|
"owner": "catppuccin",
|
||||||
|
"repo": "nix",
|
||||||
|
"rev": "d75d5803852fb0833767dc969a4581ac13204e22",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "catppuccin",
|
||||||
|
"repo": "nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crane": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"poetry2nix",
|
"bonfire",
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1703863825,
|
"lastModified": 1721322122,
|
||||||
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
|
"narHash": "sha256-a0G1NvyXGzdwgu6e1HQpmK5R5yLsfxeBe07nNDyYd+g=",
|
||||||
|
"owner": "ipetkov",
|
||||||
|
"repo": "crane",
|
||||||
|
"rev": "8a68b987c476a33e90f203f0927614a75c3f47ea",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "ipetkov",
|
||||||
|
"repo": "crane",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dream2nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"purescript-overlay": "purescript-overlay",
|
||||||
|
"pyproject-nix": "pyproject-nix"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1722526955,
|
||||||
|
"narHash": "sha256-fFS8aDnfK9Qfm2FLnQ8pqWk8FzvFEv5LvTuZTZLREnc=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nix-github-actions",
|
"repo": "dream2nix",
|
||||||
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
|
"rev": "3fd4c14d3683baac8d1f94286ae14fe160888b51",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nix-github-actions",
|
"repo": "dream2nix",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fenix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"bonfire",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"rust-analyzer-src": [
|
||||||
|
"bonfire"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1721629802,
|
||||||
|
"narHash": "sha256-GKlvM9M0mkKJrL6N1eMG4DrROO25Ds1apFw3/b8594w=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "fenix",
|
||||||
|
"rev": "1270fb024c6987dd825a20cd27319384a8d8569e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "fenix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1696426674,
|
||||||
|
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": [
|
||||||
|
"bonfire",
|
||||||
|
"nixvim",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1719994518,
|
||||||
|
"narHash": "sha256-pQMhCCHyQGRzdfAkdJ4cIWiw+JNuWsTX7f0ZYSyz0VY=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "9227223f6d922fee3c7b190b2cc238a99527bbb7",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home-manager": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"bonfire",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1721534365,
|
||||||
|
"narHash": "sha256-XpZOkaSJKdOsz1wU6JfO59Rx2fqtcarQ0y6ndIOKNpI=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"rev": "635563f245309ef5320f80c7ebcb89b2398d2949",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixos-mailserver": {
|
||||||
|
"inputs": {
|
||||||
|
"blobs": "blobs",
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"nixpkgs": [
|
||||||
|
"bonfire",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"nixpkgs-24_05": "nixpkgs-24_05"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1721121314,
|
||||||
|
"narHash": "sha256-zwc7YXga/1ppaZMWFreZykXtFwBgXodxUZiUx969r+g=",
|
||||||
|
"owner": "simple-nixos-mailserver",
|
||||||
|
"repo": "nixos-mailserver",
|
||||||
|
"rev": "059b50b2e729729ea00c6831124d3837c494f3d5",
|
||||||
|
"type": "gitlab"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "simple-nixos-mailserver",
|
||||||
|
"repo": "nixos-mailserver",
|
||||||
|
"type": "gitlab"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1714906307,
|
"lastModified": 1721379653,
|
||||||
"narHash": "sha256-UlRZtrCnhPFSJlDQE7M0eyhgvuuHBTe1eJ9N9AQlJQ0=",
|
"narHash": "sha256-8MUgifkJ7lkZs3u99UDZMB4kbOxvMEXQZ31FO3SopZ0=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "25865a40d14b3f9cf19f19b924e2ab4069b09588",
|
"rev": "1d9c2c9b3e71b9ee663d11c5d298727dace8d374",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -55,83 +247,255 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"poetry2nix": {
|
"nixpkgs-24_05": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1717144377,
|
||||||
|
"narHash": "sha256-F/TKWETwB5RaR8owkPPi+SPJh83AQsm6KrQAlJ8v/uA=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "805a384895c696f802a9bf5bf4720f37385df547",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"id": "nixpkgs",
|
||||||
|
"ref": "nixos-24.05",
|
||||||
|
"type": "indirect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-stable": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1721524707,
|
||||||
|
"narHash": "sha256-5NctRsoE54N86nWd0psae70YSLfrOek3Kv1e8KoXe/0=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "556533a23879fc7e5f98dd2e0b31a6911a213171",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "release-24.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1719223410,
|
||||||
|
"narHash": "sha256-jtIo8xR0Zp4SalIwmD+OdCwHF4l7OU6PD63UUK4ckt4=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "efb39c6052f3ce51587cf19733f5f4e5d515aa13",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_3": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1722421184,
|
||||||
|
"narHash": "sha256-/DJBI6trCeVnasdjUo9pbnodCLZcFqnVZiLUfqLH4jA=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9f918d616c5321ad374ae6cb5ea89c9e04bf3e58",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixvim": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"devshell": [
|
||||||
"nix-github-actions": "nix-github-actions",
|
"bonfire"
|
||||||
|
],
|
||||||
|
"flake-compat": [
|
||||||
|
"bonfire"
|
||||||
|
],
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"git-hooks": [
|
||||||
|
"bonfire"
|
||||||
|
],
|
||||||
|
"home-manager": [
|
||||||
|
"bonfire"
|
||||||
|
],
|
||||||
|
"nix-darwin": [
|
||||||
|
"bonfire"
|
||||||
|
],
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
|
"bonfire",
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
],
|
],
|
||||||
"systems": "systems_2",
|
"treefmt-nix": [
|
||||||
"treefmt-nix": "treefmt-nix"
|
"bonfire"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1715017507,
|
"lastModified": 1721772245,
|
||||||
"narHash": "sha256-RN2Vsba56PfX02DunWcZYkMLsipp928h+LVAWMYmbZg=",
|
"narHash": "sha256-//9p3Qm8gLbPUTsSGN2EMYkDwE5Sqq9B9P2X/z2+npw=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "poetry2nix",
|
"repo": "nixvim",
|
||||||
"rev": "e6b36523407ae6a7a4dfe29770c30b3a3563b43a",
|
"rev": "ab67ee7e8b33e788fc53d26dc6f423f9358e3e66",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "poetry2nix",
|
"repo": "nixvim",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"obs-image-reaction": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1719314544,
|
||||||
|
"narHash": "sha256-GZa3+2OELKp/9b2+EwwzaIMNvR9niCy/YZ5OERhG9Hg=",
|
||||||
|
"owner": "L-Nafaryus",
|
||||||
|
"repo": "obs-image-reaction",
|
||||||
|
"rev": "0dcb3c27de5782dfdf95cb047ccceb3e65360e6b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "L-Nafaryus",
|
||||||
|
"repo": "obs-image-reaction",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oscuro": {
|
||||||
|
"inputs": {
|
||||||
|
"bonfire": [
|
||||||
|
"bonfire"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"bonfire",
|
||||||
|
"oscuro",
|
||||||
|
"bonfire",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1714759244,
|
||||||
|
"narHash": "sha256-ZDH7WTsILPEIZuo3/C4QwOXTv7r1xoUxKOQSDFpdNEE=",
|
||||||
|
"owner": "L-Nafaryus",
|
||||||
|
"repo": "oscuro",
|
||||||
|
"rev": "68da7759c61b6d34f54087e3e845d8cc70702310",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "L-Nafaryus",
|
||||||
|
"repo": "oscuro",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"purescript-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"dream2nix",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"slimlock": "slimlock"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1696022621,
|
||||||
|
"narHash": "sha256-eMjFmsj2G1E0Q5XiibUNgFjTiSz0GxIeSSzzVdoN730=",
|
||||||
|
"owner": "thomashoneyman",
|
||||||
|
"repo": "purescript-overlay",
|
||||||
|
"rev": "047c7933abd6da8aa239904422e22d190ce55ead",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "thomashoneyman",
|
||||||
|
"repo": "purescript-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pyproject-nix": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1702448246,
|
||||||
|
"narHash": "sha256-hFg5s/hoJFv7tDpiGvEvXP0UfFvFEDgTdyHIjDVHu1I=",
|
||||||
|
"owner": "davhau",
|
||||||
|
"repo": "pyproject.nix",
|
||||||
|
"rev": "5a06a2697b228c04dd2f35659b4b659ca74f7aeb",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "davhau",
|
||||||
|
"ref": "dream2nix",
|
||||||
|
"repo": "pyproject.nix",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs",
|
"bonfire": "bonfire",
|
||||||
"poetry2nix": "poetry2nix"
|
"dream2nix": "dream2nix",
|
||||||
|
"nixpkgs": "nixpkgs_3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"slimlock": {
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"id": "systems",
|
|
||||||
"type": "indirect"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"treefmt-nix": {
|
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"poetry2nix",
|
"dream2nix",
|
||||||
|
"purescript-overlay",
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1714058656,
|
"lastModified": 1688610262,
|
||||||
"narHash": "sha256-Qv4RBm4LKuO4fNOfx9wl40W2rBbv5u5m+whxRYUMiaA=",
|
"narHash": "sha256-Wg0ViDotFWGWqKIQzyYCgayeH8s4U1OZcTiWTQYdAp4=",
|
||||||
"owner": "numtide",
|
"owner": "thomashoneyman",
|
||||||
"repo": "treefmt-nix",
|
"repo": "slimlock",
|
||||||
"rev": "c6aaf729f34a36c445618580a9f95a48f5e4e03f",
|
"rev": "b5c6cdcaf636ebbebd0a1f32520929394493f1a6",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "numtide",
|
"owner": "thomashoneyman",
|
||||||
"repo": "treefmt-nix",
|
"repo": "slimlock",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sops-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"bonfire",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"nixpkgs-stable": "nixpkgs-stable"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1721531171,
|
||||||
|
"narHash": "sha256-AsvPw7T0tBLb53xZGcUC3YPqlIpdxoSx56u8vPCr6gU=",
|
||||||
|
"owner": "Mic92",
|
||||||
|
"repo": "sops-nix",
|
||||||
|
"rev": "909e8cfb60d83321d85c8d17209d733658a21c95",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "Mic92",
|
||||||
|
"repo": "sops-nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1689347949,
|
||||||
|
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default-linux",
|
||||||
|
"rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default-linux",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
281
flake.nix
281
flake.nix
@ -1,81 +1,218 @@
|
|||||||
{
|
{
|
||||||
description = "Materia is a file server";
|
description = "Materia";
|
||||||
|
|
||||||
nixConfig = {
|
inputs = {
|
||||||
extra-substituters = [ "https://bonfire.cachix.org" ];
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
extra-trusted-public-keys = [ "bonfire.cachix.org-1:mzAGBy/Crdf8NhKail5ciK7ZrGRbPJJobW6TwFb7WYM=" ];
|
dream2nix = {
|
||||||
|
url = "github:nix-community/dream2nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
bonfire.url = "github:L-Nafaryus/bonfire";
|
||||||
|
};
|
||||||
|
|
||||||
inputs = {
|
outputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
self,
|
||||||
poetry2nix = {
|
nixpkgs,
|
||||||
url = "github:nix-community/poetry2nix";
|
dream2nix,
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
bonfire,
|
||||||
|
...
|
||||||
|
}: let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = import nixpkgs {inherit 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.x86_64-linux = {
|
||||||
|
materia-frontend-nodejs = dreamBuildPackage {
|
||||||
|
module = {
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
dream2nix,
|
||||||
|
...
|
||||||
|
}: {
|
||||||
|
name = "materia-frontend";
|
||||||
|
version = "0.0.5";
|
||||||
|
|
||||||
|
imports = [
|
||||||
|
dream2nix.modules.dream2nix.WIP-nodejs-builder-v3
|
||||||
|
];
|
||||||
|
|
||||||
|
mkDerivation = {
|
||||||
|
src = ./workspaces/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 (nodejs)";
|
||||||
|
license = licenses.mit;
|
||||||
|
maintainers = with bonLib.maintainers; [L-Nafaryus];
|
||||||
|
broken = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
materia-frontend = dreamBuildPackage {
|
||||||
|
extraArgs = {
|
||||||
|
inherit (self.packages.x86_64-linux) materia-frontend-nodejs;
|
||||||
|
};
|
||||||
|
module = {
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
dream2nix,
|
||||||
|
materia-frontend-nodejs,
|
||||||
|
...
|
||||||
|
}: {
|
||||||
|
imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
|
||||||
|
|
||||||
|
pdm.lockfile = ./workspaces/frontend/pdm.lock;
|
||||||
|
pdm.pyproject = ./workspaces/frontend/pyproject.toml;
|
||||||
|
|
||||||
|
deps = _: {
|
||||||
|
python = pkgs.python312;
|
||||||
|
};
|
||||||
|
|
||||||
|
mkDerivation = {
|
||||||
|
src = ./workspaces/frontend;
|
||||||
|
buildInputs = [
|
||||||
|
pkgs.python312.pkgs.pdm-backend
|
||||||
|
];
|
||||||
|
configurePhase = ''
|
||||||
|
cp -rv ${materia-frontend-nodejs}/dist ./src/materia_frontend/
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
meta = with nixpkgs.lib; {
|
||||||
|
description = "Materia frontend";
|
||||||
|
license = licenses.mit;
|
||||||
|
maintainers = with bonLib.maintainers; [L-Nafaryus];
|
||||||
|
broken = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
materia = dreamBuildPackage {
|
||||||
|
extraArgs = {
|
||||||
|
inherit (self.packages.x86_64-linux) materia-frontend;
|
||||||
|
};
|
||||||
|
module = {
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
dream2nix,
|
||||||
|
materia-frontend,
|
||||||
|
...
|
||||||
|
}: {
|
||||||
|
imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
|
||||||
|
|
||||||
|
pdm.lockfile = ./pdm.lock;
|
||||||
|
pdm.pyproject = ./pyproject.toml;
|
||||||
|
|
||||||
|
deps = _: {
|
||||||
|
python = pkgs.python312;
|
||||||
|
};
|
||||||
|
|
||||||
|
mkDerivation = {
|
||||||
|
src = ./.;
|
||||||
|
buildInputs = [
|
||||||
|
pkgs.python312.pkgs.pdm-backend
|
||||||
|
];
|
||||||
|
nativeBuildInputs = [
|
||||||
|
pkgs.python312.pkgs.wrapPython
|
||||||
|
];
|
||||||
|
propagatedBuildInputs = [
|
||||||
|
materia-frontend
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
meta = with nixpkgs.lib; {
|
||||||
|
description = "Materia";
|
||||||
|
license = licenses.mit;
|
||||||
|
maintainers = with bonLib.maintainers; [L-Nafaryus];
|
||||||
|
broken = false;
|
||||||
|
mainProgram = "materia";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
postgresql-devel = bonfire.packages.x86_64-linux.postgresql;
|
||||||
|
|
||||||
|
redis-devel = bonfire.packages.x86_64-linux.redis;
|
||||||
|
|
||||||
|
materia-devel = let
|
||||||
|
user = "materia";
|
||||||
|
dataDir = "/var/lib/materia";
|
||||||
|
entryPoint = pkgs.writeTextDir "entrypoint.sh" ''
|
||||||
|
materia start
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
pkgs.dockerTools.buildImage {
|
||||||
|
name = "materia";
|
||||||
|
tag = "latest";
|
||||||
|
|
||||||
|
copyToRoot = pkgs.buildEnv {
|
||||||
|
name = "image-root";
|
||||||
|
pathsToLink = ["/bin" "/etc" "/"];
|
||||||
|
paths = with pkgs; [
|
||||||
|
bash
|
||||||
|
self.packages.x86_64-linux.materia
|
||||||
|
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 = {
|
||||||
|
"54601/tcp" = {};
|
||||||
|
};
|
||||||
|
Env = [
|
||||||
|
"MATERIA_APPLICATION__WORKING_DIRECTORY=${dataDir}"
|
||||||
|
];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, poetry2nix, ... }:
|
devShells.x86_64-linux.default = pkgs.mkShell {
|
||||||
let
|
buildInputs = with pkgs; [postgresql redis pdm nodejs python312];
|
||||||
#perSystem = systems: builtins.mapAttrs (name: value: nixpkgs.lib.genAttrs systems (system: value) );
|
# greenlet requires libstdc++
|
||||||
forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" ];
|
LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [pkgs.stdenv.cc.cc];
|
||||||
nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
|
|
||||||
in
|
|
||||||
{
|
|
||||||
|
|
||||||
packages = forAllSystems (system: let
|
|
||||||
pkgs = nixpkgsFor.${system};
|
|
||||||
#inherit (poetry2nix.lib.mkPoetry2Nix { inherit pkgs; }) mkPoetryApplication;
|
|
||||||
in {
|
|
||||||
#materia = mkPoetryApplication {
|
|
||||||
# projectDir = ./.;
|
|
||||||
#};
|
|
||||||
|
|
||||||
#default = self.packages.${system}.materia;
|
|
||||||
});
|
|
||||||
|
|
||||||
apps = forAllSystems (system: {
|
|
||||||
materia = let
|
|
||||||
pkgs = nixpkgsFor.${system};
|
|
||||||
app = (poetry2nix.lib.mkPoetry2Nix { inherit pkgs; }).mkPoetryApplication { projectDir = self; };
|
|
||||||
in {
|
|
||||||
type = "app";
|
|
||||||
program = "${app}/bin/materia";
|
|
||||||
};
|
|
||||||
|
|
||||||
#default = materia;
|
|
||||||
});
|
|
||||||
|
|
||||||
devShells = forAllSystems (system:
|
|
||||||
let
|
|
||||||
pkgs = nixpkgsFor.${system};
|
|
||||||
db_name = "materia";
|
|
||||||
db_user = "materia";
|
|
||||||
db_password = "test";
|
|
||||||
db_path = "temp/materia";
|
|
||||||
in {
|
|
||||||
default = pkgs.mkShell {
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
nil
|
|
||||||
nodejs
|
|
||||||
ripgrep
|
|
||||||
|
|
||||||
postgresql
|
|
||||||
|
|
||||||
poetry
|
|
||||||
];
|
|
||||||
|
|
||||||
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}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
89
mkdocs.yml
Normal file
89
mkdocs.yml
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
site_name: Materia Documentation
|
||||||
|
site_description: Materia cloud storage
|
||||||
|
#site_url:
|
||||||
|
repo_name: L-Nafaryus/materia
|
||||||
|
repo_url: https://vcs.elnafo.ru/L-Nafaryus/materia
|
||||||
|
copyright: Copyright © 2024 L-Nafaryus
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
features:
|
||||||
|
- content.code.annotate
|
||||||
|
- content.code.copy
|
||||||
|
# - content.code.select
|
||||||
|
- content.footnote.tooltips
|
||||||
|
- content.tabs.link
|
||||||
|
- content.tooltips
|
||||||
|
- navigation.footer
|
||||||
|
- navigation.indexes
|
||||||
|
- navigation.instant
|
||||||
|
- navigation.instant.prefetch
|
||||||
|
# - navigation.instant.preview
|
||||||
|
- navigation.instant.progress
|
||||||
|
- navigation.path
|
||||||
|
- navigation.tabs
|
||||||
|
- navigation.tabs.sticky
|
||||||
|
- navigation.top
|
||||||
|
- navigation.tracking
|
||||||
|
- search.highlight
|
||||||
|
- search.share
|
||||||
|
- search.suggest
|
||||||
|
- toc.follow
|
||||||
|
logo: img/favicon.png
|
||||||
|
favicon: img/favicon.png
|
||||||
|
palette:
|
||||||
|
- media: "(prefers-color-scheme: light)"
|
||||||
|
scheme: slate
|
||||||
|
primary: deep purple
|
||||||
|
accent: deep purple
|
||||||
|
toggle:
|
||||||
|
icon: material/weather-sunny
|
||||||
|
name: Switch to light mode
|
||||||
|
- media: "(prefers-color-scheme: dark)"
|
||||||
|
scheme: default
|
||||||
|
primary: deep purple
|
||||||
|
accent: deep purple
|
||||||
|
toggle:
|
||||||
|
icon: material/weather-night
|
||||||
|
name: Switch to dark mode
|
||||||
|
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- search:
|
||||||
|
- mkdocstrings:
|
||||||
|
handlers:
|
||||||
|
python:
|
||||||
|
paths: [src] # search packages in the src folder
|
||||||
|
options:
|
||||||
|
extensions:
|
||||||
|
- griffe_typingdoc
|
||||||
|
#preload_modules:
|
||||||
|
#- sqlalchemy
|
||||||
|
#docstring_style: sphinx
|
||||||
|
show_submodules: true
|
||||||
|
show_source: true
|
||||||
|
show_if_no_docstring: true
|
||||||
|
show_symbol_type_heading: true
|
||||||
|
show_symbol_type_toc: true
|
||||||
|
show_root_heading: true
|
||||||
|
unwrap_annotated: true
|
||||||
|
merge_init_into_class: true
|
||||||
|
docstring_section_style: spacy
|
||||||
|
signature_crossrefs: true
|
||||||
|
inherited_members: true
|
||||||
|
members_order: source
|
||||||
|
separate_signature: true
|
||||||
|
filters:
|
||||||
|
- '!^_'
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- Materia: index.md
|
||||||
|
- Reference:
|
||||||
|
- reference/index.md
|
||||||
|
- reference/app.md
|
||||||
|
- reference/core.md
|
||||||
|
- reference/models.md
|
||||||
|
- reference/routers.md
|
||||||
|
- reference/security.md
|
||||||
|
- reference/tasks.md
|
||||||
|
- API: api.md
|
1654
poetry.lock
generated
1654
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,3 +0,0 @@
|
|||||||
[virtualenvs]
|
|
||||||
in-project = true
|
|
||||||
create = true
|
|
104
pyproject.toml
104
pyproject.toml
@ -1,38 +1,54 @@
|
|||||||
[tool.poetry]
|
[project]
|
||||||
name = "materia-backend"
|
name = "materia"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "Materia is a file server"
|
description = "Materia is a file server"
|
||||||
authors = [
|
authors = [
|
||||||
"L-Nafaryus <l.nafaryus@gmail.com>"
|
{name = "L-Nafaryus", email = "l.nafaryus@gmail.com"},
|
||||||
]
|
]
|
||||||
maintainers = [
|
dependencies = [
|
||||||
"L-Nafaryus <l.nafaryus@gmail.com>"
|
"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",
|
||||||
|
"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",
|
||||||
|
"gunicorn>=22.0.0",
|
||||||
|
"uvicorn-worker>=0.2.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"cryptography>=43.0.0",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
|
"jinja2>=3.1.4",
|
||||||
|
"aiofiles>=24.1.0",
|
||||||
|
"aioshutil>=1.5",
|
||||||
|
"Celery>=5.4.0",
|
||||||
|
"streaming-form-data>=1.16.0",
|
||||||
]
|
]
|
||||||
license = "MIT"
|
requires-python = ">=3.12,<3.13"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
packages = [
|
license = {text = "MIT"}
|
||||||
{ include = "src" }
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[build-system]
|
||||||
materia = "src.main:main"
|
requires = ["pdm-backend"]
|
||||||
|
build-backend = "pdm.backend"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[project.scripts]
|
||||||
python = ">=3.10,<3.12"
|
materia = "materia.app.cli:cli"
|
||||||
fastapi = "^0.111.0"
|
|
||||||
uvicorn = {version = "^0.29.0", extras = ["standard"]}
|
|
||||||
psycopg2-binary = "^2.9.9"
|
|
||||||
toml = "^0.10.2"
|
|
||||||
sqlalchemy = {version = "^2.0.30", extras = ["asyncio"]}
|
|
||||||
asyncpg = "^0.29.0"
|
|
||||||
eventlet = "^0.36.1"
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
|
||||||
black = "^23.3.0"
|
|
||||||
pytest = "^7.3.2"
|
|
||||||
pyflakes = "^3.0.1"
|
|
||||||
pyright = "^1.1.314"
|
|
||||||
alembic = "^1.13.1"
|
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
reportGeneralTypeIssues = false
|
reportGeneralTypeIssues = false
|
||||||
@ -41,6 +57,32 @@ reportGeneralTypeIssues = false
|
|||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["poetry-core"]
|
[tool.pdm]
|
||||||
build-backend = "poetry.core.masonry.api"
|
distribution = true
|
||||||
|
[tool.pdm.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
"-e file:///${PROJECT_ROOT}/workspaces/frontend",
|
||||||
|
"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",
|
||||||
|
"pytest-asyncio>=0.23.7",
|
||||||
|
"asgi-lifespan>=2.1.0",
|
||||||
|
"pytest-cov>=5.0.0",
|
||||||
|
"mkdocs-material>=9.5.34",
|
||||||
|
"mkdocstrings-python>=1.10.9",
|
||||||
|
"griffe-typingdoc>=0.2.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pdm.build]
|
||||||
|
includes = ["src/materia"]
|
||||||
|
|
||||||
|
[tool.pdm.scripts]
|
||||||
|
start.cmd = "python ./src/materia/main.py {args:start --app-mode development --log-level debug}"
|
||||||
|
setup.cmd = "psql -U postgres -h 127.0.0.1 -p 54320 -d postgres -c 'create role materia login;' -c 'create database materia owner materia;'"
|
||||||
|
teardown.cmd = "psql -U postgres -h 127.0.0.1 -p 54320 -d postgres -c 'drop database materia;' -c 'drop role materia;'"
|
||||||
|
rev.cmd = "alembic revision {args:--autogenerate}"
|
||||||
|
upgrade.cmd = "alembic upgrade {args:head}"
|
||||||
|
downgrade.cmd = "alembic downgrade {args:base}"
|
||||||
|
remove-revs.shell = "rm -v ./src/materia/models/migrations/versions/*.py"
|
||||||
|
@ -1,84 +0,0 @@
|
|||||||
from os import environ
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Self
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
import toml
|
|
||||||
|
|
||||||
class Database(BaseModel):
|
|
||||||
host: str
|
|
||||||
port: int
|
|
||||||
user: str
|
|
||||||
password: str
|
|
||||||
name: str
|
|
||||||
|
|
||||||
class Server(BaseModel):
|
|
||||||
address: str
|
|
||||||
port: int
|
|
||||||
|
|
||||||
class Jwt(BaseModel):
|
|
||||||
secret: str
|
|
||||||
expires_in: str
|
|
||||||
maxage: int
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
|
||||||
database: Database
|
|
||||||
server: Server
|
|
||||||
jwt: Jwt
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def default() -> Self:
|
|
||||||
return Config(**{
|
|
||||||
"database": Database(**{
|
|
||||||
"host": "localhost",
|
|
||||||
"port": 5432,
|
|
||||||
"user": "materia",
|
|
||||||
"password": "test",
|
|
||||||
"name": "materia"
|
|
||||||
}),
|
|
||||||
"server": Server(**{
|
|
||||||
"address": "127.0.0.1",
|
|
||||||
"port": 54601
|
|
||||||
}),
|
|
||||||
"jwt": Jwt(**{
|
|
||||||
"secret": "change_this_secret",
|
|
||||||
"expires_in": "60m",
|
|
||||||
"maxage": 3600
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
def database_url(self) -> str:
|
|
||||||
return "postgresql+asyncpg://{}:{}@{}:{}/{}".format(
|
|
||||||
self.database.user,
|
|
||||||
self.database.password,
|
|
||||||
self.database.host,
|
|
||||||
self.database.port,
|
|
||||||
self.database.name
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def open(path: Path) -> Self | None:
|
|
||||||
try:
|
|
||||||
data: dict = toml.load(path)
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return Config(**data)
|
|
||||||
|
|
||||||
def write(self, path: Path):
|
|
||||||
with open(path, "w") as file:
|
|
||||||
toml.dump(self.model_dump(), file)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def data_dir() -> Path:
|
|
||||||
cwd = Path.cwd()
|
|
||||||
if environ.get("MATERIA_DEBUG"):
|
|
||||||
return cwd / "temp"
|
|
||||||
else:
|
|
||||||
return cwd
|
|
||||||
|
|
||||||
|
|
||||||
# initialize config
|
|
||||||
config = Config.open(Config.data_dir().joinpath("config.toml"))
|
|
||||||
if not config:
|
|
||||||
config = Config.default()
|
|
@ -1,87 +0,0 @@
|
|||||||
from contextlib import asynccontextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import AsyncIterator
|
|
||||||
|
|
||||||
from alembic.config import Config
|
|
||||||
from alembic.operations import Operations
|
|
||||||
from alembic.runtime.migration import MigrationContext
|
|
||||||
from alembic.script.base import ScriptDirectory
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine, async_sessionmaker
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from asyncpg import Connection
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseManager:
|
|
||||||
def __init__(self):
|
|
||||||
self.engine: AsyncEngine | None = None
|
|
||||||
self.sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
|
||||||
self.database_url: str | None = None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_url(database_url: str):
|
|
||||||
instance = DatabaseManager()
|
|
||||||
instance.database_url = database_url
|
|
||||||
instance.engine = create_async_engine(database_url, pool_size = 100)
|
|
||||||
instance.sessionmaker = async_sessionmaker(bind = instance.engine, autocommit = False, autoflush = False)
|
|
||||||
|
|
||||||
return instance
|
|
||||||
|
|
||||||
async def dispose(self):
|
|
||||||
if self.engine is None:
|
|
||||||
raise Exception("DatabaseManager engine is not initialized")
|
|
||||||
|
|
||||||
await self.engine.dispose()
|
|
||||||
self.database_url = None
|
|
||||||
self.engine = None
|
|
||||||
self.sessionmaker = None
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def connection(self) -> AsyncIterator[AsyncConnection]:
|
|
||||||
if self.engine is None:
|
|
||||||
raise Exception("DatabaseManager engine is not initialized")
|
|
||||||
|
|
||||||
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]:
|
|
||||||
if self.sessionmaker is None:
|
|
||||||
raise Exception("DatabaseManager session is not initialized")
|
|
||||||
|
|
||||||
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):
|
|
||||||
if self.engine is None:
|
|
||||||
raise Exception("DatabaseManager engine is not initialized")
|
|
||||||
|
|
||||||
config = Config(Path("alembic.ini"))
|
|
||||||
config.set_main_option("sqlalchemy.url", self.database_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()
|
|
||||||
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
from src.db.user import User
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
|||||||
"""Create a baseline migrations
|
|
||||||
|
|
||||||
Revision ID: 269db1cef2c9
|
|
||||||
Revises:
|
|
||||||
Create Date: 2024-05-08 18:48:41.969272
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '269db1cef2c9'
|
|
||||||
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! ###
|
|
||||||
op.create_table('user',
|
|
||||||
sa.Column('id', sa.Uuid(), nullable=False),
|
|
||||||
sa.Column('login_name', sa.String(), nullable=False),
|
|
||||||
sa.Column('hashed_password', sa.String(), nullable=False),
|
|
||||||
sa.Column('name', sa.String(), nullable=False),
|
|
||||||
sa.Column('email', sa.String(), nullable=False),
|
|
||||||
sa.Column('is_admin', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('must_change_password', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('avatar', sa.String(), nullable=False),
|
|
||||||
sa.Column('created_unix', sa.BigInteger(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_table('user')
|
|
||||||
# ### end Alembic commands ###
|
|
@ -1,21 +0,0 @@
|
|||||||
from uuid import UUID, uuid4
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import Column, BigInteger
|
|
||||||
from sqlalchemy.orm import mapped_column, Mapped
|
|
||||||
|
|
||||||
from src.db import Base
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "user"
|
|
||||||
|
|
||||||
id: Mapped[UUID] = mapped_column(primary_key = True, default = uuid4)
|
|
||||||
login_name: Mapped[str]
|
|
||||||
hashed_password: Mapped[str]
|
|
||||||
name: Mapped[str]
|
|
||||||
email: Mapped[str]
|
|
||||||
is_admin: Mapped[bool]
|
|
||||||
is_active: Mapped[bool]
|
|
||||||
must_change_password: Mapped[bool]
|
|
||||||
avatar: Mapped[str]
|
|
||||||
created_unix = Column(BigInteger)
|
|
49
src/main.py
49
src/main.py
@ -1,49 +0,0 @@
|
|||||||
from contextlib import asynccontextmanager
|
|
||||||
from os import environ
|
|
||||||
|
|
||||||
import uvicorn
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from src.config import config
|
|
||||||
from src.db import DatabaseManager
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
pool = DatabaseManager.from_url(config.database_url()) # type: ignore
|
|
||||||
|
|
||||||
app.state.config = config
|
|
||||||
app.state.pool = pool
|
|
||||||
|
|
||||||
async with pool.connection() as connection:
|
|
||||||
await connection.run_sync(pool.run_migrations) # type: ignore
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
if pool.engine is not None:
|
|
||||||
await pool.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title = "materia",
|
|
||||||
version = "0.1.0",
|
|
||||||
docs_url = "/api/docs",
|
|
||||||
lifespan = lifespan
|
|
||||||
)
|
|
||||||
|
|
||||||
def main():
|
|
||||||
uvicorn.run(
|
|
||||||
"src.main:app",
|
|
||||||
port = config.server.port,
|
|
||||||
host = config.server.address,
|
|
||||||
reload = bool(environ.get("MATERIA_DEBUG"))
|
|
||||||
)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
|
|
3
src/materia/__main__.py
Normal file
3
src/materia/__main__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from materia.app import cli
|
||||||
|
|
||||||
|
cli()
|
2
src/materia/app/__init__.py
Normal file
2
src/materia/app/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from materia.app.app import Context, Application
|
||||||
|
from materia.app.cli import cli
|
171
src/materia/app/app.py
Normal file
171
src/materia/app/app.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
from contextlib import _AsyncGeneratorContextManager, asynccontextmanager
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import AsyncIterator, TypedDict, Self, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from materia.core import (
|
||||||
|
Config,
|
||||||
|
Logger,
|
||||||
|
LoggerInstance,
|
||||||
|
Database,
|
||||||
|
Cache,
|
||||||
|
Cron,
|
||||||
|
)
|
||||||
|
from materia import routers
|
||||||
|
from materia.core.misc import optional, optional_string
|
||||||
|
|
||||||
|
|
||||||
|
class Context(TypedDict):
|
||||||
|
config: Config
|
||||||
|
logger: LoggerInstance
|
||||||
|
database: Database
|
||||||
|
cache: Cache
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Application:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: Config,
|
||||||
|
):
|
||||||
|
self.config: Config = config
|
||||||
|
self.logger: Optional[LoggerInstance] = None
|
||||||
|
self.database: Optional[Database] = None
|
||||||
|
self.cache: Optional[Cache] = None
|
||||||
|
self.cron: Optional[Cron] = None
|
||||||
|
self.backend: Optional[FastAPI] = None
|
||||||
|
|
||||||
|
self.prepare_logger()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def new(config: Config):
|
||||||
|
app = Application(config)
|
||||||
|
|
||||||
|
# if user := config.application.user:
|
||||||
|
# os.setuid(pwd.getpwnam(user).pw_uid)
|
||||||
|
# if group := config.application.group:
|
||||||
|
# os.setgid(pwd.getpwnam(user).pw_gid)
|
||||||
|
app.logger.debug("Initializing application...")
|
||||||
|
await app.prepare_working_directory()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await app.prepare_database()
|
||||||
|
await app.prepare_cache()
|
||||||
|
await app.prepare_cron()
|
||||||
|
app.prepare_server()
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(" ".join(e.args))
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import materia_frontend
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
app.logger.warning(
|
||||||
|
"`materia_frontend` is not installed. No user interface will be served."
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
def prepare_logger(self):
|
||||||
|
self.logger = Logger.new(**self.config.log.model_dump())
|
||||||
|
|
||||||
|
async def prepare_working_directory(self):
|
||||||
|
try:
|
||||||
|
path = self.config.application.working_directory.resolve()
|
||||||
|
self.logger.debug(f"Changing working directory to {path}")
|
||||||
|
os.chdir(path)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
self.logger.error("Failed to change working directory: {}", e)
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
async def prepare_database(self):
|
||||||
|
url = self.config.database.url()
|
||||||
|
self.logger.info("Connecting to database {}", url)
|
||||||
|
self.database = await Database.new(url) # type: ignore
|
||||||
|
|
||||||
|
async def prepare_cache(self):
|
||||||
|
url = self.config.cache.url()
|
||||||
|
self.logger.info("Connecting to cache server {}", url)
|
||||||
|
self.cache = await Cache.new(url) # type: ignore
|
||||||
|
|
||||||
|
async def prepare_cron(self):
|
||||||
|
url = self.config.cache.url()
|
||||||
|
self.logger.info("Prepairing cron")
|
||||||
|
self.cron = Cron.new(
|
||||||
|
self.config.cron.workers_count, backend_url=url, broker_url=url
|
||||||
|
)
|
||||||
|
|
||||||
|
def prepare_server(self):
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[Context]:
|
||||||
|
yield Context(
|
||||||
|
config=self.config,
|
||||||
|
logger=self.logger,
|
||||||
|
database=self.database,
|
||||||
|
cache=self.cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.database.engine is not None:
|
||||||
|
await self.database.dispose()
|
||||||
|
|
||||||
|
self.backend = FastAPI(
|
||||||
|
title="materia",
|
||||||
|
version="0.1.0",
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None,
|
||||||
|
swagger_ui_init_oauth=None,
|
||||||
|
swagger_ui_oauth2_redirect_url=None,
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
self.backend.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost", "http://localhost:5173"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
self.backend.include_router(routers.docs.router)
|
||||||
|
self.backend.include_router(routers.api.router)
|
||||||
|
self.backend.include_router(routers.resources.router)
|
||||||
|
self.backend.include_router(routers.root.router)
|
||||||
|
|
||||||
|
for route in self.backend.routes:
|
||||||
|
if isinstance(route, APIRoute):
|
||||||
|
route.operation_id = (
|
||||||
|
optional_string(optional(route.tags.__getitem__, 0), "{}_")
|
||||||
|
+ route.name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
self.logger.info(f"Spinning up cron workers [{self.config.cron.workers_count}]")
|
||||||
|
self.cron.run_workers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info("Running database migrations")
|
||||||
|
await self.database.run_migrations()
|
||||||
|
|
||||||
|
uvicorn_config = uvicorn.Config(
|
||||||
|
self.backend,
|
||||||
|
port=self.config.server.port,
|
||||||
|
host=str(self.config.server.address),
|
||||||
|
log_config=Logger.uvicorn_config(self.config.log.level),
|
||||||
|
)
|
||||||
|
server = uvicorn.Server(uvicorn_config)
|
||||||
|
|
||||||
|
await server.serve()
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
self.logger.info("Exiting...")
|
||||||
|
sys.exit()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(" ".join(e.args))
|
||||||
|
sys.exit()
|
12
src/materia/app/asgi.py
Normal file
12
src/materia/app/asgi.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from os import environ
|
||||||
|
from pathlib import Path
|
||||||
|
from uvicorn.workers import UvicornWorker
|
||||||
|
from materia.config import Config
|
||||||
|
from materia._logging import uvicorn_log_config
|
||||||
|
|
||||||
|
|
||||||
|
class MateriaWorker(UvicornWorker):
|
||||||
|
CONFIG_KWARGS = {
|
||||||
|
"loop": "uvloop",
|
||||||
|
"log_config": uvicorn_log_config(Config.open(Path(environ["MATERIA_CONFIG"]).resolve()))
|
||||||
|
}
|
152
src/materia/app/cli.py
Normal file
152
src/materia/app/cli.py
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import click
|
||||||
|
from materia.core.config import Config
|
||||||
|
from materia.core.logging import Logger
|
||||||
|
from materia.app import Application
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--config", type=Path)
|
||||||
|
@click.option("--debug", "-d", is_flag=True, default=False, help="Enable debug output.")
|
||||||
|
def start(config: Path, debug: bool):
|
||||||
|
config_path = config
|
||||||
|
logger = Logger.new()
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
config.log.level = "debug"
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
app = await Application.new(config)
|
||||||
|
await app.start()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
|
||||||
|
@cli.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 = Logger.new()
|
||||||
|
|
||||||
|
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.parent.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()
|
||||||
|
logger = Logger.new()
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def export():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@export.command("openapi", help="Export an OpenAPI specification.")
|
||||||
|
@click.option(
|
||||||
|
"--path",
|
||||||
|
"-p",
|
||||||
|
type=Path,
|
||||||
|
default=Path.cwd().joinpath("openapi.json"),
|
||||||
|
help="Path to the file.",
|
||||||
|
)
|
||||||
|
def export_openapi(path: Path):
|
||||||
|
path = path.resolve()
|
||||||
|
logger = Logger.new()
|
||||||
|
config = Config()
|
||||||
|
app = Application(config)
|
||||||
|
app.prepare_server()
|
||||||
|
|
||||||
|
logger.info("Writing file at {}", path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "w") as io:
|
||||||
|
json.dump(app.backend.openapi(), io, sort_keys=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("{}", e)
|
||||||
|
|
||||||
|
logger.info("All done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
29
src/materia/app/wsgi.py
Normal file
29
src/materia/app/wsgi.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from gunicorn.app.wsgiapp import WSGIApplication
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
|
|
||||||
|
class MateriaProcessManager(WSGIApplication):
|
||||||
|
def __init__(self, app: str, options: dict | None = None):
|
||||||
|
self.app_uri = app
|
||||||
|
self.options = options or {}
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def load_config(self):
|
||||||
|
config = {
|
||||||
|
key: value
|
||||||
|
for key, value in self.options.items()
|
||||||
|
if key in self.cfg.settings and value is not None
|
||||||
|
}
|
||||||
|
for key, value in config.items():
|
||||||
|
self.cfg.set(key.lower(), value)
|
||||||
|
|
||||||
|
def run():
|
||||||
|
options = {
|
||||||
|
"bind": "0.0.0.0:8000",
|
||||||
|
"workers": (multiprocessing.cpu_count() * 2) + 1,
|
||||||
|
"worker_class": "materia.app.wsgi.MateriaWorker",
|
||||||
|
"raw_env": ["FOO=1"],
|
||||||
|
"user": None,
|
||||||
|
"group": None
|
||||||
|
}
|
||||||
|
MateriaProcessManager("materia.app.app:run", options).run()
|
13
src/materia/core/__init__.py
Normal file
13
src/materia/core/__init__.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from materia.core.logging import Logger, LoggerInstance, LogLevel, LogMode
|
||||||
|
from materia.core.database import (
|
||||||
|
DatabaseError,
|
||||||
|
DatabaseMigrationError,
|
||||||
|
Database,
|
||||||
|
SessionMaker,
|
||||||
|
SessionContext,
|
||||||
|
ConnectionContext,
|
||||||
|
)
|
||||||
|
from materia.core.filesystem import FileSystem, FileSystemError, TemporaryFileTarget
|
||||||
|
from materia.core.config import Config
|
||||||
|
from materia.core.cache import Cache, CacheError
|
||||||
|
from materia.core.cron import Cron, CronError
|
56
src/materia/core/cache.py
Normal file
56
src/materia/core/cache.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any, AsyncGenerator, Self
|
||||||
|
from pydantic import RedisDsn
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
from redis.asyncio.client import Pipeline
|
||||||
|
from materia.core.logging import Logger
|
||||||
|
|
||||||
|
|
||||||
|
class CacheError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
test_connection: bool = True,
|
||||||
|
) -> Self:
|
||||||
|
pool = aioredis.ConnectionPool.from_url(
|
||||||
|
str(url), encoding=encoding, decode_responses=decode_responses
|
||||||
|
)
|
||||||
|
|
||||||
|
if test_connection:
|
||||||
|
try:
|
||||||
|
if logger := Logger.instance():
|
||||||
|
logger.debug("Testing cache connection")
|
||||||
|
connection = pool.make_connection()
|
||||||
|
await connection.connect()
|
||||||
|
except ConnectionError as e:
|
||||||
|
raise CacheError(f"{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 CacheError(f"{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 CacheError(f"{e}")
|
193
src/materia/core/config.py
Normal file
193
src/materia/core/config.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
from os import environ
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal, Optional, Self, Union
|
||||||
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
Field,
|
||||||
|
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"
|
||||||
|
|
||||||
|
def url(self) -> str:
|
||||||
|
return "{}://{}:{}".format(self.scheme, self.address, self.port)
|
||||||
|
|
||||||
|
|
||||||
|
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: Optional[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
|
||||||
|
)
|
||||||
|
+ f"/{self.name}"
|
||||||
|
if self.name
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
# else:
|
||||||
|
# assert self.jwt_signing_key is not None, "JWT signing key must be set"
|
||||||
|
#
|
||||||
|
# return self
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
workers_count: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class Repository(BaseModel):
|
||||||
|
capacity: int = 5 << 30
|
||||||
|
|
||||||
|
|
||||||
|
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
|
72
src/materia/core/cron.py
Normal file
72
src/materia/core/cron.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from typing import Optional, Self
|
||||||
|
from celery import Celery
|
||||||
|
from pydantic import RedisDsn
|
||||||
|
from threading import Thread
|
||||||
|
from materia.core.logging import Logger
|
||||||
|
|
||||||
|
|
||||||
|
class CronError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Cron:
|
||||||
|
__instance__: Optional[Self] = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
workers_count: int,
|
||||||
|
backend: Celery,
|
||||||
|
):
|
||||||
|
self.workers_count = workers_count
|
||||||
|
self.backend = backend
|
||||||
|
self.workers = []
|
||||||
|
self.worker_threads = []
|
||||||
|
|
||||||
|
Cron.__instance__ = self
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def new(
|
||||||
|
workers_count: int = 1,
|
||||||
|
backend_url: Optional[RedisDsn] = None,
|
||||||
|
broker_url: Optional[RedisDsn] = None,
|
||||||
|
test_connection: bool = True,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
cron = Cron(
|
||||||
|
workers_count,
|
||||||
|
# TODO: change log level
|
||||||
|
# TODO: exclude pickle
|
||||||
|
# TODO: disable startup banner
|
||||||
|
Celery(
|
||||||
|
"cron",
|
||||||
|
backend=backend_url,
|
||||||
|
broker=broker_url,
|
||||||
|
broker_connection_retry_on_startup=True,
|
||||||
|
task_serializer="pickle",
|
||||||
|
accept_content=["pickle", "json"],
|
||||||
|
**kwargs,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(workers_count):
|
||||||
|
cron.workers.append(cron.backend.Worker())
|
||||||
|
|
||||||
|
if test_connection:
|
||||||
|
try:
|
||||||
|
if logger := Logger.instance():
|
||||||
|
logger.debug("Testing cron broker connection")
|
||||||
|
cron.backend.broker_connection().ensure_connection(max_retries=3)
|
||||||
|
except Exception as e:
|
||||||
|
raise CronError(f"Failed to connect cron broker: {broker_url}") from e
|
||||||
|
|
||||||
|
return cron
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def instance() -> Optional[Self]:
|
||||||
|
return Cron.__instance__
|
||||||
|
|
||||||
|
def run_workers(self):
|
||||||
|
for worker in self.workers:
|
||||||
|
thread = Thread(target=worker.start, daemon=True)
|
||||||
|
self.worker_threads.append(thread)
|
||||||
|
thread.start()
|
173
src/materia/core/database.py
Normal file
173
src/materia/core/database.py
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import AsyncIterator, Self, TypeAlias
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import PostgresDsn, ValidationError
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncConnection,
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
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
|
||||||
|
import alembic_postgresql_enum
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from materia.core.logging import Logger
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseMigrationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
SessionContext: TypeAlias = AsyncIterator[AsyncSession]
|
||||||
|
SessionMaker: TypeAlias = async_sessionmaker[AsyncSession]
|
||||||
|
ConnectionContext: TypeAlias = AsyncIterator[AsyncConnection]
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: PostgresDsn,
|
||||||
|
engine: AsyncEngine,
|
||||||
|
sessionmaker: SessionMaker,
|
||||||
|
):
|
||||||
|
self.url: PostgresDsn = url
|
||||||
|
self.engine: AsyncEngine = engine
|
||||||
|
self.sessionmaker: SessionMaker = sessionmaker
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def new(
|
||||||
|
url: PostgresDsn,
|
||||||
|
pool_size: int = 100,
|
||||||
|
poolclass=None,
|
||||||
|
autocommit: bool = False,
|
||||||
|
autoflush: bool = False,
|
||||||
|
expire_on_commit: bool = False,
|
||||||
|
test_connection: bool = True,
|
||||||
|
) -> Self:
|
||||||
|
engine_options = {"pool_size": pool_size}
|
||||||
|
if poolclass == NullPool:
|
||||||
|
engine_options = {"poolclass": NullPool}
|
||||||
|
|
||||||
|
engine = create_async_engine(str(url), **engine_options)
|
||||||
|
|
||||||
|
sessionmaker = async_sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
autocommit=autocommit,
|
||||||
|
autoflush=autoflush,
|
||||||
|
expire_on_commit=expire_on_commit,
|
||||||
|
)
|
||||||
|
|
||||||
|
database = Database(url=url, engine=engine, sessionmaker=sessionmaker)
|
||||||
|
|
||||||
|
if test_connection:
|
||||||
|
try:
|
||||||
|
if logger := Logger.instance():
|
||||||
|
logger.debug("Testing database connection")
|
||||||
|
async with database.connection() as connection:
|
||||||
|
await connection.rollback()
|
||||||
|
except Exception as e:
|
||||||
|
raise DatabaseError(
|
||||||
|
f"Failed to connect to database '{url}': {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
return database
|
||||||
|
|
||||||
|
async def dispose(self):
|
||||||
|
await self.engine.dispose()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def connection(self) -> ConnectionContext:
|
||||||
|
async with self.engine.connect() as connection:
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
except Exception as e:
|
||||||
|
await connection.rollback()
|
||||||
|
raise DatabaseError(*e.args) from e
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def session(self) -> SessionContext:
|
||||||
|
session = self.sessionmaker()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except (HTTPException, ValidationError) as e:
|
||||||
|
await session.rollback()
|
||||||
|
raise e from None
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
raise e # DatabaseError(*e.args) from e
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
def run_sync_migrations(self, connection: Connection):
|
||||||
|
from materia.models.base import Base
|
||||||
|
|
||||||
|
aconfig = AlembicConfig()
|
||||||
|
aconfig.set_main_option("sqlalchemy.url", str(self.url))
|
||||||
|
aconfig.set_main_option(
|
||||||
|
"script_location",
|
||||||
|
str(Path(__file__).parent.parent.joinpath("models", "migrations")),
|
||||||
|
)
|
||||||
|
|
||||||
|
context = MigrationContext.configure(
|
||||||
|
connection=connection, # type: ignore
|
||||||
|
opts={
|
||||||
|
"target_metadata": Base.metadata,
|
||||||
|
"fn": lambda rev, _: ScriptDirectory.from_config(aconfig)._upgrade_revs(
|
||||||
|
"head", rev
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with context.begin_transaction():
|
||||||
|
with Operations.context(context):
|
||||||
|
context.run_migrations()
|
||||||
|
except Exception as e:
|
||||||
|
raise DatabaseMigrationError(f"{e}")
|
||||||
|
|
||||||
|
async def run_migrations(self):
|
||||||
|
async with self.connection() as connection:
|
||||||
|
await connection.run_sync(self.run_sync_migrations) # type: ignore
|
||||||
|
|
||||||
|
def rollback_sync_migrations(self, connection: Connection):
|
||||||
|
from materia.models.base import Base
|
||||||
|
|
||||||
|
aconfig = AlembicConfig()
|
||||||
|
aconfig.set_main_option("sqlalchemy.url", str(self.url))
|
||||||
|
aconfig.set_main_option(
|
||||||
|
"script_location",
|
||||||
|
str(Path(__file__).parent.parent.joinpath("models", "migrations")),
|
||||||
|
)
|
||||||
|
|
||||||
|
context = MigrationContext.configure(
|
||||||
|
connection=connection, # type: ignore
|
||||||
|
opts={
|
||||||
|
"target_metadata": Base.metadata,
|
||||||
|
"fn": lambda rev, _: ScriptDirectory.from_config(
|
||||||
|
aconfig
|
||||||
|
)._downgrade_revs("base", rev),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with context.begin_transaction():
|
||||||
|
with Operations.context(context):
|
||||||
|
context.run_migrations()
|
||||||
|
except Exception as e:
|
||||||
|
raise DatabaseMigrationError(f"{e}")
|
||||||
|
|
||||||
|
async def rollback_migrations(self):
|
||||||
|
async with self.connection() as connection:
|
||||||
|
await connection.run_sync(self.rollback_sync_migrations) # type: ignore
|
235
src/materia/core/filesystem.py
Normal file
235
src/materia/core/filesystem.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
from typing import Optional, Self, Iterator, TypeVar
|
||||||
|
from pathlib import Path
|
||||||
|
import aiofiles
|
||||||
|
from aiofiles import os as async_os
|
||||||
|
from aiofiles import ospath as async_path
|
||||||
|
import aioshutil
|
||||||
|
import re
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from streaming_form_data.targets import BaseTarget
|
||||||
|
from uuid import uuid4
|
||||||
|
from materia.core.misc import optional
|
||||||
|
|
||||||
|
|
||||||
|
valid_path = re.compile(r"^/(.*/)*([^/]*)$")
|
||||||
|
|
||||||
|
|
||||||
|
class FileSystemError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FileSystem:
|
||||||
|
def __init__(self, path: Path, isolated_directory: Optional[Path] = None):
|
||||||
|
if path == Path() or path is None:
|
||||||
|
raise FileSystemError("The given path is empty")
|
||||||
|
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
if isolated_directory and not isolated_directory.is_absolute():
|
||||||
|
raise FileSystemError("The isolated directory must be absolute")
|
||||||
|
|
||||||
|
self.isolated_directory = isolated_directory
|
||||||
|
# self.working_directory = working_directory
|
||||||
|
# self.relative_path = path.relative_to(working_directory)
|
||||||
|
|
||||||
|
async def exists(self) -> bool:
|
||||||
|
return await async_path.exists(self.path)
|
||||||
|
|
||||||
|
async def size(self) -> int:
|
||||||
|
return await async_path.getsize(self.path)
|
||||||
|
|
||||||
|
async def is_file(self) -> bool:
|
||||||
|
return await async_path.isfile(self.path)
|
||||||
|
|
||||||
|
async def is_directory(self) -> bool:
|
||||||
|
return await async_path.isdir(self.path)
|
||||||
|
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.path.name
|
||||||
|
|
||||||
|
async def check_isolation(self, path: Path):
|
||||||
|
if not self.isolated_directory:
|
||||||
|
return
|
||||||
|
if not (await async_path.exists(self.isolated_directory)):
|
||||||
|
raise FileSystemError("Missed isolated directory")
|
||||||
|
if not optional(path.relative_to, self.isolated_directory):
|
||||||
|
raise FileSystemError(
|
||||||
|
"Attempting to work with a path that is outside the isolated directory"
|
||||||
|
)
|
||||||
|
if self.path == self.isolated_directory:
|
||||||
|
raise FileSystemError("Attempting to modify the isolated directory")
|
||||||
|
|
||||||
|
async def remove(self, shallow: bool = False):
|
||||||
|
await self.check_isolation(self.path)
|
||||||
|
try:
|
||||||
|
if await self.exists() and await self.is_file() and not shallow:
|
||||||
|
await aiofiles.os.remove(self.path)
|
||||||
|
|
||||||
|
if await self.exists() and await self.is_directory() and not shallow:
|
||||||
|
await aioshutil.rmtree(str(self.path))
|
||||||
|
except OSError as e:
|
||||||
|
raise FileSystemError(*e.args) from e
|
||||||
|
|
||||||
|
async def generate_name(self, target_directory: Path, name: str) -> str:
|
||||||
|
"""Generate name based on target directory contents and self type."""
|
||||||
|
count = 1
|
||||||
|
new_path = target_directory.joinpath(name)
|
||||||
|
|
||||||
|
while await async_path.exists(new_path):
|
||||||
|
if await self.is_file():
|
||||||
|
if with_counter := re.match(r"^(.+)\.(\d+)\.(\w+)$", new_path.name):
|
||||||
|
new_name, _, extension = with_counter.groups()
|
||||||
|
elif with_extension := re.match(r"^(.+)\.(\w+)$", new_path.name):
|
||||||
|
new_name, extension = with_extension.groups()
|
||||||
|
|
||||||
|
new_path = target_directory.joinpath(
|
||||||
|
"{}.{}.{}".format(new_name, count, extension)
|
||||||
|
)
|
||||||
|
|
||||||
|
if await self.is_directory():
|
||||||
|
if with_counter := re.match(r"^(.+)\.(\d+)$", new_path.name):
|
||||||
|
new_name, _ = with_counter.groups()
|
||||||
|
else:
|
||||||
|
new_name = new_path.name
|
||||||
|
|
||||||
|
new_path = target_directory.joinpath("{}.{}".format(new_name, count))
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return new_path.name
|
||||||
|
|
||||||
|
async def _generate_new_path(
|
||||||
|
self,
|
||||||
|
target_directory: Path,
|
||||||
|
new_name: Optional[str] = None,
|
||||||
|
force: bool = False,
|
||||||
|
shallow: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
new_name = new_name or self.path.name
|
||||||
|
|
||||||
|
if await async_path.exists(target_directory.joinpath(new_name)):
|
||||||
|
if force or shallow:
|
||||||
|
new_name = await self.generate_name(target_directory, new_name)
|
||||||
|
else:
|
||||||
|
raise FileSystemError("Target destination already exists")
|
||||||
|
|
||||||
|
return target_directory.joinpath(new_name)
|
||||||
|
|
||||||
|
async def move(
|
||||||
|
self,
|
||||||
|
target_directory: Path,
|
||||||
|
new_name: Optional[str] = None,
|
||||||
|
force: bool = False,
|
||||||
|
shallow: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
await self.check_isolation(self.path)
|
||||||
|
new_path = await self._generate_new_path(
|
||||||
|
target_directory, new_name, force=force, shallow=shallow
|
||||||
|
)
|
||||||
|
target = FileSystem(new_path, self.isolated_directory)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if await self.exists() and not shallow:
|
||||||
|
await aioshutil.move(self.path, new_path)
|
||||||
|
except Exception as e:
|
||||||
|
raise FileSystemError(*e.args) from e
|
||||||
|
|
||||||
|
return target
|
||||||
|
|
||||||
|
async def rename(
|
||||||
|
self, new_name: str, force: bool = False, shallow: bool = False
|
||||||
|
) -> Self:
|
||||||
|
return await self.move(
|
||||||
|
self.path.parent, new_name=new_name, force=force, shallow=shallow
|
||||||
|
)
|
||||||
|
|
||||||
|
async def copy(
|
||||||
|
self,
|
||||||
|
target_directory: Path,
|
||||||
|
new_name: Optional[str] = None,
|
||||||
|
force: bool = False,
|
||||||
|
shallow: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
await self.check_isolation(self.path)
|
||||||
|
new_path = await self._generate_new_path(
|
||||||
|
target_directory, new_name, force=force, shallow=shallow
|
||||||
|
)
|
||||||
|
target = FileSystem(new_path, self.isolated_directory)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if await self.is_file() and not shallow:
|
||||||
|
await aioshutil.copy(self.path, new_path)
|
||||||
|
|
||||||
|
if await self.is_directory() and not shallow:
|
||||||
|
await aioshutil.copytree(self.path, new_path)
|
||||||
|
except Exception as e:
|
||||||
|
raise FileSystemError(*e.args) from e
|
||||||
|
|
||||||
|
return target
|
||||||
|
|
||||||
|
async def make_directory(self, force: bool = False):
|
||||||
|
try:
|
||||||
|
if await self.exists() and not force:
|
||||||
|
raise FileSystemError("Already exists")
|
||||||
|
|
||||||
|
await async_os.makedirs(self.path, exist_ok=force)
|
||||||
|
except Exception as e:
|
||||||
|
raise FileSystemError(*e.args)
|
||||||
|
|
||||||
|
async def write_file(self, data: bytes, force: bool = False):
|
||||||
|
try:
|
||||||
|
if await self.exists() and not force:
|
||||||
|
raise FileSystemError("Already exists")
|
||||||
|
|
||||||
|
async with aiofiles.open(self.path, mode="wb") as file:
|
||||||
|
await file.write(data)
|
||||||
|
except Exception as e:
|
||||||
|
raise FileSystemError(*e.args)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_path(path: Path) -> bool:
|
||||||
|
return bool(valid_path.match(str(path)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize(path: Path) -> Path:
|
||||||
|
"""Resolve path and make it relative."""
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = Path("/").joinpath(path)
|
||||||
|
|
||||||
|
return Path(*path.resolve().parts[1:])
|
||||||
|
|
||||||
|
|
||||||
|
class TemporaryFileTarget(BaseTarget):
|
||||||
|
def __init__(
|
||||||
|
self, working_directory: Path, allow_overwrite: bool = True, *args, **kwargs
|
||||||
|
):
|
||||||
|
if working_directory == Path():
|
||||||
|
raise FileSystemError("The given working directory is empty")
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self._mode = "wb" if allow_overwrite else "xb"
|
||||||
|
self._fd = None
|
||||||
|
self._path = working_directory.joinpath("cache", str(uuid4()))
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
if not self._path.parent.exists():
|
||||||
|
self._path.parent.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
self._fd = open(str(self._path), mode="wb")
|
||||||
|
|
||||||
|
def on_data_received(self, chunk: bytes):
|
||||||
|
if self._fd:
|
||||||
|
self._fd.write(chunk)
|
||||||
|
|
||||||
|
def on_finish(self):
|
||||||
|
if self._fd:
|
||||||
|
self._fd.close()
|
||||||
|
|
||||||
|
def path(self) -> Optional[Path]:
|
||||||
|
return self._path
|
||||||
|
|
||||||
|
def remove(self):
|
||||||
|
if self._fd:
|
||||||
|
if (path := Path(self._fd.name)).exists():
|
||||||
|
path.unlink()
|
128
src/materia/core/logging.py
Normal file
128
src/materia/core/logging.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import sys
|
||||||
|
from typing import Sequence, Literal, Optional, TypeAlias
|
||||||
|
from pathlib import Path
|
||||||
|
from loguru import logger
|
||||||
|
from loguru._logger import Logger as LoggerInstance
|
||||||
|
import logging
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LogLevel: TypeAlias = Literal["info", "warning", "error", "critical", "debug", "trace"]
|
||||||
|
LogMode: TypeAlias = Literal["console", "file", "all"]
|
||||||
|
|
||||||
|
|
||||||
|
class Logger:
|
||||||
|
__instance__: Optional[LoggerInstance] = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def new(
|
||||||
|
mode: LogMode = "console",
|
||||||
|
level: LogLevel = "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",
|
||||||
|
interceptions: Sequence[str] = [
|
||||||
|
"uvicorn",
|
||||||
|
"uvicorn.access",
|
||||||
|
"uvicorn.error",
|
||||||
|
"uvicorn.asgi",
|
||||||
|
"fastapi",
|
||||||
|
],
|
||||||
|
) -> LoggerInstance:
|
||||||
|
logger.remove()
|
||||||
|
|
||||||
|
if mode in ["console", "all"]:
|
||||||
|
logger.add(
|
||||||
|
sys.stdout,
|
||||||
|
enqueue=True,
|
||||||
|
backtrace=True,
|
||||||
|
level=level.upper(),
|
||||||
|
format=console_format,
|
||||||
|
filter=lambda record: record["level"].name
|
||||||
|
in ["INFO", "WARNING", "DEBUG", "TRACE"],
|
||||||
|
)
|
||||||
|
logger.add(
|
||||||
|
sys.stderr,
|
||||||
|
enqueue=True,
|
||||||
|
backtrace=True,
|
||||||
|
level=level.upper(),
|
||||||
|
format=console_format,
|
||||||
|
filter=lambda record: record["level"].name in ["ERROR", "CRITICAL"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode in ["file", "all"]:
|
||||||
|
logger.add(
|
||||||
|
str(file),
|
||||||
|
rotation=file_rotation,
|
||||||
|
retention=file_retention,
|
||||||
|
enqueue=True,
|
||||||
|
backtrace=True,
|
||||||
|
level=level.upper(),
|
||||||
|
format=file_format,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
handlers=[InterceptHandler()], level=logging.NOTSET, force=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for external_logger in interceptions:
|
||||||
|
logging.getLogger(external_logger).handlers = [InterceptHandler()]
|
||||||
|
|
||||||
|
Logger.__instance__ = logger
|
||||||
|
|
||||||
|
return logger # type: ignore
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def instance() -> Optional[LoggerInstance]:
|
||||||
|
return Logger.__instance__
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def uvicorn_config(level: LogLevel) -> dict:
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"handlers": {
|
||||||
|
"default": {"class": "materia.core.logging.InterceptHandler"},
|
||||||
|
"access": {"class": "materia.core.logging.InterceptHandler"},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"uvicorn": {
|
||||||
|
"handlers": ["default"],
|
||||||
|
"level": level.upper(),
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"uvicorn.error": {"level": level.upper()},
|
||||||
|
"uvicorn.access": {
|
||||||
|
"handlers": ["access"],
|
||||||
|
"level": level.upper(),
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
28
src/materia/core/misc.py
Normal file
28
src/materia/core/misc.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from typing import Optional, Self, Iterator, TypeVar, Callable, Any, ParamSpec
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
P = ParamSpec("P")
|
||||||
|
|
||||||
|
|
||||||
|
def optional(func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> Optional[T]:
|
||||||
|
try:
|
||||||
|
res = func(*args, **kwargs)
|
||||||
|
except TypeError as e:
|
||||||
|
raise e
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def optional_next(it: Iterator[T]) -> Optional[T]:
|
||||||
|
return optional(next, it)
|
||||||
|
|
||||||
|
|
||||||
|
def optional_string(value: Any, format_string: Optional[str] = None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
res = optional(str, value)
|
||||||
|
if res is None:
|
||||||
|
return ""
|
||||||
|
return format_string.format(res)
|
31
src/materia/models/__init__.py
Normal file
31
src/materia/models/__init__.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from materia.models.auth import (
|
||||||
|
LoginType,
|
||||||
|
LoginSource,
|
||||||
|
# OAuth2Application,
|
||||||
|
# OAuth2Grant,
|
||||||
|
# OAuth2AuthorizationCode,
|
||||||
|
)
|
||||||
|
from materia.models.user import User, UserCredentials, UserInfo
|
||||||
|
from materia.models.repository import (
|
||||||
|
Repository,
|
||||||
|
RepositoryInfo,
|
||||||
|
RepositoryContent,
|
||||||
|
RepositoryError,
|
||||||
|
)
|
||||||
|
from materia.models.directory import (
|
||||||
|
Directory,
|
||||||
|
DirectoryLink,
|
||||||
|
DirectoryInfo,
|
||||||
|
DirectoryContent,
|
||||||
|
DirectoryPath,
|
||||||
|
DirectoryRename,
|
||||||
|
DirectoryCopyMove,
|
||||||
|
)
|
||||||
|
from materia.models.file import (
|
||||||
|
File,
|
||||||
|
FileLink,
|
||||||
|
FileInfo,
|
||||||
|
FilePath,
|
||||||
|
FileRename,
|
||||||
|
FileCopyMove,
|
||||||
|
)
|
3
src/materia/models/auth/__init__.py
Normal file
3
src/materia/models/auth/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from materia.models.auth.source import LoginType, LoginSource
|
||||||
|
|
||||||
|
# from materia.models.auth.oauth2 import OAuth2Application, OAuth2Grant, OAuth2AuthorizationCode
|
162
src/materia/models/auth/oauth2.py
Normal file
162
src/materia/models/auth/oauth2.py
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
from time import time
|
||||||
|
from typing import List, Optional, Self, Union
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, JSON, and_, select
|
||||||
|
from sqlalchemy.orm import mapped_column, Mapped, relationship
|
||||||
|
from pydantic import BaseModel, HttpUrl
|
||||||
|
|
||||||
|
from materia.models.base import Base
|
||||||
|
from materia.core import Database, Cache
|
||||||
|
from materia import security
|
||||||
|
|
||||||
|
|
||||||
|
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(" ")
|
31
src/materia/models/auth/source.py
Normal file
31
src/materia/models/auth/source.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import enum
|
||||||
|
from time import time
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from materia.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
|
27
src/materia/models/base.py
Normal file
27
src/materia/models/base.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from typing import Optional, Self
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {key: getattr(self, key) for key in self.__table__.columns.keys()}
|
||||||
|
|
||||||
|
def clone(self) -> Optional[Self]:
|
||||||
|
"""Clone model.
|
||||||
|
Included: columns and values, foreign keys
|
||||||
|
Ignored: primary keys, relationships
|
||||||
|
"""
|
||||||
|
# if not inspect(self).persistent:
|
||||||
|
# return
|
||||||
|
|
||||||
|
cloned = self.__class__(
|
||||||
|
**{
|
||||||
|
key: getattr(self, key)
|
||||||
|
for key in self.__table__.columns.keys()
|
||||||
|
# ignore primary keys
|
||||||
|
if key not in self.__table__.primary_key.columns.keys()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return cloned
|
310
src/materia/models/directory.py
Normal file
310
src/materia/models/directory.py
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
from time import time
|
||||||
|
from typing import List, Optional, Self
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, inspect
|
||||||
|
from sqlalchemy.orm import mapped_column, Mapped, relationship
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from materia.models.base import Base
|
||||||
|
from materia.core import SessionContext, Config, FileSystem
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Directory(Base):
|
||||||
|
__tablename__ = "directory"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
|
repository_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("repository.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
parent_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("directory.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
|
||||||
|
updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
|
||||||
|
name: Mapped[str]
|
||||||
|
is_public: Mapped[bool] = mapped_column(default=False)
|
||||||
|
|
||||||
|
repository: Mapped["Repository"] = relationship(back_populates="directories")
|
||||||
|
directories: Mapped[List["Directory"]] = relationship(back_populates="parent")
|
||||||
|
parent: Mapped["Directory"] = relationship(
|
||||||
|
back_populates="directories", remote_side=[id]
|
||||||
|
)
|
||||||
|
files: Mapped[List["File"]] = relationship(back_populates="parent")
|
||||||
|
link: Mapped["DirectoryLink"] = relationship(back_populates="directory")
|
||||||
|
|
||||||
|
async def new(self, session: SessionContext, config: Config) -> Optional[Self]:
|
||||||
|
session.add(self)
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(self, attribute_names=["repository"])
|
||||||
|
|
||||||
|
repository_path = await self.repository.real_path(session, config)
|
||||||
|
directory_path = await self.real_path(session, config)
|
||||||
|
|
||||||
|
new_directory = FileSystem(directory_path, repository_path)
|
||||||
|
await new_directory.make_directory()
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def remove(self, session: SessionContext, config: Config):
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(
|
||||||
|
self, attribute_names=["repository", "directories", "files"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.directories:
|
||||||
|
for directory in self.directories:
|
||||||
|
await directory.remove(session, config)
|
||||||
|
|
||||||
|
if self.files:
|
||||||
|
for file in self.files:
|
||||||
|
await file.remove(session, config)
|
||||||
|
|
||||||
|
repository_path = await self.repository.real_path(session, config)
|
||||||
|
directory_path = await self.real_path(session, config)
|
||||||
|
|
||||||
|
current_directory = FileSystem(directory_path, repository_path)
|
||||||
|
await current_directory.remove()
|
||||||
|
|
||||||
|
await session.delete(self)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
async def relative_path(self, session: SessionContext) -> Optional[Path]:
|
||||||
|
"""Get path of the directory relative repository root."""
|
||||||
|
if inspect(self).was_deleted:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
current_directory = self
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# ISSUE: accessing `parent` attribute raises greenlet_spawn has not been called; can't call await_only() here
|
||||||
|
# parts.append(current_directory.name)
|
||||||
|
# session.add(current_directory)
|
||||||
|
# await session.refresh(current_directory, attribute_names=["parent"])
|
||||||
|
# if current_directory.parent is None:
|
||||||
|
# break
|
||||||
|
# current_directory = current_directory.parent
|
||||||
|
|
||||||
|
parts.append(current_directory.name)
|
||||||
|
|
||||||
|
if current_directory.parent_id is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
current_directory = (
|
||||||
|
await session.scalars(
|
||||||
|
sa.select(Directory).where(
|
||||||
|
Directory.id == current_directory.parent_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return Path().joinpath(*reversed(parts))
|
||||||
|
|
||||||
|
async def real_path(
|
||||||
|
self, session: SessionContext, config: Config
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""Get absolute path of the directory"""
|
||||||
|
if inspect(self).was_deleted:
|
||||||
|
return None
|
||||||
|
|
||||||
|
repository_path = await self.repository.real_path(session, config)
|
||||||
|
relative_path = await self.relative_path(session)
|
||||||
|
|
||||||
|
return repository_path.joinpath(relative_path)
|
||||||
|
|
||||||
|
def is_root(self) -> bool:
|
||||||
|
return self.parent_id is None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def by_path(
|
||||||
|
repository: "Repository", path: Path, session: SessionContext, config: Config
|
||||||
|
) -> Optional[Self]:
|
||||||
|
if path == Path():
|
||||||
|
raise DirectoryError("Cannot find directory by empty path")
|
||||||
|
|
||||||
|
current_directory: Optional[Directory] = None
|
||||||
|
|
||||||
|
for part in path.parts:
|
||||||
|
# from root directory to target directory
|
||||||
|
current_directory = (
|
||||||
|
await session.scalars(
|
||||||
|
sa.select(Directory).where(
|
||||||
|
sa.and_(
|
||||||
|
Directory.repository_id == repository.id,
|
||||||
|
Directory.name == part,
|
||||||
|
(
|
||||||
|
Directory.parent_id == current_directory.id
|
||||||
|
if current_directory
|
||||||
|
else Directory.parent_id.is_(None)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not current_directory:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return current_directory
|
||||||
|
|
||||||
|
async def copy(
|
||||||
|
self,
|
||||||
|
target: Optional["Directory"],
|
||||||
|
session: SessionContext,
|
||||||
|
config: Config,
|
||||||
|
force: bool = False,
|
||||||
|
shallow: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["repository"])
|
||||||
|
|
||||||
|
repository_path = await self.repository.real_path(session, config)
|
||||||
|
directory_path = await self.real_path(session, config)
|
||||||
|
target_path = (
|
||||||
|
await target.real_path(session, config) if target else repository_path
|
||||||
|
)
|
||||||
|
|
||||||
|
current_directory = FileSystem(directory_path, repository_path)
|
||||||
|
new_directory = await current_directory.copy(
|
||||||
|
target_path, force=force, shallow=shallow
|
||||||
|
)
|
||||||
|
|
||||||
|
cloned = self.clone()
|
||||||
|
cloned.name = new_directory.name()
|
||||||
|
cloned.parent_id = target.id if target else None
|
||||||
|
session.add(cloned)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
await session.refresh(self, attribute_names=["files", "directories"])
|
||||||
|
for directory in self.directories:
|
||||||
|
await directory.copy(cloned, session, config, shallow=True)
|
||||||
|
for file in self.files:
|
||||||
|
await file.copy(cloned, session, config, shallow=True)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def move(
|
||||||
|
self,
|
||||||
|
target: Optional["Directory"],
|
||||||
|
session: SessionContext,
|
||||||
|
config: Config,
|
||||||
|
force: bool = False,
|
||||||
|
shallow: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["repository"])
|
||||||
|
|
||||||
|
repository_path = await self.repository.real_path(session, config)
|
||||||
|
directory_path = await self.real_path(session, config)
|
||||||
|
target_path = (
|
||||||
|
await target.real_path(session, config) if target else repository_path
|
||||||
|
)
|
||||||
|
|
||||||
|
current_directory = FileSystem(directory_path, repository_path)
|
||||||
|
moved_directory = await current_directory.move(
|
||||||
|
target_path, force=force, shallow=shallow
|
||||||
|
)
|
||||||
|
|
||||||
|
self.name = moved_directory.name()
|
||||||
|
self.parent_id = target.id if target else None
|
||||||
|
self.updated = time()
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def rename(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
session: SessionContext,
|
||||||
|
config: Config,
|
||||||
|
force: bool = False,
|
||||||
|
shallow: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["repository"])
|
||||||
|
|
||||||
|
repository_path = await self.repository.real_path(session, config)
|
||||||
|
directory_path = await self.real_path(session, config)
|
||||||
|
|
||||||
|
current_directory = FileSystem(directory_path, repository_path)
|
||||||
|
renamed_directory = await current_directory.rename(
|
||||||
|
name, force=force, shallow=shallow
|
||||||
|
)
|
||||||
|
|
||||||
|
self.name = renamed_directory.name()
|
||||||
|
await session.flush()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def info(self, session: SessionContext) -> "DirectoryInfo":
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["files"])
|
||||||
|
|
||||||
|
info = DirectoryInfo.model_validate(self)
|
||||||
|
|
||||||
|
relative_path = await self.relative_path(session)
|
||||||
|
|
||||||
|
info.path = Path("/").joinpath(relative_path) if relative_path else None
|
||||||
|
info.used = sum([file.size for file in self.files])
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryInfo(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
repository_id: int
|
||||||
|
parent_id: Optional[int]
|
||||||
|
created: int
|
||||||
|
updated: int
|
||||||
|
name: str
|
||||||
|
is_public: bool
|
||||||
|
|
||||||
|
path: Optional[Path] = None
|
||||||
|
used: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryContent(BaseModel):
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
files: list["FileInfo"]
|
||||||
|
directories: list["DirectoryInfo"]
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryPath(BaseModel):
|
||||||
|
path: Path
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryRename(BaseModel):
|
||||||
|
path: Path
|
||||||
|
name: str
|
||||||
|
force: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryCopyMove(BaseModel):
|
||||||
|
path: Path
|
||||||
|
target: Path
|
||||||
|
force: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
from materia.models.repository import Repository
|
||||||
|
from materia.models.file import File, FileInfo
|
277
src/materia/models/file.py
Normal file
277
src/materia/models/file.py
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
from time import time
|
||||||
|
from typing import Optional, Self, Union
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, inspect
|
||||||
|
from sqlalchemy.orm import mapped_column, Mapped, relationship
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from materia.models.base import Base
|
||||||
|
from materia.core import SessionContext, Config, FileSystem
|
||||||
|
|
||||||
|
|
||||||
|
class FileError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
created: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
|
||||||
|
updated: Mapped[int] = mapped_column(BigInteger, nullable=False, default=time)
|
||||||
|
name: Mapped[str]
|
||||||
|
is_public: Mapped[bool] = mapped_column(default=False)
|
||||||
|
size: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
repository: Mapped["Repository"] = relationship(back_populates="files")
|
||||||
|
parent: Mapped["Directory"] = relationship(back_populates="files")
|
||||||
|
link: Mapped["FileLink"] = relationship(back_populates="file")
|
||||||
|
|
||||||
|
async def new(
|
||||||
|
self, data: Union[bytes, Path], session: SessionContext, config: Config
|
||||||
|
) -> Optional[Self]:
|
||||||
|
session.add(self)
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(self, attribute_names=["repository"])
|
||||||
|
|
||||||
|
file_path = await self.real_path(session, config)
|
||||||
|
repository_path = await self.repository.real_path(session, config)
|
||||||
|
new_file = FileSystem(file_path, repository_path)
|
||||||
|
|
||||||
|
if isinstance(data, bytes):
|
||||||
|
await new_file.write_file(data)
|
||||||
|
elif isinstance(data, Path):
|
||||||
|
from_file = FileSystem(data, config.application.working_directory)
|
||||||
|
await from_file.move(file_path.parent, new_name=file_path.name)
|
||||||
|
else:
|
||||||
|
raise FileError(f"Unknown data type passed: {type(data)}")
|
||||||
|
|
||||||
|
self.size = await new_file.size()
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def remove(self, session: SessionContext, config: Config):
|
||||||
|
session.add(self)
|
||||||
|
|
||||||
|
file_path = await self.real_path(session, config)
|
||||||
|
|
||||||
|
new_file = FileSystem(
|
||||||
|
file_path, await self.repository.real_path(session, config)
|
||||||
|
)
|
||||||
|
await new_file.remove()
|
||||||
|
|
||||||
|
await session.delete(self)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
async def relative_path(self, session: SessionContext) -> Optional[Path]:
|
||||||
|
if inspect(self).was_deleted:
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_path = Path()
|
||||||
|
|
||||||
|
async with session.begin_nested():
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["parent"])
|
||||||
|
|
||||||
|
if self.parent:
|
||||||
|
file_path = await self.parent.relative_path(session)
|
||||||
|
|
||||||
|
return file_path.joinpath(self.name)
|
||||||
|
|
||||||
|
async def real_path(
|
||||||
|
self, session: SessionContext, config: Config
|
||||||
|
) -> Optional[Path]:
|
||||||
|
if inspect(self).was_deleted:
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_path = Path()
|
||||||
|
|
||||||
|
async with session.begin_nested():
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["repository", "parent"])
|
||||||
|
|
||||||
|
if self.parent:
|
||||||
|
file_path = await self.parent.real_path(session, config)
|
||||||
|
else:
|
||||||
|
file_path = await self.repository.real_path(session, config)
|
||||||
|
|
||||||
|
return file_path.joinpath(self.name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def by_path(
|
||||||
|
repository: "Repository", path: Path, session: SessionContext, config: Config
|
||||||
|
) -> Optional[Self]:
|
||||||
|
if path == Path():
|
||||||
|
raise FileError("Cannot find file by empty path")
|
||||||
|
|
||||||
|
parent_directory = (
|
||||||
|
None
|
||||||
|
if path.parent == Path()
|
||||||
|
else await Directory.by_path(repository, path.parent, session, config)
|
||||||
|
)
|
||||||
|
|
||||||
|
current_file = (
|
||||||
|
await session.scalars(
|
||||||
|
sa.select(File).where(
|
||||||
|
sa.and_(
|
||||||
|
File.repository_id == repository.id,
|
||||||
|
File.name == path.name,
|
||||||
|
(
|
||||||
|
File.parent_id == parent_directory.id
|
||||||
|
if parent_directory
|
||||||
|
else File.parent_id.is_(None)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return current_file
|
||||||
|
|
||||||
|
async def copy(
|
||||||
|
self,
|
||||||
|
directory: Optional["Directory"],
|
||||||
|
session: SessionContext,
|
||||||
|
config: Config,
|
||||||
|
force: bool = False,
|
||||||
|
shallow: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["repository"])
|
||||||
|
|
||||||
|
repository_path = await self.repository.real_path(session, config)
|
||||||
|
file_path = await self.real_path(session, config)
|
||||||
|
directory_path = (
|
||||||
|
await directory.real_path(session, config) if directory else repository_path
|
||||||
|
)
|
||||||
|
|
||||||
|
current_file = FileSystem(file_path, repository_path)
|
||||||
|
new_file = await current_file.copy(directory_path, force=force, shallow=shallow)
|
||||||
|
|
||||||
|
cloned = self.clone()
|
||||||
|
cloned.name = new_file.name()
|
||||||
|
cloned.parent_id = directory.id if directory else None
|
||||||
|
session.add(cloned)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def move(
|
||||||
|
self,
|
||||||
|
directory: Optional["Directory"],
|
||||||
|
session: SessionContext,
|
||||||
|
config: Config,
|
||||||
|
force: bool = False,
|
||||||
|
shallow: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["repository"])
|
||||||
|
|
||||||
|
repository_path = await self.repository.real_path(session, config)
|
||||||
|
file_path = await self.real_path(session, config)
|
||||||
|
directory_path = (
|
||||||
|
await directory.real_path(session, config) if directory else repository_path
|
||||||
|
)
|
||||||
|
|
||||||
|
current_file = FileSystem(file_path, repository_path)
|
||||||
|
moved_file = await current_file.move(
|
||||||
|
directory_path, force=force, shallow=shallow
|
||||||
|
)
|
||||||
|
|
||||||
|
self.name = moved_file.name()
|
||||||
|
self.parent_id = directory.id if directory else None
|
||||||
|
self.updated = time()
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def rename(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
session: SessionContext,
|
||||||
|
config: Config,
|
||||||
|
force: bool = False,
|
||||||
|
shallow: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["repository"])
|
||||||
|
|
||||||
|
repository_path = await self.repository.real_path(session, config)
|
||||||
|
file_path = await self.real_path(session, config)
|
||||||
|
|
||||||
|
current_file = FileSystem(file_path, repository_path)
|
||||||
|
renamed_file = await current_file.rename(name, force=force, shallow=shallow)
|
||||||
|
|
||||||
|
self.name = renamed_file.name()
|
||||||
|
self.updated = time()
|
||||||
|
await session.flush()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def info(self, session: SessionContext) -> Optional["FileInfo"]:
|
||||||
|
info = FileInfo.model_validate(self)
|
||||||
|
relative_path = await self.relative_path(session)
|
||||||
|
info.path = Path("/").joinpath(relative_path) if relative_path else None
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def convert_bytes(size: int):
|
||||||
|
for unit in ["bytes", "kB", "MB", "GB", "TB"]:
|
||||||
|
if size < 1024:
|
||||||
|
return f"{size}{unit}" if unit == "bytes" else f"{size:.1f}{unit}"
|
||||||
|
size >>= 10
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
class FileInfo(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
repository_id: int
|
||||||
|
parent_id: Optional[int]
|
||||||
|
created: int
|
||||||
|
updated: int
|
||||||
|
name: str
|
||||||
|
is_public: bool
|
||||||
|
size: int
|
||||||
|
|
||||||
|
path: Optional[Path] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FilePath(BaseModel):
|
||||||
|
path: Path
|
||||||
|
|
||||||
|
|
||||||
|
class FileRename(BaseModel):
|
||||||
|
path: Path
|
||||||
|
name: str
|
||||||
|
force: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class FileCopyMove(BaseModel):
|
||||||
|
path: Path
|
||||||
|
target: Path
|
||||||
|
force: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
from materia.models.repository import Repository
|
||||||
|
from materia.models.directory import Directory
|
@ -1,30 +1,48 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
from alembic.config import Config
|
from alembic import context
|
||||||
from alembic.runtime.migration import MigrationContext
|
import alembic_postgresql_enum
|
||||||
|
|
||||||
from src.config import config as materia_config
|
from materia.core import Config
|
||||||
from src.db import Base
|
from materia.models.base import Base
|
||||||
|
import materia.models.user
|
||||||
|
import materia.models.auth
|
||||||
|
import materia.models.repository
|
||||||
|
import materia.models.directory
|
||||||
|
import materia.models.file
|
||||||
|
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
|
||||||
config = Config(Path("alembic.ini"))
|
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.
|
# Interpret the config file for Python logging.
|
||||||
# This line sets up loggers basically.
|
# This line sets up loggers basically.
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
"""Run migrations in 'offline' mode.
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
@ -38,13 +56,12 @@ def run_migrations_offline() -> None:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
context = MigrationContext.configure(
|
context.configure(
|
||||||
url=url,
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
dialect_opts={"paramstyle": "named"},
|
dialect_opts={"paramstyle": "named"},
|
||||||
opts = {
|
version_table_schema="public",
|
||||||
"target_metadata": target_metadata,
|
|
||||||
"literal_binds": True,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
@ -52,12 +69,7 @@ def run_migrations_offline() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection: Connection) -> None:
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
context = MigrationContext.configure(
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
connection = connection,
|
|
||||||
opts = {
|
|
||||||
"target_metadata": target_metadata,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
@ -87,7 +99,8 @@ def run_migrations_online() -> None:
|
|||||||
asyncio.run(run_async_migrations())
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
#if context.is_offline_mode():
|
if context.is_offline_mode():
|
||||||
#run_migrations_offline()
|
run_migrations_offline()
|
||||||
#else:
|
else:
|
||||||
#run_migrations_online()
|
print("online")
|
||||||
|
run_migrations_online()
|
0
src/materia/models/migrations/versions/.gitkeep
Normal file
0
src/materia/models/migrations/versions/.gitkeep
Normal file
139
src/materia/models/migrations/versions/bf2ef6c7ab70_.py
Normal file
139
src/materia/models/migrations/versions/bf2ef6c7ab70_.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: bf2ef6c7ab70
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-08-02 18:37:01.697075
|
||||||
|
|
||||||
|
"""
|
||||||
|
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 = 'bf2ef6c7ab70'
|
||||||
|
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=True),
|
||||||
|
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'], ondelete='CASCADE'),
|
||||||
|
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('is_public', sa.Boolean(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['parent_id'], ['directory.id'], ondelete='CASCADE'),
|
||||||
|
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'], ondelete='CASCADE'),
|
||||||
|
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 ###
|
131
src/materia/models/repository.py
Normal file
131
src/materia/models/repository.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
from typing import List, Self, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey
|
||||||
|
from sqlalchemy.orm import mapped_column, Mapped, relationship
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from materia.models.base import Base
|
||||||
|
from materia.core import SessionContext, Config
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Repository(Base):
|
||||||
|
__tablename__ = "repository"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))
|
||||||
|
capacity: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="repository")
|
||||||
|
directories: Mapped[List["Directory"]] = relationship(back_populates="repository")
|
||||||
|
files: Mapped[List["File"]] = relationship(back_populates="repository")
|
||||||
|
|
||||||
|
async def new(self, session: SessionContext, config: Config) -> Optional[Self]:
|
||||||
|
session.add(self)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
repository_path = await self.real_path(session, config)
|
||||||
|
relative_path = repository_path.relative_to(
|
||||||
|
config.application.working_directory
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
repository_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as e:
|
||||||
|
raise RepositoryError(
|
||||||
|
f"Failed to create repository at /{relative_path}:",
|
||||||
|
*e.args,
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def real_path(self, session: SessionContext, config: Config) -> Path:
|
||||||
|
"""Get absolute path of the directory."""
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["user"])
|
||||||
|
|
||||||
|
repository_path = config.application.working_directory.joinpath(
|
||||||
|
"repository", self.user.lower_name
|
||||||
|
)
|
||||||
|
|
||||||
|
return repository_path
|
||||||
|
|
||||||
|
async def remove(self, session: SessionContext, config: Config):
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["directories", "files"])
|
||||||
|
|
||||||
|
for directory in self.directories:
|
||||||
|
if directory.is_root():
|
||||||
|
await directory.remove(session)
|
||||||
|
|
||||||
|
for file in self.files:
|
||||||
|
await file.remove(session)
|
||||||
|
|
||||||
|
repository_path = await self.real_path(session, config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.rmtree(str(repository_path))
|
||||||
|
except OSError as e:
|
||||||
|
raise RepositoryError(
|
||||||
|
f"Failed to remove repository at /{repository_path.relative_to(config.application.working_directory)}:",
|
||||||
|
*e.args,
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.delete(self)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
async def update(self, session: SessionContext):
|
||||||
|
await session.execute(
|
||||||
|
sa.update(Repository).values(self.to_dict()).where(Repository.id == self.id)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def from_user(user: "User", session: SessionContext) -> Optional[Self]:
|
||||||
|
session.add(user)
|
||||||
|
await session.refresh(user, attribute_names=["repository"])
|
||||||
|
return user.repository
|
||||||
|
|
||||||
|
async def used_capacity(self, session: SessionContext) -> int:
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["files"])
|
||||||
|
|
||||||
|
return sum([file.size for file in self.files])
|
||||||
|
|
||||||
|
async def remaining_capacity(self, session: SessionContext) -> int:
|
||||||
|
used = await self.used_capacity(session)
|
||||||
|
return self.capacity - used
|
||||||
|
|
||||||
|
async def info(self, session: SessionContext) -> "RepositoryInfo":
|
||||||
|
info = RepositoryInfo.model_validate(self)
|
||||||
|
info.used = await self.used_capacity(session)
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryInfo(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
capacity: int
|
||||||
|
used: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryContent(BaseModel):
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
files: list["FileInfo"]
|
||||||
|
directories: list["DirectoryInfo"]
|
||||||
|
|
||||||
|
|
||||||
|
from materia.models.user import User
|
||||||
|
from materia.models.directory import Directory, DirectoryInfo
|
||||||
|
from materia.models.file import File, FileInfo
|
225
src/materia/models/user.py
Normal file
225
src/materia/models/user.py
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
from uuid import UUID, uuid4
|
||||||
|
from typing import Optional, Self, BinaryIO
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, ConfigDict
|
||||||
|
from sqlalchemy import BigInteger
|
||||||
|
from sqlalchemy.orm import mapped_column, Mapped, relationship
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from PIL import Image
|
||||||
|
from sqids.sqids import Sqids
|
||||||
|
from aiofiles import os as async_os
|
||||||
|
|
||||||
|
from materia import security
|
||||||
|
from materia.models.base import Base
|
||||||
|
from materia.models.auth.source import LoginType
|
||||||
|
from materia.core import SessionContext, Config, FileSystem
|
||||||
|
|
||||||
|
valid_username = re.compile(r"^[\da-zA-Z][-.\w]*$")
|
||||||
|
invalid_username = re.compile(r"[-._]{2,}|[-._]$")
|
||||||
|
|
||||||
|
|
||||||
|
class UserError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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[Optional[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")
|
||||||
|
|
||||||
|
async def new(self, session: SessionContext, config: Config) -> Optional[Self]:
|
||||||
|
# Provide checks outer
|
||||||
|
|
||||||
|
session.add(self)
|
||||||
|
await session.flush()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def remove(self, session: SessionContext):
|
||||||
|
session.add(self)
|
||||||
|
await session.refresh(self, attribute_names=["repository"])
|
||||||
|
|
||||||
|
if self.repository:
|
||||||
|
await self.repository.remove()
|
||||||
|
|
||||||
|
await session.delete(self)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
def update_last_login(self):
|
||||||
|
self.last_login = int(time.time())
|
||||||
|
|
||||||
|
def is_local(self) -> bool:
|
||||||
|
return self.login_type == LoginType.Plain
|
||||||
|
|
||||||
|
def is_oauth2(self) -> bool:
|
||||||
|
return self.login_type == LoginType.OAuth2
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_username(name: str) -> bool:
|
||||||
|
return bool(valid_username.match(name) and not invalid_username.match(name))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_password(password: str, config: Config) -> bool:
|
||||||
|
return not len(password) < config.security.password_min_length
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def count(session: SessionContext) -> Optional[int]:
|
||||||
|
return await session.scalar(sa.select(sa.func.count(User.id)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def by_name(
|
||||||
|
name: str, session: SessionContext, with_lower: bool = False
|
||||||
|
) -> Optional[Self]:
|
||||||
|
if with_lower:
|
||||||
|
query = User.lower_name == name.lower()
|
||||||
|
else:
|
||||||
|
query = User.name == name
|
||||||
|
return (await session.scalars(sa.select(User).where(query))).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def by_email(email: str, session: SessionContext) -> Optional[Self]:
|
||||||
|
return (
|
||||||
|
await session.scalars(sa.select(User).where(User.email == email))
|
||||||
|
).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def by_id(id: UUID, session: SessionContext) -> Optional[Self]:
|
||||||
|
return (await session.scalars(sa.select(User).where(User.id == id))).first()
|
||||||
|
|
||||||
|
async def edit_name(self, name: str, session: SessionContext) -> Self:
|
||||||
|
if not User.check_username(name):
|
||||||
|
raise UserError(f"Invalid username: {name}")
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.lower_name = name.lower()
|
||||||
|
session.add(self)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def edit_password(
|
||||||
|
self, password: str, session: SessionContext, config: Config
|
||||||
|
) -> Self:
|
||||||
|
if not User.check_password(password, config):
|
||||||
|
raise UserError("Invalid password")
|
||||||
|
|
||||||
|
self.hashed_password = security.hash_password(
|
||||||
|
password, algo=config.security.password_hash_algo
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(self)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def edit_email(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def info(self) -> "UserInfo":
|
||||||
|
user_info = UserInfo.model_validate(self)
|
||||||
|
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
async def edit_avatar(
|
||||||
|
self, avatar: BinaryIO | None, session: SessionContext, config: Config
|
||||||
|
):
|
||||||
|
avatar_dir = config.application.working_directory.joinpath("avatars")
|
||||||
|
|
||||||
|
if avatar is None:
|
||||||
|
if self.avatar is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
avatar_file = FileSystem(
|
||||||
|
avatar_dir.joinpath(self.avatar), config.application.working_directory
|
||||||
|
)
|
||||||
|
if await avatar_file.exists():
|
||||||
|
await avatar_file.remove()
|
||||||
|
|
||||||
|
session.add(self)
|
||||||
|
self.avatar = None
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = Image.open(avatar)
|
||||||
|
except Exception as e:
|
||||||
|
raise UserError("Failed to read avatar data") from e
|
||||||
|
|
||||||
|
avatar_hashes: list[str] = (
|
||||||
|
await session.scalars(sa.select(User.avatar).where(User.avatar.isnot(None)))
|
||||||
|
).all()
|
||||||
|
avatar_id = Sqids(min_length=10, blocklist=avatar_hashes).encode(
|
||||||
|
[int(time.time())]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not avatar_dir.exists():
|
||||||
|
await async_os.mkdir(avatar_dir)
|
||||||
|
image.save(avatar_dir.joinpath(avatar_id), format=image.format)
|
||||||
|
except Exception as e:
|
||||||
|
raise UserError(f"Failed to save avatar: {e}") from e
|
||||||
|
|
||||||
|
if old_avatar := self.avatar:
|
||||||
|
avatar_file = FileSystem(
|
||||||
|
avatar_dir.joinpath(old_avatar), config.application.working_directory
|
||||||
|
)
|
||||||
|
if await avatar_file.exists():
|
||||||
|
await avatar_file.remove()
|
||||||
|
|
||||||
|
session.add(self)
|
||||||
|
self.avatar = avatar_id
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class UserCredentials(BaseModel):
|
||||||
|
name: str
|
||||||
|
password: str
|
||||||
|
email: Optional[EmailStr]
|
||||||
|
|
||||||
|
|
||||||
|
class UserInfo(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
lower_name: str
|
||||||
|
full_name: Optional[str]
|
||||||
|
email: Optional[str]
|
||||||
|
is_email_private: bool
|
||||||
|
must_change_password: bool
|
||||||
|
|
||||||
|
login_type: "LoginType"
|
||||||
|
|
||||||
|
created: int
|
||||||
|
updated: int
|
||||||
|
last_login: Optional[int]
|
||||||
|
|
||||||
|
is_active: bool
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
avatar: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
from materia.models.repository import Repository
|
1
src/materia/routers/__init__.py
Normal file
1
src/materia/routers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from materia.routers import middleware, api, resources, root, docs
|
17
src/materia/routers/api/__init__.py
Normal file
17
src/materia/routers/api/__init__.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from materia.routers.api.auth import auth, oauth
|
||||||
|
from materia.routers.api import docs, user, repository, directory, file
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
router.include_router(docs.router)
|
||||||
|
router.include_router(auth.router)
|
||||||
|
router.include_router(oauth.router)
|
||||||
|
router.include_router(user.router)
|
||||||
|
router.include_router(repository.router)
|
||||||
|
router.include_router(directory.router)
|
||||||
|
router.include_router(file.router)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/{catchall:path}", status_code=404, include_in_schema=False)
|
||||||
|
def not_found():
|
||||||
|
raise HTTPException(status_code=404)
|
0
src/materia/routers/api/auth/__init__.py
Normal file
0
src/materia/routers/api/auth/__init__.py
Normal file
100
src/materia/routers/api/auth/auth.py
Normal file
100
src/materia/routers/api/auth/auth.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
|
|
||||||
|
from materia import security
|
||||||
|
from materia.routers.middleware import Context
|
||||||
|
from materia.models import LoginType, User, UserCredentials
|
||||||
|
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/signup")
|
||||||
|
async def signup(body: UserCredentials, ctx: Context = Depends()):
|
||||||
|
if not User.check_username(body.name):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid username"
|
||||||
|
)
|
||||||
|
if not User.check_password(body.password, ctx.config):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Password is too short (minimum length {ctx.config.security.password_min_length})",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
if await User.by_name(body.name, session, with_lower=True):
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, detail="User already exists")
|
||||||
|
if await User.by_email(body.email, session): # type: ignore
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, detail="Email already used")
|
||||||
|
|
||||||
|
count: Optional[int] = await User.count(session)
|
||||||
|
|
||||||
|
await User(
|
||||||
|
name=body.name,
|
||||||
|
lower_name=body.name.lower(),
|
||||||
|
full_name=body.name,
|
||||||
|
email=body.email,
|
||||||
|
hashed_password=security.hash_password(
|
||||||
|
body.password, algo=ctx.config.security.password_hash_algo
|
||||||
|
),
|
||||||
|
login_type=LoginType.Plain,
|
||||||
|
# first registered user is admin
|
||||||
|
is_admin=count == 0,
|
||||||
|
).new(session, ctx.config)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/signin")
|
||||||
|
async def signin(body: UserCredentials, response: Response, ctx: Context = Depends()):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
if (current_user := await User.by_name(body.name, session)) is None:
|
||||||
|
if (current_user := await User.by_email(str(body.email), session)) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_401_UNAUTHORIZED, detail="Invalid email"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not security.validate_password(
|
||||||
|
body.password,
|
||||||
|
current_user.hashed_password,
|
||||||
|
algo=ctx.config.security.password_hash_algo,
|
||||||
|
):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
|
||||||
|
|
||||||
|
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 = Depends()):
|
||||||
|
response.delete_cookie(ctx.config.security.cookie_access_token_name)
|
||||||
|
response.delete_cookie(ctx.config.security.cookie_refresh_token_name)
|
82
src/materia/routers/api/auth/oauth.py
Normal file
82
src/materia/routers/api/auth/oauth.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
|
||||||
|
|
||||||
|
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.models import User
|
||||||
|
from materia.routers.middleware 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 = 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 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 := await 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 = Depends()):
|
||||||
|
pass
|
198
src/materia/routers/api/directory.py
Normal file
198
src/materia/routers/api/directory.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from materia.models import (
|
||||||
|
User,
|
||||||
|
Directory,
|
||||||
|
DirectoryInfo,
|
||||||
|
DirectoryContent,
|
||||||
|
DirectoryPath,
|
||||||
|
DirectoryRename,
|
||||||
|
DirectoryCopyMove,
|
||||||
|
Repository,
|
||||||
|
)
|
||||||
|
from materia.core import SessionContext, Config, FileSystem
|
||||||
|
from materia.routers import middleware
|
||||||
|
|
||||||
|
router = APIRouter(tags=["directory"])
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_current_directory(
|
||||||
|
path: Path, repository: Repository, session: SessionContext, config: Config
|
||||||
|
) -> Directory:
|
||||||
|
if not FileSystem.check_path(path):
|
||||||
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path")
|
||||||
|
|
||||||
|
if not (
|
||||||
|
directory := await Directory.by_path(
|
||||||
|
repository,
|
||||||
|
FileSystem.normalize(path),
|
||||||
|
session,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory not found")
|
||||||
|
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_target_directory(
|
||||||
|
path: Path, repository: Repository, session: SessionContext, config: Config
|
||||||
|
) -> Directory:
|
||||||
|
if not FileSystem.check_path(path):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid target path"
|
||||||
|
)
|
||||||
|
|
||||||
|
if FileSystem.normalize(path) == Path():
|
||||||
|
# mean repository root
|
||||||
|
target_directory = None
|
||||||
|
else:
|
||||||
|
if not (
|
||||||
|
target_directory := await Directory.by_path(
|
||||||
|
repository,
|
||||||
|
FileSystem.normalize(path),
|
||||||
|
session,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Target directory not found")
|
||||||
|
|
||||||
|
return target_directory
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/directory")
|
||||||
|
async def create(
|
||||||
|
path: DirectoryPath,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
if not FileSystem.check_path(path.path):
|
||||||
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path")
|
||||||
|
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
current_directory = None
|
||||||
|
current_path = Path()
|
||||||
|
directory = None
|
||||||
|
|
||||||
|
for part in FileSystem.normalize(path.path).parts:
|
||||||
|
if not (
|
||||||
|
directory := await Directory.by_path(
|
||||||
|
repository, current_path.joinpath(part), session, ctx.config
|
||||||
|
)
|
||||||
|
):
|
||||||
|
directory = await Directory(
|
||||||
|
repository_id=repository.id,
|
||||||
|
parent_id=current_directory.id if current_directory else None,
|
||||||
|
name=part,
|
||||||
|
).new(session, ctx.config)
|
||||||
|
|
||||||
|
current_directory = directory
|
||||||
|
current_path /= part
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/directory")
|
||||||
|
async def info(
|
||||||
|
path: Path,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
directory = await validate_current_directory(
|
||||||
|
path, repository, session, ctx.config
|
||||||
|
)
|
||||||
|
|
||||||
|
info = await directory.info(session)
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/directory")
|
||||||
|
async def remove(
|
||||||
|
path: Path,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
directory = await validate_current_directory(
|
||||||
|
path, repository, session, ctx.config
|
||||||
|
)
|
||||||
|
|
||||||
|
await directory.remove(session, ctx.config)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/directory/rename")
|
||||||
|
async def rename(
|
||||||
|
data: DirectoryRename,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
directory = await validate_current_directory(
|
||||||
|
data.path, repository, session, ctx.config
|
||||||
|
)
|
||||||
|
|
||||||
|
await directory.rename(data.name, session, ctx.config, force=data.force)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/directory/move")
|
||||||
|
async def move(
|
||||||
|
data: DirectoryCopyMove,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
directory = await validate_current_directory(
|
||||||
|
data.path, repository, session, ctx.config
|
||||||
|
)
|
||||||
|
target_directory = await validate_target_directory(
|
||||||
|
data.target, repository, session, ctx.config
|
||||||
|
)
|
||||||
|
|
||||||
|
await directory.move(target_directory, session, ctx.config, force=data.force)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/directory/copy")
|
||||||
|
async def copy(
|
||||||
|
data: DirectoryCopyMove,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
directory = await validate_current_directory(
|
||||||
|
data.path, repository, session, ctx.config
|
||||||
|
)
|
||||||
|
target_directory = await validate_target_directory(
|
||||||
|
data.target, repository, session, ctx.config
|
||||||
|
)
|
||||||
|
|
||||||
|
await directory.copy(target_directory, session, ctx.config, force=data.force)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/directory/content", response_model=DirectoryContent)
|
||||||
|
async def content(
|
||||||
|
path: Path,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
directory = await validate_current_directory(
|
||||||
|
path, repository, session, ctx.config
|
||||||
|
)
|
||||||
|
session.add(directory)
|
||||||
|
await session.refresh(directory, attribute_names=["directories"])
|
||||||
|
await session.refresh(directory, attribute_names=["files"])
|
||||||
|
|
||||||
|
content = DirectoryContent(
|
||||||
|
files=[await _file.info(session) for _file in directory.files],
|
||||||
|
directories=[
|
||||||
|
await _directory.info(session) for _directory in directory.directories
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return content
|
40
src/materia/routers/api/docs.py
Normal file
40
src/materia/routers/api/docs.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/docs", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def rapidoc(request: Request):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link href='http://fonts.googleapis.com/css?family=Roboto' rel='stylesheet' type='text/css'>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"
|
||||||
|
></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<rapi-doc
|
||||||
|
spec-url="{request.app.openapi_url}"
|
||||||
|
theme = "dark"
|
||||||
|
show-header = "false"
|
||||||
|
show-info = "true"
|
||||||
|
allow-authentication = "true"
|
||||||
|
allow-server-selection = "true"
|
||||||
|
allow-api-list-style-selection = "true"
|
||||||
|
theme = "dark"
|
||||||
|
render-style = "focused"
|
||||||
|
bg-color="#1e2129"
|
||||||
|
primary-color="#a47bea"
|
||||||
|
regular-font="Roboto"
|
||||||
|
mono-font="Roboto Mono"
|
||||||
|
show-method-in-nav-bar="as-colored-text">
|
||||||
|
<img slot="logo" style="display: none"/>
|
||||||
|
</rapi-doc>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
223
src/materia/routers/api/file.py
Normal file
223
src/materia/routers/api/file.py
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
from typing import Annotated, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import (
|
||||||
|
Request,
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
HTTPException,
|
||||||
|
status,
|
||||||
|
UploadFile,
|
||||||
|
File as _File,
|
||||||
|
Form,
|
||||||
|
)
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from materia.models import (
|
||||||
|
User,
|
||||||
|
File,
|
||||||
|
FileInfo,
|
||||||
|
Directory,
|
||||||
|
Repository,
|
||||||
|
FileRename,
|
||||||
|
FileCopyMove,
|
||||||
|
)
|
||||||
|
from materia.core import (
|
||||||
|
SessionContext,
|
||||||
|
Config,
|
||||||
|
FileSystem,
|
||||||
|
TemporaryFileTarget,
|
||||||
|
Database,
|
||||||
|
)
|
||||||
|
from materia.routers import middleware
|
||||||
|
from materia.routers.api.directory import validate_target_directory
|
||||||
|
from streaming_form_data import StreamingFormDataParser
|
||||||
|
from streaming_form_data.targets import ValueTarget
|
||||||
|
from starlette.requests import ClientDisconnect
|
||||||
|
from aiofiles import ospath as async_path
|
||||||
|
from materia.tasks import remove_cache_file
|
||||||
|
|
||||||
|
router = APIRouter(tags=["file"])
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_current_file(
|
||||||
|
path: Path, repository: Repository, session: SessionContext, config: Config
|
||||||
|
) -> Directory:
|
||||||
|
if not FileSystem.check_path(path):
|
||||||
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path")
|
||||||
|
|
||||||
|
if not (
|
||||||
|
file := await File.by_path(
|
||||||
|
repository,
|
||||||
|
FileSystem.normalize(path),
|
||||||
|
session,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found")
|
||||||
|
|
||||||
|
return file
|
||||||
|
|
||||||
|
|
||||||
|
class FileSizeValidator:
|
||||||
|
def __init__(self, capacity: int):
|
||||||
|
self.body = 0
|
||||||
|
self.capacity = capacity
|
||||||
|
|
||||||
|
def __call__(self, chunk: bytes):
|
||||||
|
self.body += len(chunk)
|
||||||
|
if self.body > self.capacity:
|
||||||
|
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/file", openapi_extra={
|
||||||
|
"requestBody" : {
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"required": ["file", "path"],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": { "type": "string", "format": "binary" },
|
||||||
|
"path": { "type": "string", "format": "path", "example": "/"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": True
|
||||||
|
}
|
||||||
|
})
|
||||||
|
async def create(
|
||||||
|
request: Request,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
capacity = await repository.remaining_capacity(session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
file = TemporaryFileTarget(
|
||||||
|
ctx.config.application.working_directory,
|
||||||
|
validator=FileSizeValidator(capacity),
|
||||||
|
)
|
||||||
|
path = ValueTarget()
|
||||||
|
|
||||||
|
ctx.logger.debug(f"Shedule remove cache file: {file.path().name}")
|
||||||
|
remove_cache_file.apply_async(args=(file.path(), ctx.config), countdown=10)
|
||||||
|
|
||||||
|
parser = StreamingFormDataParser(headers=request.headers)
|
||||||
|
parser.register("file", file)
|
||||||
|
parser.register("path", path)
|
||||||
|
|
||||||
|
async for chunk in request.stream():
|
||||||
|
parser.data_received(chunk)
|
||||||
|
|
||||||
|
except ClientDisconnect:
|
||||||
|
file.remove()
|
||||||
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Client disconnect")
|
||||||
|
except HTTPException as e:
|
||||||
|
file.remove()
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
file.remove()
|
||||||
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, " ".join(e.args))
|
||||||
|
|
||||||
|
path = Path(path.value.decode())
|
||||||
|
|
||||||
|
if not file.multipart_filename:
|
||||||
|
file.remove()
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_417_EXPECTATION_FAILED, "Cannot upload file without name"
|
||||||
|
)
|
||||||
|
if not FileSystem.check_path(path):
|
||||||
|
file.remove()
|
||||||
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path")
|
||||||
|
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
target_directory = await validate_target_directory(
|
||||||
|
path, repository, session, ctx.config
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await File(
|
||||||
|
repository_id=repository.id,
|
||||||
|
parent_id=target_directory.id if target_directory else None,
|
||||||
|
name=file.multipart_filename,
|
||||||
|
size=await async_path.getsize(file.path()),
|
||||||
|
).new(file.path(), session, ctx.config)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to create file"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/file", response_model=FileInfo)
|
||||||
|
async def info(
|
||||||
|
path: Path,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
file = await validate_current_file(path, repository, session, ctx.config)
|
||||||
|
|
||||||
|
info = file.info()
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/file")
|
||||||
|
async def remove(
|
||||||
|
path: Path,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
file = await validate_current_file(path, repository, session, ctx.config)
|
||||||
|
|
||||||
|
await file.remove(session, ctx.config)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/file/rename")
|
||||||
|
async def rename(
|
||||||
|
data: FileRename,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
file = await validate_current_file(data.path, repository, session, ctx.config)
|
||||||
|
|
||||||
|
await file.rename(data.name, session, ctx.config, force=data.force)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/file/move")
|
||||||
|
async def move(
|
||||||
|
data: FileCopyMove,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
file = await validate_current_file(data.path, repository, session, ctx.config)
|
||||||
|
target_directory = await validate_target_directory(
|
||||||
|
data.target, repository, session, ctx.config
|
||||||
|
)
|
||||||
|
|
||||||
|
await file.move(target_directory, session, ctx.config, force=data.force)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/file/copy")
|
||||||
|
async def copy(
|
||||||
|
data: FileCopyMove,
|
||||||
|
repository: Repository = Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
file = await validate_current_file(data.path, repository, session, ctx.config)
|
||||||
|
target_directory = await validate_target_directory(
|
||||||
|
data.target, repository, session, ctx.config
|
||||||
|
)
|
||||||
|
|
||||||
|
await file.copy(target_directory, session, ctx.config, force=data.force)
|
||||||
|
await session.commit()
|
73
src/materia/routers/api/repository.py
Normal file
73
src/materia/routers/api/repository.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from materia.models import (
|
||||||
|
User,
|
||||||
|
Repository,
|
||||||
|
RepositoryInfo,
|
||||||
|
RepositoryContent,
|
||||||
|
FileInfo,
|
||||||
|
DirectoryInfo,
|
||||||
|
)
|
||||||
|
from materia.routers import middleware
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["repository"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/repository")
|
||||||
|
async def create(
|
||||||
|
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
if await Repository.from_user(user, session):
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "Repository already exists")
|
||||||
|
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
try:
|
||||||
|
await Repository(
|
||||||
|
user_id=user.id, capacity=ctx.config.repository.capacity
|
||||||
|
).new(session, ctx.config)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=" ".join(e.args)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repository", response_model=RepositoryInfo)
|
||||||
|
async def info(
|
||||||
|
repository=Depends(middleware.repository), ctx: middleware.Context = Depends()
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
return await repository.info(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/repository")
|
||||||
|
async def remove(
|
||||||
|
repository=Depends(middleware.repository),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
await repository.remove(session, ctx.config)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, f"{e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/repository/content", response_model=RepositoryContent)
|
||||||
|
async def content(
|
||||||
|
repository=Depends(middleware.repository), ctx: middleware.Context = Depends()
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
session.add(repository)
|
||||||
|
await session.refresh(repository, attribute_names=["directories"])
|
||||||
|
await session.refresh(repository, attribute_names=["files"])
|
||||||
|
|
||||||
|
content = RepositoryContent(
|
||||||
|
files=[await _file.info(session) for _file in repository.files],
|
||||||
|
directories=[
|
||||||
|
await _directory.info(session) for _directory in repository.directories
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return content
|
16
src/materia/routers/api/tasks.py
Normal file
16
src/materia/routers/api/tasks.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from celery.result import AsyncResult
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/${task_id}")
|
||||||
|
async def status_task(task_id):
|
||||||
|
task_result = AsyncResult(task_id)
|
||||||
|
result = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"task_status": task_result.status,
|
||||||
|
"task_result": task_result.result,
|
||||||
|
}
|
||||||
|
return JSONResponse(result)
|
67
src/materia/routers/api/user.py
Normal file
67
src/materia/routers/api/user.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import uuid
|
||||||
|
import io
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile
|
||||||
|
from materia.models import User, UserInfo
|
||||||
|
from materia.routers import middleware
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["user"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user", response_model=UserInfo)
|
||||||
|
async def info(
|
||||||
|
claims=Depends(middleware.jwt_cookie), ctx: middleware.Context = Depends()
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
if not (current_user := await User.by_id(uuid.UUID(claims.sub), session)):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing user")
|
||||||
|
|
||||||
|
return current_user.info()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/user")
|
||||||
|
async def remove(
|
||||||
|
user: User = Depends(middleware.user), ctx: middleware.Context = Depends()
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
await user.remove(session)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR, f"Failed to remove user: {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/user/avatar")
|
||||||
|
async def avatar(
|
||||||
|
file: UploadFile,
|
||||||
|
user: User = Depends(middleware.user),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
try:
|
||||||
|
await user.edit_avatar(io.BytesIO(await file.read()), session, ctx.config)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
f"{e}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/user/avatar")
|
||||||
|
async def remove_avatar(
|
||||||
|
user: User = Depends(middleware.user),
|
||||||
|
ctx: middleware.Context = Depends(),
|
||||||
|
):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
try:
|
||||||
|
await user.edit_avatar(None, session, ctx.config)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
f"{e}",
|
||||||
|
)
|
33
src/materia/routers/docs.py
Normal file
33
src/materia/routers/docs.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from fastapi import APIRouter, Request, Response, status, HTTPException, Depends
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
import mimetypes
|
||||||
|
from pathlib import Path
|
||||||
|
from materia.core.misc import optional
|
||||||
|
from materia.routers import middleware
|
||||||
|
|
||||||
|
from materia import docs as materia_docs
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# templates = Jinja2Templates(directory=Path(materia_docs.__path__[0]))
|
||||||
|
# p = Path(__file__).parent.joinpath("..", "docs").resolve()
|
||||||
|
# router.mount("/docs", StaticFiles(directory="doces", html=True), name="docs")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/docs/{catchall:path}", include_in_schema=False)
|
||||||
|
async def docs(request: Request, ctx: middleware.Context = Depends()):
|
||||||
|
docs_directory = Path(materia_docs.__path__[0]).resolve()
|
||||||
|
target = docs_directory.joinpath(request.path_params["catchall"]).resolve()
|
||||||
|
|
||||||
|
if not optional(target.relative_to, docs_directory):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
if target.is_dir() and (index := target.joinpath("index.html")).is_file():
|
||||||
|
return FileResponse(index)
|
||||||
|
|
||||||
|
if not target.is_file():
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
return FileResponse(target)
|
112
src/materia/routers/middleware.py
Normal file
112
src/materia/routers/middleware.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import HTTPException, Request, Response, status, Depends
|
||||||
|
from fastapi.security.base import SecurityBase
|
||||||
|
import jwt
|
||||||
|
from sqlalchemy import select
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from enum import StrEnum
|
||||||
|
from http import HTTPMethod as HttpMethod
|
||||||
|
from fastapi.security import (
|
||||||
|
HTTPBearer,
|
||||||
|
OAuth2PasswordBearer,
|
||||||
|
OAuth2PasswordRequestForm,
|
||||||
|
APIKeyQuery,
|
||||||
|
APIKeyCookie,
|
||||||
|
APIKeyHeader,
|
||||||
|
)
|
||||||
|
|
||||||
|
from materia import security
|
||||||
|
from materia.models import User, Repository
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def jwt_cookie(request: Request, response: Response, ctx: Context = Depends()):
|
||||||
|
if not (
|
||||||
|
access_token := request.cookies.get(
|
||||||
|
ctx.config.security.cookie_access_token_name
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing token")
|
||||||
|
refresh_token = request.cookies.get(ctx.config.security.cookie_refresh_token_name)
|
||||||
|
|
||||||
|
if ctx.config.oauth2.jwt_signing_algo in ["HS256", "HS384", "HS512"]:
|
||||||
|
secret = ctx.config.oauth2.jwt_secret
|
||||||
|
else:
|
||||||
|
secret = ctx.config.oauth2.jwt_signing_key
|
||||||
|
|
||||||
|
issuer = "{}://{}".format(ctx.config.server.scheme, ctx.config.server.domain)
|
||||||
|
|
||||||
|
try:
|
||||||
|
refresh_claims = (
|
||||||
|
security.validate_token(refresh_token, secret) if refresh_token else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if refresh_claims:
|
||||||
|
if refresh_claims.exp < datetime.now().timestamp():
|
||||||
|
refresh_claims = None
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
refresh_claims = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
access_claims = security.validate_token(access_token, secret)
|
||||||
|
|
||||||
|
if access_claims.exp < datetime.now().timestamp():
|
||||||
|
if refresh_claims:
|
||||||
|
new_access_token = security.generate_token(
|
||||||
|
access_claims.sub,
|
||||||
|
str(secret),
|
||||||
|
ctx.config.oauth2.access_token_lifetime,
|
||||||
|
issuer,
|
||||||
|
)
|
||||||
|
access_claims = security.validate_token(new_access_token, secret)
|
||||||
|
response.set_cookie(
|
||||||
|
ctx.config.security.cookie_access_token_name,
|
||||||
|
value=new_access_token,
|
||||||
|
max_age=ctx.config.oauth2.access_token_lifetime,
|
||||||
|
secure=True,
|
||||||
|
httponly=ctx.config.security.cookie_http_only,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
access_claims = None
|
||||||
|
except jwt.PyJWTError as e:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}")
|
||||||
|
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
if not await User.by_id(uuid.UUID(access_claims.sub), session):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid user")
|
||||||
|
|
||||||
|
return access_claims
|
||||||
|
|
||||||
|
|
||||||
|
async def user(claims=Depends(jwt_cookie), ctx: Context = Depends()) -> User:
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
if not (current_user := await User.by_id(uuid.UUID(claims.sub), session)):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing user")
|
||||||
|
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
async def repository(user: User = Depends(user), ctx: Context = Depends()):
|
||||||
|
async with ctx.database.session() as session:
|
||||||
|
session.add(user)
|
||||||
|
await session.refresh(user, attribute_names=["repository"])
|
||||||
|
|
||||||
|
if not (repository := user.repository):
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
|
||||||
|
|
||||||
|
return repository
|
||||||
|
|
||||||
|
|
||||||
|
async def repository_path(user: User = Depends(user), ctx: Context = Depends()) -> Path:
|
||||||
|
return ctx.config.data_dir() / "repository" / user.lower_name
|
60
src/materia/routers/resources.py
Normal file
60
src/materia/routers/resources.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
from materia.routers import middleware
|
||||||
|
from materia.core import Config
|
||||||
|
|
||||||
|
router = APIRouter(tags=["resources"], prefix="/resources")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/avatars/{avatar_id}")
|
||||||
|
async def avatar(
|
||||||
|
avatar_id: str, format: str = "png", ctx: middleware.Context = Depends()
|
||||||
|
):
|
||||||
|
avatar_path = Config.data_dir() / "avatars" / avatar_id
|
||||||
|
format = format.upper()
|
||||||
|
|
||||||
|
if not avatar_path.exists():
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND, "Failed to find the given avatar"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(avatar_path)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
|
||||||
|
if format == "JPEG":
|
||||||
|
img.convert("RGB")
|
||||||
|
|
||||||
|
img.save(buffer, format=format)
|
||||||
|
|
||||||
|
except OSError:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_422_UNPROCESSABLE_ENTITY, "Failed to process image file"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(content=buffer.getvalue(), media_type=Image.MIME[format])
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import materia_frontend
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
|
||||||
|
@router.get("/assets/{filename}")
|
||||||
|
async def assets(filename: str):
|
||||||
|
path = Path(materia_frontend.__path__[0]).joinpath(
|
||||||
|
"dist", "resources", "assets", filename
|
||||||
|
)
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
return Response(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
content = path.read_bytes()
|
||||||
|
mime = mimetypes.guess_type(path)[0]
|
||||||
|
|
||||||
|
return Response(content, media_type=mime)
|
19
src/materia/routers/root.py
Normal file
19
src/materia/routers/root.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
router = APIRouter(tags=["root"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
import materia_frontend
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=Path(materia_frontend.__path__[0]) / "dist")
|
||||||
|
|
||||||
|
@router.get("/{spa:path}", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def root(request: Request):
|
||||||
|
# raise HTTPException(404)
|
||||||
|
return templates.TemplateResponse(request, "base.html", {"view": "app"})
|
3
src/materia/security/__init__.py
Normal file
3
src/materia/security/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from materia.security.secret_key import generate_key, encrypt_payload
|
||||||
|
from materia.security.token import TokenClaims, generate_token, validate_token
|
||||||
|
from materia.security.password import hash_password, validate_password
|
19
src/materia/security/password.py
Normal file
19
src/materia/security/password.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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 NotImplementedError(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 NotImplementedError(algo)
|
17
src/materia/security/secret_key.py
Normal file
17
src/materia/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
|
||||||
|
|
29
src/materia/security/token.py
Normal file
29
src/materia/security/token.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
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)
|
1
src/materia/tasks/__init__.py
Normal file
1
src/materia/tasks/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from materia.tasks.file import remove_cache_file
|
17
src/materia/tasks/file.py
Normal file
17
src/materia/tasks/file.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from materia.core import Cron, CronError, SessionContext, Config, Database
|
||||||
|
from celery import shared_task
|
||||||
|
from fastapi import UploadFile
|
||||||
|
from materia.models import File
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from materia.core import FileSystem, Config
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="remove_cache_file")
|
||||||
|
def remove_cache_file(path: Path, config: Config):
|
||||||
|
target = FileSystem(path, config.application.working_directory.joinpath("cache"))
|
||||||
|
|
||||||
|
async def wrapper():
|
||||||
|
await target.remove()
|
||||||
|
|
||||||
|
asyncio.run(wrapper())
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
166
tests/conftest.py
Normal file
166
tests/conftest.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import pytest_asyncio
|
||||||
|
from materia.models import (
|
||||||
|
User,
|
||||||
|
LoginType,
|
||||||
|
)
|
||||||
|
from materia.models.base import Base
|
||||||
|
from materia import security
|
||||||
|
from materia.app import Application
|
||||||
|
from materia.core import Config, Database, Cache, Cron
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
from httpx import AsyncClient, ASGITransport, Cookies
|
||||||
|
from asgi_lifespan import LifespanManager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def config() -> Config:
|
||||||
|
conf = Config()
|
||||||
|
conf.database.port = 54320
|
||||||
|
conf.cache.port = 63790
|
||||||
|
|
||||||
|
return conf
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def database(config: Config) -> Database:
|
||||||
|
config_postgres = config
|
||||||
|
config_postgres.database.user = "postgres"
|
||||||
|
config_postgres.database.name = "postgres"
|
||||||
|
database_postgres = await Database.new(
|
||||||
|
config_postgres.database.url(), poolclass=NullPool
|
||||||
|
)
|
||||||
|
|
||||||
|
async with database_postgres.connection() as connection:
|
||||||
|
await connection.execution_options(isolation_level="AUTOCOMMIT")
|
||||||
|
await connection.execute(sa.text("create role pytest login"))
|
||||||
|
await connection.execute(sa.text("create database pytest owner pytest"))
|
||||||
|
await connection.commit()
|
||||||
|
|
||||||
|
await database_postgres.dispose()
|
||||||
|
|
||||||
|
config.database.user = "pytest"
|
||||||
|
config.database.name = "pytest"
|
||||||
|
database_pytest = await Database.new(config.database.url(), poolclass=NullPool)
|
||||||
|
|
||||||
|
yield database_pytest
|
||||||
|
|
||||||
|
await database_pytest.dispose()
|
||||||
|
|
||||||
|
async with database_postgres.connection() as connection:
|
||||||
|
await connection.execution_options(isolation_level="AUTOCOMMIT")
|
||||||
|
await connection.execute(sa.text("drop database pytest")),
|
||||||
|
await connection.execute(sa.text("drop role pytest"))
|
||||||
|
await connection.commit()
|
||||||
|
await database_postgres.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def cache(config: Config) -> Cache:
|
||||||
|
config_pytest = config
|
||||||
|
config_pytest.cache.user = "pytest"
|
||||||
|
cache_pytest = await Cache.new(config_pytest.cache.url())
|
||||||
|
|
||||||
|
yield cache_pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def cron(config: Config) -> Cache:
|
||||||
|
cron_pytest = Cron.new(
|
||||||
|
config.cron.workers_count,
|
||||||
|
backend_url=config.cache.url(),
|
||||||
|
broker_url=config.cache.url(),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield cron_pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="function", autouse=True)
|
||||||
|
async def setup_database(database: Database):
|
||||||
|
async with database.connection() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
await connection.commit()
|
||||||
|
yield
|
||||||
|
async with database.connection() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.drop_all)
|
||||||
|
await connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def session(database: Database, request):
|
||||||
|
session = database.sessionmaker()
|
||||||
|
yield session
|
||||||
|
await session.rollback()
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="function")
|
||||||
|
async def data(config: Config):
|
||||||
|
class TestData:
|
||||||
|
user = User(
|
||||||
|
name="PyTest",
|
||||||
|
lower_name="pytest",
|
||||||
|
email="pytest@example.com",
|
||||||
|
hashed_password=security.hash_password(
|
||||||
|
"iampytest", algo=config.security.password_hash_algo
|
||||||
|
),
|
||||||
|
login_type=LoginType.Plain,
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TestData()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="function")
|
||||||
|
async def api_config(config: Config, tmpdir) -> Config:
|
||||||
|
config.application.working_directory = Path(tmpdir)
|
||||||
|
config.oauth2.jwt_secret = "pytest_secret_key"
|
||||||
|
|
||||||
|
yield config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="function")
|
||||||
|
async def api_client(
|
||||||
|
api_config: Config, database: Database, cache: Cache, cron: Cron
|
||||||
|
) -> AsyncClient:
|
||||||
|
|
||||||
|
app = Application(api_config)
|
||||||
|
app.database = database
|
||||||
|
app.cache = cache
|
||||||
|
app.cron = cron
|
||||||
|
app.prepare_server()
|
||||||
|
|
||||||
|
async with LifespanManager(app.backend) as manager:
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=manager.app), base_url=api_config.server.url()
|
||||||
|
) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="function")
|
||||||
|
async def auth_client(api_client: AsyncClient, api_config: Config) -> AsyncClient:
|
||||||
|
data = {"name": "PyTest", "password": "iampytest", "email": "pytest@example.com"}
|
||||||
|
|
||||||
|
await api_client.post(
|
||||||
|
"/api/auth/signup",
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
auth = await api_client.post(
|
||||||
|
"/api/auth/signin",
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
cookies = Cookies()
|
||||||
|
cookies.set(
|
||||||
|
"materia_at",
|
||||||
|
auth.cookies[api_config.security.cookie_access_token_name],
|
||||||
|
)
|
||||||
|
cookies.set(
|
||||||
|
"materia_rt",
|
||||||
|
auth.cookies[api_config.security.cookie_refresh_token_name],
|
||||||
|
)
|
||||||
|
|
||||||
|
api_client.cookies = cookies
|
||||||
|
|
||||||
|
yield api_client
|
191
tests/test_api.py
Normal file
191
tests/test_api.py
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import pytest
|
||||||
|
from materia.core import Config
|
||||||
|
from httpx import AsyncClient, Cookies
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
# TODO: replace downloadable images for tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth(api_client: AsyncClient, api_config: Config):
|
||||||
|
data = {"name": "PyTest", "password": "iampytest", "email": "pytest@example.com"}
|
||||||
|
|
||||||
|
response = await api_client.post(
|
||||||
|
"/api/auth/signup",
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = await api_client.post(
|
||||||
|
"/api/auth/signin",
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.cookies.get(api_config.security.cookie_access_token_name)
|
||||||
|
assert response.cookies.get(api_config.security.cookie_refresh_token_name)
|
||||||
|
|
||||||
|
# TODO: conflict usernames and emails
|
||||||
|
|
||||||
|
response = await api_client.get("/api/auth/signout")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user(auth_client: AsyncClient, api_config: Config):
|
||||||
|
info = await auth_client.get("/api/user")
|
||||||
|
assert info.status_code == 200, info.text
|
||||||
|
|
||||||
|
async with AsyncClient() as client:
|
||||||
|
pytest_logo_res = await client.get(
|
||||||
|
"https://docs.pytest.org/en/stable/_static/pytest1.png"
|
||||||
|
)
|
||||||
|
assert isinstance(pytest_logo_res.content, bytes)
|
||||||
|
pytest_logo = BytesIO(pytest_logo_res.content)
|
||||||
|
|
||||||
|
avatar = await auth_client.put(
|
||||||
|
"/api/user/avatar",
|
||||||
|
files={"file": ("pytest.png", pytest_logo)},
|
||||||
|
)
|
||||||
|
assert avatar.status_code == 200, avatar.text
|
||||||
|
|
||||||
|
info = await auth_client.get("/api/user")
|
||||||
|
avatar_info = info.json()["avatar"]
|
||||||
|
assert avatar_info is not None
|
||||||
|
assert api_config.application.working_directory.joinpath(
|
||||||
|
"avatars", avatar_info
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
avatar = await auth_client.delete("/api/user/avatar")
|
||||||
|
assert avatar.status_code == 200, avatar.text
|
||||||
|
|
||||||
|
info = await auth_client.get("/api/user")
|
||||||
|
assert info.json()["avatar"] is None
|
||||||
|
assert not api_config.application.working_directory.joinpath(
|
||||||
|
"avatars", avatar_info
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
delete = await auth_client.delete("/api/user")
|
||||||
|
assert delete.status_code == 200, delete.text
|
||||||
|
|
||||||
|
info = await auth_client.get("/api/user")
|
||||||
|
assert info.status_code == 401, info.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repository(auth_client: AsyncClient, api_config: Config):
|
||||||
|
info = await auth_client.get("/api/repository")
|
||||||
|
assert info.status_code == 404, info.text
|
||||||
|
|
||||||
|
create = await auth_client.post("/api/repository")
|
||||||
|
assert create.status_code == 200, create.text
|
||||||
|
|
||||||
|
create = await auth_client.post("/api/repository")
|
||||||
|
assert create.status_code == 409, create.text
|
||||||
|
|
||||||
|
assert api_config.application.working_directory.joinpath(
|
||||||
|
"repository", "PyTest".lower()
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
info = await auth_client.get("/api/repository")
|
||||||
|
assert info.status_code == 200, info.text
|
||||||
|
|
||||||
|
delete = await auth_client.delete("/api/repository")
|
||||||
|
assert delete.status_code == 200, delete.text
|
||||||
|
|
||||||
|
info = await auth_client.get("/api/repository")
|
||||||
|
assert info.status_code == 404, info.text
|
||||||
|
|
||||||
|
# TODO: content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_directory(auth_client: AsyncClient, api_config: Config):
|
||||||
|
first_dir_path = api_config.application.working_directory.joinpath(
|
||||||
|
"repository", "PyTest".lower(), "first_dir"
|
||||||
|
)
|
||||||
|
second_dir_path = api_config.application.working_directory.joinpath(
|
||||||
|
"repository", "PyTest".lower(), "second_dir"
|
||||||
|
)
|
||||||
|
second_dir_path_two = api_config.application.working_directory.joinpath(
|
||||||
|
"repository", "PyTest".lower(), "second_dir.1"
|
||||||
|
)
|
||||||
|
third_dir_path_one = api_config.application.working_directory.joinpath(
|
||||||
|
"repository", "PyTest".lower(), "third_dir"
|
||||||
|
)
|
||||||
|
third_dir_path_two = api_config.application.working_directory.joinpath(
|
||||||
|
"repository", "PyTest".lower(), "second_dir", "third_dir"
|
||||||
|
)
|
||||||
|
|
||||||
|
create = await auth_client.post("/api/repository")
|
||||||
|
assert create.status_code == 200, create.text
|
||||||
|
|
||||||
|
create = await auth_client.post("/api/directory", json={"path": "first_dir"})
|
||||||
|
assert create.status_code == 500, create.text
|
||||||
|
|
||||||
|
create = await auth_client.post("/api/directory", json={"path": "/first_dir"})
|
||||||
|
assert create.status_code == 200, create.text
|
||||||
|
|
||||||
|
assert first_dir_path.exists()
|
||||||
|
|
||||||
|
info = await auth_client.get("/api/directory", params=[("path", "/first_dir")])
|
||||||
|
assert info.status_code == 200, info.text
|
||||||
|
assert info.json()["used"] == 0
|
||||||
|
assert info.json()["path"] == "/first_dir"
|
||||||
|
|
||||||
|
create = await auth_client.patch(
|
||||||
|
"/api/directory/rename",
|
||||||
|
json={"path": "/first_dir", "name": "first_dir_renamed"},
|
||||||
|
)
|
||||||
|
assert create.status_code == 200, create.text
|
||||||
|
|
||||||
|
delete = await auth_client.delete(
|
||||||
|
"/api/directory", params=[("path", "/first_dir_renamed")]
|
||||||
|
)
|
||||||
|
assert delete.status_code == 200, delete.text
|
||||||
|
|
||||||
|
assert not first_dir_path.exists()
|
||||||
|
|
||||||
|
create = await auth_client.post("/api/directory", json={"path": "/second_dir"})
|
||||||
|
assert create.status_code == 200, create.text
|
||||||
|
|
||||||
|
create = await auth_client.post("/api/directory", json={"path": "/third_dir"})
|
||||||
|
assert create.status_code == 200, create.text
|
||||||
|
|
||||||
|
move = await auth_client.patch(
|
||||||
|
"/api/directory/move", json={"path": "/third_dir", "target": "/second_dir"}
|
||||||
|
)
|
||||||
|
assert move.status_code == 200, move.text
|
||||||
|
assert not third_dir_path_one.exists()
|
||||||
|
assert third_dir_path_two.exists()
|
||||||
|
|
||||||
|
info = await auth_client.get(
|
||||||
|
"/api/directory", params=[("path", "/second_dir/third_dir")]
|
||||||
|
)
|
||||||
|
assert info.status_code == 200, info.text
|
||||||
|
assert info.json()["path"] == "/second_dir/third_dir"
|
||||||
|
|
||||||
|
copy = await auth_client.post(
|
||||||
|
"/api/directory/copy",
|
||||||
|
json={"path": "/second_dir", "target": "/", "force": True},
|
||||||
|
)
|
||||||
|
assert copy.status_code == 200, copy.text
|
||||||
|
assert second_dir_path.exists()
|
||||||
|
assert second_dir_path_two.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_file(auth_client: AsyncClient, api_config: Config):
|
||||||
|
create = await auth_client.post("/api/repository")
|
||||||
|
assert create.status_code == 200, create.text
|
||||||
|
|
||||||
|
async with AsyncClient() as client:
|
||||||
|
pytest_logo_res = await client.get(
|
||||||
|
"https://docs.pytest.org/en/stable/_static/pytest1.png"
|
||||||
|
)
|
||||||
|
assert isinstance(pytest_logo_res.content, bytes)
|
||||||
|
pytest_logo = BytesIO(pytest_logo_res.content)
|
||||||
|
|
||||||
|
create = await auth_client.post(
|
||||||
|
"/api/file", files={"file": ("pytest.png", pytest_logo)}, data={"path": "/"}
|
||||||
|
)
|
||||||
|
assert create.status_code == 200, create.text
|
283
tests/test_models.py
Normal file
283
tests/test_models.py
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
import pytest_asyncio
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from materia.models import (
|
||||||
|
User,
|
||||||
|
Repository,
|
||||||
|
Directory,
|
||||||
|
RepositoryError,
|
||||||
|
File,
|
||||||
|
)
|
||||||
|
from materia.core import Config, SessionContext
|
||||||
|
from materia import security
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm.session import make_transient
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
import aiofiles
|
||||||
|
import aiofiles.os
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user(data, session: SessionContext, config: Config):
|
||||||
|
# simple
|
||||||
|
session.add(data.user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
assert data.user.id is not None
|
||||||
|
assert security.validate_password("iampytest", data.user.hashed_password)
|
||||||
|
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
# methods
|
||||||
|
await data.user.new(session, config)
|
||||||
|
|
||||||
|
assert data.user.id is not None
|
||||||
|
assert await data.user.count(session) == 1
|
||||||
|
assert await User.by_name("PyTest", session) == data.user
|
||||||
|
assert await User.by_email("pytest@example.com", session) == data.user
|
||||||
|
|
||||||
|
await data.user.edit_name("AsyncPyTest", session)
|
||||||
|
assert await User.by_name("asyncpytest", session, with_lower=True) == data.user
|
||||||
|
assert await User.by_email("pytest@example.com", session) == data.user
|
||||||
|
assert await User.by_id(data.user.id, session) == data.user
|
||||||
|
await data.user.edit_password("iamnotpytest", session, config)
|
||||||
|
assert security.validate_password("iamnotpytest", data.user.hashed_password)
|
||||||
|
|
||||||
|
await data.user.remove(session)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repository(data, tmpdir, session: SessionContext, config: Config):
|
||||||
|
config.application.working_directory = Path(tmpdir)
|
||||||
|
|
||||||
|
session.add(data.user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
repository = await Repository(
|
||||||
|
user_id=data.user.id, capacity=config.repository.capacity
|
||||||
|
).new(session, config)
|
||||||
|
|
||||||
|
assert repository
|
||||||
|
assert repository.id is not None
|
||||||
|
assert (await repository.real_path(session, config)).exists()
|
||||||
|
assert await Repository.from_user(data.user, session) == repository
|
||||||
|
|
||||||
|
await session.refresh(repository, attribute_names=["user"])
|
||||||
|
cloned_repository = repository.clone()
|
||||||
|
assert cloned_repository.id is None and cloned_repository.user is None
|
||||||
|
session.add(cloned_repository)
|
||||||
|
await session.flush()
|
||||||
|
assert cloned_repository.id is not None
|
||||||
|
|
||||||
|
await repository.remove(session, config)
|
||||||
|
make_transient(repository)
|
||||||
|
session.add(repository)
|
||||||
|
await session.flush()
|
||||||
|
with pytest.raises(RepositoryError):
|
||||||
|
await repository.remove(session, config)
|
||||||
|
assert not (await repository.real_path(session, config)).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_directory(data, tmpdir, session: SessionContext, config: Config):
|
||||||
|
config.application.working_directory = Path(tmpdir)
|
||||||
|
|
||||||
|
# setup
|
||||||
|
session.add(data.user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
repository = await Repository(
|
||||||
|
user_id=data.user.id, capacity=config.repository.capacity
|
||||||
|
).new(session, config)
|
||||||
|
|
||||||
|
directory = await Directory(
|
||||||
|
repository_id=repository.id, parent_id=None, name="test1"
|
||||||
|
).new(session, config)
|
||||||
|
|
||||||
|
# simple
|
||||||
|
assert directory.id is not None
|
||||||
|
assert (
|
||||||
|
await session.scalars(
|
||||||
|
sa.select(Directory).where(
|
||||||
|
sa.and_(
|
||||||
|
Directory.repository_id == repository.id,
|
||||||
|
Directory.name == "test1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first() == directory
|
||||||
|
assert (await directory.real_path(session, config)).exists()
|
||||||
|
|
||||||
|
# nested simple
|
||||||
|
nested_directory = await Directory(
|
||||||
|
repository_id=repository.id,
|
||||||
|
parent_id=directory.id,
|
||||||
|
name="test_nested",
|
||||||
|
).new(session, config)
|
||||||
|
|
||||||
|
assert nested_directory.id is not None
|
||||||
|
assert (
|
||||||
|
await session.scalars(
|
||||||
|
sa.select(Directory).where(
|
||||||
|
sa.and_(
|
||||||
|
Directory.repository_id == repository.id,
|
||||||
|
Directory.name == "test_nested",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first() == nested_directory
|
||||||
|
assert nested_directory.parent_id == directory.id
|
||||||
|
assert (await nested_directory.real_path(session, config)).exists()
|
||||||
|
|
||||||
|
# relationship
|
||||||
|
await session.refresh(directory, attribute_names=["directories", "files"])
|
||||||
|
assert isinstance(directory.files, list) and len(directory.files) == 0
|
||||||
|
assert isinstance(directory.directories, list) and len(directory.directories) == 1
|
||||||
|
|
||||||
|
await session.refresh(nested_directory, attribute_names=["directories", "files"])
|
||||||
|
assert (nested_directory.files, list) and len(nested_directory.files) == 0
|
||||||
|
assert (nested_directory.directories, list) and len(
|
||||||
|
nested_directory.directories
|
||||||
|
) == 0
|
||||||
|
|
||||||
|
#
|
||||||
|
assert (
|
||||||
|
await Directory.by_path(
|
||||||
|
repository, Path("test1", "test_nested"), session, config
|
||||||
|
)
|
||||||
|
== nested_directory
|
||||||
|
)
|
||||||
|
|
||||||
|
# remove nested
|
||||||
|
nested_path = await nested_directory.real_path(session, config)
|
||||||
|
assert nested_path.exists()
|
||||||
|
await nested_directory.remove(session, config)
|
||||||
|
assert inspect(nested_directory).was_deleted
|
||||||
|
assert await nested_directory.real_path(session, config) is None
|
||||||
|
assert not nested_path.exists()
|
||||||
|
|
||||||
|
await session.refresh(directory) # update attributes that was deleted
|
||||||
|
assert (await directory.real_path(session, config)).exists()
|
||||||
|
|
||||||
|
# rename
|
||||||
|
assert (
|
||||||
|
await directory.rename("test1", session, config, force=True)
|
||||||
|
).name == "test1.1"
|
||||||
|
await Directory(repository_id=repository.id, parent_id=None, name="test2").new(
|
||||||
|
session, config
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await directory.rename("test2", session, config, force=True)
|
||||||
|
).name == "test2.1"
|
||||||
|
assert (await repository.real_path(session, config)).joinpath("test2.1").exists()
|
||||||
|
assert not (await repository.real_path(session, config)).joinpath("test1").exists()
|
||||||
|
|
||||||
|
directory_path = await directory.real_path(session, config)
|
||||||
|
assert directory_path.exists()
|
||||||
|
|
||||||
|
await directory.remove(session, config)
|
||||||
|
assert await directory.real_path(session, config) is None
|
||||||
|
assert not directory_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_file(data, tmpdir, session: SessionContext, config: Config):
|
||||||
|
config.application.working_directory = Path(tmpdir)
|
||||||
|
|
||||||
|
# setup
|
||||||
|
session.add(data.user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
repository = await Repository(
|
||||||
|
user_id=data.user.id, capacity=config.repository.capacity
|
||||||
|
).new(session, config)
|
||||||
|
|
||||||
|
directory = await Directory(
|
||||||
|
repository_id=repository.id, parent_id=None, name="test1"
|
||||||
|
).new(session, config)
|
||||||
|
directory2 = await Directory(
|
||||||
|
repository_id=repository.id, parent_id=None, name="test2"
|
||||||
|
).new(session, config)
|
||||||
|
|
||||||
|
data = b"Hello there, it's a test"
|
||||||
|
file = await File(
|
||||||
|
repository_id=repository.id,
|
||||||
|
parent_id=directory.id,
|
||||||
|
name="test_file.txt",
|
||||||
|
).new(data, session, config)
|
||||||
|
|
||||||
|
# simple
|
||||||
|
assert file.id is not None
|
||||||
|
assert (
|
||||||
|
await session.scalars(
|
||||||
|
sa.select(File).where(
|
||||||
|
sa.and_(
|
||||||
|
File.repository_id == repository.id,
|
||||||
|
File.parent_id == directory.id,
|
||||||
|
File.name == "test_file.txt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first() == file
|
||||||
|
|
||||||
|
# relationship
|
||||||
|
await session.refresh(file, attribute_names=["parent", "repository"])
|
||||||
|
assert file.parent == directory
|
||||||
|
assert file.repository == repository
|
||||||
|
|
||||||
|
#
|
||||||
|
assert (
|
||||||
|
await File.by_path(repository, Path("test1", "test_file.txt"), session, config)
|
||||||
|
== file
|
||||||
|
)
|
||||||
|
|
||||||
|
#
|
||||||
|
file_path = await file.real_path(session, config)
|
||||||
|
assert file_path.exists()
|
||||||
|
assert (await aiofiles.os.stat(file_path)).st_size == file.size
|
||||||
|
async with aiofiles.open(file_path, mode="rb") as io:
|
||||||
|
content = await io.read()
|
||||||
|
assert data == content
|
||||||
|
|
||||||
|
# rename
|
||||||
|
assert (
|
||||||
|
await file.rename("test_file_rename.txt", session, config, force=True)
|
||||||
|
).name == "test_file_rename.txt"
|
||||||
|
await File(
|
||||||
|
repository_id=repository.id, parent_id=directory.id, name="test_file_2.txt"
|
||||||
|
).new(b"", session, config)
|
||||||
|
assert (
|
||||||
|
await file.rename("test_file_2.txt", session, config, force=True)
|
||||||
|
).name == "test_file_2.1.txt"
|
||||||
|
assert (
|
||||||
|
(await repository.real_path(session, config))
|
||||||
|
.joinpath("test1", "test_file_2.1.txt")
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
not (await repository.real_path(session, config))
|
||||||
|
.joinpath("test1", "test_file_rename.txt")
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
# move
|
||||||
|
await file.move(directory2, session, config)
|
||||||
|
await session.refresh(file, attribute_names=["parent"])
|
||||||
|
assert file.parent == directory2
|
||||||
|
assert (
|
||||||
|
not (await repository.real_path(session, config))
|
||||||
|
.joinpath("test1", "test_file_2.1.txt")
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
(await repository.real_path(session, config))
|
||||||
|
.joinpath("test2", "test_file_2.1.txt")
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
# remove
|
||||||
|
await file.remove(session, config)
|
||||||
|
assert not await File.by_path(
|
||||||
|
repository, Path("test1", "test_file.txt"), session, config
|
||||||
|
)
|
||||||
|
assert not file_path.exists()
|
15
workspaces/frontend/.gitignore
vendored
Normal file
15
workspaces/frontend/.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
dist/
|
||||||
|
/.venv
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.mjs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
openapi.json
|
||||||
|
src/client
|
16
workspaces/frontend/README.md
Normal file
16
workspaces/frontend/README.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# materia-frontend
|
||||||
|
|
||||||
|
## Building (npm)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building / installing (pdm)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pdm build
|
||||||
|
pdm install --prod --no-editable
|
||||||
|
```
|
||||||
|
|
13
workspaces/frontend/index.html
Normal file
13
workspaces/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>
|
4096
workspaces/frontend/package-lock.json
generated
Normal file
4096
workspaces/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
workspaces/frontend/package.json
Normal file
40
workspaces/frontend/package.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "materia-frontend",
|
||||||
|
"version": "0.0.5",
|
||||||
|
"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",
|
||||||
|
"generate-client": "openapi --input ./openapi.json --output ./src/client_old/ --client axios --name Client",
|
||||||
|
"openapi-ts": "openapi-ts --input ./openapi.json --output ./src/client/ --client @hey-api/client-axios"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@catppuccin/tailwindcss": "^0.1.6",
|
||||||
|
"@hey-api/client-axios": "^0.2.3",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"filesize": "^10.1.6",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"vue": "^3.3.11",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@hey-api/openapi-ts": "^0.53.0",
|
||||||
|
"@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",
|
||||||
|
"openapi-typescript-codegen": "^0.29.0",
|
||||||
|
"typescript": "~5.3.0",
|
||||||
|
"vite": "^5.0.10",
|
||||||
|
"vue-tsc": "^2.0.29"
|
||||||
|
}
|
||||||
|
}
|
177
workspaces/frontend/pdm.lock
Normal file
177
workspaces/frontend/pdm.lock
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
# 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:16bedb3de70622af531e01dee2c2773d108a005caf9fa9d2fbe9042267602ef6"
|
||||||
|
|
||||||
|
[[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",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"},
|
||||||
|
{file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"},
|
||||||
|
{file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"},
|
||||||
|
{file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"},
|
||||||
|
{file = "black-23.12.1-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 = "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 = "packaging"
|
||||||
|
version = "24.1"
|
||||||
|
requires_python = ">=3.8"
|
||||||
|
summary = "Core utilities for Python packages"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
|
||||||
|
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "pytest"
|
||||||
|
version = "7.4.4"
|
||||||
|
requires_python = ">=3.7"
|
||||||
|
summary = "pytest: simple powerful testing with Python"
|
||||||
|
groups = ["dev"]
|
||||||
|
dependencies = [
|
||||||
|
"colorama; sys_platform == \"win32\"",
|
||||||
|
"iniconfig",
|
||||||
|
"packaging",
|
||||||
|
"pluggy<2.0,>=0.12",
|
||||||
|
]
|
||||||
|
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 = "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"},
|
||||||
|
]
|
6
workspaces/frontend/postcss.config.js
Normal file
6
workspaces/frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
45
workspaces/frontend/pyproject.toml
Normal file
45
workspaces/frontend/pyproject.toml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
[project]
|
||||||
|
name = "materia-frontend"
|
||||||
|
version = "0.1.1"
|
||||||
|
description = "Materia frontend"
|
||||||
|
authors = [
|
||||||
|
{name = "L-Nafaryus", email = "l.nafaryus@gmail.com"},
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"loguru<1.0.0,>=0.7.2",
|
||||||
|
]
|
||||||
|
requires-python = ">=3.12,<3.13"
|
||||||
|
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_frontend" ]
|
||||||
|
|
||||||
|
[tool.pdm.scripts]
|
||||||
|
npm-install.cmd = "npm install --prefix ./"
|
||||||
|
npm-run-build.cmd = "npm run build --prefix ./"
|
||||||
|
move-dist.shell = "rm -vrf src/materia_frontend/dist && mv -v dist src/materia_frontend/ && cp -v templates/* src/materia_frontend/dist"
|
||||||
|
pre_build.composite = [ "npm-install", "npm-run-build", "move-dist" ]
|
||||||
|
materia-frontend.call = "materia_frontend.main:client"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["pdm-backend"]
|
||||||
|
build-backend = "pdm.backend"
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
reportGeneralTypeIssues = false
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
7
workspaces/frontend/src/App.vue
Normal file
7
workspaces/frontend/src/App.vue
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink, RouterView } from 'vue-router';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
69
workspaces/frontend/src/assets/style.css
Normal file
69
workspaces/frontend/src/assets/style.css
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-ctp-crust;
|
||||||
|
font-family: Inter,sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply text-ctp-lavender;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply w-full pl-3 pr-3 pt-2 pb-2 rounded border bg-ctp-mantle border-ctp-overlay0 hover:border-ctp-overlay1 focus:border-ctp-lavender text-ctp-text outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-file {
|
||||||
|
@apply block w-full border rounded cursor-pointer bg-ctp-base border-ctp-surface0 text-ctp-subtext0 focus:outline-none;
|
||||||
|
@apply file:bg-ctp-mantle file:border-ctp-surface0 file:mr-5 file:py-2 file:px-3 file:h-full file:border-y-0 file:border-l-0 file:border-r file:text-ctp-blue hover:file:cursor-pointer hover:file:bg-ctp-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
@apply text-ctp-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
@apply pt-1 pb-1 pl-3 pr-3 sm:pt-2 sm:pb-2 sm:pl-5 sm:pr-5 rounded bg-ctp-mantle border border-ctp-surface0 hover:bg-ctp-base text-ctp-blue cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
@apply button text-ctp-lavender cursor-pointer border-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hline {
|
||||||
|
@apply border-t border-ctp-surface0 ml-0 mr-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@apply text-ctp-text pt-5 pb-5 border-b border-ctp-overlay0 mb-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
@apply text-ctp-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
@apply inline-block select-none text-center overflow-visible w-6 h-6 stroke-ctp-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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%];
|
||||||
|
}
|
||||||
|
}
|
33
workspaces/frontend/src/components/ContextMenu.vue
Normal file
33
workspaces/frontend/src/components/ContextMenu.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, defineProps, defineEmits } from 'vue';
|
||||||
|
|
||||||
|
const { actions, x, y } = defineProps(['actions', 'x', 'y']);
|
||||||
|
const emit = defineEmits(['action-clicked']);
|
||||||
|
|
||||||
|
const emitAction = (action) => {
|
||||||
|
emit('action-clicked', action);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="absolute z-50 context-menu bg-ctp-mantle border rounded border-ctp-overlay0"
|
||||||
|
:style="{ top: y + 'px', left: x + 'px' }">
|
||||||
|
<div v-for="action in actions" :key="action.action" @click="emitAction(action.action)"
|
||||||
|
class="hover:bg-ctp-base text-ctp-blue">
|
||||||
|
{{ action.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.context-menu {
|
||||||
|
position: absolute;
|
||||||
|
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu div {
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
38
workspaces/frontend/src/components/CtxMenu.vue
Normal file
38
workspaces/frontend/src/components/CtxMenu.vue
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const { data, actions } = defineProps(["data", "actions"]);
|
||||||
|
const isShow = ref(false);
|
||||||
|
const posX = ref(0);
|
||||||
|
const posY = ref(0);
|
||||||
|
const anchor = ref(null);
|
||||||
|
|
||||||
|
const emit = defineEmits(["onEvent"]);
|
||||||
|
|
||||||
|
const showMenu = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
console.log("pos", event);
|
||||||
|
posX.value = event.pageX;
|
||||||
|
posY.value = event.pageY;
|
||||||
|
isShow.value = true;
|
||||||
|
|
||||||
|
emit("onEvent", event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
isShow.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="anchor" @contextmenu="showMenu($event)" style="display: contents" v-click-outside="closeMenu">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
<div v-if="isShow" class="absolute z-50 min-w-40 bg-ctp-mantle border rounded border-ctp-surface0"
|
||||||
|
:style="{ top: posY + 'px', left: posX + 'px' }">
|
||||||
|
<div v-for="action in actions" v-show="action.show()" :key="action.event" @click="action.event(data)"
|
||||||
|
class="hover:bg-ctp-base text-ctp-blue select-none pl-4 pr-4 pt-2 pb-2">
|
||||||
|
{{ action.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
84
workspaces/frontend/src/components/DataTable.vue
Normal file
84
workspaces/frontend/src/components/DataTable.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="header">
|
||||||
|
<h2>
|
||||||
|
{{ header.title }}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
{{ header.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-if="rowSelector">
|
||||||
|
<div>
|
||||||
|
<input id="contact-selectAll" type="checkbox" value="" @change="selectAll">
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th v-for="(item, idx) in fields" :key="idx">
|
||||||
|
{{ item.label }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, index) in data" :key="index">
|
||||||
|
<td v-if="rowSelector">
|
||||||
|
<div>
|
||||||
|
<input :id="`contact-${index}`" v-model="item.selected" type="checkbox">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-for="(field, idx) in fields" :key="idx" @click="rowSelected(item)">
|
||||||
|
<span v-if="!hasNamedSlot(field.key)" :item="item">
|
||||||
|
{{ item[field.key] }}
|
||||||
|
</span>
|
||||||
|
<slot v-else :name="field.key" :item="item" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasNamedSlot('footer')">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
rowSelector: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
rowSelected(item) {
|
||||||
|
this.$emit('rowSelected', item)
|
||||||
|
},
|
||||||
|
selectAll(e) {
|
||||||
|
const checked = e.target.checked
|
||||||
|
this.data.forEach((item) => { item.selected = checked })
|
||||||
|
this.$forceUpdate()
|
||||||
|
},
|
||||||
|
hasNamedSlot(slotName) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(this.$scopedSlots, slotName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
15
workspaces/frontend/src/components/DragItem.vue
Normal file
15
workspaces/frontend/src/components/DragItem.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineProps } from "vue";
|
||||||
|
|
||||||
|
const { data } = defineProps(["data"]);
|
||||||
|
|
||||||
|
const onDragBeginEvent = (event) => {
|
||||||
|
event.dataTransfer.setData("value", JSON.stringify(data));
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tr draggable="true" @dragstart="onDragBeginEvent">
|
||||||
|
<slot />
|
||||||
|
</tr>
|
||||||
|
</template>
|
18
workspaces/frontend/src/components/DropItem.vue
Normal file
18
workspaces/frontend/src/components/DropItem.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineProps } from "vue";
|
||||||
|
|
||||||
|
const { onDragOver, onDragLeave, onDrop } = defineProps(["onDragOver", "onDragLeave", "onDrop"]);
|
||||||
|
|
||||||
|
const onDragOverEvent = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (onDragOver) {
|
||||||
|
onDragOver(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tr @dragover="onDragOverEvent" @dragleave="onDragLeave" @drop="onDrop">
|
||||||
|
<slot />
|
||||||
|
</tr>
|
||||||
|
</template>
|
9
workspaces/frontend/src/components/DropdownItem.vue
Normal file
9
workspaces/frontend/src/components/DropdownItem.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fixed z-50 cursor-pointer" :style="{ top: pos_y + 'px', left: pos_x + 'px' }">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
22
workspaces/frontend/src/components/DropdownMenu.vue
Normal file
22
workspaces/frontend/src/components/DropdownMenu.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const active = ref<bool>(false);
|
||||||
|
|
||||||
|
function activate() {
|
||||||
|
active.value = !active.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivate() {
|
||||||
|
active.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div @click="activate" v-click-outside="deactivate">
|
||||||
|
<slot name="button"></slot>
|
||||||
|
<div v-if="active">
|
||||||
|
<slot name="content"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user