Compare commits

..

No commits in common. "master" and "v0.2.0" have entirely different histories.

39 changed files with 1417 additions and 251 deletions

View file

@ -1,27 +0,0 @@
name: "Test"
on:
push:
branches:
- "dev"
jobs:
push-to-registry:
name: "Build and push Docker image to Docker Hub"
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: "Build and push Docker image"
uses: "docker/build-push-action@v3"
with:
context: .
push: true
tags: clowzed/sero:dev-unstable

View file

@ -80,6 +80,7 @@ jobs:
needs:
- "get-tag"
- "upload-assets"
- "publish-api"
runs-on: "ubuntu-latest"
steps:
- name: "Check out the repo"
@ -104,53 +105,3 @@ jobs:
push: true
tags: clowzed/sero:v${{ needs.get-tag.outputs.pkg-version }}
labels: ${{ steps.meta.outputs.labels }}
upload-openapi-client:
needs:
- "push-to-registry"
name: "Build and upload openapi python client"
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: "16"
- name: Install OpenAPI Generator CLI
run: |
npm install @openapitools/openapi-generator-cli -g
- name: Extract version from OpenAPI spec
id: extract_version
run: |
VERSION=$(jq -r '.info.version' openapi.json)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Generate Python client from OpenAPI spec
run: |
openapi-generator-cli generate -i openapi.json -g python -o seroapi --additional-properties=packageName=seroapi,packageVersion=${{ env.VERSION }}
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: "3.x"
- name: Install Poetry
run: |
curl -sSL https://install.python-poetry.org | python3 -
- name: Configure Poetry
run: |
poetry config virtualenvs.in-project true
- name: Install dependencies
run: |
cd seroapi
poetry install
- name: Publish to Test PyPI
run: |
cd seroapi
poetry publish -u __token__ -p ${{ secrets.PYPI_PASSWORD }} --build

View file

@ -46,6 +46,24 @@ jobs:
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
@ -59,14 +77,14 @@ jobs:
- uses: "actions-rs/toolchain@v1"
with:
profile: "minimal"
toolchain: "nightly"
toolchain: "stable"
override: true
- run: "rustup component add rustfmt"
- uses: "actions-rs/cargo@v1"
with:
command: "fmt"
command: "+nightly fmt"
args: "--all -- --check"
clippy:

1
.gitignore vendored
View file

@ -4,4 +4,3 @@ sites-uploads
logs
test_upload_files
.env
openapi.json

View file

@ -1,6 +1,6 @@
[package]
name = "sero"
version = "0.2.8"
version = "0.2.0"
edition = "2021"
authors = ["clowzed <clowzed.work@gmail.com>"]
description = "Muiltidomain static site hosting"
@ -11,19 +11,19 @@ license = "MIT"
[dependencies]
envy = "0.4.2"
sea-orm = { version = "0.12.3", features = [
"sqlx-postgres",
"runtime-tokio-rustls",
"macros",
"sqlx-postgres",
"runtime-tokio-rustls",
"macros",
] }
tokio = { version = "1.32.0", features = ["full"] }
tokio-postgres = "0.7.10"
tracing = { version = "0.1.37", features = ["async-await"] }
tracing-subscriber = { version = "0.3.17", features = [
"env-filter",
"fmt",
"ansi",
"std",
"json",
"env-filter",
"fmt",
"ansi",
"std",
"json",
] }
entity = { path = "entity" }
migration = { path = "migration" }
@ -41,9 +41,9 @@ mime = "0.3.17"
mime_guess = "2.0.4"
argon2 = { version = "0.5.3", features = ["std"] }
utoipa = { version = "4.2.0", features = [
"axum_extras",
"chrono",
"preserve_order",
"axum_extras",
"chrono",
"preserve_order",
] }
dotenvy = "0.15.7"
toml = "0.8.8"
@ -52,16 +52,16 @@ 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",
"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",
"cors",
"trace",
"timeout",
] }
tower = { version = "0.4.13", features = ["util"] }
hyper = "0.14.28"
@ -77,11 +77,5 @@ members = [".", "entity", "migration"]
[dev-dependencies]
axum-test = "15.2.0"
[profile.release]
lto = true
strip = true
opt-level = 3
codegen-units = 1

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,69 +0,0 @@
version: "3"
services:
database:
image: postgres:16
user: postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_DB=sero
- POSTGRES_PASSWORD=1234
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- pgdata:/var/lib/postgresql/data
proxy:
image: nginx:alpine3.18-slim
environment:
- DOLLAR=$
- SERVER_PORT=8080
- SERVER=server
# Edit this
- DOMAIN=
- ZONE=
# End of edit
volumes:
- ./nginx-templates:/etc/nginx/templates
ports:
- 443:443
- 80:80
links:
- server
depends_on:
- server
server:
image: clowzed/sero:dev-unstable
build: .
depends_on:
database:
condition: service_healthy
volumes:
- server-files:/app/sites-uploads
ports:
- 8080:8080
environment:
- DATABASE_URL=postgresql://postgres:1234@database/sero
- PORT=8080
# You can edit this section
# Empty means no limits
- MAX_USERS=1
- MAX_SITES_PER_USER=100
- MAX_BODY_LIMIT_SIZE=10000000 # 10mb
- RUST_LOG=none,sero=trace
- JWT_SECRET=mysuperstrongjwtscret
# end of section
- JWT_TTL_SECONDS=120
- SQLX_LOGGING=true
- UPLOAD_FOLDER=./sites-uploads
volumes:
server-files:
pgdata:

View file

@ -4,6 +4,7 @@ services:
database:
image: postgres:16
user: postgres
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_DB=sero
@ -20,6 +21,7 @@ services:
proxy:
image: nginx:alpine3.18-slim
restart: always
environment:
- DOLLAR=$
- SERVER_PORT=8080
@ -35,17 +37,20 @@ services:
- 80:80
links:
- server
depends_on:
- server
profiles:
- donotstart
server:
image: clowzed/sero:v0.2.7
image: clowzed/sero
restart: always
build: .
depends_on:
database:
condition: service_healthy
volumes:
- server-files:/app/sites-uploads
ports:
- 8080:8080
environment:
- DATABASE_URL=postgresql://postgres:1234@database/sero
- PORT=8080
@ -55,8 +60,8 @@ services:
- MAX_SITES_PER_USER=100
- MAX_BODY_LIMIT_SIZE=10000000 # 10mb
- RUST_LOG=none,sero=trace
- JWT_SECRET=mysuperstrongjwtscret
# end of section
- JWT_SECRET=mysuperstrongjwtscret
- JWT_TTL_SECONDS=120
- SQLX_LOGGING=true
- UPLOAD_FOLDER=./sites-uploads

View file

@ -1,22 +1,18 @@
map $http_host $subdomain {
~^(?<subdomain>[a-zA-Z0-9-]+)\.${DOMAIN}\.${ZONE}${DOLLAR} $subdomain;
}
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>[a-zA-Z0-9-]+)\.${DOMAIN}\.${ZONE}${DOLLAR};
server_name ~^(?<subdomain>\w*)\.${DOMAIN}.${ZONE}${DOLLAR};
location / {
proxy_set_header X-Subdomain $subdomain;
proxy_pass http://${SERVER}:${SERVER_PORT};
}
}
server {
listen 80;
server_name ${DOMAIN}.${ZONE};
location / {
proxy_pass http://${SERVER}:${SERVER_PORT};
if ($subdomain != "" && $http_x_subdomain = "") {
proxy_set_header x-subdomain $subdomain;
}
proxy_pass http://${SERVER}:${SERVER_PORT}/;
}
}

1219
openapi.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -21,23 +21,31 @@
</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)
- [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)
- [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)
## Docs
Read [docs here]("clowzed.github.io/sero-docs/") for fast installation.
Read [docs here]("http://sero-docs.clowzed.ru") for fast installation.
## 🔧 Tools
@ -62,7 +70,7 @@ One key feature that it is self-hosted. This gives users more flexibility and co
- Custom 503.html `new` `(on disabled site)`
- Clean urls
- Dynamic CORS Management
- `[WIP]` SSE
- `[WIP]` Server events with websocket
## 🔌 Built With

View file

