feat: add base_path support for mounting under a URL prefix
Wraps the router in axum's `nest` when BOOK_BASE_PATH is set, prepends
the prefix to form actions and asset URLs, and exposes a {{base}}
template placeholder. basePath option added to the NixOS module.
This commit is contained in:
parent
b784f4dd9c
commit
6ca40e2321
9 changed files with 251 additions and 14 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -470,7 +470,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guestbook"
|
name = "guestbook"
|
||||||
version = "0.2.10"
|
version = "0.2.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "guestbook"
|
name = "guestbook"
|
||||||
version = "0.2.10"
|
version = "0.2.11"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "A configurable, self-hosted guestbook for the web, allowing visitors to leave behind messages, drawings, and voice notes, with spam-prevention and moderation via Telegram bot."
|
description = "A configurable, self-hosted guestbook for the web, allowing visitors to leave behind messages, drawings, and voice notes, with spam-prevention and moderation via Telegram bot."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -191,15 +191,21 @@ Running `guestbook` with no env vars will give you a working guestbook on `local
|
||||||
# Message textarea height in pixels.
|
# Message textarea height in pixels.
|
||||||
# BOOK_TEXTAREA_HEIGHT=150
|
# BOOK_TEXTAREA_HEIGHT=150
|
||||||
|
|
||||||
# Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders.
|
# Custom HTML template file with {{title}}, {{form}}, {{entries}}, {{style}}, and {{base}} placeholders.
|
||||||
# Uses built-in default if unset.
|
# Uses built-in default if unset.
|
||||||
# BOOK_TEMPLATE=./templates/default.html
|
# BOOK_TEMPLATE=./templates/default.html
|
||||||
|
|
||||||
# Custom success page template shown after a successful submission.
|
# Custom success page template shown after a successful submission.
|
||||||
# Supports {{title}} and {{style}} placeholders. Use <script> for dynamic behavior.
|
# Supports {{title}}, {{style}}, and {{base}} placeholders. Use <script> for dynamic behavior.
|
||||||
# Uses built-in templates/success.html if unset.
|
# Uses built-in templates/success.html if unset.
|
||||||
# BOOK_SUCCESS_TEMPLATE=./templates/success.html
|
# BOOK_SUCCESS_TEMPLATE=./templates/success.html
|
||||||
|
|
||||||
|
# URL prefix the guestbook is mounted at. Empty serves at the domain root.
|
||||||
|
# When set (e.g. /guestbook), all routes (/, /submit, /drawings/*, /voice_notes/*)
|
||||||
|
# are mounted under the prefix, and form actions and asset URLs include it.
|
||||||
|
# Templates can interpolate the prefix with the {{base}} placeholder.
|
||||||
|
# BOOK_BASE_PATH=
|
||||||
|
|
||||||
# Enable drawing canvas in submission form. Drawings are stored as PNG files in DATA_DIR/drawings/.
|
# Enable drawing canvas in submission form. Drawings are stored as PNG files in DATA_DIR/drawings/.
|
||||||
# BOOK_ENABLE_DRAWINGS=false
|
# BOOK_ENABLE_DRAWINGS=false
|
||||||
|
|
||||||
|
|
@ -430,6 +436,8 @@ entered into the 'message' field.
|
||||||
entries - Approved guestbook entries, newest first.
|
entries - Approved guestbook entries, newest first.
|
||||||
style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in
|
style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in
|
||||||
a <style> tag. Uses built-in default.css when neither is set.
|
a <style> tag. Uses built-in default.css when neither is set.
|
||||||
|
base - URL prefix the guestbook is mounted at (BOOK_BASE_PATH).
|
||||||
|
Empty when serving at the domain root.
|
||||||
|
|
||||||
See default.css for available CSS classes on rendered elements.
|
See default.css for available CSS classes on rendered elements.
|
||||||
-->
|
-->
|
||||||
|
|
@ -468,6 +476,7 @@ entered into the 'message' field.
|
||||||
|
|
||||||
title - Site title (BOOK_SITE_TITLE).
|
title - Site title (BOOK_SITE_TITLE).
|
||||||
style - Custom CSS (same as the main template).
|
style - Custom CSS (same as the main template).
|
||||||
|
base - URL prefix the guestbook is mounted at (BOOK_BASE_PATH).
|
||||||
|
|
||||||
Everything else is static — write whatever you want. Use <script> for
|
Everything else is static — write whatever you want. Use <script> for
|
||||||
dynamic behavior like showing the current time.
|
dynamic behavior like showing the current time.
|
||||||
|
|
@ -483,7 +492,7 @@ entered into the 'message' field.
|
||||||
<body>
|
<body>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<p>Thanks! Your message is pending approval.</p>
|
<p>Thanks! Your message is pending approval.</p>
|
||||||
<p><a href="/">← back</a></p>
|
<p><a href="{{base}}/">← back</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
17
module.nix
17
module.nix
|
|
@ -30,6 +30,18 @@ in
|
||||||
description = "Site title shown in nav and page title.";
|
description = "Site title shown in nav and page title.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
basePath = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "";
|
||||||
|
example = "/guestbook";
|
||||||
|
description = ''
|
||||||
|
URL prefix the guestbook is mounted at. Empty serves at the domain root.
|
||||||
|
When set, all routes (/, /submit, /drawings/*, /voice_notes/*) are
|
||||||
|
mounted under the prefix, and form actions and asset URLs include it.
|
||||||
|
Templates can interpolate the prefix with the {{base}} placeholder.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
user = mkOption {
|
user = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
default = "guestbook";
|
default = "guestbook";
|
||||||
|
|
@ -268,13 +280,13 @@ in
|
||||||
templateFile = mkOption {
|
templateFile = mkOption {
|
||||||
type = types.nullOr types.path;
|
type = types.nullOr types.path;
|
||||||
default = null;
|
default = null;
|
||||||
description = "Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders. Uses built-in default if null.";
|
description = "Custom HTML template file with {{title}}, {{form}}, {{entries}}, {{style}}, and {{base}} placeholders. Uses built-in default if null.";
|
||||||
};
|
};
|
||||||
|
|
||||||
successTemplateFile = mkOption {
|
successTemplateFile = mkOption {
|
||||||
type = types.nullOr types.path;
|
type = types.nullOr types.path;
|
||||||
default = null;
|
default = null;
|
||||||
description = "Custom success page template with {{title}} and {{style}} placeholders. Uses built-in default if null.";
|
description = "Custom success page template with {{title}}, {{style}}, and {{base}} placeholders. Uses built-in default if null.";
|
||||||
};
|
};
|
||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
|
|
@ -380,6 +392,7 @@ in
|
||||||
BOOK_CONTENT_REQUIRED = if cfg.content.required then "true" else "false";
|
BOOK_CONTENT_REQUIRED = if cfg.content.required then "true" else "false";
|
||||||
BOOK_TEXTAREA_WIDTH = toString cfg.styles.message.width;
|
BOOK_TEXTAREA_WIDTH = toString cfg.styles.message.width;
|
||||||
BOOK_TEXTAREA_HEIGHT = toString cfg.styles.message.height;
|
BOOK_TEXTAREA_HEIGHT = toString cfg.styles.message.height;
|
||||||
|
BOOK_BASE_PATH = cfg.basePath;
|
||||||
} // lib.optionalAttrs (cfg.styles.cssFile != null) {
|
} // lib.optionalAttrs (cfg.styles.cssFile != null) {
|
||||||
BOOK_STYLE_FILE = cfg.styles.cssFile;
|
BOOK_STYLE_FILE = cfg.styles.cssFile;
|
||||||
} // lib.optionalAttrs (cfg.styles.templateFile != null) {
|
} // lib.optionalAttrs (cfg.styles.templateFile != null) {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ pub struct Config {
|
||||||
pub voice_note_record_text: String,
|
pub voice_note_record_text: String,
|
||||||
pub textarea_width: u32,
|
pub textarea_width: u32,
|
||||||
pub textarea_height: u32,
|
pub textarea_height: u32,
|
||||||
|
pub base_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
|
@ -206,10 +207,22 @@ impl Config {
|
||||||
.unwrap_or_else(|_| "150".into())
|
.unwrap_or_else(|_| "150".into())
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| "BOOK_TEXTAREA_HEIGHT must be a number")?,
|
.map_err(|_| "BOOK_TEXTAREA_HEIGHT must be a number")?,
|
||||||
|
base_path: normalise_base_path(env::var("BOOK_BASE_PATH").unwrap_or_default()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalise_base_path(raw: String) -> String {
|
||||||
|
let trimmed = raw.trim().trim_end_matches('/');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else if trimmed.starts_with('/') {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("/{trimmed}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -367,4 +380,54 @@ mod tests {
|
||||||
let config = Config::from_env().unwrap();
|
let config = Config::from_env().unwrap();
|
||||||
assert!(config.success_template.is_none());
|
assert!(config.success_template.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_base_path_default_empty() {
|
||||||
|
let _lock = ENV_LOCK.lock().unwrap();
|
||||||
|
env::remove_var("BOOK_BASE_PATH");
|
||||||
|
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
|
||||||
|
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
|
||||||
|
|
||||||
|
let config = Config::from_env().unwrap();
|
||||||
|
assert_eq!(config.base_path, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_base_path_from_env() {
|
||||||
|
let _lock = ENV_LOCK.lock().unwrap();
|
||||||
|
env::set_var("BOOK_BASE_PATH", "/guestbook");
|
||||||
|
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
|
||||||
|
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
|
||||||
|
|
||||||
|
let config = Config::from_env().unwrap();
|
||||||
|
assert_eq!(config.base_path, "/guestbook");
|
||||||
|
|
||||||
|
env::remove_var("BOOK_BASE_PATH");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_base_path_normalises_trailing_slash() {
|
||||||
|
let _lock = ENV_LOCK.lock().unwrap();
|
||||||
|
env::set_var("BOOK_BASE_PATH", "/guestbook/");
|
||||||
|
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
|
||||||
|
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
|
||||||
|
|
||||||
|
let config = Config::from_env().unwrap();
|
||||||
|
assert_eq!(config.base_path, "/guestbook");
|
||||||
|
|
||||||
|
env::remove_var("BOOK_BASE_PATH");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_base_path_normalises_missing_leading_slash() {
|
||||||
|
let _lock = ENV_LOCK.lock().unwrap();
|
||||||
|
env::set_var("BOOK_BASE_PATH", "guestbook");
|
||||||
|
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
|
||||||
|
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
|
||||||
|
|
||||||
|
let config = Config::from_env().unwrap();
|
||||||
|
assert_eq!(config.base_path, "/guestbook");
|
||||||
|
|
||||||
|
env::remove_var("BOOK_BASE_PATH");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html
|
||||||
.replace("{{form}}", form_html)
|
.replace("{{form}}", form_html)
|
||||||
.replace("{{entries}}", &entries_html)
|
.replace("{{entries}}", &entries_html)
|
||||||
.replace("{{style}}", &style)
|
.replace("{{style}}", &style)
|
||||||
|
.replace("{{base}}", &config.base_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_form(config: &Config) -> String {
|
pub fn render_form(config: &Config) -> String {
|
||||||
|
|
@ -170,13 +171,14 @@ pub fn render_form(config: &Config) -> String {
|
||||||
};
|
};
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
r#"<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
|
r#"<form class="guestbook-form" method="post" action="{base}/submit" accept-charset="UTF-8">
|
||||||
<label class="guestbook-label" for="name">{label_name}</label>
|
<label class="guestbook-label" for="name">{label_name}</label>
|
||||||
<input class="guestbook-input" id="name" name="name" required>
|
<input class="guestbook-input" id="name" name="name" required>
|
||||||
{website_section}<label class="guestbook-label" for="message">{label_message}</label>
|
{website_section}<label class="guestbook-label" for="message">{label_message}</label>
|
||||||
<textarea class="guestbook-textarea" id="message" name="message" style="width:{tw}px;height:{th}px"></textarea>
|
<textarea class="guestbook-textarea" id="message" name="message" style="width:{tw}px;height:{th}px"></textarea>
|
||||||
{drawing_section}{voice_note_section}{captcha_section}<input name="url" aria-hidden="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)" tabindex="-1" autocomplete="off"><button class="guestbook-button" type="submit">{button}</button>
|
{drawing_section}{voice_note_section}{captcha_section}<input name="url" aria-hidden="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)" tabindex="-1" autocomplete="off"><button class="guestbook-button" type="submit">{button}</button>
|
||||||
</form>"#,
|
</form>"#,
|
||||||
|
base = config.base_path,
|
||||||
label_name = config.label_name,
|
label_name = config.label_name,
|
||||||
website_section = website_section,
|
website_section = website_section,
|
||||||
label_message = config.label_message,
|
label_message = config.label_message,
|
||||||
|
|
@ -200,6 +202,7 @@ pub fn render_success_page(config: &Config) -> String {
|
||||||
template
|
template
|
||||||
.replace("{{title}}", &config.site_title)
|
.replace("{{title}}", &config.site_title)
|
||||||
.replace("{{style}}", &style)
|
.replace("{{style}}", &style)
|
||||||
|
.replace("{{base}}", &config.base_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_error_page(config: &Config, error: &str) -> String {
|
pub fn render_error_page(config: &Config, error: &str) -> String {
|
||||||
|
|
@ -223,11 +226,12 @@ pub fn render_error_page(config: &Config, error: &str) -> String {
|
||||||
<body>
|
<body>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<p><a href="/">← back</a></p>
|
<p><a href="{base}/">← back</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"#,
|
</html>"#,
|
||||||
title = config.site_title,
|
title = config.site_title,
|
||||||
|
base = config.base_path,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -273,7 +277,8 @@ fn render_entry(entry: &Entry, config: &Config) -> String {
|
||||||
};
|
};
|
||||||
let drawing_html = if !entry.meta.drawing.is_empty() {
|
let drawing_html = if !entry.meta.drawing.is_empty() {
|
||||||
format!(
|
format!(
|
||||||
"<dd class=\"entry-drawing-wrap\"><img class=\"entry-drawing\" src=\"/drawings/{}\" alt=\"Drawing by {}\"></dd>",
|
"<dd class=\"entry-drawing-wrap\"><img class=\"entry-drawing\" src=\"{}/drawings/{}\" alt=\"Drawing by {}\"></dd>",
|
||||||
|
config.base_path,
|
||||||
escape_html(&entry.meta.drawing),
|
escape_html(&entry.meta.drawing),
|
||||||
escape_html(&entry.meta.name)
|
escape_html(&entry.meta.name)
|
||||||
)
|
)
|
||||||
|
|
@ -282,7 +287,8 @@ fn render_entry(entry: &Entry, config: &Config) -> String {
|
||||||
};
|
};
|
||||||
let voice_note_html = if !entry.meta.voice_note.is_empty() {
|
let voice_note_html = if !entry.meta.voice_note.is_empty() {
|
||||||
format!(
|
format!(
|
||||||
"<dd class=\"entry-voice-note-wrap\"><audio controls preload=\"metadata\" src=\"/voice_notes/{}\"></audio></dd>",
|
"<dd class=\"entry-voice-note-wrap\"><audio controls preload=\"metadata\" src=\"{}/voice_notes/{}\"></audio></dd>",
|
||||||
|
config.base_path,
|
||||||
escape_html(&entry.meta.voice_note)
|
escape_html(&entry.meta.voice_note)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -352,6 +358,7 @@ mod tests {
|
||||||
voice_note_record_text: "record".into(),
|
voice_note_record_text: "record".into(),
|
||||||
textarea_width: 400,
|
textarea_width: 400,
|
||||||
textarea_height: 150,
|
textarea_height: 150,
|
||||||
|
base_path: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -511,6 +518,73 @@ mod tests {
|
||||||
assert!(!html.contains("<script>alert("));
|
assert!(!html.contains("<script>alert("));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_form_uses_base_path_for_submit() {
|
||||||
|
let mut config = test_config();
|
||||||
|
config.base_path = "/guestbook".into();
|
||||||
|
let form = render_form(&config);
|
||||||
|
assert!(form.contains("action=\"/guestbook/submit\""));
|
||||||
|
assert!(!form.contains("action=\"/submit\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_form_default_submit_path_unchanged() {
|
||||||
|
let config = test_config();
|
||||||
|
let form = render_form(&config);
|
||||||
|
assert!(form.contains("action=\"/submit\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_entry_drawing_uses_base_path() {
|
||||||
|
let mut config = test_config();
|
||||||
|
config.base_path = "/guestbook".into();
|
||||||
|
let mut entry = make_entry("alice", "2026-04-09", "");
|
||||||
|
entry.meta.drawing = "abc.png".into();
|
||||||
|
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], "");
|
||||||
|
assert!(html.contains("src=\"/guestbook/drawings/abc.png\""));
|
||||||
|
assert!(!html.contains("src=\"/drawings/abc.png\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_entry_voice_note_uses_base_path() {
|
||||||
|
let mut config = test_config();
|
||||||
|
config.base_path = "/guestbook".into();
|
||||||
|
let mut entry = make_entry("bob", "2026-04-09", "");
|
||||||
|
entry.meta.voice_note = "1.webm".into();
|
||||||
|
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], "");
|
||||||
|
assert!(html.contains("src=\"/guestbook/voice_notes/1.webm\""));
|
||||||
|
assert!(!html.contains("src=\"/voice_notes/1.webm\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_page_substitutes_base_placeholder() {
|
||||||
|
let mut config = test_config();
|
||||||
|
config.base_path = "/guestbook".into();
|
||||||
|
let custom = "<html><a href=\"{{base}}/\">home</a> {{form}} {{entries}} {{style}} {{title}}</html>";
|
||||||
|
let html = render_page(custom, &config, &[], "");
|
||||||
|
assert!(html.contains("href=\"/guestbook/\""));
|
||||||
|
assert!(!html.contains("{{base}}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_success_page_substitutes_base_placeholder() {
|
||||||
|
let mut config = test_config();
|
||||||
|
config.base_path = "/guestbook".into();
|
||||||
|
config.success_template = Some("<a href=\"{{base}}/\">back</a> {{title}} {{style}}".into());
|
||||||
|
let html = render_success_page(&config);
|
||||||
|
assert!(html.contains("href=\"/guestbook/\""));
|
||||||
|
assert!(!html.contains("{{base}}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_error_page_back_link_uses_base_path() {
|
||||||
|
let mut config = test_config();
|
||||||
|
config.base_path = "/guestbook".into();
|
||||||
|
let html = render_error_page(&config, "oops");
|
||||||
|
assert!(html.contains("href=\"/guestbook/\""));
|
||||||
|
assert!(!html.contains("href=\"/\""));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_render_entry_preserves_html_when_injection_enabled() {
|
fn test_render_entry_preserves_html_when_injection_enabled() {
|
||||||
let mut config = test_config();
|
let mut config = test_config();
|
||||||
|
|
|
||||||
79
src/web.rs
79
src/web.rs
|
|
@ -39,11 +39,19 @@ pub struct SubmitForm {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: Arc<AppState>) -> Router {
|
pub fn router(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
let inner: Router<Arc<AppState>> = Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/submit", post(submit))
|
.route("/submit", post(submit))
|
||||||
.route("/drawings/{filename}", get(serve_drawing))
|
.route("/drawings/{filename}", get(serve_drawing))
|
||||||
.route("/voice_notes/{filename}", get(serve_voice_note))
|
.route("/voice_notes/{filename}", get(serve_voice_note));
|
||||||
|
let routed: Router<Arc<AppState>> = if state.config.base_path.is_empty() {
|
||||||
|
inner
|
||||||
|
} else {
|
||||||
|
Router::new()
|
||||||
|
.route(&format!("{}/", state.config.base_path), get(index))
|
||||||
|
.nest(&state.config.base_path, inner)
|
||||||
|
};
|
||||||
|
routed
|
||||||
.layer(DefaultBodyLimit::max(2 * 1024 * 1024))
|
.layer(DefaultBodyLimit::max(2 * 1024 * 1024))
|
||||||
.layer(middleware::from_fn(security_headers))
|
.layer(middleware::from_fn(security_headers))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
|
@ -440,6 +448,7 @@ mod tests {
|
||||||
voice_note_record_text: "record".into(),
|
voice_note_record_text: "record".into(),
|
||||||
textarea_width: 400,
|
textarea_width: 400,
|
||||||
textarea_height: 150,
|
textarea_height: 150,
|
||||||
|
base_path: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -472,6 +481,72 @@ mod tests {
|
||||||
String::from_utf8(bytes.to_vec()).unwrap()
|
String::from_utf8(bytes.to_vec()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_status(app: &Router, uri: &str) -> StatusCode {
|
||||||
|
let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
|
||||||
|
app.clone().oneshot(req).await.unwrap().status()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_form_at(app: &Router, uri: &str, body: &str) -> (StatusCode, String) {
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(uri)
|
||||||
|
.header("content-type", "application/x-www-form-urlencoded")
|
||||||
|
.body(Body::from(body.to_string()))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
let status = resp.status();
|
||||||
|
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
(status, String::from_utf8(bytes.to_vec()).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_base_path_serves_index_at_prefix_with_slash() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut config = test_config(dir.path());
|
||||||
|
config.base_path = "/guestbook".into();
|
||||||
|
let (app, _rx) = test_app(config);
|
||||||
|
assert_eq!(get_status(&app, "/guestbook/").await, StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_base_path_serves_index_at_prefix_no_slash() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut config = test_config(dir.path());
|
||||||
|
config.base_path = "/guestbook".into();
|
||||||
|
let (app, _rx) = test_app(config);
|
||||||
|
assert_eq!(get_status(&app, "/guestbook").await, StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_base_path_root_returns_404() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut config = test_config(dir.path());
|
||||||
|
config.base_path = "/guestbook".into();
|
||||||
|
let (app, _rx) = test_app(config);
|
||||||
|
assert_eq!(get_status(&app, "/").await, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_base_path_accepts_submit_at_prefix() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut config = test_config(dir.path());
|
||||||
|
config.base_path = "/guestbook".into();
|
||||||
|
let (app, _rx) = test_app(config);
|
||||||
|
let (status, body) = post_form_at(&app, "/guestbook/submit", "name=user&message=hi").await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert!(body.contains("pending approval"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_base_path_rejects_submit_at_root() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut config = test_config(dir.path());
|
||||||
|
config.base_path = "/guestbook".into();
|
||||||
|
let (app, _rx) = test_app(config);
|
||||||
|
let (status, _) = post_form_at(&app, "/submit", "name=user&message=hi").await;
|
||||||
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_enable_submissions_shows_form() {
|
async fn test_enable_submissions_shows_form() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
entries - Approved guestbook entries, newest first.
|
entries - Approved guestbook entries, newest first.
|
||||||
style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in
|
style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in
|
||||||
a <style> tag. Uses built-in default.css when neither is set.
|
a <style> tag. Uses built-in default.css when neither is set.
|
||||||
|
base - URL prefix the guestbook is mounted at (BOOK_BASE_PATH).
|
||||||
|
Empty when serving at the domain root.
|
||||||
|
|
||||||
See default.css for available CSS classes on rendered elements.
|
See default.css for available CSS classes on rendered elements.
|
||||||
-->
|
-->
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
title - Site title (BOOK_SITE_TITLE).
|
title - Site title (BOOK_SITE_TITLE).
|
||||||
style - Custom CSS (same as the main template).
|
style - Custom CSS (same as the main template).
|
||||||
|
base - URL prefix the guestbook is mounted at (BOOK_BASE_PATH).
|
||||||
|
|
||||||
Everything else is static — write whatever you want. Use <script> for
|
Everything else is static — write whatever you want. Use <script> for
|
||||||
dynamic behavior like showing the current time.
|
dynamic behavior like showing the current time.
|
||||||
|
|
@ -21,7 +22,7 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<p>Thanks! Your message is pending approval.</p>
|
<p>Thanks! Your message is pending approval.</p>
|
||||||
<p><a href="/">← back</a></p>
|
<p><a href="{{base}}/">← back</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue