Release: 0.2.0. The servr works fine. Tested

This commit is contained in:
clowzed 2024-06-24 12:50:35 +03:00
commit 3f5701f6eb
184 changed files with 7337 additions and 2843 deletions

View file

@ -6,3 +6,12 @@ readme.md
logo.svg
.github
nginx-templates
assets/
rustfmt.toml
openapi.json
logs
sero-docs
scripts
.env
.env.tests
CODE_OF_CONDUCT.md

11
.env.tests Normal file
View file

@ -0,0 +1,11 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
JWT_SECRET=mysuperstrongjwtsecret
PORT=8080
JWT_TTL_SECONDS=120
MAX_USERS=10000
SQLX_LOGGING=true
UPLOAD_FOLDER=./test_upload_files
MAX_SITES_PER_USER=100
MAX_BODY_LIMIT_SIZE=1000000
CLEAN_OBSOLETE_INTERVAL=60
RUST_LOG=none,sero=trace

View file

@ -1,31 +0,0 @@
name: docker build latest master
on:
push:
branches:
- "master"
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

107
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,107 @@
name: "Release"
permissions:
contents: "write"
on:
workflow_run:
workflows: ["Tag"]
types:
- "completed"
jobs:
get-tag:
name: "Get tag version from Cargo.toml"
runs-on: "ubuntu-latest"
outputs:
pkg-version: ${{ steps.pkg-version.outputs.PKG_VERSION }}
steps:
- name: "Check out the repo"
uses: actions/checkout@v3
with:
token: ${{ secrets.G_TOKEN }}
- name: "Get tag"
id: "pkg-version"
shell: "bash"
run: |
echo PKG_VERSION=$(awk -F ' = ' '$1 ~ /version/ { gsub(/["]/, "", $2); printf("%s",$2) }' Cargo.toml) >> $GITHUB_OUTPUT
create-release:
name: "Create release"
if: ${{ github.event.workflow_run.conclusion == 'success' }}
needs:
- "get-tag"
runs-on: "ubuntu-latest"
steps:
- name: "Check out the repo"
uses: actions/checkout@v3
- name: "Create release"
uses: "taiki-e/create-gh-release-action@v1"
with:
# (optional) Path to changelog.
# changelog: CHANGELOG.md
branch: "master"
ref: refs/tags/v${{ needs.get-tag.outputs.pkg-version }}
token: ${{ secrets.G_TOKEN }}
upload-assets:
name: "Upload assets to Github releases"
if: ${{ github.event.workflow_run.conclusion == 'success' }}
needs:
- "get-tag"
- "create-release"
strategy:
matrix:
include:
- target: "x86_64-unknown-linux-gnu"
os: "ubuntu-latest"
runs-on: ${{ matrix.os }}
steps:
- name: "Check out the repo"
uses: actions/checkout@v3
- name: "Upload Binaries"
uses: "taiki-e/upload-rust-binary-action@v1"
with:
bin: "sero"
target: ${{ matrix.target }}
archive: $bin-${{ matrix.target }}
ref: refs/tags/v${{ needs.get-tag.outputs.pkg-version }}
token: ${{ secrets.G_TOKEN }}
push-to-registry:
name: "Build and push Docker image to Docker Hub"
if: ${{ github.event.workflow_run.conclusion == 'success' }}
needs:
- "get-tag"
- "upload-assets"
- "publish-api"
runs-on: "ubuntu-latest"
steps:
- name: "Check out the repo"
uses: actions/checkout@v3
- name: "Log in to Docker Hub"
uses: "docker/login-action@v2"
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: "Extract metadata (tags, labels) for Docker"
id: "meta"
uses: "docker/metadata-action@v4"
with:
images: "bwks/shazam"
- name: "Build and push Docker image"
uses: "docker/build-push-action@v3"
with:
context: .
push: true
tags: clowzed/sero:v${{ needs.get-tag.outputs.pkg-version }}
labels: ${{ steps.meta.outputs.labels }}

30
.github/workflows/tag.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: "Tag"
on:
push:
branches:
- "master"
permissions:
contents: write
jobs:
create-tag:
name: "Create git tag"
runs-on: "ubuntu-latest"
steps:
- name: "Check out the repo"
uses: actions/checkout@v3
with:
token: ${{ secrets.G_TOKEN }}
- name: "Get tag from Cargo.toml"
id: "get-tag"
shell: "bash"
run: |
echo PKG_VERSION=$(awk -F ' = ' '$1 ~ /version/ { gsub(/["]/, "", $2); printf("%s",$2) }' Cargo.toml) >> $GITHUB_OUTPUT
- name: "Set git tag"
shell: "bash"
run: |
git tag v${{ steps.get-tag.outputs.PKG_VERSION }} && git push --tags

108
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,108 @@
name: "Test"
on:
pull_request:
push:
branches:
- "**" # matches every branch
jobs:
check:
name: "Run cargo check"
runs-on: "ubuntu-latest"
steps:
- name: "Check out the repo"
uses: actions/checkout@v3
- uses: "actions-rs/toolchain@v1"
with:
profile: "minimal"
toolchain: "stable"
override: true
- uses: "actions-rs/cargo@v1"
with:
command: "check"
tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: "Get version from Cargo.toml"
id: "get-cargo-version"
shell: "bash"
run: |
echo PKG_VERSION=$(awk -F ' = ' '$1 ~ /version/ { gsub(/["]/, "", $2); printf("%s",$2) }' Cargo.toml) >> $GITHUB_OUTPUT
- name: Get version from openapi.json
id: get-openapi-version
run: |
echo OAPI_VERSION=$(jq -r '.info.version' openapi.json) >> $GITHUB_OUTPUT
- name: Compare versions
run: |
if [ "${{ steps.get-cargo-version.outputs.PKG_VERSION }}" != "${{ steps.get-openapi-version.outputs.OAPI_VERSION }}" ]; then
echo "Version mismatch between cargo.toml and generated OpenAPI JSON."
exit 1
else
echo "Version matches between cargo.toml and generated OpenAPI JSON."
fi
- name: Run tests (with database service)
run: cargo test --verbose -- --test-threads=1
fmt:
name: "Run cargo fmt"
runs-on: "ubuntu-latest"
steps:
- name: "Check out the repo"
uses: actions/checkout@v3
- uses: "actions-rs/toolchain@v1"
with:
profile: "minimal"
toolchain: "stable"
override: true
- run: "rustup component add rustfmt"
- uses: "actions-rs/cargo@v1"
with:
command: "+nightly fmt"
args: "--all -- --check"
clippy:
name: "Run cargo clippy"
runs-on: "ubuntu-latest"
steps:
- name: "Check out the repo"
uses: actions/checkout@v3
- uses: "actions-rs/toolchain@v1"
with:
profile: "minimal"
toolchain: "stable"
override: true
- run: "rustup component add clippy"
- uses: "actions-rs/cargo@v1"
with:
command: "clippy"
args: "-- -D warnings"

View file

@ -1,27 +0,0 @@
name: docker build latest testable
on:
push:
branches-ignore:
- master
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:testable

View file

@ -1,35 +0,0 @@
name: Run tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v2
- name: Build executable
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose -- --test-threads=1
env:
DATABASE_URL: "postgres://postgres:postgres@localhost:5432/postgres"
PORT: 8080

5
.gitignore vendored
View file

@ -1,3 +1,6 @@
/target
Cargo.lock
[0-9a-zA-Z]*-[0-9a-zA-Z]*-[0-9a-zA-Z]*-[0-9a-zA-Z]*-[0-9a-zA-Z]*
sites-uploads
logs
test_upload_files
.env

View file

@ -1,6 +1,6 @@
[package]
name = "sero"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
authors = ["clowzed <clowzed.work@gmail.com>"]
description = "Muiltidomain static site hosting"
@ -9,48 +9,73 @@ 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"] }
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", "ansi", "std", "json"] }
tracing = { version = "0.1.37", features = ["async-await"] }
tracing-subscriber = { version = "0.3.17", features = [
"env-filter",
"fmt",
"ansi",
"std",
"json",
] }
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"
jsonwebtoken = "9.2.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"] }
async_zip = { version = "0.0.17", features = ["full"] }
bytes = "1.5.0"
async-trait = "0.1.73"
tokio-util = { version = "0.7.9", features = ["io"] }
dotenv = "0.15.0"
tower-http = { version = "0.4.4", features = ["cors"] }
futures = "0.3.29"
tower = { version = "0.4.13", features = ["util"] }
serde_urlencoded = "0.7.1"
serde_json = "1.0.108"
mime = "0.3.17"
axum-test-helper = "0.3.0"
hyper = "0.14.27"
reqwest = { version = "0.11.22", features = ["json", "multipart"] }
oneshot = "0.1.6"
crossfire = "1.0.1"
http-body = "0.4.5"
mime_guess = "2.0.4"
argon2 = { version = "0.5.3", features = ["std"] }
utoipa = { version = "4.2.0", features = [
"axum_extras",
"chrono",
"preserve_order",
] }
dotenvy = "0.15.7"
toml = "0.8.8"
tokio-util = { version = "0.7.10", features = ["io"] }
utoipa-rapidoc = { version = "4.0.0", features = ["axum"] }
utoipa-redoc = { version = "4.0.0", features = ["axum"] }
utoipa-swagger-ui = { version = "7.1.0", features = ["axum"] }
axum = { version = "0.7.4", features = [
"macros",
"tracing",
"json",
"multipart",
] }
axum_typed_multipart = "0.11.0"
tower-http = { git = "https://github.com/tower-rs/tower-http.git", features = [
"cors",
"trace",
"timeout",
] }
tower = { version = "0.4.13", features = ["util"] }
hyper = "0.14.28"
utoipauto = "0.1.10"
validator = { version = "0.18.0", features = ["derive"] }
axum-valid = "0.18.0"
sea-query = "0.30.7"
serde_with = "3.8.1"
tracing-appender = "0.2.3"
[workspace]
members = [".", "entity", "migration"]
[profile.release]
lto = true
strip = true
opt-level = 3
codegen-units = 1
[dev-dependencies]
axum-test = "15.2.0"

View file

@ -1,4 +1,4 @@
FROM rust:1.73 as builder
FROM rust:latest as builder
WORKDIR /usr/src/app
COPY . .
RUN cargo build --release

69
Makefile.toml Normal file
View file

@ -0,0 +1,69 @@
[tasks.start-postgres]
command = "sudo"
args = [
"docker",
"run",
"--name",
"postgres-test",
"-d",
"-e",
"POSTGRES_PASSWORD=postgres",
"-e",
"POSTGRES_USER=postgres",
"-e",
"POSTGRES_DB=postgres",
"-p",
"5432:5432",
"--health-cmd=pg_isready",
"--health-interval=10s",
"--health-timeout=5s",
"--health-retries=5",
"postgres:16",
]
[tasks.stop-postgres]
script = ['''
sudo docker stop postgres-test || echo "Postgres container not running"
''']
[tasks.remove-postgres]
script = ['''
sudo docker rm -v postgres-test || echo "Postgres container not found"
''']
[tasks.wait-for-postgres]
script = ['''
while ! sudo docker exec postgres-test pg_isready -U postgres; do
echo "Waiting for postgres to be ready..."
sleep 2
done
echo "Postgres is ready!"
''']
[tasks.run-tests]
env = { "RUST_BACKTRACE" = "0" }
command = "cargo"
args = ["test", "--", "--test-threads=1", "--nocapture", "--show-output"]
[tasks.cleanup]
script = [
'''
sudo docker stop postgres-test && sudo docker rm -v postgres-test|| echo "Postgres container not found"
''',
]
[tasks.flow]
dependencies = [
"cleanup",
"start-postgres",
"wait-for-postgres",
"run-tests",
"cleanup",
]
[config]
skip_core_tasks = true
[env]
CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true

