mirror of
https://github.com/clowzed/sero
synced 2024-05-23 09:42:12 +02:00
journey starts here
This commit is contained in:
commit
9d66e9301a
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
@ -0,0 +1,8 @@
|
|||
target
|
||||
.git
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
readme.md
|
||||
logo.svg
|
||||
.github
|
||||
nginx-templates
|
31
.github/workflows/build.yml
vendored
Normal file
31
.github/workflows/build.yml
vendored
Normal file
|
@ -0,0 +1,31 @@
|
|||
name: docker build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: clowzed
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: clowzed/sero:latest
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
3718
Cargo.lock
generated
Normal file
3718
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
43
Cargo.toml
Normal file
43
Cargo.toml
Normal file
|
@ -0,0 +1,43 @@
|
|||
[package]
|
||||
name = "sero"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["clowzed <clowzed.work@gmail.com>"]
|
||||
description = "Muiltidomain static site hosting"
|
||||
publish = false
|
||||
readme = "readme.md"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.6.20", features = ["macros", "tracing", "headers", "multipart"] }
|
||||
envy = "0.4.2"
|
||||
sea-orm = { version = "0.12.3", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
|
||||
tokio = { version = "1.32.0", features = ["full"] }
|
||||
tokio-postgres = "0.7.10"
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter", "fmt"] }
|
||||
entity = { path = "entity" }
|
||||
migration = { path = "migration" }
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
jsonwebtoken = "8.3.0"
|
||||
axum-extra = { version = "0.8.0", features = ["cookie"] }
|
||||
sha256 = "1.4.0"
|
||||
thiserror = "1.0.49"
|
||||
chrono = "0.4.31"
|
||||
futures-util = "0.3.28"
|
||||
axum_typed_multipart = "0.10.0"
|
||||
uuid = { version = "1.4.1", features = ["v4"] }
|
||||
async_zip = { version = "0.0.15", features = ["tokio-fs", "tokio", "full"] }
|
||||
bytes = "1.5.0"
|
||||
async-trait = "0.1.73"
|
||||
tokio-util = { version = "0.7.9", features = ["io"] }
|
||||
dotenv = "0.15.0"
|
||||
|
||||
[workspace]
|
||||
members = [".", "entity", "migration"]
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
strip = true
|
||||
opt-level = 3
|
||||
codegen-units = 1
|
7
Dockerfile
Normal file
7
Dockerfile
Normal file
|
@ -0,0 +1,7 @@
|
|||
FROM rust:1.73 as builder
|
||||
WORKDIR /usr/src/app
|
||||
COPY . .
|
||||
RUN cargo build --release
|
||||
FROM debian:stable-slim
|
||||
COPY --from=builder /usr/src/app/target/release/sero /usr/local/bin/sero
|
||||
CMD ["sero"]
|
63
docker-compose.yml
Normal file
63
docker-compose.yml
Normal file
|
@ -0,0 +1,63 @@
|
|||
version: "3"
|
||||
|
||||
services:
|
||||
database:
|
||||
image: postgres:latest
|
||||
user: postgres
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_DB=sero
|
||||
- POSTGRES_PASSWORD=1234
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
|
||||
proxy:
|
||||
image: nginx
|
||||
restart: always
|
||||
environment:
|
||||
- DOLLAR=$
|
||||
- SERVER_PORT=8080
|
||||
- SERVER=server
|
||||
# Edit this
|
||||
- DOMAIN=
|
||||
- ZONE=
|
||||
# End of edit
|
||||
volumes:
|
||||
- ./nginx-templates:/etc/nginx/templates
|
||||
ports:
|
||||
- 443:443
|
||||
- 80:80
|
||||
links:
|
||||
- server
|
||||
|
||||
server:
|
||||
image: clowzed/sero
|
||||
restart: always
|
||||
build: .
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- server-files:/app
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:1234@database/sero
|
||||
- PORT=8080
|
||||
# You can edit this section
|
||||
# Empty means no limits
|
||||
- MAX_USERS=1
|
||||
- MAX_SITES_PER_USER=100
|
||||
- MAX_BODY_LIMIT_BYTES=10000000
|
||||
- RUST_LOG=none,sero=trace
|
||||
# end of section
|
||||
|
||||
volumes:
|
||||
server-files:
|
||||
pgdata:
|
9
entity/Cargo.toml
Normal file
9
entity/Cargo.toml
Normal file
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "entity"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
sea-orm = { version = "0.12" }
|
34
entity/src/file.rs
Normal file
34
entity/src/file.rs
Normal file
|
@ -0,0 +1,34 @@
|
|||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "file")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub subdomain_id: i32,
|
||||
pub user_path: String,
|
||||
#[sea_orm(unique)]
|
||||
pub real_path: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::subdomain::Entity",
|
||||
from = "Column::SubdomainId",
|
||||
to = "super::subdomain::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Subdomain,
|
||||
}
|
||||
|
||||
impl Related<super::subdomain::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Subdomain.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
5
entity/src/lib.rs
Normal file
5
entity/src/lib.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
pub mod prelude;
|
||||
|
||||
pub mod file;
|
||||
pub mod subdomain;
|
||||
pub mod user;
|
7
entity/src/mod.rs
Normal file
7
entity/src/mod.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod file;
|
||||
pub mod subdomain;
|
||||
pub mod user;
|
15
entity/src/prelude.rs
Normal file
15
entity/src/prelude.rs
Normal file
|
@ -0,0 +1,15 @@
|
|||
pub use super::file::Entity as FileEntity;
|
||||
pub use super::subdomain::Entity as SubdomainEntity;
|
||||
pub use super::user::Entity as UserEntity;
|
||||
|
||||
pub use super::file::ActiveModel as ActiveFile;
|
||||
pub use super::subdomain::ActiveModel as ActiveSubdomain;
|
||||
pub use super::user::ActiveModel as ActiveUser;
|
||||
|
||||
pub use super::file::Model as File;
|
||||
pub use super::subdomain::Model as Subdomain;
|
||||
pub use super::user::Model as User;
|
||||
|
||||
pub use super::file::Column as FileColumn;
|
||||
pub use super::subdomain::Column as SubdomainColumn;
|
||||
pub use super::user::Column as UserColumn;
|
44
entity/src/subdomain.rs
Normal file
44
entity/src/subdomain.rs
Normal file
|
@ -0,0 +1,44 @@
|
|||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "subdomain")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub owner_id: i32,
|
||||
pub enabled: bool,
|
||||
#[sea_orm(unique)]
|
||||
pub name: String,
|
||||
#[sea_orm(unique)]
|
||||
pub archive_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::file::Entity")]
|
||||
File,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::OwnerId",
|
||||
to = "super::user::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "NoAction"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::file::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::File.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
39
entity/src/user.rs
Normal file
39
entity/src/user.rs
Normal file
|
@ -0,0 +1,39 @@
|
|||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
|
||||
|
||||
use std::fmt::Debug;
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "user")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
#[sea_orm(unique)]
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::subdomain::Entity")]
|
||||
Subdomain,
|
||||
}
|
||||
|
||||
impl Related<super::subdomain::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Subdomain.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
impl Debug for Model {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Model")
|
||||
.field("id", &self.id)
|
||||
.field("username", &self.username)
|
||||
.field("password", &"...")
|
||||
.finish()
|
||||
}
|
||||
}
|
32
logo.svg
Normal file
32
logo.svg
Normal file
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 413.182 413.182" xml:space="preserve">
|
||||
<g>
|
||||
<path style="fill:#010002;" d="M413.182,200.621h-0.143c-1.402-48.666-19.905-95.154-52.544-131.468
|
||||
c-38.051-42.341-92.093-66.99-148.826-68.154V0.886h-4.195h-1.772h-4.195v0.113C144.774,2.163,90.732,26.812,52.687,69.153
|
||||
c-32.645,36.314-51.142,82.802-52.55,131.468H0v5.967v5.967h0.137c1.408,48.666,19.911,95.148,52.556,131.468
|
||||
c38.045,42.347,92.093,66.996,148.82,68.16v0.113h4.195h1.772h4.195v-0.113c56.733-1.164,110.775-25.813,148.826-68.154
|
||||
c32.639-36.314,51.136-82.808,52.544-131.474h0.131v-5.716c0-0.084,0.006-0.167,0.006-0.251V200.621z M401.147,200.621h-78.936
|
||||
c-0.656-27.907-5.919-55.009-15.687-80.726c18.33-10.084,34.882-22.77,49.334-37.836
|
||||
C383.879,115.414,399.816,157.069,401.147,200.621z M121.802,298.931c24.512-11.832,50.677-18.223,77.939-19.034v111.98
|
||||
C165.986,370.307,138.676,337.764,121.802,298.931z M213.441,145.219c28.802-0.811,56.453-7.482,82.391-19.876
|
||||
c8.956,24.005,13.807,49.262,14.446,75.278h-96.836V145.219z M213.441,133.285V21.305c33.749,21.564,61.059,54.102,77.939,92.946
|
||||
C266.868,126.077,240.704,132.473,213.441,133.285z M199.735,133.285c-27.257-0.812-53.415-7.208-77.939-19.04
|
||||
c16.874-38.838,44.185-71.376,77.939-92.94V133.285z M117.344,125.343c25.938,12.393,53.582,19.064,82.385,19.876v55.402h-96.83
|
||||
C103.543,174.606,108.388,149.342,117.344,125.343z M199.741,212.555v55.402c-28.802,0.811-56.453,7.482-82.385,19.882
|
||||
c-8.962-24.005-13.807-49.268-14.452-75.284H199.741z M213.441,279.891c27.257,0.817,53.421,7.208,77.945,19.034
|
||||
c-16.874,38.844-44.185,71.382-77.945,92.94V279.891z M295.837,287.833c-25.938-12.393-53.588-19.064-82.396-19.876v-55.402h96.836
|
||||
C309.645,238.57,304.794,263.834,295.837,287.833z M347.767,73.061c-13.425,14.124-28.79,26.039-45.814,35.569
|
||||
c-17.28-39.334-44.769-72.509-78.709-95.166C270.466,17.343,314.908,38.555,347.767,73.061z M189.931,13.464
|
||||
c-33.94,22.656-61.435,55.832-78.709,95.166c-17.023-9.529-32.388-21.445-45.82-35.569
|
||||
C98.268,38.555,142.704,17.343,189.931,13.464z M57.318,82.059c14.452,15.066,31.004,27.752,49.334,37.836
|
||||
c-9.762,25.723-15.031,52.819-15.687,80.726H12.023C13.36,157.069,29.297,115.408,57.318,82.059z M12.023,212.555h78.948
|
||||
c0.656,27.919,5.925,55.015,15.687,80.732c-18.336,10.084-34.888,22.77-49.334,37.83C29.303,297.768,13.36,256.113,12.023,212.555z
|
||||
M65.409,340.115c13.425-14.112,28.784-26.033,45.82-35.569c17.28,39.334,44.769,72.509,78.715,95.166
|
||||
C142.716,395.839,98.28,374.627,65.409,340.115z M223.244,399.712c33.94-22.656,61.435-55.832,78.709-95.166
|
||||
c17.035,9.541,32.406,21.457,45.826,35.569C314.913,374.621,270.472,395.839,223.244,399.712z M355.864,331.117
|
||||
c-14.446-15.054-30.992-27.74-49.334-37.836c9.768-25.717,15.031-52.813,15.687-80.726h78.936
|
||||
C399.822,256.113,383.885,297.768,355.864,331.117z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3 KiB |
19
migration/Cargo.toml
Normal file
19
migration/Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "migration"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "migration"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
||||
|
||||
[dependencies.sea-orm-migration]
|
||||
version = "0.12.0"
|
||||
features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlx-postgres",
|
||||
]
|
41
migration/README.md
Normal file
41
migration/README.md
Normal file
|
@ -0,0 +1,41 @@
|
|||
# Running Migrator CLI
|
||||
|
||||
- Generate a new migration file
|
||||
```sh
|
||||
cargo run -- generate MIGRATION_NAME
|
||||
```
|
||||
- Apply all pending migrations
|
||||
```sh
|
||||
cargo run
|
||||
```
|
||||
```sh
|
||||
cargo run -- up
|
||||
```
|
||||
- Apply first 10 pending migrations
|
||||
```sh
|
||||
cargo run -- up -n 10
|
||||
```
|
||||
- Rollback last applied migrations
|
||||
```sh
|
||||
cargo run -- down
|
||||
```
|
||||
- Rollback last 10 applied migrations
|
||||
```sh
|
||||
cargo run -- down -n 10
|
||||
```
|
||||
- Drop all tables from the database, then reapply all migrations
|
||||
```sh
|
||||
cargo run -- fresh
|
||||
```
|
||||
- Rollback all applied migrations, then reapply all migrations
|
||||
```sh
|
||||
cargo run -- refresh
|
||||
```
|
||||
- Rollback all applied migrations
|
||||
```sh
|
||||
cargo run -- reset
|
||||
```
|
||||
- Check the status of all migrations
|
||||
```sh
|
||||
cargo run -- status
|
||||
```
|
18
migration/src/lib.rs
Normal file
18
migration/src/lib.rs
Normal file
|
@ -0,0 +1,18 @@
|
|||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20230927_162921_create_users;
|
||||
mod m20230929_081415_create_subdomains;
|
||||
mod m20230929_152215_create_files;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![
|
||||
Box::new(m20230927_162921_create_users::Migration),
|
||||
Box::new(m20230929_081415_create_subdomains::Migration),
|
||||
Box::new(m20230929_152215_create_files::Migration),
|
||||
]
|
||||
}
|
||||
}
|
46
migration/src/m20230927_162921_create_users.rs
Normal file
46
migration/src/m20230927_162921_create_users.rs
Normal file
|
@ -0,0 +1,46 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(User::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(User::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(User::Username)
|
||||
.string()
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(ColumnDef::new(User::Password).string().not_null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(User::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
pub enum User {
|
||||
Table,
|
||||
Id,
|
||||
Username,
|
||||
Password,
|
||||
}
|
61
migration/src/m20230929_081415_create_subdomains.rs
Normal file
61
migration/src/m20230929_081415_create_subdomains.rs
Normal file
|
@ -0,0 +1,61 @@
|
|||
use crate::m20230927_162921_create_users::User;
|
||||
use sea_orm_migration::prelude::*;
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Subdomain::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Subdomain::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Subdomain::OwnerId).integer().not_null())
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("FK_user_subdomain")
|
||||
.from(Subdomain::Table, Subdomain::OwnerId)
|
||||
.to(User::Table, User::Id),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Subdomain::Enabled)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Subdomain::Name)
|
||||
.string()
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Subdomain::ArchivePath).string().unique_key())
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Subdomain::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
pub enum Subdomain {
|
||||
Table,
|
||||
Id,
|
||||
Name,
|
||||
OwnerId,
|
||||
ArchivePath,
|
||||
Enabled,
|
||||
}
|
56
migration/src/m20230929_152215_create_files.rs
Normal file
56
migration/src/m20230929_152215_create_files.rs
Normal file
|
@ -0,0 +1,56 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
use crate::m20230929_081415_create_subdomains::Subdomain;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(File::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(File::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(File::SubdomainId).integer().not_null())
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.from(File::Table, File::SubdomainId)
|
||||
.to(Subdomain::Table, Subdomain::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.col(ColumnDef::new(File::UserPath).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(File::RealPath)
|
||||
.string()
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(File::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum File {
|
||||
Table,
|
||||
Id,
|
||||
SubdomainId,
|
||||
UserPath,
|
||||
RealPath,
|
||||
}
|
6
migration/src/main.rs
Normal file
6
migration/src/main.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() {
|
||||
cli::run_cli(migration::Migrator).await;
|
||||
}
|
21
nginx-templates/default.conf.template
Normal file
21
nginx-templates/default.conf.template
Normal file
|
@ -0,0 +1,21 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name ~^(?<subdomain>\w*)\.${DOMAIN}.${ZONE}${DOLLAR};
|
||||
|
||||
location / {
|
||||
if ($http_x_subdomain = "") {
|
||||
set $http_x_subdomain $subdomain;
|
||||
}
|
||||
proxy_set_header X-Subdomain $http_x_subdomain;
|
||||
proxy_pass http://${SERVER}:${SERVER_PORT}/;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${DOMAIN}.${ZONE};
|
||||
|
||||
location / {
|
||||
proxy_pass http://${SERVER}:${SERVER_PORT}/;
|
||||
}
|
||||
}
|
351
openapi.yml
Normal file
351
openapi.yml
Normal file
|
@ -0,0 +1,351 @@
|
|||
openapi: 3.1.0
|
||||
info:
|
||||
title: sero API
|
||||
description: API of sero server
|
||||
version: 1.0.0
|
||||
|
||||
paths:
|
||||
/api/health:
|
||||
get:
|
||||
summary: Check that server is alive
|
||||
responses:
|
||||
"200":
|
||||
description: Server is alive
|
||||
|
||||
/api/login:
|
||||
post:
|
||||
summary: Authenticate user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthCredentials"
|
||||
responses:
|
||||
"200":
|
||||
description: Authentication successful, returns AuthToken
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthToken"
|
||||
"400":
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"401":
|
||||
description: Authentication failed, unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"500":
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
|
||||
/api/registration:
|
||||
post:
|
||||
summary: Register user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthCredentials"
|
||||
responses:
|
||||
"200":
|
||||
description: Registration succeeded
|
||||
|
||||
"400":
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
|
||||
"409":
|
||||
description: Registration failed, user has already been registered
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"500":
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
/api/enable:
|
||||
post:
|
||||
summary: Enable site
|
||||
parameters:
|
||||
- in: header
|
||||
name: X-Subdomain
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
security:
|
||||
- bearerAuth: []
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: Site was successfully enabled
|
||||
|
||||
"403":
|
||||
description: Subdomain is owned by another user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"404":
|
||||
description: Subdomain was not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"400":
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"500":
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
|
||||
/api/disable:
|
||||
post:
|
||||
summary: Enable site
|
||||
parameters:
|
||||
- in: header
|
||||
name: X-Subdomain
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
security:
|
||||
- bearerAuth: []
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: Site was successfully disabled
|
||||
|
||||
"403":
|
||||
description: Subdomain is owned by another user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"404":
|
||||
description: Subdomain was not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"400":
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"500":
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
/api/teardown:
|
||||
post:
|
||||
summary: Enable site
|
||||
parameters:
|
||||
- in: header
|
||||
name: X-Subdomain
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
security:
|
||||
- bearerAuth: []
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: Site was deleted
|
||||
"403":
|
||||
description: Subdomain is owned by another user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"404":
|
||||
description: Subdomain was not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"400":
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"500":
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
|
||||
/api/download:
|
||||
post:
|
||||
summary: Download site as zip
|
||||
parameters:
|
||||
- in: header
|
||||
name: X-Subdomain
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
security:
|
||||
- bearerAuth: []
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: Site was successfully downloaded
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
|
||||
"403":
|
||||
description: Subdomain is owned by another user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"404":
|
||||
description: Subdomain was not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"400":
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"500":
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
|
||||
/api/upload:
|
||||
post:
|
||||
summary: Upload site as zip
|
||||
parameters:
|
||||
- in: header
|
||||
name: X-Subdomain
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
archive:
|
||||
type: string
|
||||
format: binary
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: Site was successfully uploaded
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
|
||||
"403":
|
||||
description: Subdomain is owned by another user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"400":
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
"500":
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Details"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
AuthCredentials:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
AuthToken:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
Details:
|
||||
type: object
|
||||
properties:
|
||||
details:
|
||||
type: string
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
86
readme.md
Normal file
86
readme.md
Normal file
|
@ -0,0 +1,86 @@
|
|||
<div align="center">
|
||||
<img src="./logo.svg" style="height:100px">
|
||||
<h1> Sero Server </h1>
|
||||
<h3>Sero is a web server that allows you to easily host your static sites without pain<br>
|
||||
The idea was inspired by <a href="https://surge.sh">surge.sh</a> but gives you <b>full control</b>.</h3><br>
|
||||
|
||||
![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white)
|
||||
![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white)
|
||||
![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)
|
||||
![Nginx](https://img.shields.io/badge/nginx-%23009639.svg?style=for-the-badge&logo=nginx&logoColor=white)
|
||||
|
||||
![License](https://img.shields.io/badge/license-MIT-green?style=for-the-badge)
|
||||
|
||||
</div>
|
||||
|
||||
## Upload
|
||||
|
||||
[Use this tool for uploading your site to sero server in one command](https://github.com/clowzed/seroup)
|
||||
|
||||
## Features
|
||||
|
||||
- Deploy the site with a custom subdomain in seconds!
|
||||
- Download the site that you already host!
|
||||
- Teardown sites that you do not like anymore
|
||||
- Control amount of users and sites
|
||||
- Custom 404.html
|
||||
- Custom 503.html
|
||||
- Enable and disable site for maintenance
|
||||
|
||||
## Installation and deployment
|
||||
|
||||
### Requirements
|
||||
|
||||
- You need to have access to the DNS configuration of the server
|
||||
|
||||
## Step 0:
|
||||
```
|
||||
git clone https://github.com/clowzed/sero
|
||||
```
|
||||
|
||||
## Step 1: Configure DNS records
|
||||
|
||||
- Add wildcard (\*) subdomain to your server
|
||||
|
||||
## Step 2: Configure docker-compose.yml
|
||||
|
||||
1. Configure your domain and zone (for example for example.com)
|
||||
|
||||
| environment variable | description |
|
||||
|----------------------|-------------|
|
||||
| DOMAIN | example |
|
||||
| ZONE | com |
|
||||
|
||||
2. Configure limits
|
||||
|
||||
| environment variable | description | already setted |
|
||||
|----------------------|----------------------------------------------------|-----------------|
|
||||
| MAX_USERS | Maximum amount of users to be registered | 1 |
|
||||
| MAX_SITES_PER_USER | Maximum amount of sites which each user can upload | 100 |
|
||||
| MAX_BODY_LIMIT_BYTES | Maximum body limit in bytes | 10000000 (10mb) |
|
||||
|
||||
#### Step 2: Deploy
|
||||
|
||||
```bash
|
||||
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## TODO:
|
||||
|
||||
- [ ] UI
|
||||
- [ ] CORS
|
||||
|
||||
# Author
|
||||
|
||||
- [@clowzed](https://github.com/clowzed)
|
||||
|
||||
# License
|
||||
|
||||
- MIT
|
||||
|
||||
<style>
|
||||
*{
|
||||
font-family: PT Mono, SF Mono, "Courier New"
|
||||
}
|
||||
</style>
|
134
src/apperror.rs
Normal file
134
src/apperror.rs
Normal file
|
@ -0,0 +1,134 @@
|
|||
use std::{error::Error, fmt::Debug};
|
||||
|
||||
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Details {
|
||||
details: String,
|
||||
}
|
||||
|
||||
pub enum SeroError {
|
||||
XSubdomainHeaderMissing,
|
||||
AuthorizationHeaderMissing,
|
||||
AuthorizationHeaderBadSchema,
|
||||
AuthorizationHeaderBabChars,
|
||||
InternalServerError(Box<dyn Error>),
|
||||
SubdomainIsOwnedByAnotherUser(String),
|
||||
UserWasNotFoundUsingJwt,
|
||||
RegisteredUserLimitExceeded,
|
||||
Unauthorized,
|
||||
UserHasAlreadyBeenRegistered,
|
||||
SubdomainWasNotFound(String),
|
||||
ArchiveFileWasNotFoundForSubdomain(String),
|
||||
MaxSitesPerUserLimitExceeded,
|
||||
SiteDisabled,
|
||||
EmptyCredentials,
|
||||
}
|
||||
|
||||
impl IntoResponse for SeroError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let response = match self {
|
||||
SeroError::XSubdomainHeaderMissing => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(Details {
|
||||
details: "X-Subdomain header is missing!".into(),
|
||||
}),
|
||||
),
|
||||
SeroError::AuthorizationHeaderMissing => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(Details {
|
||||
details: "Authorization header is missing!".into(),
|
||||
}),
|
||||
),
|
||||
SeroError::AuthorizationHeaderBadSchema => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(Details {
|
||||
details: "Authorization header does not match schema!
|
||||
Required schema: Authorization: Bearer <token>"
|
||||
.into(),
|
||||
}),
|
||||
),
|
||||
SeroError::SubdomainIsOwnedByAnotherUser(subdomain_name) => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(Details {
|
||||
details: format!(
|
||||
"Subdomain with name {} is owned by another user!",
|
||||
subdomain_name
|
||||
),
|
||||
}),
|
||||
),
|
||||
SeroError::AuthorizationHeaderBabChars => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(Details {
|
||||
details: "Authorization header contains invalid characters!".into(),
|
||||
}),
|
||||
),
|
||||
SeroError::InternalServerError(cause) => {
|
||||
tracing::error!(%cause, "Error!");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(Details {
|
||||
details: "Some error occurred on the server!".into(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
SeroError::UserWasNotFoundUsingJwt => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(Details {
|
||||
details: "User with id from jwt token was not found!".into(),
|
||||
}),
|
||||
),
|
||||
SeroError::RegisteredUserLimitExceeded => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(Details {
|
||||
details: "Registered user limit exceeded!".into(),
|
||||
}),
|
||||
),
|
||||
SeroError::Unauthorized => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(Details {
|
||||
details: "Unauthorized! Bad credentials were provided!".into(),
|
||||
}),
|
||||
),
|
||||
SeroError::UserHasAlreadyBeenRegistered => (
|
||||
StatusCode::CONFLICT,
|
||||
Json(Details {
|
||||
details: "User with this username has already been registered!".into(),
|
||||
}),
|
||||
),
|
||||
SeroError::SubdomainWasNotFound(subdomain_name) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(Details {
|
||||
details: format!("Subdomain with name {subdomain_name} was not found!"),
|
||||
}),
|
||||
),
|
||||
SeroError::ArchiveFileWasNotFoundForSubdomain(subdomain_name) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(Details {
|
||||
details: format!("Archive file was not found for subdomain {subdomain_name}"),
|
||||
}),
|
||||
),
|
||||
SeroError::MaxSitesPerUserLimitExceeded => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(Details {
|
||||
details: "Max sites per this user limit exceeded!".into(),
|
||||
}),
|
||||
),
|
||||
SeroError::SiteDisabled => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(Details {
|
||||
details: "Service is currently unavailable!".into(),
|
||||
}),
|
||||
),
|
||||
SeroError::EmptyCredentials => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(Details {
|
||||
details: "Username or password is empty!".into(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
tracing::error!(cause = response.1.details, "Response with error!");
|
||||
response.into_response()
|
||||
}
|
||||
}
|
20
src/config/mod.rs
Normal file
20
src/config/mod.rs
Normal file
|
@ -0,0 +1,20 @@
|
|||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub max_sites_per_user: Option<usize>,
|
||||
pub max_users: Option<u64>,
|
||||
pub max_body_limit_size: Option<usize>,
|
||||
pub jwt_secret: Option<String>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let mut config: Self =
|
||||
envy::from_env().expect("Failed to read config from environment variables!");
|
||||
if config.jwt_secret.is_none() {
|
||||
config.jwt_secret = Some(uuid::Uuid::new_v4().to_string())
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
134
src/extractors/mod.rs
Normal file
134
src/extractors/mod.rs
Normal file
|
@ -0,0 +1,134 @@
|
|||
use axum::{
|
||||
async_trait,
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::request::Parts,
|
||||
};
|
||||
use sea_orm::prelude::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{apperror::SeroError, services::users::UsersService};
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone)]
|
||||
pub struct TokenClaims {
|
||||
pub sub: i32,
|
||||
pub iat: u64,
|
||||
pub exp: u64,
|
||||
}
|
||||
|
||||
pub struct Subdomain(pub String);
|
||||
|
||||
pub struct SubdomainModel(pub entity::prelude::Subdomain);
|
||||
pub struct AuthJWT(pub entity::user::Model);
|
||||
|
||||
pub struct RegistrationGuard;
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for AuthJWT
|
||||
where
|
||||
Arc<crate::AppState>: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = SeroError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app_state = Arc::from_ref(state);
|
||||
|
||||
let auth_header_value = parts
|
||||
.headers
|
||||
.get("Authorization")
|
||||
.ok_or(SeroError::AuthorizationHeaderMissing)?
|
||||
.to_str()
|
||||
.map_err(|_| SeroError::AuthorizationHeaderBabChars)?;
|
||||
|
||||
let token = match auth_header_value.split_once(' ') {
|
||||
Some(("Bearer", contents)) => Ok(contents.to_string()),
|
||||
_ => Err(SeroError::AuthorizationHeaderBadSchema),
|
||||
}?;
|
||||
|
||||
match crate::services::auth::AuthService::jwtcheck(
|
||||
&token,
|
||||
&app_state.connection,
|
||||
app_state.config.jwt_secret.as_ref().unwrap(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(user)) => Ok(Self(user)),
|
||||
Ok(None) => Err(SeroError::UserWasNotFoundUsingJwt),
|
||||
Err(cause) => Err(SeroError::InternalServerError(Box::new(cause))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for Subdomain
|
||||
where
|
||||
Arc<crate::AppState>: FromRef<S>,
|
||||
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = SeroError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
Ok(Self({
|
||||
let header = parts
|
||||
.headers
|
||||
.get("X-Subdomain")
|
||||
.ok_or(SeroError::XSubdomainHeaderMissing)?
|
||||
.to_str()
|
||||
.map_err(|_| SeroError::XSubdomainHeaderMissing)?
|
||||
.to_string();
|
||||
|
||||
match header.is_empty() {
|
||||
true => Err(SeroError::XSubdomainHeaderMissing)?,
|
||||
false => header,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for SubdomainModel
|
||||
where
|
||||
Arc<crate::AppState>: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = SeroError;
|
||||
|
||||
#[tracing::instrument(skip(parts, state))]
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app_state = Arc::from_ref(state);
|
||||
|
||||
let subdomain_name = Subdomain::from_request_parts(parts, state).await?.0;
|
||||
Ok(match entity::prelude::SubdomainEntity::find()
|
||||
.filter(entity::prelude::SubdomainColumn::Name.eq(&subdomain_name))
|
||||
.one(&app_state.connection)
|
||||
.await
|
||||
{
|
||||
Ok(Some(subdomain)) => Ok(Self(subdomain)),
|
||||
Ok(None) => Err(SeroError::SubdomainWasNotFound(subdomain_name)),
|
||||
Err(cause) => Err(SeroError::InternalServerError(Box::new(cause))),
|
||||
}?)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for RegistrationGuard
|
||||
where
|
||||
Arc<crate::AppState>: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = SeroError;
|
||||
|
||||
#[tracing::instrument(skip(_parts, state))]
|
||||
async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app_state = Arc::from_ref(state);
|
||||
|
||||
match UsersService::count(&app_state.connection).await {
|
||||
Ok(count) => match app_state.config.max_users {
|
||||
Some(max_users) if count > max_users => Err(SeroError::RegisteredUserLimitExceeded),
|
||||
_ => Ok(Self {}),
|
||||
},
|
||||
Err(cause) => Err(SeroError::InternalServerError(Box::new(cause))),
|
||||
}
|
||||
}
|
||||
}
|
56
src/handlers/auth.rs
Normal file
56
src/handlers/auth.rs
Normal file
|
@ -0,0 +1,56 @@
|
|||
use crate::{
|
||||
apperror::SeroError,
|
||||
extractors::RegistrationGuard,
|
||||
services::auth::{AuthCredentials, AuthService, Jwt},
|
||||
AppState,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Form, Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct AuthToken {
|
||||
token: Jwt,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Form(credentials): Form<AuthCredentials>,
|
||||
) -> Response {
|
||||
if !credentials.valid() {
|
||||
return SeroError::EmptyCredentials.into_response();
|
||||
}
|
||||
match AuthService::login(
|
||||
credentials,
|
||||
&state.connection,
|
||||
state.config.jwt_secret.as_ref().unwrap(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(token)) => (StatusCode::OK, Json(AuthToken { token })).into_response(),
|
||||
Ok(None) => SeroError::Unauthorized.into_response(),
|
||||
Err(cause) => SeroError::InternalServerError(Box::new(cause)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn registration(
|
||||
_: RegistrationGuard,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Form(credentials): Form<AuthCredentials>,
|
||||
) -> Response {
|
||||
if !credentials.valid() {
|
||||
return SeroError::EmptyCredentials.into_response();
|
||||
}
|
||||
match AuthService::registration(credentials, &state.connection).await {
|
||||
Ok(Some(_)) => StatusCode::OK.into_response(),
|
||||
Ok(None) => SeroError::UserHasAlreadyBeenRegistered.into_response(),
|
||||
Err(cause) => SeroError::InternalServerError(Box::new(cause)).into_response(),
|
||||
}
|
||||
}
|
2
src/handlers/mod.rs
Normal file
2
src/handlers/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod auth;
|
||||
pub mod sites;
|
172
src/handlers/sites.rs
Normal file
172
src/handlers/sites.rs
Normal file
|
@ -0,0 +1,172 @@
|
|||
use crate::{
|
||||
apperror::SeroError,
|
||||
extractors::{
|
||||
AuthJWT, Subdomain as SubdomainExtractor, SubdomainModel as SubdomainModelExtractor,
|
||||
},
|
||||
services::sites::SitesService,
|
||||
AppState,
|
||||
};
|
||||
use entity::prelude::*;
|
||||
|
||||
use sea_orm::prelude::*;
|
||||
|
||||
use axum::{
|
||||
body::StreamBody,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
|
||||
use std::sync::Arc;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
#[derive(TryFromMultipart)]
|
||||
pub struct UploadData {
|
||||
pub archive: FieldData<Bytes>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, archive))]
|
||||
pub async fn upload(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AuthJWT(user): AuthJWT,
|
||||
SubdomainExtractor(subdomain_name): SubdomainExtractor,
|
||||
TypedMultipart(UploadData { archive }): TypedMultipart<UploadData>,
|
||||
) -> Response {
|
||||
match user
|
||||
.find_related(SubdomainEntity)
|
||||
.all(&state.connection)
|
||||
.await
|
||||
{
|
||||
Ok(subdomains) => {
|
||||
if state.config.max_sites_per_user.is_some()
|
||||
&& subdomains.len() >= state.config.max_sites_per_user.unwrap()
|
||||
&& !subdomains
|
||||
.iter()
|
||||
.any(|subdomain| subdomain.name == subdomain_name)
|
||||
{
|
||||
return SeroError::MaxSitesPerUserLimitExceeded.into_response();
|
||||
}
|
||||
}
|
||||
Err(cause) => return SeroError::InternalServerError(Box::new(cause)).into_response(),
|
||||
};
|
||||
|
||||
let subdomain = match SitesService::associate(user, &subdomain_name, &state.connection).await {
|
||||
Ok(Some(subdomain)) => subdomain,
|
||||
Ok(None) => {
|
||||
return SeroError::SubdomainIsOwnedByAnotherUser(subdomain_name).into_response()
|
||||
}
|
||||
Err(cause) => return SeroError::InternalServerError(Box::new(cause)).into_response(),
|
||||
};
|
||||
|
||||
match SitesService::upload(&subdomain, archive.contents, &state.connection).await {
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(cause) => return SeroError::InternalServerError(Box::new(cause)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn teardown(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AuthJWT(user): AuthJWT,
|
||||
SubdomainModelExtractor(subdomain): SubdomainModelExtractor,
|
||||
) -> Response {
|
||||
if subdomain.owner_id != user.id {
|
||||
return SeroError::SubdomainIsOwnedByAnotherUser(subdomain.name).into_response();
|
||||
}
|
||||
|
||||
if let Err(cause) = SitesService::teardown(subdomain, &state.connection).await {
|
||||
return SeroError::InternalServerError(Box::new(cause)).into_response();
|
||||
}
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
#[tracing::instrument()]
|
||||
pub async fn download(
|
||||
AuthJWT(user): AuthJWT,
|
||||
SubdomainModelExtractor(subdomain): SubdomainModelExtractor,
|
||||
) -> Response {
|
||||
if !subdomain.owner_id == user.id {
|
||||
return SeroError::SubdomainIsOwnedByAnotherUser(subdomain.name).into_response();
|
||||
}
|
||||
match SitesService::download(&subdomain).await {
|
||||
Some(path) => StreamBody::new(ReaderStream::new(
|
||||
tokio::fs::File::open(path).await.unwrap(),
|
||||
))
|
||||
.into_response(),
|
||||
None => SeroError::ArchiveFileWasNotFoundForSubdomain(subdomain.name).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
SubdomainModelExtractor(subdomain): SubdomainModelExtractor,
|
||||
Path(mut path): Path<String>,
|
||||
) -> Response {
|
||||
if !subdomain.enabled {
|
||||
return match SitesService::getfile(&subdomain, "503.html".to_owned(), &state.connection)
|
||||
.await
|
||||
{
|
||||
Ok(Some((is_404, file))) if !is_404 => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
StreamBody::new(ReaderStream::new(
|
||||
tokio::fs::File::open(file).await.unwrap(),
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
Err(cause) => SeroError::InternalServerError(Box::new(cause)).into_response(),
|
||||
_ => SeroError::SiteDisabled.into_response(),
|
||||
};
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
path = "index.html".to_owned();
|
||||
}
|
||||
match SitesService::getfile(&subdomain, path, &state.connection).await {
|
||||
Ok(Some((is_404, file))) => (
|
||||
match is_404 {
|
||||
true => StatusCode::NOT_FOUND,
|
||||
false => StatusCode::OK,
|
||||
},
|
||||
StreamBody::new(ReaderStream::new(
|
||||
tokio::fs::File::open(file).await.unwrap(),
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(cause) => SeroError::InternalServerError(Box::new(cause)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn disable(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AuthJWT(user): AuthJWT,
|
||||
SubdomainModelExtractor(subdomain): SubdomainModelExtractor,
|
||||
) -> Response {
|
||||
if subdomain.owner_id != user.id {
|
||||
return SeroError::SubdomainIsOwnedByAnotherUser(subdomain.name).into_response();
|
||||
}
|
||||
|
||||
if let Err(cause) = SitesService::disable(subdomain, &state.connection).await {
|
||||
return SeroError::InternalServerError(Box::new(cause)).into_response();
|
||||
}
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn enable(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AuthJWT(user): AuthJWT,
|
||||
SubdomainModelExtractor(subdomain): SubdomainModelExtractor,
|
||||
) -> Response {
|
||||
if subdomain.owner_id != user.id {
|
||||
return SeroError::SubdomainIsOwnedByAnotherUser(subdomain.name).into_response();
|
||||
}
|
||||
|
||||
if let Err(cause) = SitesService::enable(subdomain, &state.connection).await {
|
||||
return SeroError::InternalServerError(Box::new(cause)).into_response();
|
||||
}
|
||||
StatusCode::OK.into_response()
|
||||
}
|
70
src/main.rs
Normal file
70
src/main.rs
Normal file
|
@ -0,0 +1,70 @@
|
|||
use std::{fmt::Debug, net::SocketAddr};
|
||||
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use migration::{Migrator, MigratorTrait};
|
||||
use sea_orm::{ConnectOptions, Database};
|
||||
|
||||
mod apperror;
|
||||
mod config;
|
||||
mod extractors;
|
||||
mod handlers;
|
||||
mod services;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppState {
|
||||
connection: sea_orm::DatabaseConnection,
|
||||
config: config::Config,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenv::dotenv().ok();
|
||||
let config = config::Config::default();
|
||||
|
||||
let mut opt = ConnectOptions::new(&config.database_url);
|
||||
opt.sqlx_logging(true);
|
||||
let connection = Database::connect(opt).await.unwrap();
|
||||
connection.ping().await.unwrap();
|
||||
|
||||
Migrator::up(&connection, None).await.unwrap();
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let api_router = Router::new()
|
||||
.route("/health", get(|| async { StatusCode::OK }))
|
||||
.route("/login", post(handlers::auth::login))
|
||||
.route("/registration", post(handlers::auth::registration))
|
||||
.route("/upload", post(handlers::sites::upload))
|
||||
.route("/teardown", post(handlers::sites::teardown))
|
||||
.route("/download", post(handlers::sites::download))
|
||||
.route("/enable", post(handlers::sites::enable))
|
||||
.route("/disable", post(handlers::sites::disable));
|
||||
|
||||
let state = std::sync::Arc::new(AppState {
|
||||
connection,
|
||||
config: Default::default(),
|
||||
});
|
||||
|
||||
let mut app = Router::new()
|
||||
.nest("/api", api_router)
|
||||
.route("/*path", get(handlers::sites::file))
|
||||
.with_state(state.clone());
|
||||
|
||||
if config.max_body_limit_size.is_some() {
|
||||
app = app.layer(DefaultBodyLimit::max(config.max_body_limit_size.unwrap()));
|
||||
}
|
||||
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
156
src/services/archive.rs
Normal file
156
src/services/archive.rs
Normal file
|
@ -0,0 +1,156 @@
|
|||
use async_zip::base::read::seek::ZipFileReader;
|
||||
use bytes::Bytes;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArchiveFile {
|
||||
pub real_path: std::path::PathBuf,
|
||||
pub user_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl helpers::Deletable for ArchiveFile {
|
||||
#[tracing::instrument]
|
||||
fn delete(&self) -> Result<(), std::io::Error> {
|
||||
std::fs::remove_file(&self.real_path)
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchiveFile {
|
||||
pub fn new(real_path: std::path::PathBuf, user_path: std::path::PathBuf) -> Self {
|
||||
Self {
|
||||
real_path,
|
||||
user_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod helpers {
|
||||
pub trait Deletable {
|
||||
fn delete(&self) -> Result<(), std::io::Error>;
|
||||
}
|
||||
|
||||
pub struct FileDropper<T: Deletable + Clone> {
|
||||
files: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T: Deletable + Clone> Default for FileDropper<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
files: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Deletable + Clone> Drop for FileDropper<T> {
|
||||
fn drop(&mut self) {
|
||||
for file in &self.files {
|
||||
file.delete().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Deletable + Clone + std::fmt::Debug> FileDropper<T> {
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub fn add(&mut self, inner: T) {
|
||||
self.files.push(inner);
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub fn cleanup(&mut self) {
|
||||
self.files.clear();
|
||||
}
|
||||
|
||||
pub fn files(&self) -> Vec<T> {
|
||||
self.files.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ArchiveService;
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ArchiveServiceError {
|
||||
#[error("Archive contains no files!")]
|
||||
EmptyArchive,
|
||||
|
||||
#[error(transparent)]
|
||||
ZipError(#[from] async_zip::error::ZipError),
|
||||
|
||||
#[error(transparent)]
|
||||
FsError(#[from] std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
DbError(#[from] sea_orm::DbErr),
|
||||
}
|
||||
|
||||
impl ArchiveService {
|
||||
#[tracing::instrument(skip(archive_bytes))]
|
||||
pub async fn process(archive_bytes: Bytes) -> Result<Vec<ArchiveFile>, ArchiveServiceError> {
|
||||
let mut zip = ZipFileReader::with_tokio(std::io::Cursor::new(&archive_bytes)).await?;
|
||||
|
||||
let entries = zip
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.cloned()
|
||||
.enumerate()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if entries.is_empty() {
|
||||
return Err(ArchiveServiceError::EmptyArchive);
|
||||
}
|
||||
|
||||
let mut fmanager = helpers::FileDropper::default();
|
||||
|
||||
for (index, entry) in entries {
|
||||
let entry = entry.entry();
|
||||
|
||||
if entry.dir()? {
|
||||
continue;
|
||||
} // skip directories
|
||||
|
||||
let filename_of_entry = entry.filename().as_str().unwrap();
|
||||
|
||||
let path = std::path::PathBuf::from(filename_of_entry)
|
||||
.components()
|
||||
.skip(1)
|
||||
.collect::<std::path::PathBuf>();
|
||||
|
||||
if path.file_name().unwrap_or_default() == "sero.toml" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let extension = path.extension().unwrap_or_default().to_str().unwrap();
|
||||
|
||||
let extension = match extension.is_empty() {
|
||||
false => format!(".{}", extension),
|
||||
true => extension.to_string(),
|
||||
};
|
||||
|
||||
let unique_filename = format!("{}-{}", uuid::Uuid::new_v4(), uuid::Uuid::new_v4());
|
||||
|
||||
let filename_to_save: String = format!("{unique_filename}{extension}");
|
||||
|
||||
let mut out = tokio::fs::File::create(&filename_to_save).await?;
|
||||
|
||||
let mut bytes = vec![];
|
||||
let mut reader = zip.reader_with_entry(index).await.unwrap();
|
||||
|
||||
reader.read_to_end_checked(&mut bytes).await?;
|
||||
|
||||
out.write_all(&bytes).await?;
|
||||
fmanager.add(ArchiveFile::new(
|
||||
filename_to_save.into(),
|
||||
filename_of_entry.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = fmanager.files();
|
||||
|
||||
fmanager.cleanup(); // prevent deleting
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
114
src/services/auth.rs
Normal file
114
src/services/auth.rs
Normal file
|
@ -0,0 +1,114 @@
|
|||
use crate::extractors::TokenClaims;
|
||||
use crate::services::users::UsersService;
|
||||
use chrono::Duration;
|
||||
use chrono::Utc;
|
||||
use entity::prelude::*;
|
||||
use jsonwebtoken::DecodingKey;
|
||||
use jsonwebtoken::Validation;
|
||||
use sea_orm::DbErr;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::users::UserCredentials;
|
||||
pub type Jwt = String;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AuthError {}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone)]
|
||||
pub struct AuthCredentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl AuthCredentials {
|
||||
pub fn valid(&self) -> bool {
|
||||
!self.username.is_empty() && !self.password.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AuthCredentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AuthCredentials")
|
||||
.field("username", &self.username)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthCredentials> for UserCredentials {
|
||||
fn from(val: AuthCredentials) -> Self {
|
||||
UserCredentials {
|
||||
username: val.username,
|
||||
password: val.password,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct AuthService;
|
||||
|
||||
impl AuthService {
|
||||
#[tracing::instrument(skip(connection, key))]
|
||||
pub async fn login(
|
||||
credentials: AuthCredentials,
|
||||
connection: &sea_orm::DatabaseConnection,
|
||||
key: &str,
|
||||
) -> Result<Option<Jwt>, migration::DbErr> {
|
||||
match UsersService::find(credentials.clone().into(), connection).await? {
|
||||
Some(user) => {
|
||||
let claims: TokenClaims = TokenClaims {
|
||||
sub: user.id,
|
||||
exp: (Utc::now() + Duration::minutes(10)).timestamp() as u64,
|
||||
iat: Utc::now().timestamp() as u64,
|
||||
};
|
||||
|
||||
let token = Ok(Some(
|
||||
jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::default(),
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_secret(key.as_bytes()),
|
||||
)
|
||||
.unwrap(),
|
||||
));
|
||||
token
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(connection, key))]
|
||||
pub async fn jwtcheck(
|
||||
token: &Jwt,
|
||||
connection: &sea_orm::DatabaseConnection,
|
||||
key: &str,
|
||||
) -> Result<Option<User>, migration::DbErr> {
|
||||
let claims = match jsonwebtoken::decode::<TokenClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(key.as_bytes()),
|
||||
&Validation::default(),
|
||||
) {
|
||||
Ok(decoded) => {
|
||||
let claims = decoded.claims;
|
||||
if claims.exp <= jsonwebtoken::get_current_timestamp() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
claims
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
UsersService::find_by_id(claims.sub, connection).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(connection))]
|
||||
pub async fn registration(
|
||||
credentials: AuthCredentials,
|
||||
connection: &sea_orm::DatabaseConnection,
|
||||
) -> Result<Option<User>, DbErr> {
|
||||
match UsersService::find_by_username(&credentials.username, connection).await? {
|
||||
Some(_) => Ok(None),
|
||||
None => Ok(Some(
|
||||
UsersService::new_user(credentials.into(), connection).await?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
4
src/services/mod.rs
Normal file
4
src/services/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
pub mod archive;
|
||||
pub mod auth;
|
||||
pub mod sites;
|
||||
pub mod users;
|
224
src/services/sites.rs
Normal file
224
src/services/sites.rs
Normal file
|
@ -0,0 +1,224 @@
|
|||
use entity::prelude::*;
|
||||
use sea_orm::prelude::*;
|
||||
use sea_orm::Set;
|
||||
use sea_orm::{ConnectionTrait, DbErr, ModelTrait};
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use sea_orm::TransactionTrait;
|
||||
|
||||
use super::archive::ArchiveService;
|
||||
|
||||
use super::archive::ArchiveServiceError;
|
||||
|
||||
pub struct SitesService;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum SiteServiceError {
|
||||
#[error(transparent)]
|
||||
FsError(#[from] std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
DbErr(#[from] DbErr),
|
||||
|
||||
#[error(transparent)]
|
||||
ArchiveError(#[from] ArchiveServiceError),
|
||||
}
|
||||
|
||||
impl SitesService {
|
||||
#[tracing::instrument(skip(connection))]
|
||||
pub async fn teardown<T: ConnectionTrait + TransactionTrait>(
|
||||
subdomain: Subdomain,
|
||||
connection: &T,
|
||||
) -> Result<(), DbErr> {
|
||||
if let Some(archive) = &subdomain.archive_path {
|
||||
if let Err(cause) = tokio::fs::remove_file(archive).await {
|
||||
tracing::warn!(%cause, "Could not remove archive related to subdomain!")
|
||||
}
|
||||
}
|
||||
|
||||
match subdomain.find_related(FileEntity).all(connection).await {
|
||||
Ok(files) => {
|
||||
for file in files.iter() {
|
||||
if let Err(cause) = tokio::fs::remove_file(&file.real_path).await {
|
||||
tracing::warn!(%cause, "Could not remove file related to subdomain with path: {}!", file.real_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(cause) => tracing::warn!(%cause, "Could not find file related to subdomain!"),
|
||||
};
|
||||
|
||||
subdomain.delete(connection).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn download(subdomain: &Subdomain) -> Option<PathBuf> {
|
||||
if let Some(ref path) = subdomain.archive_path {
|
||||
let path_as_pathbuf = PathBuf::from(path);
|
||||
|
||||
return match tokio::fs::metadata(&path_as_pathbuf).await {
|
||||
Ok(_) => Some(path_as_pathbuf),
|
||||
Err(_) => None,
|
||||
};
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(connection, contents))]
|
||||
pub async fn upload<T: ConnectionTrait + TransactionTrait>(
|
||||
subdomain: &Subdomain,
|
||||
contents: bytes::Bytes,
|
||||
connection: &T,
|
||||
) -> Result<(), SiteServiceError> {
|
||||
if let Some(ref old_archive) = subdomain.archive_path {
|
||||
if let Err(cause) = tokio::fs::remove_file(old_archive).await {
|
||||
tracing::warn!(%cause, "Failed to remove old archive!");
|
||||
}
|
||||
}
|
||||
|
||||
let new_archive_path = format!("{}.zip", uuid::Uuid::new_v4());
|
||||
|
||||
let mut new_archive_file = match tokio::fs::File::create(&new_archive_path).await {
|
||||
Ok(file) => file,
|
||||
Err(cause) => {
|
||||
return Err(cause.into());
|
||||
}
|
||||
};
|
||||
|
||||
match new_archive_file.write_all(&contents).await {
|
||||
Ok(()) => {
|
||||
let mut active: ActiveSubdomain = subdomain.clone().into();
|
||||
active.archive_path = Set(Some(new_archive_path));
|
||||
|
||||
if let Err(cause) = active.update(connection).await {
|
||||
return Err(cause.into());
|
||||
}
|
||||
}
|
||||
Err(cause) => {
|
||||
return Err(cause.into());
|
||||
}
|
||||
}
|
||||
|
||||
FileEntity::delete_many()
|
||||
.filter(FileColumn::SubdomainId.eq(subdomain.id))
|
||||
.exec(connection)
|
||||
.await?;
|
||||
|
||||
let files = match ArchiveService::process(contents).await {
|
||||
Ok(files) => files,
|
||||
Err(cause) => {
|
||||
return Err(cause.into());
|
||||
}
|
||||
};
|
||||
|
||||
let transaction = connection.begin().await?;
|
||||
|
||||
let models = files
|
||||
.iter()
|
||||
.map(|file| ActiveFile {
|
||||
subdomain_id: Set(subdomain.id),
|
||||
user_path: Set(file.user_path.to_string_lossy().to_string()),
|
||||
real_path: Set(file.real_path.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
FileEntity::insert_many(models).exec(&transaction).await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(connection))]
|
||||
pub async fn getfile<T: ConnectionTrait + TransactionTrait>(
|
||||
subdomain: &Subdomain,
|
||||
path: String,
|
||||
connection: &T,
|
||||
) -> Result<Option<(bool, std::path::PathBuf)>, SiteServiceError> {
|
||||
let files = subdomain
|
||||
.find_related(FileEntity)
|
||||
.filter(FileColumn::UserPath.is_in([&path, "404.html"]))
|
||||
.all(connection)
|
||||
.await?;
|
||||
|
||||
let file = match files.len() {
|
||||
0 => None,
|
||||
1 => Some(&files[0]),
|
||||
2 => Some(
|
||||
files
|
||||
.iter()
|
||||
.find(|file| !file.user_path.eq("404.html"))
|
||||
.unwrap(),
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
match file {
|
||||
Some(file) => match tokio::fs::metadata(&file.real_path).await {
|
||||
Ok(_) => Ok(Some((
|
||||
file.user_path == "404.html",
|
||||
std::path::PathBuf::from(&file.real_path),
|
||||
))),
|
||||
Err(cause) => Err(cause.into()),
|
||||
},
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enable<T: ConnectionTrait + TransactionTrait>(
|
||||
subdomain: Subdomain,
|
||||
connection: &T,
|
||||
) -> Result<(), DbErr> {
|
||||
let mut active_subdomain: ActiveSubdomain = subdomain.into();
|
||||
active_subdomain.enabled = Set(true);
|
||||
active_subdomain.update(connection).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disable<T: ConnectionTrait + TransactionTrait>(
|
||||
subdomain: Subdomain,
|
||||
connection: &T,
|
||||
) -> Result<(), DbErr> {
|
||||
let mut active_subdomain: ActiveSubdomain = subdomain.into();
|
||||
active_subdomain.enabled = Set(false);
|
||||
active_subdomain.update(connection).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(connection))]
|
||||
pub async fn associate<T: ConnectionTrait + TransactionTrait>(
|
||||
user: User,
|
||||
subdomain_name: &str,
|
||||
connection: &T,
|
||||
) -> Result<Option<Subdomain>, DbErr> {
|
||||
match SubdomainEntity::find()
|
||||
.filter(SubdomainColumn::Name.eq(subdomain_name))
|
||||
.one(connection)
|
||||
.await?
|
||||
{
|
||||
Some(subdomain) => {
|
||||
if subdomain.owner_id == user.id {
|
||||
Ok(Some(subdomain))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let new_subdomain = entity::subdomain::ActiveModel {
|
||||
owner_id: Set(user.id),
|
||||
name: Set(subdomain_name.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let new_subdomain = SubdomainEntity::insert(new_subdomain.clone())
|
||||
.exec_with_returning(connection)
|
||||
.await?;
|
||||
|
||||
Ok(Some(new_subdomain))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
83
src/services/users.rs
Normal file
83
src/services/users.rs
Normal file
|
@ -0,0 +1,83 @@
|
|||
use entity::prelude::*;
|
||||
use sea_orm::prelude::*;
|
||||
use sea_orm::Set;
|
||||
use sea_orm::TransactionTrait;
|
||||
|
||||
pub struct UsersService;
|
||||
|
||||
pub struct UserCredentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UserCredentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UserCredentials")
|
||||
.field("username", &self.username)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl UserCredentials {
|
||||
#[inline]
|
||||
pub fn password_hash(&self) -> String {
|
||||
sha256::digest(&self.password)
|
||||
}
|
||||
}
|
||||
|
||||
impl UsersService {
|
||||
#[tracing::instrument(skip(connection))]
|
||||
pub async fn find<T: ConnectionTrait + TransactionTrait>(
|
||||
credentials: UserCredentials,
|
||||
connection: &T,
|
||||
) -> Result<Option<User>, DbErr> {
|
||||
UserEntity::find()
|
||||
.filter(
|
||||
UserColumn::Username
|
||||
.eq(&credentials.username)
|
||||
.and(UserColumn::Password.eq(&credentials.password_hash())),
|
||||
)
|
||||
.one(connection)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(connection))]
|
||||
pub async fn find_by_id<T: ConnectionTrait + TransactionTrait>(
|
||||
id: i32,
|
||||
connection: &T,
|
||||
) -> Result<Option<User>, DbErr> {
|
||||
UserEntity::find_by_id(id).one(connection).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(connection))]
|
||||
pub async fn find_by_username<T: ConnectionTrait + TransactionTrait>(
|
||||
username: &str,
|
||||
connection: &T,
|
||||
) -> Result<Option<User>, DbErr> {
|
||||
UserEntity::find()
|
||||
.filter(UserColumn::Username.eq(username))
|
||||
.one(connection)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(connection))]
|
||||
pub async fn new_user<T: ConnectionTrait + TransactionTrait>(
|
||||
credentials: UserCredentials,
|
||||
connection: &T,
|
||||
) -> Result<User, DbErr> {
|
||||
UserEntity::insert(ActiveUser {
|
||||
username: Set(credentials.username.clone()),
|
||||
password: Set(credentials.password_hash().clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.exec_with_returning(connection)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(connection))]
|
||||
pub async fn count<T: ConnectionTrait + TransactionTrait>(
|
||||
connection: &T,
|
||||
) -> Result<u64, DbErr> {
|
||||
UserEntity::find().count(connection).await
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue