Compare commits
14 commits
c05cbf4cbc
...
8e11bd3558
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e11bd3558 | |||
| 7f0008f079 | |||
| c3e1ff145d | |||
| 1d209636b4 | |||
| 3544ff7435 | |||
| e8a388be84 | |||
| 05022a1669 | |||
| 06336d8146 | |||
| 3e64ff4857 | |||
| ab6dd05b6e | |||
| 68080a1455 | |||
| 54764935c2 | |||
| d66e004b0d | |||
| ca9eaf0662 |
12 changed files with 660 additions and 86 deletions
|
|
@ -96,11 +96,14 @@
|
|||
# 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
|
||||
|
||||
# Enable voice note recording in submission form. Voice notes are stored as WebM files in DATA_DIR/voice_notes/.
|
||||
# BOOK_ENABLE_VOICE_NOTES=false
|
||||
|
||||
# Maximum voice note duration in seconds. Max file size is derived as duration * 10KB.
|
||||
# BOOK_VOICE_NOTE_MAX_DURATION=20
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -458,7 +458,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "guestbook"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
[package]
|
||||
name = "guestbook"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
edition = "2021"
|
||||
description = "A configurable web guestbook made to be easy to use, with entries in plain text files, an optional drawing canvas, honeypots and captchas to deter spam, and moderation via Telegram bot."
|
||||
description = "A standalone guestbook to let visitors to your site leave behind written messages, drawings, and voice notes, with spam-prevention and moderation via Telegram bot."
|
||||
license = "MIT"
|
||||
repository = "https://git.ily.rs/lew/guestbook"
|
||||
|
||||
|
|
|
|||
74
README.md
74
README.md
|
|
@ -3,14 +3,15 @@
|
|||
|
||||
`guestbook` is a self-hosted guestbook web service with:
|
||||
- entries stored in plaintext,
|
||||
- optional [drawing canvas](#drawing) for visitors to sketch alongside their message,
|
||||
- notifications and moderation via [Telegram](#telegram) (including drawing previews),
|
||||
- a [drawing canvas](#drawing) for visitors to sketch alongside their message,
|
||||
- [voice notes](#voice-notes) for visitors to record a short audio clip,
|
||||
- notifications and moderation via [Telegram](#telegram),
|
||||
- spam prevention via honeypot and/or [captcha](#captcha),
|
||||
- completely customisable [styling](#customisation),
|
||||
- fairly customisable [styling](#customisation),
|
||||
|
||||
and more, written in Rust, and inspired by [t0.vc/g](https://t0.vc/g).
|
||||
|
||||
`guestbook` is a single binary that serves a single-page guestbook aimed at personal sites. There's a form for visitors to submit a name, message, and optionally a link to their own site. Visitors can also draw a picture if the drawing feature is enabled. Entries are written to plain text files with TOML frontmatter, and are initially marked as pending. The frontmatter can be manually edited to mark entries as approved or denied, or a Telegram bot can be hooked up for notifications and moderation (drawings are sent as photos so you can see them before approving). Running the Telegram bot just requires handing over a bot token, and it'll run off the same binary.
|
||||
`guestbook` is a single binary that serves a single-page guestbook aimed at personal sites. There's a form for visitors to submit a name, message, and optionally a link to their own site. Visitors can also draw a picture or leave a voice note if those features are enabled. Entries are written to plain text files with TOML frontmatter, and are initially marked as pending. The frontmatter can be manually edited to mark entries as approved or denied, or a Telegram bot can be hooked up for notifications and moderation (drawings are sent as photos and voice notes as voice messages so you can review them before approving). Running the Telegram bot just requires handing over a bot token, and it'll run off the same binary.
|
||||
|
||||
Everything is configured through environment variables (see [`.env.example`](#default-config) for the defaults). If you're hosting with Nix, there's a flake that can set up the `guestbook` service end-to-end, running on a systemd service with a Caddy reverse proxy. Optionally, just ignore the flake and set up all the extra stuff yourself.
|
||||
|
||||
|
|
@ -182,6 +183,21 @@ Running `guestbook` with no env vars will give you a working guestbook on `local
|
|||
# Supports {{title}} and {{style}} placeholders. Use <script> for dynamic behavior.
|
||||
# Uses built-in templates/success.html if unset.
|
||||
# BOOK_SUCCESS_TEMPLATE=./templates/success.html
|
||||
|
||||
# Enable drawing canvas in submission form. Drawings are stored as PNG files in DATA_DIR/drawings/.
|
||||
# BOOK_ENABLE_DRAWINGS=false
|
||||
|
||||
# Drawing canvas width in pixels.
|
||||
# BOOK_CANVAS_WIDTH=400
|
||||
|
||||
# Drawing canvas height in pixels.
|
||||
# BOOK_CANVAS_HEIGHT=200
|
||||
|
||||
# Enable voice note recording in submission form. Voice notes are stored as WebM files in DATA_DIR/voice_notes/.
|
||||
# BOOK_ENABLE_VOICE_NOTES=false
|
||||
|
||||
# Maximum voice note duration in seconds. Max file size is derived as duration * 10KB.
|
||||
# BOOK_VOICE_NOTE_MAX_DURATION=20
|
||||
```
|
||||
|
||||
#### NixOS Module
|
||||
|
|
@ -210,6 +226,10 @@ services.guestbook = {
|
|||
canvasWidth = 400;
|
||||
canvasHeight = 200;
|
||||
};
|
||||
voiceNote = {
|
||||
enable = false;
|
||||
maxDuration = 20;
|
||||
};
|
||||
telegram = {
|
||||
enable = false;
|
||||
# botTokenFile = <path>; -- required when enabled
|
||||
|
|
@ -246,7 +266,6 @@ services.guestbook = {
|
|||
name = "Your name:";
|
||||
website = "Your website (optional):";
|
||||
message = "Your message:";
|
||||
drawing = "Draw (optional):";
|
||||
};
|
||||
message = {
|
||||
width = 400;
|
||||
|
|
@ -268,17 +287,27 @@ When Telegram moderation is enabled, drawings are sent as photos in the notifica
|
|||
|
||||
---
|
||||
|
||||
### Voice Notes
|
||||
|
||||
Set `BOOK_ENABLE_VOICE_NOTES=true` to let visitors record a short audio clip alongside their message. Recording uses the browser's MediaRecorder API (WebM/Opus format). The form shows an "add a voice note" link that starts recording on click, with a timer counting up to the configured max duration (`BOOK_VOICE_NOTE_MAX_DURATION`, default 20 seconds). After recording, visitors can listen back, re-record, or discard.
|
||||
|
||||
Server-side validation checks the WebM magic bytes (`\x1a\x45\xdf\xa3`) and enforces a file size cap derived from the max duration (`duration * 10KB`). Voice notes are stored as WebM files in `{data_dir}/voice_notes/` and rendered as native `<audio>` elements below the entry header, independent of the HTML injection setting.
|
||||
|
||||
When Telegram moderation is enabled, voice notes are sent as voice messages in the notification so you can hear them before approving.
|
||||
|
||||
---
|
||||
|
||||
### Telegram
|
||||
|
||||
To enable Telegram moderation, create a bot via [@BotFather](https://t.me/BotFather) and set `BOOK_TELEGRAM_BOT_TOKEN` to the token it gives you. Set `BOOK_TELEGRAM_CHAT_ID` to the chat ID where you want notifications sent: the easiest way to find this is to message the bot and check the [getUpdates](https://api.telegram.org/bot<token>/getUpdates) endpoint.
|
||||
|
||||
When a visitor submits an entry, the bot sends a message with the entry details and `/allow_<id>` and `/deny_<id>` commands. Tap either to approve or deny. If you approve something and later want to deny it, or vice versa, just hit the opposite option and it'll work as expected.
|
||||
When a visitor submits an entry, the bot sends a message with the entry details and `/allow_<id>` and `/deny_<id>` commands, as well as any drawing or voice note attached. Tap either command to approve or deny. If you approve something and later want to deny it, or vice versa, just hit the opposite option and it'll work as expected.
|
||||
|
||||
---
|
||||
|
||||
### Entry Format
|
||||
|
||||
Each entry is a plain text file in `{data_dir}/entries/`. The filename is `{epoch}_{uuid}.txt`. If the entry has a drawing, the drawing is stored as `{epoch}_{uuid}.png` in `{data_dir}/drawings/` with the same prefix.
|
||||
Each entry is a plain text file in `{data_dir}/entries/`. The filename is `{epoch}_{uuid}.txt`. If the entry has a drawing, the drawing is stored as `{epoch}_{uuid}.png` in `{data_dir}/drawings/` with the same prefix. Voice notes work the same way, stored as `{epoch}_{uuid}.webm` in `{data_dir}/voice_notes/`.
|
||||
|
||||
```
|
||||
+++
|
||||
|
|
@ -286,12 +315,13 @@ name = "someone"
|
|||
date = "2026-04-09T12:00:00"
|
||||
website = "https://example.com"
|
||||
drawing = "1744185600_abcd1234.png"
|
||||
voice_note = "1744300800_abcd1234.webm"
|
||||
status = "pending"
|
||||
+++
|
||||
Message body here.
|
||||
```
|
||||
|
||||
The `status` field can be `pending`, `approved`, or `denied`. Only approved entries are displayed. The `drawing` field is empty when there's no drawing. To moderate without Telegram, just edit the file and change `status` to `approved` or `denied`.
|
||||
The `status` field can be `pending`, `approved`, or `denied`. Only approved entries are displayed. The `drawing` and `voice_note` fields are empty when there's no drawing or voice note. To moderate without Telegram, just edit the file and change `status` to `approved` or `denied`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -368,19 +398,41 @@ Validation errors (empty fields, wrong captcha, etc.) show a simple error page w
|
|||
.guestbook-form {}
|
||||
.guestbook-label {}
|
||||
.guestbook-input {}
|
||||
.guestbook-textarea {}
|
||||
.guestbook-button {}
|
||||
.guestbook-textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.guestbook-button {
|
||||
display: block;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
/* Drawings */
|
||||
.guestbook-canvas {
|
||||
border: 1px solid #000;
|
||||
cursor: crosshair;
|
||||
display: block;
|
||||
}
|
||||
.guestbook-drawing-content {
|
||||
display: block;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.guestbook-canvas-reset {}
|
||||
.entry-drawing {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Voice notes */
|
||||
.guestbook-voice-record.recording {
|
||||
color: red;
|
||||
}
|
||||
.guestbook-voice-timer {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
audio {
|
||||
display: block;
|
||||
margin-top: 0.6em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
/* Entries */
|
||||
.entry-header {}
|
||||
.entry-date {}
|
||||
|
|
|
|||
25
module.nix
25
module.nix
|
|
@ -94,6 +94,20 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
voiceNote = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable voice note recording in the submission form. Stores WebM files in dataDir/voice_notes/.";
|
||||
};
|
||||
|
||||
maxDuration = mkOption {
|
||||
type = types.int;
|
||||
default = 20;
|
||||
description = "Maximum voice note duration in seconds. Max file size is derived as duration * 10KB.";
|
||||
};
|
||||
};
|
||||
|
||||
telegram = {
|
||||
enable = mkEnableOption "Telegram moderation notifications";
|
||||
|
||||
|
|
@ -236,12 +250,6 @@ in
|
|||
default = "Your message:";
|
||||
description = "Label for the message field.";
|
||||
};
|
||||
|
||||
drawing = mkOption {
|
||||
type = types.str;
|
||||
default = "Draw (optional):";
|
||||
description = "Label for the drawing canvas.";
|
||||
};
|
||||
};
|
||||
|
||||
message = {
|
||||
|
|
@ -292,9 +300,10 @@ in
|
|||
BOOK_LABEL_NAME = cfg.styles.labels.name;
|
||||
BOOK_LABEL_WEBSITE = cfg.styles.labels.website;
|
||||
BOOK_LABEL_MESSAGE = cfg.styles.labels.message;
|
||||
BOOK_LABEL_DRAWING = cfg.styles.labels.drawing;
|
||||
BOOK_CANVAS_WIDTH = toString cfg.features.drawing.canvasWidth;
|
||||
BOOK_CANVAS_HEIGHT = toString cfg.features.drawing.canvasHeight;
|
||||
BOOK_ENABLE_VOICE_NOTES = if cfg.features.voiceNote.enable then "true" else "false";
|
||||
BOOK_VOICE_NOTE_MAX_DURATION = toString cfg.features.voiceNote.maxDuration;
|
||||
BOOK_TEXTAREA_WIDTH = toString cfg.styles.message.width;
|
||||
BOOK_TEXTAREA_HEIGHT = toString cfg.styles.message.height;
|
||||
} // lib.optionalAttrs (cfg.styles.cssFile != null) {
|
||||
|
|
@ -309,7 +318,7 @@ in
|
|||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStartPre = "+${pkgs.writeShellScript "guestbook-prepare" ''
|
||||
mkdir -p ${cfg.dataDir}/entries ${cfg.dataDir}/drawings
|
||||
mkdir -p ${cfg.dataDir}/entries ${cfg.dataDir}/drawings ${cfg.dataDir}/voice_notes
|
||||
chown -R ${cfg.user}:${cfg.group} ${cfg.dataDir}
|
||||
''}";
|
||||
Restart = "on-failure";
|
||||
|
|
|
|||
|
|
@ -22,9 +22,10 @@ pub struct Config {
|
|||
pub captcha_exact: bool,
|
||||
pub captcha_casesensitive: bool,
|
||||
pub enable_drawings: bool,
|
||||
pub label_drawing: String,
|
||||
pub canvas_width: u32,
|
||||
pub canvas_height: u32,
|
||||
pub enable_voice_notes: bool,
|
||||
pub voice_note_max_duration: u32,
|
||||
pub template: Option<String>,
|
||||
pub success_template: Option<String>,
|
||||
pub separator: String,
|
||||
|
|
@ -49,6 +50,12 @@ impl Config {
|
|||
self.canvas_width as usize * self.canvas_height as usize * 4
|
||||
}
|
||||
|
||||
/// Maximum voice note file size: duration * 10KB.
|
||||
/// Generous cap — real WebM/Opus clips are much smaller.
|
||||
pub fn max_voice_note_bytes(&self) -> usize {
|
||||
self.voice_note_max_duration as usize * 10 * 1024
|
||||
}
|
||||
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
Ok(Config {
|
||||
port: env::var("BOOK_PORT")
|
||||
|
|
@ -105,8 +112,6 @@ impl Config {
|
|||
enable_drawings: env::var("BOOK_ENABLE_DRAWINGS")
|
||||
.map(|v| v != "false")
|
||||
.unwrap_or(false),
|
||||
label_drawing: env::var("BOOK_LABEL_DRAWING")
|
||||
.unwrap_or_else(|_| "Draw (optional):".into()),
|
||||
canvas_width: env::var("BOOK_CANVAS_WIDTH")
|
||||
.unwrap_or_else(|_| "400".into())
|
||||
.parse()
|
||||
|
|
@ -115,6 +120,13 @@ impl Config {
|
|||
.unwrap_or_else(|_| "200".into())
|
||||
.parse()
|
||||
.map_err(|_| "BOOK_CANVAS_HEIGHT must be a number")?,
|
||||
enable_voice_notes: env::var("BOOK_ENABLE_VOICE_NOTES")
|
||||
.map(|v| v != "false")
|
||||
.unwrap_or(false),
|
||||
voice_note_max_duration: env::var("BOOK_VOICE_NOTE_MAX_DURATION")
|
||||
.unwrap_or_else(|_| "20".into())
|
||||
.parse()
|
||||
.map_err(|_| "BOOK_VOICE_NOTE_MAX_DURATION must be a number")?,
|
||||
separator: env::var("BOOK_SEPARATOR")
|
||||
.unwrap_or_else(|_| "------------------------------------------------------------".into()),
|
||||
template: env::var("BOOK_TEMPLATE").ok().map(|path| {
|
||||
|
|
@ -283,7 +295,19 @@ mod tests {
|
|||
assert_eq!(config.canvas_width, 400);
|
||||
assert_eq!(config.canvas_height, 200);
|
||||
assert_eq!(config.max_drawing_bytes(), 400 * 200 * 4);
|
||||
assert_eq!(config.label_drawing, "Draw (optional):");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_voice_notes_default() {
|
||||
let _lock = ENV_LOCK.lock().unwrap();
|
||||
env::remove_var("BOOK_ENABLE_VOICE_NOTES");
|
||||
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
|
||||
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
assert!(!config.enable_voice_notes);
|
||||
assert_eq!(config.voice_note_max_duration, 20);
|
||||
assert_eq!(config.max_voice_note_bytes(), 20 * 10 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ pub struct EntryMeta {
|
|||
pub website: String,
|
||||
#[serde(default)]
|
||||
pub drawing: String,
|
||||
#[serde(default)]
|
||||
pub voice_note: String,
|
||||
pub status: Status,
|
||||
}
|
||||
|
||||
|
|
@ -225,4 +227,44 @@ Hello!"#;
|
|||
let reparsed = Entry::parse("test", &serialized).unwrap();
|
||||
assert_eq!(reparsed.meta.drawing, "abc123.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_entry_with_voice_note() {
|
||||
let contents = r#"+++
|
||||
name = "alice"
|
||||
date = "2026-04-10"
|
||||
status = "approved"
|
||||
voice_note = "1744300800_abcd1234.webm"
|
||||
+++
|
||||
Hello!"#;
|
||||
let entry = Entry::parse("test", contents).unwrap();
|
||||
assert_eq!(entry.meta.voice_note, "1744300800_abcd1234.webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_entry_without_voice_note() {
|
||||
let contents = r#"+++
|
||||
name = "bob"
|
||||
date = "2026-04-10"
|
||||
status = "pending"
|
||||
+++
|
||||
Hi!"#;
|
||||
let entry = Entry::parse("test", contents).unwrap();
|
||||
assert_eq!(entry.meta.voice_note, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_with_voice_note() {
|
||||
let contents = r#"+++
|
||||
name = "alice"
|
||||
date = "2026-04-10"
|
||||
status = "approved"
|
||||
voice_note = "1744300800_abcd1234.webm"
|
||||
+++
|
||||
Hello!"#;
|
||||
let entry = Entry::parse("test", contents).unwrap();
|
||||
let serialized = entry.to_file_contents();
|
||||
let reparsed = Entry::parse("test", &serialized).unwrap();
|
||||
assert_eq!(reparsed.meta.voice_note, "1744300800_abcd1234.webm");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ async fn main() {
|
|||
|
||||
std::fs::create_dir_all(&entries_dir).ok();
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<(entries::Entry, Option<Vec<u8>>)>(32);
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<(entries::Entry, Option<Vec<u8>>, Option<Vec<u8>>)>(32);
|
||||
|
||||
// Spawn telegram tasks if configured
|
||||
match (&config.telegram_bot_token, config.telegram_chat_id) {
|
||||
|
|
|
|||
241
src/render.rs
241
src/render.rs
|
|
@ -41,46 +41,67 @@ pub fn render_form(config: &Config) -> String {
|
|||
|
||||
let drawing_section = if config.enable_drawings {
|
||||
format!(
|
||||
r##"<label class="guestbook-label">{label}</label>
|
||||
<canvas class="guestbook-canvas" width="{w}" height="{h}"></canvas>
|
||||
<span class="guestbook-canvas-tools"><span class="guestbook-swatch active" data-c="#000" style="background:#000"></span><span class="guestbook-swatch" data-c="#e03131" style="background:#e03131"></span><span class="guestbook-swatch" data-c="#2f9e44" style="background:#2f9e44"></span><span class="guestbook-swatch" data-c="#1971c2" style="background:#1971c2"></span> <input type="range" class="guestbook-size-slider" min="1" max="20" value="5"> | <a href="#" class="guestbook-undo">undo</a> | <a href="#" class="guestbook-canvas-reset">reset</a></span><input type="hidden" name="drawing"><script>(function(){{
|
||||
var c=document.querySelector('.guestbook-canvas'),
|
||||
x=c.getContext('2d'),
|
||||
d=false,lx,ly,h=[],col='#000',sz=5;
|
||||
x.strokeStyle=col;x.lineWidth=sz;x.lineCap='round';x.lineJoin='round';
|
||||
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]}}
|
||||
function save(){{if(h.length>=20)h.shift();
|
||||
h.push(x.getImageData(0,0,c.width,c.height))}}
|
||||
r##"<span class="guestbook-drawing-wrap"><span class="guestbook-drawing-inline"><a href="#" class="guestbook-drawing-toggle">add a drawing</a></span>
|
||||
<span class="guestbook-drawing-content"></span></span><input type="hidden" name="drawing"><script>(function(){{
|
||||
var inl=document.querySelector('.guestbook-drawing-inline'),
|
||||
cnt=document.querySelector('.guestbook-drawing-content'),
|
||||
hid=document.querySelector('[name=drawing]'),
|
||||
c,x,d=false,lx,ly,h=[],col='#000',sz=5;
|
||||
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]}}
|
||||
function save(){{if(h.length>=20)h.shift();h.push(x.getImageData(0,0,c.width,c.height))}}
|
||||
function dot(px,py){{x.beginPath();x.arc(px,py,sz/2,0,Math.PI*2);x.fillStyle=col;x.fill()}}
|
||||
c.addEventListener('mousedown',function(e){{save();d=true;var p=pos(e);lx=p[0];ly=p[1];dot(lx,ly)}});
|
||||
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();save();var p=tpos(e);lx=p[0];ly=p[1];dot(lx,ly)}});
|
||||
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]}});
|
||||
var sw=document.querySelectorAll('.guestbook-swatch');
|
||||
sw.forEach(function(s){{s.addEventListener('click',function(){{
|
||||
sw.forEach(function(el){{el.classList.remove('active')}});
|
||||
s.classList.add('active');col=s.getAttribute('data-c');x.strokeStyle=col}})}});
|
||||
document.querySelector('.guestbook-size-slider').addEventListener('input',function(e){{
|
||||
sz=parseInt(e.target.value);x.lineWidth=sz}});
|
||||
document.querySelector('.guestbook-undo').addEventListener('click',function(e){{
|
||||
e.preventDefault();if(h.length)x.putImageData(h.pop(),0,0)}});
|
||||
document.querySelector('.guestbook-canvas-reset').addEventListener('click',function(e){{
|
||||
e.preventDefault();h=[];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');
|
||||
}}
|
||||
}});
|
||||
function bindCanvas(){{
|
||||
x=c.getContext('2d');x.strokeStyle=col;x.lineWidth=sz;x.lineCap='round';x.lineJoin='round';
|
||||
c.addEventListener('mousedown',function(e){{save();d=true;var p=pos(e);lx=p[0];ly=p[1];dot(lx,ly)}});
|
||||
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();save();var p=tpos(e);lx=p[0];ly=p[1];dot(lx,ly)}});
|
||||
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]}});
|
||||
}}
|
||||
function showCanvas(){{
|
||||
inl.innerHTML='';
|
||||
var sw=[{{c:'#000'}},{{c:'#e03131'}},{{c:'#2f9e44'}},{{c:'#1971c2'}}];
|
||||
sw.forEach(function(s,i){{
|
||||
var sp=document.createElement('span');
|
||||
sp.className='guestbook-swatch'+(i===0?' active':'');
|
||||
sp.setAttribute('data-c',s.c);sp.style.background=s.c;
|
||||
sp.addEventListener('click',function(){{
|
||||
inl.querySelectorAll('.guestbook-swatch').forEach(function(el){{el.classList.remove('active')}});
|
||||
sp.classList.add('active');col=s.c;x.strokeStyle=col;
|
||||
}});
|
||||
inl.appendChild(sp);
|
||||
}});
|
||||
var sl=document.createElement('input');
|
||||
sl.type='range';sl.className='guestbook-size-slider';sl.min='1';sl.max='20';sl.value='5';
|
||||
sl.addEventListener('input',function(){{sz=parseInt(sl.value);x.lineWidth=sz}});
|
||||
inl.appendChild(document.createTextNode(' '));inl.appendChild(sl);
|
||||
inl.appendChild(document.createTextNode(' | '));
|
||||
var undo=document.createElement('a');undo.href='#';undo.textContent='undo';
|
||||
undo.addEventListener('click',function(e){{e.preventDefault();if(h.length)x.putImageData(h.pop(),0,0)}});
|
||||
inl.appendChild(undo);
|
||||
inl.appendChild(document.createTextNode(' | '));
|
||||
var disc=document.createElement('a');disc.href='#';disc.textContent='discard';
|
||||
disc.addEventListener('click',function(e){{
|
||||
e.preventDefault();h=[];col='#000';sz=5;d=false;cnt.innerHTML='';hid.value='';setInit();
|
||||
}});
|
||||
inl.appendChild(disc);
|
||||
c=document.createElement('canvas');c.className='guestbook-canvas';c.width={w};c.height={h};
|
||||
cnt.innerHTML='';cnt.appendChild(c);bindCanvas();
|
||||
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}})){{hid.value=c.toDataURL('image/png')}}
|
||||
}});
|
||||
}}
|
||||
function setInit(){{
|
||||
inl.innerHTML='';
|
||||
var a=document.createElement('a');a.href='#';a.textContent='add a drawing';
|
||||
a.addEventListener('click',function(e){{e.preventDefault();showCanvas()}});
|
||||
inl.appendChild(a);
|
||||
}}
|
||||
inl.querySelector('a').addEventListener('click',function(e){{e.preventDefault();showCanvas()}});
|
||||
}})();</script>"##,
|
||||
label = config.label_drawing,
|
||||
w = config.canvas_width,
|
||||
h = config.canvas_height,
|
||||
)
|
||||
|
|
@ -88,6 +109,70 @@ pub fn render_form(config: &Config) -> String {
|
|||
String::new()
|
||||
};
|
||||
|
||||
let voice_note_section = if config.enable_voice_notes {
|
||||
format!(
|
||||
r##"<span class="guestbook-voice-wrap"><span class="guestbook-voice-inline"><a href="#" class="guestbook-voice-record">add a voice note</a> <span class="guestbook-voice-timer"></span></span><span class="guestbook-voice-playback"></span></span><input type="hidden" name="voice_note"><script>(function(){{
|
||||
var maxDur={max_dur};
|
||||
var inl=document.querySelector('.guestbook-voice-inline'),
|
||||
pb=document.querySelector('.guestbook-voice-playback'),
|
||||
hid=document.querySelector('[name=voice_note]'),
|
||||
rec=null,chunks=[],iv=null,st=0;
|
||||
function fmt(s){{var m=Math.floor(s/60),sec=s%60;return m+':'+(sec<10?'0':'')+sec}}
|
||||
function setInit(){{
|
||||
if(rec&&rec.state==='recording'){{rec.stop();rec.stream.getTracks().forEach(function(t){{t.stop()}})}}
|
||||
rec=null;chunks=[];clearInterval(iv);iv=null;pb.innerHTML='';hid.value='';
|
||||
inl.innerHTML='';
|
||||
var a=document.createElement('a');a.href='#';a.textContent='add a voice note';
|
||||
a.addEventListener('click',function(e){{e.preventDefault();startRec()}});
|
||||
inl.appendChild(a);
|
||||
}}
|
||||
function setRec(){{
|
||||
inl.innerHTML='';
|
||||
var a=document.createElement('a');a.href='#';a.className='guestbook-voice-record recording';
|
||||
a.textContent='stop recording';
|
||||
a.addEventListener('click',function(e){{e.preventDefault();rec.stop();rec.stream.getTracks().forEach(function(t){{t.stop()}})}});
|
||||
inl.appendChild(a);inl.appendChild(document.createTextNode(' '));
|
||||
var t=document.createElement('span');t.className='guestbook-voice-timer';inl.appendChild(t);
|
||||
st=Date.now();t.textContent=fmt(0)+' / '+fmt(maxDur);
|
||||
iv=setInterval(function(){{
|
||||
var el=Math.floor((Date.now()-st)/1000);t.textContent=fmt(el)+' / '+fmt(maxDur);
|
||||
if(el>=maxDur){{rec.stop();rec.stream.getTracks().forEach(function(t){{t.stop()}})}}
|
||||
}},250);
|
||||
}}
|
||||
function setResult(){{
|
||||
clearInterval(iv);iv=null;
|
||||
var blob=new Blob(chunks,{{type:'audio/webm;codecs=opus'}});
|
||||
inl.innerHTML='';
|
||||
var re=document.createElement('a');re.href='#';re.textContent='re-record';
|
||||
re.addEventListener('click',function(e){{e.preventDefault();setInit();startRec()}});
|
||||
var disc=document.createElement('a');disc.href='#';disc.textContent='discard';
|
||||
disc.addEventListener('click',function(e){{e.preventDefault();setInit()}});
|
||||
inl.appendChild(re);inl.appendChild(document.createTextNode(' | '));inl.appendChild(disc);
|
||||
var url=URL.createObjectURL(blob);
|
||||
var au=document.createElement('audio');au.controls=true;au.preload='metadata';au.src=url;
|
||||
pb.innerHTML='';pb.appendChild(au);
|
||||
var rd=new FileReader();rd.onload=function(){{hid.value=rd.result}};rd.readAsDataURL(blob);
|
||||
}}
|
||||
function startRec(){{
|
||||
chunks=[];hid.value='';pb.innerHTML='';
|
||||
navigator.mediaDevices.getUserMedia({{audio:true}}).then(function(stream){{
|
||||
rec=new MediaRecorder(stream,{{mimeType:'audio/webm;codecs=opus'}});
|
||||
rec.ondataavailable=function(e){{if(e.data.size>0)chunks.push(e.data)}};
|
||||
rec.onstop=function(){{setResult()}};
|
||||
rec.start();setRec();
|
||||
}}).catch(function(){{
|
||||
inl.querySelector('a').textContent='add a voice note';
|
||||
inl.appendChild(document.createTextNode(' (mic denied)'));
|
||||
}});
|
||||
}}
|
||||
inl.querySelector('a').addEventListener('click',function(e){{e.preventDefault();startRec()}});
|
||||
}})();</script>"##,
|
||||
max_dur = config.voice_note_max_duration,
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<span class="guestbook-prompt">{prompt}</span>
|
||||
<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
|
||||
|
|
@ -97,8 +182,7 @@ pub fn render_form(config: &Config) -> String {
|
|||
<label class="guestbook-label">{label_message}</label>
|
||||
<textarea class="guestbook-textarea" name="message" style="width:{tw}px;height:{th}px" required></textarea>
|
||||
{captcha_section}
|
||||
{drawing_section}<input name="url" style="display:none" tabindex="-1" autocomplete="off">
|
||||
<button class="guestbook-button" type="submit">{button}</button>
|
||||
{drawing_section}{voice_note_section}<input name="url" style="display:none" tabindex="-1" autocomplete="off"><button class="guestbook-button" type="submit">{button}</button>
|
||||
</form>"#,
|
||||
prompt = config.form_prompt,
|
||||
label_name = config.label_name,
|
||||
|
|
@ -108,6 +192,7 @@ pub fn render_form(config: &Config) -> String {
|
|||
th = config.textarea_height,
|
||||
captcha_section = captcha_section,
|
||||
drawing_section = drawing_section,
|
||||
voice_note_section = voice_note_section,
|
||||
button = config.button_text,
|
||||
)
|
||||
}
|
||||
|
|
@ -195,14 +280,22 @@ fn render_entry(entry: &Entry, config: &Config) -> String {
|
|||
};
|
||||
let drawing_html = if !entry.meta.drawing.is_empty() {
|
||||
format!(
|
||||
"\n<img class=\"entry-drawing\" src=\"/drawings/{}\">",
|
||||
"<img class=\"entry-drawing\" src=\"/drawings/{}\">",
|
||||
escape_html(&entry.meta.drawing)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let voice_note_html = if !entry.meta.voice_note.is_empty() {
|
||||
format!(
|
||||
"<span class=\"entry-voice-note\"><audio controls preload=\"metadata\" src=\"/voice_notes/{}\"></audio></span>",
|
||||
escape_html(&entry.meta.voice_note)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"\n{header}\n{drawing_html}\n<span class=\"entry-body\">{body}</span>\n\n<span class=\"entry-separator\">{}</span>\n",
|
||||
"\n{header}\n{drawing_html}{voice_note_html}\n<span class=\"entry-body\">{body}</span>\n\n<span class=\"entry-separator\">{}</span>\n",
|
||||
config.separator
|
||||
)
|
||||
}
|
||||
|
|
@ -234,9 +327,10 @@ mod tests {
|
|||
captcha_exact: false,
|
||||
captcha_casesensitive: false,
|
||||
enable_drawings: false,
|
||||
label_drawing: "Draw (optional):".into(),
|
||||
canvas_width: 400,
|
||||
canvas_height: 200,
|
||||
enable_voice_notes: false,
|
||||
voice_note_max_duration: 20,
|
||||
template: None,
|
||||
success_template: None,
|
||||
separator: "---".into(),
|
||||
|
|
@ -259,6 +353,7 @@ mod tests {
|
|||
date: date.into(),
|
||||
website: String::new(),
|
||||
drawing: String::new(),
|
||||
voice_note: String::new(),
|
||||
status: Status::Approved,
|
||||
},
|
||||
body: body.into(),
|
||||
|
|
@ -426,21 +521,20 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_form_shows_canvas_when_drawings_enabled() {
|
||||
fn test_render_form_shows_drawing_toggle_when_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("add a drawing"));
|
||||
assert!(form.contains("guestbook-drawing-toggle"));
|
||||
assert!(form.contains("name=\"drawing\""));
|
||||
assert!(form.contains("reset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_form_hides_canvas_when_drawings_disabled() {
|
||||
fn test_render_form_hides_drawing_when_disabled() {
|
||||
let config = test_config();
|
||||
let form = render_form(&config);
|
||||
assert!(!form.contains("<canvas"));
|
||||
assert!(!form.contains("add a drawing"));
|
||||
assert!(!form.contains("name=\"drawing\""));
|
||||
}
|
||||
|
||||
|
|
@ -504,4 +598,53 @@ mod tests {
|
|||
assert!(html.contains("back"));
|
||||
assert!(html.contains("<style>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_entry_with_voice_note() {
|
||||
let config = test_config();
|
||||
let mut entry = make_entry("alice", "2026-04-10", "Hello!");
|
||||
entry.meta.voice_note = "1744300800_abcd1234.webm".into();
|
||||
let form = render_form(&config);
|
||||
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
|
||||
assert!(html.contains(r#"<audio controls preload="metadata" src="/voice_notes/1744300800_abcd1234.webm">"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_entry_voice_note_works_without_html_injection() {
|
||||
let mut config = test_config();
|
||||
config.enable_html_injection = false;
|
||||
let mut entry = make_entry("alice", "2026-04-10", "<script>xss</script>");
|
||||
entry.meta.voice_note = "1744300800_abcd1234.webm".into();
|
||||
let form = render_form(&config);
|
||||
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
|
||||
assert!(html.contains(r#"<audio controls preload="metadata" src="/voice_notes/1744300800_abcd1234.webm">"#));
|
||||
assert!(html.contains("<script>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_entry_without_voice_note() {
|
||||
let config = test_config();
|
||||
let entry = make_entry("alice", "2026-04-10", "Hello!");
|
||||
let form = render_form(&config);
|
||||
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
|
||||
assert!(!html.contains("<audio"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_form_shows_voice_note_when_enabled() {
|
||||
let mut config = test_config();
|
||||
config.enable_voice_notes = true;
|
||||
let form = render_form(&config);
|
||||
assert!(form.contains("add a voice note"));
|
||||
assert!(form.contains("guestbook-voice-record"));
|
||||
assert!(form.contains("name=\"voice_note\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_form_hides_voice_note_when_disabled() {
|
||||
let config = test_config();
|
||||
let form = render_form(&config);
|
||||
assert!(!form.contains("add a voice note"));
|
||||
assert!(!form.contains("name=\"voice_note\""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ async fn notify(bot: &Bot, chat_id: ChatId, entry: &Entry) {
|
|||
}
|
||||
|
||||
/// Listen for new entries on the channel and send Telegram notifications.
|
||||
pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver<(Entry, Option<Vec<u8>>)>) {
|
||||
while let Some((entry, drawing_bytes)) = rx.recv().await {
|
||||
pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver<(Entry, Option<Vec<u8>>, Option<Vec<u8>>)>) {
|
||||
while let Some((entry, drawing_bytes, voice_bytes)) = rx.recv().await {
|
||||
notify(&bot, chat_id, &entry).await;
|
||||
if let Some(bytes) = drawing_bytes {
|
||||
if let Err(e) = bot.send_photo(
|
||||
|
|
@ -29,6 +29,14 @@ pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver<(Entr
|
|||
tracing::error!("failed to send drawing photo: {e}");
|
||||
}
|
||||
}
|
||||
if let Some(bytes) = voice_bytes {
|
||||
if let Err(e) = bot.send_voice(
|
||||
chat_id,
|
||||
teloxide::types::InputFile::memory(bytes).file_name("voice_note.webm"),
|
||||
).await {
|
||||
tracing::error!("failed to send voice note: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
271
src/web.rs
271
src/web.rs
|
|
@ -18,7 +18,7 @@ use crate::render::{self, DEFAULT_TEMPLATE, render_error_page, render_success_pa
|
|||
|
||||
pub struct AppState {
|
||||
pub config: Config,
|
||||
pub tx: tokio::sync::mpsc::Sender<(Entry, Option<Vec<u8>>)>,
|
||||
pub tx: tokio::sync::mpsc::Sender<(Entry, Option<Vec<u8>>, Option<Vec<u8>>)>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -33,6 +33,8 @@ pub struct SubmitForm {
|
|||
captcha: String,
|
||||
#[serde(default)]
|
||||
drawing: String,
|
||||
#[serde(default)]
|
||||
voice_note: String,
|
||||
}
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
|
|
@ -40,6 +42,7 @@ pub fn router(state: Arc<AppState>) -> Router {
|
|||
.route("/", get(index))
|
||||
.route("/submit", post(submit))
|
||||
.route("/drawings/{filename}", get(serve_drawing))
|
||||
.route("/voice_notes/{filename}", get(serve_voice_note))
|
||||
.layer(DefaultBodyLimit::max(2 * 1024 * 1024))
|
||||
.with_state(state)
|
||||
}
|
||||
|
|
@ -90,6 +93,33 @@ async fn serve_drawing(
|
|||
}
|
||||
}
|
||||
|
||||
async fn serve_voice_note(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AxumPath(filename): AxumPath<String>,
|
||||
) -> Response {
|
||||
if !filename.ends_with(".webm")
|
||||
|| !filename[..filename.len() - 5]
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
let path = state.config.data_dir.join("voice_notes").join(&filename);
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "audio/webm"),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn submit(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Form(form): Form<SubmitForm>,
|
||||
|
|
@ -185,6 +215,34 @@ async fn submit(
|
|||
None
|
||||
};
|
||||
|
||||
// Process voice note if enabled and provided
|
||||
let voice_note_bytes: Option<Vec<u8>> = if state.config.enable_voice_notes && !form.voice_note.is_empty() {
|
||||
let b64 = form.voice_note
|
||||
.strip_prefix("data:audio/webm;codecs=opus;base64,")
|
||||
.unwrap_or("");
|
||||
if b64.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Html(render_error_page(&state.config, "Invalid voice note data.")),
|
||||
};
|
||||
let max = state.config.max_voice_note_bytes();
|
||||
if max > 0 && bytes.len() > max {
|
||||
return Html(render_error_page(&state.config, &format!("Voice note is too large (max {} bytes).", max)));
|
||||
}
|
||||
|
||||
// Validate WebM: magic bytes
|
||||
if bytes.len() < 4 || &bytes[..4] != b"\x1a\x45\xdf\xa3" {
|
||||
return Html(render_error_page(&state.config, "Invalid voice note format."));
|
||||
}
|
||||
|
||||
Some(bytes)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let epoch = now.timestamp();
|
||||
let short_id = &Uuid::new_v4().to_string()[..8];
|
||||
|
|
@ -205,6 +263,20 @@ async fn submit(
|
|||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let voice_note_filename = if let Some(ref bytes) = voice_note_bytes {
|
||||
let vn_name = format!("{prefix}.webm");
|
||||
let vn_dir = state.config.data_dir.join("voice_notes");
|
||||
std::fs::create_dir_all(&vn_dir).ok();
|
||||
if let Err(e) = std::fs::write(vn_dir.join(&vn_name), bytes) {
|
||||
tracing::error!("failed to write voice note: {e}");
|
||||
return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
|
||||
}
|
||||
vn_name
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let entry = Entry {
|
||||
id: filename.trim_end_matches(".txt").to_string(),
|
||||
meta: EntryMeta {
|
||||
|
|
@ -212,6 +284,7 @@ async fn submit(
|
|||
date,
|
||||
website,
|
||||
drawing: drawing_filename,
|
||||
voice_note: voice_note_filename,
|
||||
status: Status::Pending,
|
||||
},
|
||||
body: message,
|
||||
|
|
@ -227,7 +300,7 @@ async fn submit(
|
|||
}
|
||||
|
||||
// Notify telegram task
|
||||
let _ = state.tx.send((entry, drawing_bytes)).await;
|
||||
let _ = state.tx.send((entry, drawing_bytes, voice_note_bytes)).await;
|
||||
|
||||
Html(render_success_page(&state.config))
|
||||
}
|
||||
|
|
@ -262,9 +335,10 @@ mod tests {
|
|||
captcha_exact: false,
|
||||
captcha_casesensitive: false,
|
||||
enable_drawings: false,
|
||||
label_drawing: "Draw (optional):".into(),
|
||||
canvas_width: 400,
|
||||
canvas_height: 200,
|
||||
enable_voice_notes: false,
|
||||
voice_note_max_duration: 20,
|
||||
template: None,
|
||||
success_template: None,
|
||||
separator: "---".into(),
|
||||
|
|
@ -279,7 +353,7 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn test_app(config: Config) -> (Router, tokio::sync::mpsc::Receiver<(Entry, Option<Vec<u8>>)>) {
|
||||
fn test_app(config: Config) -> (Router, tokio::sync::mpsc::Receiver<(Entry, Option<Vec<u8>>, Option<Vec<u8>>)>) {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
||||
let state = Arc::new(AppState { config, tx });
|
||||
(router(state), rx)
|
||||
|
|
@ -602,6 +676,12 @@ mod tests {
|
|||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
fn fake_webm() -> Vec<u8> {
|
||||
let mut webm = vec![0x1A, 0x45, 0xDF, 0xA3];
|
||||
webm.extend_from_slice(&[0; 50]);
|
||||
webm
|
||||
}
|
||||
|
||||
/// Build a fake but valid PNG with the given dimensions.
|
||||
fn fake_png(width: u32, height: u32) -> Vec<u8> {
|
||||
let mut png = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
|
|
@ -818,4 +898,187 @@ mod tests {
|
|||
assert!(body.contains("Name and message are required"));
|
||||
assert!(body.contains("back"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_voice_note() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_voice_notes = true;
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let webm = fake_webm();
|
||||
let voice_data = base64::engine::general_purpose::STANDARD.encode(&webm);
|
||||
let data_url = format!("data:audio/webm;codecs=opus;base64,{voice_data}");
|
||||
let body = format!(
|
||||
"name=alice&message=hello&voice_note={}",
|
||||
urlencoding::encode(&data_url)
|
||||
);
|
||||
let (_, resp) = post_form(&app, &body).await;
|
||||
assert!(resp.contains("pending approval"));
|
||||
|
||||
let entries: Vec<_> = std::fs::read_dir(dir.path().join("entries"))
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(entries.len(), 1);
|
||||
let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap();
|
||||
assert!(content.contains("voice_note = "));
|
||||
|
||||
let voice_notes: Vec<_> = std::fs::read_dir(dir.path().join("voice_notes"))
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(voice_notes.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_without_voice_note() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_voice_notes = true;
|
||||
let (app, _rx) = test_app(config);
|
||||
let (_, resp) = post_form(&app, "name=alice&message=hello").await;
|
||||
assert!(resp.contains("pending approval"));
|
||||
|
||||
let vn_dir = dir.path().join("voice_notes");
|
||||
if vn_dir.exists() {
|
||||
let count = std::fs::read_dir(&vn_dir).unwrap().count();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_voice_note_too_large() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_voice_notes = true;
|
||||
config.voice_note_max_duration = 1;
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let mut webm = vec![0x1A, 0x45, 0xDF, 0xA3];
|
||||
webm.extend_from_slice(&[0; 20_000]);
|
||||
let voice_data = base64::engine::general_purpose::STANDARD.encode(&webm);
|
||||
let data_url = format!("data:audio/webm;codecs=opus;base64,{voice_data}");
|
||||
let body = format!(
|
||||
"name=alice&message=hello&voice_note={}",
|
||||
urlencoding::encode(&data_url)
|
||||
);
|
||||
let (_, resp) = post_form(&app, &body).await;
|
||||
assert!(resp.contains("too large"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_voice_note_rejects_non_webm() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_voice_notes = true;
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let voice_data = base64::engine::general_purpose::STANDARD.encode(b"not a webm file");
|
||||
let data_url = format!("data:audio/webm;codecs=opus;base64,{voice_data}");
|
||||
let body = format!(
|
||||
"name=alice&message=hello&voice_note={}",
|
||||
urlencoding::encode(&data_url)
|
||||
);
|
||||
let (_, resp) = post_form(&app, &body).await;
|
||||
assert!(resp.contains("Invalid voice note"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_voice_note_ignored_when_disabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_voice_notes = false;
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let webm = fake_webm();
|
||||
let voice_data = base64::engine::general_purpose::STANDARD.encode(&webm);
|
||||
let data_url = format!("data:audio/webm;codecs=opus;base64,{voice_data}");
|
||||
let body = format!(
|
||||
"name=alice&message=hello&voice_note={}",
|
||||
urlencoding::encode(&data_url)
|
||||
);
|
||||
let (_, resp) = post_form(&app, &body).await;
|
||||
assert!(resp.contains("pending approval"));
|
||||
|
||||
let entries: Vec<_> = std::fs::read_dir(dir.path().join("entries"))
|
||||
.unwrap()
|
||||
.collect();
|
||||
let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap();
|
||||
assert!(content.contains("voice_note = \"\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_serve_voice_note() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = test_config(dir.path());
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let vn_dir = dir.path().join("voice_notes");
|
||||
std::fs::create_dir_all(&vn_dir).unwrap();
|
||||
let webm_bytes = b"\x1a\x45\xdf\xa3fake";
|
||||
std::fs::write(vn_dir.join("test123.webm"), webm_bytes).unwrap();
|
||||
|
||||
let (status, body) = get_path(&app, "/voice_notes/test123.webm").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body, webm_bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_serve_voice_note_not_found() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = test_config(dir.path());
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let (status, _) = get_path(&app, "/voice_notes/nonexistent.webm").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_serve_voice_note_rejects_path_traversal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = test_config(dir.path());
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let (status, _) = get_path(&app, "/voice_notes/../entries/secret.txt").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_voice_note_full_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_voice_notes = true;
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
// Submit with a voice note
|
||||
let webm = fake_webm();
|
||||
let voice_data = base64::engine::general_purpose::STANDARD.encode(&webm);
|
||||
let data_url = format!("data:audio/webm;codecs=opus;base64,{voice_data}");
|
||||
let body = format!(
|
||||
"name=alice&message=hello&voice_note={}",
|
||||
urlencoding::encode(&data_url)
|
||||
);
|
||||
post_form(&app, &body).await;
|
||||
|
||||
// Approve the entry
|
||||
let entries_dir = dir.path().join("entries");
|
||||
let entry_file = std::fs::read_dir(&entries_dir).unwrap().next().unwrap().unwrap();
|
||||
let content = std::fs::read_to_string(entry_file.path()).unwrap();
|
||||
let id = entry_file.path().file_stem().unwrap().to_str().unwrap().to_string();
|
||||
let mut entry = entries::Entry::parse(&id, &content).unwrap();
|
||||
entry.meta.status = entries::Status::Approved;
|
||||
std::fs::write(entry_file.path(), entry.to_file_contents()).unwrap();
|
||||
|
||||
let vn_filename = entry.meta.voice_note.clone();
|
||||
assert!(!vn_filename.is_empty(), "entry should have a voice_note filename");
|
||||
|
||||
// Verify index shows the voice note
|
||||
let html = get_index(&app).await;
|
||||
assert!(html.contains("entry-voice-note"));
|
||||
assert!(html.contains(&format!("/voice_notes/{vn_filename}")));
|
||||
|
||||
// Verify the voice note file is served
|
||||
let (status, bytes) = get_path(&app, &format!("/voice_notes/{vn_filename}")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(bytes, webm);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@
|
|||
.guestbook-textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.guestbook-button {}
|
||||
.guestbook-button {
|
||||
display: block;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
/* Drawings */
|
||||
.guestbook-canvas {
|
||||
|
|
@ -29,6 +32,13 @@
|
|||
.guestbook-canvas-tools a {
|
||||
cursor: pointer;
|
||||
}
|
||||
.guestbook-drawing-inline a {
|
||||
cursor: pointer;
|
||||
}
|
||||
.guestbook-drawing-content {
|
||||
display: block;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.guestbook-swatch {
|
||||
display: inline-block;
|
||||
width: 0.85em;
|
||||
|
|
@ -51,6 +61,26 @@
|
|||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Voice notes */
|
||||
.guestbook-voice-record.recording {
|
||||
color: red;
|
||||
}
|
||||
.guestbook-voice-timer {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.guestbook-voice-playback:empty {
|
||||
display: none;
|
||||
}
|
||||
.guestbook-voice-playback {
|
||||
display: block;
|
||||
white-space: normal;
|
||||
}
|
||||
audio {
|
||||
display: block;
|
||||
margin-top: 0.6em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
/* Entries */
|
||||
.entry-header {}
|
||||
.entry-date {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue