Add SSL support, add not found page

This commit is contained in:
Miroslav Vasilev 2024-01-13 13:18:12 +02:00
parent 30919f7700
commit 6f0913816e
15 changed files with 361 additions and 49 deletions

View file

@ -1,3 +1,11 @@
DATABASE_URL= // the url to the database ( example local postgres: postgres://postgres:postgres@localhost/findthetime )
EVENT_UID_SIZE= // size of snowflake ids for events ( 20 )
LOG_LEVEL= // see https://docs.rs/tracing-subscriber/0.3.15/tracing_subscriber/struct.EnvFilter.html#. Default is info
DATABASE_URL= # the url to the database ( example local postgres: postgres://postgres:postgres@localhost/findthetime )
EVENT_UID_SIZE= # size of snowflake ids for events ( 20 )
LOG_LEVEL= # see https://docs.rs/tracing-subscriber/0.3.15/tracing_subscriber/struct.EnvFilter.html#. Default is info
HTTP_PORT= # 8080 by default
SSL_ENABLED= # false by default
SSL_REDIRECT= # true by default
SSL_PORT= # 8443 by default
SSL_CERT_PATH= # no default, if SSL_ENABLED is true, this must be configured, or the backend will panic. In a docker environment, this is configured by default ( see Dockerfile )
SSL_KEY_PATH= # no default, if SSL_ENABLED is true, this must be configured, or the backend will panic. In a docker environment, this is configured by default ( see Dockerfile )

3
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target
.env
.env
.self-signed

110
Cargo.lock generated
View file

@ -60,6 +60,12 @@ dependencies = [
"libc",
]
[[package]]
name = "arc-swap"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6"
[[package]]
name = "async-trait"
version = "0.1.77"
@ -164,6 +170,29 @@ dependencies = [
"syn 2.0.48",
]
[[package]]
name = "axum-server"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1ad46c3ec4e12f4a4b6835e173ba21c25e484c9d02b49770bf006ce5367c036"
dependencies = [
"arc-swap",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"pin-project-lite",
"rustls",
"rustls-pemfile",
"tokio",
"tokio-rustls",
"tower",
"tower-service",
]
[[package]]
name = "backtrace"
version = "0.3.69"
@ -426,9 +455,10 @@ checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5"
[[package]]
name = "findtheti-me"
version = "0.1.4"
version = "0.1.7"
dependencies = [
"axum",
"axum-server",
"chrono",
"dotenv",
"env_logger",
@ -1308,6 +1338,20 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]]
name = "ring"
version = "0.17.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74"
dependencies = [
"cc",
"getrandom",
"libc",
"spin 0.9.8",
"untrusted",
"windows-sys 0.48.0",
]
[[package]]
name = "rsa"
version = "0.9.6"
@ -1347,6 +1391,44 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustls"
version = "0.21.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba"
dependencies = [
"log",
"ring",
"rustls-webpki",
"sct",
]
[[package]]
name = "rustls-pemfile"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35e4980fa29e4c4b212ffb3db068a564cbf560e51d3944b7c88bd8bf5bec64f4"
dependencies = [
"base64",
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9d979b3ce68192e42760c7810125eb6cf2ea10efae545a156063e61f314e2a"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.14"
@ -1365,6 +1447,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sct"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "serde"
version = "1.0.195"
@ -1861,6 +1953,16 @@ dependencies = [
"syn 2.0.48",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.14"
@ -2049,6 +2151,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.0"

View file

@ -1,12 +1,13 @@
[package]
name = "findtheti-me"
version = "0.1.4"
version = "0.1.7"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.7.3", features = ["macros", "tokio"] }
axum-server = { version = "0.6.0", features = ["tls-rustls"] }
chrono = { version = "0.4.31", features = ["serde"] }
dotenv = "0.15.0"
env_logger = "0.10.1"
@ -16,6 +17,6 @@ serde = { version = "1.0.195", features = ["derive"] }
serde_json = "1.0.111"
sqlx = { version = "0.7.3", features = ["runtime-tokio", "postgres", "chrono"] }
tokio = {version = "1.35.1", features = ["macros", "rt-multi-thread", "rt", "net"]}
tower-http = { version = "0.5.0", features = ["fs", "trace"] }
tower-http = { version = "0.5.0", features = ["fs", "trace", "cors"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "std"] }

View file

@ -54,8 +54,14 @@ USER appuser
ENV LOG_LEVEL=info
ENV EVENT_UID_SIZE=20
ENV HTTP_PORT=8080
ENV SSL_ENABLED=false
ENV SSL_REDIRECT=true
ENV SSL_PORT=8443
ENV SSL_CERT_PATH=/etc/findtheti-me/certs/server.cert
ENV SSL_KEY_PATH=/etc/findtheti-me/certs/server.key
WORKDIR ./findtheti-me
ENTRYPOINT ["./findtheti-me"]
EXPOSE 8080/tcp
CMD ["./findtheti-me"]

View file

@ -9,15 +9,27 @@ Also, it is only compatible with PostgreSQL at the moment. It is required to hav
### Simple (With Docker)
To use `findtheti.me` with docker, simply run
#### Without SSL
```sh
docker run
-e DATABASE_URL='postgresql://{postgres user}:{postgres password}@{postgres host}/{postgres database}'
-p {port to run on}:8080
mvv97/findthetime
mvv97/findthetime:latest
```
#### Example docker-compose.yml
#### With SSL
```sh
docker run
-e DATABASE_URL='postgresql://{postgres user}:{postgres password}@{postgres host}/{postgres database}'
-e SSL_ENABLED='true'
-v /data/findtheti-me/certs:/etc/findtheti-me/certs # Place your cert files in /data/findtheti-me/certs and ensure they have permissions of at least 644
-p {http port to run on}:8080 # if SSL_REDIRECT=false, this can be skipped
-p {ssl port to run on}:8443
mvv97/findthetime:latest
```
### Example docker-compose.yml
```yml
version: "3.4"
@ -38,10 +50,20 @@ services:
restart: unless-stopped
environment:
DATABASE_URL: "postgres://${PG_USER:-findthetime}:${PG_PASS}@postgresql/${PG_DB:-findthetime}"
SSL_ENABLED: 'true' # when this is set to false ( default ), the ssl port is not listened to.
SSL_REDIRECT: 'true'
SSL_PORT: '8443'
SSL_CERT_PATH: '/etc/findtheti-me/certs/server.cert'
SSL_KEY_PATH: '/etc/findtheti-me/certs/server.key'
volumes:
- '/data/findtheti-me/certs:/etc/findtheti-me/certs'
ports:
- '8080:8080'
- '8443:8443'
```
Ensure you have the necessary environment variables configured: `PG_DB`, `PG_USER` and `PG_PASS`.
### Advanced (Without Docker)
1. Compile Backend (`cargo build --release`)
@ -60,6 +82,21 @@ installationDir/
Finally, run `./findtheti-me` in the root, and the application should start.
### Enable SSL
In order to enable SSL, configure `SSL_ENABLED=true`, `SSL_PORT` with the desired port ( `8443` by default ), and `SSL_CERT_PATH` and `SSL_KEY_PATH`
with the paths to your certificate and key files ( `/etc/letsencrypt/live/your.domain/cert.pem` and `/etc/letsencrypt/live/your.domain/key.pem`, for example ).
Ensure the permissions of these files are at least `644`, as the container user will need to be able to read them.
**Note that there is currently no support for encrypted private keys ( those that start with `-----BEGIN ENCRYPTED PRIVATE KEY-----`).
Attempting to use such will be met with the error:**
```
Unable to use files configured in 'SSL_CERT_PATH' or 'SSL_KEY_PATH': Custom { kind: Other, error: "private key format not supported" }
```
`findtheti.me` will automatically register a listener at the configured `HTTP_PORT` to redirect
to the configured `SSL_PORT`. To disable this, configure `SSL_REDIRECT=false` ( `true` by default ).
## Setup For Development
### Backend
1. Create a PostgreSQL database
@ -69,7 +106,8 @@ Finally, run `./findtheti-me` in the root, and the application should start.
### Frontend
1. `yarn install`
2. `yarn dev` ( or `yarn build`/`yarn preview` )
2. If using SSL on the backend, change the proxy in `vite.config.ts` to reflect that.
3. `yarn dev` ( or `yarn build`/`yarn preview` )
### Docker Build Image
1. Do Backend and Frontend setups first

View file

@ -3,6 +3,7 @@ version: "3.4"
services:
postgresql:
container_name: db
image: "docker.io/library/postgres:16-alpine"
restart: unless-stopped
volumes:
@ -13,9 +14,17 @@ services:
POSTGRES_DB: ${PG_DB:-findthetime}
findthetime:
image: "docker.io/mvv97/findthetime:latest"
build: .
restart: unless-stopped
environment:
DATABASE_URL: "postgres://${PG_USER:-findthetime}:${PG_PASS}@postgresql/${PG_DB:-findthetime}"
DATABASE_URL: "postgres://${PG_USER:-findthetime}:${PG_PASS}@db/${PG_DB:-findthetime}"
SSL_ENABLED: 'true' # when this is set to false ( default ), the ssl port is not listened to.
SSL_REDIRECT: 'true'
SSL_PORT: '8443'
SSL_CERT_PATH: '/etc/findtheti-me/certs/server.cert'
SSL_KEY_PATH: '/etc/findtheti-me/certs/server.key'
volumes:
- '/home/haedhutner/Workspace/findtheti-me/.self-signed:/etc/findtheti-me/certs'
ports:
- '8080:8080'
- '8080:8080'
- '8443:8443'

View file

@ -4,6 +4,7 @@ import RootLayout from './pages/RootLayout'
import NewEventPage from './pages/NewEventPage'
import ExistingEventPage from './pages/ExistingEventPage'
import ThankYouPage from './pages/ThankYouPage'
import NotFoundPage from './pages/NotFoundPage'
function App() {
return (
@ -12,6 +13,7 @@ function App() {
<Route path="/" element={<NewEventPage />} />
<Route path="/:eventId" element={<ExistingEventPage />} />
<Route path="/thank-you" element={<ThankYouPage />} />
<Route path="/not-found" element={<NotFoundPage />} />
</Routes>
</RootLayout>
)

View file

@ -0,0 +1,17 @@
import { Typography } from '@mui/material';
import Grid from '@mui/material/Unstable_Grid2';
const NotFoundPage = () => {
return (
<Grid container>
<Grid xs={12}>
<Typography variant={"h2"}>404 Not Found!</Typography>
</Grid>
<Grid xs={12}>
<Typography>Not sure what you were looking for, but you won't find it here.</Typography>
</Grid>
</Grid>
);
}
export default NotFoundPage;

View file

@ -12,6 +12,13 @@ const utils = {
performRequest: (url: string | URL | Request, options?: RequestInit | undefined): Promise<any> => {
return fetch(url, options).then(async resp => {
if (!resp.ok) {
if (resp.status === 404) {
window.location.replace(`${window.location.origin}/not-found`)
throw 'Not Found';
}
let errorTextResult = await resp.text();
var errorMsg = errorTextResult;

View file

@ -9,7 +9,7 @@ export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
target: "https://localhost:8443",
changeOrigin: true,
secure: false,
},

View file

@ -14,7 +14,7 @@ use axum::{
use serde::Serialize;
use sqlx::{migrate::MigrateError, PgPool};
use crate::endpoints;
use crate::{endpoints, config};
pub(crate) async fn routes() -> Result<Router, ApplicationError> {
Ok(Router::new()
@ -107,9 +107,7 @@ impl AppState {
pool
},
event_uid_size: env::var("EVENT_UID_SIZE")?
.parse()
.expect("EVENT_UID_SIZE is undefined. Must be a number."),
event_uid_size: config::get_or_default("EVENT_UID_SIZE", 20),
})
}
}

32
src/config.rs Normal file
View file

@ -0,0 +1,32 @@
use std::{env, str::FromStr};
use axum::http::StatusCode;
use crate::api::ApplicationError;
pub const DEFAULT_HTTP_PORT: u16 = 8080;
pub const DEFAULT_SSL_PORT: u16 = 8443;
pub const DEFAULT_SSL_ENABLED: bool = false;
pub const DEFAULT_SSL_REDIRECT: bool = true;
pub fn get<T>(name: &str, fail_status_code: StatusCode) -> Result<T, ApplicationError> where T: FromStr {
match env::var(name).map(|r| r.parse()) {
Ok(Ok(a)) => Ok(a),
_ => Err(ApplicationError::new(format!("Unabled to get or parse environment variable '{}'.", name), fail_status_code)),
}
}
pub fn get_or_default<T>(name: &str, default: T) -> T where T: FromStr {
match env::var(name).map(|r| r.parse()) {
Ok(Ok(a)) => a,
_ => default,
}
}
pub fn get_or_panic<T>(name: &str) -> T where T: FromStr {
match get(name, StatusCode::INTERNAL_SERVER_ERROR) {
Ok(r) => r,
Err(e) => panic!("{}", e),
}
}

View file

@ -156,7 +156,11 @@ pub async fn fetch_event(
let res = conn
.transaction(|txn| {
Box::pin(async move {
let event = db::fetch_event_by_snowflake_id(txn, event_snowflake_id).await?;
let event = match db::fetch_event_by_snowflake_id(txn, event_snowflake_id).await {
Ok(e) => e,
Err(sqlx::Error::RowNotFound) => return Err(ApplicationError::new("No such event found".to_string(), StatusCode::NOT_FOUND)),
Err(e) => return Err(e.into())
};
Ok(EventDto {
snowflake_id: event.snowflake_id,

View file

@ -1,22 +1,28 @@
use std::net::SocketAddr;
use axum::Router;
use axum::extract::Host;
use axum::handler::HandlerWithoutStateExt;
use axum::http::uri::Scheme;
use axum::response::Redirect;
use axum::{Router, BoxError};
use axum::http::{Uri, StatusCode};
use axum_server::tls_rustls::RustlsConfig;
use dotenv::dotenv;
use tokio::net::TcpListener;
use tower_http::services::ServeFile;
use tower_http::trace::TraceLayer;
use tower_http::{services::ServeDir, trace};
use tracing::Level;
use tracing::{Level, instrument, info, warn};
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
mod api;
mod db;
mod endpoints;
mod entity;
mod config;
#[instrument(name = "findtheti-me")]
#[tokio::main(flavor = "current_thread")]
async fn main() {
println!("Starting findtheti.me...");
info!("Starting findtheti.me...");
dotenv().ok();
@ -25,37 +31,112 @@ async fn main() {
.with(EnvFilter::from_env("LOG_LEVEL"))
.init();
let router = init_router()
.await
.layer(
TraceLayer::new_for_http()
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
.on_response(trace::DefaultOnResponse::new().level(Level::INFO)),
)
.into_make_service_with_connect_info::<SocketAddr>();
let http_port = config::get_or_default("HTTP_PORT", config::DEFAULT_HTTP_PORT);
let ssl_enabled = config::get_or_default("SSL_ENABLED", config::DEFAULT_SSL_ENABLED);
if ssl_enabled {
info!("SSL marked as enabled, will create http to https redirect.");
let (ssl_port, ssl_redirect, ssl_cert_path, ssl_key_path): (u16, bool, String, String) = (
config::get_or_default("SSL_PORT", config::DEFAULT_SSL_PORT),
config::get_or_default("SSL_REDIRECT", config::DEFAULT_SSL_REDIRECT),
config::get_or_panic("SSL_CERT_PATH"),
config::get_or_panic("SSL_KEY_PATH")
);
let ssl_config = match RustlsConfig::from_pem_file(ssl_cert_path, ssl_key_path).await {
Ok(c) => c,
Err(e) => {
panic!("Unable to use files configured in 'SSL_CERT_PATH' or 'SSL_KEY_PATH': {:?}", e);
},
};
if ssl_redirect {
tokio::spawn(redirect_http_to_https(http_port, ssl_port));
}
let addr = SocketAddr::from(([0, 0, 0, 0], ssl_port));
info!("Listening on {}", addr);
axum_server::bind_rustls(addr, ssl_config)
.serve(router)
.await
.unwrap()
} else {
let addr = SocketAddr::from(([0, 0, 0, 0], http_port));
info!("Listening on {}", addr);
axum_server::bind(addr).serve(router).await.unwrap()
}
}
async fn init_router() -> Router {
let api_routes = api::routes().await.expect("Unable to create api routes");
let mut routes = Router::new().nest("/api", api_routes);
// If in release mod, serve static files
// If in release mode, serve static files
if !cfg!(debug_assertions) {
println!("Initializing frontend routes...");
info!("Initializing frontend routes...");
routes = routes
.nest_service("/assets", ServeDir::new("./frontend/dist/assets"))
.fallback_service(ServeFile::new("./frontend/dist/index.html"));
}
println!("Routes initialized...");
info!("Routes initialized...");
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
let listener = TcpListener::bind(addr).await.unwrap();
println!("Starting server...");
axum::serve(
listener,
routes
.layer(
TraceLayer::new_for_http()
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
.on_response(trace::DefaultOnResponse::new().level(Level::INFO)),
)
.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
routes
}
async fn redirect_http_to_https(http_port: u16, https_port: u16) {
fn make_https(host: String, uri: Uri, http_port: u16, https_port: u16) -> Result<Uri, BoxError> {
let mut parts = uri.into_parts();
parts.scheme = Some(Scheme::HTTPS);
if parts.path_and_query.is_none() {
parts.path_and_query = Some("/".parse().unwrap());
}
let https_host = host.replace(&http_port.to_string(), &https_port.to_string());
parts.authority = Some(https_host.parse()?);
Ok(Uri::from_parts(parts)?)
}
let redirect = move |Host(host): Host, uri: Uri| async move {
match make_https(host, uri, http_port, https_port) {
Ok(uri) => Ok(Redirect::permanent(&uri.to_string())),
Err(error) => {
warn!(%error, "failed to convert URI to HTTPS");
Err(StatusCode::BAD_REQUEST)
}
}
};
let addr = SocketAddr::from(([0, 0, 0, 0], http_port));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
info!("HTTPS redirect listening on {}", listener.local_addr().unwrap());
axum::serve(listener, redirect.into_make_service())
.await
.unwrap();
}