From f33679ee6fb47249ae704df1af8253d417aeef66 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sun, 8 Mar 2026 17:04:22 -0700 Subject: [PATCH 1/3] dev bump for next release fixed config file not storing title data --- CHANGELOG.md | 15 + Cargo.lock | 2 +- Cargo.toml | 2 +- docs/rustchan-showcase.html | 2644 ----------------------------------- src/config.rs | 34 + src/handlers/admin.rs | 30 + src/main.rs | 21 +- src/templates/admin.rs | 168 ++- src/templates/mod.rs | 3 + static/style.css | 117 ++ 10 files changed, 338 insertions(+), 2698 deletions(-) delete mode 100644 docs/rustchan-showcase.html diff --git a/CHANGELOG.md b/CHANGELOG.md index eecb75d..66f46ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to RustChan will be documented in this file. --- +## [1.0.13] — 2026-03-08 + +### ✨ Added +- **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.toml` now includes a `default_theme` field so the server-side default theme can be set before first startup, without requiring admin panel access. +- **Home page subtitle in settings.toml** — `site_subtitle` is now present in the generated `settings.toml` directly below `forum_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. + +--- + ## [1.0.12] — 2026-03-07 ### 🔄 Changed diff --git a/Cargo.lock b/Cargo.lock index 34bf957..a0171e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1256,7 +1256,7 @@ dependencies = [ [[package]] name = "rustchan" -version = "1.0.12" +version = "1.0.13" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index 98a6b4c..dd2908d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustchan" -version = "1.0.12" +version = "1.0.13" edition = "2021" license = "MIT" # FIX[LOW-1]: Removed hardware-specific description. This binary is portable diff --git a/docs/rustchan-showcase.html b/docs/rustchan-showcase.html deleted file mode 100644 index 7fdfbd7..0000000 --- a/docs/rustchan-showcase.html +++ /dev/null @@ -1,2644 +0,0 @@ - - - - - -RustChan — Self-Hosted Imageboard. One Binary. Zero Dependencies. - - - - - - - - - - -
-
- -
-██████╗ ██╗   ██╗███████╗████████╗ ██████╗██╗  ██╗ █████╗ ███╗   ██╗
-██╔══██╗██║   ██║██╔════╝╚══██╔══╝██╔════╝██║  ██║██╔══██╗████╗  ██║
-██████╔╝██║   ██║███████╗   ██║   ██║     ███████║███████║██╔██╗ ██║
-██╔══██╗██║   ██║╚════██║   ██║   ██║     ██╔══██║██╔══██║██║╚██╗██║
-██║  ██║╚██████╔╝███████║   ██║   ╚██████╗██║  ██║██║  ██║██║ ╚████║
-╚═╝  ╚═╝ ╚═════╝ ╚══════╝   ╚═╝    ╚═════╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═══╝
- -

RUSTCHAN

-

A self-hosted imageboard. One binary. Zero runtime dependencies.

- -
- 🦀 Rust 1.75+ - 🗄 SQLite WAL - 📦 Axum 0.8 - MIT License - 🆓 Free & Open Source - 🖥 12–18 MiB Binary -
- - -
- - -
-
-
-
1
-
Binary to Deploy
-
-
-
0
-
Runtime Deps
-
-
-
5
-
Built-in Themes
-
-
-
15MB
-
Max Binary Size
-
-
-
<1s
-
Cold Start Time
-
-
-
100%
-
Web-Based Admin
-
-
-
- -
- - -
-
- -

Everything included.
Nothing required.

-

RustChan ships with a complete imageboard stack. Drop it on a VPS, a Raspberry Pi, or your laptop — it runs immediately. All data lives in one directory next to the binary.

-
- -
- -
- 📋 -
Boards & Posting
-
    -
  • Multiple boards with independent per-board configuration
  • -
  • Threaded replies with unique post numbers across the instance
  • -
  • Thread polls — OP-only, 2–10 options, live % bar results
  • -
  • Spoiler tags, dice rolling, emoji shortcodes (25 built-in)
  • -
  • Cross-board links with live hover-preview popup
  • -
  • Bold, italic, greentext, inline quote-links
  • -
  • Post sage — reply without bumping thread
  • -
  • Post editing within configurable time window
  • -
  • Draft autosave to localStorage every 3 seconds
  • -
  • Tripcodes and user-deletable posts
  • -
-
- -
- 🖼️ -
Rich Media
-
    -
  • Images: JPEG (EXIF-stripped), PNG, GIF, WebP
  • -
  • Video: MP4 & WebM — auto-transcoded to VP9+Opus via ffmpeg
  • -
  • Audio: MP3, OGG, FLAC, WAV, M4A, AAC (up to 150 MB)
  • -
  • Image + audio combo posts on a single post
  • -
  • Audio waveform thumbnails via ffmpeg showwavespic
  • -
  • YouTube, Invidious & Streamable embed unfurling
  • -
  • Auto-generated thumbnails, resizable inline image expansion
  • -
  • Two-layer file validation: Content-Type + magic byte
  • -
-
- -
- 🛡️ -
Moderation & Admin
-
    -
  • Board creation, settings editing, deletion
  • -
  • Thread sticky / lock toggles
  • -
  • Per-post inline ban+delete from admin view (⛔ button)
  • -
  • Ban appeal system with admin queue and auto-unban
  • -
  • IP history view across all boards per poster
  • -
  • PoW CAPTCHA for new threads (per-board opt-in)
  • -
  • Word filters (pattern → replacement, site-wide)
  • -
  • SQLite VACUUM one-click with before/after size display
  • -
-
- -
- 💾 -
Backup & Restore
-
    -
  • Full site backups — save to server or download .zip
  • -
  • Per-board backups — self-contained with all media
  • -
  • Restore from server file or local upload, no restart
  • -
  • Uses sqlite3_backup_init() — zero corruption risk
  • -
  • Backup management panel — list, download, delete
  • -
  • Consistent snapshot via VACUUM INTO under live writes
  • -
-
- -
- 🗂️ -
Thread Lifecycle
-
    -
  • Thread archiving — overflow threads preserved, not deleted
  • -
  • Archive page with thumbnails, reply counts, pagination
  • -
  • Per-board toggle: archive-on-overflow vs hard-delete
  • -
  • Thread auto-update with delta-compressed state
  • -
  • Floating "+N new replies" pill with scroll-to
  • -
  • "(You)" post tracking persisted across refreshes
  • -
  • Board index, catalog grid, full-text search, pagination
  • -
-
- -
- 📱 -
Mobile & UX
-
    -
  • Mobile reply drawer on ≤767 px — floating ✏ Reply button
  • -
  • Cross-board quotelink hover previews with client-side cache
  • -
  • Five built-in themes, user-selectable, no load flash
  • -
  • Live home page stats — posts, images, videos, audio, size
  • -
  • Interactive keyboard console: [s] stats · [l] boards · [h] help
  • -
  • Fully responsive layout for all screen sizes
  • -
-
- -
-
- - -
-
- -
- -

Full Feature Reference

-
- -
-
- - - - - - -
- - -
-
-
-

📸 Image Processing

-
    -
  • 🟩JPEG uploads re-encoded through image crate — GPS, device ID & all EXIF/XMP/IPTC metadata discarded
  • -
  • 🟩PNG, GIF, WebP supported natively with auto-thumbnail generation
  • -
  • 🟩Drag-to-resize inline image expansion in thread view
  • -
  • 🟩Configurable thumbnail max dimension via CHAN_THUMB_SIZE
  • -
  • 🟩Two-layer file validation: Content-Type header + magic byte inspection — extension never trusted
  • -
- -

🎬 Video Processing

-
    -
  • 🟨MP4 uploads auto-transcoded to VP9+Opus WebM when ffmpeg present ffmpeg
  • -
  • 🟨AV1 WebM re-encoded to VP9 for broad browser compatibility ffmpeg
  • -
  • 🟨First-frame video thumbnails for catalog and index previews ffmpeg
  • -
  • 🟩Videos served in original format when ffmpeg absent — graceful degradation
  • -
-
-
-

🎵 Audio Processing

-
    -
  • 🟩MP3, OGG, FLAC, WAV, M4A, AAC supported (up to 150 MB default)
  • -
  • 🟨Standalone audio uploads generate static waveform PNG via ffmpeg's showwavespic filter ffmpeg
  • -
  • 🟩Image + audio combo posts — attach both to same post simultaneously
  • -
  • 🟩Generic music-note icon shown when ffmpeg absent — no errors
  • -
- -

📺 Video Embed Unfurling

-
    -
  • 🟩YouTube, Invidious, Streamable URLs detected in post bodies new
  • -
  • 🟩Replaced inline with thumbnail + click-to-play iframe widget
  • -
  • 🟩Positioned before post body like a native webm attachment
  • -
  • 🟩YouTube thumbnails appear in catalog and board index too
  • -
  • 🟩Original URL preserved as a link alongside the widget
  • -
  • 🟩Per-board opt-in toggle in admin panel
  • -
- -
- # Upload size limits (settings.toml)
- max_image_size_mb = 8
- max_video_size_mb = 50
- max_audio_size_mb = 150 -
-
-
-
- - -
-
-
-

🏛️ Board Management

-
    -
  • ⚙️Create, edit, and delete boards from the web admin panel
  • -
  • ⚙️Per-board settings: NSFW flag, bump limit, max thread cap
  • -
  • ⚙️Archive vs delete on overflow toggle per board
  • -
  • ⚙️Post editing toggle + configurable edit window per board
  • -
  • ⚙️PoW CAPTCHA toggle per board for new thread creation
  • -
  • ⚙️Video embed unfurling opt-in per board
  • -
- -

⛔ Ban Management

-
    -
  • 🚫Per-post inline ban+delete — ⛔ button on every post in admin view new
  • -
  • 🚫Browser prompt collects reason and duration at click time
  • -
  • 🚫Atomically bans IP hash and deletes post (or full thread if OP)
  • -
  • 🚫Ban appeal system — banned users see appeal textarea on ban page new
  • -
  • 🚫Appeals queue in admin panel with ✕ dismiss / ✓ accept+unban buttons
  • -
  • 🚫Accept immediately removes ban — 24h per-IP cooldown on appeals
  • -
  • 🚫CLI: permanent and timed bans, unban, list bans
  • -
-
-
-

🔍 Investigation Tools

-
    -
  • 🔎IP history view — 🔍 link beside every admin-visible post new
  • -
  • 🔎Paginated history of all posts from that IP hash across all boards
  • -
  • 🔎Raw IPs never stored — salted SHA-256 hash only
  • -
- -

⚡ Site-Wide Controls

-
    -
  • 🌐Site name and home page subtitle configurable from admin panel
  • -
  • 🌐Greentext wall collapsing toggle (site-wide)
  • -
  • 🌐Word filters — pattern → replacement rules, site-wide
  • -
  • 🌐SQLite VACUUM — one-click with before/after size display
  • -
  • 🌐Admin session: configurable duration (default 8h)
  • -
  • 🌐Thread sticky / lock toggles from any thread page
  • -
- -

🖥️ CLI Administration

-
- # Admin accounts
- ./rustchan-cli admin create-admin user pw
- ./rustchan-cli admin reset-password user pw

- # Boards
- ./rustchan-cli admin create-board b "Random"

- # Bans
- ./rustchan-cli admin ban <hash> "reason" [hours] -
-
-
-
- - -
-
-
-

🗄️ SQLite Architecture

-
    -
  • 💎SQLite in WAL (Write-Ahead Logging) mode for concurrent reads
  • -
  • 💎SQLite bundled via rusqlite — no system library required
  • -
  • 💎Connection pool via r2d2 + r2d2_sqlite
  • -
  • 💎No ORM — all SQL hand-written in db.rs for full control
  • -
  • 💎WAL checkpoint background task — prevents WAL from growing unbounded
  • -
  • 💎Configurable checkpoint interval (default: 3600s, set 0 to disable)
  • -
- -

📊 Data Layout

-
-rustchan-data/
-├── settings.toml ← instance config
-├── chan.db ← SQLite DB (WAL)
-├── full-backups/ ← full site backups
-├── board-backups/ ← per-board backups
-└── boards/
- ├── b/
- │ ├── <uuid>.<ext> ← uploads
- │ └── thumbs/ ← thumbnails
- └── tech/ -
-
-
-

⚡ Performance Features

-
    -
  • 🚀Delta-compressed thread state — new replies fetched without full reload
  • -
  • 🚀In-memory per-IP sliding window rate limiting via DashMap
  • -
  • 🚀Single binary starts in under a second — no boot sequence
  • -
  • 🚀HTML rendered with plain Rust format! strings — zero template overhead
  • -
  • 🚀Client-side caching of cross-board quotelink hover previews
  • -
  • 🚀strip=true, lto=thin, panic=abort — 12–18 MiB release binary
  • -
- -

🔧 Configuration

-
-# Environment variable overrides
-CHAN_DB=path/to/chan.db
-CHAN_BUMP_LIMIT=500
-CHAN_MAX_THREADS=150
-CHAN_RATE_POSTS=10
-CHAN_RATE_WINDOW=60
-CHAN_WAL_CHECKPOINT_SECS=3600
-CHAN_THUMB_SIZE=250 -
- -

🏗️ Tech Stack

- - - - - - - -
Web frameworkAxum 0.8
Async runtimeTokio 1.x
DatabaseSQLite / rusqlite (bundled)
Image processingimage crate
Password hashingargon2 — Argon2id
HTML renderingPlain Rust format! macros
-
-
-
- - -
-
-
-

🌐 Full Site Backups

-
    -
  • 💾Save to server — creates a .zip in full-backups/ without stopping the server
  • -
  • ⬇️Download to computer — streams the saved zip directly to your browser
  • -
  • Restore from server file — restores live DB from a saved backup, no restart
  • -
  • Restore from local file — upload a .zip from your computer to restore
  • -
  • 🗑️Delete — permanently removes the .zip from server filesystem
  • -
  • 🔒Consistent snapshot via SQLite's VACUUM INTO — safe under live writes
  • -
  • 📦Zip contains database snapshot + all uploaded files and thumbnails
  • -
- -

🗃️ Per-Board Backups

-
    -
  • 📋Self-contained: board.json manifest (all posts, threads, polls, votes, file hash records)
  • -
  • 📁Includes that board's entire upload directory
  • -
  • 🛡️Other boards are never touched during a per-board backup
  • -
  • 💾Save to server or download — one-click from each board's admin card
  • -
-
-
-

♻️ Restore Behavior

-
    -
  • Board exists → content wiped and replaced; settings updated from manifest
  • -
  • Board doesn't exist → created from scratch with the manifest's configuration
  • -
  • All row IDs remapped on import — zero collision risk with existing data
  • -
  • Uses sqlite3_backup_init() API — no file swapping, no WAL corruption
  • -
  • Every pooled connection reads restored data immediately — no restart required
  • -
  • Backup filenames validated to [a-zA-Z0-9._-] only — no path traversal
  • -
- -

🔬 How it Works Internally

-
    -
  • ⚙️RustChan uses SQLite's sqlite3_backup_init() rather than file swapping
  • -
  • ⚙️Pages copied directly into the live connection's open file descriptors
  • -
  • ⚙️No file renaming, no WAL deletion, no restart — truly hot restore
  • -
- -
- # Backup filenames (auto-generated)
- rustchan-backup-20260304_120000.zip
- rustchan-board-tech-20260304_120000.zip -
-
-
-
- - -
-
-
-

✍️ Post Markup

-
-
-
>quoted text> greentext line
-
>>123>>123 (jump to post)
-
>>>/board/123>>>/board/123 (cross-board)
-
**bold text**bold text
-
__italic text__italic text
-
[spoiler]text[/spoiler]hover to reveal
-
[dice 2d6]🎲 2d6 ▸ ⚄ ⚅ = 11
-
:fire: :think: :based:🔥 🤔 🗿
-
-
- -

📊 Thread Polls

-
    -
  • 📊OP-only poll creation, 2–10 options, optional expiry
  • -
  • 📊Live percentage bar results, one vote per IP enforced at DB level
  • -
  • 📊Closed polls show results without vote option
  • -
-
-
-

🎲 Interactive Features

-
    -
  • 🎲Server-side dice rolling — [dice NdM] rolled at post time, embedded immutably in rendered HTML
  • -
  • 🎲d6 faces shown as Unicode die chars (⚀–⚅), other sizes as 【N】
  • -
  • ✏️Post editing within configurable window — deletion-token auth
  • -
  • ✏️Edited posts show (edited HH:MM:SS) badge
  • -
  • 💾Draft autosave — textarea persisted to localStorage every 3s
  • -
  • 🔕Post sage — reply without bumping the thread
  • -
  • 🔑Tripcodes — SHA-256 based, displayed in amber
  • -
- -

🔒 PoW CAPTCHA

-
    -
  • 🧩SHA-256 hashcash at 20-bit difficulty for new thread creation
  • -
  • 🧩Solved entirely in browser (~50–200ms) before form submits
  • -
  • 🧩Replies intentionally exempt — only new threads gated
  • -
  • 🧩Solutions verified server-side with 5-minute grace window for clock skew
  • -
  • 🧩Per-board opt-in toggle in admin panel
  • -
-
-
-
- - -
-
-
-

🎬 ffmpeg Integration optional

-
    -
  • 🟨Detected automatically on PATH at startup — no config required
  • -
  • 🟨MP4 → VP9+Opus WebM transcode (original MP4 never stored)
  • -
  • 🟨AV1 WebM → VP9+Opus re-encode for cross-browser support
  • -
  • 🟨Audio waveform PNGs via showwavespic filter — colour-matched to theme
  • -
  • 🟨Video thumbnails from first frame for catalog/index
  • -
  • 🟩RustChan degrades gracefully without ffmpeg — logs a warning only
  • -
  • ⚙️Set require_ffmpeg = true to make absence a hard startup error
  • -
-
-
-

🧅 Tor / Onion Service optional

-
    -
  • 🧅Enable with enable_tor_support = true in settings.toml
  • -
  • 🧅Detects Tor at startup — checks system PATH, Homebrew on macOS
  • -
  • 🧅Reads .onion address from hidden-service hostname file
  • -
  • 🧅Displays .onion address on home page and in admin panel
  • -
  • 🧅Prints setup hints if Tor installed but hidden service not configured
  • -
  • 🧅Tor handles all onion routing independently — RustChan binds to normal port
  • -
- -

🚀 Deployment Targets

-
    -
  • 🖥Linux x86-64, ARM64 (Raspberry Pi 4/5), Windows x86-64
  • -
  • 🖥Cross-compilation via cross for ARM targets
  • -
  • 🖥systemd service with hardened directives (NoNewPrivileges, PrivateTmp)
  • -
  • 🖥nginx reverse proxy + TLS via Let's Encrypt documented in SETUP.md
  • -
  • 🖥Behind-proxy mode: trust X-Forwarded-For header
  • -
-
-
-
- -
-
-
- - -
-
-
- -

Five Built-in Themes

-

User-selectable from a floating picker on every page. Persisted in localStorage with zero load flash. Click any theme below to preview it — this entire page will transform.

-
- -
- - - - - - - - - - - -
- - -
-
- -
- [b] - [tech] - [meta] -
- admin -
-
-
/tech/ — Technology
-
- -
- Just deployed RustChan on a Raspberry Pi 4. Single binary, no containers, no npm. Runs perfectly. -
>mfw it just works -
-
-
- -
- >>1337 What OS? Did you need ffmpeg? -
-
-
- -
- Raspberry Pi OS Lite. ffmpeg optional — works without it too. 🎲 2d6 ▸ ⚄ ⚅ = 11 -
-
-
-
- -
-
- - -
-
- -
- -

Every format. Every device.
Optional GPU acceleration.

-

RustChan handles images, video, and audio natively. When ffmpeg is present on PATH, it upgrades automatically — more formats, waveform thumbnails, and broad-compatibility transcoding. Without it, everything still works.

-
- - -
-
Supported Formats
-
- -
-
📸
-
Images
-
- JPEG - PNG - GIF - WebP -
-
    -
  • JPEG uploads re-encoded via image crate — all EXIF, GPS, XMP, IPTC discarded before saving
  • -
  • Auto-generated thumbnails with configurable max dimension (CHAN_THUMB_SIZE, default 250px)
  • -
  • Drag-to-resize inline image expansion in thread view
  • -
  • Click-to-expand overlay with close button; smooth CSS transition
  • -
  • Reply thumbnails capped smaller (80×80) to preserve board layout
  • -
-
- -
-
🎬
-
Video ffmpeg optional
-
- MP4→WebM - WebM - AV1→VP9 -
-
    -
  • ffmpeg present: MP4 auto-transcoded to VP9+Opus WebM — original MP4 never stored on disk
  • -
  • AV1 WebM re-encoded to VP9+Opus for browsers without AV1 decoder support
  • -
  • First-frame thumbnail extracted for catalog and board index previews
  • -
  • ffmpeg absent: videos served in original format, graceful warning at startup only
  • -
  • Set require_ffmpeg = true to make absence a hard startup error
  • -
-
- -
-
🎵
-
Audio ffmpeg optional
-
- MP3 - OGG - FLAC - WAV - M4A - AAC -
-
    -
  • Up to 150 MB default — configurable via CHAN_MAX_AUDIO_MB
  • -
  • ffmpeg present: standalone audio generates a colour-matched waveform PNG thumbnail via showwavespic
  • -
  • ffmpeg absent: generic music-note icon shown — no errors, no missing features for core posting
  • -
  • Image + audio combo posts — attach one image and one audio file to the same post simultaneously
  • -
-
- -
-
- - -
- -
-
📺
-
Video Embed Unfurling v1.0.10
-

YouTube, Invidious, and Streamable URLs pasted in post bodies are automatically detected and replaced with a click-to-play widget — identical in layout to a native WebM attachment.

-
    -
  • Thumbnail fetched and displayed inline, positioned before post body
  • -
  • Click thumbnail → plays inline via iframe; original URL preserved as a link
  • -
  • YouTube thumbnails visible in catalog grid and board index too
  • -
  • Per-board opt-in toggle in admin panel — disabled by default
  • -
  • Supports YouTube, Invidious instances, and Streamable links
  • -
  • No external JavaScript — server renders the widget HTML at post time
  • -
- -
-
-
- -
-
-
youtube.com/watch?v=dQw4w9WgXcQ
-
Click to play · opens inline iframe
-
-
-
-
- -
-
🔍
-
Two-Layer File Validation
-

Every upload passes two independent security checks before being accepted. File extensions are never trusted — the actual binary content is inspected.

- -
-
-
Layer 1 — Content-Type Header
-
Browser-supplied MIME type checked against the allowlist before reading the file body.
-
-
-
Layer 2 — Magic Byte Inspection
-
First bytes of the file content read and matched against known format signatures. A JPEG renamed to .png is rejected.
-
-
- -
    -
  • File extension is never consulted — completely untrusted
  • -
  • Both layers must agree before the upload proceeds
  • -
  • Malformed or polyglot files caught at the gate
  • -
  • Upload size limits enforced before touching disk
  • -
-
-
- - -
-
-
- CAPABILITY COMPARISON — WITH vs WITHOUT FFMPEG -
-
- - - - - - - - - - - - - - - - -
FeatureWith ffmpegWithout ffmpeg
MP4 uploadTranscoded to VP9+Opus WebMServed as original MP4
AV1 WebM uploadRe-encoded to VP9+OpusServed as-is (may not play everywhere)
Video thumbnails✓ First frame extractedGeneric icon
Audio waveform thumbnails✓ showwavespic PNGMusic-note icon
Image uploads✓ Full support✓ Full support
EXIF stripping✓ Always on (via image crate)✓ Always on (via image crate)
-
-
-
- -
-
- - -
- -
- -

Full control.
No shell access required.

-

Every moderation action — bans, appeals, board config, content removal, backups, word filters, database maintenance — is available directly from the web admin panel. The CLI is there if you need it for scripting.

-
- - -
- -
- 🏛️ -
Board Management
-
    -
  • Create boards with a short slug (1–8 chars), name, and description
  • -
  • Edit board settings inline — NSFW flag, bump limit, thread cap, description
  • -
  • Per-board editing toggle + configurable edit window in seconds
  • -
  • Per-board archive-on-overflow vs hard-delete-on-overflow toggle
  • -
  • Per-board PoW CAPTCHA toggle for new thread creation
  • -
  • Per-board video embed unfurling opt-in
  • -
  • Delete a board entirely — removes all posts, media, and threads
  • -
  • All settings in collapsible cards — expand only what you need
  • -
-
- -
- -
Ban System v1.0.10
-
    -
  • Inline ⛔ button on every post in admin view — no copy-pasting IP hashes
  • -
  • Browser prompt collects ban reason and duration at click time
  • -
  • Atomically bans poster's IP hash + deletes post in a single action
  • -
  • If post is the thread OP, entire thread is deleted with it
  • -
  • Timed bans (hours) or permanent (omit duration)
  • -
  • Ban list in admin panel — view all active bans, unban with one click
  • -
  • IP hash shown alongside each ban — raw IP never stored or displayed
  • -
-
- -
- 📬 -
Ban Appeal System v1.0.10
-
    -
  • Banned users see an appeal textarea on the ban page (max 512 chars)
  • -
  • All open appeals queue in a dedicated section of the admin panel
  • -
  • ✕ Dismiss — closes appeal without action
  • -
  • ✓ Accept + Unban — immediately removes the ban and closes appeal
  • -
  • 24-hour per-IP cooldown prevents appeal spam
  • -
  • Appeal count visible in admin nav — never miss a pending appeal
  • -
-
- -
- 🔍 -
IP History View v1.0.10
-
    -
  • 🔍 link beside every admin-visible post opens IP history page
  • -
  • Paginated list of every post from that IP hash across all boards
  • -
  • Board, thread, post number, timestamp, and post preview shown
  • -
  • Quickly identify ban-evaders and coordinated spam campaigns
  • -
  • Raw IP is never shown — only the salted SHA-256 hash
  • -
-
- -
- 🔤 -
Word Filters & Site Settings
-
    -
  • Pattern → replacement rules applied site-wide at render time
  • -
  • Add, edit, or remove filters from the admin panel without restart
  • -
  • Site name and home page subtitle editable from the panel
  • -
  • Greentext wall collapsing toggle — collapse long greentext blocks
  • -
  • Settings take effect immediately — no configuration file edit needed
  • -
-
- -
- 🧹 -
Database Maintenance
-
    -
  • SQLite VACUUM with one click from the admin panel
  • -
  • Before and after file size displayed — see exactly how much space reclaimed
  • -
  • WAL checkpoint runs as a configurable background task (default: hourly)
  • -
  • All database ops use the live connection — no downtime, no restart
  • -
-
- -
- - -
- -
-
🧩
-
Proof-of-Work CAPTCHA v1.0.10
-

New thread creation can be gated behind a SHA-256 hashcash PoW challenge solved silently in the browser. No images to click, no third-party tracking, no accessibility barriers.

-
-
-
20-bit difficulty
-
~50–200ms solution time in browser — imperceptible to humans, expensive to automate at scale
-
-
-
Browser-side only
-
JS Web Worker solves the challenge; solution included in the form submission automatically
-
-
-
Server-side verification
-
Solution verified server-side with a 5-minute grace window for clock skew — cannot be faked
-
-
-
Replies always exempt
-
Only new thread OPs are gated — replies flow freely, preserving discussion momentum
-
-
-
- -
-
🖥️
-
Admin CLI Reference
-

All admin operations are also available from the command line — ideal for provisioning scripts, automated backups, and CI pipelines.

- -
- # ── Admin accounts ──────────────────
- ./rustchan-cli admin create-admin user pw
- ./rustchan-cli admin reset-password user pw
- ./rustchan-cli admin list-admins

- # ── Boards ──────────────────────────
- ./rustchan-cli admin create-board b "Random" "desc"
- ./rustchan-cli admin create-board nsfw "18+" "" --nsfw
- ./rustchan-cli admin delete-board b
- ./rustchan-cli admin list-boards

- # ── Bans ────────────────────────────
- ./rustchan-cli admin ban <hash> "reason" 24
- ./rustchan-cli admin ban <hash> "permanent"
- ./rustchan-cli admin unban <ban_id>
- ./rustchan-cli admin list-bans -
-
-
- - -
-
📌 Thread-Level Controls
-
-
-
📌 Sticky
-
Pin a thread to the top of the board index and catalog. One click from the thread page in admin view.
-
-
-
🔒 Lock
-
Prevent new replies. Thread remains readable. Locked badge shown to all users. Toggle from thread view.
-
-
-
🗑️ Delete Thread
-
Delete the entire thread and all replies with associated media in one action. Permanent.
-
-
-
⛔ Ban + Delete Post
-
Inline button on every post in admin view. Reason and duration in a single prompt. Atomic action.
-
-
-
🔍 IP History
-
See paginated cross-board post history for any poster's IP hash. Identify patterns instantly.
-
-
-
✏️ Post Edit Window
-
Per-board configurable window (seconds). Edited posts show (edited HH:MM:SS) badge. Disable editing per-board.
-
-
-
- -
- - -
-
- -
- -

SQLite. Bundled. Zero setup.

-

No Postgres to configure, no Redis to run, no migrations to manage manually. SQLite is bundled at compile time — no system library required. WAL mode gives you concurrent reads without connection queuing.

-
- - -
- -
-
🏗️
-
Storage Architecture
-
-rustchan-data/
-├── settings.toml ← auto-generated on first run
-├── chan.db ← SQLite database (WAL mode)
-├── chan.db-wal ← write-ahead log (auto-managed)
-├── chan.db-shm ← shared memory file
-├── full-backups/
-│ └── rustchan-backup-20260304_120000.zip
-├── board-backups/
-│ └── rustchan-board-tech-20260304_120000.zip
-└── boards/
- ├── b/
- │ ├── <uuid>.<ext> ← uploaded files
- │ └── thumbs/ ← generated thumbnails
- └── tech/
- ├── <uuid>.<ext>
- └── thumbs/ -
-

Everything lives in one directory next to the binary. Migrating to a new server is literally cp -r rustchan-data/ newserver:

-
- -
-
-
-
WAL Mode
-

Write-Ahead Logging lets multiple readers proceed simultaneously while a single writer commits — no read-blocking, no connection queuing under normal load. The WAL file is checkpointed by a configurable background task (default: every 3600s) to prevent it from growing unbounded.

-
-
-
🔗
-
Connection Pool
-

r2d2 + r2d2_sqlite manages a pool of open SQLite connections. Async Axum handlers check out a connection, execute SQL, return it immediately — no blocking the async executor.

-
-
-
✍️
-
No ORM
-

All queries are handwritten SQL in src/db.rs. No query builder, no migrations framework, no N+1 surprises. You can read every query the app will ever run in one file.

-
-
-
- - -
- -
-
TECHNOLOGY STACK
- - - - - - - - - - - - - - -
Web frameworkAxum 0.8
Async runtimeTokio 1.x
Database driverrusqlite — SQLite bundled, no system lib needed
Connection poolr2d2 + r2d2_sqlite
Image processingimage crate (JPEG, PNG, GIF, WebP)
Video / audioffmpeg (optional, detected at runtime)
Password hashingargon2 crate — Argon2id t=2 m=65536 p=2
Rate limitingDashMap in-memory sliding window
HTML renderingPlain Rust format! — zero template engine
Configsettings.toml + env var overrides via once_cell::Lazy
Loggingtracing + tracing-subscriber (stdout / journald)
-
- -
-
ENVIRONMENT VARIABLES
- - - - - - - - - - - - - - - - - - - - - -
VariableDefault
CHAN_DB<exe-dir>/rustchan-data/chan.db
CHAN_UPLOADS<exe-dir>/rustchan-data/boards
CHAN_PORT8080
CHAN_BUMP_LIMIT500
CHAN_MAX_THREADS150
CHAN_RATE_POSTS10
CHAN_RATE_WINDOW60
CHAN_THUMB_SIZE250
CHAN_SESSION_SECS28800 (8 hours)
CHAN_WAL_CHECKPOINT_SECS3600
CHAN_BEHIND_PROXYfalse
RUST_LOGrustchan-cli=info
-
- -
- - -
-
SOURCE LAYOUT — src/
-
-
-
main.rs
-
Entry point, router, keyboard console, background tasks (WAL checkpoint)
-
-
-
db.rs
-
All SQL queries — no ORM. Every database interaction in one readable file
-
-
-
config.rs
-
settings.toml parsing + env var overrides, first-run generation
-
-
-
handlers/admin.rs
-
Admin panel, board/ban/filter/backup/appeal management routes
-
-
-
handlers/board.rs
-
Board index, catalog, archive, search, thread creation, ban appeals
-
-
-
handlers/thread.rs
-
Thread view, reply posting, poll voting, post editing, auto-update
-
-
-
utils/files.rs
-
Upload validation, thumbnail generation, EXIF stripping, waveforms
-
-
-
utils/sanitize.rs
-
HTML escaping, markup renderer (greentext, spoilers, dice, embeds)
-
-
-
utils/crypto.rs
-
Argon2id, CSRF tokens, session IDs, IP hashing, PoW verification
-
-
-
- -
-
- - -
- -
- -

Hot backups. Live restore.
Zero downtime.

-

RustChan's backup system is entirely web-based — every action happens from the admin panel, no SSH required. Full site or per-board, save to server or download to your laptop, restore without restarting.

-
- - -
- -
-
🌐
-
Full Site Backups
-

A full backup is a single .zip containing a consistent SQLite snapshot (via VACUUM INTO, safe under live writes) plus every board's uploaded files and thumbnails.

- -
-
- 💾 -
-
Save to server
-
Creates the backup and writes it to rustchan-data/full-backups/ — listed in the panel for later download or restore.
-
-
-
- ⬇️ -
-
Download to computer
-
Streams a saved server-side backup as a .zip directly to your browser — no wget, no scp.
-
-
-
- -
-
Restore from server file
-
Restores the live database from a saved file in-place. No re-upload. No restart. Every connection sees the new data immediately.
-
-
-
- 📤 -
-
Restore from local file
-
Upload a .zip from your computer directly to restore. Useful when moving instances or recovering from a cloud backup.
-
-
-
- 🗑️ -
-
Delete from server
-
Permanently removes the .zip from the server filesystem. Filename validated to [a-zA-Z0-9._-] before any filesystem operation.
-
-
-
-
- -
-
🗃️
-
Per-Board Backups
-

Board backups are fully self-contained. A board.json manifest carries every post, thread, poll, vote, and file hash record for that board — plus the entire upload directory. Other boards are never touched.

- -
-
-
board.json contains:
-
    -
  • All threads and posts with original timestamps
  • -
  • Poll questions, options, and all vote records
  • -
  • File hash records and upload metadata
  • -
  • Board settings (name, description, NSFW flag, limits)
  • -
-
-
- -
Restore Behaviour
-
-
- EXISTS - Content wiped and replaced. Board settings updated from the manifest. -
-
- NEW - Created from scratch using the manifest's configuration — board slug, name, settings all restored. -
-
- - All row IDs remapped on import — zero collision risk with existing boards or posts -
-
- - Restore completes in seconds — board immediately available after completion -
-
-
-
- - -
-
- 🔬 - How Restore Works Internally -
-
-
-

RustChan uses SQLite's sqlite3_backup_init() API rather than swapping files on disk. This means the restore happens at the page level — SQLite pages are copied directly into the live connection's open file descriptors.

-

Every connection in the pool reads the restored data the moment the copy completes. There is no window where some requests see the old data and others see the new — the transition is atomic from the perspective of all active connections.

-
-
-
-
✅ No file swapping
-
The live chan.db file is never renamed, moved, or unlinked. The WAL and SHM files remain valid throughout.
-
-
-
✅ No WAL corruption
-
WAL checkpointing is paused during restore via the backup API's locking protocol. No orphaned WAL entries can corrupt the restored state.
-
-
-
✅ No restart required
-
The server keeps accepting requests throughout. The restored state becomes live the instant the copy finishes — no systemd restart, no downtime window.
-
-
-
✅ Consistent snapshot source
-
Backups use VACUUM INTO which produces a consistent, compacted snapshot safe to read while the server is actively writing posts.
-
-
-
-
- - -
-
Backup & Restore Flow
-
- -
-
📝
-
1. Admin Panel
-
Click "Save to server" or "Download" on any board card or in the full-backup section
-
- -
-
📸
-
2. VACUUM INTO
-
SQLite creates a consistent, compacted snapshot of chan.db safe under concurrent writes
-
- -
-
📦
-
3. Zip Assembly
-
Snapshot + all uploaded files + thumbnails packed into a single timestamped .zip
-
- -
-
💾
-
4. Stored / Streamed
-
Written to full-backups/ on server, or streamed directly to browser — your choice
-
- -
-
-
5. Hot Restore
-
sqlite3_backup_init() pages copied into live connections. Instantly live. No restart.
-
- -
-
- -
- - -
-
- -

Hardened by Default

-

Security is built in, not bolted on. Every layer of the stack has been thought through — from passwords to file uploads to IP privacy.

-
- -
-
-
🔐
-
Argon2id Passwords
-
Memory-hard, GPU-resistant. t=2, m=65536, p=2. ~200ms on Raspberry Pi 4. Brute force computationally infeasible.
-
-
-
🍪
-
Secure Sessions
-
HttpOnly + SameSite=Strict + path-scoped to /admin. Configurable Secure flag for HTTPS. Default 8h duration.
-
-
-
🛡️
-
CSRF Protection
-
Double-submit cookie pattern on every state-changing POST. CSRF token validated against session cookie on every mutation.
-
-
-
🔏
-
IP Privacy
-
Raw IPs are never stored. Salted SHA-256 hash keyed to cookie_secret only. Changing the secret invalidates all existing hashes.
-
-
-
-
Rate Limiting
-
In-memory sliding window per hashed IP via DashMap. Default: 10 POSTs per 60 seconds. Fully configurable via env vars.
-
-
-
📁
-
File Safety
-
Two-layer check: Content-Type header + magic byte inspection. File extension is never trusted. Malicious files rejected early.
-
-
-
🗺️
-
EXIF Stripping
-
All JPEG uploads re-encoded via image crate. GPS coordinates, device serial numbers, camera metadata — all discarded before save.
-
-
-
💉
-
XSS Prevention
-
All user input passes through escape_html() before insertion. Markup transformations (bold, greentext) applied after HTML escaping.
-
-
-
- - -
-
-
- -

Up in 4 commands.

-

No containers. No npm install. No runtime to maintain. Build, create an admin account, create some boards, run.

-
- -
-
-
- # 1. Build
- cargo build --release

- # 2. Create your first admin account
- ./rustchan-cli admin create-admin admin "StrongPassword!"

- # 3. Create some boards
- ./rustchan-cli admin create-board b "Random" "General discussion"
- ./rustchan-cli admin create-board tech "Technology" "Programming"

- # 4. Run
- ./rustchan-cli

- # Open http://localhost:8080 — admin panel at /admin -
- -
- On first launch, rustchan-data/settings.toml is generated automatically - with a freshly-generated cookie_secret and every setting documented inline. - Edit it and restart to apply changes. Everything lives in rustchan-data/ — migrations are a cp -r. -
-
-
- -
-
-
🍓
-
Raspberry Pi
-
Cross-compile for ARM64. Runs with SD card wear reduction. SETUP.md has full guide.
-
-
-
☁️
-
VPS Deployment
-
systemd service, nginx reverse proxy, TLS via Let's Encrypt. Full guide in SETUP.md.
-
-
-
🪟
-
Windows Support
-
Cross-compile to x86-64 Windows target. ffmpeg and Tor installation covered in SETUP.md.
-
-
- -
-
- - - - - - -
-
Select Theme
- - - - - -
- - - - diff --git a/src/config.rs b/src/config.rs index ed0513a..24def58 100644 --- a/src/config.rs +++ b/src/config.rs @@ -50,6 +50,13 @@ fn settings_file_path() -> PathBuf { #[derive(Deserialize, Default)] struct SettingsFile { forum_name: Option, + /// Home page subtitle shown below the site name. Displayed on the index + /// page. Can also be changed later via the admin panel. + site_subtitle: Option, + /// Default theme served to first-time visitors before they pick one. + /// Valid values: terminal, aero, dorfic, fluorogrid, neoncubicle, chanclassic + /// (empty string or "terminal" = default dark terminal theme). + default_theme: Option, port: Option, max_image_size_mb: Option, max_video_size_mb: Option, @@ -100,6 +107,16 @@ pub fn generate_settings_file_if_missing() { # Name shown in the browser tab, page header, and home page title. forum_name = "RustChan" +# Subtitle shown below the site name on the home page. +# Can also be changed at any time from the admin panel → Site Settings. +site_subtitle = "select board to proceed" + +# Default theme for first-time visitors (before they choose their own). +# Valid values: terminal, aero, dorfic, fluorogrid, neoncubicle, chanclassic +# Leave as "terminal" (or empty) for the default dark terminal look. +# Can also be changed at any time from the admin panel → Site Settings. +default_theme = "terminal" + # Port the server listens on (binds to 0.0.0.0:). port = 8080 @@ -148,6 +165,11 @@ pub static CONFIG: Lazy = Lazy::new(Config::from_env); pub struct Config { // ── Loaded from settings.toml (env vars still override) ────────────────── pub forum_name: String, + /// Initial subtitle shown on the home page (seeds the DB on first run). + pub initial_site_subtitle: String, + /// Initial default theme slug (seeds the DB on first run). + /// Valid: terminal, aero, dorfic, fluorogrid, neoncubicle, chanclassic + pub initial_default_theme: String, #[allow(dead_code)] pub port: u16, pub max_image_size: usize, // bytes @@ -192,6 +214,16 @@ impl Config { "CHAN_FORUM_NAME", s.forum_name.as_deref().unwrap_or("RustChan"), ); + let initial_site_subtitle = env_str( + "CHAN_SITE_SUBTITLE", + s.site_subtitle + .as_deref() + .unwrap_or("select board to proceed"), + ); + let initial_default_theme = env_str( + "CHAN_DEFAULT_THEME", + s.default_theme.as_deref().unwrap_or("terminal"), + ); let port = env_u16("CHAN_PORT", s.port.unwrap_or(8080)); let max_image_mb = env_u32("CHAN_MAX_IMAGE_MB", s.max_image_size_mb.unwrap_or(8)); let max_video_mb = env_u32("CHAN_MAX_VIDEO_MB", s.max_video_size_mb.unwrap_or(50)); @@ -225,6 +257,8 @@ impl Config { Self { forum_name, + initial_site_subtitle, + initial_default_theme, port, max_image_size: (max_image_mb as usize) * 1024 * 1024, max_video_size: (max_video_mb as usize) * 1024 * 1024, diff --git a/src/handlers/admin.rs b/src/handlers/admin.rs index c403970..53157ca 100644 --- a/src/handlers/admin.rs +++ b/src/handlers/admin.rs @@ -412,6 +412,7 @@ pub async fn admin_panel( let appeals = db::get_open_ban_appeals(&conn)?; let site_name = db::get_site_name(&conn); let site_subtitle = db::get_site_subtitle(&conn); + let default_theme = db::get_default_user_theme(&conn); // Collect saved backup file lists (read from disk, not DB). let full_backups = list_backup_files(&full_backup_dir()); @@ -449,6 +450,7 @@ pub async fn admin_panel( &appeals, &site_name, &site_subtitle, + &default_theme, tor_address.as_deref(), flash_ref, )) @@ -3472,6 +3474,9 @@ pub struct SiteSettingsForm { pub site_name: Option, /// Custom home page subtitle line below the site name. pub site_subtitle: Option, + /// Default theme served to first-time visitors. + /// Valid slugs: terminal, aero, dorfic, fluorogrid, neoncubicle, chanclassic + pub default_theme: Option, } pub async fn update_site_settings( @@ -3527,6 +3532,31 @@ pub async fn update_site_settings( db::set_site_setting(&conn, "site_subtitle", &new_subtitle)?; crate::templates::set_live_site_subtitle(&new_subtitle); info!("Admin updated site subtitle to: {:?}", new_subtitle); + + // Save the default theme slug (validated against allowed values). + const VALID_THEMES: &[&str] = &[ + "terminal", + "aero", + "dorfic", + "fluorogrid", + "neoncubicle", + "chanclassic", + ]; + let new_theme = form + .default_theme + .as_deref() + .unwrap_or("terminal") + .trim() + .to_string(); + let new_theme = if VALID_THEMES.contains(&new_theme.as_str()) { + new_theme + } else { + "terminal".to_string() + }; + db::set_site_setting(&conn, "default_theme", &new_theme)?; + crate::templates::set_live_default_theme(&new_theme); + info!("Admin updated default theme to: {:?}", new_theme); + Ok(()) } }) diff --git a/src/main.rs b/src/main.rs index 2c7a798..204ae51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -213,11 +213,28 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { if let Ok(conn) = pool.get() { let name = db::get_site_name(&conn); templates::set_live_site_name(&name); + + // Seed subtitle from settings.toml if not yet configured in DB. let subtitle = db::get_site_subtitle(&conn); + let subtitle = if subtitle.is_empty() && !CONFIG.initial_site_subtitle.is_empty() { + let _ = db::set_site_setting(&conn, "site_subtitle", &CONFIG.initial_site_subtitle); + CONFIG.initial_site_subtitle.clone() + } else { + subtitle + }; templates::set_live_site_subtitle(&subtitle); - // Seed the default-theme cache so the first page served already - // carries the correct data-default-theme attribute on . + + // Seed default_theme from settings.toml if not yet configured in DB. let default_theme = db::get_default_user_theme(&conn); + let default_theme = if default_theme.is_empty() + && !CONFIG.initial_default_theme.is_empty() + && CONFIG.initial_default_theme != "terminal" + { + let _ = db::set_site_setting(&conn, "default_theme", &CONFIG.initial_default_theme); + CONFIG.initial_default_theme.clone() + } else { + default_theme + }; templates::set_live_default_theme(&default_theme); } } diff --git a/src/templates/admin.rs b/src/templates/admin.rs index ee72f67..98147bb 100644 --- a/src/templates/admin.rs +++ b/src/templates/admin.rs @@ -54,6 +54,7 @@ pub fn admin_panel_page( appeals: &[crate::models::BanAppeal], site_name: &str, site_subtitle: &str, + default_theme: &str, tor_address: Option<&str>, // Optional one-time flash message shown at the top of the panel. // (is_error, message) — is_error=true → red, false → green. @@ -379,22 +380,46 @@ pub fn admin_panel_page( -
-

// report inbox{report_badge}

- - -{report_rows} -
postcontent previewreasonfiledaction
-
- -
-

// ban appeals{appeal_badge}

- - -{appeal_rows} -
ip (partial)appeal messagefiledaction
+ +
+

// site settings

+
+ +
+ + + +
+
+ +
+ +
+

// boards

{board_cards}
@@ -409,13 +434,43 @@ pub fn admin_panel_page(
+ +
+

// moderation log [ view full log ]

+

All admin actions are recorded in the moderation log. Click view full log to browse the history.

+
+ + +
+

// report inbox{report_badge}

+ + +{report_rows} +
postcontent previewreasonfiledaction
+
+ +
-

// active bans

+

// moderation

+ +

Ban appeals{appeal_badge}

+ + +{appeal_rows} +
ip (partial)appeal messagefiledaction
+ +

Active bans

{ban_rows}
ip hash (partial)reasonexpiresaction
-

add ban

+

add ban

@@ -423,15 +478,13 @@ pub fn admin_panel_page(
-
-
-

// word filters

+

Word filters

{filter_rows}
patternreplacementaction
-

add filter

+

add filter

@@ -440,37 +493,11 @@ pub fn admin_panel_page(
+
-

// site settings

-
- -
- - -
-
- -
- -
-
- -
-

// moderation log [ view full log ]

-

All admin actions are recorded in the moderation log. Click view full log to browse the history.

-
- -
-

// backup & restore

+

// full site backup & restore

Full backups include the complete database and all uploaded files. Save to server stores the backup in rustchan-data/full-backups/ on the server filesystem (listed below). Restore from local file uploads a zip from your computer.

@@ -490,6 +517,9 @@ pub fn admin_panel_page(
+

// board backup & restore

Board backups cover a single board. Use save to server on a board card above to store the backup in rustchan-data/board-backups/, or use the table below to download, restore, or delete saved backups. Restore from local file uploads a zip from your computer.

@@ -507,6 +537,9 @@ pub fn admin_panel_page(
+

// database maintenance

@@ -521,6 +554,10 @@ pub fn admin_panel_page( data-confirm="Run VACUUM? This will briefly block the database while it rebuilds. Continue?">🧹 run VACUUM

+ + {tor_section} "#, csrf = escape_html(csrf_token), @@ -538,9 +575,40 @@ pub fn admin_panel_page( appeal_badge = appeal_badge, site_name_val = escape_html(site_name), site_subtitle_val = escape_html(site_subtitle), + sel_terminal = if default_theme == "terminal" || default_theme.is_empty() { + " selected" + } else { + "" + }, + sel_aero = if default_theme == "aero" { + " selected" + } else { + "" + }, + sel_dorfic = if default_theme == "dorfic" { + " selected" + } else { + "" + }, + sel_fluorogrid = if default_theme == "fluorogrid" { + " selected" + } else { + "" + }, + sel_neoncubicle = if default_theme == "neoncubicle" { + " selected" + } else { + "" + }, + sel_chanclassic = if default_theme == "chanclassic" { + " selected" + } else { + "" + }, tor_section = match tor_address { Some(addr) => format!( r#"
+

// active onion address

{}

diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 92b2efd..af058c9 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -344,6 +344,9 @@ pub(super) fn base_layout( + diff --git a/static/style.css b/static/style.css index ec5a25a..08af907 100644 --- a/static/style.css +++ b/static/style.css @@ -1654,6 +1654,123 @@ main { [data-theme="neoncubicle"] .op, [data-theme="neoncubicle"] .reply { border-radius: 6px; } +/* ── ChanClassic ──────────────────────────────────────────────────────────── */ +/* Mimics the classic 4chan layout: tan/beige background, maroon header, */ +/* blue post-number links, bordered post blocks. */ +[data-theme="chanclassic"] { + color-scheme: light; + --bg: #ead6b8; + --bg-panel: #f0e0d6; + --bg-post: #f0e0d6; + --bg-op: #f0e0d6; + --bg-input: #ffffff; + --border: #d9bfb7; + --border-glow: #800000; + --green: #800000; + --green-dim: #b05080; + --green-bright: #0000ee; + --green-pale: #0000cc; + --amber: #cc6600; + --red: #cc0000; + --gray: #c8b8b0; + --gray-light: #ddd0c8; + --text: #000000; + --text-dim: #707070; + --font: Arial, Helvetica, sans-serif; + --font-display: Arial, Helvetica, sans-serif; +} +[data-theme="chanclassic"] body { + background: #ead6b8; +} +[data-theme="chanclassic"] .site-header { + background: #800000; + border-bottom: 2px solid #5a0000; + box-shadow: none; +} +[data-theme="chanclassic"] .site-header::before { display: none; } +[data-theme="chanclassic"] .site-name { + color: #ffffff; + text-shadow: none; + font-family: Arial, Helvetica, sans-serif; + font-size: 1.1rem; + font-weight: bold; + letter-spacing: 0.04em; +} +[data-theme="chanclassic"] .site-header a, +[data-theme="chanclassic"] .site-header button { + color: #ffcccc; +} +[data-theme="chanclassic"] .site-subtitle { + color: #ffcccc; +} +[data-theme="chanclassic"] a { color: #0000ee; text-decoration: underline; } +[data-theme="chanclassic"] a:hover { color: #cc0000; text-shadow: none; } +[data-theme="chanclassic"] .op, +[data-theme="chanclassic"] .reply { + background: #f0e0d6; + border: 1px solid #d9bfb7; + border-radius: 0; + box-shadow: none; + backdrop-filter: none; +} +[data-theme="chanclassic"] .page-box, +[data-theme="chanclassic"] .post-form-container, +[data-theme="chanclassic"] .admin-section { + background: #f0e0d6; + border: 1px solid #d9bfb7; + border-radius: 0; + backdrop-filter: none; +} +/* Post number / quote link */ +[data-theme="chanclassic"] .post-num a, +[data-theme="chanclassic"] .post-ref { color: #0000ee; } +/* Greentext */ +[data-theme="chanclassic"] .greentext { color: #789922; } +/* Poster name */ +[data-theme="chanclassic"] .name { color: #117743; font-weight: bold; } +/* Subject line */ +[data-theme="chanclassic"] .subject { color: #0f0c5d; font-weight: bold; } +/* Admin/mod capcode */ +[data-theme="chanclassic"] .capcode { color: #800000; } +/* Buttons */ +[data-theme="chanclassic"] button, +[data-theme="chanclassic"] .btn, +[data-theme="chanclassic"] input[type="submit"] { + background: #ead6b8; + border: 1px solid #b09080; + color: #000000; + border-radius: 2px; +} +[data-theme="chanclassic"] button:hover { + background: #d8c8b0; + border-color: #800000; + color: #800000; + box-shadow: none; +} +/* Board header bar */ +[data-theme="chanclassic"] .board-header { + border-bottom: 1px solid #d9bfb7; +} +/* Thread divider */ +[data-theme="chanclassic"] .thread + .thread { border-top: 1px solid #d9bfb7; } +/* Catalog items */ +[data-theme="chanclassic"] .catalog-item { + border: 1px solid #d9bfb7; + border-radius: 0; + background: #f0e0d6; +} +/* Board list cards */ +[data-theme="chanclassic"] .board-card { + border: 1px solid #d9bfb7; + border-radius: 0; + background: #f0e0d6; +} +/* File info */ +[data-theme="chanclassic"] .file-info { color: #707070; font-size: 0.8rem; } +/* Spoiler text */ +[data-theme="chanclassic"] .spoiler { background: #000; color: #000; } +[data-theme="chanclassic"] .spoiler:hover { color: #fff; } + /* ── Theme Picker Widget ──────────────────────────────────────────────────── */ #theme-picker-btn { From 5e97d2ed663eac3d3aa1f0e3c1096084c84d11ac Mon Sep 17 00:00:00 2001 From: csd113 Date: Sun, 8 Mar 2026 17:56:37 -0700 Subject: [PATCH 2/3] fixed backup system calloing for poll data cuasing an error 500 redirect --- src/handlers/admin.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/handlers/admin.rs b/src/handlers/admin.rs index 53157ca..e6dbe3e 100644 --- a/src/handlers/admin.rs +++ b/src/handlers/admin.rs @@ -2043,7 +2043,7 @@ pub async fn create_board_backup( let poll_votes: Vec = { let mut s = conn .prepare( - "SELECT pv.id, pv.poll_id, pv.option_id, pv.ip_hash, pv.created_at + "SELECT pv.id, pv.poll_id, pv.option_id, pv.ip_hash FROM poll_votes pv JOIN polls p ON p.id = pv.poll_id JOIN threads t ON t.id = p.thread_id @@ -2057,7 +2057,6 @@ pub async fn create_board_backup( poll_id: r.get(1)?, option_id: r.get(2)?, ip_hash: r.get(3)?, - created_at: r.get(4)?, }) }) .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))? @@ -2646,8 +2645,8 @@ pub async fn restore_saved_board_backup( })?; conn.execute( "INSERT OR IGNORE INTO poll_votes - (poll_id, option_id, ip_hash, created_at) VALUES (?1,?2,?3,?4)", - params![new_pid, new_oid, v.ip_hash, v.created_at], + (poll_id, option_id, ip_hash) VALUES (?1,?2,?3)", + params![new_pid, new_oid, v.ip_hash], ) .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert vote: {}", e)))?; } @@ -2867,7 +2866,6 @@ mod board_backup_types { pub poll_id: i64, pub option_id: i64, pub ip_hash: String, - pub created_at: i64, } #[derive(Serialize, Deserialize)] pub struct FileHashRow { @@ -3011,7 +3009,7 @@ pub async fn board_backup( // ── Poll votes ──────────────────────────────────────────────── let poll_votes: Vec = { let mut s = conn.prepare( - "SELECT pv.id, pv.poll_id, pv.option_id, pv.ip_hash, pv.created_at + "SELECT pv.id, pv.poll_id, pv.option_id, pv.ip_hash FROM poll_votes pv JOIN polls p ON p.id = pv.poll_id JOIN threads t ON t.id = p.thread_id @@ -3019,7 +3017,7 @@ pub async fn board_backup( ).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; let rows = s.query_map(params![board_id], |r| Ok(PollVoteRow { id: r.get(0)?, poll_id: r.get(1)?, option_id: r.get(2)?, - ip_hash: r.get(3)?, created_at: r.get(4)?, + ip_hash: r.get(3)?, })).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))? .collect::,_>>() .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; rows @@ -3380,8 +3378,8 @@ pub async fn board_restore( })?; conn.execute( "INSERT OR IGNORE INTO poll_votes - (poll_id, option_id, ip_hash, created_at) VALUES (?1,?2,?3,?4)", - params![new_pid, new_oid, v.ip_hash, v.created_at], + (poll_id, option_id, ip_hash) VALUES (?1,?2,?3)", + params![new_pid, new_oid, v.ip_hash], ) .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert vote {}: {}", v.id, e)))?; } From f4a6e67d6c6b8522d64ca4e0871231311c3774f1 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sun, 8 Mar 2026 21:30:13 -0700 Subject: [PATCH 3/3] **Backup system rewritten to stream instead of buffering in RAM** --- CHANGELOG.md | 1 + src/handlers/admin.rs | 504 +++++++++++++++++++++++++++++------------ src/main.rs | 5 + src/middleware/mod.rs | 54 +++++ src/templates/admin.rs | 26 ++- static/main.js | 212 ++++++++++++++++- 6 files changed, 649 insertions(+), 153 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f46ed..7569e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to RustChan will be documented in this file. ## [1.0.13] — 2026-03-08 ### ✨ 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.toml` now includes a `default_theme` field so the server-side default theme can be set before first startup, without requiring admin panel access. - **Home page subtitle in settings.toml** — `site_subtitle` is now present in the generated `settings.toml` directly below `forum_name`, allowing the home page subtitle to be configured at install time. diff --git a/src/handlers/admin.rs b/src/handlers/admin.rs index e6dbe3e..42ae8b9 100644 --- a/src/handlers/admin.rs +++ b/src/handlers/admin.rs @@ -33,10 +33,12 @@ use chrono::Utc; use dashmap::DashMap; use once_cell::sync::Lazy; use serde::Deserialize; +use std::io::{Seek, Write}; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use time; +use tokio_util::io::ReaderStream; use tracing::{info, warn}; const SESSION_COOKIE: &str = "chan_admin_session"; @@ -1291,100 +1293,165 @@ pub async fn update_board_settings( // ─── GET /admin/backup ──────────────────────────────────────────────────────── /// Stream a full zip backup of the database + all uploaded files. -/// The WAL is checkpointed first so the backup contains a consistent snapshot. +/// +/// MEM-FIX: The zip is built to a NamedTempFile on disk (not a Vec in +/// RAM), so peak heap usage is O(compression-buffer) not O(zip-size). +/// The response body is streamed from disk in 64 KiB chunks via ReaderStream. pub async fn admin_backup(State(state): State, jar: CookieJar) -> Result { let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - let upload_dir = CONFIG.upload_dir.clone(); + let progress = state.backup_progress.clone(); - let (zip_bytes, filename) = tokio::task::spawn_blocking({ + let (tmp_path, filename, file_size) = tokio::task::spawn_blocking({ let pool = state.db.clone(); - move || -> Result<(Vec, String)> { + move || -> Result<(PathBuf, String, u64)> { let conn = pool.get()?; require_admin_session_sid(&conn, session_id.as_deref())?; - // Use VACUUM INTO to create an atomic, defragmented, WAL-free - // snapshot of the database. Unlike checkpoint + read-file, this - // is safe even if other connections are actively writing — SQLite - // holds a read lock for the duration and produces a consistent - // single-file copy with no sidecar files. + progress.reset(crate::middleware::backup_phase::SNAPSHOT_DB); + let temp_dir = std::env::temp_dir(); let tmp_id = uuid::Uuid::new_v4().to_string().replace('-', ""); let temp_db = temp_dir.join(format!("chan_backup_{}.db", tmp_id)); let temp_db_str = temp_db .to_str() .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Temp path is non-UTF-8")))? - .replace('\'', "''"); // SQL-escape single quotes in path (just in case) + .replace('\'', "''"); conn.execute_batch(&format!("VACUUM INTO '{}'", temp_db_str)) .map_err(|e| AppError::Internal(anyhow::anyhow!("VACUUM INTO failed: {}", e)))?; - - // We no longer need the live connection. drop(conn); - let db_data = std::fs::read(&temp_db) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Read vacuum snapshot: {}", e)))?; - let _ = std::fs::remove_file(&temp_db); - - // Build the zip in memory. - let buf = std::io::Cursor::new(Vec::::new()); - let mut zip = zip::ZipWriter::new(buf); - // zip 2+: SimpleFileOptions replaces the old generic FileOptions. - let opts = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); - - // ── Database snapshot ────────────────────────────────────────── + // Count files for progress bar before compressing. + progress.reset(crate::middleware::backup_phase::COUNT_FILES); + let uploads_base = std::path::Path::new(&upload_dir); + let file_count = count_files_in_dir(uploads_base); + // +1 for chan.db + progress + .files_total + .store(file_count + 1, Ordering::Relaxed); + + // MEM-FIX: write zip directly to a NamedTempFile instead of Vec. + let zip_tmp = tempfile::NamedTempFile::new() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp zip: {}", e)))?; { - use std::io::Write; + let out_file = + std::io::BufWriter::new(zip_tmp.as_file().try_clone().map_err(|e| { + AppError::Internal(anyhow::anyhow!("Clone temp file handle: {}", e)) + })?); + let mut zip = zip::ZipWriter::new(out_file); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + progress.reset(crate::middleware::backup_phase::COMPRESS); + progress + .files_total + .store(file_count + 1, Ordering::Relaxed); + + // ── Database snapshot (streamed, not read into RAM) ──────── zip.start_file("chan.db", opts) .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip DB entry: {}", e)))?; - zip.write_all(&db_data) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Write DB to zip: {}", e)))?; - } + let mut db_src = std::fs::File::open(&temp_db) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Open DB snapshot: {}", e)))?; + let copied = std::io::copy(&mut db_src, &mut zip) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Stream DB to zip: {}", e)))?; + drop(db_src); + let _ = std::fs::remove_file(&temp_db); + progress.files_done.fetch_add(1, Ordering::Relaxed); + progress.bytes_done.fetch_add(copied, Ordering::Relaxed); + + // ── Upload files (streamed file-by-file via io::copy) ────── + if uploads_base.exists() { + add_dir_to_zip(&mut zip, uploads_base, uploads_base, opts, &progress)?; + } - // ── Upload files ────────────────────────────────────────────── - let uploads_base = std::path::Path::new(&upload_dir); - if uploads_base.exists() { - add_dir_to_zip(&mut zip, uploads_base, uploads_base, opts)?; + zip.finish() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; } - let cursor = zip - .finish() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; - let bytes = cursor.into_inner(); + let file_size = zip_tmp.as_file().metadata().map(|m| m.len()).unwrap_or(0); + + // Persist the temp file (prevents auto-delete on drop). + // We delete it manually in the background after serving. + let (_, tmp_path_obj) = zip_tmp.into_parts(); + let final_path = tmp_path_obj + .keep() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Persist temp zip: {}", e)))?; let ts = Utc::now().format("%Y%m%d_%H%M%S"); let fname = format!("rustchan-backup-{}.zip", ts); - info!( - "Admin downloaded backup ({} bytes, {} upload bytes included)", - bytes.len(), - db_data.len() - ); - Ok((bytes, fname)) + info!("Admin downloaded full backup ({} bytes on disk)", file_size); + progress + .phase + .store(crate::middleware::backup_phase::DONE, Ordering::Relaxed); + Ok((final_path, fname, file_size)) } }) .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + // MEM-FIX: Stream the zip file from disk in chunks — never load it all into heap. + let file = tokio::fs::File::open(&tmp_path) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("Open backup for streaming: {}", e)))?; + let stream = ReaderStream::new(file); + let body = axum::body::Body::from_stream(stream); + + // Schedule temp-file cleanup after a generous window so even slow clients finish. + let cleanup_path = tmp_path; + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_secs(600)).await; + let _ = tokio::fs::remove_file(cleanup_path).await; + }); + use axum::http::header; let disposition = format!("attachment; filename=\"{}\"", filename); Ok(( [ (header::CONTENT_TYPE, "application/zip".to_string()), (header::CONTENT_DISPOSITION, disposition), + (header::CONTENT_LENGTH, file_size.to_string()), ], - zip_bytes, + body, ) .into_response()) } +/// Count regular files (not directories) under `dir` recursively. +/// Used to initialise the progress bar's files_total before compression starts. +fn count_files_in_dir(dir: &std::path::Path) -> u64 { + if !dir.is_dir() { + return 0; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + entries.flatten().fold(0u64, |acc, entry| { + let p = entry.path(); + if p.is_dir() { + acc + count_files_in_dir(&p) + } else if p.is_file() { + acc + 1 + } else { + acc + } + }) +} + /// Recursively add every file under `dir` into the zip as `uploads/{rel_path}`. -fn add_dir_to_zip( - zip: &mut zip::ZipWriter>>, +/// +/// MEM-FIX: Uses std::io::copy with the zip writer directly, streaming each +/// file through a kernel buffer (~8 KiB) instead of reading the whole file +/// into a Vec first. Peak RAM per file = io::copy's 8 KiB stack buffer. +/// +/// Progress tracking: increments progress.files_done and progress.bytes_done +/// after each file is written to the zip. +fn add_dir_to_zip( + zip: &mut zip::ZipWriter, base: &std::path::Path, dir: &std::path::Path, - // zip 2+: SimpleFileOptions replaces the old generic FileOptions. opts: zip::write::SimpleFileOptions, + progress: &crate::middleware::BackupProgress, ) -> Result<()> { let entries = std::fs::read_dir(dir) .map_err(|e| AppError::Internal(anyhow::anyhow!("read_dir {}: {}", dir.display(), e)))?; @@ -1396,23 +1463,25 @@ fn add_dir_to_zip( let relative = path .strip_prefix(base) .map_err(|e| AppError::Internal(anyhow::anyhow!("strip_prefix: {}", e)))?; - // Normalise to forward-slashes so the zip is portable. let rel_str = relative.to_string_lossy().replace('\\', "/"); let zip_path = format!("uploads/{}", rel_str); if path.is_dir() { zip.add_directory(&zip_path, opts) .map_err(|e| AppError::Internal(anyhow::anyhow!("zip dir: {}", e)))?; - add_dir_to_zip(zip, base, &path, opts)?; + add_dir_to_zip(zip, base, &path, opts, progress)?; } else if path.is_file() { - use std::io::Write; - let data = std::fs::read(&path).map_err(|e| { - AppError::Internal(anyhow::anyhow!("read {}: {}", path.display(), e)) + // MEM-FIX: open file, stream through io::copy — no Vec allocation. + let mut src = std::fs::File::open(&path).map_err(|e| { + AppError::Internal(anyhow::anyhow!("open {}: {}", path.display(), e)) })?; zip.start_file(&zip_path, opts) - .map_err(|e| AppError::Internal(anyhow::anyhow!("zip file: {}", e)))?; - zip.write_all(&data) - .map_err(|e| AppError::Internal(anyhow::anyhow!("write zip: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("zip file entry: {}", e)))?; + let copied = std::io::copy(&mut src, zip).map_err(|e| { + AppError::Internal(anyhow::anyhow!("copy {} to zip: {}", path.display(), e)) + })?; + progress.files_done.fetch_add(1, Ordering::Relaxed); + progress.bytes_done.fetch_add(copied, Ordering::Relaxed); } } Ok(()) @@ -1773,6 +1842,11 @@ fn list_backup_files(dir: &std::path::Path) -> Vec { // ─── POST /admin/backup/create ──────────────────────────────────────────────── /// Create a full backup and save it to rustchan-data/full-backups/. +/// +/// MEM-FIX: The zip is written directly to the final destination file via a +/// BufWriter, so peak RAM usage is O(compression-buffer) not O(zip-size). +/// A `.tmp` suffix is used during writing; the file is renamed on success so +/// the backup list never shows a partial/corrupt zip. pub async fn create_full_backup( State(state): State, jar: CookieJar, @@ -1782,6 +1856,7 @@ pub async fn create_full_backup( check_csrf_jar(&jar, form._csrf.as_deref())?; let upload_dir = CONFIG.upload_dir.clone(); + let progress = state.backup_progress.clone(); tokio::task::spawn_blocking({ let pool = state.db.clone(); @@ -1789,6 +1864,8 @@ pub async fn create_full_backup( let conn = pool.get()?; require_admin_session_sid(&conn, session_id.as_deref())?; + progress.reset(crate::middleware::backup_phase::SNAPSHOT_DB); + // VACUUM INTO for a consistent snapshot. let temp_dir = std::env::temp_dir(); let tmp_id = uuid::Uuid::new_v4().to_string().replace('-', ""); @@ -1802,48 +1879,77 @@ pub async fn create_full_backup( .map_err(|e| AppError::Internal(anyhow::anyhow!("VACUUM INTO: {}", e)))?; drop(conn); - let db_data = std::fs::read(&temp_db) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Read snapshot: {}", e)))?; - let _ = std::fs::remove_file(&temp_db); + // Count files for progress bar before compressing. + progress.reset(crate::middleware::backup_phase::COUNT_FILES); + let uploads_base = std::path::Path::new(&upload_dir); + let file_count = count_files_in_dir(uploads_base); + progress + .files_total + .store(file_count + 1, Ordering::Relaxed); - // Build zip in memory. - let buf = std::io::Cursor::new(Vec::::new()); - let mut zip = zip::ZipWriter::new(buf); - let opts = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); + // MEM-FIX: write zip directly to a .tmp file on disk, not a Vec. + let backup_dir = full_backup_dir(); + std::fs::create_dir_all(&backup_dir).map_err(|e| { + AppError::Internal(anyhow::anyhow!("Create full-backups dir: {}", e)) + })?; + let ts = Utc::now().format("%Y%m%d_%H%M%S"); + let fname = format!("rustchan-backup-{}.zip", ts); + let final_path = backup_dir.join(&fname); + let tmp_path = backup_dir.join(format!("{}.tmp", fname)); { - use std::io::Write; + let out_file = + std::io::BufWriter::new(std::fs::File::create(&tmp_path).map_err(|e| { + AppError::Internal(anyhow::anyhow!("Create zip tmp: {}", e)) + })?); + let mut zip = zip::ZipWriter::new(out_file); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + progress.reset(crate::middleware::backup_phase::COMPRESS); + progress + .files_total + .store(file_count + 1, Ordering::Relaxed); + + // ── Database snapshot (streamed, not read into RAM) ──────── zip.start_file("chan.db", opts) .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip DB: {}", e)))?; - zip.write_all(&db_data) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Write DB zip: {}", e)))?; - } + let mut db_src = std::fs::File::open(&temp_db) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Open DB snapshot: {}", e)))?; + let copied = std::io::copy(&mut db_src, &mut zip) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Stream DB to zip: {}", e)))?; + drop(db_src); + let _ = std::fs::remove_file(&temp_db); + progress.files_done.fetch_add(1, Ordering::Relaxed); + progress.bytes_done.fetch_add(copied, Ordering::Relaxed); + + // ── Upload files (streamed via io::copy) ─────────────────── + if uploads_base.exists() { + add_dir_to_zip(&mut zip, uploads_base, uploads_base, opts, &progress)?; + } - let uploads_base = std::path::Path::new(&upload_dir); - if uploads_base.exists() { - add_dir_to_zip(&mut zip, uploads_base, uploads_base, opts)?; + let writer = zip + .finish() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; + // Flush the BufWriter so the OS buffer is committed to disk. + writer + .into_inner() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Flush zip writer: {}", e)))? + .sync_all() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Sync zip file: {}", e)))?; } - let cursor = zip - .finish() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; - let bytes = cursor.into_inner(); - - // Write to full-backups/. - let backup_dir = full_backup_dir(); - std::fs::create_dir_all(&backup_dir).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Create full-backups dir: {}", e)) + // Atomic rename: only becomes visible in the list when complete. + std::fs::rename(&tmp_path, &final_path).map_err(|e| { + let _ = std::fs::remove_file(&tmp_path); + AppError::Internal(anyhow::anyhow!("Rename backup: {}", e)) })?; - let ts = Utc::now().format("%Y%m%d_%H%M%S"); - let fname = format!("rustchan-backup-{}.zip", ts); - std::fs::write(backup_dir.join(&fname), &bytes) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Write backup file: {}", e)))?; - info!( - "Admin created full backup: {} ({} bytes)", - fname, - bytes.len() - ); + + let size = std::fs::metadata(&final_path).map(|m| m.len()).unwrap_or(0); + info!("Admin created full backup: {} ({} bytes)", fname, size); + progress + .phase + .store(crate::middleware::backup_phase::DONE, Ordering::Relaxed); Ok(()) } }) @@ -1881,6 +1987,7 @@ pub async fn create_board_backup( } let upload_dir = CONFIG.upload_dir.clone(); + let progress = state.backup_progress.clone(); tokio::task::spawn_blocking({ let pool = state.db.clone(); @@ -1890,8 +1997,7 @@ pub async fn create_board_backup( let conn = pool.get()?; require_admin_session_sid(&conn, session_id.as_deref())?; - - // Re-use the same data-extraction logic as board_backup. + progress.reset(crate::middleware::backup_phase::SNAPSHOT_DB); let board: BoardRow = conn .query_row( "SELECT id, short_name, name, description, nsfw, max_threads, bump_limit, @@ -2103,45 +2209,62 @@ pub async fn create_board_backup( let manifest_json = serde_json::to_vec_pretty(&manifest) .map_err(|e| AppError::Internal(anyhow::anyhow!("JSON: {}", e)))?; - let buf = std::io::Cursor::new(Vec::::new()); - let mut zip = zip::ZipWriter::new(buf); - let opts = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); + // MEM-FIX: write zip directly to a .tmp file on disk, not a Vec. + let backup_dir = board_backup_dir(); + std::fs::create_dir_all(&backup_dir).map_err(|e| { + AppError::Internal(anyhow::anyhow!("Create board-backups dir: {}", e)) + })?; + let ts = Utc::now().format("%Y%m%d_%H%M%S"); + let fname = format!("rustchan-board-{}-{}.zip", board_short, ts); + let final_path = backup_dir.join(&fname); + let tmp_path = backup_dir.join(format!("{}.tmp", fname)); + + let uploads_base = std::path::Path::new(&upload_dir); + let board_upload_path = uploads_base.join(&board_short); + let file_count = count_files_in_dir(&board_upload_path); + progress.reset(crate::middleware::backup_phase::COMPRESS); + // +1 for board.json manifest + progress.files_total.store(file_count + 1, Ordering::Relaxed); { - use std::io::Write; + let out_file = std::io::BufWriter::new( + std::fs::File::create(&tmp_path).map_err(|e| { + AppError::Internal(anyhow::anyhow!("Create zip tmp: {}", e)) + })?, + ); + let mut zip = zip::ZipWriter::new(out_file); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + zip.start_file("board.json", opts) .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip manifest: {}", e)))?; zip.write_all(&manifest_json) .map_err(|e| AppError::Internal(anyhow::anyhow!("Write manifest: {}", e)))?; - } + progress.files_done.fetch_add(1, Ordering::Relaxed); + progress.bytes_done.fetch_add(manifest_json.len() as u64, Ordering::Relaxed); - let uploads_base = std::path::Path::new(&upload_dir); - let board_upload_path = uploads_base.join(&board_short); - if board_upload_path.exists() { - add_dir_to_zip(&mut zip, uploads_base, &board_upload_path, opts)?; - } + if board_upload_path.exists() { + add_dir_to_zip(&mut zip, uploads_base, &board_upload_path, opts, &progress)?; + } - let cursor = zip - .finish() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; - let bytes = cursor.into_inner(); + let writer = zip + .finish() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; + writer + .into_inner() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Flush zip writer: {}", e)))? + .sync_all() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Sync zip file: {}", e)))?; + } - // Write to board-backups/. - let backup_dir = board_backup_dir(); - std::fs::create_dir_all(&backup_dir).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Create board-backups dir: {}", e)) - })?; - let ts = Utc::now().format("%Y%m%d_%H%M%S"); - let fname = format!("rustchan-board-{}-{}.zip", board_short, ts); - std::fs::write(backup_dir.join(&fname), &bytes).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Write board backup: {}", e)) + std::fs::rename(&tmp_path, &final_path).map_err(|e| { + let _ = std::fs::remove_file(&tmp_path); + AppError::Internal(anyhow::anyhow!("Rename board backup: {}", e)) })?; - info!( - "Admin created board backup: {} ({} bytes)", - fname, - bytes.len() - ); + + let size = std::fs::metadata(&final_path).map(|m| m.len()).unwrap_or(0); + info!("Admin created board backup: {} ({} bytes)", fname, size); + progress.phase.store(crate::middleware::backup_phase::DONE, Ordering::Relaxed); Ok(()) } }) @@ -2154,6 +2277,15 @@ pub async fn create_board_backup( // ─── GET /admin/backup/download/{kind}/{filename} ──────────────────────────── /// Download a saved backup file. `kind` must be "full" or "board". +/// +/// MEM-FIX (original bug): The old implementation used `tokio::fs::read()` +/// which loaded the entire file into a Vec before beginning the HTTP +/// response. For a 5 GiB backup on a slow connection that means 5 GiB of +/// heap held for the entire download duration. +/// +/// The fix: open a tokio::fs::File and wrap it in a ReaderStream so Axum +/// sends the data in 64 KiB chunks pulled directly from the OS page cache. +/// Peak heap = one 64 KiB chunk; the rest stays on disk. pub async fn download_backup( State(state): State, jar: CookieJar, @@ -2194,9 +2326,19 @@ pub async fn download_backup( }; let path = backup_dir.join(&safe_filename); - let bytes = tokio::fs::read(&path) + + // Get file size for Content-Length (so the browser shows a progress bar). + let file_size = tokio::fs::metadata(&path) + .await + .map_err(|_| AppError::NotFound("Backup file not found.".into()))? + .len(); + + // MEM-FIX: stream the file in 64 KiB chunks instead of loading it all. + let file = tokio::fs::File::open(&path) .await .map_err(|_| AppError::NotFound("Backup file not found.".into()))?; + let stream = ReaderStream::new(file); + let body = axum::body::Body::from_stream(stream); use axum::http::header; let disposition = format!("attachment; filename=\"{}\"", safe_filename); @@ -2204,8 +2346,54 @@ pub async fn download_backup( [ (header::CONTENT_TYPE, "application/zip".to_string()), (header::CONTENT_DISPOSITION, disposition), + (header::CONTENT_LENGTH, file_size.to_string()), ], - bytes, + body, + ) + .into_response()) +} + +// ─── GET /admin/backup/progress ────────────────────────────────────────────── + +/// Return current backup progress as JSON. Polled by the admin panel JS. +/// +/// Response: { phase: u64, files_done: u64, files_total: u64, +/// bytes_done: u64, bytes_total: u64 } +/// +/// phase codes: 0=idle, 1=snapshot_db, 2=count_files, 3=compress, 4=save, 5=done +/// +/// Auth is required to prevent any guest from watching backup progress. +pub async fn backup_progress_json( + State(state): State, + jar: CookieJar, +) -> Result { + let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + require_admin_session_sid(&conn, session_id.as_deref())?; + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + use std::sync::atomic::Ordering::Relaxed; + let p = &state.backup_progress; + let json = format!( + r#"{{"phase":{},"files_done":{},"files_total":{},"bytes_done":{},"bytes_total":{}}}"#, + p.phase.load(Relaxed), + p.files_done.load(Relaxed), + p.files_total.load(Relaxed), + p.bytes_done.load(Relaxed), + p.bytes_total.load(Relaxed), + ); + + use axum::http::header; + Ok(( + [(header::CONTENT_TYPE, "application/json".to_string())], + json, ) .into_response()) } @@ -2889,6 +3077,9 @@ mod board_backup_types { } /// Stream a board-level backup zip: manifest JSON + that board's upload files. +/// +/// MEM-FIX: Same approach as admin_backup — build zip into a NamedTempFile on +/// disk, then stream the result in 64 KiB chunks. pub async fn board_backup( State(state): State, jar: CookieJar, @@ -2896,17 +3087,18 @@ pub async fn board_backup( ) -> Result { let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); let upload_dir = CONFIG.upload_dir.clone(); + let progress = state.backup_progress.clone(); - let (zip_bytes, filename) = tokio::task::spawn_blocking({ + let (tmp_path, filename, file_size) = tokio::task::spawn_blocking({ let pool = state.db.clone(); - move || -> Result<(Vec, String)> { + move || -> Result<(PathBuf, String, u64)> { use board_backup_types::*; use rusqlite::params; let conn = pool.get()?; require_admin_session_sid(&conn, session_id.as_deref())?; - // ── Board row ───────────────────────────────────────────────── + progress.reset(crate::middleware::backup_phase::SNAPSHOT_DB); let board: BoardRow = conn.query_row( "SELECT id, short_name, name, description, nsfw, max_threads, bump_limit, allow_images, allow_video, allow_audio, allow_tripcodes, edit_window_secs, @@ -3044,50 +3236,82 @@ pub async fn board_backup( version: 1, board, threads, posts, polls, poll_options, poll_votes, file_hashes, }; + + // ── Build zip to NamedTempFile (MEM-FIX) ───────────────────── let manifest_json = serde_json::to_vec_pretty(&manifest) .map_err(|e| AppError::Internal(anyhow::anyhow!("JSON serialise: {}", e)))?; - // ── Build zip ───────────────────────────────────────────────── - let buf = std::io::Cursor::new(Vec::::new()); - let mut zip = zip::ZipWriter::new(buf); - let opts = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); + let uploads_base = std::path::Path::new(&upload_dir); + let board_upload_path = uploads_base.join(&board_short); + let file_count = count_files_in_dir(&board_upload_path); + progress.reset(crate::middleware::backup_phase::COMPRESS); + progress.files_total.store(file_count + 1, Ordering::Relaxed); + let zip_tmp = tempfile::NamedTempFile::new() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp zip: {}", e)))?; { - use std::io::Write; + let out_file = std::io::BufWriter::new( + zip_tmp.as_file().try_clone().map_err(|e| { + AppError::Internal(anyhow::anyhow!("Clone temp file handle: {}", e)) + })?, + ); + let mut zip = zip::ZipWriter::new(out_file); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + zip.start_file("board.json", opts) .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip manifest: {}", e)))?; zip.write_all(&manifest_json) .map_err(|e| AppError::Internal(anyhow::anyhow!("Write manifest: {}", e)))?; - } + progress.files_done.fetch_add(1, Ordering::Relaxed); + progress.bytes_done.fetch_add(manifest_json.len() as u64, Ordering::Relaxed); - let uploads_base = std::path::Path::new(&upload_dir); - let board_upload_path = uploads_base.join(&board_short); - if board_upload_path.exists() { - add_dir_to_zip(&mut zip, uploads_base, &board_upload_path, opts)?; + if board_upload_path.exists() { + add_dir_to_zip(&mut zip, uploads_base, &board_upload_path, opts, &progress)?; + } + + zip.finish() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; } - let cursor = zip.finish() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; - let bytes = cursor.into_inner(); + let file_size = zip_tmp.as_file().metadata().map(|m| m.len()).unwrap_or(0); + + let (_, tmp_path_obj) = zip_tmp.into_parts(); + let final_path = tmp_path_obj.keep().map_err(|e| { + AppError::Internal(anyhow::anyhow!("Persist temp zip: {}", e)) + })?; let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S"); let fname = format!("rustchan-board-{}-{}.zip", board_short, ts); - info!("Admin downloaded board backup for /{}/ ({} bytes)", board_short, bytes.len()); - Ok((bytes, fname)) + info!("Admin downloaded board backup for /{}/ ({} bytes on disk)", board_short, file_size); + progress.phase.store(crate::middleware::backup_phase::DONE, Ordering::Relaxed); + Ok((final_path, fname, file_size)) } }) .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + let file = tokio::fs::File::open(&tmp_path).await.map_err(|e| { + AppError::Internal(anyhow::anyhow!("Open board backup for streaming: {}", e)) + })?; + let stream = ReaderStream::new(file); + let body = axum::body::Body::from_stream(stream); + + let cleanup_path = tmp_path; + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_secs(600)).await; + let _ = tokio::fs::remove_file(cleanup_path).await; + }); + use axum::http::header; let disposition = format!("attachment; filename=\"{}\"", filename); Ok(( [ (header::CONTENT_TYPE, "application/zip".to_string()), (header::CONTENT_DISPOSITION, disposition), + (header::CONTENT_LENGTH, file_size.to_string()), ], - zip_bytes, + body, ) .into_response()) } diff --git a/src/main.rs b/src/main.rs index 204ae51..59c1475 100644 --- a/src/main.rs +++ b/src/main.rs @@ -265,6 +265,7 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { workers::start_worker_pool(q.clone(), ffmpeg_available); q }, + backup_progress: std::sync::Arc::new(middleware::BackupProgress::new()), }; // Keep a reference to the job queue cancel token for graceful shutdown (#7). let worker_cancel = state.job_queue.cancel.clone(); @@ -490,6 +491,10 @@ fn build_router(state: AppState) -> Router { "/admin/backup/download/{kind}/{filename}", get(handlers::admin::download_backup), ) + .route( + "/admin/backup/progress", + get(handlers::admin::backup_progress_json), + ) .route("/admin/backup/delete", post(handlers::admin::delete_backup)) .route( "/admin/backup/restore-saved", diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index e8d3d99..6cb0fb3 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -53,6 +53,58 @@ static RATE_TABLE: Lazy> = Lazy::new(DashMap::new); /// clean on a time basis, not just when the table exceeds a size threshold. static LAST_CLEANUP_SECS: AtomicU64 = AtomicU64::new(0); +// ─── Backup progress tracking ───────────────────────────────────────────────── +// +// A single global progress counter is sufficient because only one admin can +// run a backup at a time, and backups are serialised through spawn_blocking. +// All fields use Ordering::Relaxed — the JS poller only needs eventual +// consistency; strict happens-before ordering is not required here. + +/// Phase codes stored in BackupProgress::phase. +pub mod backup_phase { + pub const IDLE: u64 = 0; + pub const SNAPSHOT_DB: u64 = 1; + pub const COUNT_FILES: u64 = 2; + pub const COMPRESS: u64 = 3; + #[allow(dead_code)] + pub const SAVE: u64 = 4; + pub const DONE: u64 = 5; +} + +/// Shared atomic progress state for backup operations. +/// Stored as Arc in AppState so admin handlers and the +/// progress endpoint can both access it without locking. +pub struct BackupProgress { + pub phase: std::sync::atomic::AtomicU64, + pub files_done: std::sync::atomic::AtomicU64, + pub files_total: std::sync::atomic::AtomicU64, + pub bytes_done: std::sync::atomic::AtomicU64, + pub bytes_total: std::sync::atomic::AtomicU64, +} + +impl BackupProgress { + pub fn new() -> Self { + use std::sync::atomic::AtomicU64; + Self { + phase: AtomicU64::new(backup_phase::IDLE), + files_done: AtomicU64::new(0), + files_total: AtomicU64::new(0), + bytes_done: AtomicU64::new(0), + bytes_total: AtomicU64::new(0), + } + } + + /// Reset all counters and set a new phase. + pub fn reset(&self, phase: u64) { + use std::sync::atomic::Ordering::Relaxed; + self.files_done.store(0, Relaxed); + self.files_total.store(0, Relaxed); + self.bytes_done.store(0, Relaxed); + self.bytes_total.store(0, Relaxed); + self.phase.store(phase, Relaxed); + } +} + /// Shared state for extracting the DB pool in middleware #[derive(Clone)] pub struct AppState { @@ -63,6 +115,8 @@ pub struct AppState { /// Background job queue — enqueue CPU-heavy work here instead of blocking /// the HTTP request path. pub job_queue: std::sync::Arc, + /// Live backup progress counters. Polled by GET /admin/backup/progress. + pub backup_progress: std::sync::Arc, } /// Get current Unix timestamp in seconds. diff --git a/src/templates/admin.rs b/src/templates/admin.rs index 98147bb..2cfcc5a 100644 --- a/src/templates/admin.rs +++ b/src/templates/admin.rs @@ -108,10 +108,10 @@ pub fn admin_panel_page( - + -
+ @@ -202,7 +202,7 @@ pub fn admin_panel_page( {size} {modified} - ⇓ download to computer + ⇓ download to computer @@ -238,7 +238,7 @@ pub fn admin_panel_page( {size} {modified} - ⇓ download to computer + ⇓ download to computer @@ -500,9 +500,9 @@ pub fn admin_panel_page(

// full site backup & restore

Full backups include the complete database and all uploaded files. Save to server stores the backup in rustchan-data/full-backups/ on the server filesystem (listed below). Restore from local file uploads a zip from your computer.

- + - +
@@ -559,6 +559,20 @@ pub fn admin_panel_page( // active onion address ═══════════════════════════════════════════════════════════════════════════ --> {tor_section} +
+ + +"#, csrf = escape_html(csrf_token), flash = flash_html, diff --git a/static/main.js b/static/main.js index 1836165..97382d9 100644 --- a/static/main.js +++ b/static/main.js @@ -338,7 +338,8 @@ function closeReportModal() { // ─── Theme picker ───────────────────────────────────────────────────────────── (function () { - var THEMES = ['terminal', 'aero', 'dorfic', 'fluorogrid', 'neoncubicle']; + // Must match VALID_THEMES in src/handlers/admin.rs + var THEMES = ['terminal', 'aero', 'dorfic', 'fluorogrid', 'neoncubicle', 'chanclassic']; function applyTheme(t) { if (t === 'terminal') { @@ -346,8 +347,9 @@ function closeReportModal() { } else { document.documentElement.setAttribute('data-theme', t); } - document.querySelectorAll('.tp-option').forEach(function (el, i) { - el.classList.toggle('active', THEMES[i] === t); + // Match by data-theme attribute so order in DOM doesn't matter. + document.querySelectorAll('.tp-option').forEach(function (el) { + el.classList.toggle('active', el.dataset.theme === t); }); } @@ -375,10 +377,18 @@ function closeReportModal() { } }); - try { - var saved = localStorage.getItem('rustchan_theme'); - if (saved && THEMES.indexOf(saved) !== -1) { applyTheme(saved); } - } catch (e) {} + // Priority: personal localStorage preference > server-configured default. + // The server injects data-default-theme on when the admin picks a + // non-terminal default. New visitors (no localStorage) should see that + // theme instead of always falling back to terminal. + (function () { + var active = null; + try { active = localStorage.getItem('rustchan_theme'); } catch (e) {} + if (!active || THEMES.indexOf(active) === -1) { + active = document.documentElement.getAttribute('data-default-theme') || 'terminal'; + } + if (active && THEMES.indexOf(active) !== -1) { applyTheme(active); } + }()); })(); // ─── Collapse greentext blocks ──────────────────────────────────────────────── @@ -1251,3 +1261,191 @@ document.querySelectorAll('input[type="file"][data-onchange-check-size]').forEac window.checkFileSize && window.checkFileSize(inp); }); }); + +// ─── Admin backup progress bar ──────────────────────────────────────────────── +// +// Covers two flows: +// +// A) "Save to server" forms — POST via fetch(), modal shows live progress, +// "Done — reload" button appears when the fetch resolves. +// +// B) "Download to computer" links — GET triggers a file download. We show +// the modal with live progress while the server builds the zip, then +// dismiss it automatically once phase=DONE is reported. The actual +// download still happens natively in the browser (iframe trick). +// +// Note: all handlers here are CSP-safe (no inline onclick/onX attributes). +// The "Done — reload" button uses data-action="close-backup-modal" and is +// dispatched by the existing global click handler below. +// +// Phase codes (mirror middleware::backup_phase in Rust): +// 0=idle 1=snapshot_db 2=count_files 3=compress 4=save 5=done +(function () { + var _pollTimer = null; + var _downloadMode = false; // true when modal is showing for a download + + var PHASE_LABELS = [ + 'Idle', + 'Snapshotting database\u2026', + 'Counting files\u2026', + 'Compressing files\u2026', + 'Saving\u2026', + 'Done!', + ]; + + function showBackupModal(title) { + var modal = document.getElementById('backup-modal'); + var titleEl = document.getElementById('backup-modal-title'); + var done = document.getElementById('backup-done-actions'); + if (!modal) return; + if (titleEl) titleEl.textContent = title || '\uD83D\uDCBE Creating Backup\u2026'; + if (done) done.style.display = 'none'; + _setBkProgress(0, 'Starting\u2026'); + modal.style.display = 'flex'; + } + + function hideBackupModal() { + var modal = document.getElementById('backup-modal'); + if (modal) modal.style.display = 'none'; + } + + function showDoneButton() { + var done = document.getElementById('backup-done-actions'); + if (done) done.style.display = 'flex'; + } + + function _setBkProgress(pct, text) { + var bar = document.getElementById('backup-progress-bar'); + var txt = document.getElementById('backup-progress-text'); + if (bar) bar.style.width = Math.min(100, Math.max(0, pct)) + '%'; + if (txt) txt.textContent = text; + } + + function _startPolling(onDone) { + if (_pollTimer) return; + _pollTimer = setInterval(function () { + fetch('/admin/backup/progress', { credentials: 'same-origin' }) + .then(function (r) { return r.json(); }) + .then(function (data) { + var phase = data.phase || 0; + var label = PHASE_LABELS[phase] || 'Working\u2026'; + var pct = 0; + if (data.files_total > 0) { + pct = Math.min(98, Math.round((data.files_done / data.files_total) * 100)); + } else if (phase === 1) { pct = 5; } + else if (phase === 2) { pct = 10; } + var detail = data.files_total > 0 + ? ' (' + data.files_done + '/' + data.files_total + ' files)' + : ''; + _setBkProgress(pct, label + detail); + + // In download mode the fetch resolves as soon as the response headers + // arrive (streaming body). Poll phase instead to know when done. + if (_downloadMode && phase === 5) { + _stopPolling(); + _setBkProgress(100, '\u2713 Download ready!'); + // Auto-dismiss after 1.5 s — the file is already downloading. + setTimeout(hideBackupModal, 1500); + if (onDone) onDone(); + } + }) + .catch(function () { /* ignore transient poll errors */ }); + }, 500); + } + + function _stopPolling() { + if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } + } + + // ── Flow A: "Save to server" forms ────────────────────────────────────────── + + function _submitBackupForm(form, title) { + _downloadMode = false; + showBackupModal(title); + _startPolling(null); + + // URLSearchParams → application/x-www-form-urlencoded, required by Axum's Form<>. + var params = new URLSearchParams(new FormData(form)); + fetch(form.action, { method: 'POST', body: params, credentials: 'same-origin' }) + .then(function (resp) { + _stopPolling(); + if (resp.ok || resp.redirected) { + _setBkProgress(100, '\u2713 Backup saved to server!'); + } else { + _setBkProgress(0, 'Server returned an error (' + resp.status + ')'); + } + showDoneButton(); + }) + .catch(function (err) { + _stopPolling(); + _setBkProgress(0, 'Error: ' + (err.message || 'backup failed')); + showDoneButton(); + }); + } + + // ── Flow B: "Download to computer" links ──────────────────────────────────── + + function _triggerDownload(url, label) { + _downloadMode = true; + showBackupModal('\uD83D\uDCBE Preparing ' + (label || 'backup') + '\u2026'); + _startPolling(null); + + // Trigger the file download without navigating away. + // A hidden .click() makes a standard GET request and the + // browser saves the response as a file — no page navigation occurs. + // We cannot use an