Moved uploaded banner and generated styles to a persistent data directory. Fixes #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:25:57 +02:00
co-authored by Claude Opus 5.5
parent cc21056ed3
commit 21b7007d28
10 changed files with 270 additions and 49 deletions
+1 -1
View File
@@ -48,5 +48,5 @@ paths:
- Custom styles go to a CSS file in `assets/css/` and must be linked with `<Link rel="stylesheet">` in `src/components/header.rs` (order matters: after the vendor files to override them). `style/main.scss` is not linked anywhere and has no effect. Never edit `assets/vendor/` (the one app-owned file there is `assets/vendor/css/control.css`). - Custom styles go to a CSS file in `assets/css/` and must be linked with `<Link rel="stylesheet">` in `src/components/header.rs` (order matters: after the vendor files to override them). `style/main.scss` is not linked anywhere and has no effect. Never edit `assets/vendor/` (the one app-owned file there is `assets/vendor/css/control.css`).
- The admin portal has a light and a dark theme (`src/components/theme.rs`, switch in the navbar: auto / light / dark, stored in the `theme` cookie). Dark colours are overrides in `assets/css/dark.css`, which `AdminPortal` links with a `media` attribute — the public page and the login page never load it. When you add or restyle something in the admin portal, check it in both themes and add the dark override there; don't hard-code light colours (`bg-light`, `bg-white`, inline `color`) in admin views. - The admin portal has a light and a dark theme (`src/components/theme.rs`, switch in the navbar: auto / light / dark, stored in the `theme` cookie). Dark colours are overrides in `assets/css/dark.css`, which `AdminPortal` links with a `media` attribute — the public page and the login page never load it. When you add or restyle something in the admin portal, check it in both themes and add the dark override there; don't hard-code light colours (`bg-light`, `bg-white`, inline `color`) in admin views.
- Charts are drawn on the server, so the chart server fns take a `dark` flag from `Theme::is_dark()`. - Charts are drawn on the server, so the chart server fns take a `dark` flag from `Theme::is_dark()`.
- The public page banner style is `assets/data/banner.css`, rewritten at runtime by the appearance upload (`/*bg-img*/` marker) — keep that marker / the `background-image: url('...')` form intact. - The public page banner style template is `assets/banner.css`. On start it is copied to the data directory, and the appearance upload generates `banner-<random>.css` from it by replacing the `/*bg-img*/` marker — keep that marker intact.
- Stylesheets and meta tags are declared in `src/components/header.rs`. - Stylesheets and meta tags are declared in `src/components/header.rs`.
+1
View File
@@ -18,4 +18,5 @@ playwright/.cache/
/.settings/ /.settings/
/.vscode/ /.vscode/
/config.toml /config.toml
/data/
/html/ /html/
+2 -1
View File
@@ -32,7 +32,8 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[
- Migrations in `migrations/` are embedded by `sqlx::migrate!()` and run on startup. - Migrations in `migrations/` are embedded by `sqlx::migrate!()` and run on startup.
- First start seeds an `admin` / `admin` user, a placeholder company, empty mail templates and an appearance row (`create_admin`, `check_company`, `check_messages`, `check_appearance` in `src/main.rs`). - First start seeds an `admin` / `admin` user, a placeholder company, empty mail templates and an appearance row (`create_admin`, `check_company`, `check_messages`, `check_appearance` in `src/main.rs`).
- With `transport = "File"` e-mails are written as `.eml` into `Maildir/` instead of being sent. - With `transport = "File"` e-mails are written as `.eml` into `Maildir/` instead of being sent.
- `assets/` is copied to `target/site` on build. `assets/data/` (banner.css, uploaded banner) is untracked but required: the page links `/data/<css_name>` and uploads are written to `target/site/data/` at runtime, relative to the working directory. A rebuild wipes `target/site`, so runtime uploads there are lost in dev. - `assets/` is copied to `target/site` on build and served as static files.
- Files created at runtime (uploaded banner, generated banner CSS) go to the data directory: `data/` in the working directory by default (gitignored), `[storage] data_dir` in the config, `/app/data` volume in Docker. It is served under `/data` and survives rebuilds. On start the server copies the `banner.css` template there from the site root and regenerates a missing banner style. Server code gets the path with `get_data_dir()`; never write into `target/site`.
## Layout ## Layout
+4
View File
@@ -51,5 +51,9 @@ EXPOSE 3000
RUN mkdir -p /app/config RUN mkdir -p /app/config
VOLUME /app/config VOLUME /app/config
# Uploaded banner and generated styles. Keep this volume when the container is replaced.
RUN mkdir -p /app/data
VOLUME /app/data
# Run the server # Run the server
CMD ["/app/rezervator", "-c", "/app/config/config.toml"] CMD ["/app/rezervator", "-c", "/app/config/config.toml"]
+8 -2
View File
@@ -27,11 +27,17 @@ The server reads `config.toml` from the working directory, or from the path give
The file contains the database and SMTP passwords and the session key. Keep it outside the site directory (`target/site`): everything in there is served as static files. The server refuses to start when the config file is inside it. The file contains the database and SMTP passwords and the session key. Keep it outside the site directory (`target/site`): everything in there is served as static files. The server refuses to start when the config file is inside it.
## Data directory
Files created while the application runs (the uploaded banner and the styles generated for it) are stored in the data directory and served under `/data`. By default it is `data` in the working directory; change it with `data_dir` in the `[storage]` section of the config. Keep this directory when you upgrade the application and include it in backups. Never put the config file into it.
When upgrading from a version that kept these files in `target/site/data`, move the banner image to the data directory. The style for it is generated again on start.
## Running in Docker ## Running in Docker
Mount the config file to `/app/config/config.toml`: Mount the config file to `/app/config/config.toml` and a persistent volume for the data directory to `/app/data`:
`docker run -p 3000:3000 -v /path/to/config-dir:/app/config rezervator` `docker run -p 3000:3000 -v /path/to/config-dir:/app/config -v rezervator-data:/app/data rezervator`
Set `bind_ip = '0.0.0.0'` in the config so the server is reachable from outside the container. Set `bind_ip = '0.0.0.0'` in the config so the server is reachable from outside the container.
+5
View File
@@ -24,5 +24,10 @@ path = "Maildir"
#tls = true #tls = true
#accept_all_certs = true #accept_all_certs = true
# Directory for uploaded files (banner) and generated styles, served under /data.
# Default is "data" in the working directory. Must survive application upgrades.
#[storage]
#data_dir = "data"
[logging] [logging]
severity = "debug" severity = "debug"
+169 -25
View File
@@ -7,12 +7,13 @@ use crate::components::data_form::ForValidation;
cfg_if! { if #[cfg(feature = "ssr")] { cfg_if! { if #[cfg(feature = "ssr")] {
use std::fs; use std::fs;
use std::path::Path;
use actix_web::{post, Responder}; use actix_web::{post, Responder};
use actix_multipart::Multipart; use actix_multipart::Multipart;
use actix_session::Session; use actix_session::Session;
use actix_web::web::Redirect; use actix_web::web::Redirect;
use std::fs::File; use std::fs::File;
use std::io::{Write, Read}; use std::io::Write;
use futures_util::{StreamExt, TryStreamExt}; use futures_util::{StreamExt, TryStreamExt};
use crate::backend::data::User; use crate::backend::data::User;
use crate::error::AppError; use crate::error::AppError;
@@ -23,8 +24,44 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use regex::Regex; use regex::Regex;
use rand::Rng; use rand::Rng;
use rand::distributions::Alphanumeric; use rand::distributions::Alphanumeric;
use log::{info, warn};
pub async fn check_appearance(pool: &PgPool) -> Result<(), AppError> { /// Style of the public page banner. The template is shipped with the application,
/// the copy in the data directory is the base for generated styles.
const BANNER_CSS: &str = "banner.css";
const BANNER_EXTENSIONS: [&str; 5] = ["jpg", "jpeg", "png", "gif", "webp"];
/// Turns the file name sent by the browser into a safe name inside the data directory.
/// Returns `None` when the file is not an image that can be used as a banner.
pub fn banner_file_name(name: &str) -> Option<String> {
// Only the last path segment, whatever separator the client used
let name = name.rsplit(['/', '\\']).next().unwrap_or_default();
let (stem, extension) = name.rsplit_once('.')?;
if stem.is_empty() || !BANNER_EXTENSIONS.contains(&extension.to_lowercase().as_str()) {
return None
}
let stem = stem.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect::<String>();
Some(format!("{}.{}", stem, extension.to_lowercase()))
}
/// Creates the data directory and puts the banner style template into it, if it is not there yet
pub fn ensure_data_dir(data_dir: &Path, site_root: &Path) -> Result<(), AppError> {
fs::create_dir_all(data_dir)?;
if !data_dir.join(BANNER_CSS).exists() {
info!("Copying {} to data directory {}", BANNER_CSS, data_dir.display());
fs::copy(site_root.join(BANNER_CSS), data_dir.join(BANNER_CSS))?;
}
Ok(())
}
pub async fn check_appearance(pool: &PgPool, data_dir: &Path) -> Result<(), AppError> {
let count: (i64,) = query_as("SELECT COUNT(id) FROM appearance") let count: (i64,) = query_as("SELECT COUNT(id) FROM appearance")
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
@@ -37,7 +74,22 @@ cfg_if! { if #[cfg(feature = "ssr")] {
let app = query_as::<_, Appearance>("SELECT * FROM appearance").fetch_one(pool).await?; let app = query_as::<_, Appearance>("SELECT * FROM appearance").fetch_one(pool).await?;
if let None = app.css_name { if let None = app.css_name {
query("UPDATE appearance SET css_name = 'banner.css'").execute(pool).await?; query("UPDATE appearance SET css_name = $1").bind(BANNER_CSS).execute(pool).await?;
}
// The generated style can be missing, e.g. when the data directory was not kept during an upgrade
let css_name = app.css_name.unwrap_or(BANNER_CSS.to_string());
if !data_dir.join(&css_name).exists() {
match app.banner {
Some(banner) if data_dir.join(&banner).exists() => {
warn!("Banner style {} is missing, generating it again", css_name);
modify_style(&banner, pool, data_dir).await?;
}
_ => {
warn!("Banner style {} is missing, using default style", css_name);
query("UPDATE appearance SET css_name = $1").bind(BANNER_CSS).execute(pool).await?;
}
}
} }
Ok(()) Ok(())
@@ -52,10 +104,8 @@ cfg_if! { if #[cfg(feature = "ssr")] {
Ok(()) Ok(())
} }
async fn modify_style(file_name: &str, pool: &PgPool) -> Result<(), AppError> { async fn modify_style(file_name: &str, pool: &PgPool, data_dir: &Path) -> Result<(), AppError> {
let mut css_file = File::open("target/site/data/banner.css")?; let mut css_str = fs::read_to_string(data_dir.join(BANNER_CSS))?;
let mut css_str= String::new();
css_file.read_to_string(&mut css_str)?;
if css_str.contains("/*bg-img*/") { if css_str.contains("/*bg-img*/") {
css_str = css_str.replace("/*bg-img*/", &format!("background-image: url('{}');", file_name)); css_str = css_str.replace("/*bg-img*/", &format!("background-image: url('{}');", file_name));
@@ -64,12 +114,15 @@ cfg_if! { if #[cfg(feature = "ssr")] {
css_str = re.replace(&css_str, &format!("background-image: url('{}')", file_name)).to_string(); css_str = re.replace(&css_str, &format!("background-image: url('{}')", file_name)).to_string();
} }
let old_css: (String,) = query_as("SELECT css_name FROM appearance") let old_css: (Option<String>,) = query_as("SELECT css_name FROM appearance")
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
if old_css.0 != "banner.css" { if let Some(old_css) = old_css.0 {
fs::remove_file(format!("target/site/data/{}", old_css.0))?; if old_css != BANNER_CSS {
// The old style may be already gone
let _ = fs::remove_file(data_dir.join(old_css));
}
} }
let s: String = rand::thread_rng() let s: String = rand::thread_rng()
@@ -78,7 +131,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
.map(char::from) .map(char::from)
.collect(); .collect();
let css_name = format!("banner-{}.css", s); let css_name = format!("banner-{}.css", s);
let mut css_file = File::create(format!("target/site/data/{}", css_name))?; let mut css_file = File::create(data_dir.join(&css_name))?;
css_file.write_all(css_str.as_bytes())?; css_file.write_all(css_str.as_bytes())?;
query("UPDATE appearance SET css_name = $1") query("UPDATE appearance SET css_name = $1")
.bind(css_name) .bind(css_name)
@@ -88,6 +141,30 @@ cfg_if! { if #[cfg(feature = "ssr")] {
Ok(()) Ok(())
} }
async fn save_banner(field: &mut actix_multipart::Field, file_name: &str, app_data: &AppData) -> Result<(), AppError> {
let data_dir = app_data.data_dir();
let old_banner: (Option<String>,) = query_as("SELECT banner FROM appearance")
.fetch_one(app_data.db_pool())
.await?;
let mut file = File::create(data_dir.join(file_name))?;
while let Some(chunk) = field.next().await {
let chunk = chunk.map_err(|e| AppError::FileIOError(e.to_string()))?;
file.write_all(&chunk)?;
}
set_banner_name(file_name, app_data.db_pool()).await?;
modify_style(file_name, app_data.db_pool(), data_dir).await?;
if let Some(old_banner) = old_banner.0 {
if old_banner != file_name {
let _ = fs::remove_file(data_dir.join(old_banner));
}
}
Ok(())
}
#[post("/upload")] #[post("/upload")]
pub async fn upload(mut data: Multipart, session: Session, app_data: Data<AppData>) -> impl Responder { pub async fn upload(mut data: Multipart, session: Session, app_data: Data<AppData>) -> impl Responder {
let user = session.get::<User>("user").unwrap_or(None); let user = session.get::<User>("user").unwrap_or(None);
@@ -103,21 +180,23 @@ cfg_if! { if #[cfg(feature = "ssr")] {
} }
if let Ok(Some(mut field)) = data.try_next().await { if let Ok(Some(mut field)) = data.try_next().await {
let content_disp = field.content_disposition(); let sent_name = field.content_disposition()
let file_name = content_disp.unwrap().get_filename().unwrap().to_string(); .and_then(|cd| cd.get_filename())
.unwrap_or_default()
.to_string();
if file_name.is_empty() { match banner_file_name(&sent_name) {
return Redirect::to("/admin/appearance").see_other(); Some(file_name) => {
if let Err(e) = save_banner(&mut field, &file_name, &app_data).await {
warn!("Banner {} not saved: {}", file_name, e);
}
}
None => {
if !sent_name.is_empty() {
warn!("Uploaded file {:?} is not a supported image", sent_name);
}
}
} }
let mut file = File::create(format!("target/site/data/{}", file_name)).unwrap();
let _name = field.name();
while let Some(chunk) = field.next().await {
let c = chunk.unwrap();
let _ = file.write_all(&c);
}
let _ = set_banner_name(&file_name, &app_data.db_pool).await;
let _ = modify_style(&file_name, &app_data.db_pool).await;
} }
Redirect::to("/admin/appearance").see_other() Redirect::to("/admin/appearance").see_other()
@@ -163,11 +242,13 @@ impl ForValidation for UpdateAppearance {
pub async fn delete_banner() -> Result<ApiResponse<()>, ServerFnError> { pub async fn delete_banner() -> Result<ApiResponse<()>, ServerFnError> {
use std::fs; use std::fs;
use crate::perm_check; use crate::perm_check;
use crate::backend::get_data_dir;
perm_check!(is_admin); perm_check!(is_admin);
let appearance = get_appearance().await?; let appearance = get_appearance().await?;
let pool = get_pool().await?; let pool = get_pool().await?;
let data_dir = get_data_dir().await?;
query("UPDATE appearance SET banner = $1 WHERE id = $2") query("UPDATE appearance SET banner = $1 WHERE id = $2")
.bind(None::<String>) .bind(None::<String>)
.bind(appearance.id()) .bind(appearance.id())
@@ -175,8 +256,71 @@ pub async fn delete_banner() -> Result<ApiResponse<()>, ServerFnError> {
.await?; .await?;
if let Some(f) = appearance.banner { if let Some(f) = appearance.banner {
fs::remove_file(format!("target/site/data/{}", f))?; // The file may be already gone
let _ = fs::remove_file(data_dir.join(f));
} }
Ok(ApiResponse::Data(())) Ok(ApiResponse::Data(()))
} }
#[cfg(all(test, feature = "ssr"))]
mod tests {
use super::*;
#[test]
fn banner_file_name_keeps_simple_names() {
assert_eq!(banner_file_name("banner1.jpg"), Some("banner1.jpg".to_string()));
assert_eq!(banner_file_name("my-banner_2.PNG"), Some("my-banner_2.png".to_string()));
}
#[test]
fn banner_file_name_strips_directories() {
assert_eq!(banner_file_name("../../etc/banner.jpg"), Some("banner.jpg".to_string()));
assert_eq!(banner_file_name("/etc/banner.jpg"), Some("banner.jpg".to_string()));
assert_eq!(banner_file_name("C:\\Users\\joe\\banner.jpg"), Some("banner.jpg".to_string()));
assert_eq!(banner_file_name("..\\..\\banner.jpg"), Some("banner.jpg".to_string()));
}
#[test]
fn banner_file_name_replaces_unsafe_characters() {
assert_eq!(banner_file_name("můj banner (1).jpg"), Some("m_j_banner__1_.jpg".to_string()));
assert_eq!(banner_file_name("a'b\"c.gif"), Some("a_b_c.gif".to_string()));
assert_eq!(banner_file_name("archive.tar.webp"), Some("archive_tar.webp".to_string()));
}
#[test]
fn banner_file_name_rejects_other_files() {
for name in ["", "banner", "banner.css", "config.toml", "index.html", "script.js", "image.svg", ".jpg", "..", "dir/"] {
assert_eq!(banner_file_name(name), None, "name: {:?}", name);
}
}
#[test]
fn ensure_data_dir_copies_template_once() {
let base = std::env::temp_dir().join(format!("rezervator-test-data-{}", std::process::id()));
let site_root = base.join("site");
let data_dir = base.join("data/nested");
fs::create_dir_all(&site_root).unwrap();
fs::write(site_root.join(BANNER_CSS), "template").unwrap();
ensure_data_dir(&data_dir, &site_root).unwrap();
assert_eq!(fs::read_to_string(data_dir.join(BANNER_CSS)).unwrap(), "template");
// An existing file is not overwritten
fs::write(data_dir.join(BANNER_CSS), "customized").unwrap();
ensure_data_dir(&data_dir, &site_root).unwrap();
assert_eq!(fs::read_to_string(data_dir.join(BANNER_CSS)).unwrap(), "customized");
fs::remove_dir_all(base).unwrap();
}
#[test]
fn ensure_data_dir_fails_without_template() {
let base = std::env::temp_dir().join(format!("rezervator-test-notemplate-{}", std::process::id()));
fs::create_dir_all(base.join("site")).unwrap();
assert!(ensure_data_dir(&base.join("data"), &base.join("site")).is_err());
fs::remove_dir_all(base).unwrap();
}
}
+16 -3
View File
@@ -56,21 +56,29 @@ cfg_if!{
use leptos_actix::extract; use leptos_actix::extract;
use leptos::prelude::ServerFnError; use leptos::prelude::ServerFnError;
use crate::backend::mail::Mailer; use crate::backend::mail::Mailer;
use std::path::PathBuf;
#[derive(Clone)] #[derive(Clone)]
pub struct AppData { pub struct AppData {
db_pool: PgPool, db_pool: PgPool,
mailer: Mailer mailer: Mailer,
data_dir: PathBuf
} }
impl AppData { impl AppData {
pub fn new(db_pool: PgPool, mailer: Mailer) -> Self { pub fn new(db_pool: PgPool, mailer: Mailer, data_dir: PathBuf) -> Self {
Self { Self {
db_pool, db_pool,
mailer mailer,
data_dir
} }
} }
/// Directory for files created at runtime, served under `/data`
pub fn data_dir(&self) -> &PathBuf {
&self.data_dir
}
pub fn db_pool(&self) -> &PgPool { pub fn db_pool(&self) -> &PgPool {
&self.db_pool &self.db_pool
} }
@@ -85,6 +93,11 @@ cfg_if!{
Ok(data.db_pool().clone()) Ok(data.db_pool().clone())
} }
pub async fn get_data_dir() -> Result<PathBuf, ServerFnError> {
let data = extract::<Data<AppData>>().await?;
Ok(data.data_dir().clone())
}
pub async fn get_mailing() -> Result<Mailer, ServerFnError> { pub async fn get_mailing() -> Result<Mailer, ServerFnError> {
let data = extract::<Data<AppData>>().await?; let data = extract::<Data<AppData>>().await?;
Ok(data.mailer().clone()) Ok(data.mailer().clone())
+42 -6
View File
@@ -3,7 +3,7 @@ use std::fs::read_to_string;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::net::SocketAddr; use std::net::SocketAddr;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::path::Path; use std::path::{Path, PathBuf};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::str::FromStr; use std::str::FromStr;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -122,6 +122,21 @@ impl Logging {
} }
} }
/// Where files created at runtime (uploaded banner, generated CSS) are kept
#[cfg(feature = "ssr")]
#[derive(Deserialize, Default)]
pub struct Storage {
data_dir: Option<String>
}
#[cfg(feature = "ssr")]
impl Storage {
/// Directory served under `/data`. Defaults to `data` in the working directory.
pub fn data_dir(&self) -> PathBuf {
PathBuf::from(self.data_dir.as_deref().unwrap_or("data"))
}
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Configuration { pub struct Configuration {
@@ -129,7 +144,9 @@ pub struct Configuration {
network: Network, network: Network,
database: Database, database: Database,
mailing: Mailing, mailing: Mailing,
logging: Logging logging: Logging,
#[serde(default)]
storage: Storage
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -149,13 +166,16 @@ impl Configuration {
pub fn logging(&self) -> &Logging { pub fn logging(&self) -> &Logging {
&self.logging &self.logging
} }
pub fn storage(&self) -> &Storage {
&self.storage
}
} }
/// The config file holds passwords and the session key, so it must never lie in the directory /// The config file holds passwords and the session key, so it must never lie in a directory
/// that is served as static files. Returns true when `config_path` is inside `site_root`. /// that is served as static files. Returns true when `config_path` is inside `public_dir`.
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub fn is_publicly_served(config_path: &Path, site_root: &Path) -> bool { pub fn is_publicly_served(config_path: &Path, public_dir: &Path) -> bool {
match (config_path.canonicalize(), site_root.canonicalize()) { match (config_path.canonicalize(), public_dir.canonicalize()) {
(Ok(config), Ok(root)) => config.starts_with(root), (Ok(config), Ok(root)) => config.starts_with(root),
_ => false _ => false
} }
@@ -224,6 +244,22 @@ mod tests {
std::fs::remove_dir_all(base).unwrap(); std::fs::remove_dir_all(base).unwrap();
} }
#[test]
fn data_dir_defaults_to_data() {
// The sample config has the storage section commented out
assert_eq!(sample().storage().data_dir(), PathBuf::from("data"));
let storage: Storage = toml::from_str("").unwrap();
assert_eq!(storage.data_dir(), PathBuf::from("data"));
}
#[test]
fn data_dir_can_be_configured() {
let storage: Storage = toml::from_str("data_dir = '/var/lib/rezervator'").unwrap();
assert_eq!(storage.data_dir(), PathBuf::from("/var/lib/rezervator"));
}
#[test] #[test]
fn sample_session_key_is_long_enough() { fn sample_session_key_is_long_enough() {
// actix_web::cookie::Key::from panics on keys shorter than 64 bytes // actix_web::cookie::Key::from panics on keys shorter than 64 bytes
+21 -10
View File
@@ -4,7 +4,7 @@
use actix_web::middleware::Logger; use actix_web::middleware::Logger;
use leptos_captcha::spow::pow::Pow; use leptos_captcha::spow::pow::Pow;
use log::error; use log::error;
use rezervator::backend::appearance::check_appearance; use rezervator::backend::appearance::{check_appearance, ensure_data_dir};
use rezervator::backend::company::check_company; use rezervator::backend::company::check_company;
use rezervator::backend::mail::{check_messages, Mailer}; use rezervator::backend::mail::{check_messages, Mailer};
use rezervator::backend::user::create_admin; use rezervator::backend::user::create_admin;
@@ -56,13 +56,22 @@ async fn main() -> std::io::Result<()> {
let conf = get_configuration(None).unwrap(); let conf = get_configuration(None).unwrap();
// The site root is served as static files, the config with passwords must not be there // Files created at runtime (banner, generated styles) live outside the site root, so they survive upgrades
if is_publicly_served(Path::new(cfg_path.trim()), Path::new(&*conf.leptos_options.site_root)) { let data_dir = srv_conf.storage().data_dir();
let msg = format!( if let Err(e) = ensure_data_dir(&data_dir, Path::new(&*conf.leptos_options.site_root)) {
"Config file {} is inside the publicly served directory {}. Move it elsewhere and change the passwords and the session key in it.", error!("Cannot prepare data directory {}: {}", data_dir.display(), e);
cfg_path.trim(), conf.leptos_options.site_root); return Err(std::io::Error::other(e));
error!("{}", msg); }
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg));
// The site root and the data directory are served as static files, the config with passwords must not be there
for public_dir in [Path::new(&*conf.leptos_options.site_root), data_dir.as_path()] {
if is_publicly_served(Path::new(cfg_path.trim()), public_dir) {
let msg = format!(
"Config file {} is inside the publicly served directory {}. Move it elsewhere and change the passwords and the session key in it.",
cfg_path.trim(), public_dir.display());
error!("{}", msg);
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg));
}
} }
let key = Key::from(srv_conf.session().key()); let key = Key::from(srv_conf.session().key());
@@ -84,7 +93,7 @@ async fn main() -> std::io::Result<()> {
if let Err(e) = check_messages(&pool).await { if let Err(e) = check_messages(&pool).await {
error!("Error while checking messages: {:?}", e); error!("Error while checking messages: {:?}", e);
} }
if let Err(e) = check_appearance(&pool).await { if let Err(e) = check_appearance(&pool, &data_dir).await {
error!("Error while checking messages: {:?}", e); error!("Error while checking messages: {:?}", e);
} }
@@ -95,7 +104,7 @@ async fn main() -> std::io::Result<()> {
let site_root = leptos_options.site_root.clone().to_string(); let site_root = leptos_options.site_root.clone().to_string();
App::new() App::new()
.app_data(Data::new(AppData::new(pool.clone(), mailer.clone()))) .app_data(Data::new(AppData::new(pool.clone(), mailer.clone(), data_dir.clone())))
.wrap(Logger::default()) .wrap(Logger::default())
.wrap(Authentication) .wrap(Authentication)
.wrap(SessionMiddleware::new( .wrap(SessionMiddleware::new(
@@ -123,6 +132,8 @@ async fn main() -> std::io::Result<()> {
}) })
.app_data(Data::new(leptos_options.to_owned())) .app_data(Data::new(leptos_options.to_owned()))
.service(appearance::upload) .service(appearance::upload)
// Runtime files first, the site root would shadow them
.service(Files::new("/data", data_dir.clone()))
.service(Files::new("/", site_root)) .service(Files::new("/", site_root))
.wrap(middleware::Compress::default()) .wrap(middleware::Compress::default())
}) })