Compare commits
No commits in common. "459584cb9cca7a801c715a48ed71584fe67b7416" and "73823e84ec98be869c015613c78f73371470e14e" have entirely different histories.
459584cb9c
...
73823e84ec
12 changed files with 127 additions and 756 deletions
22
.env.example
22
.env.example
|
|
@ -59,7 +59,7 @@
|
|||
|
||||
# Custom CSS injected into a style tag.
|
||||
# Classes: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input,
|
||||
# .guestbook-textarea, .guestbook-button, .entry-header, .entry-date, .entry-name,
|
||||
# .guestbook-textarea, .guestbook-button, .entry-header, .entry-name,
|
||||
# .entry-website, .entry-body, .entry-separator
|
||||
# BOOK_STYLE=
|
||||
|
||||
|
|
@ -78,24 +78,12 @@
|
|||
# Label for the message field.
|
||||
# BOOK_LABEL_MESSAGE=Your message:
|
||||
|
||||
# Message textarea width in pixels.
|
||||
# BOOK_TEXTAREA_WIDTH=400
|
||||
# Number of rows for the message textarea.
|
||||
# BOOK_TEXTAREA_ROWS=8
|
||||
|
||||
# Message textarea height in pixels.
|
||||
# BOOK_TEXTAREA_HEIGHT=150
|
||||
# Number of columns for the message textarea.
|
||||
# BOOK_TEXTAREA_COLS=60
|
||||
|
||||
# Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders.
|
||||
# Uses built-in default if unset.
|
||||
# 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
|
||||
|
|
|
|||
18
Cargo.lock
generated
18
Cargo.lock
generated
|
|
@ -101,12 +101,6 @@ version = "0.21.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
|
|
@ -461,7 +455,6 @@ name = "guestbook"
|
|||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"http-body-util",
|
||||
|
|
@ -473,7 +466,6 @@ dependencies = [
|
|||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
|
@ -1186,7 +1178,7 @@ version = "0.11.27"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
|
|
@ -1251,7 +1243,7 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1900,12 +1892,6 @@ dependencies = [
|
|||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name = "guestbook"
|
||||
version = "0.2.1"
|
||||
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 configurable web guestbook made to be easy to use, with entries in plain text files, options for honeypots and captchas to deter spam, and moderation via Telegram bot."
|
||||
license = "MIT"
|
||||
repository = "https://git.ily.rs/lew/guestbook"
|
||||
|
||||
|
|
@ -15,7 +15,6 @@ toml = "0.8"
|
|||
dotenvy = "0.15"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = "0.4"
|
||||
base64 = "0.22"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
|
||||
|
|
@ -23,4 +22,3 @@ tracing-subscriber = "0.3"
|
|||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
tempfile = "3"
|
||||
urlencoding = "2"
|
||||
|
|
|
|||
81
README.md
81
README.md
|
|
@ -3,14 +3,13 @@
|
|||
|
||||
`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),
|
||||
- notifications and moderation via [Telegram](#telegram),
|
||||
- spam prevention via honeypot and/or [captcha](#captcha),
|
||||
- completely 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. 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. 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.
|
||||
|
||||
|
|
@ -49,7 +48,7 @@ This will run the site on localhost on the port you've configured, or `8123` by
|
|||
```nix
|
||||
# flake.nix
|
||||
{
|
||||
inputs.guestbook.url = "git+https://git.ily.rs/lew/guestbook";
|
||||
inputs.guestbook.url = "github:llywelwyn/guestbook";
|
||||
|
||||
outputs = { self, nixpkgs, guestbook, ... }: {
|
||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||
|
|
@ -149,7 +148,7 @@ Running `guestbook` with no env vars will give you a working guestbook on `local
|
|||
|
||||
# Custom CSS injected into a style tag.
|
||||
# Classes: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input,
|
||||
# .guestbook-textarea, .guestbook-button, .entry-header, .entry-date, .entry-name,
|
||||
# .guestbook-textarea, .guestbook-button, .entry-header, .entry-name,
|
||||
# .entry-website, .entry-body, .entry-separator
|
||||
# BOOK_STYLE=
|
||||
|
||||
|
|
@ -168,11 +167,11 @@ Running `guestbook` with no env vars will give you a working guestbook on `local
|
|||
# Label for the message field.
|
||||
# BOOK_LABEL_MESSAGE=Your message:
|
||||
|
||||
# Message textarea width in pixels.
|
||||
# BOOK_TEXTAREA_WIDTH=400
|
||||
# Number of rows for the message textarea.
|
||||
# BOOK_TEXTAREA_ROWS=8
|
||||
|
||||
# Message textarea height in pixels.
|
||||
# BOOK_TEXTAREA_HEIGHT=150
|
||||
# Number of columns for the message textarea.
|
||||
# BOOK_TEXTAREA_COLS=60
|
||||
|
||||
# Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders.
|
||||
# Uses built-in default if unset.
|
||||
|
|
@ -197,24 +196,17 @@ services.guestbook = {
|
|||
forwardAuth = null; # e.g. "localhost:9090"
|
||||
};
|
||||
|
||||
features = {
|
||||
submissions.enable = true;
|
||||
websites.enable = true;
|
||||
drawing = {
|
||||
security = {
|
||||
enableSubmissions = true;
|
||||
enableHtmlInjection = false;
|
||||
enableWebsiteLinks = true;
|
||||
enableHoneypot = true;
|
||||
captcha = {
|
||||
enable = false;
|
||||
canvasWidth = 400;
|
||||
canvasHeight = 200;
|
||||
};
|
||||
security = {
|
||||
htmlInjection.enable = false;
|
||||
honeypot.enable = true;
|
||||
captcha = {
|
||||
enable = false;
|
||||
question = "";
|
||||
answer = "";
|
||||
exact = false;
|
||||
caseSensitive = false;
|
||||
};
|
||||
question = "";
|
||||
answer = "";
|
||||
exact = false;
|
||||
caseSensitive = false;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -241,11 +233,10 @@ services.guestbook = {
|
|||
name = "Your name:";
|
||||
website = "Your website (optional):";
|
||||
message = "Your message:";
|
||||
drawing = "Draw (optional):";
|
||||
};
|
||||
message = {
|
||||
width = 400;
|
||||
height = 150;
|
||||
rows = 8;
|
||||
cols = 60;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -253,16 +244,6 @@ services.guestbook = {
|
|||
|
||||
---
|
||||
|
||||
### Drawing
|
||||
|
||||
Set `BOOK_ENABLE_DRAWINGS=true` to add a drawing canvas to the form. Visitors draw with mouse or touch; on submit, the canvas is converted to a base64 PNG data URL in a hidden field. Drawings are stored as PNGs in `{data_dir}/drawings/` and rendered above the message body, independent of the HTML injection setting.
|
||||
|
||||
Server-side validation checks the PNG magic bytes (`\x89PNG\r\n\x1a\n`), then reads width/height from the IHDR chunk and rejects anything that doesn't match `BOOK_CANVAS_WIDTH` x `BOOK_CANVAS_HEIGHT`. Max file size is derived from canvas dimensions (`w * h * 4`, the raw RGBA ceiling). A 2MB request body limit is enforced on all form submissions.
|
||||
|
||||
When Telegram moderation is enabled, drawings are sent as photos in the notification so you can see 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.
|
||||
|
|
@ -273,20 +254,19 @@ When a visitor submits an entry, the bot sends a message with the entry details
|
|||
|
||||
### 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 `{date}-{short_id}.txt`.
|
||||
|
||||
```
|
||||
+++
|
||||
name = "someone"
|
||||
date = "2026-04-09T12:00:00"
|
||||
date = "2026-04-09"
|
||||
website = "https://example.com"
|
||||
drawing = "1744185600_abcd1234.png"
|
||||
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. To moderate without Telegram, just edit the file and change `status` to `approved` or `denied`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -306,8 +286,8 @@ The `status` field can be `pending`, `approved`, or `denied`. Only approved entr
|
|||
title - Site title (BOOK_SITE_TITLE). Useful in <title> and headings.
|
||||
form - The submission form (labels, inputs, button). Controlled by
|
||||
BOOK_FORM_PROMPT, BOOK_LABEL_NAME, BOOK_LABEL_WEBSITE,
|
||||
BOOK_LABEL_MESSAGE, BOOK_BUTTON_TEXT, BOOK_TEXTAREA_WIDTH,
|
||||
BOOK_TEXTAREA_HEIGHT. Empty when BOOK_ENABLE_SUBMISSIONS=false.
|
||||
BOOK_LABEL_MESSAGE, BOOK_BUTTON_TEXT, BOOK_TEXTAREA_ROWS,
|
||||
BOOK_TEXTAREA_COLS. Empty when BOOK_ENABLE_SUBMISSIONS=false.
|
||||
entries - Approved guestbook entries, newest first. Entry separator
|
||||
controlled by BOOK_SEPARATOR.
|
||||
style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in
|
||||
|
|
@ -360,19 +340,8 @@ entries
|
|||
.guestbook-textarea {}
|
||||
.guestbook-button {}
|
||||
|
||||
/* Drawings */
|
||||
.guestbook-canvas {
|
||||
border: 1px solid #000;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.guestbook-canvas-reset {}
|
||||
.entry-drawing {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Entries */
|
||||
.entry-header {}
|
||||
.entry-date {}
|
||||
.entry-name {}
|
||||
.entry-website {}
|
||||
.entry-body {}
|
||||
|
|
|
|||
162
module.nix
162
module.nix
|
|
@ -57,86 +57,56 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
features = {
|
||||
submissions = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected.";
|
||||
};
|
||||
security = {
|
||||
enableSubmissions = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected.";
|
||||
};
|
||||
|
||||
websites = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Show website field in form and render website links in entries. When false, the input is hidden, submitted values are ignored, and existing links are not displayed.";
|
||||
};
|
||||
enableHtmlInjection = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. Website URLs are always escaped.";
|
||||
};
|
||||
|
||||
drawing = {
|
||||
enable = mkOption {
|
||||
enableWebsiteLinks = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Show website field in form and render website links in entries. When false, the input is hidden, submitted values are ignored, and existing links are not displayed.";
|
||||
};
|
||||
|
||||
enableHoneypot = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Enable honeypot field for spam prevention.";
|
||||
};
|
||||
|
||||
captcha = {
|
||||
enable = mkEnableOption "captcha on submission form";
|
||||
|
||||
question = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = "Captcha question displayed as a label.";
|
||||
};
|
||||
|
||||
answer = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = "Captcha answer to validate against.";
|
||||
};
|
||||
|
||||
exact = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable the drawing canvas in the submission form. Stores PNG files in dataDir/drawings/.";
|
||||
description = "Require exact match. When false, the answer just needs to be contained in the response.";
|
||||
};
|
||||
|
||||
canvasWidth = mkOption {
|
||||
type = types.int;
|
||||
default = 400;
|
||||
description = "Drawing canvas width in pixels.";
|
||||
};
|
||||
|
||||
canvasHeight = mkOption {
|
||||
type = types.int;
|
||||
default = 200;
|
||||
description = "Drawing canvas height in pixels.";
|
||||
};
|
||||
};
|
||||
|
||||
security = {
|
||||
htmlInjection = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. Website URLs are always escaped.";
|
||||
};
|
||||
};
|
||||
|
||||
honeypot = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Enable honeypot field for spam prevention.";
|
||||
};
|
||||
};
|
||||
|
||||
captcha = {
|
||||
enable = mkEnableOption "captcha on submission form";
|
||||
|
||||
question = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = "Captcha question displayed as a label.";
|
||||
};
|
||||
|
||||
answer = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = "Captcha answer to validate against.";
|
||||
};
|
||||
|
||||
exact = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Require exact match. When false, the answer just needs to be contained in the response.";
|
||||
};
|
||||
|
||||
caseSensitive = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Require case-sensitive match.";
|
||||
};
|
||||
caseSensitive = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Require case-sensitive match.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -179,7 +149,7 @@ in
|
|||
css = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = "Custom CSS injected into a style tag. Use class names: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input, .guestbook-textarea, .guestbook-button, .guestbook-canvas, .entry-header, .entry-date, .entry-name, .entry-website, .entry-body, .entry-drawing, .entry-separator";
|
||||
description = "Custom CSS injected into a style tag. Use class names: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input, .guestbook-textarea, .guestbook-button, .entry-header, .entry-name, .entry-website, .entry-body, .entry-separator";
|
||||
};
|
||||
|
||||
cssFile = mkOption {
|
||||
|
|
@ -230,25 +200,19 @@ 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 = {
|
||||
width = mkOption {
|
||||
rows = mkOption {
|
||||
type = types.int;
|
||||
default = 400;
|
||||
description = "Message textarea width in pixels.";
|
||||
default = 8;
|
||||
description = "Number of rows for the message textarea.";
|
||||
};
|
||||
|
||||
height = mkOption {
|
||||
cols = mkOption {
|
||||
type = types.int;
|
||||
default = 150;
|
||||
description = "Message textarea height in pixels.";
|
||||
default = 60;
|
||||
description = "Number of columns for the message textarea.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -266,16 +230,15 @@ in
|
|||
BOOK_DATA_DIR = cfg.dataDir;
|
||||
BOOK_SITE_TITLE = cfg.siteTitle;
|
||||
|
||||
BOOK_ENABLE_SUBMISSIONS = if cfg.features.submissions.enable then "true" else "false";
|
||||
BOOK_ENABLE_WEBSITE_LINKS = if cfg.features.websites.enable then "true" else "false";
|
||||
BOOK_ENABLE_DRAWINGS = if cfg.features.drawing.enable then "true" else "false";
|
||||
BOOK_ENABLE_HTML_INJECTION = if cfg.features.security.htmlInjection.enable then "true" else "false";
|
||||
BOOK_ENABLE_HONEYPOT = if cfg.features.security.honeypot.enable then "true" else "false";
|
||||
BOOK_ENABLE_CAPTCHA = if cfg.features.security.captcha.enable then "true" else "false";
|
||||
BOOK_CAPTCHA_QUESTION = cfg.features.security.captcha.question;
|
||||
BOOK_CAPTCHA_ANSWER = cfg.features.security.captcha.answer;
|
||||
BOOK_CAPTCHA_EXACT = if cfg.features.security.captcha.exact then "true" else "false";
|
||||
BOOK_CAPTCHA_CASESENSITIVE = if cfg.features.security.captcha.caseSensitive then "true" else "false";
|
||||
BOOK_ENABLE_HONEYPOT = if cfg.security.enableHoneypot then "true" else "false";
|
||||
BOOK_ENABLE_SUBMISSIONS = if cfg.security.enableSubmissions then "true" else "false";
|
||||
BOOK_ENABLE_HTML_INJECTION = if cfg.security.enableHtmlInjection then "true" else "false";
|
||||
BOOK_ENABLE_WEBSITE_LINKS = if cfg.security.enableWebsiteLinks then "true" else "false";
|
||||
BOOK_ENABLE_CAPTCHA = if cfg.security.captcha.enable then "true" else "false";
|
||||
BOOK_CAPTCHA_QUESTION = cfg.security.captcha.question;
|
||||
BOOK_CAPTCHA_ANSWER = cfg.security.captcha.answer;
|
||||
BOOK_CAPTCHA_EXACT = if cfg.security.captcha.exact then "true" else "false";
|
||||
BOOK_CAPTCHA_CASESENSITIVE = if cfg.security.captcha.caseSensitive then "true" else "false";
|
||||
BOOK_MAX_NAME_LENGTH = toString cfg.limits.name;
|
||||
BOOK_MAX_MESSAGE_LENGTH = toString cfg.limits.message;
|
||||
BOOK_MAX_WEBSITE_LENGTH = toString cfg.limits.website;
|
||||
|
|
@ -286,11 +249,8 @@ 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_TEXTAREA_WIDTH = toString cfg.styles.message.width;
|
||||
BOOK_TEXTAREA_HEIGHT = toString cfg.styles.message.height;
|
||||
BOOK_TEXTAREA_ROWS = toString cfg.styles.message.rows;
|
||||
BOOK_TEXTAREA_COLS = toString cfg.styles.message.cols;
|
||||
} // lib.optionalAttrs (cfg.styles.cssFile != null) {
|
||||
BOOK_STYLE_FILE = cfg.styles.cssFile;
|
||||
} // lib.optionalAttrs (cfg.styles.templateFile != null) {
|
||||
|
|
@ -301,7 +261,7 @@ in
|
|||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStartPre = "+${pkgs.writeShellScript "guestbook-prepare" ''
|
||||
mkdir -p ${cfg.dataDir}/entries ${cfg.dataDir}/drawings
|
||||
mkdir -p ${cfg.dataDir}/entries
|
||||
chown -R ${cfg.user}:${cfg.group} ${cfg.dataDir}
|
||||
''}";
|
||||
Restart = "on-failure";
|
||||
|
|
|
|||
|
|
@ -21,10 +21,6 @@ pub struct Config {
|
|||
pub captcha_answer: String,
|
||||
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 template: Option<String>,
|
||||
pub separator: String,
|
||||
pub style: String,
|
||||
|
|
@ -33,8 +29,8 @@ pub struct Config {
|
|||
pub label_name: String,
|
||||
pub label_website: String,
|
||||
pub label_message: String,
|
||||
pub textarea_width: u32,
|
||||
pub textarea_height: u32,
|
||||
pub textarea_rows: u32,
|
||||
pub textarea_cols: u32,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -42,12 +38,6 @@ impl Config {
|
|||
format!("127.0.0.1:{}", self.port)
|
||||
}
|
||||
|
||||
/// Maximum drawing file size: width * height * 4 (raw RGBA).
|
||||
/// Any valid PNG from the configured canvas will be smaller than this.
|
||||
pub fn max_drawing_bytes(&self) -> usize {
|
||||
self.canvas_width as usize * self.canvas_height as usize * 4
|
||||
}
|
||||
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
Ok(Config {
|
||||
port: env::var("BOOK_PORT")
|
||||
|
|
@ -101,19 +91,6 @@ impl Config {
|
|||
captcha_casesensitive: env::var("BOOK_CAPTCHA_CASESENSITIVE")
|
||||
.map(|v| v != "false")
|
||||
.unwrap_or(false),
|
||||
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()
|
||||
.map_err(|_| "BOOK_CANVAS_WIDTH must be a number")?,
|
||||
canvas_height: env::var("BOOK_CANVAS_HEIGHT")
|
||||
.unwrap_or_else(|_| "200".into())
|
||||
.parse()
|
||||
.map_err(|_| "BOOK_CANVAS_HEIGHT must be a number")?,
|
||||
separator: env::var("BOOK_SEPARATOR")
|
||||
.unwrap_or_else(|_| "------------------------------------------------------------".into()),
|
||||
template: env::var("BOOK_TEMPLATE").ok().map(|path| {
|
||||
|
|
@ -138,14 +115,14 @@ impl Config {
|
|||
.unwrap_or_else(|_| "Your website (optional):".into()),
|
||||
label_message: env::var("BOOK_LABEL_MESSAGE")
|
||||
.unwrap_or_else(|_| "Your message:".into()),
|
||||
textarea_width: env::var("BOOK_TEXTAREA_WIDTH")
|
||||
.unwrap_or_else(|_| "400".into())
|
||||
textarea_rows: env::var("BOOK_TEXTAREA_ROWS")
|
||||
.unwrap_or_else(|_| "8".into())
|
||||
.parse()
|
||||
.map_err(|_| "BOOK_TEXTAREA_WIDTH must be a number")?,
|
||||
textarea_height: env::var("BOOK_TEXTAREA_HEIGHT")
|
||||
.unwrap_or_else(|_| "150".into())
|
||||
.map_err(|_| "BOOK_TEXTAREA_ROWS must be a number")?,
|
||||
textarea_cols: env::var("BOOK_TEXTAREA_COLS")
|
||||
.unwrap_or_else(|_| "60".into())
|
||||
.parse()
|
||||
.map_err(|_| "BOOK_TEXTAREA_HEIGHT must be a number")?,
|
||||
.map_err(|_| "BOOK_TEXTAREA_COLS must be a number")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -265,19 +242,4 @@ mod tests {
|
|||
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
|
||||
env::remove_var("BOOK_ENABLE_HTML_INJECTION");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_drawings_default() {
|
||||
let _lock = ENV_LOCK.lock().unwrap();
|
||||
env::remove_var("BOOK_ENABLE_DRAWINGS");
|
||||
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
|
||||
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
assert!(!config.enable_drawings);
|
||||
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):");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ pub struct EntryMeta {
|
|||
pub date: String,
|
||||
#[serde(default)]
|
||||
pub website: String,
|
||||
#[serde(default)]
|
||||
pub drawing: String,
|
||||
pub status: Status,
|
||||
}
|
||||
|
||||
|
|
@ -185,44 +183,4 @@ Hello world!"#;
|
|||
let result = Entry::parse("x", "no frontmatter here");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_entry_with_drawing() {
|
||||
let contents = r#"+++
|
||||
name = "alice"
|
||||
date = "2026-04-09"
|
||||
status = "approved"
|
||||
drawing = "abc123.png"
|
||||
+++
|
||||
Hello!"#;
|
||||
let entry = Entry::parse("test", contents).unwrap();
|
||||
assert_eq!(entry.meta.drawing, "abc123.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_entry_without_drawing() {
|
||||
let contents = r#"+++
|
||||
name = "bob"
|
||||
date = "2026-04-09"
|
||||
status = "pending"
|
||||
+++
|
||||
Hi!"#;
|
||||
let entry = Entry::parse("test", contents).unwrap();
|
||||
assert_eq!(entry.meta.drawing, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_with_drawing() {
|
||||
let contents = r#"+++
|
||||
name = "alice"
|
||||
date = "2026-04-09"
|
||||
status = "approved"
|
||||
drawing = "abc123.png"
|
||||
+++
|
||||
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.drawing, "abc123.png");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(32);
|
||||
|
||||
// Spawn telegram tasks if configured
|
||||
match (&config.telegram_bot_token, config.telegram_chat_id) {
|
||||
|
|
|
|||
130
src/render.rs
130
src/render.rs
|
|
@ -38,47 +38,6 @@ pub fn render_form(config: &Config) -> String {
|
|||
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!(
|
||||
r#"<span class="guestbook-prompt">{prompt}</span>
|
||||
<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
|
||||
|
|
@ -86,9 +45,8 @@ pub fn render_form(config: &Config) -> String {
|
|||
<input class="guestbook-input" name="name" required>
|
||||
{website_section}
|
||||
<label class="guestbook-label">{label_message}</label>
|
||||
<textarea class="guestbook-textarea" name="message" style="width:{tw}px;height:{th}px" required></textarea>
|
||||
<textarea class="guestbook-textarea" name="message" rows="{rows}" cols="{cols}" required></textarea>
|
||||
{captcha_section}
|
||||
{drawing_section}
|
||||
<input name="url" style="display:none" tabindex="-1" autocomplete="off">
|
||||
<button class="guestbook-button" type="submit">{button}</button>
|
||||
</form>"#,
|
||||
|
|
@ -96,10 +54,9 @@ pub fn render_form(config: &Config) -> String {
|
|||
label_name = config.label_name,
|
||||
website_section = website_section,
|
||||
label_message = config.label_message,
|
||||
tw = config.textarea_width,
|
||||
th = config.textarea_height,
|
||||
rows = config.textarea_rows,
|
||||
cols = config.textarea_cols,
|
||||
captcha_section = captcha_section,
|
||||
drawing_section = drawing_section,
|
||||
button = config.button_text,
|
||||
)
|
||||
}
|
||||
|
|
@ -127,7 +84,7 @@ fn render_entry(entry: &Entry, config: &Config) -> String {
|
|||
escape_html(&entry.meta.name)
|
||||
};
|
||||
let mut header = format!(
|
||||
"<span class=\"entry-header\"><span class=\"entry-date\">{}</span> - <span class=\"entry-name\">{}</span>",
|
||||
"<span class=\"entry-header\">{} - <span class=\"entry-name\">{}</span>",
|
||||
&entry.meta.date[..10], name
|
||||
);
|
||||
if config.enable_website_links && !entry.meta.website.is_empty() {
|
||||
|
|
@ -143,16 +100,8 @@ fn render_entry(entry: &Entry, config: &Config) -> String {
|
|||
} else {
|
||||
escape_html(&entry.body)
|
||||
};
|
||||
let drawing_html = if !entry.meta.drawing.is_empty() {
|
||||
format!(
|
||||
"\n<img class=\"entry-drawing\" src=\"/drawings/{}\">",
|
||||
escape_html(&entry.meta.drawing)
|
||||
)
|
||||
} 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\n<span class=\"entry-body\">{body}</span>\n\n<span class=\"entry-separator\">{}</span>\n",
|
||||
config.separator
|
||||
)
|
||||
}
|
||||
|
|
@ -183,10 +132,6 @@ mod tests {
|
|||
captcha_answer: String::new(),
|
||||
captcha_exact: false,
|
||||
captcha_casesensitive: false,
|
||||
enable_drawings: false,
|
||||
label_drawing: "Draw (optional):".into(),
|
||||
canvas_width: 400,
|
||||
canvas_height: 200,
|
||||
template: None,
|
||||
separator: "---".into(),
|
||||
style: String::new(),
|
||||
|
|
@ -195,8 +140,8 @@ mod tests {
|
|||
label_name: "Your name:".into(),
|
||||
label_website: "Your website (optional):".into(),
|
||||
label_message: "Your message:".into(),
|
||||
textarea_width: 400,
|
||||
textarea_height: 150,
|
||||
textarea_rows: 8,
|
||||
textarea_cols: 60,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +152,6 @@ mod tests {
|
|||
name: name.into(),
|
||||
date: date.into(),
|
||||
website: String::new(),
|
||||
drawing: String::new(),
|
||||
status: Status::Approved,
|
||||
},
|
||||
body: body.into(),
|
||||
|
|
@ -297,11 +241,11 @@ mod tests {
|
|||
#[test]
|
||||
fn test_render_form_custom_textarea() {
|
||||
let mut config = test_config();
|
||||
config.textarea_width = 500;
|
||||
config.textarea_height = 200;
|
||||
config.textarea_rows = 12;
|
||||
config.textarea_cols = 40;
|
||||
let form = render_form(&config);
|
||||
assert!(form.contains("width:500px"));
|
||||
assert!(form.contains("height:200px"));
|
||||
assert!(form.contains("rows=\"12\""));
|
||||
assert!(form.contains("cols=\"40\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -373,56 +317,4 @@ mod tests {
|
|||
"<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("<img class=\"entry-drawing\""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use crate::entries::{self, Entry, Status};
|
|||
|
||||
/// Send a notification to Telegram about a new entry.
|
||||
async fn notify(bot: &Bot, chat_id: ChatId, entry: &Entry) {
|
||||
let short_id = entry.id.split('_').last().unwrap_or(&entry.id);
|
||||
let short_id = entry.id.split('-').last().unwrap_or(&entry.id);
|
||||
let text = format!(
|
||||
"New guestbook entry:\n\nName: {}\nWebsite: {}\n\n{}\n\n/allow_{}\n/deny_{}",
|
||||
entry.meta.name, entry.meta.website, entry.body, short_id, short_id
|
||||
|
|
@ -18,17 +18,9 @@ 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>) {
|
||||
while let Some(entry) = rx.recv().await {
|
||||
notify(&bot, chat_id, &entry).await;
|
||||
if let Some(bytes) = drawing_bytes {
|
||||
if let Err(e) = bot.send_photo(
|
||||
chat_id,
|
||||
teloxide::types::InputFile::memory(bytes).file_name("drawing.png"),
|
||||
).await {
|
||||
tracing::error!("failed to send drawing photo: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
339
src/web.rs
339
src/web.rs
|
|
@ -1,13 +1,9 @@
|
|||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
extract::Path as AxumPath,
|
||||
extract::State,
|
||||
http::{header, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
response::Html,
|
||||
routing::{get, post},
|
||||
Form, Router,
|
||||
};
|
||||
use base64::Engine;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -18,7 +14,7 @@ use crate::render::{self, DEFAULT_TEMPLATE};
|
|||
|
||||
pub struct AppState {
|
||||
pub config: Config,
|
||||
pub tx: tokio::sync::mpsc::Sender<(Entry, Option<Vec<u8>>)>,
|
||||
pub tx: tokio::sync::mpsc::Sender<Entry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -31,16 +27,12 @@ pub struct SubmitForm {
|
|||
url: String, // honeypot
|
||||
#[serde(default)]
|
||||
captcha: String,
|
||||
#[serde(default)]
|
||||
drawing: String,
|
||||
}
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/submit", post(submit))
|
||||
.route("/drawings/{filename}", get(serve_drawing))
|
||||
.layer(DefaultBodyLimit::max(2 * 1024 * 1024))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
|
|
@ -62,34 +54,6 @@ async fn index(State(state): State<Arc<AppState>>) -> Html<String> {
|
|||
Html(html)
|
||||
}
|
||||
|
||||
async fn serve_drawing(
|
||||
State(state): State<Arc<AppState>>,
|
||||
AxumPath(filename): AxumPath<String>,
|
||||
) -> Response {
|
||||
// Validate filename: only safe chars + .png
|
||||
if !filename.ends_with(".png")
|
||||
|| !filename[..filename.len() - 4]
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
let path = state.config.data_dir.join("drawings").join(&filename);
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "image/png"),
|
||||
(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>,
|
||||
|
|
@ -152,66 +116,17 @@ async fn submit(
|
|||
return Html(format!("Message is too long (max {max_msg} chars)."));
|
||||
}
|
||||
|
||||
// Process drawing if enabled and provided
|
||||
let drawing_bytes: Option<Vec<u8>> = if state.config.enable_drawings && !form.drawing.is_empty() {
|
||||
let b64 = form.drawing
|
||||
.strip_prefix("data:image/png;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("Invalid drawing data.".to_string()),
|
||||
};
|
||||
let max = state.config.max_drawing_bytes();
|
||||
if max > 0 && bytes.len() > max {
|
||||
return Html(format!("Drawing is too large (max {} bytes).", max));
|
||||
}
|
||||
|
||||
// Validate PNG: magic bytes + IHDR dimensions match configured canvas
|
||||
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" {
|
||||
return Html("Invalid drawing format.".to_string());
|
||||
}
|
||||
let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
|
||||
let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
if width != state.config.canvas_width || height != state.config.canvas_height {
|
||||
return Html("Invalid drawing dimensions.".to_string());
|
||||
}
|
||||
|
||||
Some(bytes)
|
||||
}
|
||||
} 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");
|
||||
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");
|
||||
|
||||
// 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");
|
||||
std::fs::create_dir_all(&drawings_dir).ok();
|
||||
if let Err(e) = std::fs::write(drawings_dir.join(&drawing_name), bytes) {
|
||||
tracing::error!("failed to write drawing: {e}");
|
||||
return Html("Something went wrong. Please try again.".to_string());
|
||||
}
|
||||
drawing_name
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let entry = Entry {
|
||||
id: filename.trim_end_matches(".txt").to_string(),
|
||||
meta: EntryMeta {
|
||||
name,
|
||||
date,
|
||||
website,
|
||||
drawing: drawing_filename,
|
||||
status: Status::Pending,
|
||||
},
|
||||
body: message,
|
||||
|
|
@ -227,7 +142,7 @@ async fn submit(
|
|||
}
|
||||
|
||||
// Notify telegram task
|
||||
let _ = state.tx.send((entry, drawing_bytes)).await;
|
||||
let _ = state.tx.send(entry).await;
|
||||
|
||||
Html("Thanks! Your message is pending approval.".to_string())
|
||||
}
|
||||
|
|
@ -237,7 +152,6 @@ mod tests {
|
|||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use base64::Engine;
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
|
|
@ -261,10 +175,6 @@ mod tests {
|
|||
captcha_answer: String::new(),
|
||||
captcha_exact: false,
|
||||
captcha_casesensitive: false,
|
||||
enable_drawings: false,
|
||||
label_drawing: "Draw (optional):".into(),
|
||||
canvas_width: 400,
|
||||
canvas_height: 200,
|
||||
template: None,
|
||||
separator: "---".into(),
|
||||
style: String::new(),
|
||||
|
|
@ -273,12 +183,12 @@ mod tests {
|
|||
label_name: "Your name:".into(),
|
||||
label_website: "Your website (optional):".into(),
|
||||
label_message: "Your message:".into(),
|
||||
textarea_width: 400,
|
||||
textarea_height: 150,
|
||||
textarea_rows: 8,
|
||||
textarea_cols: 60,
|
||||
}
|
||||
}
|
||||
|
||||
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>) {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
||||
let state = Arc::new(AppState { config, tx });
|
||||
(router(state), rx)
|
||||
|
|
@ -553,235 +463,4 @@ mod tests {
|
|||
assert!(html.contains("What is 2+2?"));
|
||||
assert!(html.contains("name=\"captcha\""));
|
||||
}
|
||||
|
||||
async fn get_path(app: &Router, path: &str) -> (StatusCode, Vec<u8>) {
|
||||
let req = Request::builder()
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes().to_vec();
|
||||
(status, bytes)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_serve_drawing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = test_config(dir.path());
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let drawings_dir = dir.path().join("drawings");
|
||||
std::fs::create_dir_all(&drawings_dir).unwrap();
|
||||
let png_bytes = b"\x89PNG\r\n\x1a\nfake";
|
||||
std::fs::write(drawings_dir.join("test123.png"), png_bytes).unwrap();
|
||||
|
||||
let (status, body) = get_path(&app, "/drawings/test123.png").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body, png_bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_serve_drawing_not_found() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = test_config(dir.path());
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let (status, _) = get_path(&app, "/drawings/nonexistent.png").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_serve_drawing_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, "/drawings/../entries/secret.txt").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// 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];
|
||||
png.extend_from_slice(&13u32.to_be_bytes());
|
||||
png.extend_from_slice(b"IHDR");
|
||||
png.extend_from_slice(&width.to_be_bytes());
|
||||
png.extend_from_slice(&height.to_be_bytes());
|
||||
png.extend_from_slice(&[8, 6, 0, 0, 0]);
|
||||
png.extend_from_slice(&[0, 0, 0, 0]);
|
||||
png
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_drawing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_drawings = true;
|
||||
config.canvas_width = 400;
|
||||
config.canvas_height = 200;
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let png = fake_png(400, 200);
|
||||
let drawing_data = base64::engine::general_purpose::STANDARD.encode(&png);
|
||||
let data_url = format!("data:image/png;base64,{drawing_data}");
|
||||
let body = format!(
|
||||
"name=alice&message=hello&drawing={}",
|
||||
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("drawing = "));
|
||||
|
||||
let drawings: Vec<_> = std::fs::read_dir(dir.path().join("drawings"))
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(drawings.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_without_drawing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_drawings = true;
|
||||
let (app, _rx) = test_app(config);
|
||||
let (_, resp) = post_form(&app, "name=alice&message=hello").await;
|
||||
assert!(resp.contains("pending approval"));
|
||||
|
||||
let drawings_dir = dir.path().join("drawings");
|
||||
if drawings_dir.exists() {
|
||||
let count = std::fs::read_dir(&drawings_dir).unwrap().count();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_drawing_too_large() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_drawings = true;
|
||||
config.canvas_width = 1;
|
||||
config.canvas_height = 1;
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
// PNG with dimensions 1x1 — max_drawing_bytes() is 4, but the fake_png itself is 33 bytes
|
||||
let png = fake_png(1, 1);
|
||||
let drawing_data = base64::engine::general_purpose::STANDARD.encode(&png);
|
||||
let data_url = format!("data:image/png;base64,{drawing_data}");
|
||||
let body = format!(
|
||||
"name=alice&message=hello&drawing={}",
|
||||
urlencoding::encode(&data_url)
|
||||
);
|
||||
let (_, resp) = post_form(&app, &body).await;
|
||||
assert!(resp.contains("too large"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_drawing_rejects_non_png() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_drawings = true;
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let drawing_data = base64::engine::general_purpose::STANDARD.encode(b"not a png file at all");
|
||||
let data_url = format!("data:image/png;base64,{drawing_data}");
|
||||
let body = format!(
|
||||
"name=alice&message=hello&drawing={}",
|
||||
urlencoding::encode(&data_url)
|
||||
);
|
||||
let (_, resp) = post_form(&app, &body).await;
|
||||
assert!(resp.contains("Invalid drawing"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_drawing_rejects_wrong_dimensions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_drawings = true;
|
||||
config.canvas_width = 400;
|
||||
config.canvas_height = 200;
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let png = fake_png(1920, 1080);
|
||||
let drawing_data = base64::engine::general_purpose::STANDARD.encode(&png);
|
||||
let data_url = format!("data:image/png;base64,{drawing_data}");
|
||||
let body = format!(
|
||||
"name=alice&message=hello&drawing={}",
|
||||
urlencoding::encode(&data_url)
|
||||
);
|
||||
let (_, resp) = post_form(&app, &body).await;
|
||||
assert!(resp.contains("Invalid drawing dimensions"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_drawing_ignored_when_disabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_drawings = false;
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
let png = fake_png(400, 200);
|
||||
let drawing_data = base64::engine::general_purpose::STANDARD.encode(&png);
|
||||
let data_url = format!("data:image/png;base64,{drawing_data}");
|
||||
let body = format!(
|
||||
"name=alice&message=hello&drawing={}",
|
||||
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("drawing = \"\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drawing_full_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_drawings = true;
|
||||
config.canvas_width = 400;
|
||||
config.canvas_height = 200;
|
||||
let (app, _rx) = test_app(config);
|
||||
|
||||
// Submit with a drawing
|
||||
let png = fake_png(400, 200);
|
||||
let drawing_data = base64::engine::general_purpose::STANDARD.encode(&png);
|
||||
let data_url = format!("data:image/png;base64,{drawing_data}");
|
||||
let body = format!(
|
||||
"name=alice&message=hello&drawing={}",
|
||||
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 drawing_filename = entry.meta.drawing.clone();
|
||||
assert!(!drawing_filename.is_empty(), "entry should have a drawing filename");
|
||||
|
||||
// Verify index shows the drawing
|
||||
let html = get_index(&app).await;
|
||||
assert!(html.contains("entry-drawing"));
|
||||
assert!(html.contains(&format!("/drawings/{drawing_filename}")));
|
||||
|
||||
// Verify the drawing file is served
|
||||
let (status, bytes) = get_path(&app, &format!("/drawings/{drawing_filename}")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(bytes, png);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,24 +12,11 @@
|
|||
.guestbook-form {}
|
||||
.guestbook-label {}
|
||||
.guestbook-input {}
|
||||
.guestbook-textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.guestbook-textarea {}
|
||||
.guestbook-button {}
|
||||
|
||||
/* Drawings */
|
||||
.guestbook-canvas {
|
||||
border: 1px solid #000;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.guestbook-canvas-reset {}
|
||||
.entry-drawing {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Entries */
|
||||
.entry-header {}
|
||||
.entry-date {}
|
||||
.entry-name {}
|
||||
.entry-website {}
|
||||
.entry-body {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue