Compare commits
32 Commits
ffa723c242
...
93189789f2
Author | SHA1 | Date | |
---|---|---|---|
93189789f2 | |||
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*
|
||||
/repl-result*
|
||||
temp/
|
||||
|
||||
dist/
|
||||
/.venv
|
||||
__pycache__/
|
||||
/temp
|
||||
*.egg-info
|
||||
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build
|
||||
|
||||
.pytest_cache
|
||||
.coverage
|
||||
|
||||
/site
|
||||
src/materia/docs
|
||||
|
159
README.md
159
README.md
@ -1,21 +1,152 @@
|
||||
# materia
|
||||
# Materia
|
||||
|
||||
Materia is a file server.
|
||||
<style>
|
||||
.md-content .md-typeset h1 { display: none; }
|
||||
</style>
|
||||
|
||||
## Database migrations
|
||||
```bash
|
||||
# Initialize
|
||||
alembic init -t async src/db/migrations
|
||||
<p align="center">
|
||||
<a href="https://materia.elnafo.ru"><img src="https://vcs.elnafo.ru/L-Nafaryus/materia/src/branch/master/docs/img/logo-full.png" alt="Materia"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/nix%20flake-gray.svg?logo=nixos" alt="nix-flake"/>
|
||||
<a href="https://bonfire.cachix.org"><img src="https://img.shields.io/badge/cachix-bonfire-pink.svg" alt="bonfire-cachix" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<strong><em>Materia is a simple and fast cloud storage</em></strong>
|
||||
</p>
|
||||
|
||||
# Autogenerate new migration
|
||||
alembic revision --autogenerate -m "Initial migration"
|
||||
**Documentation**: [https://storage.elnafo.ru/docs](https://storage.elnafo.ru/docs)
|
||||
|
||||
# Apply the migration
|
||||
alembic upgrade head
|
||||
**Source**: [https://vcs.elnafo.ru/L-Nafaryus/materia](https://vcs.elnafo.ru/L-Nafaryus/materia)
|
||||
|
||||
# Rollback the migration
|
||||
alembic downgrade head
|
||||
Materia is a modern and high-performance cloud storage (thanks to FastAPI and Vue) built with REST in mind.
|
||||
|
||||
/// warning
|
||||
|
||||
This project is still in development.
|
||||
You can test it but at your own risk.
|
||||
|
||||
You can also create an [issue](https://github.com/L-Nafaryus/materia/issues).
|
||||
|
||||
///
|
||||
|
||||
## Example
|
||||
|
||||
### Standalone backend server
|
||||
|
||||
/// warning
|
||||
|
||||
The project is currently unstable for packaging due to the complex setup.
|
||||
So the best way is to use the sources.
|
||||
It also uses a specific development environment via Nix.
|
||||
Tools like Podman can easily be replaced with Docker, so be careful.
|
||||
|
||||
///
|
||||
|
||||
First of all you need to prepare working instances of Postgres (database) and Redis (broker). There are many ways, you can choose any.
|
||||
|
||||
- Postgres:
|
||||
|
||||
```sh
|
||||
nix build .#postgresql-devel
|
||||
podman load < result
|
||||
podman run -p 54320:5432 --name database -dt postgresql:latest
|
||||
```
|
||||
# License
|
||||
|
||||
**materia** is licensed under the [MIT License](LICENSE).
|
||||
- Redis:
|
||||
|
||||
```sh
|
||||
nix build .#redis-devel
|
||||
podman load < result
|
||||
podman run -p 63790:6379 --name cache -dt redis:latest
|
||||
```
|
||||
|
||||
- Development environment:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pdm install --dev
|
||||
eval $(pdm venv activate)
|
||||
```
|
||||
|
||||
- Running tests (pytest):
|
||||
|
||||
```sh
|
||||
pytest
|
||||
```
|
||||
|
||||
- Running server:
|
||||
|
||||
```sh
|
||||
MATERIA_DATABASE__PORT=54320 MATERIA_CACHE__PORT=63790 materia start
|
||||
```
|
||||
|
||||
- Generating configuration and starting with it:
|
||||
|
||||
```sh
|
||||
materia config export --path ./config.toml
|
||||
materia start --config config.toml
|
||||
```
|
||||
|
||||
### Standalone frontend server
|
||||
|
||||
The frontend does not include a client by default. It must be generated from the OpenAPI specification which in turn must be generated from the current state of the FastAPI application. After that you can safely run the Vite server or build the entire Vue project.
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pdm install --dev
|
||||
eval $(pdm venv activate)
|
||||
|
||||
materia export openapi --path workspaces/frontend/openapi.json
|
||||
|
||||
cd workspaces/frontend
|
||||
npm install
|
||||
npm run openapi
|
||||
```
|
||||
|
||||
- Running frontend server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
In development mode, the server is configured to the address and port of the backend server.
|
||||
|
||||
## Design
|
||||
|
||||
At the current stage, the PDM package manager is used, since it is the only one that copes well with the concept of a monorepository. At the output, two Python packages should be built - materia (backend server) and materia-frontend (which wraps a built version of the NPM package materia-frontend-vue). Documentation (optional) and the frontend are served through the backend server.
|
||||
|
||||
The target platform of the project is NixOS. This also indirectly affects the approach to choosing tools for developing the current project and the support priority. And only secondly, packaging components for registries such as pypi and others. Support for the rest is a matter of time.
|
||||
|
||||
## Build
|
||||
|
||||
All build steps are wrapped in PDM pre-build scripts, so in a typical case you only need PDM and NodeJS.
|
||||
|
||||
- materia:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pdm build --skip ":all"
|
||||
```
|
||||
|
||||
- materia with documentation:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pdm install --prod --no-default --group docs
|
||||
pdm build
|
||||
```
|
||||
|
||||
- materia-frontend:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pdm install --prod
|
||||
cd workspaces/frontend
|
||||
pdm build
|
||||
```
|
||||
|
||||
|
||||
## License
|
||||
|
||||
**materia** is licensed under the terms of the [MIT License](LICENSE).
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
[alembic]
|
||||
# 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
|
||||
# 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
|
||||
# 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]
|
||||
@ -91,7 +91,7 @@ keys = console
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
level = INFO
|
||||
handlers = console
|
||||
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 |
151
docs/index.md
Normal file
151
docs/index.md
Normal file
@ -0,0 +1,151 @@
|
||||
# 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">
|
||||
<img src="https://img.shields.io/badge/nix%20flake-gray.svg?logo=nixos" alt="nix-flake"/>
|
||||
<a href="https://bonfire.cachix.org"><img src="https://img.shields.io/badge/cachix-bonfire-pink.svg" alt="bonfire-cachix" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<strong><em>Materia is a simple and fast cloud storage</em></strong>
|
||||
</p>
|
||||
|
||||
**Documentation**: [https://storage.elnafo.ru/docs](https://storage.elnafo.ru/docs)
|
||||
|
||||
**Source**: [https://vcs.elnafo.ru/L-Nafaryus/materia](https://vcs.elnafo.ru/L-Nafaryus/materia)
|
||||
|
||||
Materia is a modern and high-performance cloud storage (thanks to FastAPI and Vue) built with REST in mind.
|
||||
|
||||
/// warning
|
||||
|
||||
This project is still in development.
|
||||
You can test it but at your own risk.
|
||||
|
||||
You can also create an [issue](https://github.com/L-Nafaryus/materia/issues).
|
||||
|
||||
///
|
||||
|
||||
## Example
|
||||
|
||||
### Standalone backend server
|
||||
|
||||
/// warning
|
||||
|
||||
The project is currently unstable for packaging due to the complex setup.
|
||||
So the best way is to use the sources.
|
||||
It also uses a specific development environment via Nix.
|
||||
Tools like Podman can easily be replaced with Docker, so be careful.
|
||||
|
||||
///
|
||||
|
||||
First of all you need to prepare working instances of Postgres (database) and Redis (broker). There are many ways, you can choose any.
|
||||
|
||||
- Postgres:
|
||||
|
||||
```sh
|
||||
nix build .#postgresql-devel
|
||||
podman load < result
|
||||
podman run -p 54320:5432 --name database -dt postgresql:latest
|
||||
```
|
||||
|
||||
- Redis:
|
||||
|
||||
```sh
|
||||
nix build .#redis-devel
|
||||
podman load < result
|
||||
podman run -p 63790:6379 --name cache -dt redis:latest
|
||||
```
|
||||
|
||||
- Development environment:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pdm install --dev
|
||||
eval $(pdm venv activate)
|
||||
```
|
||||
|
||||
- Running tests (pytest):
|
||||
|
||||
```sh
|
||||
pytest
|
||||
```
|
||||
|
||||
- Running server:
|
||||
|
||||
```sh
|
||||
MATERIA_DATABASE__PORT=54320 MATERIA_CACHE__PORT=63790 materia start
|
||||
```
|
||||
|
||||
- Generating configuration and starting with it:
|
||||
|
||||
```sh
|
||||
materia config export --path ./config.toml
|
||||
materia start --config config.toml
|
||||
```
|
||||
|
||||
### Standalone frontend server
|
||||
|
||||
The frontend does not include a client by default. It must be generated from the OpenAPI specification which in turn must be generated from the current state of the FastAPI application. After that you can safely run the Vite server or build the entire Vue project.
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pdm install --dev
|
||||
eval $(pdm venv activate)
|
||||
|
||||
materia export openapi --path workspaces/frontend/openapi.json
|
||||
|
||||
cd workspaces/frontend
|
||||
npm install
|
||||
npm run openapi
|
||||
```
|
||||
|
||||
- Running frontend server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
In development mode, the server is configured to the address and port of the backend server.
|
||||
|
||||
## Design
|
||||
|
||||
At the current stage, the PDM package manager is used, since it is the only one that copes well with the concept of a monorepository. At the output, two Python packages should be built - materia (backend server) and materia-frontend (which wraps a built version of the NPM package materia-frontend-vue). Documentation (optional) and the frontend are served through the backend server.
|
||||
|
||||
The target platform of the project is NixOS. This also indirectly affects the approach to choosing tools for developing the current project and the support priority. And only secondly, packaging components for registries such as pypi and others. Support for the rest is a matter of time.
|
||||
|
||||
## Build
|
||||
|
||||
All build steps are wrapped in PDM pre-build scripts, so in a typical case you only need PDM and NodeJS.
|
||||
|
||||
- materia:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pdm build --skip ":all"
|
||||
```
|
||||
|
||||
- materia with documentation:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pdm install --prod --no-default --group docs
|
||||
pdm build
|
||||
```
|
||||
|
||||
- materia-frontend:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pdm install --prod
|
||||
cd workspaces/frontend
|
||||
pdm build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
**materia** is licensed under the terms of the [MIT License](LICENSE).
|
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
generated
500
flake.lock
generated
@ -1,51 +1,243 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"ags": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"bonfire",
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"lastModified": 1721306136,
|
||||
"narHash": "sha256-VKPsIGf3/a+RONBipx4lEE4LXG2sdMNkWQu22LNQItg=",
|
||||
"owner": "Aylur",
|
||||
"repo": "ags",
|
||||
"rev": "344ea72cd3b8d4911f362fec34bce7d8fb37028c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"owner": "Aylur",
|
||||
"repo": "ags",
|
||||
"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": {
|
||||
"nixpkgs": [
|
||||
"poetry2nix",
|
||||
"bonfire",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1703863825,
|
||||
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
|
||||
"lastModified": 1721322122,
|
||||
"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",
|
||||
"repo": "nix-github-actions",
|
||||
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
|
||||
"repo": "dream2nix",
|
||||
"rev": "3fd4c14d3683baac8d1f94286ae14fe160888b51",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"repo": "dream2nix",
|
||||
"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": {
|
||||
"locked": {
|
||||
"lastModified": 1714906307,
|
||||
"narHash": "sha256-UlRZtrCnhPFSJlDQE7M0eyhgvuuHBTe1eJ9N9AQlJQ0=",
|
||||
"lastModified": 1721379653,
|
||||
"narHash": "sha256-8MUgifkJ7lkZs3u99UDZMB4kbOxvMEXQZ31FO3SopZ0=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "25865a40d14b3f9cf19f19b924e2ab4069b09588",
|
||||
"rev": "1d9c2c9b3e71b9ee663d11c5d298727dace8d374",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -55,83 +247,255 @@
|
||||
"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": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nix-github-actions": "nix-github-actions",
|
||||
"devshell": [
|
||||
"bonfire"
|
||||
],
|
||||
"flake-compat": [
|
||||
"bonfire"
|
||||
],
|
||||
"flake-parts": "flake-parts",
|
||||
"git-hooks": [
|
||||
"bonfire"
|
||||
],
|
||||
"home-manager": [
|
||||
"bonfire"
|
||||
],
|
||||
"nix-darwin": [
|
||||
"bonfire"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"bonfire",
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems_2",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
"treefmt-nix": [
|
||||
"bonfire"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1715017507,
|
||||
"narHash": "sha256-RN2Vsba56PfX02DunWcZYkMLsipp928h+LVAWMYmbZg=",
|
||||
"lastModified": 1721772245,
|
||||
"narHash": "sha256-//9p3Qm8gLbPUTsSGN2EMYkDwE5Sqq9B9P2X/z2+npw=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "e6b36523407ae6a7a4dfe29770c30b3a3563b43a",
|
||||
"repo": "nixvim",
|
||||
"rev": "ab67ee7e8b33e788fc53d26dc6f423f9358e3e66",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"poetry2nix": "poetry2nix"
|
||||
"bonfire": "bonfire",
|
||||
"dream2nix": "dream2nix",
|
||||
"nixpkgs": "nixpkgs_3"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"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": {
|
||||
"slimlock": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"poetry2nix",
|
||||
"dream2nix",
|
||||
"purescript-overlay",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1714058656,
|
||||
"narHash": "sha256-Qv4RBm4LKuO4fNOfx9wl40W2rBbv5u5m+whxRYUMiaA=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "c6aaf729f34a36c445618580a9f95a48f5e4e03f",
|
||||
"lastModified": 1688610262,
|
||||
"narHash": "sha256-Wg0ViDotFWGWqKIQzyYCgayeH8s4U1OZcTiWTQYdAp4=",
|
||||
"owner": "thomashoneyman",
|
||||
"repo": "slimlock",
|
||||
"rev": "b5c6cdcaf636ebbebd0a1f32520929394493f1a6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"owner": "thomashoneyman",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
294
flake.nix
294
flake.nix
@ -1,81 +1,261 @@
|
||||
{
|
||||
description = "Materia is a file server";
|
||||
|
||||
nixConfig = {
|
||||
extra-substituters = [ "https://bonfire.cachix.org" ];
|
||||
extra-trusted-public-keys = [ "bonfire.cachix.org-1:mzAGBy/Crdf8NhKail5ciK7ZrGRbPJJobW6TwFb7WYM=" ];
|
||||
};
|
||||
description = "Materia";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
poetry2nix = {
|
||||
url = "github:nix-community/poetry2nix";
|
||||
dream2nix = {
|
||||
url = "github:nix-community/dream2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
bonfire.url = "github:L-Nafaryus/bonfire";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, poetry2nix, ... }:
|
||||
let
|
||||
#perSystem = systems: builtins.mapAttrs (name: value: nixpkgs.lib.genAttrs systems (system: value) );
|
||||
forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" ];
|
||||
nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
|
||||
in
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
dream2nix,
|
||||
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 =
|
||||
{
|
||||
|
||||
packages = forAllSystems (system: let
|
||||
pkgs = nixpkgsFor.${system};
|
||||
#inherit (poetry2nix.lib.mkPoetry2Nix { inherit pkgs; }) mkPoetryApplication;
|
||||
inherit dream2nix;
|
||||
packageSets.nixpkgs = pkgs;
|
||||
}
|
||||
// extraArgs;
|
||||
}
|
||||
)
|
||||
.config
|
||||
.public
|
||||
// {inherit meta;};
|
||||
in {
|
||||
#materia = mkPoetryApplication {
|
||||
# projectDir = ./.;
|
||||
#};
|
||||
packages.x86_64-linux = {
|
||||
materia-server = dreamBuildPackage {
|
||||
module = {
|
||||
config,
|
||||
lib,
|
||||
dream2nix,
|
||||
...
|
||||
}: {
|
||||
imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
|
||||
|
||||
#default = self.packages.${system}.materia;
|
||||
});
|
||||
pdm.lockfile = ./pdm.lock;
|
||||
pdm.pyproject = ./pyproject.toml;
|
||||
|
||||
apps = forAllSystems (system: {
|
||||
materia = let
|
||||
pkgs = nixpkgsFor.${system};
|
||||
app = (poetry2nix.lib.mkPoetry2Nix { inherit pkgs; }).mkPoetryApplication { projectDir = self; };
|
||||
in {
|
||||
type = "app";
|
||||
program = "${app}/bin/materia";
|
||||
deps = _: {
|
||||
python = pkgs.python312;
|
||||
};
|
||||
|
||||
#default = materia;
|
||||
});
|
||||
mkDerivation = {
|
||||
src = ./.;
|
||||
buildInputs = [
|
||||
pkgs.python312.pkgs.pdm-backend
|
||||
];
|
||||
nativeBuildInputs = [
|
||||
pkgs.python312.pkgs.wrapPython
|
||||
];
|
||||
configurePhase = ''
|
||||
${lib.getExe pkgs.mkdocs} build -d src/materia/docs/
|
||||
'';
|
||||
# TODO: include docs
|
||||
};
|
||||
};
|
||||
meta = with nixpkgs.lib; {
|
||||
description = "Materia";
|
||||
license = licenses.mit;
|
||||
maintainers = with bonLib.maintainers; [L-Nafaryus];
|
||||
broken = false;
|
||||
mainProgram = "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
|
||||
materia-frontend-vue = dreamBuildPackage {
|
||||
module = {
|
||||
lib,
|
||||
config,
|
||||
dream2nix,
|
||||
...
|
||||
}: {
|
||||
name = "materia-frontend-vue";
|
||||
version = "0.1.1";
|
||||
|
||||
postgresql
|
||||
|
||||
poetry
|
||||
imports = [
|
||||
dream2nix.modules.dream2nix.WIP-nodejs-builder-v3
|
||||
];
|
||||
|
||||
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}
|
||||
mkDerivation = {
|
||||
src = ./workspaces/frontend;
|
||||
configurePhase = ''
|
||||
${self.packages.x86_64-linux.materia-server}/bin/materia export openapi --path ./
|
||||
npm run openapi
|
||||
'';
|
||||
};
|
||||
});
|
||||
|
||||
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-vue;
|
||||
};
|
||||
module = {
|
||||
config,
|
||||
lib,
|
||||
dream2nix,
|
||||
materia-frontend-vue,
|
||||
...
|
||||
}: {
|
||||
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-vue}/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}"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
devShells.x86_64-linux.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [postgresql redis pdm nodejs python312];
|
||||
# greenlet requires libstdc++
|
||||
LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [pkgs.stdenv.cc.cc];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
133
mkdocs.yml
Normal file
133
mkdocs.yml
Normal file
@ -0,0 +1,133 @@
|
||||
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
|
||||
|
||||
markdown_extensions:
|
||||
# Python Markdown
|
||||
abbr:
|
||||
attr_list:
|
||||
footnotes:
|
||||
md_in_html:
|
||||
tables:
|
||||
toc:
|
||||
permalink: true
|
||||
|
||||
# Python Markdown Extensions
|
||||
pymdownx.betterem:
|
||||
smart_enable: all
|
||||
pymdownx.caret:
|
||||
pymdownx.highlight:
|
||||
line_spans: __span
|
||||
pymdownx.inlinehilite:
|
||||
pymdownx.keys:
|
||||
pymdownx.mark:
|
||||
pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
pymdownx.tilde:
|
||||
|
||||
# pymdownx blocks
|
||||
pymdownx.blocks.admonition:
|
||||
types:
|
||||
- note
|
||||
- attention
|
||||
- caution
|
||||
- danger
|
||||
- error
|
||||
- tip
|
||||
- hint
|
||||
- warning
|
||||
# Custom types
|
||||
- info
|
||||
- check
|
||||
pymdownx.blocks.details:
|
||||
pymdownx.blocks.tab:
|
||||
alternate_style: True
|
2
pdm.toml
Normal file
2
pdm.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[repository.elnafo-vcs]
|
||||
url = "https://vcs.elnafo.ru/api/packages/L-Nafaryus/pypi"
|
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
|
128
pyproject.toml
128
pyproject.toml
@ -1,38 +1,69 @@
|
||||
[tool.poetry]
|
||||
name = "materia-backend"
|
||||
version = "0.1.0"
|
||||
description = "Materia is a file server"
|
||||
[project]
|
||||
name = "materia"
|
||||
version = "0.1.1"
|
||||
description = "Materia is a simple and fast cloud storage"
|
||||
authors = [
|
||||
"L-Nafaryus <l.nafaryus@gmail.com>"
|
||||
{name = "L-Nafaryus", email = "l.nafaryus@gmail.com"},
|
||||
]
|
||||
maintainers = [
|
||||
"L-Nafaryus <l.nafaryus@gmail.com>"
|
||||
dependencies = [
|
||||
"fastapi<1.0.0,>=0.111.0",
|
||||
"uvicorn[standard]<1.0.0,>=0.29.0",
|
||||
"psycopg2-binary<3.0.0,>=2.9.9",
|
||||
"toml<1.0.0,>=0.10.2",
|
||||
"sqlalchemy[asyncio]<3.0.0,>=2.0.30",
|
||||
"asyncpg<1.0.0,>=0.29.0",
|
||||
"eventlet<1.0.0,>=0.36.1",
|
||||
"bcrypt==4.1.2",
|
||||
"pyjwt<3.0.0,>=2.8.0",
|
||||
"requests<3.0.0,>=2.31.0",
|
||||
"pillow<11.0.0,>=10.3.0",
|
||||
"sqids<1.0.0,>=0.4.1",
|
||||
"alembic<2.0.0,>=1.13.1",
|
||||
"authlib<2.0.0,>=1.3.0",
|
||||
"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"
|
||||
packages = [
|
||||
{ include = "src" }
|
||||
license = {text = "MIT"}
|
||||
|
||||
[project.optional-dependencies]
|
||||
docs = [
|
||||
"mkdocs-material>=9.5.38",
|
||||
"mkdocstrings-python>=1.11.1",
|
||||
"griffe-typingdoc>=0.2.7",
|
||||
"pymdown-extensions>=10.11",
|
||||
]
|
||||
frontend = [
|
||||
"materia-frontend>=0.1.1",
|
||||
]
|
||||
all = [
|
||||
"materia[docs,frontend]",
|
||||
]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
materia = "src.main:main"
|
||||
[build-system]
|
||||
requires = ["pdm-backend"]
|
||||
build-backend = "pdm.backend"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10,<3.12"
|
||||
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"
|
||||
[project.scripts]
|
||||
|
||||
[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"
|
||||
materia = "materia.app.cli:cli"
|
||||
|
||||
[tool.pyright]
|
||||
reportGeneralTypeIssues = false
|
||||
@ -41,6 +72,43 @@ reportGeneralTypeIssues = false
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
[tool.pdm]
|
||||
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",
|
||||
]
|
||||
|
||||
[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"
|
||||
docs.shell = "pdm run mkdocs build -d src/materia/docs/"
|
||||
pre_build.composite = [ "docs" ]
|
||||
|
||||
[tool.pdm.resolution]
|
||||
respect-source-order = true
|
||||
|
||||
[[tool.pdm.source]]
|
||||
name = "pypi"
|
||||
url = "https://pypi.org/simple"
|
||||
|
||||
[[tool.pdm.source]]
|
||||
name = "elnafo-vcs"
|
||||
url = "https://vcs.elnafo.ru/api/packages/L-Nafaryus/pypi"
|
||||
verify_ssl = true
|
||||
|
@ -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 NotImplementedError()
|
||||
|
||||
|
||||
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]] = (
|
||||
"changeme" # 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
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic import context
|
||||
import alembic_postgresql_enum
|
||||
|
||||
from src.config import config as materia_config
|
||||
from src.db import Base
|
||||
from materia.core import Config
|
||||
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.set_main_option("sqlalchemy.url", materia_config.database_url())
|
||||
config = context.config
|
||||
|
||||
# config.set_main_option("sqlalchemy.url", Config().database.url())
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
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
|
||||
|
||||
|
||||
# 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:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
@ -38,13 +56,12 @@ def run_migrations_offline() -> None:
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context = MigrationContext.configure(
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
opts = {
|
||||
"target_metadata": target_metadata,
|
||||
"literal_binds": True,
|
||||
}
|
||||
version_table_schema="public",
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
@ -52,12 +69,7 @@ def run_migrations_offline() -> None:
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context = MigrationContext.configure(
|
||||
connection = connection,
|
||||
opts = {
|
||||
"target_metadata": target_metadata,
|
||||
}
|
||||
)
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
@ -87,7 +99,8 @@ def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
#if context.is_offline_mode():
|
||||
#run_migrations_offline()
|
||||
#else:
|
||||
#run_migrations_online()
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
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}",
|
||||
)
|
34
src/materia/routers/docs.py
Normal file
34
src/materia/routers/docs.py
Normal file
@ -0,0 +1,34 @@
|
||||
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
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
try:
|
||||
from materia import docs as materia_docs
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
|
||||
@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
|
7
workspaces/frontend/README.md
Normal file
7
workspaces/frontend/README.md
Normal file
@ -0,0 +1,7 @@
|
||||
# materia-frontend
|
||||
|
||||
**Documentation**: [https://storage.elnafo.ru/docs](https://storage.elnafo.ru/docs)
|
||||
|
||||
**Source**: [https://vcs.elnafo.ru/L-Nafaryus/materia](https://vcs.elnafo.ru/L-Nafaryus/materia)
|
||||
|
||||
This is part of the Materia project.
|
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>
|
4075
workspaces/frontend/package-lock.json
generated
Normal file
4075
workspaces/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
workspaces/frontend/package.json
Normal file
38
workspaces/frontend/package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "materia-frontend-vue",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build-check": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"openapi": "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",
|
||||
"typescript": "~5.3.0",
|
||||
"vite": "^5.0.10",
|
||||
"vue-tsc": "^2.0.29"
|
||||
}
|
||||
}
|
177
workspaces/frontend/pdm.lock
generated
Normal file
177
workspaces/frontend/pdm.lock
generated
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: {},
|
||||
},
|
||||
}
|
47
workspaces/frontend/pyproject.toml
Normal file
47
workspaces/frontend/pyproject.toml
Normal file
@ -0,0 +1,47 @@
|
||||
[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]
|
||||
openapi-json.cmd = "pdm run -p ../../ python -m materia export openapi --path ./openapi.json"
|
||||
npm-install.cmd = "npm install --prefix ./"
|
||||
openapi-client.cmd = "npm run openapi"
|
||||
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 = [ "openapi-json", "npm-install", "openapi-client", "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>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user