feat: renders drawpad, and saves files as epoch_uuid for better sorting on disk
This commit is contained in:
parent
7663237f57
commit
d34768b63d
3 changed files with 142 additions and 19 deletions
12
.env.example
12
.env.example
|
|
@ -87,3 +87,15 @@
|
||||||
# Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders.
|
# Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders.
|
||||||
# Uses built-in default if unset.
|
# Uses built-in default if unset.
|
||||||
# BOOK_TEMPLATE=./templates/default.html
|
# BOOK_TEMPLATE=./templates/default.html
|
||||||
|
|
||||||
|
# Enable drawing canvas in submission form. Drawings are stored as PNG files in DATA_DIR/drawings/.
|
||||||
|
# BOOK_ENABLE_DRAWINGS=false
|
||||||
|
|
||||||
|
# Label for the drawing canvas.
|
||||||
|
# BOOK_LABEL_DRAWING=Draw (optional):
|
||||||
|
|
||||||
|
# Drawing canvas width in pixels.
|
||||||
|
# BOOK_CANVAS_WIDTH=400
|
||||||
|
|
||||||
|
# Drawing canvas height in pixels.
|
||||||
|
# BOOK_CANVAS_HEIGHT=200
|
||||||
|
|
|
||||||
106
src/render.rs
106
src/render.rs
|
|
@ -38,6 +38,48 @@ pub fn render_form(config: &Config) -> String {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let drawing_section = if config.enable_drawings {
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<label class="guestbook-label">{label}</label>
|
||||||
|
<canvas class="guestbook-canvas" width="{w}" height="{h}"></canvas>
|
||||||
|
<a href="#" class="guestbook-canvas-reset">Reset</a>
|
||||||
|
<input type="hidden" name="drawing">
|
||||||
|
<script>
|
||||||
|
(function(){{
|
||||||
|
var c=document.querySelector('.guestbook-canvas'),
|
||||||
|
x=c.getContext('2d'),
|
||||||
|
d=false,lx,ly;
|
||||||
|
function pos(e){{var r=c.getBoundingClientRect();
|
||||||
|
return[e.clientX-r.left,e.clientY-r.top]}}
|
||||||
|
function tpos(e){{var r=c.getBoundingClientRect(),t=e.touches[0];
|
||||||
|
return[t.clientX-r.left,t.clientY-r.top]}}
|
||||||
|
c.addEventListener('mousedown',function(e){{d=true;var p=pos(e);lx=p[0];ly=p[1]}});
|
||||||
|
c.addEventListener('mousemove',function(e){{if(!d)return;var p=pos(e);
|
||||||
|
x.beginPath();x.moveTo(lx,ly);x.lineTo(p[0],p[1]);x.stroke();lx=p[0];ly=p[1]}});
|
||||||
|
c.addEventListener('mouseup',function(){{d=false}});
|
||||||
|
c.addEventListener('mouseleave',function(){{d=false}});
|
||||||
|
c.addEventListener('touchstart',function(e){{e.preventDefault();var p=tpos(e);lx=p[0];ly=p[1]}});
|
||||||
|
c.addEventListener('touchmove',function(e){{e.preventDefault();var p=tpos(e);
|
||||||
|
x.beginPath();x.moveTo(lx,ly);x.lineTo(p[0],p[1]);x.stroke();lx=p[0];ly=p[1]}});
|
||||||
|
document.querySelector('.guestbook-canvas-reset').addEventListener('click',function(e){{
|
||||||
|
e.preventDefault();x.clearRect(0,0,c.width,c.height)}});
|
||||||
|
c.closest('form').addEventListener('submit',function(){{
|
||||||
|
var px=new Uint32Array(x.getImageData(0,0,c.width,c.height).data.buffer);
|
||||||
|
if(px.some(function(v){{return v!==0}})){{
|
||||||
|
c.closest('form').querySelector('[name=drawing]').value=c.toDataURL('image/png');
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}})();
|
||||||
|
</script>"##,
|
||||||
|
label = config.label_drawing,
|
||||||
|
w = config.canvas_width,
|
||||||
|
h = config.canvas_height,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
r#"<span class="guestbook-prompt">{prompt}</span>
|
r#"<span class="guestbook-prompt">{prompt}</span>
|
||||||
<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
|
<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
|
||||||
|
|
@ -47,6 +89,7 @@ pub fn render_form(config: &Config) -> String {
|
||||||
<label class="guestbook-label">{label_message}</label>
|
<label class="guestbook-label">{label_message}</label>
|
||||||
<textarea class="guestbook-textarea" name="message" rows="{rows}" cols="{cols}" required></textarea>
|
<textarea class="guestbook-textarea" name="message" rows="{rows}" cols="{cols}" required></textarea>
|
||||||
{captcha_section}
|
{captcha_section}
|
||||||
|
{drawing_section}
|
||||||
<input name="url" style="display:none" tabindex="-1" autocomplete="off">
|
<input name="url" style="display:none" tabindex="-1" autocomplete="off">
|
||||||
<button class="guestbook-button" type="submit">{button}</button>
|
<button class="guestbook-button" type="submit">{button}</button>
|
||||||
</form>"#,
|
</form>"#,
|
||||||
|
|
@ -57,6 +100,7 @@ pub fn render_form(config: &Config) -> String {
|
||||||
rows = config.textarea_rows,
|
rows = config.textarea_rows,
|
||||||
cols = config.textarea_cols,
|
cols = config.textarea_cols,
|
||||||
captcha_section = captcha_section,
|
captcha_section = captcha_section,
|
||||||
|
drawing_section = drawing_section,
|
||||||
button = config.button_text,
|
button = config.button_text,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -100,8 +144,16 @@ fn render_entry(entry: &Entry, config: &Config) -> String {
|
||||||
} else {
|
} else {
|
||||||
escape_html(&entry.body)
|
escape_html(&entry.body)
|
||||||
};
|
};
|
||||||
|
let drawing_html = if !entry.meta.drawing.is_empty() {
|
||||||
format!(
|
format!(
|
||||||
"\n{header}\n\n<span class=\"entry-body\">{body}</span>\n\n<span class=\"entry-separator\">{}</span>\n",
|
"\n<img class=\"entry-drawing\" src=\"/drawings/{}\">",
|
||||||
|
escape_html(&entry.meta.drawing)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"\n{header}\n\n<span class=\"entry-body\">{body}</span>{drawing_html}\n\n<span class=\"entry-separator\">{}</span>\n",
|
||||||
config.separator
|
config.separator
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -322,4 +374,56 @@ mod tests {
|
||||||
"<b>test</b> & "quotes" 'apos'"
|
"<b>test</b> & "quotes" 'apos'"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_form_shows_canvas_when_drawings_enabled() {
|
||||||
|
let mut config = test_config();
|
||||||
|
config.enable_drawings = true;
|
||||||
|
let form = render_form(&config);
|
||||||
|
assert!(form.contains("<canvas"));
|
||||||
|
assert!(form.contains("class=\"guestbook-canvas\""));
|
||||||
|
assert!(form.contains("name=\"drawing\""));
|
||||||
|
assert!(form.contains("Reset"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_form_hides_canvas_when_drawings_disabled() {
|
||||||
|
let config = test_config();
|
||||||
|
let form = render_form(&config);
|
||||||
|
assert!(!form.contains("<canvas"));
|
||||||
|
assert!(!form.contains("name=\"drawing\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_entry_with_drawing() {
|
||||||
|
let config = test_config();
|
||||||
|
let mut entry = make_entry("alice", "2026-04-09", "Hello!");
|
||||||
|
entry.meta.drawing = "2026-04-09-abc123.png".into();
|
||||||
|
let form = render_form(&config);
|
||||||
|
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
|
||||||
|
assert!(html.contains(r#"<img class="entry-drawing" src="/drawings/2026-04-09-abc123.png">"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_entry_drawing_works_without_html_injection() {
|
||||||
|
let mut config = test_config();
|
||||||
|
config.enable_html_injection = false;
|
||||||
|
let mut entry = make_entry("alice", "2026-04-09", "<script>xss</script>");
|
||||||
|
entry.meta.drawing = "2026-04-09-abc123.png".into();
|
||||||
|
let form = render_form(&config);
|
||||||
|
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
|
||||||
|
// Drawing renders regardless
|
||||||
|
assert!(html.contains(r#"<img class="entry-drawing" src="/drawings/2026-04-09-abc123.png">"#));
|
||||||
|
// But body HTML is escaped
|
||||||
|
assert!(html.contains("<script>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_entry_without_drawing() {
|
||||||
|
let config = test_config();
|
||||||
|
let entry = make_entry("alice", "2026-04-09", "Hello!");
|
||||||
|
let form = render_form(&config);
|
||||||
|
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
|
||||||
|
assert!(!html.contains("entry-drawing"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
src/web.rs
35
src/web.rs
|
|
@ -153,12 +153,12 @@ async fn submit(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process drawing if enabled and provided
|
// Process drawing if enabled and provided
|
||||||
let (drawing_filename, drawing_bytes) = if state.config.enable_drawings && !form.drawing.is_empty() {
|
let drawing_bytes: Option<Vec<u8>> = if state.config.enable_drawings && !form.drawing.is_empty() {
|
||||||
let b64 = form.drawing
|
let b64 = form.drawing
|
||||||
.strip_prefix("data:image/png;base64,")
|
.strip_prefix("data:image/png;base64,")
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
if b64.is_empty() {
|
if b64.is_empty() {
|
||||||
(String::new(), None)
|
None
|
||||||
} else {
|
} else {
|
||||||
let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) {
|
let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
|
|
@ -179,27 +179,34 @@ async fn submit(
|
||||||
return Html("Invalid drawing dimensions.".to_string());
|
return Html("Invalid drawing dimensions.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let drawing_id = &Uuid::new_v4().to_string()[..8];
|
Some(bytes)
|
||||||
let date_now = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
}
|
||||||
let drawing_name = format!("{date_now}-{drawing_id}.png");
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let epoch = now.timestamp();
|
||||||
|
let short_id = &Uuid::new_v4().to_string()[..8];
|
||||||
|
let prefix = format!("{epoch}_{short_id}");
|
||||||
|
let date = now.format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||||
|
let filename = format!("{prefix}.txt");
|
||||||
|
|
||||||
|
// Save drawing with the same prefix as the entry
|
||||||
|
let drawing_filename = if let Some(ref bytes) = drawing_bytes {
|
||||||
|
let drawing_name = format!("{prefix}.png");
|
||||||
let drawings_dir = state.config.data_dir.join("drawings");
|
let drawings_dir = state.config.data_dir.join("drawings");
|
||||||
std::fs::create_dir_all(&drawings_dir).ok();
|
std::fs::create_dir_all(&drawings_dir).ok();
|
||||||
if let Err(e) = std::fs::write(drawings_dir.join(&drawing_name), &bytes) {
|
if let Err(e) = std::fs::write(drawings_dir.join(&drawing_name), bytes) {
|
||||||
tracing::error!("failed to write drawing: {e}");
|
tracing::error!("failed to write drawing: {e}");
|
||||||
return Html("Something went wrong. Please try again.".to_string());
|
return Html("Something went wrong. Please try again.".to_string());
|
||||||
}
|
}
|
||||||
(drawing_name, Some(bytes))
|
drawing_name
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
(String::new(), None)
|
String::new()
|
||||||
};
|
};
|
||||||
let _ = drawing_bytes;
|
let _ = drawing_bytes;
|
||||||
|
|
||||||
let short_id = &Uuid::new_v4().to_string()[..8];
|
|
||||||
let date = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string();
|
|
||||||
let date_short = &date[..10];
|
|
||||||
let filename = format!("{date_short}-{short_id}.txt");
|
|
||||||
|
|
||||||
let entry = Entry {
|
let entry = Entry {
|
||||||
id: filename.trim_end_matches(".txt").to_string(),
|
id: filename.trim_end_matches(".txt").to_string(),
|
||||||
meta: EntryMeta {
|
meta: EntryMeta {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue