diff --git a/.env.example b/.env.example index 90e281c..1da874c 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +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 ) \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0b745e2..f5cddd1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -.env \ No newline at end of file +.env +.self-signed \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a6515c2..de52c55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 49e21bb..0f1c224 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/Dockerfile b/Dockerfile index e645f23..b2d3ea0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ No newline at end of file +CMD ["./findtheti-me"] \ No newline at end of file diff --git a/README.md b/README.md index 4ccf2c1..e67c927 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index a728a24..58f4f19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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' \ No newline at end of file + - '8080:8080' + - '8443:8443' \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3e6d8c6..e068894 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> ) diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..a4d6659 --- /dev/null +++ b/frontend/src/pages/NotFoundPage.tsx @@ -0,0 +1,17 @@ +import { Typography } from '@mui/material'; +import Grid from '@mui/material/Unstable_Grid2'; + +const NotFoundPage = () => { + return ( + + + 404 Not Found! + + + Not sure what you were looking for, but you won't find it here. + + + ); +} + +export default NotFoundPage; \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index fc311b5..8a57cda 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -12,6 +12,13 @@ const utils = { performRequest: (url: string | URL | Request, options?: RequestInit | undefined): Promise => { 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; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 21d76fe..b6cbfd8 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ server: { proxy: { "/api": { - target: "http://localhost:8080", + target: "https://localhost:8443", changeOrigin: true, secure: false, }, diff --git a/src/api.rs b/src/api.rs index 6e1cf88..e198daf 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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 { 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), }) } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..cccb84a --- /dev/null +++ b/src/config.rs @@ -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(name: &str, fail_status_code: StatusCode) -> Result 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(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(name: &str) -> T where T: FromStr { + match get(name, StatusCode::INTERNAL_SERVER_ERROR) { + Ok(r) => r, + Err(e) => panic!("{}", e), + } +} \ No newline at end of file diff --git a/src/endpoints.rs b/src/endpoints.rs index 52f7ade..e6eee65 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -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, diff --git a/src/main.rs b/src/main.rs index 2287bc2..43754cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::(); + + 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::(), - ) - .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 { + 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(); +} \ No newline at end of file