Skip to content

Commit c578a1d

Browse files
committed
Add polls, thread IDs & markup enhancements
Bump version to 1.0.4 and implement several feature additions and markup/UI improvements: - Database: add polls, poll_options and poll_votes tables with UNIQUE(poll_id, ip_hash) to enforce one vote per IP; helper queries for creating polls, fetching poll data (with vote counts and user choice), casting votes, and resolving poll context. - Handlers: accept poll fields in new-thread multipart parsing; create polls when OP supplies question + ≥2 options (duration clamped 1 minute–30 days); new POST /vote handler to cast votes with CSRF and expiry checks. - Templates/UI: show permanent thread numeric badges and clickable [#id] links; new poll creator UI in new-thread form (add/remove options, duration), poll rendering on thread pages (voting form and results view anchored at #poll); markup hint bar; resizable expanded images and media improvements. - Sanitizer/markup: add cross-board links (>>>/board/123 and >>>/board/), spoiler tags, emoji shortcodes, improved greentext handling (collapsible groups), URL/reply handling and ordering of transforms; word filters run on raw text before escaping. - Static/CSS: per-board uploads route (/boards), styles for thread IDs, spoilers, poll UI, cross-board links, and media resize/rotation; fix greentext/quote class mismatch. Also update CHANGELOG with feature list and adjust upload/data directory naming. The commit focuses on adding polls and richer post markup while preserving XSS-safe escaping and enforcing CSRF and vote uniqueness.
1 parent 7d890a7 commit c578a1d

11 files changed

Lines changed: 1053 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@
22

33
All notable changes to RustChan will be documented in this file.
44

5+
## [1.0.4] - 2026-03-03
6+
7+
### Added
8+
- **Thread IDs** — every thread is now assigned a permanent numeric ID displayed as a badge (`Thread No.1234`) at the top of its page. Board index thread summaries show a clickable `[ #1234 ]` link beside each post number.
9+
- **Cross-board links** — post bodies now parse `>>>/board/123` into a clickable link to that thread and `>>>/board/` into a board index link. Cross-board links are styled in amber to distinguish them from local reply links.
10+
- **Emoji shortcodes** — 25 shortcodes supported in post bodies (e.g. `:fire:` → 🔥, `:think:` → 🤔, `:based:` → 🗿, `:kek:` → 🤣). Applied after HTML transforms to avoid conflicts.
11+
- **Spoiler tags**`[spoiler]text[/spoiler]` hides content behind a same-color block; clicking or hovering reveals it with a smooth transition.
12+
- **Markup hint bar** — a compact row of syntax reminders is shown below the body textarea in the new thread form listing available markup options.
13+
- **Thread polls** — the new thread form includes a collapsible `[ 📊 Add a Poll ]` section. Polls are OP-only, support 2–10 options (dynamically added/removed), and require a duration in hours or minutes (clamped to 1 minute–30 days). Votes are cast via a radio-button form, one vote per IP enforced at the database level. Results display as a percentage bar chart after voting or once the poll closes. Polls are anchored at `#poll` on their thread page.
14+
- **Resizable expanded images** — expanded images support `resize: both`, allowing users to drag the corner to any size without reloading.
15+
- **Per-board upload directories** — files are now stored under `rustchan-data/boards/{board}/` and thumbnails under `rustchan-data/boards/{board}/thumbs/` for clean per-board organisation.
16+
17+
### Changed
18+
- **Data directory renamed** from `chan-data/` to `rustchan-data/` for clarity.
19+
- **Upload directory renamed** from `uploads/` to `boards/` inside the data directory. The static file route changed from `/uploads/` to `/boards/` accordingly.
20+
- **Bold** (`**text**`) and **italic** (`__text__`) markup now render correctly in all post bodies.
21+
22+
### Fixed
23+
- Greentext CSS class mismatch — renderer emits `class="quote"` but the stylesheet only targeted `.greentext`; both are now covered.
24+
- Spoiler CSS specificity — `.post-body` color was overriding the spoiler hide rule; selectors updated to `.post-body .spoiler`.
25+
- Poll "Question" input overflowing the form on narrow layouts — label and input now use `width: 100%; box-sizing: border-box` and `min-width: 0`.
26+
27+
---
28+
529
## [1.0.3] - 2026-03-03
630

731
### Changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rustchan"
3-
version = "1.0.3"
3+
version = "1.0.4"
44
edition = "2021"
55
# FIX[LOW-1]: Removed hardware-specific description. This binary is portable
66
# and targets any architecture/OS that Rust supports.

src/db.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,33 @@ fn create_schema(conn: &rusqlite::Connection) -> Result<()> {
149149
replacement TEXT NOT NULL
150150
);
151151
152+
-- Polls (one per thread, OP only)
153+
CREATE TABLE IF NOT EXISTS polls (
154+
id INTEGER PRIMARY KEY AUTOINCREMENT,
155+
thread_id INTEGER NOT NULL UNIQUE REFERENCES threads(id) ON DELETE CASCADE,
156+
question TEXT NOT NULL,
157+
expires_at INTEGER NOT NULL,
158+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
159+
);
160+
161+
-- Poll options
162+
CREATE TABLE IF NOT EXISTS poll_options (
163+
id INTEGER PRIMARY KEY AUTOINCREMENT,
164+
poll_id INTEGER NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
165+
text TEXT NOT NULL,
166+
position INTEGER NOT NULL DEFAULT 0
167+
);
168+
169+
-- Poll votes — one per (poll, ip_hash) pair
170+
CREATE TABLE IF NOT EXISTS poll_votes (
171+
id INTEGER PRIMARY KEY AUTOINCREMENT,
172+
poll_id INTEGER NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
173+
option_id INTEGER NOT NULL REFERENCES poll_options(id) ON DELETE CASCADE,
174+
ip_hash TEXT NOT NULL,
175+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
176+
UNIQUE(poll_id, ip_hash)
177+
);
178+
152179
-- Indices for common query patterns
153180
CREATE INDEX IF NOT EXISTS idx_threads_board_sticky_bumped
154181
ON threads(board_id, sticky DESC, bumped_at DESC);
@@ -171,6 +198,7 @@ fn create_schema(conn: &rusqlite::Connection) -> Result<()> {
171198
let migrations = [
172199
"ALTER TABLE boards ADD COLUMN allow_video INTEGER NOT NULL DEFAULT 1",
173200
"ALTER TABLE boards ADD COLUMN allow_tripcodes INTEGER NOT NULL DEFAULT 1",
201+
// Poll tables added later — CREATE TABLE IF NOT EXISTS handles this gracefully
174202
];
175203
for sql in &migrations {
176204
let _ = conn.execute(sql, []); // ignore "duplicate column" errors
@@ -849,6 +877,136 @@ pub fn record_file_hash(
849877
Ok(())
850878
}
851879

880+
// ─── Poll queries ─────────────────────────────────────────────────────────────
881+
882+
/// Create a poll with its options atomically.
883+
pub fn create_poll(
884+
conn: &rusqlite::Connection,
885+
thread_id: i64,
886+
question: &str,
887+
options: &[String],
888+
expires_at: i64,
889+
) -> Result<i64> {
890+
conn.execute(
891+
"INSERT INTO polls (thread_id, question, expires_at) VALUES (?1, ?2, ?3)",
892+
params![thread_id, question, expires_at],
893+
)?;
894+
let poll_id = conn.last_insert_rowid();
895+
for (i, text) in options.iter().enumerate() {
896+
conn.execute(
897+
"INSERT INTO poll_options (poll_id, text, position) VALUES (?1, ?2, ?3)",
898+
params![poll_id, text, i as i64],
899+
)?;
900+
}
901+
Ok(poll_id)
902+
}
903+
904+
/// Fetch the full poll for a thread including vote counts and the user's choice.
905+
pub fn get_poll_for_thread(
906+
conn: &rusqlite::Connection,
907+
thread_id: i64,
908+
ip_hash: &str,
909+
) -> Result<Option<crate::models::PollData>> {
910+
let now = chrono::Utc::now().timestamp();
911+
912+
let poll_row = conn
913+
.query_row(
914+
"SELECT id, thread_id, question, expires_at, created_at FROM polls WHERE thread_id = ?1",
915+
params![thread_id],
916+
|r| {
917+
Ok(crate::models::Poll {
918+
id: r.get(0)?,
919+
thread_id: r.get(1)?,
920+
question: r.get(2)?,
921+
expires_at: r.get(3)?,
922+
created_at: r.get(4)?,
923+
})
924+
},
925+
)
926+
.optional()?;
927+
928+
let poll = match poll_row {
929+
Some(p) => p,
930+
None => return Ok(None),
931+
};
932+
933+
// Options with live vote counts
934+
let mut stmt = conn.prepare_cached(
935+
"SELECT po.id, po.poll_id, po.text, po.position,
936+
COUNT(pv.id) as vote_count
937+
FROM poll_options po
938+
LEFT JOIN poll_votes pv ON pv.option_id = po.id
939+
WHERE po.poll_id = ?1
940+
GROUP BY po.id
941+
ORDER BY po.position ASC",
942+
)?;
943+
let options: Vec<crate::models::PollOption> = stmt
944+
.query_map(params![poll.id], |r| {
945+
Ok(crate::models::PollOption {
946+
id: r.get(0)?,
947+
poll_id: r.get(1)?,
948+
text: r.get(2)?,
949+
position: r.get(3)?,
950+
vote_count: r.get(4)?,
951+
})
952+
})?
953+
.collect::<rusqlite::Result<_>>()?;
954+
955+
let total_votes: i64 = options.iter().map(|o| o.vote_count).sum();
956+
957+
// Did this user vote, and for which option?
958+
let user_voted_option: Option<i64> = conn
959+
.query_row(
960+
"SELECT option_id FROM poll_votes WHERE poll_id = ?1 AND ip_hash = ?2",
961+
params![poll.id, ip_hash],
962+
|r| r.get(0),
963+
)
964+
.optional()?;
965+
966+
let is_expired = poll.expires_at <= now;
967+
968+
Ok(Some(crate::models::PollData {
969+
poll,
970+
options,
971+
total_votes,
972+
user_voted_option,
973+
is_expired,
974+
}))
975+
}
976+
977+
/// Cast a vote. Returns true if vote was recorded, false if already voted.
978+
pub fn cast_vote(
979+
conn: &rusqlite::Connection,
980+
poll_id: i64,
981+
option_id: i64,
982+
ip_hash: &str,
983+
) -> Result<bool> {
984+
let result = conn.execute(
985+
"INSERT OR IGNORE INTO poll_votes (poll_id, option_id, ip_hash)
986+
VALUES (?1, ?2, ?3)",
987+
params![poll_id, option_id, ip_hash],
988+
)?;
989+
Ok(result > 0)
990+
}
991+
992+
/// Resolve (poll_id, thread_id, board_short) from an option_id.
993+
pub fn get_poll_context(
994+
conn: &rusqlite::Connection,
995+
option_id: i64,
996+
) -> Result<Option<(i64, i64, String)>> {
997+
Ok(conn.query_row(
998+
"SELECT p.id, p.thread_id, b.short_name
999+
FROM poll_options po
1000+
JOIN polls p ON p.id = po.poll_id
1001+
JOIN threads t ON t.id = p.thread_id
1002+
JOIN boards b ON b.id = t.board_id
1003+
WHERE po.id = ?1",
1004+
params![option_id],
1005+
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1006+
)
1007+
.optional()?)
1008+
}
1009+
8521010
// ─── Row mapping helpers ──────────────────────────────────────────────────────
8531011

8541012
fn map_board(row: &rusqlite::Row<'_>) -> rusqlite::Result<Board> {

src/handlers/board.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,14 +163,16 @@ pub async fn create_thread(
163163
let name_val = form.name;
164164
let subject_val = form.subject;
165165
let del_token_val = form.deletion_token;
166+
let poll_question = form.poll_question;
167+
let poll_options = form.poll_options;
168+
let poll_duration = form.poll_duration_secs;
166169

167170
let redirect_url = tokio::task::spawn_blocking({
168171
let pool = state.db.clone();
169172
move || -> Result<String> {
170173
let conn = pool.get()?;
171174
let board = db::get_board_by_short(&conn, &board_short)?
172175
.ok_or_else(|| AppError::NotFound(format!("Board /{board_short}/ not found")))?;
173-
174176
let ip_hash = hash_ip(&client_ip, &cookie_secret);
175177
if let Some(reason) = db::is_banned(&conn, &ip_hash)? {
176178
return Err(AppError::Forbidden(format!(
@@ -256,6 +258,20 @@ pub async fn create_thread(
256258
&new_post,
257259
)?;
258260

261+
// Create poll if question + at least 2 options were supplied
262+
let q = poll_question.trim().to_string();
263+
let valid_opts: Vec<String> = poll_options.iter()
264+
.map(|o| o.trim().to_string())
265+
.filter(|o| !o.is_empty())
266+
.collect();
267+
if !q.is_empty() && valid_opts.len() >= 2 {
268+
if let Some(secs) = poll_duration {
269+
let secs = secs.max(60).min(30 * 24 * 3600); // clamp 1 min..30 days
270+
let expires_at = chrono::Utc::now().timestamp() + secs;
271+
db::create_poll(&conn, thread_id, &q, &valid_opts, expires_at)?;
272+
}
273+
}
274+
259275
let max_threads = board.max_threads;
260276
let paths = db::prune_old_threads(&conn, board.id, max_threads)?;
261277
for path in paths {

src/handlers/mod.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ pub struct PostFormData {
2020
pub deletion_token: String,
2121
/// Raw bytes + original filename if a file was attached.
2222
pub file: Option<(Vec<u8>, String)>,
23+
// ── Poll fields (only used when creating a new thread) ────────────────
24+
pub poll_question: String,
25+
pub poll_options: Vec<String>,
26+
/// Duration in seconds (parsed from value + unit)
27+
pub poll_duration_secs: Option<i64>,
2328
}
2429

2530
/// Drain all fields from a multipart form into [`PostFormData`].
@@ -34,6 +39,10 @@ pub async fn parse_post_multipart(
3439
let mut body = String::new();
3540
let mut deletion_token = String::new();
3641
let mut file: Option<(Vec<u8>, String)> = None;
42+
let mut poll_question = String::new();
43+
let mut poll_options: Vec<String> = Vec::new();
44+
let mut poll_duration_value: Option<i64> = None;
45+
let mut poll_duration_unit = String::from("hours");
3746

3847
while let Some(field) = multipart
3948
.next_field()
@@ -51,6 +60,21 @@ pub async fn parse_post_multipart(
5160
Some("subject") => subject = field.text().await.unwrap_or_default(),
5261
Some("body") => body = field.text().await.unwrap_or_default(),
5362
Some("deletion_token") => deletion_token = field.text().await.unwrap_or_default(),
63+
Some("poll_question") => poll_question = field.text().await.unwrap_or_default(),
64+
Some("poll_option") => {
65+
let v = field.text().await.unwrap_or_default();
66+
let trimmed = v.trim().to_string();
67+
if !trimmed.is_empty() {
68+
poll_options.push(trimmed);
69+
}
70+
}
71+
Some("poll_duration_value") => {
72+
let v = field.text().await.unwrap_or_default();
73+
poll_duration_value = v.trim().parse::<i64>().ok();
74+
}
75+
Some("poll_duration_unit") => {
76+
poll_duration_unit = field.text().await.unwrap_or_default();
77+
}
5478
Some("file") => {
5579
let fname = field.file_name().unwrap_or("upload").to_string();
5680
let bytes = field
@@ -65,5 +89,17 @@ pub async fn parse_post_multipart(
6589
}
6690
}
6791

68-
Ok(PostFormData { csrf_verified, name, subject, body, deletion_token, file })
92+
// Convert duration value + unit → seconds
93+
let poll_duration_secs = if !poll_question.trim().is_empty() {
94+
poll_duration_value.map(|v| {
95+
if poll_duration_unit == "minutes" { v * 60 } else { v * 3600 }
96+
})
97+
} else {
98+
None
99+
};
100+
101+
Ok(PostFormData {
102+
csrf_verified, name, subject, body, deletion_token, file,
103+
poll_question, poll_options, poll_duration_secs,
104+
})
69105
}

0 commit comments

Comments
 (0)