Releases: csd113/RustChan
v1.1.0 Alpha 1
[1.1.0]
🌐 New: ChanNet API (Port 7070)
RustChan can now talk to other RustChans. Introducing the ChanNet API — a two-layer federation and gateway system living entirely on port 7070. Disabled by default, enable with ./rustchan-cli --chan-net
Layer 1 — Federation (/chan/export, /chan/import, /chan/refresh, /chan/poll): nodes sync with each other via ZIP snapshots. Push your posts out, pull theirs in, keep your mirror fresh.
Layer 2 — RustWave Gateway (/chan/command): the RustWave audio transport client gets its own command interface. Send a typed JSON command, get a ZIP back. Supported commands: full_export, board_export, thread_export, archive_export, force_refresh, and reply_push (the only one that actually writes anything).
Text only — no images, no media, no binary data cross this interface by design. Full schema docs in channet_api_reference.docx.
Architecture Refactor
This release restructures the codebase for maintainability. No user-facing
behavior has changed. Every route, every feature, every pixel is identical.
The only difference is where the code lives.
The problem
main.rs had grown to 1,757 lines and owned everything from the HTTP router
to the ASCII startup banner. handlers/admin.rs hit 4,576 lines with 33
handler functions covering auth, backups, bans, reports, settings, and more.
Both files were becoming difficult to navigate and risky to modify.
What changed
Phase 1 — Cleanup
- Removed unused
src/theme-init.js(dead duplicate ofstatic/theme-init.js) - Moved
validate_password()frommain.rstoutils/crypto.rsalongside
the other credential helpers - Moved
first_run_check()andget_per_board_stats()frommain.rsinto
thedbmodule, eliminating the only raw SQL that lived outsidedb/
Phase 2 — Background work
- Moved
evict_thumb_cache()frommain.rstoworkers/mod.rswhere it
belongs alongside the other background maintenance operations
Phase 3 — Console extraction
- Created
src/server/directory for server infrastructure - Extracted terminal stats, keyboard console, startup banner, and all
kb_*
helpers toserver/console.rs(~350 lines)
Phase 4 — CLI extraction
- Moved
Cli,Command,AdminActionclap types andrun_admin()to
server/cli.rs(~250 lines)
Phase 5 — Server extraction
- Moved
run_server(),build_router(), all 7 background task spawns,
static asset handlers, HSTS middleware, request tracking,ScopedDecrement,
and global atomics toserver/server.rs(~800 lines) main.rsis now ~50 lines: runtime construction, CLI parsing, dispatch
Phase 6 — Admin handler decomposition
- Converted
handlers/admin.rsto a module folder (handlers/admin/) - Extracted
backup.rs— all backup and restore handlers (~2,500 lines) - Extracted
auth.rs— login, logout, session management - Extracted
moderation.rs— bans, reports, appeals, word filters, mod log - Extracted
content.rs— post/thread actions, board management - Extracted
settings.rs— site settings, VACUUM, admin panel admin/mod.rsnow contains only shared session helpers and re-exports
By the numbers
File Before After
main.rs 1,757 lines ~50 lines
handlers/admin.rs 4,576 lines split across 6 files
server/ (new) — ~1,400 lines total
db/ unchanged + 2 functions from main.rs
workers/ unchanged + evict_thumb_cache
utils/crypto.rs unchanged + validate_password
What was not changed
db/, templates/, utils/, media/, config.rs, error.rs, models.rs,
detect.rs, handlers/board.rs, handlers/thread.rs, and middleware/ are
all untouched. They were already well-structured.
## New Module: src/media/
### media/ffmpeg.rs — FFmpeg detection and subprocess execution
- Added detect_ffmpeg() for checking FFmpeg availability (synchronous, suitable for spawn_blocking)
- Added run_ffmpeg() shared executor used by all FFmpeg calls
- Added ffmpeg_image_to_webp() with quality 85 and metadata stripping
- Added ffmpeg_gif_to_webm() using VP9 codec, CRF 30, zero bitrate target, metadata stripped
- Added ffmpeg_thumbnail() extracting first frame as WebP at quality 80 with aspect-preserving scale
- Added probe_video_codec() via ffprobe subprocess (moved from utils/files.rs)
- Added ffmpeg_transcode_to_webm() using path-based API (replaces old bytes-in/bytes-out version)
- Added ffmpeg_audio_waveform() using path-based API (same refactor as above)
### media/convert.rs — Per-format conversion logic
- Added ConversionAction enum: ToWebp, ToWebm, ToWebpIfSmaller, KeepAsIs
- Added conversion_action() mapping each MIME type to the correct action
- Added convert_file() as the main entry point for all conversions
- PNG to WebP is attempted but original PNG is kept if WebP is larger
- All conversions use atomic temp-then-rename strategy
- FFmpeg failures fall back to original file with a warning (never panics, never returns 500)
### media/thumbnail.rs — WebP thumbnail generation
- All thumbnails output as .webp
- SVG placeholders used for video without FFmpeg, audio, and SVG sources
- Added generate_thumbnail() as unified entry point
- Added image crate fallback path for when FFmpeg is unavailable (decode, resize, save as WebP)
- Added thumbnail_output_path() for determining correct output path and extension
- Added write_placeholder() for generating static SVG placeholders by kind
### media/exif.rs — EXIF orientation handling (new file)
- Moved read_exif_orientation and apply_exif_orientation from utils/files.rs
### media/mod.rs — Public API
- Added ProcessedMedia struct with file_path, thumbnail_path, mime_type, was_converted, original_size, final_size
- Added MediaProcessor::new() with FFmpeg detection and warning log if not found
- Added MediaProcessor::new_with_ffmpeg() as lightweight constructor for request handlers
- Added MediaProcessor::process_upload() for conversion and thumbnail generation (never propagates FFmpeg errors)
- Added MediaProcessor::generate_thumbnail() for standalone thumbnail regeneration
- Registered submodules: convert, ffmpeg, thumbnail, exif
---
## Modified Files
### src/utils/files.rs
- Extended detect_mime_type with BMP, TIFF (LE and BE), and SVG detection including BOM stripping
- Rewrote save_upload to delegate conversion and thumbnailing to MediaProcessor
- GIF to WebM conversions now set processing_pending = false (converted inline, no background job)
- MP4 and WebM uploads still set processing_pending = true as before
- Removed dead functions: generate_video_thumb, ffmpeg_first_frame, generate_video_placeholder, generate_audio_placeholder, generate_image_thumb
- Removed relocated functions: ffprobe_video_codec, probe_video_codec, ffmpeg_transcode_webm, transcode_to_webm, ffmpeg_audio_waveform, gen_waveform_png
- EXIF functions kept as thin private delegates to crate::media::exif for backward compatibility
- Added mime_to_ext_pub() public wrapper for use by media/convert.rs
- Added apply_thumb_exif_orientation() for post-hoc EXIF correction on image crate thumbnails
- Added tests for BMP, TIFF LE, TIFF BE, SVG detection and new mime_to_ext mappings
### src/models.rs
- Updated from_ext to include bmp, tiff, tif, and svg
### src/lib.rs and src/main.rs
- Registered new media module
### src/workers/mod.rs
- Updated probe_video_codec call to use crate::media::ffmpeg::probe_video_codec
- Replaced in-memory transcode_to_webm with path-based ffmpeg_transcode_to_webm using temp file persist
- Replaced in-memory gen_waveform_png with path-based ffmpeg_audio_waveform using temp file persist
- File bytes now read from disk only for SHA-256 dedup step
### Cargo.toml
- Added bmp and tiff features to the image crate dependency
v1.0.13 Mostly bugfixes and optimizations
[1.0.13] — 2026-03-08
WAL Mode + Connection Tuning
db/mod.rs
cache_size bumped from -4096 (4 MiB) to -32000 (32 MiB) in the pool's with_init pragma block. The journal_mode=WAL and synchronous=NORMAL pragmas were already present.
Missing Indexes
db/mod.rs
Two new migrations added at the end of the migration table:
- Migration 23:
CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)— supplements the existing composite index for queries that filter onthread_idalone. - Migration 24:
CREATE INDEX IF NOT EXISTS idx_posts_ip_hash ON posts(ip_hash)— eliminates the full-table scan on the admin IP history page and per-IP cooldown checks.
Prepared Statement Caching Audit
db/threads.rs · db/boards.rs · db/posts.rs
All remaining bare conn.prepare(...) calls on hot or repeated queries replaced with conn.prepare_cached(...): delete_thread, archive_old_threads, prune_old_threads (outer SELECT) in threads.rs; delete_board in boards.rs; search_posts in posts.rs. Every query path is now consistently cached.
Transaction Batching for Thread Prune
Already implemented in the codebase. Both prune_old_threads and archive_old_threads already use unchecked_transaction() / tx.commit() to batch all deletes/updates into a single atomic transaction. No changes needed.
RETURNING Clause for Inserts
db/threads.rs · db/posts.rs
create_thread_with_op and create_post_inner now use INSERT … RETURNING id via query_row, replacing the execute() + last_insert_rowid() pattern. The new ID is returned atomically in the same statement, eliminating the implicit coupling to connection-local state.
Scheduled VACUUM
config.rs · main.rs
Added auto_vacuum_interval_hours = 24 to config. A background Tokio task now sleeps for the configured interval (staggered from startup), then calls db::run_vacuum() via spawn_blocking and logs the bytes reclaimed.
Expired Poll Cleanup
config.rs · main.rs · db/posts.rs
Added poll_cleanup_interval_hours = 72. A new cleanup_expired_poll_votes() DB function deletes vote rows for polls whose expires_at is older than the retention window. A background task runs it on the configured interval, preserving poll questions and options.
DB Size Warning
config.rs · handlers/admin.rs · templates/admin.rs
Added db_warn_threshold_mb = 2048. The admin panel handler reads the actual file size via std::fs::metadata, computes a boolean flag, and passes it to the template. The template renders a red warning banner in the database maintenance section when the threshold is exceeded.
Job Queue Back-Pressure
config.rs · workers/mod.rs
Added job_queue_capacity = 1000. The enqueue() method now checks pending_job_count() before inserting — if the queue is at or over capacity, the job is dropped with a warn! log and a sentinel -1 is returned, avoiding OOM under post floods.
Coalesce Duplicate Media Jobs
workers/mod.rs
Added an Arc<DashMap<String, bool>> (in_progress) to JobQueue. Before dispatching a VideoTranscode or AudioWaveform job, handle_job checks if the file_path is already in the map — if so it skips and logs. The entry is removed on both success and failure.
FFmpeg Timeout
config.rs · workers/mod.rs
Replaced hardcoded FFMPEG_TRANSCODE_TIMEOUT / FFMPEG_WAVEFORM_TIMEOUT constants with CONFIG.ffmpeg_timeout_secs (default: 120). Both transcode_video and generate_waveform now read this value at runtime so operators can tune it in settings.toml.
Auto-Archive Before Prune
workers/mod.rs · config.rs
prune_threads now evaluates allow_archive || CONFIG.archive_before_prune. The new global flag (default true) means no thread is ever silently hard-deleted on a board that has archiving enabled at the global level, even if the individual board didn't opt in.
Waveform Cache Eviction
main.rs · config.rs
A background task runs every hour (after a 30-min startup stagger). It walks every {board}/thumbs/ directory, sorts files oldest-first by mtime, and deletes until total size is under waveform_cache_max_mb (default 200 MiB). A new evict_thumb_cache function handles the scan-and-prune logic; originals are never touched.
Streaming Multipart
handlers/mod.rs
The old .bytes().await (full in-memory buffering) is replaced by read_field_bytes, which streams via .chunk() and returns a 413 UploadTooLarge the moment the running total exceeds the configured limit — before memory is exhausted.
ETag / Conditional GET
handlers/board.rs · handlers/thread.rs
Both handlers now accept HeaderMap, derive an ETag (board index: "{max_bump_ts}-{page}"; thread: "{bumped_at}"), check If-None-Match, and return 304 Not Modified on a hit. The ETag is included on all 200 responses too.
Gzip / Brotli Compression
main.rs · Cargo.toml
tower-http features updated to compression-full. CompressionLayer::new() added to the middleware stack — it negotiates gzip, Brotli, or zstd based on the client's Accept-Encoding header.
Blocking Pool Sizing
main.rs · config.rs
#[tokio::main] replaced with a manual tokio::runtime::Builder that calls .max_blocking_threads(CONFIG.blocking_threads). Default is logical_cpus × 4 (auto-detected); configurable via blocking_threads in settings.toml or CHAN_BLOCKING_THREADS.
EXIF Orientation Correction
utils/files.rs · Cargo.toml
kamadak-exif = "0.5" added. generate_image_thumb now calls read_exif_orientation for JPEGs and passes the result to apply_exif_orientation, which dispatches to imageops::rotate90/180/270 and flip_horizontal/vertical as needed. Non-JPEG formats skip the EXIF path entirely.
✨ Added
- Backup system rewritten to stream instead of buffering in RAM — all backup operations previously loaded entire zip files into memory, risking OOM on large instances. Downloads now stream from disk in 64 KiB chunks (browsers also get a proper progress bar). Backup creation now writes directly to disk via temp files with atomic rename on success, so partial backups never appear in the saved list. Individual file archiving now streams through an 8 KiB buffer instead of reading each file fully into memory. Peak RAM usage dropped from "entire backup size" to roughly 64 KiB regardless of instance size.
- ChanClassic theme — a new theme that mimics the classic 4chan aesthetic: light tan/beige background, maroon/red accents, blue post-number links, and the iconic post block styling. Available in the theme picker alongside existing themes.
- Default theme in settings.toml — the generated
settings.tomlnow includes adefault_themefield so the server-side default theme can be set before first startup, without requiring admin panel access. - Home page subtitle in settings.toml —
site_subtitleis now present in the generatedsettings.tomldirectly belowforum_name, allowing the home page subtitle to be configured at install time. - Default theme selector in admin panel — the Site Settings section now includes a dropdown to set the site-wide default theme served to new visitors.
🔄 Changed
- Admin panel reorganized — sections are now ordered: Site Settings → Boards → Moderation Log → Report Inbox → Moderation (ban appeals, active bans, word filters consolidated) → Full Site Backup & Restore → Board Backup & Restore → Database Maintenance → Active Onion Address. Code order matches page order for easier future editing.
- "Backup & Restore" renamed to "Full Site Backup & Restore" to clearly distinguish it from the board-level backup section.
- Ban appeals, active bans, and word filters condensed into a single Moderation panel with clearly labelled subsections.
v1.0.12 Per board post cooldowns and more
[1.0.12] — 2026-03-07
🔄 Changed
- Database module fixes —
threads.rs: added explicitROLLBACKon failedCOMMITto prevent dirty transaction state.mod.rs: addedsort_unstable+deduptopaths_safe_to_deleteto eliminate duplicate path entries.mod.rs: addedmedia_typeandedited_atcolumns to the baseCREATE TABLE postsschema to match the final migrated state.admin.rs: replaced inlined Post row mapper with sharedsuper::posts::map_postto eliminate duplication.admin.rs: clarifiedrun_wal_checkpointdoc comment on return tuple order. - Template module fixes —
board.rs: fixed archive thumbnail path prefix from/static/to/boards/.board.rs: movedfmt_tsto the top-level import, removed redundant localuseinsidearchive_page.thread.rs: corrected misleading comment about embed and draft script loading.thread.rs: added doc comment documenting thebody_htmltrust precondition onrender_post.forms.rs: removed deadcaptcha_jsvariable and no-op string concatenation. - CSS cleanup — removed 11 dead rules for classes never emitted by templates or JS (
.greentext,.quote-link,.admin-thread-del-btn, duplicate.media-expanded,.media-rotate-btn,.thread-id-badge,.quote-block,.quote-toggle,.archive-heading,.autoupdate-bar,.video-player). Fixed two undefined CSS variable references (--font-mono→--font,--bg-body→--bg). Merged duplicate.file-containerblock into a single declaration. - Database module split — the 2,264-line monolithic
db.rshas been reorganized into five focused modules with zero call-site changes (all existingdb::references compile unchanged):mod.rs(466 lines) — connection pool, shared types (NewPost,CachedFile), schema initialization, shared helpersboards.rs(293 lines) — site settings, board CRUD, statsthreads.rs(333 lines) — thread listing, creation, mutation, archiving, pruningposts.rs(642 lines) — post CRUD, file deduplication, polls, job queue, worker helpersadmin.rs(558 lines) — admin sessions, bans, word filters, reports, mod log, ban appeals, IP history, maintenance
- Template module split — the 2,736-line monolithic template file has been reorganized into five focused modules with no changes to the public API (all existing handler code works without modification):
mod.rs(392 lines) — shared infrastructure: site name/subtitle statics, base layout, pagination, timestamp formatting, utility helpersboard.rs(697 lines) — home page, board index, catalog, search, and archive renderingthread.rs(738 lines) — thread view, post rendering, polls, and post edit formadmin.rs(760 lines) — login page, admin panel, mod log, VACUUM results, IP historyforms.rs(198 lines) — new thread and reply forms, shared across board and thread pages
🔒 Security Fixes
Critical
- PoW bypass on replies — proof-of-work verification was only enforced on new threads but not on replies. Replies now require a valid PoW nonce when the board has CAPTCHA enabled.
- PoW nonce replay — the same proof-of-work solution could be submitted repeatedly. Used nonces are now tracked in memory and rejected within their 5-minute validity window. Stale entries are automatically pruned.
High
- Removed inline JavaScript — all inline
<script>blocks andonclick/onchange/onsubmitattributes have been extracted into external.jsfiles. The Content Security Policy now usesscript-src 'self'with nounsafe-inline, closing a major XSS surface. - Backup upload size cap — the restore endpoints previously accepted uploads of unlimited size, risking out-of-memory crashes. Both full and board restore routes are now capped at 512 MiB.
🐛 Fixes
- Post rate limiting simplified — removed the global
check_post_rate_limitfunction that was silently overriding per-board cooldown settings. A board withpost_cooldown_secs = 0now correctly means zero cooldown. The per-board setting is the sole post rate control. - API endpoints excluded from GET rate limit — hover-preview requests (
/api/post/*) were being counted against the navigational rate limit, causing false throttling on threads with many quote links. All/api/routes are now excluded alongside/static/,/boards/, and/admin/. The GET limiter now only covers page loads that a scraper would target (board index, catalog, archive, threads, search, home). - Trailing slash 404s — several routes returned 404 when accessed with or without a trailing slash (board index, catalog, archive, thread pages, post editing). Added middleware to normalize trailing slashes so all URL variations resolve correctly. Bookmarks and manually typed URLs now work as expected.
v1.0.11 Security Fix -- no more zip bombz
[1.0.11] — 2026-03-06
Security — Critical
-
CRIT-1: Security headers — added
Content-Security-Policy,Strict-Transport-Security,
andPermissions-Policyresponse headers tobuild_router(). CSP restricts scripts, styles,
and media to'self', preventing external payload loading or data exfiltration via XSS.
HSTS enforces HTTPS for one year including subdomains. Permissions-Policy disables
camera, microphone, and geolocation APIs. -
CRIT-2: Proxy-aware IP extraction in post handlers — all post-creation handlers
(create_thread,post_reply) now use the proxy-awareextract_ip()/ClientIpextractor
instead of the raw socket address. Bans and rate limits are now effective when the server
runs behind nginx or any other reverse proxy. -
CRIT-3: Rate limiting on GET endpoints — the catalog, search, thread-update, and JSON
API endpoints were previously completely unrate-limited, allowing trivial DoS via unbounded
LIKE scans or 200-thread catalog loads. A separate GET rate limiter (60 req/min per IP)
has been applied to all read-heavy routes. -
CRIT-4: Zip-bomb protection on restore handlers — all four backup restore handlers
previously usedstd::io::copywith no size or entry limits. Each entry is now capped at
1 GiB via.take()and extraction aborts if more than 50 000 entries are encountered,
preventing a 1 KB zip from exhausting disk space. -
CRIT-5: IP address hashing — raw IP addresses are no longer stored in the
ACTIVE_IPS
DashMapor printed to stdout/logs. All IP tracking now uses the same HMAC-keyed hash
(hash_ip) used elsewhere, preventing IP exposure in coredumps or log aggregators. -
CRIT-6: Admin login brute-force lockout — the admin login endpoint previously had no
per-IP failure tracking beyond the global rate limit (~600 attempts/hour). Failed login
attempts are now counted per IP and the account is locked for a progressive delay after
5 consecutive failures. -
CRIT-7: Constant-time CSRF token comparison — CSRF token validation was using
standard==string comparison, leaking prefix-matching information via timing side
channel. Comparison now usessubtle::ct_eqfor constant-time equality. -
CRIT-8: Poll input length and count caps — poll questions and options had no
server-side limits, allowing megabytes of text or thousands of options per submission.
Poll options are now capped at 10, each option at 128 characters, and the question
at 256 characters.
Security — High
-
HIGH-1: Admin session cookie
Max-Age— the session cookie previously had no
Max-AgeorExpiresattribute, causing browsers to persist it indefinitely after the
tab was closed. The cookie now carries aMax-Agematching the server-side
session_durationconfig value. -
HIGH-2: Database connection pool timeout — the r2d2 connection pool had no
acquisition timeout, allowingspawn_blockingthreads to block forever under load and
exhaust the Tokio thread pool. A 5-secondconnection_timeouthas been added to the
pool builder. -
HIGH-3: Per-route body limits on small-payload endpoints — the global 50 MiB
DefaultBodyLimitwas applied to every route including login, vote, report, and appeal,
causing the server to buffer 50 MiB before returning 400 on oversized requests. These
four endpoints now carry an explicit 64 KiB per-route limit. -
HIGH-4: Open redirect hardening on
return_to— the logoutreturn_toparameter
check only blocked//and.., allowing backslash (\) and URL-encoded variants
(%5C) to redirect to external hosts on some browsers. The filter now also rejects any
value containing a literal backslash or its percent-encoded form. -
HIGH-5: Proxy-aware IP in
file_reportandsubmit_appeal— these two handlers
were using the raw socket IP for per-IP rate limiting, making the limit ineffective
behind a reverse proxy. Both now use theClientIpextractor, consistent with post
handlers (same root cause as CRIT-2). -
HIGH-6: Exponential backoff with jitter in worker error recovery — all four
background workers recovered from DB errors with a flat 2-second sleep, meaning all
workers could retry simultaneously and storm the database. Error recovery now uses
exponential backoff (500 ms base, doubling per failure, capped at 60 s) with 0–500 ms
random jitter to spread retries across workers. -
HIGH-7: TOCTOU race in file deduplication — concurrent identical uploads could both
pass the hash check before either had written tofile_hashes, causing the second
record_file_hashcall to return a 500. The insert now usesINSERT OR IGNOREso the
second concurrent insert is silently a no-op instead of an error.
10.0.10 Integrated youtube linker
[1.0.10] — 2026-03-06
Added
-
Per-post inline ban+delete — the admin toolbar that appears on every post in
thread view now includes a ⛔ ban+del button alongside the existing delete
button. Clicking it prompts for a ban reason and duration (hours;0= permanent)
via browser dialog, then submits aPOST /admin/post/ban-deleteaction that
atomically bans the post author's IP hash and deletes the post (or the entire
thread if the post is the OP), then redirects back to the thread (or the board
index if the thread was deleted). No more manual copy-pasting of IP hashes in
the admin panel. Backed by a newadmin_ban_and_deletehandler. -
Ban appeal system — when a banned user attempts to post and receives the ban
page they now see a short textarea below the ban reason to submit an appeal
(max 512 chars). Submissions hitPOST /appealand are stored in a new
ban_appealsSQLite table (added via additive migration). All open appeals
appear in a new // ban appeals section in the admin panel (adjacent to the
report inbox), with ✕ dismiss and ✓ accept + unban buttons. Accepting
an appeal deletes the corresponding ban and marks the appeal closed. A 24-hour
per-IP cooldown prevents appeal spam (has_recent_appealDB helper). New DB
functions:file_ban_appeal,get_open_ban_appeals,dismiss_ban_appeal,
accept_ban_appeal,has_recent_appeal. New routes:POST /appeal,
POST /admin/appeal/dismiss,POST /admin/appeal/accept. -
PoW CAPTCHA for new threads — thread creation can now require a lightweight
hashcash-style proof-of-work solved entirely in JS before the form submits.
Replies are intentionally exempt to keep them frictionless. When a board has
the new "PoW CAPTCHA on new threads" checkbox enabled, the new-thread form
shows a status row ("solving proof-of-work…") and automatically mines a SHA-256
nonce withPOW_DIFFICULTY = 20leading zero bits (~1 M iterations on average,
~50–200 ms in a modern browser) using the native Web Crypto API
(crypto.subtle.digest). The nonce is submitted as a hiddenpow_noncefield.
The server callsverify_powinsrc/utils/crypto.rs, which accepts solutions
for the current minute and up to 4 prior minutes (5-minute grace window covering
clock skew and solve time). The feature is off by default; enabled per-board via
the admin settings panel. Backed by a newallow_captchacolumn on theboards
table (default0) added via additive SQLite migration. -
Spoiler text markup (verified existing) —
[spoiler]text[/spoiler]renders
as hidden text that is revealed on hover or click. Confirmed complete with XSS
safety analysis and passing unit test. -
Video embed unfurling — when a post body contains a YouTube, Invidious, or
Streamable URL, the markup parser now emits a<span class="video-unfurl">
placeholder alongside the hyperlink, carryingdata-embed-typeand
data-embed-idattributes. On thread pages a new client-side script replaces
each placeholder with a thumbnail + circular play button; clicking it swaps in
the embedded iframe with autoplay. YouTube thumbnails are loaded directly from
img.youtube.com; Streamable shows a labelled placeholder until clicked.
Invidious instances are detected by the standard?v=query parameter on any
non-YouTube domain, so any self-hosted instance is automatically supported. The
feature is opt-in at the board level via a new "Embed video links" checkbox
in the admin board-settings panel. The embed JS is a no-op when the board flag
is off, and the placeholder spans in existingbody_htmlare simply hidden by
CSS, so toggling the flag does not require re-rendering stored posts. Backed by
a newallow_video_embedscolumn on theboardstable (default0) added via
an additive SQLite migration. -
Cross-board quotelink hover previews —
>>>/board/123links were previously
rendered as styled amber anchors with no interactive preview. They now carry
data-crossboardanddata-pidattributes and are wired by a new client-side
script that fetchesGET /api/post/{board}/{thread_id}on hover, renders the OP
post in the same floating popup used by same-thread>>Nquotelinks, and caches
results for the lifetime of the page so repeat hovers are instant. A loading
placeholder is shown while the fetch is in flight; a terse error message is
shown for non-existent threads. The newGET /api/post/{board}/{thread_id}
endpoint is rate-limited by the existing middleware and returns JSON
{"html":"…"}containing the server-rendered OP post (thumbnail included,
delete/admin controls stripped). A newdb::get_op_post_for_threadDB
function powers the lookup. The cross-board popup shares the popupdivand
positioning logic already used by same-thread quotelinks, so all five visual
themes render correctly without additional CSS. -
Spoiler text markup —
[spoiler]text[/spoiler]tags were already parsed by
the markup pipeline and confirmed to produce<span class="spoiler">with CSS
background == color(text invisible at rest, revealed on hover or click via
.spoiler:hover/.spoiler.revealed). No code change was required; this entry
documents that the feature is fully implemented, tested (test_spoilerpasses),
and safe against XSS (the[and]delimiters surviveescape_htmlunchanged
because they are not HTML-special characters, and the rendered content is already
escaped before the spoiler regex runs). -
Floating new-reply pill — when the auto-updater fetches new posts, a
floating pill reading "+N new replies ↓" fades in over the thread. Clicking
it smooth-scrolls to the bottom of the page and dismisses the pill. The pill
also auto-dismisses when the user scrolls within 200 px of the bottom, or
after 30 seconds. This replaces reliance on the small status span in the nav
bar, which was easy to miss — directly equivalent to 4chan X's new-post
notification pill. -
Delta-compressed thread state in the auto-update endpoint — the
GET /:board/thread/:id/updates?since=Nresponse now carries a richer JSON
envelope:reply_count,bump_time,locked, andstickyalongside the
existinghtml/last_id/countfields. The client consumes these to keep
the nav-bar reply counter and lock/sticky badges in sync without a full page
reload. A newR: Nreply counter has been added to the thread nav bar and
is updated live on every poll cycle. If the thread becomes locked while the
user is watching, a lock notice is injected above the posts automatically. -
"(You)" post tracking — post IDs submitted by the current browser are
persisted inlocalStorageunder a per-thread key and survive page refreshes.
A subtle(You)badge is rendered next to the post number of every post you
authored, making it easy to spot replies to your own posts. The mechanism
works by setting arustchan_you_pending_<board>_<thread>flag before the
reply form submits; on the redirect landing, the post ID is extracted from
the URL fragment and saved. Badges are also re-applied whenever the
auto-updater inserts new posts.
Changed
Boardmodel — one new field:allow_video_embeds: bool(defaultfalse).
All DB queries reading or writing board rows have been updated. Board backup /
restore manifests include the new field so the setting survives a round-trip;
older backup zips that pre-date the field default it tofalseon restore via
#[serde(default)].
v1.0.9 mobile and admin optimizations
[1.0.9] — 2026-03-06
Added
- Per-board post editing toggle — each board now has an
allow_editing
flag (off by default) that gates whether users can edit their own posts.
When disabled the edit link is hidden and the edit endpoints return an error
immediately, regardless of the global edit-window logic. The flag is
exposed as a checkbox in the admin board-settings form (Enable editing). - Per-board edit window — a companion
edit_window_secscolumn on the
boardstable lets operators configure how long after posting a user may
edit their own post on a per-board basis. Setting it to0falls back to
the server-wide default of 300 s (5 minutes). The value is shown in the
admin board-settings form as a number input (Edit window (s)) and is
respected by both the edit-form handler and the edit-submit handler. - Per-board archive toggle — a new
allow_archivecolumn on theboards
table (default1on existing rows, i.e. archiving enabled) lets operators
choose, per board, whether overflow threads are archived or permanently
deleted when the board hits itsmax_threadslimit. TheThreadPrune
background worker now reads this flag from the job payload and calls either
db::archive_old_threadsordb::prune_old_threadsaccordingly. The
admin board-settings form exposes this as a checkbox (Enable archive).
Fixed
- WebM AV1 → VP9 transcoding — uploaded WebM files containing an AV1
video stream are now detected viaffprobeand re-encoded to VP9 + Opus
by theVideoTranscodebackground worker. Previously, all WebM uploads
were accepted as-is regardless of codec, meaning AV1 content would be
served to browsers that do not support it. VP8 and VP9 WebM files are
identified and skipped cheaply so they are never unnecessarily re-encoded. - VP9 CRF rate-control conflict (
exit status: 234) — theffmpeg
transcode command previously combined-b:v 0(pure CRF mode) with
-maxrate 2M -bufsize 4M(constrained-quality mode). libvpx-vp9 treats
these as mutually exclusive: setting a peak bitrate cap without a target
bitrate causes the encoder to abort with "Rate control parameters set
without a bitrate". The-maxrateand-bufsizeflags have been
removed; the transcoder now uses pure CRF 33 with unconstrained average
bitrate (-b:v 0), which is the correct mode for quality-driven encoding. E0597borrow lifetime indb::prune_file_paths— the
stmt.query_map(…).collect()expression at the end of a block created a
temporaryMappedRowsiterator that outlivedstmt(dropped at the
closing brace), causing a compile error. The result is now collected into
an explicitletbinding before the block ends, ensuring the iterator is
fully consumed whilestmtis still in scope.
Changed
Boardmodel — three new fields:allow_editing: bool,
edit_window_secs: i64, andallow_archive: bool. All DB queries that
read or write board rows have been updated accordingly. Board backup /
restore manifests also include these fields so settings survive a
round-trip.
1.0.8 Admin and Backend
[1.0.8] — 2026-03-05
Added
- Thread archiving — when a board hits its
max_threadslimit, the oldest
non-sticky threads are now moved to an archived state instead of being
deleted. Archived threads gainarchived = 1, locked = 1in the database:
they remain fully readable and are kept forever, but no new replies can be
posted to them and they do not appear in the board index or catalog. A new
GET /{board}/archivepage lists all archived threads for a board with
pagination (20 per page), newest-bumped first, showing a thumbnail, subject,
body preview, reply count, and creation date. The archive is linked from the
sticky catalog bar that appears on every board page. A new
db::archive_old_threadsfunction replaces the oldprune_old_threads; the
background worker (ThreadPrunejob) now calls it instead of deleting. An
additive SQLite migration adds thearchived INTEGER NOT NULL DEFAULT 0
column tothreadsand a covering index
idx_threads_archived(board_id, archived, bumped_at DESC). All existing
board-index and catalog queries gainAND t.archived = 0so they are
unaffected by archived rows. TheThreadmodel gains anarchived: bool
field that is populated everywhere a thread row is mapped from the database. - Mobile-optimised reply drawer — on viewports ≤ 767 px the desktop
inline reply form toggle is hidden and replaced with a floating action button
(FAB) fixed to the bottom-right corner labelled ✏ Reply. Tapping it
slides a full-width drawer up from the bottom of the screen (max-height 80 vh,
scroll-overflow enabled) containing the reply form. A close button in the
drawer header (✕) collapses it. TheappendReply(id)function that
populates the>>Nquote when tapping a post number is media-query aware: on
mobile it opens and populates the drawer textarea rather than the desktop
form. All behaviour is implemented with vanilla JS and a@media (max-width: 767px)CSS block — no external dependencies. The drawer slides
with a CSStransform: translateYtransition (0.22 s ease) and the FAB fades
out while the drawer is open to avoid overlap. - Server-side dice rolling — posts may now include
[dice NdM]anywhere
in their body (e.g.[dice 2d6],[dice 1d20]). The server rolls the
dice usingOsRngat the moment the post body is processed through
render_post_body, and the result is embedded immutably inbody_htmlso
every reader sees the same rolls forever. The rendered output is a<span class="dice-roll">element showing the notation, individual die results, and
sum: e.g.🎲 2d6 ▸ ⚄ ⚅ = 11. For d6 rolls each individual die is
displayed as the corresponding Unicode die-face character (⚀–⚅); for all
other dice sizes the value is shown as【N】. Limits: 1–20 dice, 2–999
sides; out-of-range values are clamped silently. The feature is implemented
entirely inutils/sanitize.rsas a pre-pass regex substitution inside
render_post_bodyusingrand_core::OsRng(already a transitive
dependency) — no new dependencies are added. - Post sage — the reply form now includes a sage checkbox. When checked,
the reply is posted normally but does not bump the thread'sbumped_at
timestamp, so it does not rise in the board index regardless of its reply
count relative to the bump limit. Sage is parsed as a standard multipart
checkbox field (name="sage" value="1"), stored nowhere server-side (it
only controls whetherdb::bump_threadis called), and is a no-op when
posting a new thread. The label is rendered in a dimmed style with a brief
"(don't bump thread)" hint to match the classic imageboard convention. - Post editing — users may edit their own post within a 5-minute window
after it was created, authenticated by the same deletion token they set (or
were assigned) at post time. A small edit link appears next to the delete
form on every post while the window is open; clicking it navigates to
GET /{board}/post/{id}/edit, which shows the current post body in a
pre-filled textarea alongside a deletion-token input. Submitting the form
(POST /{board}/post/{id}/edit) verifies the token with constant-time
comparison, re-validates and re-renders the body through the same word-filter
and HTML-sanitisation pipeline as a normal post, then writes the updated
body,body_html, and anedited_atUnix timestamp to the database.
Invalid tokens or expired windows display an inline error without losing the
typed text. After a successful edit the user is redirected back to the post
anchor in the thread. An (edited HH:MM:SS) badge is appended to the
post-meta line of any post whoseedited_atis not NULL, with the full
timestamp in the title attribute. The feature is backed by an additive
SQLite migration (ALTER TABLE posts ADD COLUMN edited_at INTEGER) and a
newdb::edit_postfunction that enforces the window check atomically.
EDIT_WINDOW_SECS = 300is a public constant indb.rsfor easy tuning. - Draft autosave — the reply textarea contents are automatically
persisted tolocalStorageevery 3 seconds under the key
rustchan_draft_{board}_{thread_id}. On page load the saved draft is
restored into the textarea so a refresh, accidental navigation, or browser
crash does not lose a half-written reply. The draft is cleared when the
reply form is submitted. All localStorage access is wrapped in try/catch so
environments with storage disabled (e.g. private-browsing with strict
settings) fail silently. The script is injected once per thread page and
does not affect new-thread forms or any other page type. - WAL checkpoint tuning — a background Tokio task now runs
PRAGMA wal_checkpoint(TRUNCATE)at a configurable interval to prevent
SQLite's write-ahead log from growing unbounded under sustained write load.
TRUNCATE mode performs a full checkpoint and then resets the WAL file to
zero bytes, reclaiming disk space immediately. The interval is set via
wal_checkpoint_interval_secsinsettings.toml(default: 3600, i.e.
hourly) or theCHAN_WAL_CHECKPOINT_SECSenvironment variable; set to
0to disable entirely. The task is staggered to fire at half the
configured interval after startup so it does not overlap with the session
purge task. Checkpoint pages/moved/backfill counts are logged at DEBUG
level; failures are logged as warnings and do not crash the server. - SQLite VACUUM endpoint — a new "// database maintenance" section in
the admin panel shows the current database file size and provides a
POST /admin/vacuumbutton that runsVACUUMto compact the database
after bulk deletions. The button requires a CSRF-token-protected form
submission and an active admin session. On completion a result page is
shown with the before/after file size and the number of bytes reclaimed
(and the percentage reduction). Thedb::get_db_size_byteshelper
(usingPRAGMA page_count * page_size) anddb::run_vacuumare exposed
as public DB functions for use by any future tooling. - IP history view — every post rendered in an admin session now has an
🔍 ip historylink beside the admin-delete button. Clicking it
opensGET /admin/ip/{ip_hash}, which lists every post that IP hash has
ever made across all boards, newest first, with pagination (25 per page).
Each row shows the timestamp, a clickable link to the exact post in its
thread, an OP badge when applicable, a media type indicator, a 120-char
body preview, and an inline admin-delete button. The IP hash path
component is validated (must be alphanumeric, ≤ 64 chars) to prevent
information leakage through crafted URLs. Two new DB functions support
this:count_posts_by_ip_hashandget_posts_by_ip_hash.
v1.0.6 Feature Complete Release
This version is now feature complete.
[1.0.6] — 2026-03-04
Added
Backup system overhaul
- Disk-based backups stored inside
rustchan-data/- Full backups →
rustchan-data/full-backups/ - Board backups →
rustchan-data/board-backups/
- Full backups →
- Admin panel backup manager showing all saved backups with:
- download
- restore
- delete
- Full backup creation endpoint
POST /admin/backup/create
- Board backup creation
POST /admin/board/backup/create
- Saved backup download
GET /admin/backup/download/{kind}/{filename}
- Backup deletion
POST /admin/backup/delete
- Restore from saved backups
POST /admin/backup/restore-savedPOST /admin/board/backup/restore-saved
Board-level backups
- Each board now has a backup button in the admin panel.
- Creates a self-contained
.zipcontaining:- board data (
board.json) - all uploaded media
- board data (
- Board restore will:
- recreate the board if missing
- replace its content if it already exists
- safely remap database IDs to avoid collisions.
CI pipeline
- Added GitHub Actions workflow that runs:
cargo buildcargo testclippyrustfmt
- Builds are tested across:
- macOS (x86_64, ARM)
- Linux (x86_64, ARM)
- Windows (x86_64)
Tor Integration
RustChan can now automatically detect and configure Tor to expose the board as a .onion service.
The new detect.rs module:
- Detects Tor from common locations:
/opt/homebrew/bin/tor/usr/local/bin/tortoron PATH
- Creates a hidden service directory:
<data_dir>/tor_hidden_service- permissions set to
0700(required by Tor)
- Generates a
torrcconfiguration automatically. - Launches Tor in the background.
- Waits for the generated
.onionhostname and prints it in a banner.
Improvements
- Tor startup stderr is now captured, making startup failures visible.
- Added early Tor health check to detect crashes immediately.
- Increased hidden service startup timeout to 120 seconds.
- Added Linux
/usr/bin/tordetection forapt install tor. - Improved troubleshooting messages for common Tor issues.
- Onion banner now shows where the private key is stored and reminds users to back it up.
Fixed
- Fixed
rand_core::OsRngcompile error by enabling thegetrandomfeature. - Updated all routes to Axum 0.8 syntax (
/{param}instead of/:param). - Fixed
rusqlitelifetime issues in board backup queries. - Cleaned up formatting issues so the project passes
cargo fmt --check.
v1.0.5
[1.0.5] - 2026-03-04
Added
- Automatic WebM transcoding — when ffmpeg is present, all uploaded MP4 files are automatically transcoded to WebM (VP9 + Opus) before being saved. Already-WebM uploads are kept as-is. If ffmpeg is unavailable or transcoding fails, the original MP4 is saved as a fallback with a warning logged.
- Home page stats section — the index page now shows a
// Statspanel at the bottom with five live counters: total posts, lifetime images uploaded, lifetime videos uploaded, lifetime audio files uploaded, and total size of active content in GB.
Fixed
- Tor detection on Homebrew — the startup probe now checks
/opt/homebrew/bin/tor(Apple Silicon) and/usr/local/bin/tor(Intel Mac) in addition to baretoron PATH. Also changed from.success()to.is_ok()to handle tor builds that exit with code 1 for--versioneven when installed correctly. - Audio uploads blocked in browser — the file input
acceptattribute was missing all audio MIME types, causing the OS file picker to hide audio files entirely. All audio types are now listed (audio/mpeg,audio/ogg,audio/flac,audio/wav,audio/mp4,audio/aac,audio/webm) along with their extensions as a fallback. - Audio size limit — default
max_audio_size_mbraised from 16 → 150 to accommodate lossless formats such as FLAC. - Audio size not shown in UI — the file hint row below the upload input now includes audio formats and their size limit alongside the existing image and video hints.
- Dead-code warning on
MediaType::from_ext— added#[allow(dead_code)]to suppress the compiler warning for this migration-use function. - Stats section letter-spacing — removed
letter-spacingfrom.index-stat-value(CSS letter-spacing adds a trailing gap after the last character, breaking number alignment) and reduced label tracking from0.08emto0.04em.
v1.0.4
[1.0.4] - 2026-03-03
Added
- 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. - Cross-board links — post bodies now parse
>>>/board/123into 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. - Emoji shortcodes — 25 shortcodes supported in post bodies (e.g.
:fire:→ 🔥,:think:→ 🤔,:based:→ 🗿,:kek:→ 🤣). Applied after HTML transforms to avoid conflicts. - Spoiler tags —
[spoiler]text[/spoiler]hides content behind a same-color block; clicking or hovering reveals it with a smooth transition. - Markup hint bar — a compact row of syntax reminders is shown below the body textarea in the new thread form listing available markup options.
- 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#pollon their thread page. - Resizable expanded images — expanded images support
resize: both, allowing users to drag the corner to any size without reloading. - Per-board upload directories — files are now stored under
rustchan-data/boards/{board}/and thumbnails underrustchan-data/boards/{board}/thumbs/for clean per-board organisation.
Changed
- Data directory renamed from
chan-data/torustchan-data/for clarity. - Upload directory renamed from
uploads/toboards/inside the data directory. The static file route changed from/uploads/to/boards/accordingly. - Bold (
**text**) and italic (__text__) markup now render correctly in all post bodies.
Fixed
- Greentext CSS class mismatch — renderer emits
class="quote"but the stylesheet only targeted.greentext; both are now covered. - Spoiler CSS specificity —
.post-bodycolor was overriding the spoiler hide rule; selectors updated to.post-body .spoiler. - Poll "Question" input overflowing the form on narrow layouts — label and input now use
width: 100%; box-sizing: border-boxandmin-width: 0.
[1.0.3] - 2026-03-03
Changed
- Binary renamed from
rustchantorustchan-clito avoid filesystem conflicts with theRustChan/source directory on case-insensitive filesystems (macOS).
Added
- Dynamic upload progress bar — while a file upload is in progress, a live spinner and pulsing bar are shown in the terminal stats output (e.g.
⠹ UPLOAD [██████░░░░] 2 file(s) uploading). - Requests per second counter — the stats line now includes a live req/s figure computed over the interval since the last tick (e.g.
4.5 req/s). - Board-specific stats — below the main stats line, per-board thread and post counts are shown (e.g.
/b/ threads:12 posts:89 │ /tech/ threads:5 posts:22). - New-event highlighting — when the stats tick detects newly created threads or posts since the last check, those counts are printed in bold yellow with a
(+N)delta indicator. - Active connections / users online — the stats output now shows the count of unique client IPs active within the last 5 minutes and lists up to 5 of them (e.g.
users online: 3 │ IPs: 192.168.1.2, 192.168.1.5). - Keyboard-driven admin console — an interactive prompt is available while the server is running. Commands:
[s]show stats,[l]list boards,[c]create board,[d]delete thread,[u]clear thumbnail cache,[h]help,[q]quit hint.
[1.0.2] - 2026-03-03
Changed
- Frutiger Aero: Softened the background gradient from saturated electric sky-blue to a cooler, more muted pearl-slate. Border glow pulled back from
#38b6ffto a dusty steel blue (#6aaed6). Glass panels now feel frosted rather than bright. Button styles added to match the new palette. - NeonCubicle: Replaced blinding pure cyan (
#0FF0FF) borders and hot magenta (#FF00AA) accents with muted steel-teal borders and a softer dusty rose/orchid for accents. Lavender panels desaturated slightly. Scanlines dialed back to 7% opacity.
[1.0.1] - 2026-03-03
Added
- Theme picker button fixed to the bottom-right corner of every page. Clicking it opens a panel with five selectable themes; the choice is persisted in
localStorageand applied on load with no flash.- Terminal (default) — dark matrix-green monospace aesthetic.
- Frutiger Aero — glossy sky-blue gradients, glassy panels with backdrop-filter blur, rounded corners, Segoe UI font.
- DORFic Aero — dark hewn-stone background with warm amber/copper glassmorphic panels and torchlit glow. Underground fortress meets Vista-era frosted glass.
- FluoroGrid — pale sage background with muted teal grid lines, dusty lavender panels, and plum accents evoking a fluorescent-lit 80s office.
- NeonCubicle — off-white with horizontal scanlines, lavender panels, cyan borders, and hot magenta accents.
Changed
- FluoroGrid: Softened from pure cyan/magenta to muted teal borders and dusty plum accents for a more comfortable reading experience.
- DORFic: Fully redesigned as DORFic Aero — dark stone walls, amber glass panels, copper glow borders, parchment text.
[1.0.0] - 2026-03-03
Initial release.
Features
- Imageboard-style boards with threaded posts and image/video uploads
- Tripcodes and secure deletion tokens for anonymous users
- Admin panel with board management, post moderation, and ban system
- Rate limiting and CSRF protection
- Configurable via
settings.tomlor environment variables - SQLite backend with connection pooling
- Nginx and systemd deployment configuration included