mirror of
https://github.com/kristoferssolo/axum-template.git
synced 2025-10-21 17:20:35 +00:00
Initial commit
Add docker setup Add rust files
This commit is contained in:
commit
0060453ac8
8
.dockerfile
Normal file
8
.dockerfile
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
target/
|
||||||
|
tests/
|
||||||
|
Dockerfile
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
scripts/
|
||||||
|
migrations/
|
||||||
1
.env
Normal file
1
.env
Normal file
@ -0,0 +1 @@
|
|||||||
|
DATABASE_URL=postgres://postgres:password@localhost:5432/axum-template
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
2907
Cargo.lock
generated
Normal file
2907
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
Cargo.toml
Normal file
62
Cargo.toml
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
[package]
|
||||||
|
name = "axum-template"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
path = "src/main.rs"
|
||||||
|
name = "axum-template"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.7"
|
||||||
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
|
config = { version = "0.14", features = ["toml"], default-features = false }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
sqlx = { version = "0.8", default-features = false, features = [
|
||||||
|
"runtime-tokio",
|
||||||
|
"tls-rustls",
|
||||||
|
"macros",
|
||||||
|
"postgres",
|
||||||
|
"uuid",
|
||||||
|
"chrono",
|
||||||
|
"migrate",
|
||||||
|
] }
|
||||||
|
tokio = { version = "1.39", features = [
|
||||||
|
"rt",
|
||||||
|
"macros",
|
||||||
|
"tracing",
|
||||||
|
"rt-multi-thread",
|
||||||
|
] }
|
||||||
|
uuid = { version = "1.8", features = ["v4", "serde"] }
|
||||||
|
tracing = { version = "0.1", features = ["log"] }
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
|
||||||
|
tower-http = { version = "0.5", features = ["trace"] }
|
||||||
|
tracing-bunyan-formatter = "0.3"
|
||||||
|
tracing-log = "0.2"
|
||||||
|
secrecy = { version = "0.8", features = ["serde"] }
|
||||||
|
serde-aux = "4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = [
|
||||||
|
"json",
|
||||||
|
"rustls-tls",
|
||||||
|
] }
|
||||||
|
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
once_cell = "1.19"
|
||||||
|
fake = "2.9"
|
||||||
|
quickcheck = "1.0"
|
||||||
|
quickcheck_macros = "1.0"
|
||||||
|
wiremock = "0.6"
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[package.metadata.clippy]
|
||||||
|
warn = [
|
||||||
|
"clippy::pedantic",
|
||||||
|
"clippy::nursery",
|
||||||
|
"clippy::unwrap_used",
|
||||||
|
"clippy::expect_used",
|
||||||
|
]
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
FROM lukemathwalker/cargo-chef:latest-rust-1.77.0 AS chef
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN apt-get update && apt-get install lld clang -y
|
||||||
|
|
||||||
|
FROM chef as planner
|
||||||
|
COPY . .
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
FROM chef AS builder
|
||||||
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
|
COPY . .
|
||||||
|
ENV SQLX_OFFLINE true
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
# openssl - it is dynamically linked by some dependencies
|
||||||
|
# ca-certificates - it is needed to verify TLS certificates when establishing HTTPS connections
|
||||||
|
RUN apt-get update -y \
|
||||||
|
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||||
|
# Clean up
|
||||||
|
&& apt-get autoremove -y \
|
||||||
|
&& apt-get clean -y \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/axum-template axum-template
|
||||||
|
COPY config config
|
||||||
|
ENV APP_ENVIRONMENT production
|
||||||
|
ENTRYPOINT ["./axum-template"]
|
||||||
9
config/base.toml
Normal file
9
config/base.toml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[application]
|
||||||
|
port = 8000
|
||||||
|
|
||||||
|
[database]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 5432
|
||||||
|
username = "postgres"
|
||||||
|
password = "password"
|
||||||
|
database_name = "axum-template"
|
||||||
5
config/local.toml
Normal file
5
config/local.toml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[application]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
require_ssl = false
|
||||||
5
config/production.toml
Normal file
5
config/production.toml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[application]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
require_ssl = true
|
||||||
46
scripts/init_db
Executable file
46
scripts/init_db
Executable file
@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
if ! [ -x "$(command -v psql)" ]; then
|
||||||
|
echo >&2 "Error: psql is not installed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [ -x "$(command -v sqlx)" ]; then
|
||||||
|
echo >&2 "Error: sqlx is not installed."
|
||||||
|
echo >&2 "Use:"
|
||||||
|
echo >&2 " cargo install sqlx-cli --no-default-features --features rustls,postgres"
|
||||||
|
echo >&2 "to install it."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DB_USER="${POSTGRES_USER:=postgres}"
|
||||||
|
DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
|
||||||
|
DB_NAME="${POSTGRES_DB:=newsletter}"
|
||||||
|
DB_PORT="${POSTGRES_PORT:=5432}"
|
||||||
|
DB_HOST="${POSTGRES_HOST:=localhost}"
|
||||||
|
|
||||||
|
if [[ -z "${SKIP_DOCKER}" ]]; then
|
||||||
|
docker run\
|
||||||
|
-e POSTGRES_USER=${DB_USER}\
|
||||||
|
-e POSTGRES_PASSWORD=${DB_PASSWORD}\
|
||||||
|
-e POSTGRES_DB=${DB_NAME}\
|
||||||
|
-p "${DB_PORT}":5432\
|
||||||
|
-d postgres\
|
||||||
|
postgres -N 1000
|
||||||
|
# Increase max number of connections for testing purposes
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep pinging Postgres until it's ready to accept commands
|
||||||
|
export PGPASSWORD="${DB_PASSWORD}"
|
||||||
|
until psql -h "${DB_HOST}" -U "${DB_USER}" -p "${DB_PORT}" -d "postgres" -c '\q'; do
|
||||||
|
>&2 echo "Postgres is still unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
>&2 echo "Postgres is still up and running on port ${DB_PORT} - runing migrations now!"
|
||||||
|
|
||||||
|
DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||||
|
export DATABASE_URL
|
||||||
|
sqlx database create
|
||||||
|
sqlx migrate run
|
||||||
108
src/config.rs
Normal file
108
src/config.rs
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use secrecy::{ExposeSecret, Secret};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_aux::field_attributes::deserialize_number_from_string;
|
||||||
|
use sqlx::{
|
||||||
|
postgres::{PgConnectOptions, PgSslMode},
|
||||||
|
ConnectOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub database: DatabaseSettings,
|
||||||
|
pub application: ApplicationSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DatabaseSettings {
|
||||||
|
pub username: String,
|
||||||
|
pub password: Secret<String>,
|
||||||
|
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||||
|
pub port: u16,
|
||||||
|
pub host: String,
|
||||||
|
pub database_name: String,
|
||||||
|
pub require_ssl: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ApplicationSettings {
|
||||||
|
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||||
|
pub port: u16,
|
||||||
|
pub host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Environment {
|
||||||
|
Local,
|
||||||
|
Production,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_config() -> Result<Settings, config::ConfigError> {
|
||||||
|
let base_path = std::env::current_dir().expect("Failed to determine current directory");
|
||||||
|
let config_directory = base_path.join("config");
|
||||||
|
let env: Environment = std::env::var("APP_ENVIRONMENT")
|
||||||
|
.unwrap_or_else(|_| "local".into())
|
||||||
|
.try_into()
|
||||||
|
.expect("Failed to parse APP_ENVIRONMENT");
|
||||||
|
|
||||||
|
let env_filename = format!("{}.toml", &env);
|
||||||
|
|
||||||
|
let settings = config::Config::builder()
|
||||||
|
.add_source(config::File::from(config_directory.join("base.toml")))
|
||||||
|
.add_source(config::File::from(config_directory.join(env_filename)))
|
||||||
|
.add_source(
|
||||||
|
config::Environment::with_prefix("APP")
|
||||||
|
.prefix_separator("_")
|
||||||
|
.separator("__"),
|
||||||
|
)
|
||||||
|
.build()?;
|
||||||
|
settings.try_deserialize::<Settings>()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabaseSettings {
|
||||||
|
pub fn without_db(&self) -> PgConnectOptions {
|
||||||
|
let ssl_mode = if self.require_ssl {
|
||||||
|
PgSslMode::Require
|
||||||
|
} else {
|
||||||
|
PgSslMode::Prefer
|
||||||
|
};
|
||||||
|
|
||||||
|
PgConnectOptions::new()
|
||||||
|
.host(&self.host)
|
||||||
|
.username(&self.username)
|
||||||
|
.password(self.password.expose_secret())
|
||||||
|
.port(self.port)
|
||||||
|
.ssl_mode(ssl_mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_db(&self) -> PgConnectOptions {
|
||||||
|
self.without_db()
|
||||||
|
.database(&self.database_name)
|
||||||
|
.log_statements(tracing_log::log::LevelFilter::Trace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Environment {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Environment::Local => write!(f, "local"),
|
||||||
|
Environment::Production => write!(f, "production"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for Environment {
|
||||||
|
type Error = String;
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
match value.to_lowercase().as_str() {
|
||||||
|
"local" => Ok(Self::Local),
|
||||||
|
"production" => Ok(Self::Production),
|
||||||
|
other => Err(format!(
|
||||||
|
"{} is not supported environment. \
|
||||||
|
Use either `local` or `production`.",
|
||||||
|
other
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/domain/mod.rs
Normal file
1
src/domain/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
4
src/lib.rs
Normal file
4
src/lib.rs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod domain;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod telemetry;
|
||||||
21
src/main.rs
Normal file
21
src/main.rs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
use axum_template::{
|
||||||
|
config::get_config,
|
||||||
|
routes::route,
|
||||||
|
telemetry::{get_subscriber, init_subscriber},
|
||||||
|
};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), std::io::Error> {
|
||||||
|
let subscriber = get_subscriber("axum-template", "info", std::io::stdout);
|
||||||
|
init_subscriber(subscriber);
|
||||||
|
let config = get_config().expect("Failed to read configuation.");
|
||||||
|
let pool = PgPoolOptions::new().connect_lazy_with(config.database.with_db());
|
||||||
|
let addr = format!("{}:{}", config.application.host, config.application.port);
|
||||||
|
let listener = TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.expect("Failed to bind port 8000.");
|
||||||
|
|
||||||
|
axum::serve(listener, route(pool)).await
|
||||||
|
}
|
||||||
5
src/routes/health_check.rs
Normal file
5
src/routes/health_check.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
use axum::{http::StatusCode, response::IntoResponse};
|
||||||
|
|
||||||
|
pub async fn health_check() -> impl IntoResponse {
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
49
src/routes/mod.rs
Normal file
49
src/routes/mod.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
mod health_check;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::MatchedPath,
|
||||||
|
http::{HeaderMap, Request},
|
||||||
|
response::Response,
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use health_check::*;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
|
||||||
|
use tracing::{info_span, Span};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub fn route(pool: PgPool) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/health_check", get(health_check))
|
||||||
|
.with_state(pool)
|
||||||
|
.layer(
|
||||||
|
TraceLayer::new_for_http()
|
||||||
|
.make_span_with(|request: &Request<_>| {
|
||||||
|
let matched_path = request
|
||||||
|
.extensions()
|
||||||
|
.get::<MatchedPath>()
|
||||||
|
.map(MatchedPath::as_str);
|
||||||
|
info_span!(
|
||||||
|
"http-request",
|
||||||
|
method = ?request.method(),
|
||||||
|
matched_path,
|
||||||
|
some_other_field = tracing::field::Empty,
|
||||||
|
request_id=%Uuid::new_v4(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.on_request(|_request: &Request<_>, _span: &Span| {})
|
||||||
|
.on_response(|_response: &Response<_>, _latency: Duration, _span: &Span| {})
|
||||||
|
.on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {})
|
||||||
|
.on_eos(
|
||||||
|
|_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {},
|
||||||
|
)
|
||||||
|
.on_failure(
|
||||||
|
|_error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/telemetry.rs
Normal file
25
src/telemetry.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use tracing::{subscriber::set_global_default, Subscriber};
|
||||||
|
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
|
||||||
|
use tracing_log::LogTracer;
|
||||||
|
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry};
|
||||||
|
|
||||||
|
pub fn get_subscriber<Sink>(
|
||||||
|
name: &str,
|
||||||
|
env_filter: &str,
|
||||||
|
sink: Sink,
|
||||||
|
) -> impl Subscriber + Sync + Send
|
||||||
|
where
|
||||||
|
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| env_filter.into());
|
||||||
|
let formatting_layer = BunyanFormattingLayer::new(name.into(), sink);
|
||||||
|
Registry::default()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(JsonStorageLayer)
|
||||||
|
.with(formatting_layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_subscriber(subscriber: impl Subscriber + Sync + Send) {
|
||||||
|
LogTracer::init().expect("Failed to set logger");
|
||||||
|
set_global_default(subscriber).expect("Failed to set subscriber.");
|
||||||
|
}
|
||||||
95
tests/health_check.rs
Normal file
95
tests/health_check.rs
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
use axum_template::{
|
||||||
|
config::{get_config, DatabaseSettings},
|
||||||
|
routes::route,
|
||||||
|
telemetry::{get_subscriber, init_subscriber},
|
||||||
|
};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use reqwest::Client;
|
||||||
|
use sqlx::{Connection, Executor, PgConnection, PgPool};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn health_check() {
|
||||||
|
let app = spawn_app().await;
|
||||||
|
let url = format!("{}/health_check", &app.address);
|
||||||
|
let client = Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert!(response.status().is_success());
|
||||||
|
assert_eq!(Some(0), response.content_length());
|
||||||
|
}
|
||||||
|
|
||||||
|
static TRACING: Lazy<()> = Lazy::new(|| {
|
||||||
|
let default_filter_level = "trace";
|
||||||
|
let subscriber_name = "test";
|
||||||
|
if std::env::var("TEST_LOG").is_ok() {
|
||||||
|
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout);
|
||||||
|
init_subscriber(subscriber);
|
||||||
|
} else {
|
||||||
|
let subscriber = get_subscriber(default_filter_level, subscriber_name, std::io::sink);
|
||||||
|
init_subscriber(subscriber);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async fn spawn_app() -> TestApp {
|
||||||
|
Lazy::force(&TRACING);
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("Failed to bind random port");
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let address = format!("http://127.0.0.1:{}", port);
|
||||||
|
|
||||||
|
let mut config = get_config().expect("Failed to read configuration.");
|
||||||
|
|
||||||
|
config.database.database_name = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let pool = configure_database(&config.database).await;
|
||||||
|
let pool_clone = pool.clone();
|
||||||
|
|
||||||
|
let _ = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, route(pool_clone))
|
||||||
|
.await
|
||||||
|
.expect("Failed to bind address.")
|
||||||
|
});
|
||||||
|
TestApp { address, pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn configure_database(config: &DatabaseSettings) -> PgPool {
|
||||||
|
let mut connection = PgConnection::connect_with(&config.without_db())
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to Postgres.");
|
||||||
|
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
CREATE DATABASE "{}"
|
||||||
|
"#,
|
||||||
|
config.database_name
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create database.");
|
||||||
|
|
||||||
|
let pool = PgPool::connect_with(config.with_db())
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to Postgres.");
|
||||||
|
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to migrate database");
|
||||||
|
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TestApp {
|
||||||
|
pub address: String,
|
||||||
|
pub pool: PgPool,
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user