configurable separator, .env.example

This commit is contained in:
Lewis Wynne 2026-04-09 16:58:42 +01:00
parent d751eb62a9
commit ef6a190549
5 changed files with 39 additions and 14 deletions

13
.env.example Normal file
View file

@ -0,0 +1,13 @@
BOOK_PORT=8123
BOOK_DATA_DIR=./data
BOOK_SITE_TITLE=guestbook
BOOK_SITE_URL=https://example.com
BOOK_TELEGRAM_BOT_TOKEN=your-bot-token-here
BOOK_TELEGRAM_CHAT_ID=0
BOOK_HONEYPOT=true
BOOK_MAX_NAME_LENGTH=50
BOOK_MAX_MESSAGE_LENGTH=1000
BOOK_MAX_WEBSITE_LENGTH=100
BOOK_OPEN_REGISTRATION=true
BOOK_SEPARATOR=------------------------------------------------------------
# BOOK_TEMPLATE=./templates/default.html

View file

@ -75,6 +75,12 @@ in
description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected."; description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected.";
}; };
separator = mkOption {
type = types.str;
default = "------------------------------------------------------------";
description = "Separator between guestbook entries.";
};
templateFile = mkOption { templateFile = mkOption {
type = types.nullOr types.path; type = types.nullOr types.path;
default = null; default = null;
@ -121,6 +127,7 @@ in
BOOK_MAX_MESSAGE_LENGTH = toString cfg.maxMessageLength; BOOK_MAX_MESSAGE_LENGTH = toString cfg.maxMessageLength;
BOOK_MAX_WEBSITE_LENGTH = toString cfg.maxWebsiteLength; BOOK_MAX_WEBSITE_LENGTH = toString cfg.maxWebsiteLength;
BOOK_OPEN_REGISTRATION = if cfg.openRegistration then "true" else "false"; BOOK_OPEN_REGISTRATION = if cfg.openRegistration then "true" else "false";
BOOK_SEPARATOR = cfg.separator;
} // lib.optionalAttrs (cfg.templateFile != null) { } // lib.optionalAttrs (cfg.templateFile != null) {
BOOK_TEMPLATE = cfg.templateFile; BOOK_TEMPLATE = cfg.templateFile;
}; };

View file

