journey starts here

This commit is contained in:
clowzed 2023-10-25 18:08:11 +03:00
commit 9d66e9301a
37 changed files with 5930 additions and 0 deletions

8
.dockerignore Normal file
View 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
View 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
View file

@ -0,0 +1 @@
/target

3718
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

43
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
pub mod prelude;
pub mod file;
pub mod subdomain;
pub mod user;

7
entity/src/mod.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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),
]
}
}

View 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,
}

View 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,
}

View 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
View file

@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
pub mod auth;
pub mod sites;

172
src/handlers/sites.rs Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
pub mod archive;
pub mod auth;
pub mod sites;
pub mod users;

224
src/services/sites.rs Normal file
View 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
View 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
}
}