@ -27,7 +27,7 @@ impl IntoResponse for LoginError {
fn into_response(self) -> Response {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -1,15 +1,25 @@
use serde::{Deserialize, Serialize};
use std::fmt::{self, Debug};
use serde::{Deserialize, Serialize};
use utoipa::{schema, ToSchema};
use validator::Validate;
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))]
#[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,
@ -17,11 +27,53 @@ pub struct LoginRequest {
/// It must meet the following requirements:
/// - Minimum length of 12 characters.
/// - Maximum length of 40 characters.
#[validate(length(min = 12, max = 40))]
/// - 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")

View file

@ -32,7 +32,6 @@ impl IntoResponse for RegistrationError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -1,15 +1,25 @@
use serde::{Deserialize, Serialize};
use std::fmt::{self, Debug};
use serde::{Deserialize, Serialize};
use utoipa::{schema, ToSchema};
use validator::Validate;
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))]
#[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,
@ -17,11 +27,53 @@ pub struct RegistrationRequest {
/// It must meet the following requirements:
/// - Minimum length of 12 characters.
/// - Maximum length of 40 characters.
#[validate(length(min = 12, max = 40))]
/// - 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")

View file

@ -24,7 +24,6 @@ impl IntoResponse for AddOriginError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -28,7 +28,6 @@ impl IntoResponse for DeleteOriginError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -24,7 +24,6 @@ impl IntoResponse for ListOriginsError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -24,7 +24,6 @@ impl IntoResponse for DeleteOriginsError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -24,7 +24,6 @@ impl IntoResponse for GetOriginError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -27,7 +27,6 @@ impl IntoResponse for DisableError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -32,7 +32,6 @@ impl IntoResponse for DownloadError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -28,7 +28,6 @@ impl IntoResponse for EnableError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -28,7 +28,6 @@ impl IntoResponse for PageError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -28,7 +28,6 @@ impl IntoResponse for TeardownError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -34,7 +34,6 @@ impl IntoResponse for UploadError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -61,7 +61,6 @@ impl IntoResponse for AuthError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -49,7 +49,6 @@ impl IntoResponse for GuardError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -41,7 +41,6 @@ impl IntoResponse for SubdomainError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -32,7 +32,6 @@ impl IntoResponse for SubdomainNameError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -41,7 +41,6 @@ impl IntoResponse for SubdomainOwnedError {
let reason = self.to_string();
let status_code: StatusCode = self.into();
tracing::error!(%reason, %status_code, "Error occurred while trying to handle request!");
(status_code, Json(Details { reason })).into_response()
}
}

View file

@ -11,7 +11,7 @@ use configuration::{reader::ConfigurationReader, *};
use futures::StreamExt;
use migration::{Migrator, MigratorTrait};
use origin::service::Service as CorsService;
use sea_orm::{ActiveModelTrait, ConnectOptions, Database, DbErr, IntoActiveModel};
use sea_orm::{ConnectOptions, Database, DbErr};
use serde::{Deserialize, Serialize};
use services::*;
use site::service::Service as SiteService;
@ -165,15 +165,6 @@ pub async fn app() -> Result<(Router, Arc<State>), AppCreationError> {
|cause| tracing::warn!(%cause, "Failed to remove file with path : {}", file.real_path),
)
.ok();
let file_id = file.id;
file.into_active_model()
.delete(state_for_file_deletion_task.connection())
.await
.inspect_err(
|cause| tracing::warn!(%cause, %file_id, "Failed to remove file from database"),
)
.ok();
}
}
}
@ -206,12 +197,6 @@ pub async fn app() -> Result<(Router, Arc<State>), AppCreationError> {
};
async move {
//? If header was not provided
//? Allow as it probably management tool
if task.subdomain.is_empty() {
return true;
}
if cors_task_sender
.send(task)
.await
@ -247,10 +232,10 @@ pub async fn app() -> Result<(Router, Arc<State>), AppCreationError> {
let mut app = Router::new()
.merge(openapi)
.nest("/api", api::router())
.route("/*path", get(api::site::page::handler::implementation))
.route("/", get(api::site::page::handler::redirect::implementation))
.layer(cors_layer)
.nest("/api", api::router())
.layer(tracing_layer)
.layer(TimeoutLayer::new(Duration::from_secs(10)))
.with_state(state.clone());

View file

@ -44,11 +44,11 @@ impl Service {
.filename()
.as_str()
.inspect_err(|cause| tracing::warn!(%cause, "Failed to convert entry filepath to str"))?;
PathBuf::from(entry_filename)
PathBuf::from(entry_filename).components().skip(1).collect::<PathBuf>()
};
tracing::trace!(?path, "Entry filepath was successfully retrieved");
//? Generating filename for enty
//? Generating filename for entry
// Just random to prevent collisions
let u1 = Uuid::new_v4();
let u2 = Uuid::new_v4();