@ -15,6 +15,7 @@ pub struct Config {
pub max_website_length: usize, pub max_website_length: usize,
pub open_registration: bool, pub open_registration: bool,
pub template: Option<String>, pub template: Option<String>,
pub separator: String,
} }
impl Config { impl Config {
@ -57,6 +58,8 @@ impl Config {
open_registration: env::var("BOOK_OPEN_REGISTRATION") open_registration: env::var("BOOK_OPEN_REGISTRATION")
.map(|v| v != "false") .map(|v| v != "false")
.unwrap_or(true), .unwrap_or(true),
separator: env::var("BOOK_SEPARATOR")
.unwrap_or_else(|_| "------------------------------------------------------------".into()),
template: env::var("BOOK_TEMPLATE").ok().map(|path| { template: env::var("BOOK_TEMPLATE").ok().map(|path| {
std::fs::read_to_string(&path) std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read template {path}: {e}")) .unwrap_or_else(|e| panic!("failed to read template {path}: {e}"))

View file

@ -35,23 +35,23 @@ entries
</html> </html>
"#; "#;
pub fn render_page(template: &str, title: &str, entries: &[Entry], form_html: &str) -> String { pub fn render_page(template: &str, title: &str, entries: &[Entry], form_html: &str, separator: &str) -> String {
let entries_html = render_entries(entries); let entries_html = render_entries(entries, separator);
template template
.replace("{{title}}", title) .replace("{{title}}", title)
.replace("{{form}}", form_html) .replace("{{form}}", form_html)
.replace("{{entries}}", &entries_html) .replace("{{entries}}", &entries_html)
} }
fn render_entries(entries: &[Entry]) -> String { fn render_entries(entries: &[Entry], separator: &str) -> String {
let mut html = String::new(); let mut html = String::new();
for entry in entries { for entry in entries {
html.push_str(&render_entry(entry)); html.push_str(&render_entry(entry, separator));
} }
html html
} }
fn render_entry(entry: &Entry) -> String { fn render_entry(entry: &Entry, separator: &str) -> String {
let mut header = format!("{} - {}", entry.meta.date, entry.meta.name); let mut header = format!("{} - {}", entry.meta.date, entry.meta.name);
if !entry.meta.website.is_empty() { if !entry.meta.website.is_empty() {
header.push_str(&format!( header.push_str(&format!(
@ -60,7 +60,7 @@ fn render_entry(entry: &Entry) -> String {
)); ));
} }
format!( format!(
"\n{header}\n\n{}\n\n------------------------------------------------------------\n", "\n{header}\n\n{}\n\n{separator}\n",
entry.body entry.body
) )
} }
@ -99,16 +99,16 @@ mod tests {
#[test] #[test]
fn test_render_default_template() { fn test_render_default_template() {
let html = render_page(DEFAULT_TEMPLATE, "ily.rs", &[], FORM_HTML); let html = render_page(DEFAULT_TEMPLATE, "ily.rs", &[], FORM_HTML, "---");
assert!(html.contains("<title>ily.rs</title>")); assert!(html.contains("<title>ily.rs</title>"));
assert!(html.contains("action=\"/submit\"")); assert!(html.contains("action=\"/submit\""));
assert!(html.contains("<pre style=font:unset>")); assert!(html.contains("<pre>"));
} }
#[test] #[test]
fn test_render_custom_template() { fn test_render_custom_template() {
let custom = "<html>{{title}} {{form}} {{entries}}</html>"; let custom = "<html>{{title}} {{form}} {{entries}}</html>";
let html = render_page(custom, "my site", &[], FORM_HTML); let html = render_page(custom, "my site", &[], FORM_HTML, "---");
assert!(html.contains("my site")); assert!(html.contains("my site"));
assert!(html.contains("action=\"/submit\"")); assert!(html.contains("action=\"/submit\""));
} }
@ -116,31 +116,31 @@ mod tests {
#[test] #[test]
fn test_render_entry_no_website() { fn test_render_entry_no_website() {
let entry = make_entry("alice", "2026-04-09", "Hello!"); let entry = make_entry("alice", "2026-04-09", "Hello!");
let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML); let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML, "---");
assert!(html.contains("2026-04-09 - alice")); assert!(html.contains("2026-04-09 - alice"));
assert!(html.contains("Hello!")); assert!(html.contains("Hello!"));
assert!(html.contains("----")); assert!(html.contains("---"));
} }
#[test] #[test]
fn test_render_entry_with_website() { fn test_render_entry_with_website() {
let mut entry = make_entry("bob", "2026-04-09", "Hi!"); let mut entry = make_entry("bob", "2026-04-09", "Hi!");
entry.meta.website = "https://bob.com".into(); entry.meta.website = "https://bob.com".into();
let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML); let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML, "---");
assert!(html.contains(r#"<a href="https://bob.com">"#)); assert!(html.contains(r#"<a href="https://bob.com">"#));
} }
#[test] #[test]
fn test_render_preserves_html_in_body() { fn test_render_preserves_html_in_body() {
let entry = make_entry("carol", "2026-04-09", "<b>Bold</b> <script>alert(1)</script>"); let entry = make_entry("carol", "2026-04-09", "<b>Bold</b> <script>alert(1)</script>");
let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML); let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML, "---");
assert!(html.contains("<b>Bold</b>")); assert!(html.contains("<b>Bold</b>"));
assert!(html.contains("<script>alert(1)</script>")); assert!(html.contains("<script>alert(1)</script>"));
} }
#[test] #[test]
fn test_render_empty_form_when_closed() { fn test_render_empty_form_when_closed() {
let html = render_page(DEFAULT_TEMPLATE, "test", &[], ""); let html = render_page(DEFAULT_TEMPLATE, "test", &[], "", "---");
assert!(!html.contains("action=\"/submit\"")); assert!(!html.contains("action=\"/submit\""));
} }
} }

View file

@ -44,6 +44,7 @@ async fn index(State(state): State<Arc<AppState>>) -> Html<String> {
&state.config.site_title, &state.config.site_title,
&entries, &entries,
form, form,
&state.config.separator,
); );
Html(html) Html(html)
} }
@ -134,6 +135,7 @@ mod tests {
max_website_length: 100, max_website_length: 100,
open_registration: true, open_registration: true,
template: None, template: None,
separator: "---".into(),
} }
} }