Binary file not shown.

BIN
assets/zips/correct-1.zip Normal file

Binary file not shown.

BIN
assets/zips/correct-2.zip Normal file

Binary file not shown.

BIN
assets/zips/correct-3.zip Normal file

Binary file not shown.

BIN
assets/zips/correct-big.zip Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

0
assets/zips/empty.zip Normal file
View file

View file

@ -2,7 +2,7 @@ version: "3"
services:
database:
image: postgres:15
image: postgres:16
user: postgres
restart: always
environment:
@ -20,7 +20,7 @@ services:
proxy:
image: nginx
image: nginx:alpine3.18-slim
restart: always
environment:
- DOLLAR=$
@ -37,6 +37,8 @@ services:
- 80:80
links:
- server
profiles:
- donotstart
server:
image: clowzed/sero
@ -46,7 +48,9 @@ services:
database:
condition: service_healthy
volumes:
- server-files:/app
- server-files:/app/sites-uploads
ports:
- 8080:8080
environment:
- DATABASE_URL=postgresql://postgres:1234@database/sero
- PORT=8080
@ -54,9 +58,14 @@ services:
# Empty means no limits
- MAX_USERS=1
- MAX_SITES_PER_USER=100
- MAX_BODY_LIMIT_BYTES=10000000
- MAX_BODY_LIMIT_SIZE=10000000 # 10mb
- RUST_LOG=none,sero=trace
# end of section
- JWT_SECRET=mysuperstrongjwtscret
- JWT_TTL_SECONDS=120
- SQLX_LOGGING=true
- UPLOAD_FOLDER=./sites-uploads
volumes:
server-files:

View file

@ -6,4 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-trait = "0.1.80"
sea-orm = { version = "0.12" }
serde = { version = "1.0.196", features = ["derive"] }
utoipa = { version = "4.2.3", features = ["axum_extras", "chrono", "preserve_order"] }

View file

@ -1,16 +1,18 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "file")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub subdomain_id: i32,
pub id: i64,
pub subdomain_id: Option<i64>,
pub user_path: String,
#[sea_orm(unique)]
pub real_path: String,
pub obsolete: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@ -20,7 +22,7 @@ pub enum Relation {
from = "Column::SubdomainId",
to = "super::subdomain::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
on_delete = "SetNull"
)]
Subdomain,
}

View file

@ -1,6 +1,8 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
pub mod prelude;
pub mod cors;
pub mod file;
pub mod origin;
pub mod subdomain;
pub mod user;

View file

@ -1,8 +0,0 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
pub mod prelude;
pub mod cors;
pub mod file;
pub mod subdomain;
pub mod user;

View file

@ -1,14 +1,16 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "cors")]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize, ToSchema)]
#[sea_orm(table_name = "origin")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub subdomain_id: i32,
pub origin: String,
pub id: i64,
pub subdomain_id: i64,
pub value: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@ -30,9 +32,3 @@ impl Related<super::subdomain::Entity> for Entity {
}
impl ActiveModelBehavior for ActiveModel {}
impl Model {
pub fn matches(&self, origin: &str) -> bool {
self.origin == "*" || self.origin == origin
}
}

View file

@ -1,19 +1,19 @@
pub use super::cors::Entity as CorsEntity;
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::Entity as FileEntity, origin::Entity as OriginEntity, subdomain::Entity as SubdomainEntity,
user::Entity as UserEntity,
};
pub use super::cors::ActiveModel as ActiveCors;
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 FileModel, origin::Model as OriginModel, subdomain::Model as SubdomainModel,
user::Model as UserModel,
};
pub use super::cors::Model as Cors;
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, origin::Column as OriginColumn, subdomain::Column as SubdomainColumn,
user::Column as UserColumn,
};
pub use super::cors::Column as CorsColumn;
pub use super::file::Column as FileColumn;
pub use super::subdomain::Column as SubdomainColumn;
pub use super::user::Column as UserColumn;
pub use super::{
file::ActiveModel as FileActiveModel, origin::ActiveModel as OriginActiveModel,
subdomain::ActiveModel as SubdomainActiveModel, user::ActiveModel as UserActiveModel,
};

View file

@ -1,13 +1,14 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "subdomain")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub owner_id: i32,
pub id: i64,
pub owner_id: i64,
pub enabled: bool,
#[sea_orm(unique)]
pub name: String,
@ -17,32 +18,32 @@ pub struct Model {
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::cors::Entity")]
Cors,
#[sea_orm(has_many = "super::file::Entity")]
File,
#[sea_orm(has_many = "super::origin::Entity")]
Origin,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::OwnerId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
on_delete = "Cascade"
)]
User,
}
impl Related<super::cors::Entity> for Entity {
fn to() -> RelationDef {
Relation::Cors.def()
}
}
impl Related<super::file::Entity> for Entity {
fn to() -> RelationDef {
Relation::File.def()
}
}
impl Related<super::origin::Entity> for Entity {
fn to() -> RelationDef {
Relation::Origin.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()

View file

@ -1,14 +1,17 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3
use sea_orm::entity::prelude::*;
use std::fmt::Debug;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub id: i64,
#[sea_orm(unique)]
pub username: String,
pub login: String,
pub password: String,
}
@ -18,6 +21,16 @@ pub enum Relation {
Subdomain,
}
impl Debug for Model {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Model")
.field("id", &self.id)
.field("login", &self.login)
.field("password", &"***")
.finish()
}
}
impl Related<super::subdomain::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subdomain.def()

View file

@ -2,8 +2,8 @@ pub use sea_orm_migration::prelude::*;
mod m20230927_162921_create_users;
mod m20230929_081415_create_subdomains;
mod m20230929_152215_create_files;
mod m20231105_171000_create_cors;
mod m20230929_152215_create_file;
mod m20231105_171000_create_origin;
pub struct Migrator;
@ -13,8 +13,8 @@ impl MigratorTrait for Migrator {
vec![
Box::new(m20230927_162921_create_users::Migration),
Box::new(m20230929_081415_create_subdomains::Migration),
Box::new(m20230929_152215_create_files::Migration),
Box::new(m20231105_171000_create_cors::Migration),
Box::new(m20230929_152215_create_file::Migration),
Box::new(m20231105_171000_create_origin::Migration),
]
}
}

View file

@ -13,27 +13,33 @@ impl MigrationTrait for Migration {
.if_not_exists()
.col(
ColumnDef::new(User::Id)
.integer()
.big_integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(User::Username)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(User::Login).string().not_null().unique_key())
.col(ColumnDef::new(User::Password).string().not_null())
.to_owned(),
)
.await
.await?;
manager
.create_index(
Index::create()
.unique()
.name("user-login-idx")
.table(User::Table)
.col(User::Login)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await
manager.drop_table(Table::drop().table(User::Table).to_owned()).await
}
}
@ -41,6 +47,6 @@ impl MigrationTrait for Migration {
pub enum User {
Table,
Id,
Username,
Login,
Password,
}

View file

@ -13,34 +13,36 @@ impl MigrationTrait for Migration {
.if_not_exists()
.col(
ColumnDef::new(Subdomain::Id)
.integer()
.big_integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Subdomain::OwnerId).integer().not_null())
.col(ColumnDef::new(Subdomain::OwnerId).big_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(),
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.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
.await?;
manager
.create_index(
Index::create()
.unique()
.name("subdomain-name-idx")
.table(Subdomain::Table)
.col(Subdomain::Name)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {

View file

@ -15,34 +15,43 @@ impl MigrationTrait for Migration {
.if_not_exists()
.col(
ColumnDef::new(File::Id)
.integer()
.big_integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(File::SubdomainId).integer().not_null())
.col(ColumnDef::new(File::SubdomainId).big_integer())
.foreign_key(
ForeignKey::create()
.from(File::Table, File::SubdomainId)
.to(Subdomain::Table, Subdomain::Id)
.on_delete(ForeignKeyAction::Cascade),
.on_delete(ForeignKeyAction::SetNull),
)
.col(ColumnDef::new(File::UserPath).string().not_null())
.col(ColumnDef::new(File::RealPath).string().not_null().unique_key())
.col(
ColumnDef::new(File::RealPath)
.string()
ColumnDef::new(File::Obsolete)
.boolean()
.not_null()
.unique_key(),
.default(Expr::val(false)),
)
.to_owned(),
)
.await
.await?;
manager
.create_index(
Index::create()
.name("file-user-path-idx")
.table(File::Table)
.col(File::UserPath)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(File::Table).to_owned())
.await
manager.drop_table(Table::drop().table(File::Table).to_owned()).await
}
}
@ -53,4 +62,5 @@ enum File {
SubdomainId,
UserPath,
RealPath,
Obsolete,
}

View file

@ -1,6 +1,5 @@
use sea_orm_migration::prelude::*;
use crate::m20230929_081415_create_subdomains::Subdomain;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
@ -11,39 +10,37 @@ impl MigrationTrait for Migration {
manager
.create_table(
Table::create()
.table(Cors::Table)
.table(Origin::Table)
.if_not_exists()
.col(
ColumnDef::new(Cors::Id)
.integer()
ColumnDef::new(Origin::Id)
.big_integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Cors::SubdomainId).integer().not_null())
.col(ColumnDef::new(Origin::SubdomainId).big_integer().not_null())
.foreign_key(
ForeignKey::create()
.from(Cors::Table, Cors::SubdomainId)
.from(Origin::Table, Origin::SubdomainId)
.to(Subdomain::Table, Subdomain::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.col(ColumnDef::new(Cors::Origin).string().not_null())
.col(ColumnDef::new(Origin::Value).string().not_null())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Cors::Table).to_owned())
.await
manager.drop_table(Table::drop().table(Origin::Table).to_owned()).await
}
}
#[derive(DeriveIden)]
pub enum Cors {
pub enum Origin {
Table,
Id,
SubdomainId,
Origin,
Value,
}

View file

@ -1,21 +1,18 @@
server {
# We check if $subdomain is not empty and if the X-subdomain header is not present in the request
# ($http_x_subdomain = "").
# If both conditions are met, we set the X-subdomain header using proxy_set_header.
# Otherwise, we do nothing, and the existing X-subdomain header (if any) will be preserved.
listen 80;
server_name ~^(?<subdomain>\w*)\.${DOMAIN}.${ZONE}${DOLLAR};
location / {
if ($http_x_subdomain = "") {
set $http_x_subdomain $subdomain;
if ($subdomain != "" && $http_x_subdomain = "") {
proxy_set_header 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}/;
}
}

1219
openapi.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,437 +0,0 @@
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
"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"
/api/cors/add:
post:
summary: Add origin
parameters:
- in: header
name: X-Subdomain
schema:
type: string
required: true
security:
- bearerAuth: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: "#/components/schemas/Origin"
responses:
"200":
description: Origin was successfully added
"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"
"403":
description: Subdomain is owned by another user
content:
application/json:
schema:
$ref: "#/components/schemas/Details"
"500":
description: Server error
content:
application/json:
schema:
$ref: "#/components/schemas/Details"
/api/cors/clear:
post:
summary: Clear related origins
parameters:
- in: header
name: X-Subdomain
schema:
type: string
required: true
security:
- bearerAuth: []
responses:
"200":
description: Origins were successfully removed
"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"
"403":
description: Subdomain is owned by another user
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
Origin:
type: object
properties:
origin:
type: string
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT

218
readme.md
View file

@ -16,56 +16,51 @@
![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>
</p>
</div>
</p>
</p>
# Warning
> [!CAUTION]
>
> **_This project was in a huge rewrite and upload tool and docs are not updated!
> THis will be fixed very soon._**
## 📖 Table Of Contents
- [📖 Table Of Contents](#-table-of-contents)
- [🔧 Tools](#-tools)
- [❓ About The Project](#-about-the-project)
- [🚀 Features](#-features)
- [🔌 Built With](#-built-with)
- [⌨️ Getting Started](#-getting-started)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Clone this repository](#clone-this-repository)
- [✏️ Configure DNS records](#-configure-dns-records)
- [✏️ Configure `docker-compose.yml`](#-configure-docker-composeyml)
- [🚀 Deploy](#-deploy)
- [✨ Usage](#-usage)
- [Installing cli tool](#installing-cli-tool)
- [🆕 Creating and initializing folder](#-creating-and-initializing-folder)
- [🆕 Creating index.html](#-creating-indexhtml)
- [🔍 Inspecting `sero.toml`](#-inspecting-serotoml)
- [✏️ Changing `sero.toml`](#-changing-serotoml)
- [⬆️ Registration and uploading](#-registration-and-uploading)
- [Now your site is available at http://oursite.awesome.com/index.html](#now-your-site-is-available-at-httpoursiteawesomecomindexhtml)
- [Advanced usage with `new` features](#advanced-usage-with-new-features)
- [📍 Roadmap](#-roadmap)
- [🧑‍🤝‍🧑 Contributing](#-contributing)
- [Warning!](#warning)
- [📖 Table Of Contents](#-table-of-contents)
- [Docs](#docs)
- [🔧 Tools](#-tools)
- [❓ About The Project](#-about-the-project)
- [🚀 Features](#-features)
- [🔌 Built With](#-built-with)
- [📍 Roadmap](#-roadmap)
- [🧑‍🤝‍🧑 Contributing](#-contributing)
- [Creating A Pull Request](#creating-a-pull-request)
- [License](#license)
- [Authors](#authors)
- [License](#license)
- [Authors](#authors)
## Docs
Read [docs here]("http://sero-docs.clowzed.ru") for fast installation.
## 🔧 Tools
**Sero - [this tool is used to upload your site to sero servers](https://github.com/clowzed/seroup)**
## ❓ About The Project
This project is essentially a revamp of the well-known surge.sh platform. While surge.sh is a fantastic tool for publishing web applications, I noticed it lacked certain features that could significantly enhance its utility. Therefore, I decided to create my own version, incorporating those missing elements to provide a more comprehensive and seamless user experience.
One key feature that it is self-hosted. This gives users more flexibility and control over their projects, allowing them to truly make their work their own. My goal with this project is to create a platform that not only meets but exceeds the needs of web developers, making the process of publishing web applications as hassle-free and efficient as possible. It also has some features that are not in surge.sh
**So saying shortly this is a simple web server for static websites but with an ability to deploy it with custom subdomain without any configuration using cli tool. On upload it will automatically create subdomain for your site.**
**This is a cli tool for upload [tool](https://github.com/clowzed/seroup)**
## 🚀 Features
- Deploy in seconds without configuration
- Enable and disable site `new`
- Download site `new`
@ -74,160 +69,17 @@ One key feature that it is self-hosted. This gives users more flexibility and co
- Custom 404.html `(on 404 status user will see your 404.html)`
- Custom 503.html `new` `(on disabled site)`
- Clean urls
- CORS
- Dynamic CORS Management
- `[WIP]` Server events with websocket
## 🔌 Built With
- `Rust`
- `Sea-orm` - [`sero is added to community examples`](https://github.com/SeaQL/sea-orm/blob/06c632712f3d167df0cda742dd228661b953ab7f/COMMUNITY.md?plain=1#L28)
- `Axum` - [`sero is added to community examples`](https://github.com/tokio-rs/axum/blob/d7258bf009194cf2f242694e673759d1dbf8cfc0/ECOSYSTEM.md?plain=1#L78)
- `Postgres`
- `Nginx`
## ⌨️ Getting Started
### Prerequisites
That is what you will need to deploy this project
* You need to buy a domain
* You need a server
* You need access to DNS records
* You need to have `docker-compose` installed
### Installation
#### Clone this repository
```bash
git clone https://github.com/clowzed/sero
```
#### ✏️ Configure DNS records
- Add wildcard subdomain to your server.
It is usually done by adding `TXT` DNS record with value `"*"` pointing to your server
#### ✏️ Configure `docker-compose.yml`
Simply open a [docker-compose.yml](https://github.com/clowzed/sero/blob/master/docker-compose.yml) file from this repo with any redactor you link
1. Configure your domain and zone (for example I bought example.com) (lines 30 and 31)
| environment variable | description |
|----------------------|-------------|
| DOMAIN | example |
| ZONE | com |
2. Configure desired limits if you want (you can skip this)
| environment variable | description | already set |
|----------------------|----------------------------------------------------|-----------------|
| 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) |
#### 🚀 Deploy
```bash
docker-compose up -d
```
## ✨ Usage
Let see an example of uploading your site to your sero server.
Consider our domain is awesome.com
### Installing cli tool
```bash
npm i -g @clowzed/sero
```
### 🆕 Creating and initializing folder
```bash
mkdir our-website
cd our-website
sero init # This will generate default sero.toml file
```
### 🆕 Creating index.html
```bash
echo "Hello from our website!" > index.html
```
### 🔍 Inspecting `sero.toml`
Tha is how default sero.toml file looks like
```toml
[credentials]
username = "clowzed" # Here will be your hostname
password = ""
[server]
url = "http://sero.com/"
subdomain = "clowzed"
[cors]
origins = []
```
### ✏️ Changing `sero.toml`
So now we need to change url to point to our server.
We also want to change subdomain for our website
```toml
[credentials]
username = "clowzed"
password = ""
[server]
url = "http://awesome.com/"
subdomain = "oursite"
[cors]
origins = ["*"] # list of origins you trust
```
### ⬆️ Registration and uploading
```bash
sero register # We need to call it this only if we've changed username
sero upload
```
#### Now your site is available at [http://oursite.awesome.com/index.html]()
### Advanced usage with `new` features
1) Disabling site `new`
You can disable your site using this command.
```bash
sero disable
```
This will preserve your subdomain so other users will not be able to borrow it.
This will return `503 status code` for any request to site with your subdomain.
You can create `503.html` file so it will be returned to user. You can do it for maintenance. The `503.html` file should be at root of your folder
2) You can enable site with this command. Now it will work as usual. `new`
```bash
sero enable
```
3) Download your site `new`
You can easily download your site as zip with this command
```bash
sero download
```
4) Delete your site and free subdomain
```bash
sero teardown
```
## 📍 Roadmap
See the [open issues](https://github.com/clowzed/sero/issues) for a list of proposed features (and known issues).
@ -235,10 +87,11 @@ See the [open issues](https://github.com/clowzed/sero/issues) for a list of prop
## 🧑‍🤝‍🧑 Contributing
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**.
* If you have suggestions for adding or removing projects, feel free to [open an issue](https://github.com/clowzed/sero/issues/new) to discuss it, or directly create a pull request after you edit the *README.md* file with necessary changes.
* Please make sure you check your spelling and grammar.
* Create individual PR for each suggestion.
* Please also read through the [Code Of Conduct](https://github.com/clowzed/sero/blob/main/CODE_OF_CONDUCT.md) before posting your first idea as well.
- If you have suggestions for adding or removing projects, feel free to [open an issue](https://github.com/clowzed/sero/issues/new) to discuss it, or directly create a pull request after you edit the _README.md_ file with necessary changes.
- Please make sure you check your spelling and grammar.
- Create individual PR for each suggestion.
- Please also read through the [Code Of Conduct](https://github.com/clowzed/sero/blob/main/CODE_OF_CONDUCT.md) before posting your first idea as well.
### Creating A Pull Request
@ -254,5 +107,4 @@ Contributions are what make the open source community such an amazing place to b
## Authors
* **clowzed**
- **clowzed**

2
rustfmt.toml Normal file
View file

@ -0,0 +1,2 @@
imports_granularity = "Crate"
max_width = 120

View file

@ -0,0 +1,5 @@
sudo docker stop postgres-migrations
sudo docker run -d --rm -P --network host -e POSTGRES_PASSWORD="1234" -e POSTGRES_USER="me" -e POSTGRES_DB="test" --name postgres-migrations postgres
sea-orm-cli migrate up --database-url postgresql://me:1234@127.0.0.1:5432/test
sea-orm-cli generate entity -o entity/src --database-url postgresql://me:1234@127.0.0.1:5432/test --lib --with-serde both
sudo docker stop postgres-migrations

View file

@ -0,0 +1,33 @@
use crate::{services::auth::error::ServiceError as AuthServiceError, Details};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use validator::ValidationErrors;
#[derive(thiserror::Error, Debug)]
pub enum LoginError {
#[error(transparent)]
AuthServiceError(#[from] AuthServiceError),
#[error("Login or password does not match validation rules!")]
ValidationError(#[from] ValidationErrors),
}
impl From<LoginError> for StatusCode {
fn from(value: LoginError) -> Self {
match value {
LoginError::AuthServiceError(error) => Self::from(error),
LoginError::ValidationError(_) => Self::BAD_REQUEST,
}
}
}
impl IntoResponse for LoginError {
fn into_response(self) -> Response {
let reason = self.to_string();
let status_code: StatusCode = self.into();
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -0,0 +1,57 @@
use super::{error::LoginError, request::LoginRequest, response::LoginResponse};
use crate::{
auth::parameters::{JwtGenerationParameters, UserCredentials},
services::auth::service::Service as AuthService,
state::State as AppState,
};
use axum::{extract::State, Json};
use std::sync::Arc;
use validator::Validate;
/// Login user and receive JWT token.
///
/// This endpoint allows users to login to sero server. The TTL for token is set by
/// the owner of the server by `JWT_TTL` env.
#[utoipa::path(
post,
tag = "Account management",
operation_id = "Login",
path = "/api/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "User was successfully authenticated.", body = LoginResponse),
(status = 400, description = "Bad request or bad credentials. See details.", body = Details),
(status = 404, description = "Login was not found.", body = Details),
(status = 500, description = "Some error occurred on the server.", body = Details),
),
)]
#[tracing::instrument(skip(state))]
pub async fn implementation(
State(state): State<Arc<AppState>>,
Json(credentials): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, LoginError> {
//? I am validating here for
//? custom error response
//? Safe because of custom Debug impl
tracing::trace!(?credentials, "Validating provided credentials...");
credentials.validate()?;
tracing::trace!(?credentials, "Credentials are valid!");
let credentials = UserCredentials {
login: credentials.login,
password: credentials.password,
};
let user = AuthService::login(credentials, state.connection()).await?;
tracing::trace!(?user, "User was successfully found!");
let parameters = JwtGenerationParameters {
secret: state.configuration().jwt_secret(),
ttl: state.configuration().jwt_ttl_seconds(),
};
let token = AuthService::generate_jwt(user.id, parameters)?;
tracing::trace!("Token was successfully generated!");
Ok(Json(LoginResponse { token }))
}

View file

@ -0,0 +1,6 @@
pub mod error;
pub mod handler;
pub mod request;
pub mod response;
#[cfg(test)]
pub mod tests;

View file

@ -0,0 +1,84 @@
use std::fmt::{self, Debug};
use serde::{Deserialize, Serialize};
use utoipa::{schema, ToSchema};
use validator::{Validate, ValidationError};
#[derive(Deserialize, Serialize, Validate, ToSchema)]
pub struct LoginRequest {
/// The username used for authentication.
/// It must adhere to the following criteria:
/// - It can contain letters (a-z), numbers (0-9), and periods (.).
/// - It cannot contain any of the following characters: & = ' - + , < >
/// - It cannot have multiple periods (.) consecutively.
/// - Minimum length of 5 characters.
/// - Maximum length of 40 characters.
#[validate(
length(min = 5, max = 40),
custom(
function = validate_login,
message = "Login can contain letters (a-z), numbers (0-9), and periods (.),
and cannot contain any of the following characters: & = ' + , < > or multiple periods (.)"
))]
#[schema(min_length = 5, max_length = 40)]
pub login: String,
/// The password used for authentication.
/// It must meet the following requirements:
/// - Minimum length of 12 characters.
/// - Maximum length of 40 characters.
/// - A combination of letters, numbers, and symbols.
#[validate(
length(min = 12, max = 40),
custom(function = validate_password,
message = "Minimum length of 12 characters and maximum length of 40 characters and a combination of
letters, numbers, and symbols.")
)]
#[schema(min_length = 12, max_length = 40)]
pub password: String,
}
fn validate_login(login: &str) -> Result<(), ValidationError> {
let invalid_chars = "&='+,<>";
let invalid_double_period = "..";
if login.chars().any(|c| invalid_chars.contains(c)) || login.contains(invalid_double_period) {
return Err(ValidationError::new(
"Rules for login: Login can contain letters (a-z), numbers (0-9), and periods (.),
and cannot contain any of the following characters: & = ' + , < > or multiple periods (.)",
));
}
Ok(())
}
fn validate_password(password: &str) -> Result<(), ValidationError> {
let mut has_digit = false;
let mut has_special_char = false;
for c in password.chars() {
if !has_digit && c.is_ascii_digit() {
has_digit = true;
} else if c.is_ascii_punctuation() || c.is_ascii_whitespace() {
has_special_char = true;
}
if has_digit && has_special_char {
return Ok(());
}
}
Err(ValidationError::new(
"Rules for password: Minimum length of 12 characters and maximum length of 40 characters and a combination of
letters, numbers, and symbols.",
))
}
impl Debug for LoginRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoginRequest")
.field("login", &self.login)
.field("password", &"***")
.finish()
}
}

View file

@ -0,0 +1,10 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// The JWT token generated for authentication purposes.
#[derive(Deserialize, Serialize, ToSchema, Debug)]
#[schema(example = json!({"token": "ferwfwerfwer.fwerfwerfwerfwer.fwerfewfr"}))]
pub struct LoginResponse {
/// Token in JWT format
pub token: String,
}

View file

@ -0,0 +1,27 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::auth::login::{request::LoginRequest, tests::call::test::login},
app,
};
use axum::http::StatusCode;
use axum_test::TestServer as TestClient;
use uuid::Uuid;
#[tokio::test]
async fn bad_credentials() {
dotenvy::from_filename_override(".env.tests").ok();
let (app, _) = app().await.expect("Failed to initialize application!");
let client = TestClient::new(app).expect("Failed to run server for testing");
let invalid_credentials = LoginRequest {
login: Uuid::new_v4().into(),
password: "".into(),
};
let invalid_user_login_response = login(&client, &invalid_credentials).await;
assert!(invalid_user_login_response.is_err_and(|error| error.0 == StatusCode::BAD_REQUEST));
}
}

View file

@ -0,0 +1,24 @@
#[cfg(test)]
pub mod test {
use crate::{
api::{
auth::login::{request::LoginRequest, response::LoginResponse},
tests::post,
},
Details,
};
use axum::http::StatusCode;
use axum_test::TestServer as TestClient;
pub async fn login(
client: &TestClient,
credentials: &LoginRequest,
) -> Result<LoginResponse, (StatusCode, Details)> {
let response = post(client, "/api/auth/login", Some(credentials)).await;
match response.status_code().is_success() {
true => Ok(response.json::<LoginResponse>()),
false => Err((response.status_code(), response.json())),
}
}
}

View file

@ -0,0 +1,42 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::auth::{
login::{request::LoginRequest, tests::call::test::login},
registration::{request::RegistrationRequest, tests::call::tests::registration},
},
app,
};
use axum_test::TestServer as TestClient;
use uuid::Uuid;
#[tokio::test]
async fn correct() {
dotenvy::from_filename_override(".env.tests").ok();
let (app, _) = app().await.expect("Failed to initialize application!");
let client = TestClient::new(app).expect("Failed to run server for testing");
let first_user_login = Uuid::new_v4();
let first_user_password = Uuid::new_v4();
//* Registration
let first_user_registration_request = RegistrationRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_registration_response = registration(&client, &first_user_registration_request).await;
assert!(first_user_registration_response.is_ok());
//* Login
let first_user_login_request: LoginRequest = LoginRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_login_response = login(&client, &first_user_login_request).await;
assert!(first_user_login_response.is_ok());
assert!(!first_user_login_response.unwrap().token.is_empty());
}
}

View file

@ -0,0 +1,4 @@
pub mod bad_credentials;
pub mod call;
pub mod correct;
pub mod not_found;

View file

@ -0,0 +1,27 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::auth::login::{request::LoginRequest, tests::call::test::login},
app,
};
use axum::http::StatusCode;
use axum_test::TestServer as TestClient;
use uuid::Uuid;
#[tokio::test]
async fn not_found() {
dotenvy::from_filename_override(".env.tests").ok();
let (app, _) = app().await.expect("Failed to initialize application!");
let client = TestClient::new(app).expect("Failed to run server for testing");
let first_user_login_request: LoginRequest = LoginRequest {
login: Uuid::new_v4().into(),
password: Uuid::new_v4().into(),
};
let first_user_login_response = login(&client, &first_user_login_request).await;
assert!(first_user_login_response.is_err_and(|error| error.0 == StatusCode::NOT_FOUND));
}
}

12
src/api/auth/mod.rs Normal file
View file

@ -0,0 +1,12 @@
use crate::state::State as AppState;
use axum::{routing::post, Router};
use std::sync::Arc;
pub mod login;
pub mod registration;
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/login", post(login::handler::implementation))
.route("/registration", post(registration::handler::implementation))
}

View file

@ -0,0 +1,37 @@
use crate::{services::auth::error::ServiceError as AuthServiceError, Details};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use sea_orm::DbErr;
use validator::ValidationErrors;
#[derive(thiserror::Error, Debug)]
pub enum RegistrationError {
#[error(transparent)]
AuthServiceError(#[from] AuthServiceError),
#[error(transparent)]
DatabaseError(#[from] DbErr),
#[error("Login or password does not match validation rules!")]
ValidationError(#[from] ValidationErrors),
}
impl From<RegistrationError> for StatusCode {
fn from(value: RegistrationError) -> Self {
match value {
RegistrationError::AuthServiceError(error) => Self::from(error),
RegistrationError::DatabaseError(_error) => StatusCode::INTERNAL_SERVER_ERROR,
RegistrationError::ValidationError(_) => Self::BAD_REQUEST,
}
}
}
impl IntoResponse for RegistrationError {
fn into_response(self) -> Response {
let reason = self.to_string();
let status_code: StatusCode = self.into();
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -0,0 +1,60 @@
use super::{error::RegistrationError, request::RegistrationRequest, response::RegistrationResponse};
use crate::{
auth::parameters::UserCredentials, extractors::*, services::auth::service::Service as AuthService,
state::State as AppState,
};
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use sea_orm::TransactionTrait;
use std::sync::Arc;
use validator::Validate;
/// Register new user for sero server.
///
/// This endpoint creates new user for sero server. The amount of users is checked
/// by [RegistrationGuard]. The amount of allowed users is determined by `MAX_USERS` env.
#[utoipa::path(
post,
tag = "Account management",
operation_id = "Registration",
path = "/api/auth/registration",
request_body = RegistrationRequest,
responses(
(status = 200, description = "User was successfully registered.", body = RegistrationResponse),
(status = 400, description = "Bad request or bad credentials. See details.", body = Details),
(status = 409, description = "Login has already been registered.", body = Details),
(status = 500, description = "Some error occurred on the server.", body = Details),
),
)]
#[tracing::instrument(skip(state))]
pub async fn implementation(
_: RegistrationGuard,
State(state): State<Arc<AppState>>,
Json(credentials): Json<RegistrationRequest>,
) -> Result<impl IntoResponse, RegistrationError> {
//? I am validating here for
//? custom error response
//? Safe because of custom Debug impl
tracing::trace!(?credentials, "Validating provided credentials...");
credentials.validate()?;
tracing::trace!(?credentials, "Credentials are valid!");
let transaction = state.connection().begin().await?;
let credentials = UserCredentials {
login: credentials.login,
password: credentials.password,
};
let user_id = AuthService::registration(credentials, &transaction).await?.id;
tracing::trace!(%user_id, "User was created! Committing changes...");
transaction.commit().await?;
tracing::trace!("Successfully committed!");
Ok((
StatusCode::CREATED,
// Leave this one in case of api additions
// to match REST
//[(header::LOCATION, format!("/api/user/{user_id}"))],
Json(RegistrationResponse { id: user_id }),
))
}

View file

@ -0,0 +1,6 @@
pub mod error;
pub mod handler;
pub mod request;
pub mod response;
#[cfg(test)]
pub mod tests;

View file

@ -0,0 +1,84 @@
use std::fmt::{self, Debug};
use serde::{Deserialize, Serialize};
use utoipa::{schema, ToSchema};
use validator::{Validate, ValidationError};
#[derive(Deserialize, Serialize, Validate, ToSchema)]
pub struct RegistrationRequest {
/// The username used for authentication.
/// It must adhere to the following criteria:
/// - It can contain letters (a-z), numbers (0-9), and periods (.).
/// - It cannot contain any of the following characters: & = ' - + , < >
/// - It cannot have multiple periods (.) consecutively.
/// - Minimum length of 5 characters.
/// - Maximum length of 40 characters.
#[validate(
length(min = 5, max = 40),
custom(
function = validate_login,
message = "Login can contain letters (a-z), numbers (0-9), and periods (.),
and cannot contain any of the following characters: & = ' + , < > or multiple periods (.)"
))]
#[schema(min_length = 5, max_length = 40)]
pub login: String,
/// The password used for authentication.
/// It must meet the following requirements:
/// - Minimum length of 12 characters.
/// - Maximum length of 40 characters.
/// - A combination of letters, numbers, and symbols.
#[validate(
length(min = 12, max = 40),
custom(function = validate_password,
message = "Minimum length of 12 characters and maximum length of 40 characters and a combination of
letters, numbers, and symbols.")
)]
#[schema(min_length = 12, max_length = 40)]
pub password: String,
}
fn validate_login(login: &str) -> Result<(), ValidationError> {
let invalid_chars = "&='+,<>";
let invalid_double_period = "..";
if login.chars().any(|c| invalid_chars.contains(c)) || login.contains(invalid_double_period) {
return Err(ValidationError::new(
"Rules for login: Login can contain letters (a-z), numbers (0-9), and periods (.),
and cannot contain any of the following characters: & = ' + , < > or multiple periods (.)",
));
}
Ok(())
}
fn validate_password(password: &str) -> Result<(), ValidationError> {
let mut has_digit = false;
let mut has_special_char = false;
for c in password.chars() {
if !has_digit && c.is_ascii_digit() {
has_digit = true;
} else if c.is_ascii_punctuation() || c.is_ascii_whitespace() {
has_special_char = true;
}
if has_digit && has_special_char {
return Ok(());
}
}
Err(ValidationError::new(
"Rules for password: Minimum length of 12 characters and maximum length of 40 characters and a combination of
letters, numbers, and symbols.",
))
}
impl Debug for RegistrationRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegistrationRequest")
.field("login", &self.login)
.field("password", &"***")
.finish()
}
}

View file

@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema, Debug)]
#[schema(example = json!({"id" : 1293983717}))]
pub struct RegistrationResponse {
/// Auto generated id of a registered user
pub id: i64,
}

View file

@ -0,0 +1,39 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::auth::registration::{request::RegistrationRequest, tests::call::tests::registration},
app,
};
use axum::http::StatusCode;
use axum_test::TestServer as TestClient;
use entity::prelude::*;
use sea_orm::EntityTrait;
use uuid::Uuid;
#[tokio::test]
async fn check_invalid_credentials_registration() {
dotenvy::from_filename_override(".env.tests").ok();
//? Set maximum users allowed for registration
std::env::set_var("MAX_USERS", "1");
let (app, state) = app().await.expect("Failed to initialize application!");
//? Perform drop for users
UserEntity::delete_many()
.exec(state.connection())
.await
.expect("Failed to delete all users");
let client = TestClient::new(app).expect("Failed to run server for testing");
let invalid_credentials_registration_request = RegistrationRequest {
login: Uuid::new_v4().into(),
password: "".into(),
};
let invalid_credentials_registration_response =
registration(&client, &invalid_credentials_registration_request).await;
assert!(invalid_credentials_registration_response.is_err_and(|error| error.0 == StatusCode::BAD_REQUEST));
}
}

View file

@ -0,0 +1,23 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::{
auth::registration::{request::RegistrationRequest, response::RegistrationResponse},
tests::post,
},
Details,
};
use axum::http::StatusCode;
use axum_test::TestServer as TestClient;
pub async fn registration(
client: &TestClient,
credentials: &RegistrationRequest,
) -> Result<RegistrationResponse, (StatusCode, Details)> {
let response = post(client, "/api/auth/registration", Some(credentials)).await;
match response.status_code().is_success() {
true => Ok(response.json::<RegistrationResponse>()),
false => Err((response.status_code(), response.json())),
}
}
}

View file

@ -0,0 +1,37 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::auth::registration::{request::RegistrationRequest, tests::call::tests::registration},
app,
};
use axum_test::TestServer as TestClient;
use entity::prelude::*;
use sea_orm::EntityTrait;
use uuid::Uuid;
#[tokio::test]
async fn correct_registration() {
dotenvy::from_filename_override(".env.tests").ok();
//? Set maximum users allowed for registration
std::env::set_var("MAX_USERS", "1");
let (app, state) = app().await.expect("Failed to initialize application!");
//? Perform drop for users
UserEntity::delete_many()
.exec(state.connection())
.await
.expect("Failed to delete all users");
let client = TestClient::new(app).expect("Failed to run server for testing");
let first_user_registration_request = RegistrationRequest {
login: Uuid::new_v4().into(),
password: Uuid::new_v4().into(),
};
let first_user_registration_response = registration(&client, &first_user_registration_request).await;
assert!(first_user_registration_response.is_ok());
}
}

View file

@ -0,0 +1,37 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::auth::registration::{request::RegistrationRequest, tests::call::tests::registration},
app,
};
use axum::http::StatusCode;
use axum_test::TestServer as TestClient;
use entity::prelude::*;
use sea_orm::EntityTrait;
use uuid::Uuid;
#[tokio::test]
async fn guard() {
dotenvy::from_filename_override(".env.tests").ok();
std::env::set_var("MAX_USERS", "0");
let (app, state) = app().await.expect("Failed to initialize application!");
//? Perform drop for users
UserEntity::delete_many()
.exec(state.connection())
.await
.expect("Failed to delete all users");
let client = TestClient::new(app).expect("Failed to run server for testing");
let first_user_registration_request = RegistrationRequest {
login: Uuid::new_v4().into(),
password: Uuid::new_v4().into(),
};
let first_user_registration_response = registration(&client, &first_user_registration_request).await;
assert!(first_user_registration_response.is_err_and(|error| error.0 == StatusCode::FORBIDDEN));
}
}

View file

@ -0,0 +1,5 @@
pub mod bad_credentials;
pub mod call;
pub mod correct;
pub mod guard;
pub mod occupied;

View file

@ -0,0 +1,41 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::auth::registration::{request::RegistrationRequest, tests::call::tests::registration},
app,
};
use axum::http::StatusCode;
use axum_test::TestServer as TestClient;
use entity::prelude::*;
use sea_orm::EntityTrait;
use uuid::Uuid;
#[tokio::test]
async fn occupied() {
dotenvy::from_filename_override(".env.tests").ok();
//? Set maximum users allowed for registration
std::env::set_var("MAX_USERS", "2");
let (app, state) = app().await.expect("Failed to initialize application!");
//? Perform drop for users
UserEntity::delete_many()
.exec(state.connection())
.await
.expect("Failed to delete all users");
let client = TestClient::new(app).expect("Failed to run server for testing");
let first_user_registration_request = RegistrationRequest {
login: Uuid::new_v4().into(),
password: Uuid::new_v4().into(),
};
let first_user_registration_response = registration(&client, &first_user_registration_request).await;
assert!(first_user_registration_response.is_ok());
let first_user_second_attempt_registration_response =
registration(&client, &first_user_registration_request).await;
assert!(first_user_second_attempt_registration_response.is_err_and(|error| error.0 == StatusCode::CONFLICT));
}
}

48
src/api/mod.rs Normal file
View file

@ -0,0 +1,48 @@
use crate::state::State as AppState;
use axum::{http::StatusCode, routing::get, Router};
use std::sync::Arc;
pub mod auth;
pub mod origin;
pub mod site;
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.nest("/auth", auth::router())
.nest("/origin", origin::router())
.nest("/site", site::router())
.route("/health", get(|| async { StatusCode::OK }))
}
#[cfg(test)]
pub mod tests {
use axum_test::{TestRequest, TestServer as TestClient};
use serde::Serialize;
macro_rules! impl_call {
($method:ident) => {
pub fn $method<T, U>(client: &TestClient, url: U, data: Option<T>) -> TestRequest
where
T: Serialize,
U: AsRef<str>,
{
let mut builder = client.$method(url.as_ref());
if let Some(data) = data {
builder = builder.json(&data);
}
builder
}
};
}
impl_call!(post);
impl_call!(patch);
impl_call!(delete);
pub fn get<U>(client: &TestClient, url: U) -> TestRequest
where
U: AsRef<str>,
{
client.get(url.as_ref())
}
}

View file

@ -0,0 +1,29 @@
use crate::{services::origin::error::ServiceError as OriginServiceError, Details};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
#[derive(thiserror::Error, Debug)]
pub enum AddOriginError {
#[error(transparent)]
OriginServiceError(#[from] OriginServiceError),
}
impl From<AddOriginError> for StatusCode {
fn from(value: AddOriginError) -> Self {
match value {
AddOriginError::OriginServiceError(error) => Self::from(error),
}
}
}
impl IntoResponse for AddOriginError {
fn into_response(self) -> Response {
let reason = self.to_string();
let status_code: StatusCode = self.into();
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -0,0 +1,68 @@
use super::{error::AddOriginError, request::AddOriginRequest, response::AddOriginResponse};
use crate::{extractors::*, services::origin::service::Service as CorsService, state::State as AppState};
use axum::{
extract::State,
http::{header, StatusCode},
response::IntoResponse,
Json,
};
use std::sync::Arc;
/// Adds a new origin to a specified subdomain for dynamic CORS (Cross-Origin Resource Sharing) management.
///
/// This endpoint allows users to add origins that are permitted to access resources
/// on their specified subdomains. The action is authenticated using a JWT, and the subdomain must
/// be owned by the user making the request. This will be checked by the server.
#[utoipa::path(
post,
tag = "Origins Management and Dynamic Access Control",
operation_id = "Create origin",
path = "/api/origin",
request_body = AddOriginRequest,
params(
("x-subdomain" = String,
Header,
description = "'x-subdomain' header represents the name of the subdomain on which the action is to be performed."),
),
responses(
(status = 201, description = "The origin was successfully added.", body = AddOriginResponse),
(status = 400, description = "The 'x-subdomain' header is missing or contains invalid characters.", body = Details),
(status = 401, description = "Unauthorized: The JWT in the header is invalid or expired.", body = Details),
(status = 403, description = "Forbidden: The subdomain is owned by another user.", body = Details),
(status = 404, description = "Not Found: The login or subdomain was not found. See details for more information.", body = Details),
(status = 500, description = "Internal Server Error: An error occurred on the server.", body = Details),
),
security(("Bearer-JWT" = []))
)]
#[tracing::instrument(skip(state))]
pub async fn implementation(
State(state): State<Arc<AppState>>,
SubdomainOwned { user, subdomain }: SubdomainOwned,
Json(payload): Json<AddOriginRequest>,
) -> Result<impl IntoResponse, AddOriginError> {
tracing::trace!(
%payload.origin,
%subdomain.name,
%subdomain.id,
%user.id,
"Adding origin for subdomain...",
);
let added_origin = CorsService::add_origin_for(subdomain.id, payload.origin, state.connection()).await?;
tracing::trace!(
?added_origin,
%subdomain.name,
%subdomain.id,
%user.id,
"Origin was successfully added to subdomain!",
);
let id = added_origin.id;
let origin = added_origin.value;
Ok((
StatusCode::CREATED,
[(header::LOCATION, format!("/api/origin/{id}"))],
Json(AddOriginResponse { id, origin }),
))
}

View file

@ -0,0 +1,6 @@
pub mod error;
pub mod handler;
pub mod request;
pub mod response;
#[cfg(test)]
pub mod tests;

View file

@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, Serialize, ToSchema)]
#[schema(example = json!({"origin": "https://example.com/"}))]
pub struct AddOriginRequest {
/// Origin to be added
pub origin: String,
}

View file

@ -0,0 +1,13 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, Serialize, ToSchema, PartialEq)]
#[schema(example = json!({"id" : "42", "origin": "https://example.com/"}))]
pub struct AddOriginResponse {
/// Automatically generated id for new origin
/// This can be used for further management
pub id: i64,
/// This duplicates origin from response payload
/// to match REST specification
pub origin: String,
}

View file

@ -0,0 +1,40 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::{
origin::create::{request::AddOriginRequest, response::AddOriginResponse},
tests::post,
},
Details,
};
use axum::http::{HeaderName, HeaderValue, StatusCode};
use axum_test::TestServer as TestClient;
use std::fmt::Display;
pub async fn create<T, S, O>(
client: &TestClient,
token: T,
subdomain: S,
origin: O,
) -> Result<AddOriginResponse, (StatusCode, Details)>
where
T: AsRef<str> + Display,
S: AsRef<str> + Display,
O: Into<String>,
{
let origin = AddOriginRequest { origin: origin.into() };
let response = post(client, "/api/origin", Some(origin))
.add_header(
HeaderName::from_static("x-subdomain"),
HeaderValue::from_str(subdomain.as_ref()).expect("Failed to convert subdomain name to header value!"),
)
.authorization_bearer(token)
.await;
match response.status_code().is_success() {
true => Ok(response.json()),
false => Err((response.status_code(), response.json())),
}
}
}

View file

@ -0,0 +1,85 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::{
auth::{
login::{request::LoginRequest, tests::call::test::login},
registration::{request::RegistrationRequest, tests::call::tests::registration},
},
origin::{create::tests::call::tests::create, tests::preflight},
site::{page::tests::call::tests::page, upload::tests::call::tests::upload},
},
app,
};
use axum_test::TestServer as TestClient;
use uuid::Uuid;
#[tokio::test]
async fn correct() {
dotenvy::from_filename_override(".env.tests").ok();
let (app, _state) = app().await.expect("Failed to initialize application!");
let app_cloned = app.clone();
let client = TestClient::new(app_cloned).expect("Failed to run server for testing");
let first_user_login = Uuid::new_v4();
let first_user_password = Uuid::new_v4();
//* Registration
let first_user_registration_request = RegistrationRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_registration_response = registration(&client, &first_user_registration_request).await;
assert!(first_user_registration_response.is_ok());
//* Login
let first_user_login_request = LoginRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_login_response = login(&client, &first_user_login_request).await;
assert!(first_user_login_response.is_ok());
let first_user_token = first_user_login_response.expect("to never fail").token;
let first_random_subdomain = Uuid::new_v4().to_string();
let first_correct_zip_path = "./assets/zips/correct-1.zip";
//* Correct upload
let first_correct_upload_response = upload(
&client,
&first_user_token,
&first_random_subdomain,
first_correct_zip_path,
)
.await;
assert_eq!(first_correct_upload_response, Ok(()));
//* Test that page is correct
let first_user_correct_zip_index_response = page(&client, "/some/index.html", &first_random_subdomain).await;
assert!(first_user_correct_zip_index_response.status_code().is_success());
//* Add some origin
let first_subdomain_add_origin_response =
create(&client, &first_user_token, &first_random_subdomain, "some").await;
assert!(first_subdomain_add_origin_response.is_ok());
//* Check origin was added
let preflight_response = preflight(&client, &first_random_subdomain, "some").await;
let preflight_response_allowed_origin = preflight_response
.headers()
.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
assert!(preflight_response_allowed_origin.is_some());
//* Check origin which was not allowed
let preflight_response = preflight(&client, &first_random_subdomain, "another").await;
let preflight_response_allowed_origin = preflight_response
.headers()
.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
assert!(preflight_response_allowed_origin.is_none());
}
}

View file

@ -0,0 +1,2 @@
pub mod call;
pub mod correct;

View file

@ -0,0 +1,33 @@
use crate::{services::origin::error::ServiceError as OriginServiceError, Details};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use sea_orm::DbErr;
#[derive(thiserror::Error, Debug)]
pub enum DeleteOriginError {
#[error(transparent)]
OriginServiceError(#[from] OriginServiceError),
#[error(transparent)]
DatabaseError(#[from] DbErr),
}
impl From<DeleteOriginError> for StatusCode {
fn from(value: DeleteOriginError) -> Self {
match value {
DeleteOriginError::OriginServiceError(error) => Self::from(error),
DeleteOriginError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for DeleteOriginError {
fn into_response(self) -> Response {
let reason = self.to_string();
let status_code: StatusCode = self.into();
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -0,0 +1,72 @@
use super::error::DeleteOriginError;
use crate::{extractors::*, services::origin::service::Service as CorsService, state::State as AppState};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use sea_orm::TransactionTrait;
use std::sync::Arc;
/// Delete origin by id for specified subdomain for dynamic CORS (Cross-Origin Resource Sharing) management.
///
/// This endpoint allows users to delete origin by id that is permitted to access resources
/// on their specified subdomains. The action is authenticated using a JWT, and the subdomain must
/// be owned by the user making the request. This will be checked by the server.
#[utoipa::path(
delete,
tag = "Origins Management and Dynamic Access Control",
operation_id = "Delete origin by id",
path = "/api/origin/{id}",
params(
("x-subdomain" = String,
Header,
description = "'x-subdomain' header represents the name of the subdomain on which the action is to be performed."),
("id" = i64, Path, description = "Id of the origin to delete"),
),
responses(
(status = 204, description = "Origin was successfully deleted for subdomain."),
(status = 400, description = "The 'x-subdomain' header is missing or contains invalid characters.", body = Details),
(status = 401, description = "Unauthorized: The JWT in the header is invalid or expired.", body = Details),
(status = 403, description = "Forbidden: The origin is owned by another user.", body = Details),
(status = 404, description = "Not Found: The login or subdomain or origin was not found. See details for more information.", body = Details),
(status = 500, description = "Internal Server Error: An error occurred on the server.", body = Details),
),
security(("Bearer-JWT" = []))
)]
#[tracing::instrument(skip(state))]
pub async fn implementation(
State(state): State<Arc<AppState>>,
SubdomainOwned { user, subdomain }: SubdomainOwned,
Path(origin_id): Path<i64>,
) -> Result<impl IntoResponse, DeleteOriginError> {
let transaction = state.connection().begin().await?;
tracing::trace!(
%origin_id,
%subdomain.name,
%subdomain.id,
%user.id,
"Deleting origin for subdomain...",
);
let rows_affected = CorsService::delete_origin_of(subdomain.id, origin_id, &transaction).await?;
tracing::trace!(
%origin_id,
%subdomain.name,
%subdomain.id,
%rows_affected,
%user.id,
"Origin was successfully deleted for subdomain. Committing changes...",
);
transaction.commit().await?;
tracing::trace!( %origin_id,
%subdomain.name,
%subdomain.id,
%user.id,
%rows_affected,
"Origin was successfully deleted for subdomain. Changes were successfully committed!");
Ok(StatusCode::NO_CONTENT)
}

View file

@ -0,0 +1,4 @@
pub mod error;
pub mod handler;
#[cfg(test)]
pub mod tests;

View file

@ -0,0 +1,26 @@
#[cfg(test)]
pub mod tests {
use crate::{api::tests::delete as delete_helper, Details};
use axum::http::{HeaderName, HeaderValue, StatusCode};
use axum_test::TestServer as TestClient;
use std::fmt::Display;
pub async fn delete<T, S>(client: &TestClient, token: T, subdomain: S, id: i64) -> Result<(), (StatusCode, Details)>
where
T: Display,
S: AsRef<str> + Display,
{
let response = delete_helper(client, &format!("/api/origin/{id}"), Option::<()>::None)
.add_header(
HeaderName::from_static("x-subdomain"),
HeaderValue::from_str(subdomain.as_ref()).expect("Failed to convert subdomain name to header value!"),
)
.authorization_bearer(token)
.await;
match response.status_code().is_success() {
true => Ok(()),
false => Err((response.status_code(), response.json())),
}
}
}

View file

@ -0,0 +1,97 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::{
auth::{
login::{request::LoginRequest, tests::call::test::login},
registration::{request::RegistrationRequest, tests::call::tests::registration},
},
origin::{create::tests::call::tests::create, delete::tests::call::tests::delete, tests::preflight},
site::{page::tests::call::tests::page, upload::tests::call::tests::upload},
},
app,
};
use axum_test::TestServer as TestClient;
use uuid::Uuid;
#[tokio::test]
async fn correct() {
dotenvy::from_filename_override(".env.tests").ok();
let (app, _state) = app().await.expect("Failed to initialize application!");
let app_cloned = app.clone();
let client = TestClient::new(app_cloned).expect("Failed to run server for testing");
let first_user_login = Uuid::new_v4();
let first_user_password = Uuid::new_v4();
//* Registration
let first_user_registration_request = RegistrationRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_registration_response = registration(&client, &first_user_registration_request).await;
assert!(first_user_registration_response.is_ok());
//* Login
let first_user_login_request = LoginRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_login_response = login(&client, &first_user_login_request).await;
assert!(first_user_login_response.is_ok());
let first_user_token = first_user_login_response.expect("to never fail").token;
let first_random_subdomain = Uuid::new_v4().to_string();
let first_correct_zip_path = "./assets/zips/correct-1.zip";
//* Correct upload
let first_correct_upload_response = upload(
&client,
&first_user_token,
&first_random_subdomain,
first_correct_zip_path,
)
.await;
assert!(first_correct_upload_response.is_ok());
//* Test that page is correct
let first_user_correct_zip_index_response = page(&client, "/some/index.html", &first_random_subdomain).await;
assert!(first_user_correct_zip_index_response.status_code().is_success());
//* Add some origin
let first_subdomain_add_origin_response =
create(&client, &first_user_token, &first_random_subdomain, "some").await;
assert!(first_subdomain_add_origin_response.is_ok());
let first_subdomain_added_origin = first_subdomain_add_origin_response.expect("never fail");
//* Check origin was added
let preflight_response = preflight(&client, &first_random_subdomain, "some").await;
let preflight_response_allowed_origin = preflight_response
.headers()
.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
assert!(preflight_response_allowed_origin.is_some());
//* Delete
let first_subdomain_delete_first_origin_response = delete(
&client,
&first_user_token,
&first_random_subdomain,
first_subdomain_added_origin.id,
)
.await;
assert!(first_subdomain_delete_first_origin_response.is_ok());
//* Check origin again
let preflight_response = preflight(&client, &first_random_subdomain, "some").await;
let preflight_response_allowed_origin = preflight_response
.headers()
.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
assert!(preflight_response_allowed_origin.is_none());
}
}

View file

@ -0,0 +1,3 @@
pub mod call;
pub mod correct;
pub mod only_one;

View file

@ -0,0 +1,100 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::{
auth::{
login::{request::LoginRequest, tests::call::test::login},
registration::{request::RegistrationRequest, tests::call::tests::registration},
},
origin::{create::tests::call::tests::create, delete::tests::call::tests::delete, tests::preflight},
site::{page::tests::call::tests::page, upload::tests::call::tests::upload},
},
app,
};
use axum_test::TestServer as TestClient;
use uuid::Uuid;
#[tokio::test]
async fn only_one() {
dotenvy::from_filename_override(".env.tests").ok();
let (app, _state) = app().await.expect("Failed to initialize application!");
let client = TestClient::new(app).expect("Failed to run server for testing");
let first_user_login = Uuid::new_v4();
let first_user_password = Uuid::new_v4();
//* Registration
let first_user_registration_request = RegistrationRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_registration_response = registration(&client, &first_user_registration_request).await;
assert!(first_user_registration_response.is_ok());
//* Login
let first_user_login_request = LoginRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_login_response = login(&client, &first_user_login_request).await;
assert!(first_user_login_response.is_ok());
let first_user_token = first_user_login_response.expect("to never fail").token;
let first_random_subdomain = Uuid::new_v4().to_string();
let first_correct_zip_path = "./assets/zips/correct-1.zip";
//* Correct upload
let first_correct_upload_response = upload(
&client,
&first_user_token,
&first_random_subdomain,
first_correct_zip_path,
)
.await;
assert!(first_correct_upload_response.is_ok());
//* Test that page is correct
let first_user_correct_zip_index_response = page(&client, "/some/index.html", &first_random_subdomain).await;
assert!(first_user_correct_zip_index_response.status_code().is_success());
//* Add some origin
let first_subdomain_first_add_origin_response =
create(&client, &first_user_token, &first_random_subdomain, "some").await;
assert!(first_subdomain_first_add_origin_response.is_ok());
//* Add another origin
let first_subdomain_second_add_origin_response =
create(&client, &first_user_token, &first_random_subdomain, "another").await;
assert!(first_subdomain_second_add_origin_response.is_ok());
let first_subdomain_first_added_origin = first_subdomain_first_add_origin_response.expect("never fail");
//* Check origin was added
let preflight_response = preflight(&client, &first_random_subdomain, "some").await;
let preflight_response_allowed_origin = preflight_response
.headers()
.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
assert!(preflight_response_allowed_origin.is_some());
//* Delete
let first_subdomain_delete_first_origin_response = delete(
&client,
&first_user_token,
&first_random_subdomain,
first_subdomain_first_added_origin.id,
)
.await;
assert!(first_subdomain_delete_first_origin_response.is_ok());
//* Check origin again
let preflight_response = preflight(&client, &first_random_subdomain, "another").await;
let preflight_response_allowed_origin = preflight_response
.headers()
.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
assert!(preflight_response_allowed_origin.is_some());
}
}

View file

@ -0,0 +1,29 @@
use crate::{services::origin::error::ServiceError as OriginServiceError, Details};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
#[derive(thiserror::Error, Debug)]
pub enum ListOriginsError {
#[error(transparent)]
OriginServiceError(#[from] OriginServiceError),
}
impl From<ListOriginsError> for StatusCode {
fn from(value: ListOriginsError) -> Self {
match value {
ListOriginsError::OriginServiceError(error) => Self::from(error),
}
}
}
impl IntoResponse for ListOriginsError {
fn into_response(self) -> Response {
let reason = self.to_string();
let status_code: StatusCode = self.into();
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -0,0 +1,54 @@
use super::{error::ListOriginsError, response::ListOriginsResponse};
use crate::{extractors::*, services::origin::service::Service as CorsService, state::State as AppState};
use axum::{extract::State, response::IntoResponse, Json};
use std::sync::Arc;
/// List all origins for specified subdomain for dynamic CORS (Cross-Origin Resource Sharing) management.
///
/// This endpoint allows users to list all origins that are permitted to access resources
/// on their specified subdomains. The action is authenticated using a JWT, and the subdomain must
/// be owned by the user making the request. This will be checked by the server.
#[utoipa::path(
get,
tag = "Origins Management and Dynamic Access Control",
operation_id = "Get all origins",
path = "/api/origin",
params(
("x-subdomain" = String,
Header,
description = "'x-subdomain' header represents the name of the subdomain on which the action is to be performed."),
),
responses(
(status = 201, description = "Origins were successfully retrieved for subdomain.", body = ListOriginsResponse),
(status = 400, description = "The 'x-subdomain' header is missing or contains invalid characters.", body = Details),
(status = 401, description = "Unauthorized: The JWT in the header is invalid or expired.", body = Details),
(status = 403, description = "Forbidden: The subdomain is owned by another user.", body = Details),
(status = 404, description = "Not Found: The login or subdomain was not found. See details for more information.", body = Details),
(status = 500, description = "Internal Server Error: An error occurred on the server.", body = Details),
),
security(("Bearer-JWT" = []))
)]
#[tracing::instrument(skip(state))]
pub async fn implementation(
State(state): State<Arc<AppState>>,
SubdomainOwned { user, subdomain }: SubdomainOwned,
) -> Result<impl IntoResponse, ListOriginsError> {
tracing::trace!(
%subdomain.name,
%subdomain.id,
%user.id,
"Retrieving origins list for subdomain...",
);
let origins = CorsService::retrieve_origins_for(subdomain.id, state.connection()).await?;
tracing::trace!(
%subdomain.name,
%subdomain.id,
%user.id,
amount = origins.len(),
"Origins list was successfully retrieved!",
);
Ok(Json(ListOriginsResponse { origins }))
}

View file

@ -0,0 +1,5 @@
pub mod error;
pub mod handler;
pub mod response;
#[cfg(test)]
pub mod tests;

View file

@ -0,0 +1,10 @@
use entity::prelude::OriginModel;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, Serialize, ToSchema)]
#[schema(example = json!({"origins": [{"id": 42, "subdomain_id": 1, "value": "https://example.com"}]}))]
pub struct ListOriginsResponse {
/// List of retrieved origins
pub origins: Vec<OriginModel>,
}

View file

@ -0,0 +1,33 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::{origin::list::response::ListOriginsResponse, tests::get},
Details,
};
use axum::http::{HeaderName, HeaderValue, StatusCode};
use axum_test::TestServer as TestClient;
use std::fmt::Display;
pub async fn list<T, S>(
client: &TestClient,
token: T,
subdomain: S,
) -> Result<ListOriginsResponse, (StatusCode, Details)>
where
T: Display,
S: AsRef<str> + Display,
{
let response = get(client, "/api/origin")
.add_header(
HeaderName::from_static("x-subdomain"),
HeaderValue::from_str(subdomain.as_ref()).expect("Failed to convert subdomain name to header value!"),
)
.authorization_bearer(token)
.await;
match response.status_code().is_success() {
true => Ok(response.json()),
false => Err((response.status_code(), response.json())),
}
}
}

View file

@ -0,0 +1,94 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::{
auth::{
login::{request::LoginRequest, tests::call::test::login},
registration::{request::RegistrationRequest, tests::call::tests::registration},
},
origin::{create::tests::call::tests::create, list::tests::call::tests::list, tests::preflight},
site::{page::tests::call::tests::page, upload::tests::call::tests::upload},
},
app,
};
use axum_test::TestServer as TestClient;
use uuid::Uuid;
#[tokio::test]
async fn correct() {
dotenvy::from_filename_override(".env.tests").ok();
let (app, _state) = app().await.expect("Failed to initialize application!");
let app_cloned = app.clone();
let client = TestClient::new(app_cloned).expect("Failed to run server for testing");
let first_user_login = Uuid::new_v4();
let first_user_password = Uuid::new_v4();
//* Registration
let first_user_registration_request = RegistrationRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_registration_response = registration(&client, &first_user_registration_request).await;
assert!(first_user_registration_response.is_ok());
//* Login
let first_user_login_request = LoginRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_login_response = login(&client, &first_user_login_request).await;
assert!(first_user_login_response.is_ok());
let first_user_token = first_user_login_response.expect("to never fail").token;
let first_random_subdomain = Uuid::new_v4().to_string();
let first_correct_zip_path = "./assets/zips/correct-1.zip";
//* Correct upload
let first_correct_upload_response = upload(
&client,
&first_user_token,
&first_random_subdomain,
first_correct_zip_path,
)
.await;
assert!(first_correct_upload_response.is_ok());
//* Test that page is correct
let first_user_correct_zip_index_response = page(&client, "/some/index.html", &first_random_subdomain).await;
assert!(first_user_correct_zip_index_response.status_code().is_success());
//* Add some origin
let origins = ["some", "another", "third"];
for origin in origins {
let first_subdomain_add_origin_response =
create(&client, &first_user_token, &first_random_subdomain, origin).await;
assert!(first_subdomain_add_origin_response.is_ok());
let preflight_response = preflight(&client, &first_random_subdomain, origin).await;
let preflight_response_allowed_origin = preflight_response
.headers()
.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
assert!(preflight_response_allowed_origin.is_some());
}
let origins_list = list(&client, &first_user_token, &first_random_subdomain).await;
assert!(origins_list.is_ok());
let origins_list = origins_list.expect("never fail");
assert!(origins_list
.origins
.iter()
.map(|o| &o.value)
.all(|value| origins.contains(&value.as_str())));
assert_eq!(origins_list.origins.len(), origins.len());
}
}

View file

@ -0,0 +1,2 @@
pub mod call;
pub mod correct;

66
src/api/origin/mod.rs Normal file
View file

@ -0,0 +1,66 @@
use crate::state::State as AppState;
use axum::{
routing::{delete, get, post},
Router,
};
use std::sync::Arc;
use utoipa::ToSchema;
pub mod create;
pub mod delete;
pub mod list;
pub mod purge;
pub mod retrieve;
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/", post(create::handler::implementation))
.route("/", get(list::handler::implementation))
.route("/", delete(purge::handler::implementation))
.route("/:id", delete(delete::handler::implementation))
.route("/:id", get(retrieve::handler::implementation))
}
// We need this as utoipa
// currently does not support types
// from external crates
#[derive(ToSchema)]
#[schema(as = OriginModel)]
pub struct OriginModelSchema {
pub id: i64,
pub subdomain_id: i64,
pub value: String,
}
#[cfg(test)]
pub mod tests {
use axum::http::{HeaderName, HeaderValue};
use axum_test::{TestResponse, TestServer};
//? That is a common call of preflight
//? for testing in all origins handlers
pub async fn preflight<S, O>(app: &TestServer, subdomain: S, origin: O) -> TestResponse
where
S: AsRef<str>,
O: AsRef<str>,
{
app.method(axum::http::Method::OPTIONS, "/")
.add_header(
HeaderName::from_static("x-subdomain"),
HeaderValue::from_str(subdomain.as_ref()).expect("Failed to convert subdomain name to header value!"),
)
.add_header(
axum::http::header::ORIGIN,
HeaderValue::from_str(origin.as_ref()).expect("Failed to convert origin to header value!"),
)
.add_header(
axum::http::header::ACCESS_CONTROL_REQUEST_METHOD,
HeaderValue::from_static("GET"),
)
.add_header(
axum::http::header::ACCESS_CONTROL_REQUEST_HEADERS,
HeaderValue::from_static("x-subdomain"),
)
.await
}
}

View file

@ -0,0 +1,29 @@
use crate::{services::origin::error::ServiceError as OriginServiceError, Details};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
#[derive(thiserror::Error, Debug)]
pub enum DeleteOriginsError {
#[error(transparent)]
OriginServiceError(#[from] OriginServiceError),
}
impl From<DeleteOriginsError> for StatusCode {
fn from(value: DeleteOriginsError) -> Self {
match value {
DeleteOriginsError::OriginServiceError(error) => Self::from(error),
}
}
}
impl IntoResponse for DeleteOriginsError {
fn into_response(self) -> Response {
let reason = self.to_string();
let status_code: StatusCode = self.into();
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -0,0 +1,53 @@
use super::error::DeleteOriginsError;
use crate::{extractors::*, services::origin::service::Service as CorsService, state::State as AppState};
use axum::{extract::State, http::StatusCode, response::IntoResponse};
use std::sync::Arc;
/// Delete all origins for specified subdomain for dynamic CORS (Cross-Origin Resource Sharing) management.
///
/// This endpoint allows users to delete all origins that are permitted to access resources
/// on their specified subdomains. The action is authenticated using a JWT, and the subdomain must
/// be owned by the user making the request. This will be checked by the server.
#[utoipa::path(
delete,
tag = "Origins Management and Dynamic Access Control",
operation_id = "Delete all origins",
path = "/api/origin",
params(
("x-subdomain" = String,
Header,
description = "'x-subdomain' header represents the name of the subdomain on which the action is to be performed."),
),
responses(
(status = 204, description = "Origins were successfully deleted for subdomain."),
(status = 400, description = "The 'x-subdomain' header is missing or contains invalid characters.", body = Details),
(status = 401, description = "Unauthorized: The JWT in the header is invalid or expired.", body = Details),
(status = 403, description = "Forbidden: The subdomain is owned by another user.", body = Details),
(status = 404, description = "Not Found: The login or subdomain was not found. See details for more information.", body = Details),
(status = 500, description = "Internal Server Error: An error occurred on the server.", body = Details),
),
security(("Bearer-JWT" = []))
)]
#[tracing::instrument(skip(state))]
pub async fn implementation(
State(state): State<Arc<AppState>>,
SubdomainOwned { user, subdomain }: SubdomainOwned,
) -> Result<impl IntoResponse, DeleteOriginsError> {
tracing::trace!(
%subdomain.name,
%subdomain.id,
%user.id,
"Deleting all origins for subdomain...",
);
let rows_affected = CorsService::delete_origins_for(subdomain.id, state.connection()).await?;
tracing::trace!(
%rows_affected,
%subdomain.name,
%subdomain.id,
%user.id,
"Origins were successfully deleted!",
);
Ok(StatusCode::NO_CONTENT)
}

View file

@ -0,0 +1,4 @@
pub mod error;
pub mod handler;
#[cfg(test)]
pub mod tests;

View file

@ -0,0 +1,26 @@
#[cfg(test)]
pub mod tests {
use crate::{api::tests::delete as delete_helper, Details};
use axum::http::{HeaderName, HeaderValue, StatusCode};
use axum_test::TestServer as TestClient;
use std::fmt::Display;
pub async fn purge<T, S>(client: &TestClient, token: T, subdomain: S) -> Result<(), (StatusCode, Details)>
where
T: Display,
S: AsRef<str> + Display,
{
let response = delete_helper(client, "/api/origin", Option::<()>::None)
.add_header(
HeaderName::from_static("x-subdomain"),
HeaderValue::from_str(subdomain.as_ref()).expect("Failed to convert subdomain name to header value!"),
)
.authorization_bearer(token)
.await;
match response.status_code().is_success() {
true => Ok(()),
false => Err((response.status_code(), response.json())),
}
}
}

View file

@ -0,0 +1,103 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::{
auth::{
login::{request::LoginRequest, tests::call::test::login},
registration::{request::RegistrationRequest, tests::call::tests::registration},
},
origin::{
create::tests::call::tests::create, list::tests::call::tests::list, purge::tests::call::tests::purge,
tests::preflight,
},
site::{page::tests::call::tests::page, upload::tests::call::tests::upload},
},
app,
};
use axum_test::TestServer as TestClient;
use uuid::Uuid;
#[tokio::test]
async fn correct() {
dotenvy::from_filename_override(".env.tests").ok();
let (app, _state) = app().await.expect("Failed to initialize application!");
let app_cloned = app.clone();
let client = TestClient::new(app_cloned).expect("Failed to run server for testing");
let first_user_login = Uuid::new_v4();
let first_user_password = Uuid::new_v4();
//* Registration
let first_user_registration_request = RegistrationRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_registration_response = registration(&client, &first_user_registration_request).await;
assert!(first_user_registration_response.is_ok());
//* Login
let first_user_login_request = LoginRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_login_response = login(&client, &first_user_login_request).await;
assert!(first_user_login_response.is_ok());
let first_user_token = first_user_login_response.expect("to never fail").token;
let first_random_subdomain = Uuid::new_v4().to_string();
let first_correct_zip_path = "./assets/zips/correct-1.zip";
//* Correct upload
let first_correct_upload_response = upload(
&client,
&first_user_token,
&first_random_subdomain,
first_correct_zip_path,
)
.await;
assert!(first_correct_upload_response.is_ok());
//* Test that page is correct
let first_user_correct_zip_index_response = page(&client, "/some/index.html", &first_random_subdomain).await;
assert!(first_user_correct_zip_index_response.status_code().is_success());
//* Add some origin
let origins = ["some", "another", "third"];
for origin in origins {
let first_subdomain_add_origin_response =
create(&client, &first_user_token, &first_random_subdomain, origin).await;
assert!(first_subdomain_add_origin_response.is_ok());
let preflight_response = preflight(&client, &first_random_subdomain, origin).await;
let preflight_response_allowed_origin = preflight_response
.headers()
.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
assert!(preflight_response_allowed_origin.is_some());
}
let origins_list = list(&client, &first_user_token, &first_random_subdomain).await;
assert!(origins_list.is_ok());
let origins_list = origins_list.expect("never fail");
assert!(origins_list
.origins
.iter()
.map(|o| &o.value)
.all(|value| origins.contains(&value.as_str())));
assert_eq!(origins_list.origins.len(), origins.len());
let first_subdomain_purge_response = purge(&client, &first_user_token, &first_random_subdomain).await;
assert!(first_subdomain_purge_response.is_ok());
let origins_list = list(&client, &first_user_token, &first_random_subdomain).await;
assert!(origins_list.is_ok());
assert!(origins_list.expect("never fail").origins.is_empty());
}
}

View file

@ -0,0 +1,2 @@
pub mod call;
pub mod correct;

View file

@ -0,0 +1,29 @@
use crate::{services::origin::error::ServiceError as OriginServiceError, Details};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
#[derive(thiserror::Error, Debug)]
pub enum GetOriginError {
#[error(transparent)]
OriginServiceError(#[from] OriginServiceError),
}
impl From<GetOriginError> for StatusCode {
fn from(value: GetOriginError) -> Self {
match value {
GetOriginError::OriginServiceError(error) => Self::from(error),
}
}
}
impl IntoResponse for GetOriginError {
fn into_response(self) -> Response {
let reason = self.to_string();
let status_code: StatusCode = self.into();
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -0,0 +1,59 @@
use super::{error::GetOriginError, response::GetOriginResponse};
use crate::{extractors::*, services::origin::service::Service as CorsService, state::State as AppState};
use axum::{
extract::{Path, State},
response::IntoResponse,
Json,
};
use std::sync::Arc;
/// Get specified origin [by id] for specified subdomain for dynamic CORS (Cross-Origin Resource Sharing) management.
///
/// This endpoint allows users to get specified origin by id that is permitted to access resources
/// on specified subdomain. The action is authenticated using a JWT, and the subdomain must
/// be owned by the user making the request. This will be checked by the server.
#[utoipa::path(
get,
tag = "Origins Management and Dynamic Access Control",
operation_id = "Get origin by id",
path = "/api/origin/{id}",
params(
("x-subdomain" = String,
Header,
description = "'x-subdomain' header represents the name of the subdomain on which the action is to be performed."),
("id" = i64, Path, description = "Id of the origin to retrieve"),
),
responses(
(status = 200, description = "Origin was successfully retrieved.", body = GetOriginResponse),
(status = 400, description = "The 'x-subdomain' header is missing or contains invalid characters.", body = Details),
(status = 401, description = "Unauthorized: The JWT in the header is invalid or expired.", body = Details),
(status = 403, description = "Forbidden: The subdomain is owned by another user.", body = Details),
(status = 404, description = "Not Found: The login or subdomain or origin was not found. See details for more information.", body = Details),
(status = 500, description = "Internal Server Error: An error occurred on the server.", body = Details),
),
security(("Bearer-JWT" = []))
)]
#[tracing::instrument(skip(state))]
pub async fn implementation(
State(state): State<Arc<AppState>>,
SubdomainOwned { user, subdomain }: SubdomainOwned,
Path(origin_id): Path<i64>,
) -> Result<impl IntoResponse, GetOriginError> {
tracing::trace!(
%origin_id,
%subdomain.name,
%subdomain.id,
%user.id,
"Retrieving origin by id for subdomain...",
);
let origin = CorsService::fetch_origin_of(subdomain.id, origin_id, state.connection()).await?;
tracing::trace!(
?origin,
%subdomain.name,
%subdomain.id,
%user.id,
"Origin was successfully retrieved by id",
);
Ok(Json(GetOriginResponse { origin }))
}

View file

@ -0,0 +1,5 @@
pub mod error;
pub mod handler;
pub mod response;
#[cfg(test)]
pub mod tests;

View file

@ -0,0 +1,9 @@
use entity::origin::Model as OriginModel;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, Serialize, ToSchema)]
#[schema(example = json!({"origin": {"id": 42, "subdomain_id": 1, "value": "https://example.com"}}))]
pub struct GetOriginResponse {
pub origin: OriginModel,
}

View file

@ -0,0 +1,34 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::{origin::retrieve::response::GetOriginResponse, tests::get},
Details,
};
use axum::http::{HeaderName, HeaderValue, StatusCode};
use axum_test::TestServer as TestClient;
use std::fmt::Display;
pub async fn retrieve<T, S>(
client: &TestClient,
token: T,
subdomain: S,
id: i64,
) -> Result<GetOriginResponse, (StatusCode, Details)>
where
T: Display,
S: AsRef<str> + Display,
{
let response = get(client, &format!("/api/origin/{id}"))
.add_header(
HeaderName::from_static("x-subdomain"),
HeaderValue::from_str(subdomain.as_ref()).expect("Failed to convert subdomain name to header value!"),
)
.authorization_bearer(token)
.await;
match response.status_code().is_success() {
true => Ok(response.json()),
false => Err((response.status_code(), response.json())),
}
}
}

View file

@ -0,0 +1,85 @@
#[cfg(test)]
pub mod tests {
use crate::{
api::{
auth::{
login::{request::LoginRequest, tests::call::test::login},
registration::{request::RegistrationRequest, tests::call::tests::registration},
},
origin::{create::tests::call::tests::create, retrieve::tests::call::tests::retrieve, tests::preflight},
site::{page::tests::call::tests::page, upload::tests::call::tests::upload},
},
app,
};
use axum_test::TestServer as TestClient;
use uuid::Uuid;
#[tokio::test]
async fn correct() {
dotenvy::from_filename_override(".env.tests").ok();
let (app, _state) = app().await.expect("Failed to initialize application!");
let app_cloned = app.clone();
let client = TestClient::new(app_cloned).expect("Failed to run server for testing");
let first_user_login = Uuid::new_v4();
let first_user_password = Uuid::new_v4();
//* Registration
let first_user_registration_request = RegistrationRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_registration_response = registration(&client, &first_user_registration_request).await;
assert!(first_user_registration_response.is_ok());
//* Login
let first_user_login_request = LoginRequest {
login: first_user_login.into(),
password: first_user_password.into(),
};
let first_user_login_response = login(&client, &first_user_login_request).await;
assert!(first_user_login_response.is_ok());
let first_user_token = first_user_login_response.expect("to never fail").token;
let first_random_subdomain = Uuid::new_v4().to_string();
let first_correct_zip_path = "./assets/zips/correct-1.zip";
//* Correct upload
let first_correct_upload_response = upload(
&client,
&first_user_token,
&first_random_subdomain,
first_correct_zip_path,
)
.await;
assert!(first_correct_upload_response.is_ok());
//* Test that page is correct
let first_user_correct_zip_index_response = page(&client, "/some/index.html", &first_random_subdomain).await;
assert!(first_user_correct_zip_index_response.status_code().is_success());
//* Add some origin
let first_subdomain_add_origin_response =
create(&client, &first_user_token, &first_random_subdomain, "some").await;
assert!(first_subdomain_add_origin_response.is_ok());
let origin_id = first_subdomain_add_origin_response.expect("never fail").id;
let preflight_response = preflight(&client, &first_random_subdomain, "some").await;
let preflight_response_allowed_origin = preflight_response
.headers()
.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
assert!(preflight_response_allowed_origin.is_some());
let retrieved_origin_response = retrieve(&client, &first_user_token, &first_random_subdomain, origin_id).await;
assert!(retrieved_origin_response.is_ok());
let retrieved_origin = retrieved_origin_response.expect("never fail");
assert_eq!(retrieved_origin.origin.id, origin_id);
assert_eq!(retrieved_origin.origin.value, "some");
}
}

Some files were not shown because too many files have changed in this diff Show more