From 20fe95d3672ea359bd52cb2ca21eaca7a9d70b3b Mon Sep 17 00:00:00 2001
From: csd113
Date: Wed, 27 May 2026 22:39:21 -0700
Subject: [PATCH 01/26] ### Upload Auto-Compression Reliability
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Hardened oversized image and video auto-compression so processed media is re-statted and validated before post finalization.
- Fixed cases where compressed media could still exceed board/site upload limits and leave broken posts, stale DB references, or orphaned files.
- Improved cleanup for failed upload-processing paths, including stale staged uploads after new-thread poll validation failures.
- Fixed misleading upload-limit messages for very small configured limits.
- Improved browser-side auto-compression reliability for extension-only or blank-MIME uploads.
- Added submit-time guards, clearer failure text, inline “Auto-compressed X to Y” status messages, stronger image retry behavior, and more reliable video recording retries.
- Fixed Chromium/WebKit video compression timeouts by attaching a hidden muted 1px `` element during browser capture and removing it during cleanup.
- Added ignored opt-in Playwright coverage for oversized image/video compression success paths and negative failure paths.
---
src/handlers/mod.rs | 8 +-
src/handlers/posting.rs | 54 ++++++++++-
src/templates/forms.rs | 8 +-
src/utils/files/storage.rs | 103 +++++++++++++++++++-
static/main.js | 193 +++++++++++++++++++++++++++++++------
static/style.css | 6 ++
6 files changed, 328 insertions(+), 44 deletions(-)
diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs
index a30eeab9..dc6e4ac7 100644
--- a/src/handlers/mod.rs
+++ b/src/handlers/mod.rs
@@ -195,8 +195,8 @@ async fn stream_field_to_temp_file(
"multipart upload field exceeded board limit"
);
return Err(AppError::UploadTooLarge(format!(
- "File too large. Maximum upload size is {} MiB.",
- max_bytes / 1024 / 1024
+ "File too large. Maximum upload size is {}.",
+ format_upload_limit(max_bytes)
)));
}
if sniff_bytes.len() < MIME_SNIFF_BYTES {
@@ -229,6 +229,10 @@ async fn stream_field_to_temp_file(
})
}
+fn format_upload_limit(max_bytes: usize) -> String {
+ crate::utils::files::format_file_size(i64::try_from(max_bytes).unwrap_or(i64::MAX))
+}
+
async fn read_upload_field(
field: axum::extract::multipart::Field<'_>,
max_bytes: usize,
diff --git a/src/handlers/posting.rs b/src/handlers/posting.rs
index bcf00e10..79842608 100644
--- a/src/handlers/posting.rs
+++ b/src/handlers/posting.rs
@@ -556,18 +556,23 @@ pub fn submit_post(
None
} else {
if q.is_empty() {
+ uploads.rollback_new_files(conn, &upload_dir)?;
return Err(AppError::BadRequest(
"Polls need a question and at least two options.".into(),
));
}
if valid_opts.len() < 2 {
+ uploads.rollback_new_files(conn, &upload_dir)?;
return Err(AppError::BadRequest(
"Polls need a question and at least two options.".into(),
));
}
- let secs = poll_duration_secs.ok_or_else(|| {
- AppError::BadRequest("A duration is required when creating a poll.".into())
- })?;
+ let Some(secs) = poll_duration_secs else {
+ uploads.rollback_new_files(conn, &upload_dir)?;
+ return Err(AppError::BadRequest(
+ "A duration is required when creating a poll.".into(),
+ ));
+ };
let secs = secs.clamp(60, 30 * 24 * 3600);
let expires_at = chrono::Utc::now().timestamp().saturating_add(secs);
Some(db::threads::PollInsert {
@@ -1185,12 +1190,53 @@ mod tests {
match error {
AppError::UploadTooLarge(message) => {
- assert!(message.contains("Maximum image size is 0 MiB."));
+ assert!(message.contains("Maximum image upload size is 64 B."));
}
other => panic!("expected UploadTooLarge, got {other:?}"),
}
}
+ #[test]
+ fn submit_post_cleans_staged_upload_when_poll_validation_fails() {
+ let state = crate::test_support::app_state();
+ let upload_dir = tempfile::tempdir().expect("upload dir");
+ let conn = state.db.get().expect("db connection");
+ crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).expect("create board");
+
+ let mut command = thread_command_with_poll(
+ TEST_BOARD,
+ "poll-upload-token",
+ "thread body",
+ upload_dir.path().to_str().expect("upload dir"),
+ "pick one",
+ vec!["only option"],
+ Some(3600),
+ );
+ command.file_data = Some(temp_upload("cover.png", &one_pixel_png()));
+
+ let error = match submit_post(&conn, state.job_queue.as_ref(), command) {
+ Ok(result) => panic!(
+ "poll validation should reject submission, got {}",
+ result.redirect_url
+ ),
+ Err(error) => error,
+ };
+
+ match error {
+ AppError::BadRequest(message) => {
+ assert_eq!(message, "Polls need a question and at least two options.");
+ }
+ other => panic!("expected BadRequest, got {other:?}"),
+ }
+
+ assert_eq!(pending_upload_stage_count(upload_dir.path()), 0);
+ assert!(!upload_dir.path().join(TEST_BOARD).exists());
+ let post_count: i64 = conn
+ .query_row("SELECT COUNT(*) FROM posts", [], |row| row.get(0))
+ .expect("post count");
+ assert_eq!(post_count, 0);
+ }
+
#[test]
fn submit_post_uses_board_limit_for_upload_validation() {
let state = crate::test_support::app_state();
diff --git a/src/templates/forms.rs b/src/templates/forms.rs
index 67c0fe64..3950936f 100644
--- a/src/templates/forms.rs
+++ b/src/templates/forms.rs
@@ -124,6 +124,12 @@ fn render_single_upload_row(board: &Board, audio_image_hint: &str) -> String {
accept_parts.push("application/pdf,.pdf");
hint_parts.push(format!("pdf · max {generic_upload_mb} MiB"));
}
+ match (board.allow_images, board.allow_video) {
+ (true, true) => hint_parts.push("oversized images/videos can auto-compress".to_owned()),
+ (true, false) => hint_parts.push("oversized images can auto-compress".to_owned()),
+ (false, true) => hint_parts.push("oversized videos can auto-compress".to_owned()),
+ (false, false) => {}
+ }
let file_accept = if allow_any_files {
String::new()
@@ -149,7 +155,7 @@ fn render_single_upload_row(board: &Board, audio_image_hint: &str) -> String {
▾ Optional Image
- {audio_image_hint} · jpg/png/gif/webp/heic · max {image_mb} MiB
+ {audio_image_hint} · jpg/png/gif/webp/heic · max {image_mb} MiB · oversized images can auto-compress
"#
)
diff --git a/src/utils/files/storage.rs b/src/utils/files/storage.rs
index 374be4fb..6e5ab523 100644
--- a/src/utils/files/storage.rs
+++ b/src/utils/files/storage.rs
@@ -139,8 +139,8 @@ pub fn save_audio_with_image_thumb_from_path(
}
if original_size > max_audio_size {
return Err(anyhow::anyhow!(
- "Audio file too large. Maximum size is {} MiB.",
- max_audio_size / 1024 / 1024
+ "Audio file too large. Maximum audio upload size is {}.",
+ format_upload_limit(max_audio_size)
));
}
@@ -332,9 +332,9 @@ fn validate_upload(
let max_size = max_size_for_media(media_type, options);
if original_size > max_size {
anyhow::bail!(
- "File too large. Maximum {} size is {} MiB.",
+ "File too large. Maximum {} upload size is {}.",
media_label(media_type),
- max_size / 1024 / 1024
+ format_upload_limit(max_size)
);
}
if media_type == crate::models::MediaType::Image {
@@ -426,6 +426,8 @@ fn save_processed_upload(
apply_thumb_exif_orientation(&processed.thumbnail_path, plan.jpeg_orientation);
}
+ let final_size = final_processed_size_within_limit(&processed, options)?;
+
let filename = processed
.file_path
.file_name()
@@ -442,7 +444,7 @@ fn save_processed_upload(
thumb_path: format!("{}/thumbs/{thumb_filename}", options.board_short),
original_name: crate::utils::sanitize::sanitize_filename(options.original_filename),
mime_type: processed.mime_type.clone(),
- file_size: i64::try_from(processed.final_size).context("File size overflows i64")?,
+ file_size: i64::try_from(final_size).context("File size overflows i64")?,
media_type: crate::models::MediaType::from_mime(&processed.mime_type),
processing_pending: if processed.was_converted {
false
@@ -453,6 +455,66 @@ fn save_processed_upload(
})
}
+fn final_processed_size_within_limit(
+ processed: &crate::media::ProcessedMedia,
+ options: &SaveUploadOptions<'_>,
+) -> Result {
+ let media_type = crate::models::MediaType::from_mime(&processed.mime_type);
+ let max_size = max_size_for_media(media_type, options);
+ let final_size = std::fs::metadata(&processed.file_path)
+ .with_context(|| {
+ format!(
+ "Failed to stat stored upload {}",
+ processed.file_path.display()
+ )
+ })?
+ .len();
+ if final_size != processed.final_size {
+ tracing::debug!(
+ before_orientation_bytes = processed.final_size,
+ final_bytes = final_size,
+ "stored upload size changed after post-processing"
+ );
+ }
+
+ if final_size <= u64::try_from(max_size).unwrap_or(u64::MAX) {
+ return Ok(final_size);
+ }
+
+ cleanup_processed_outputs(processed);
+ anyhow::bail!(
+ "File too large after media processing. Maximum {} upload size is {}; processed file is {}.",
+ media_label(media_type),
+ format_upload_limit(max_size),
+ format_upload_limit_u64(final_size)
+ );
+}
+
+fn cleanup_processed_outputs(processed: &crate::media::ProcessedMedia) {
+ for path in [&processed.file_path, &processed.thumbnail_path] {
+ match std::fs::remove_file(path) {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(error) => {
+ tracing::warn!(
+ path = %path.display(),
+ error = %error,
+ "failed to remove oversized processed upload output"
+ );
+ }
+ }
+ }
+}
+
+fn format_upload_limit(max_bytes: usize) -> String {
+ format_upload_limit_u64(u64::try_from(max_bytes).unwrap_or(u64::MAX))
+}
+
+fn format_upload_limit_u64(max_bytes: u64) -> String {
+ let display_bytes = i64::try_from(max_bytes).unwrap_or(i64::MAX);
+ format_file_size(display_bytes)
+}
+
fn max_size_for_media(
media_type: crate::models::MediaType,
options: &SaveUploadOptions<'_>,
@@ -772,6 +834,37 @@ trailer << /Root 1 0 R >>
"
}
+ #[test]
+ fn final_processed_size_validation_removes_oversized_outputs() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let board_dir = tempdir.path().join("test");
+ let thumb_dir = board_dir.join("thumbs");
+ std::fs::create_dir_all(&thumb_dir).expect("create dirs");
+ let file_path = board_dir.join("stored.png");
+ let thumbnail_path = thumb_dir.join("stored.webp");
+ std::fs::write(&file_path, b"too large").expect("write output");
+ std::fs::write(&thumbnail_path, b"thumb").expect("write thumb");
+
+ let mut options = test_upload_options(tempdir.path(), "stored.png");
+ options.max_image_size = 4;
+ let processed = crate::media::ProcessedMedia {
+ file_path: file_path.clone(),
+ thumbnail_path: thumbnail_path.clone(),
+ mime_type: "image/png".to_owned(),
+ was_converted: false,
+ final_size: 9,
+ };
+
+ let error = super::final_processed_size_within_limit(&processed, &options)
+ .expect_err("oversized processed output should be rejected");
+
+ assert!(error
+ .to_string()
+ .contains("Maximum image upload size is 4 B"));
+ assert!(!file_path.exists());
+ assert!(!thumbnail_path.exists());
+ }
+
fn valid_webm_header() -> &'static [u8] {
b"\x1a\x45\xdf\xa3\x00\x00\x00\x00\x00\x00\x42\x82\x84webm\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
}
diff --git a/static/main.js b/static/main.js
index 90322fa2..b8b7eeed 100644
--- a/static/main.js
+++ b/static/main.js
@@ -1442,9 +1442,33 @@ window.requestConfirmation = requestConfirmation;
return 0;
}
+ function fileExtension(file) {
+ var name = (file && file.name ? file.name : '').toLowerCase();
+ var match = name.match(/\.([a-z0-9]+)$/);
+ return match ? match[1] : '';
+ }
+
+ function fileMediaKind(file) {
+ if (!file) return '';
+ var type = file.type || '';
+ var ext = fileExtension(file);
+ if (type.indexOf('image/') === 0 || /^(jpe?g|png|gif|webp|heic|heif|bmp|tiff?)$/i.test(ext)) return 'image';
+ if (type.indexOf('video/') === 0 || /^(mp4|webm)$/i.test(ext)) return 'video';
+ return '';
+ }
+
+ function limitForFile(file) {
+ var kind = fileMediaKind(file);
+ if (kind === 'image') return getMax('image');
+ if (kind === 'video') return getMax('video');
+ return 0;
+ }
+
function imageOutputType(file) {
- if (file.type === 'image/jpeg' || file.type === 'image/jpg') return 'image/jpeg';
- if (file.type === 'image/png' || file.type === 'image/webp') {
+ var type = file.type || '';
+ var ext = fileExtension(file);
+ if (type === 'image/jpeg' || type === 'image/jpg' || ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
+ if (type === 'image/png' || type === 'image/webp' || ext === 'png' || ext === 'webp') {
return canvasSupportsType('image/webp') ? 'image/webp' : 'image/jpeg';
}
return 'image/jpeg';
@@ -1516,6 +1540,50 @@ window.requestConfirmation = requestConfirmation;
return /\.[^.]+$/.test(name) ? name.replace(/\.[^.]+$/, '') : name;
}
+ function compressionStatusNode(input) {
+ if (!input || !input.parentNode) return null;
+ var existing = input.parentNode.querySelector('.auto-compress-status[data-for-upload-status="1"]');
+ if (existing) return existing;
+ var node = document.createElement('span');
+ node.className = 'form-field-help auto-compress-status';
+ node.dataset.forUploadStatus = '1';
+ input.insertAdjacentElement('afterend', node);
+ return node;
+ }
+
+ function clearCompressionStatus(input) {
+ if (!input || !input.parentNode) return;
+ var existing = input.parentNode.querySelector('.auto-compress-status[data-for-upload-status="1"]');
+ if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
+ }
+
+ function setCompressionStatus(input, text) {
+ var node = compressionStatusNode(input);
+ if (node) node.textContent = text;
+ }
+
+ function resetCompressionState(input) {
+ if (!input || !input.dataset) return;
+ delete input.dataset.autoCompressed;
+ delete input.dataset.autoCompressedOriginalName;
+ delete input.dataset.autoCompressedOriginalSize;
+ delete input.dataset.autoCompressedFinalSize;
+ clearCompressionStatus(input);
+ }
+
+ function openCompressModal(input, file, limit) {
+ _input = input;
+ _file = file;
+ _max = limit;
+ var size = formatBytes(file.size);
+ var max = formatBytes(limit);
+ var info = document.getElementById('compress-info');
+ if (info) info.textContent = '"' + file.name + '" is ' + size + '. Board limit is ' + max + '.';
+ _setView('actions');
+ var modal = document.getElementById('compress-modal');
+ if (modal) modal.style.display = 'flex';
+ }
+
function stopMediaStream(stream) {
if (!stream || !stream.getTracks) return;
stream.getTracks().forEach(function (track) {
@@ -1530,6 +1598,9 @@ window.requestConfirmation = requestConfirmation;
videoEl.removeAttribute('src');
videoEl.load();
} catch (e) {}
+ if (videoEl.parentNode) {
+ videoEl.parentNode.removeChild(videoEl);
+ }
}
function videoRecorderMimeType() {
@@ -1550,27 +1621,20 @@ window.requestConfirmation = requestConfirmation;
window.checkFileSize = function (input) {
var file = input.files && input.files[0];
if (!file) return;
- var isImg = file.type.startsWith('image/');
- var isVideo = file.type.startsWith('video/');
- var limit = isImg ? getMax('image') : (isVideo ? getMax('video') : 0);
+ resetCompressionState(input);
+ var limit = limitForFile(file);
if (limit === 0 || file.size <= limit) return;
- _input = input;
- _file = file;
- _max = limit;
- var sizeMiB = (file.size / 1048576).toFixed(1);
- var limMiB = (limit / 1048576).toFixed(1);
- var info = document.getElementById('compress-info');
- if (info) info.textContent = '\u201c' + file.name + '\u201d is ' + sizeMiB + ' MiB \u2014 board limit is ' + limMiB + ' MiB.';
- _setView('actions');
- var modal = document.getElementById('compress-modal');
- if (modal) modal.style.display = 'flex';
+ openCompressModal(input, file, limit);
};
window.dismissCompressModal = function () {
if (_compressing) return;
var modal = document.getElementById('compress-modal');
if (modal) modal.style.display = 'none';
- if (_input) { _input.value = ''; }
+ if (_input) {
+ _input.value = '';
+ resetCompressionState(_input);
+ }
_input = null; _file = null; _compressing = false;
};
@@ -1580,15 +1644,17 @@ window.requestConfirmation = requestConfirmation;
_setView('progress');
_setProgress(0, 'Starting\u2026');
- var isImg = _file.type.startsWith('image/');
- var isVideo = _file.type.startsWith('video/');
+ var kind = fileMediaKind(_file);
+ var isImg = kind === 'image';
+ var isVideo = kind === 'video';
var promise = isImg ? _compressImage(_file, _max)
: isVideo ? _compressVideo(_file, _max)
: Promise.reject(new Error('Unsupported type'));
promise.then(function (blob) {
if (!blob || blob.size > _max) {
- _setProgress(100, 'Could not compress to the required size. Please use a smaller file.');
+ var resultSize = blob && blob.size ? ' Result was ' + formatBytes(blob.size) + '.' : '';
+ _setProgress(100, 'Could not compress under ' + formatBytes(_max) + '.' + resultSize + ' Please use a smaller file.');
_compressing = false;
_setView('done');
return;
@@ -1598,8 +1664,12 @@ window.requestConfirmation = requestConfirmation;
var dt = new DataTransfer();
dt.items.add(new File([blob], newName, { type: blob.type }));
_input.files = dt.files;
- var finalMiB = (blob.size / 1048576).toFixed(2);
- _setProgress(100, '\u2713 Compressed to ' + finalMiB + ' MiB. Ready to post.');
+ _input.dataset.autoCompressed = '1';
+ _input.dataset.autoCompressedOriginalName = _file.name;
+ _input.dataset.autoCompressedOriginalSize = String(_file.size);
+ _input.dataset.autoCompressedFinalSize = String(blob.size);
+ setCompressionStatus(_input, 'Auto-compressed ' + formatBytes(_file.size) + ' to ' + formatBytes(blob.size) + '.');
+ _setProgress(100, '\u2713 Compressed to ' + formatBytes(blob.size) + '. Ready to post.');
_compressing = false;
setTimeout(function () {
var modal = document.getElementById('compress-modal');
@@ -1613,6 +1683,22 @@ window.requestConfirmation = requestConfirmation;
});
};
+ window.ensureAutoCompressedUploadsReady = function (form) {
+ if (!form) return true;
+ var inputs = form.querySelectorAll('input[type="file"][data-onchange-check-size]');
+ for (var i = 0; i < inputs.length; i += 1) {
+ var input = inputs[i];
+ var file = input.files && input.files[0];
+ if (!file) continue;
+ var limit = limitForFile(file);
+ if (limit === 0 || file.size <= limit) continue;
+ if (input.dataset.autoCompressed === '1') continue;
+ openCompressModal(input, file, limit);
+ return false;
+ }
+ return true;
+ };
+
function _setView(which) {
var acts = document.getElementById('compress-actions');
var prog = document.getElementById('compress-progress');
@@ -1663,9 +1749,12 @@ window.requestConfirmation = requestConfirmation;
_setProgress(Math.min(attempt * 15, 90), 'Compressing\u2026 attempt ' + attempt);
if (!blob) { reject(new Error('Canvas toBlob failed')); return; }
if (blob.size <= maxBytes) { resolve(blob); return; }
- if (attempt >= 8) { resolve(blob); return; }
- quality -= 0.1;
- if (quality < 0.3) { quality = 0.5; scale *= 0.75; }
+ if (attempt >= 12) { resolve(blob); return; }
+ quality -= 0.12;
+ if (quality < 0.34) {
+ quality = 0.82;
+ scale *= 0.72;
+ }
tryEncode();
}, outputType, quality);
}
@@ -1688,12 +1777,22 @@ window.requestConfirmation = requestConfirmation;
videoEl.muted = true;
videoEl.playsInline = true;
videoEl.src = url;
+ videoEl.style.position = 'fixed';
+ videoEl.style.left = '-9999px';
+ videoEl.style.top = '0';
+ videoEl.style.width = '1px';
+ videoEl.style.height = '1px';
+ videoEl.style.opacity = '0';
+ videoEl.style.pointerEvents = 'none';
+ document.body.appendChild(videoEl);
var duration = 0;
var stream = null;
var recorder = null;
var progressTimer = null;
var safetyTimer = null;
var settled = false;
+ var attempt = 0;
+ var currentBitsPerSec = 0;
function finish(err, blob) {
if (settled) return;
@@ -1725,25 +1824,53 @@ window.requestConfirmation = requestConfirmation;
finish(new Error('Video capture stream is not available'));
return;
}
+ currentBitsPerSec = Math.max(targetBitsPerSec, 64000);
+ startRecordingAttempt();
+ };
+ videoEl.onerror = function () { finish(new Error('Video load error')); };
+ videoEl.load();
+
+ function startRecordingAttempt() {
+ attempt += 1;
+ if (progressTimer) clearInterval(progressTimer);
+ if (safetyTimer) clearTimeout(safetyTimer);
+ var chunks = [];
try {
recorder = new MediaRecorder(stream, {
mimeType: mimeType,
- videoBitsPerSecond: Math.max(targetBitsPerSec, 120000)
+ videoBitsPerSecond: currentBitsPerSec
});
} catch (e) {
finish(e);
return;
}
- var chunks = [];
recorder.ondataavailable = function (e) { if (e.data && e.data.size > 0) chunks.push(e.data); };
recorder.onstop = function () {
- finish(null, new Blob(chunks, { type: 'video/webm' }));
+ if (settled) return;
+ var blob = new Blob(chunks, { type: 'video/webm' });
+ if (blob.size > maxBytes && attempt < 4) {
+ currentBitsPerSec = Math.max(Math.floor(currentBitsPerSec * 0.6), 48000);
+ _setProgress(12, 'Retrying at lower bitrate\u2026 attempt ' + (attempt + 1));
+ window.setTimeout(function () {
+ try {
+ videoEl.currentTime = 0;
+ } catch (e) {}
+ startRecordingAttempt();
+ }, 0);
+ return;
+ }
+ finish(null, blob);
};
recorder.onerror = function (e) { finish(e.error || new Error('MediaRecorder error')); };
- videoEl.currentTime = 0;
+ try {
+ videoEl.currentTime = 0;
+ } catch (e) {}
recorder.start(1000);
progressTimer = setInterval(function () {
- _setProgress(Math.min(10 + Math.round((videoEl.currentTime / duration) * 80), 90), 'Re-encoding\u2026 ' + Math.round((videoEl.currentTime / duration) * 100) + '%');
+ _setProgress(
+ Math.min(10 + Math.round((videoEl.currentTime / duration) * 80), 90),
+ 'Re-encoding\u2026 attempt ' + attempt + ' · ' + Math.round((videoEl.currentTime / duration) * 100) + '%'
+ );
}, 500);
safetyTimer = setTimeout(function () {
if (recorder && recorder.state !== 'inactive') {
@@ -1760,9 +1887,7 @@ window.requestConfirmation = requestConfirmation;
videoEl.play().catch(function (err) {
finish(err || new Error('Video playback failed during compression'));
});
- };
- videoEl.onerror = function () { finish(new Error('Video load error')); };
- videoEl.load();
+ }
});
}
})();
@@ -3587,6 +3712,10 @@ document.addEventListener('submit', function (e) {
setPostFormOpen(true, { scrollIntoView: true });
return;
}
+ if (window.ensureAutoCompressedUploadsReady && !window.ensureAutoCompressedUploadsReady(form)) {
+ e.preventDefault();
+ return;
+ }
if (submitPostFormWithProgress(form)) {
e.preventDefault();
return;
diff --git a/static/style.css b/static/style.css
index 0627aee2..5d4e25fb 100644
--- a/static/style.css
+++ b/static/style.css
@@ -5814,6 +5814,12 @@ html:not([data-theme]) .crosslink:hover { text-shadow: 0 0 6px rgba(255,179,0,0.
line-height: 1.4;
}
+.auto-compress-status {
+ display: block;
+ margin-top: 0.25rem;
+ color: var(--green-pale);
+}
+
.poll-remove-btn {
background: transparent;
border: 1px solid var(--border);
From 7ab1df6584272684fe184cb980fc8ea5128cefa8 Mon Sep 17 00:00:00 2001
From: csd113
Date: Wed, 27 May 2026 22:45:12 -0700
Subject: [PATCH 02/26] Bump RustChan version to 1.3.0
Prepare for the 1.3.0 release: update the changelog header, bump the package version in Cargo.toml, refresh documentation references (README, SETUP), and adjust test/template strings in src/templates/admin.rs to reflect the new version.
---
CHANGELOG.md | 2 +-
Cargo.lock | 2 +-
Cargo.toml | 2 +-
README.md | 2 +-
SETUP.md | 4 ++--
src/templates/admin.rs | 6 +++---
6 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bcd8d8df..b9797a50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
All notable changes to RustChan will be documented in this file.
-## RustChan 1.2.2
+## RustChan 1.3.0
- Replaced browser proof-of-work posting CAPTCHA with server-generated image CAPTCHA challenges.
- Improved secure-cookie handling across HTTP, HTTPS, trusted-proxy HTTPS, admin sessions, board access cookies, CSRF cookies, and owned-post cookies.
diff --git a/Cargo.lock b/Cargo.lock
index 02cc8dba..77cc3436 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4001,7 +4001,7 @@ dependencies = [
[[package]]
name = "rustchan"
-version = "1.2.2"
+version = "1.3.0"
dependencies = [
"anyhow",
"argon2",
diff --git a/Cargo.toml b/Cargo.toml
index 5835eac5..17869b4c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "rustchan"
-version = "1.2.2"
+version = "1.3.0"
edition = "2021"
# axum 0.8 requires Rust 1.75; arti-client 0.41 bumps the effective floor to
# 1.90 — keep both fields in sync.
diff --git a/README.md b/README.md
index 689521b3..8a109a44 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ One binary. One data folder. SQLite only. The rest is features.
RustChan is built in Rust, ships with bundled SQLite, and is designed to be understandable, movable, and fun to run.
-Current development version: `1.2.2`.
+Current development version: `1.3.0`.
[What is RustChan?](#what-is-rustchan) ·
[Why it exists](#why-it-exists) ·
diff --git a/SETUP.md b/SETUP.md
index f9646124..98ed2747 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -2,7 +2,7 @@
Current setup and deployment guide for Linux, macOS, and Windows.
-Current development version: `1.2.2`.
+Current development version: `1.3.0`.
This guide reflects the current RustChan architecture:
@@ -265,7 +265,7 @@ rustchan-data/
## Banner Artwork Requirements
-RustChan `1.2.2` includes board banners plus a separate home-page announcement banner.
+RustChan `1.3.0` includes board banners plus a separate home-page announcement banner.
Banner upload requirements:
diff --git a/src/templates/admin.rs b/src/templates/admin.rs
index e0f2e349..e9369774 100644
--- a/src/templates/admin.rs
+++ b/src/templates/admin.rs
@@ -1893,7 +1893,7 @@ mod tests {
fn sample_site_health() -> AdminPanelSiteHealthView<'static> {
AdminPanelSiteHealthView {
server_status: "ready",
- rustchan_version: "1.2.2",
+ rustchan_version: "1.3.0",
database_integrity_status: "not checked",
last_successful_backup: "none saved",
next_scheduled_backup: "not scheduled",
@@ -1914,7 +1914,7 @@ mod tests {
failed_jobs: 0,
backup_jobs: "idle",
restore_jobs: "not available",
- diagnostics_text: "RustChan version: 1.2.2\nRecent warnings:\n none",
+ diagnostics_text: "RustChan version: 1.3.0\nRecent warnings:\n none",
}
}
@@ -2246,7 +2246,7 @@ mod tests {
assert!(html.contains("Database integrity status"));
assert!(html.contains("open media panel"));
assert!(html.contains("copy diagnostics"));
- assert!(html.contains("RustChan version: 1.2.2"));
+ assert!(html.contains("RustChan version: 1.3.0"));
assert!(html.contains(r#"data-admin-health-jobs-url="/admin/site-health/jobs""#));
assert!(html.contains(r#"data-admin-health-job="running_jobs""#));
assert!(html.contains(r#"data-admin-health-job="queued_jobs""#));
From 1742c74788d54816326f2695d8952643e2b6ccce Mon Sep 17 00:00:00 2001
From: csd113
Date: Wed, 27 May 2026 22:46:14 -0700
Subject: [PATCH 03/26] Update Cargo.lock
---
Cargo.lock | 54 +++++++++++++++++++++++++++---------------------------
1 file changed, 27 insertions(+), 27 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 77cc3436..c8204f1b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -711,9 +711,9 @@ dependencies = [
[[package]]
name = "brotli"
-version = "8.0.2"
+version = "8.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
+checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -722,9 +722,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
-version = "5.0.0"
+version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
+checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -1502,9 +1502,9 @@ dependencies = [
[[package]]
name = "displaydoc"
-version = "0.2.5"
+version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
@@ -2239,9 +2239,9 @@ checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2"
[[package]]
name = "http"
-version = "1.4.0"
+version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [
"bytes",
"itoa",
@@ -2315,9 +2315,9 @@ dependencies = [
[[package]]
name = "hyper"
-version = "1.9.0"
+version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
+checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc"
dependencies = [
"atomic-waker",
"bytes",
@@ -2810,9 +2810,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
-version = "0.1.16"
+version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"libc",
]
@@ -2869,9 +2869,9 @@ dependencies = [
[[package]]
name = "log"
-version = "0.4.29"
+version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "matchers"
@@ -2890,9 +2890,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
-version = "2.8.0"
+version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memmap2"
@@ -3564,7 +3564,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
- "toml_edit 0.25.11+spec-1.1.0",
+ "toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
@@ -3864,9 +3864,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
-version = "0.13.3"
+version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -4703,9 +4703,9 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "sqlite-wasm-rs"
-version = "0.5.4"
+version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee"
+checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
@@ -5142,9 +5142,9 @@ dependencies = [
[[package]]
name = "toml_edit"
-version = "0.25.11+spec-1.1.0"
+version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
+checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -7312,18 +7312,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.48"
+version = "0.8.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.48"
+version = "0.8.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e"
dependencies = [
"proc-macro2",
"quote",
From cd10b00dfea0d6e3ed47b22b1c36d385d7670dfc Mon Sep 17 00:00:00 2001
From: csd113
Date: Wed, 27 May 2026 23:37:54 -0700
Subject: [PATCH 04/26] ### Fixed - Fixed non-image media upload regressions
affecting small audio files, including FLAC and other supported audio
formats. - Fixed audio/video validation so supported audio types are
preserved correctly and invalid media is rejected safely. - Fixed
MKV/Matroska video detection so valid `.mkv` uploads work when video uploads
are enabled. - Fixed WebM audio MIME handling so `audio/webm` is not
persisted as `video/webm`. - Fixed failed media upload cleanup to avoid stale
staged files, partial outputs, thumbnails, or DB rows. - Removed unintended
manual textarea resizing handles from RustChan forms.
---
src/config/template.rs | 4 +-
src/db/schema.rs | 3 +-
src/handlers/board/media.rs | 4 +-
src/handlers/posting.rs | 166 +++++++++++++
src/media/convert.rs | 53 +++-
src/media/ffmpeg.rs | 23 +-
src/models.rs | 2 +-
src/templates/forms.rs | 23 +-
src/templates/mod.rs | 2 +-
src/utils/files.rs | 26 ++
src/utils/files/mime.rs | 45 ++--
src/utils/files/storage.rs | 476 ++++++++++++++++++++++++++++++++++--
src/workers/mod.rs | 9 +-
static/admin.css | 2 +-
static/main.js | 2 +-
static/style.css | 2 +-
16 files changed, 768 insertions(+), 74 deletions(-)
diff --git a/src/config/template.rs b/src/config/template.rs
index 4fc137a0..bc8aa2b6 100644
--- a/src/config/template.rs
+++ b/src/config/template.rs
@@ -52,10 +52,10 @@ trusted_proxy_cidrs = ["127.0.0.1/32", "::1/128"]
# Maximum size for image uploads in MiB (jpg, png, gif, webp, heic).
max_image_size_mb = 8
-# Maximum size for video uploads in MiB (mp4, webm).
+# Maximum size for video uploads in MiB (mp4, webm, mkv).
max_video_size_mb = 50
-# Maximum size for audio uploads in MiB (mp3, ogg, flac, wav, m4a, aac).
+# Maximum size for audio uploads in MiB (mp3, ogg/oga, opus, flac, wav, m4a, aac, webm audio).
max_audio_size_mb = 150
# Master switch for arbitrary file uploads.
diff --git a/src/db/schema.rs b/src/db/schema.rs
index 84926c18..d1263464 100644
--- a/src/db/schema.rs
+++ b/src/db/schema.rs
@@ -966,7 +966,8 @@ fn backfill_media_type(conn: &rusqlite::Connection) -> Result<()> {
file_path LIKE '%.png' OR file_path LIKE '%.gif' OR
file_path LIKE '%.webp' OR file_path LIKE '%.heic' OR
file_path LIKE '%.heif' THEN 'image'
- WHEN file_path LIKE '%.mp4' OR file_path LIKE '%.webm' THEN 'video'
+ WHEN file_path LIKE '%.mp4' OR file_path LIKE '%.webm' OR
+ file_path LIKE '%.mkv' THEN 'video'
WHEN file_path LIKE '%.mp3' OR file_path LIKE '%.ogg' OR
file_path LIKE '%.flac' OR file_path LIKE '%.wav' OR
file_path LIKE '%.m4a' OR file_path LIKE '%.aac' OR
diff --git a/src/handlers/board/media.rs b/src/handlers/board/media.rs
index c39a89c2..fdc4f791 100644
--- a/src/handlers/board/media.rs
+++ b/src/handlers/board/media.rs
@@ -24,8 +24,10 @@ fn media_content_type(path: &std::path::Path) -> Option<&'static str> {
// absence here documents the security decision.
Some("webm") => Some("video/webm"),
Some("mp4") => Some("video/mp4"),
+ Some("mkv") => Some("video/x-matroska"),
Some("mp3") => Some("audio/mpeg"),
- Some("ogg") => Some("audio/ogg"),
+ Some("ogg" | "oga") => Some("audio/ogg"),
+ Some("opus") => Some("audio/opus"),
Some("flac") => Some("audio/flac"),
Some("wav") => Some("audio/wav"),
Some("m4a") => Some("audio/mp4"),
diff --git a/src/handlers/posting.rs b/src/handlers/posting.rs
index 79842608..d9fff7be 100644
--- a/src/handlers/posting.rs
+++ b/src/handlers/posting.rs
@@ -869,6 +869,14 @@ mod tests {
bytes
}
+ fn flac_header_bytes() -> Vec {
+ b"fLaC\x00\x00\x00\x22tiny test flac bytes".to_vec()
+ }
+
+ fn webm_header_bytes() -> Vec {
+ b"\x1a\x45\xdf\xa3\x00\x00\x00\x00\x00\x00\x42\x82\x84webm\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00".to_vec()
+ }
+
fn pending_upload_stage_count(upload_dir: &std::path::Path) -> usize {
let pending = upload_dir.join(".pending");
if !pending.exists() {
@@ -879,6 +887,17 @@ mod tests {
.count()
}
+ fn assert_no_partial_post_rows(conn: &rusqlite::Connection) {
+ let thread_count: i64 = conn
+ .query_row("SELECT COUNT(*) FROM threads", [], |row| row.get(0))
+ .expect("thread count");
+ let post_count: i64 = conn
+ .query_row("SELECT COUNT(*) FROM posts", [], |row| row.get(0))
+ .expect("post count");
+ assert_eq!(thread_count, 0);
+ assert_eq!(post_count, 0);
+ }
+
#[test]
fn submit_post_rejects_banned_user_before_creating_thread() {
let state = crate::test_support::app_state();
@@ -1196,6 +1215,153 @@ mod tests {
}
}
+ #[test]
+ fn submit_post_rejects_disabled_audio_without_stale_uploads_or_rows() {
+ let state = crate::test_support::app_state();
+ let upload_dir = tempfile::tempdir().expect("upload dir");
+ let conn = state.db.get().expect("db connection");
+ crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).expect("create board");
+ let mut command = thread_command(
+ TEST_BOARD,
+ "audio-disabled",
+ "thread body",
+ upload_dir.path().to_str().expect("upload dir"),
+ );
+ command.file_data = Some(temp_upload("tiny.flac", &flac_header_bytes()));
+
+ let error = match submit_post(&conn, state.job_queue.as_ref(), command) {
+ Ok(result) => panic!(
+ "audio-disabled board should reject, got {}",
+ result.redirect_url
+ ),
+ Err(error) => error,
+ };
+
+ match error {
+ AppError::BadRequest(message) => {
+ assert!(message.contains("Audio uploads are disabled"));
+ }
+ other => panic!("expected BadRequest, got {other:?}"),
+ }
+ assert_eq!(pending_upload_stage_count(upload_dir.path()), 0);
+ assert!(!upload_dir.path().join(TEST_BOARD).exists());
+ assert_no_partial_post_rows(&conn);
+ }
+
+ #[test]
+ fn submit_post_rejects_disabled_video_without_stale_uploads_or_rows() {
+ let state = crate::test_support::app_state();
+ let upload_dir = tempfile::tempdir().expect("upload dir");
+ let conn = state.db.get().expect("db connection");
+ crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).expect("create board");
+ conn.execute(
+ "UPDATE boards SET allow_video = 0 WHERE short_name = ?1",
+ rusqlite::params![TEST_BOARD],
+ )
+ .expect("disable video");
+ let mut command = thread_command(
+ TEST_BOARD,
+ "video-disabled",
+ "thread body",
+ upload_dir.path().to_str().expect("upload dir"),
+ );
+ command.file_data = Some(temp_upload("tiny.webm", &webm_header_bytes()));
+
+ let error = match submit_post(&conn, state.job_queue.as_ref(), command) {
+ Ok(result) => panic!(
+ "video-disabled board should reject, got {}",
+ result.redirect_url
+ ),
+ Err(error) => error,
+ };
+
+ match error {
+ AppError::BadRequest(message) => {
+ assert!(message.contains("Video uploads are disabled"));
+ }
+ other => panic!("expected BadRequest, got {other:?}"),
+ }
+ assert_eq!(pending_upload_stage_count(upload_dir.path()), 0);
+ assert!(!upload_dir.path().join(TEST_BOARD).exists());
+ assert_no_partial_post_rows(&conn);
+ }
+
+ #[test]
+ fn submit_post_rejects_overlimit_audio_without_stale_uploads_or_rows() {
+ let state = crate::test_support::app_state();
+ let upload_dir = tempfile::tempdir().expect("upload dir");
+ let conn = state.db.get().expect("db connection");
+ crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).expect("create board");
+ conn.execute(
+ "UPDATE boards SET allow_audio = 1, max_audio_size = 8 WHERE short_name = ?1",
+ rusqlite::params![TEST_BOARD],
+ )
+ .expect("shrink audio limit");
+ let mut command = thread_command(
+ TEST_BOARD,
+ "audio-overlimit",
+ "thread body",
+ upload_dir.path().to_str().expect("upload dir"),
+ );
+ command.file_data = Some(temp_upload("tiny.flac", &flac_header_bytes()));
+
+ let error = match submit_post(&conn, state.job_queue.as_ref(), command) {
+ Ok(result) => panic!(
+ "over-limit audio should reject, got {}",
+ result.redirect_url
+ ),
+ Err(error) => error,
+ };
+
+ match error {
+ AppError::UploadTooLarge(message) => {
+ assert!(message.contains("Maximum audio upload size is 8 B."));
+ }
+ other => panic!("expected UploadTooLarge, got {other:?}"),
+ }
+ assert_eq!(pending_upload_stage_count(upload_dir.path()), 0);
+ assert!(!upload_dir.path().join(TEST_BOARD).exists());
+ assert_no_partial_post_rows(&conn);
+ }
+
+ #[test]
+ fn submit_post_rejects_overlimit_video_without_stale_uploads_or_rows() {
+ let state = crate::test_support::app_state();
+ let upload_dir = tempfile::tempdir().expect("upload dir");
+ let conn = state.db.get().expect("db connection");
+ crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).expect("create board");
+ conn.execute(
+ "UPDATE boards SET max_video_size = 8 WHERE short_name = ?1",
+ rusqlite::params![TEST_BOARD],
+ )
+ .expect("shrink video limit");
+ let mut command = thread_command(
+ TEST_BOARD,
+ "video-overlimit",
+ "thread body",
+ upload_dir.path().to_str().expect("upload dir"),
+ );
+ command.file_data = Some(temp_upload("tiny.webm", &webm_header_bytes()));
+
+ let error = match submit_post(&conn, state.job_queue.as_ref(), command) {
+ Ok(result) => panic!(
+ "over-limit video should reject, got {}",
+ result.redirect_url
+ ),
+ Err(error) => error,
+ };
+
+ match error {
+ AppError::UploadTooLarge(message) => {
+ assert!(message.contains("Maximum video upload size is 8 B."));
+ }
+ other => panic!("expected UploadTooLarge, got {other:?}"),
+ }
+ assert_eq!(pending_upload_stage_count(upload_dir.path()), 0);
+ assert!(!upload_dir.path().join(TEST_BOARD).exists());
+ assert_no_partial_post_rows(&conn);
+ }
+
#[test]
fn submit_post_cleans_staged_upload_when_poll_validation_fails() {
let state = crate::test_support::app_state();
diff --git a/src/media/convert.rs b/src/media/convert.rs
index aebaa012..1f0cf76b 100644
--- a/src/media/convert.rs
+++ b/src/media/convert.rs
@@ -11,7 +11,7 @@
// png → WebP ONLY if the WebP output is smaller; otherwise keep PNG
// svg → keep as-is (no conversion)
// webp → keep as-is
-// webm → keep as-is
+// webm/mkv → keep as-is
// all audio → keep as-is
// mp4 → keep as-is (background worker handles MP4→WebM separately)
//
@@ -212,7 +212,13 @@ fn copy_as_is(
file_stem: &str,
) -> Result {
let ext = crate::utils::files::mime_to_ext_pub(mime);
- copy_as_is_with_ext(input, output_dir, file_stem, ext)
+ copy_as_is_with_mime(
+ input,
+ output_dir,
+ file_stem,
+ ext,
+ upload_mime_to_static(mime),
+ )
}
/// Copy `input` to `output_dir/{file_stem}.{ext}`, returning a `ConversionResult`.
@@ -221,13 +227,21 @@ fn copy_as_is_with_ext(
output_dir: &Path,
file_stem: &str,
ext: &str,
+) -> Result {
+ copy_as_is_with_mime(input, output_dir, file_stem, ext, ext_to_static_mime(ext))
+}
+
+fn copy_as_is_with_mime(
+ input: &Path,
+ output_dir: &Path,
+ file_stem: &str,
+ ext: &str,
+ final_mime: &'static str,
) -> Result {
let output = output_dir.join(format!("{file_stem}.{ext}"));
std::fs::copy(input, &output)
.with_context(|| format!("failed to copy upload to {}", output.display()))?;
let final_size = file_size(&output)?;
- // Determine MIME from extension for reporting
- let final_mime = ext_to_static_mime(ext);
Ok(ConversionResult {
final_path: output,
final_mime,
@@ -236,6 +250,33 @@ fn copy_as_is_with_ext(
})
}
+fn upload_mime_to_static(mime: &str) -> &'static str {
+ match mime {
+ "image/jpeg" => "image/jpeg",
+ "image/png" => "image/png",
+ "image/gif" => "image/gif",
+ "image/heic" => "image/heic",
+ "image/heif" => "image/heif",
+ "image/bmp" => "image/bmp",
+ "image/tiff" => "image/tiff",
+ "image/webp" => "image/webp",
+ "image/svg+xml" => "image/svg+xml",
+ "application/pdf" => "application/pdf",
+ "video/webm" => "video/webm",
+ "video/mp4" => "video/mp4",
+ "video/x-matroska" | "video/matroska" => "video/x-matroska",
+ "audio/webm" => "audio/webm",
+ "audio/mpeg" | "audio/mp3" => "audio/mpeg",
+ "audio/ogg" | "application/ogg" | "audio/oga" => "audio/ogg",
+ "audio/opus" => "audio/opus",
+ "audio/flac" | "audio/x-flac" => "audio/flac",
+ "audio/wav" | "audio/wave" | "audio/x-wav" | "audio/vnd.wave" => "audio/wav",
+ "audio/mp4" | "audio/m4a" | "audio/x-m4a" => "audio/mp4",
+ "audio/aac" | "audio/x-aac" => "audio/aac",
+ _ => "application/octet-stream",
+ }
+}
+
// ─── Path and size utilities ──────────────────────────────────────────────────
/// Create a UUID-named sibling path for use as an atomic temp output.
@@ -281,8 +322,10 @@ fn ext_for_original_mime(path: &Path) -> &'static str {
Some("tiff" | "tif") => "tiff",
Some("webp") => "webp",
Some("webm") => "webm",
+ Some("mkv") => "mkv",
Some("svg") => "svg",
Some("pdf") => "pdf",
+ Some("opus") => "opus",
_ => "bin",
}
}
@@ -301,9 +344,11 @@ fn ext_to_static_mime(ext: &str) -> &'static str {
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
"webm" => "video/webm",
+ "mkv" => "video/x-matroska",
"mp4" => "video/mp4",
"mp3" => "audio/mpeg",
"ogg" => "audio/ogg",
+ "opus" => "audio/opus",
"flac" => "audio/flac",
"wav" => "audio/wav",
"m4a" => "audio/mp4",
diff --git a/src/media/ffmpeg.rs b/src/media/ffmpeg.rs
index 129523f9..83f5ef82 100644
--- a/src/media/ffmpeg.rs
+++ b/src/media/ffmpeg.rs
@@ -207,7 +207,7 @@ pub fn ffmpeg_thumbnail(input: &Path, output: &Path, max_dim: u32) -> Result<()>
.with_context(|| format!("thumbnail generation failed for {in_str}"))
}
-/// Probe whether a `WebM` container is audio-only or contains video streams.
+/// Probe whether a media container is audio-only or contains video streams.
///
/// # Errors
/// Returns an error if `ffprobe` cannot be spawned, times out, exits non-zero,
@@ -263,12 +263,29 @@ pub fn probe_stream_kind(path: &Path) -> Result {
/// Returns an error if `ffprobe` cannot be spawned, exits non-zero, or its
/// output contains no recognisable codec name.
pub fn probe_video_codec(path: &str) -> Result {
+ probe_codec(path, "v:0", "video")
+}
+
+/// Probe the primary audio codec of a media file using `ffprobe`.
+///
+/// Returns the lowercase codec name (e.g. `"flac"`, `"mp3"`, `"opus"`) on
+/// success.
+///
+/// # Errors
+/// Returns an error if `ffprobe` cannot be spawned, exits non-zero, or its
+/// output contains no recognisable codec name.
+pub fn probe_audio_codec(path: &Path) -> Result {
+ let path_str = path_to_str(path)?;
+ probe_codec(path_str, "a:0", "audio")
+}
+
+fn probe_codec(path: &str, stream_selector: &str, stream_label: &str) -> Result {
let output = run_command_with_timeout(
ffprobe_command().args([
"-v",
"quiet",
"-select_streams",
- "v:0",
+ stream_selector,
"-show_entries",
"stream=codec_name",
"-of",
@@ -293,7 +310,7 @@ pub fn probe_video_codec(path: &str) -> Result {
if codec.is_empty() {
return Err(anyhow::anyhow!(
- "ffprobe returned no codec name for: {path}"
+ "ffprobe returned no {stream_label} codec name for: {path}"
));
}
diff --git a/src/models.rs b/src/models.rs
index b308c292..22a93e67 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -45,7 +45,7 @@ impl MediaType {
match ext {
"jpg" | "jpeg" | "png" | "gif" | "webp" | "heic" | "heif" | "bmp" | "tiff" | "tif"
| "svg" => Self::Image,
- "mp4" | "webm" => Self::Video,
+ "mp4" | "webm" | "mkv" => Self::Video,
"mp3" | "ogg" | "flac" | "wav" | "m4a" | "aac" | "opus" => Self::Audio,
"pdf" => Self::Pdf,
_ => Self::Other,
diff --git a/src/templates/forms.rs b/src/templates/forms.rs
index 3950936f..89f29e6f 100644
--- a/src/templates/forms.rs
+++ b/src/templates/forms.rs
@@ -34,7 +34,8 @@ const fn upload_progress_row() -> &'static str {
}
const AUDIO_ACCEPT: &str =
- "audio/mpeg,audio/ogg,audio/flac,audio/wav,audio/mp4,audio/aac,audio/webm,.mp3,.ogg,.flac,.wav,.m4a,.aac";
+ "audio/mpeg,audio/mp3,audio/ogg,application/ogg,audio/oga,audio/opus,audio/flac,audio/x-flac,audio/wav,audio/wave,audio/x-wav,audio/vnd.wave,audio/mp4,audio/m4a,audio/x-m4a,audio/aac,audio/x-aac,audio/webm,.mp3,.ogg,.oga,.opus,.flac,.wav,.m4a,.aac,.webm";
+const VIDEO_ACCEPT: &str = "video/mp4,video/webm,video/x-matroska,video/matroska,.mp4,.webm,.mkv";
const IMAGE_ACCEPT: &str =
"image/jpeg,image/png,image/gif,image/webp,image/heic,image/heif,.heic,.heif";
const POLL_OPTION_MAX_LENGTH: usize = 200;
@@ -113,12 +114,14 @@ fn render_single_upload_row(board: &Board, audio_image_hint: &str) -> String {
hint_parts.push(format!("jpg/png/gif/webp/heic · max {image_mb} MiB"));
}
if board.allow_video {
- accept_parts.push("video/mp4,video/webm");
- hint_parts.push(format!("mp4/webm · max {video_mb} MiB"));
+ accept_parts.push(VIDEO_ACCEPT);
+ hint_parts.push(format!("mp4/webm/mkv · max {video_mb} MiB"));
}
if board.allow_audio {
accept_parts.push(AUDIO_ACCEPT);
- hint_parts.push(format!("mp3/ogg/flac/wav/m4a · max {audio_mb} MiB"));
+ hint_parts.push(format!(
+ "mp3/ogg/oga/opus/flac/wav/m4a/aac/webm · max {audio_mb} MiB"
+ ));
}
if board.allow_pdf {
accept_parts.push("application/pdf,.pdf");
@@ -179,7 +182,7 @@ fn render_single_upload_row(board: &Board, audio_image_hint: &str) -> String {
file_accept
};
let primary_hint = if audio_image_dual_mode {
- format!("mp3/ogg/flac/wav/m4a · max {audio_mb} MiB")
+ format!("mp3/ogg/oga/opus/flac/wav/m4a/aac/webm · max {audio_mb} MiB")
} else {
file_hint
};
@@ -376,7 +379,7 @@ pub(super) fn reply_form(
mod tests {
use super::{
build_upload_form_policy, new_thread_form, render_poll_option_row, reply_form,
- PostFormState, POLL_OPTION_MAX_COUNT, POLL_OPTION_MAX_LENGTH,
+ PostFormState, AUDIO_ACCEPT, POLL_OPTION_MAX_COUNT, POLL_OPTION_MAX_LENGTH,
};
fn uploads_disabled_board() -> crate::models::Board {
@@ -431,10 +434,12 @@ mod tests {
assert!(html.contains("optional cover image for the audio post"));
assert!(html.contains("image/heic"));
assert!(html.contains(".heic"));
- assert!(html.contains("accept=\"audio/mpeg,audio/ogg,audio/flac,audio/wav,audio/mp4,audio/aac,audio/webm,.mp3,.ogg,.flac,.wav,.m4a,.aac\""));
- assert!(html.contains("mp3/ogg/flac/wav/m4a · max"));
+ assert!(html.contains(&format!("accept=\"{AUDIO_ACCEPT}\"")));
+ assert!(html.contains("mp3/ogg/oga/opus/flac/wav/m4a/aac/webm · max"));
assert!(
- !html.contains("jpg/png/gif/webp/heic · max 8 MiB | mp3/ogg/flac/wav/m4a")
+ !html.contains(
+ "jpg/png/gif/webp/heic · max 8 MiB | mp3/ogg/oga/opus/flac/wav/m4a/aac/webm"
+ )
);
assert!(!html.contains("video/mp4,video/webm"));
assert!(!html.contains("name=\"file\""));
diff --git a/src/templates/mod.rs b/src/templates/mod.rs
index d45bee2f..1d9af6a4 100644
--- a/src/templates/mod.rs
+++ b/src/templates/mod.rs
@@ -961,7 +961,7 @@ appeals are reviewed by site staff. one appeal per 24 hours.
+ style="width:100%;box-sizing:border-box;margin:0.75rem 0;background:var(--bg-post);color:var(--text);border:1px solid var(--border);padding:0.5rem;resize:none">
submit appeal
return home
diff --git a/src/utils/files.rs b/src/utils/files.rs
index 5dbc4081..c6151df2 100644
--- a/src/utils/files.rs
+++ b/src/utils/files.rs
@@ -46,13 +46,21 @@ mod tests {
assert_eq!(mime_to_ext_pub("image/heif"), "heif");
assert_eq!(mime_to_ext_pub("video/mp4"), "mp4");
assert_eq!(mime_to_ext_pub("video/webm"), "webm");
+ assert_eq!(mime_to_ext_pub("video/x-matroska"), "mkv");
+ assert_eq!(mime_to_ext_pub("video/matroska"), "mkv");
assert_eq!(mime_to_ext_pub("audio/webm"), "webm");
assert_eq!(mime_to_ext_pub("audio/mpeg"), "mp3");
assert_eq!(mime_to_ext_pub("audio/ogg"), "ogg");
+ assert_eq!(mime_to_ext_pub("application/ogg"), "ogg");
+ assert_eq!(mime_to_ext_pub("audio/opus"), "opus");
assert_eq!(mime_to_ext_pub("audio/flac"), "flac");
+ assert_eq!(mime_to_ext_pub("audio/x-flac"), "flac");
assert_eq!(mime_to_ext_pub("audio/wav"), "wav");
+ assert_eq!(mime_to_ext_pub("audio/x-wav"), "wav");
assert_eq!(mime_to_ext_pub("audio/mp4"), "m4a");
+ assert_eq!(mime_to_ext_pub("audio/x-m4a"), "m4a");
assert_eq!(mime_to_ext_pub("audio/aac"), "aac");
+ assert_eq!(mime_to_ext_pub("audio/x-aac"), "aac");
}
#[test]
@@ -152,6 +160,24 @@ mod tests {
);
}
+ #[test]
+ fn detect_matroska_doctype() {
+ let data: &[u8] = b"\x1a\x45\xdf\xa3\xa3\x42\x86\x81\x01\x42\xf7\x81\x01\x42\xf2\x81\x04\x42\xf3\x81\x08\x42\x82\x88matroska\x42\x87\x81\x04";
+ assert_eq!(
+ super::mime::detect_mime_type(data).expect("matroska"),
+ "video/x-matroska"
+ );
+ }
+
+ #[test]
+ fn detect_ogg_opus_header() {
+ let data: &[u8] = b"OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00OpusHead\x01\x01\x38\x01";
+ assert_eq!(
+ super::mime::detect_mime_type(data).expect("opus"),
+ "audio/opus"
+ );
+ }
+
#[test]
fn exif_orientation_6_rotates_90cw_swaps_dims() {
let img = image::DynamicImage::new_rgba8(4, 6);
diff --git a/src/utils/files/mime.rs b/src/utils/files/mime.rs
index c0cd5472..153a5792 100644
--- a/src/utils/files/mime.rs
+++ b/src/utils/files/mime.rs
@@ -39,27 +39,17 @@ pub fn detect_mime_type(data: &[u8]) -> Result<&'static str> {
}
if header.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) {
- let scan = data.get(..data.len().min(64)).unwrap_or(data);
- if let Some(pos) = scan.windows(2).position(|w| w == [0x42, 0x82]) {
- let size_idx = pos + 2;
- if let Some(&sz) = scan.get(size_idx) {
- let len = usize::from(sz & 0x7F);
- let start = size_idx + 1;
- let end = start.saturating_add(len);
- if let Some(dt) = scan.get(start..end) {
- if dt.eq_ignore_ascii_case(b"webm") {
- return Ok("video/webm");
- }
- if dt.eq_ignore_ascii_case(b"matroska") {
- return Err(anyhow::anyhow!(
- "File type not allowed. Matroska/MKV is not accepted; use WebM."
- ));
- }
- }
+ let scan = data.get(..data.len().min(512)).unwrap_or(data);
+ if let Some(doc_type) = ebml_doc_type(scan) {
+ if doc_type.eq_ignore_ascii_case(b"webm") {
+ return Ok("video/webm");
+ }
+ if doc_type.eq_ignore_ascii_case(b"matroska") {
+ return Ok("video/x-matroska");
}
}
return Err(anyhow::anyhow!(
- "File type not allowed. EBML container is not a valid WebM."
+ "File type not allowed. EBML container is not valid WebM or Matroska/MKV media."
));
}
@@ -82,6 +72,12 @@ pub fn detect_mime_type(data: &[u8]) -> Result<&'static str> {
return Ok("audio/aac");
}
if header.starts_with(b"OggS") {
+ if data
+ .get(..data.len().min(512))
+ .is_some_and(|scan| scan.windows(b"OpusHead".len()).any(|w| w == b"OpusHead"))
+ {
+ return Ok("audio/opus");
+ }
return Ok("audio/ogg");
}
if header.starts_with(b"fLaC") {
@@ -120,6 +116,19 @@ fn has_ftyp_brand(data: &[u8], accepted: &[&[u8; 4]]) -> bool {
})
}
+fn ebml_doc_type(scan: &[u8]) -> Option<&[u8]> {
+ let pos = scan.windows(2).position(|w| w == [0x42, 0x82])?;
+ let size_idx = pos.checked_add(2)?;
+ let size = *scan.get(size_idx)?;
+ let len = usize::from(size & 0x7F);
+ if len == 0 || len > 32 {
+ return None;
+ }
+ let start = size_idx.checked_add(1)?;
+ let end = start.checked_add(len)?;
+ scan.get(start..end)
+}
+
#[must_use]
pub const fn fallback_download_mime_type() -> &'static str {
"application/octet-stream"
diff --git a/src/utils/files/storage.rs b/src/utils/files/storage.rs
index 6e5ab523..6579dc9e 100644
--- a/src/utils/files/storage.rs
+++ b/src/utils/files/storage.rs
@@ -63,26 +63,7 @@ pub fn classify_upload_mime(
Err(error) => return Err(error),
};
- if detected == "video/webm" && ffprobe_available {
- match crate::media::ffmpeg::probe_stream_kind(input_path) {
- Ok(crate::media::ffmpeg::StreamKind::AudioOnly) => return Ok("audio/webm".to_owned()),
- Ok(crate::media::ffmpeg::StreamKind::Video) => {}
- Err(error) => {
- tracing::debug!(
- path = %input_path.display(),
- error = %error,
- "ffprobe could not refine WebM media type; treating upload as video/webm"
- );
- }
- }
- } else if detected == "video/webm" {
- tracing::debug!(
- path = %input_path.display(),
- "ffprobe unavailable; treating WebM upload as video/webm"
- );
- }
-
- Ok(detected)
+ refine_probe_mime(input_path, &detected, ffprobe_available)
}
/// Save and process a primary upload from an already-streamed temporary file.
@@ -143,6 +124,12 @@ pub fn save_audio_with_image_thumb_from_path(
format_upload_limit(max_audio_size)
));
}
+ validate_av_stream_kind(
+ input_path,
+ &mime_type,
+ crate::models::MediaType::Audio,
+ ffprobe_available,
+ )?;
let file_id = Uuid::new_v4().simple().to_string();
let ext = mime_to_ext(&mime_type);
@@ -278,7 +265,8 @@ fn build_upload_plan(
)
&& (validated.media_type != crate::models::MediaType::Video
|| validated.mime_type == "video/mp4"
- || validated.mime_type == "video/webm");
+ || validated.mime_type == "video/webm"
+ || is_matroska_mime(&validated.mime_type));
Ok(UploadPlan {
mime_type: validated.mime_type,
@@ -341,6 +329,16 @@ fn validate_upload(
validate_decodable_image(input_path, &mime_type)?;
} else if media_type == crate::models::MediaType::Pdf {
validate_pdf_structure(input_path)?;
+ } else if matches!(
+ media_type,
+ crate::models::MediaType::Audio | crate::models::MediaType::Video
+ ) {
+ validate_av_stream_kind(
+ input_path,
+ &mime_type,
+ media_type,
+ options.ffprobe_available,
+ )?;
}
let jpeg_orientation = if mime_type == "image/jpeg" {
@@ -686,6 +684,147 @@ fn mime_to_image_format(mime_type: &str) -> Option {
}
}
+fn refine_probe_mime(input_path: &Path, detected: &str, ffprobe_available: bool) -> Result {
+ let media_type = crate::models::MediaType::from_mime(detected);
+ let should_probe = matches!(
+ media_type,
+ crate::models::MediaType::Audio | crate::models::MediaType::Video
+ );
+ if !should_probe {
+ return Ok(detected.to_owned());
+ }
+
+ if !ffprobe_available {
+ if is_matroska_mime(detected) {
+ anyhow::bail!(
+ "File appears to be Matroska/MKV, but ffprobe is required to validate MKV uploads."
+ );
+ }
+ if detected == "video/webm" {
+ tracing::debug!(
+ path = %input_path.display(),
+ "ffprobe unavailable; treating WebM upload as video/webm"
+ );
+ }
+ return Ok(detected.to_owned());
+ }
+
+ let stream_kind = crate::media::ffmpeg::probe_stream_kind(input_path).with_context(|| {
+ format!("File appears to be {detected}, but ffprobe could not validate its streams")
+ })?;
+
+ match (media_type, stream_kind) {
+ (crate::models::MediaType::Audio, crate::media::ffmpeg::StreamKind::AudioOnly) => {
+ canonical_audio_mime(input_path, detected)
+ }
+ (crate::models::MediaType::Video, crate::media::ffmpeg::StreamKind::Video) => {
+ Ok(canonical_video_mime(detected).to_owned())
+ }
+ (crate::models::MediaType::Video, crate::media::ffmpeg::StreamKind::AudioOnly)
+ if detected == "video/mp4" || detected == "video/webm" =>
+ {
+ canonical_audio_mime(input_path, detected)
+ }
+ (crate::models::MediaType::Video, crate::media::ffmpeg::StreamKind::AudioOnly)
+ if is_matroska_mime(detected) =>
+ {
+ anyhow::bail!("Matroska/MKV uploads must contain a video stream.")
+ }
+ (crate::models::MediaType::Video, crate::media::ffmpeg::StreamKind::AudioOnly) => {
+ anyhow::bail!("File appears to be {detected}, but contains only audio streams.")
+ }
+ (crate::models::MediaType::Audio, crate::media::ffmpeg::StreamKind::Video) => {
+ anyhow::bail!("File appears to be {detected}, but contains a video stream.")
+ }
+ (
+ crate::models::MediaType::Image
+ | crate::models::MediaType::Pdf
+ | crate::models::MediaType::Other,
+ _,
+ ) => Ok(detected.to_owned()),
+ }
+}
+
+fn canonical_audio_mime(input_path: &Path, detected: &str) -> Result {
+ let codec = crate::media::ffmpeg::probe_audio_codec(input_path).with_context(|| {
+ format!("File appears to be {detected}, but ffprobe could not identify its audio codec")
+ })?;
+ let mime = match codec.as_str() {
+ "flac" => "audio/flac",
+ "mp3" | "mp2" | "mp1" => "audio/mpeg",
+ "aac" => {
+ if detected == "audio/aac" {
+ "audio/aac"
+ } else {
+ "audio/mp4"
+ }
+ }
+ "opus" => {
+ if detected == "video/webm" || detected == "audio/webm" {
+ "audio/webm"
+ } else {
+ "audio/opus"
+ }
+ }
+ "vorbis" => "audio/ogg",
+ codec if codec.starts_with("pcm_") => "audio/wav",
+ _ => detected,
+ };
+ Ok(canonical_audio_mime_variant(mime).to_owned())
+}
+
+fn canonical_audio_mime_variant(mime: &str) -> &str {
+ match mime {
+ "audio/mp3" => "audio/mpeg",
+ "audio/x-flac" => "audio/flac",
+ "audio/wave" | "audio/x-wav" | "audio/vnd.wave" => "audio/wav",
+ "application/ogg" | "audio/oga" => "audio/ogg",
+ "audio/m4a" | "audio/x-m4a" => "audio/mp4",
+ "audio/x-aac" => "audio/aac",
+ _ => mime,
+ }
+}
+
+fn canonical_video_mime(mime: &str) -> &str {
+ match mime {
+ "video/matroska" => "video/x-matroska",
+ _ => mime,
+ }
+}
+
+fn is_matroska_mime(mime: &str) -> bool {
+ matches!(mime, "video/x-matroska" | "video/matroska")
+}
+
+fn validate_av_stream_kind(
+ input_path: &Path,
+ mime_type: &str,
+ media_type: crate::models::MediaType,
+ ffprobe_available: bool,
+) -> Result<()> {
+ if !ffprobe_available {
+ return Ok(());
+ }
+
+ let stream_kind = crate::media::ffmpeg::probe_stream_kind(input_path).with_context(|| {
+ format!("File appears to be {mime_type}, but ffprobe could not validate its streams")
+ })?;
+ let expected_stream_kind = match media_type {
+ crate::models::MediaType::Audio => crate::media::ffmpeg::StreamKind::AudioOnly,
+ crate::models::MediaType::Video => crate::media::ffmpeg::StreamKind::Video,
+ crate::models::MediaType::Image
+ | crate::models::MediaType::Pdf
+ | crate::models::MediaType::Other => return Ok(()),
+ };
+ if stream_kind == expected_stream_kind {
+ return Ok(());
+ }
+ if media_type == crate::models::MediaType::Audio {
+ anyhow::bail!("File appears to be {mime_type}, but contains a video stream.");
+ }
+ anyhow::bail!("File appears to be {mime_type}, but contains only audio streams.");
+}
+
fn apply_thumb_exif_orientation(thumb_path: &Path, orientation: u32) {
if orientation <= 1 {
return;
@@ -763,12 +902,14 @@ fn mime_to_ext(mime: &str) -> &'static str {
"image/svg+xml" => "svg",
"video/mp4" => "mp4",
"video/webm" | "audio/webm" => "webm",
- "audio/mpeg" => "mp3",
- "audio/ogg" => "ogg",
- "audio/flac" => "flac",
- "audio/wav" => "wav",
- "audio/mp4" => "m4a",
- "audio/aac" => "aac",
+ "video/x-matroska" | "video/matroska" => "mkv",
+ "audio/mpeg" | "audio/mp3" => "mp3",
+ "audio/ogg" | "application/ogg" | "audio/oga" => "ogg",
+ "audio/opus" => "opus",
+ "audio/flac" | "audio/x-flac" => "flac",
+ "audio/wav" | "audio/wave" | "audio/x-wav" | "audio/vnd.wave" => "wav",
+ "audio/mp4" | "audio/m4a" | "audio/x-m4a" => "m4a",
+ "audio/aac" | "audio/x-aac" => "aac",
"application/pdf" => "pdf",
_ => "bin",
}
@@ -780,6 +921,8 @@ mod tests {
delete_file_checked, save_audio_with_image_thumb_from_path, save_upload_from_path,
SaveUploadOptions,
};
+ use std::path::Path;
+ use std::process::{Command, Stdio};
fn one_pixel_png() -> Vec {
let mut bytes = Vec::new();
@@ -869,6 +1012,285 @@ trailer << /Root 1 0 R >>
b"\x1a\x45\xdf\xa3\x00\x00\x00\x00\x00\x00\x42\x82\x84webm\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
}
+ fn sniff(bytes: &[u8]) -> &[u8] {
+ bytes.get(..bytes.len().min(512)).unwrap_or(bytes)
+ }
+
+ fn ffmpeg_available() -> bool {
+ Command::new(&crate::config::CONFIG.ffmpeg_path)
+ .arg("-version")
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .is_ok_and(|status| status.success())
+ }
+
+ fn ffprobe_available() -> bool {
+ Command::new(&crate::config::CONFIG.ffprobe_path)
+ .arg("-version")
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .is_ok_and(|status| status.success())
+ }
+
+ fn generate_ffmpeg_fixture(output: &Path, args: &[&str]) -> Option> {
+ let mut command = Command::new(&crate::config::CONFIG.ffmpeg_path);
+ command
+ .arg("-hide_banner")
+ .arg("-loglevel")
+ .arg("error")
+ .arg("-y")
+ .args(args)
+ .arg(output)
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+ if !command.status().ok()?.success() {
+ return None;
+ }
+ std::fs::read(output).ok()
+ }
+
+ struct AudioFixtureCase<'a> {
+ file_name: &'a str,
+ expected_mime: &'a str,
+ args: &'a [&'a str],
+ }
+
+ const AUDIO_FIXTURE_CASES: &[AudioFixtureCase<'static>] = &[
+ AudioFixtureCase {
+ file_name: "tiny.flac",
+ expected_mime: "audio/flac",
+ args: &[
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=0.05",
+ "-c:a",
+ "flac",
+ ],
+ },
+ AudioFixtureCase {
+ file_name: "tiny.mp3",
+ expected_mime: "audio/mpeg",
+ args: &[
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=0.05",
+ "-c:a",
+ "libmp3lame",
+ "-b:a",
+ "64k",
+ ],
+ },
+ AudioFixtureCase {
+ file_name: "tiny.wav",
+ expected_mime: "audio/wav",
+ args: &[
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=0.05",
+ "-c:a",
+ "pcm_s16le",
+ ],
+ },
+ AudioFixtureCase {
+ file_name: "tiny.ogg",
+ expected_mime: "audio/ogg",
+ args: &[
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=0.05",
+ "-c:a",
+ "libvorbis",
+ ],
+ },
+ AudioFixtureCase {
+ file_name: "tiny.oga",
+ expected_mime: "audio/ogg",
+ args: &[
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=0.05",
+ "-c:a",
+ "libvorbis",
+ ],
+ },
+ AudioFixtureCase {
+ file_name: "tiny.opus",
+ expected_mime: "audio/opus",
+ args: &[
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=0.05",
+ "-c:a",
+ "libopus",
+ "-b:a",
+ "32k",
+ ],
+ },
+ AudioFixtureCase {
+ file_name: "tiny.m4a",
+ expected_mime: "audio/mp4",
+ args: &[
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=0.05",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "64k",
+ ],
+ },
+ AudioFixtureCase {
+ file_name: "tiny-isom.mp4",
+ expected_mime: "audio/mp4",
+ args: &[
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=0.05",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "64k",
+ "-f",
+ "mp4",
+ ],
+ },
+ AudioFixtureCase {
+ file_name: "tiny.aac",
+ expected_mime: "audio/aac",
+ args: &[
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=0.05",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "64k",
+ "-f",
+ "adts",
+ ],
+ },
+ AudioFixtureCase {
+ file_name: "tiny-audio.webm",
+ expected_mime: "audio/webm",
+ args: &[
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=0.05",
+ "-c:a",
+ "libopus",
+ "-b:a",
+ "32k",
+ "-f",
+ "webm",
+ ],
+ },
+ ];
+
+ #[test]
+ fn valid_audio_uploads_accept_common_formats_with_ffprobe() {
+ if !ffmpeg_available() || !ffprobe_available() {
+ eprintln!("skipping ffmpeg audio fixture test; ffmpeg/ffprobe unavailable");
+ return;
+ }
+
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let mut checked = 0usize;
+ for case in AUDIO_FIXTURE_CASES {
+ let input_path = tempdir.path().join(case.file_name);
+ let Some(bytes) = generate_ffmpeg_fixture(&input_path, case.args) else {
+ eprintln!(
+ "skipping audio fixture {}; ffmpeg could not generate it",
+ case.file_name
+ );
+ continue;
+ };
+ let mut options = test_upload_options(tempdir.path(), case.file_name);
+ options.ffprobe_available = true;
+
+ let uploaded = save_upload_from_path(&input_path, sniff(&bytes), bytes.len(), &options)
+ .unwrap_or_else(|error| panic!("{} should upload: {error:#}", case.file_name));
+
+ assert_eq!(uploaded.mime_type, case.expected_mime, "{}", case.file_name);
+ assert_eq!(uploaded.media_type, crate::models::MediaType::Audio);
+ assert!(tempdir.path().join(&uploaded.file_path).exists());
+ checked = checked.saturating_add(1);
+ }
+
+ assert!(checked >= 8, "expected most audio fixtures to be generated");
+ }
+
+ #[test]
+ fn valid_mkv_uploads_save_as_video_with_probe() {
+ if !ffmpeg_available() || !ffprobe_available() {
+ eprintln!("skipping MKV fixture test; ffmpeg/ffprobe unavailable");
+ return;
+ }
+
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let input_path = tempdir.path().join("tiny.mkv");
+ let bytes = generate_ffmpeg_fixture(
+ &input_path,
+ &[
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=black:s=16x16:d=0.1",
+ "-an",
+ "-c:v",
+ "mpeg4",
+ "-f",
+ "matroska",
+ ],
+ )
+ .expect("generate mkv fixture");
+ let mut options = test_upload_options(tempdir.path(), "browser-octet-stream.bin");
+ options.ffprobe_available = true;
+
+ let uploaded = save_upload_from_path(&input_path, sniff(&bytes), bytes.len(), &options)
+ .expect("valid MKV upload should save");
+
+ assert_eq!(uploaded.mime_type, "video/x-matroska");
+ assert_eq!(uploaded.media_type, crate::models::MediaType::Video);
+ assert!(std::path::Path::new(&uploaded.file_path)
+ .extension()
+ .is_some_and(|ext| ext.eq_ignore_ascii_case("mkv")));
+ assert!(tempdir.path().join(&uploaded.file_path).exists());
+ assert!(tempdir.path().join(&uploaded.thumb_path).exists());
+ }
+
+ #[test]
+ fn fake_mkv_is_rejected_without_outputs() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let input_path = tempdir.path().join("fake.mkv");
+ let fake_mkv = b"\x1a\x45\xdf\xa3\xa3\x42\x86\x81\x01\x42\xf7\x81\x01\x42\xf2\x81\x04\x42\xf3\x81\x08\x42\x82\x88matroska\x42\x87\x81\x04not real media";
+ std::fs::write(&input_path, fake_mkv).expect("write fake mkv");
+ let mut options = test_upload_options(tempdir.path(), "fake.mkv");
+ options.ffprobe_available = true;
+
+ let Err(error) =
+ save_upload_from_path(&input_path, sniff(fake_mkv), fake_mkv.len(), &options)
+ else {
+ panic!("fake MKV should be rejected");
+ };
+
+ assert!(error
+ .to_string()
+ .contains("ffprobe could not validate its streams"));
+ assert!(!tempdir.path().join("test").exists());
+ }
+
#[test]
fn delete_file_checked_removes_valid_in_tree_file() {
let tempdir = tempfile::tempdir().expect("tempdir");
diff --git a/src/workers/mod.rs b/src/workers/mod.rs
index 257f2762..522277e4 100644
--- a/src/workers/mod.rs
+++ b/src/workers/mod.rs
@@ -16,7 +16,7 @@
// shutdown drains in-progress jobs before exiting (#7).
//
// Job types:
-// VideoTranscode — MP4 → WebM (VP9 + Opus) via ffmpeg (off the hot path)
+// VideoTranscode — MP4/MKV → WebM (VP9 + Opus) via ffmpeg (off the hot path)
// AudioWaveform — waveform PNG from audio via ffmpeg (off the hot path)
// ThreadPrune — delete overflow threads from a board asynchronously
// SpamCheck — lightweight abuse signal logging
@@ -58,7 +58,7 @@ const POLL_INTERVAL: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", content = "d")]
pub enum Job {
- /// Transcode an uploaded MP4 to `WebM` (VP9 + Opus) via ffmpeg.
+ /// Transcode an uploaded MP4/MKV to `WebM` (VP9 + Opus) via ffmpeg.
VideoTranscode {
post_id: i64,
/// Path relative to `CONFIG.upload_dir`, e.g. "b/abc123.mp4"
@@ -637,7 +637,7 @@ async fn transcode_video(
if !ffmpeg_vp9_available {
warn!(
"VideoTranscode skipped for post {post_id}: libvpx-vp9 or libopus not available. \
- Install ffmpeg with VP9 + Opus support to enable MP4→WebM transcoding."
+ Install ffmpeg with VP9 + Opus support to enable MP4/MKV→WebM transcoding."
);
return Ok(());
}
@@ -736,7 +736,7 @@ fn transcode_video_prepare(
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
- if ext != "mp4" && ext != "webm" {
+ if ext != "mp4" && ext != "webm" && ext != "mkv" {
debug!("VideoTranscode: skipping unrecognised extension {file_path}");
return Ok(None);
}
@@ -1570,6 +1570,7 @@ mod tests {
#[test]
fn webm_reencode_uses_distinct_output_name() {
assert_eq!(transcoded_webm_name("clip", "mp4"), "clip.webm");
+ assert_eq!(transcoded_webm_name("clip", "mkv"), "clip.webm");
assert_eq!(transcoded_webm_name("clip", "webm"), "clip.vp9.webm");
}
diff --git a/static/admin.css b/static/admin.css
index 0df2eee4..4813ae47 100644
--- a/static/admin.css
+++ b/static/admin.css
@@ -663,7 +663,7 @@ html:not([data-theme]) .admin-panel h1 {
.theme-editor-css-panel textarea {
width: 100%;
min-height: 22rem;
- resize: vertical;
+ resize: none;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.8rem;
line-height: 1.55;
diff --git a/static/main.js b/static/main.js
index b8b7eeed..234b9143 100644
--- a/static/main.js
+++ b/static/main.js
@@ -1453,7 +1453,7 @@ window.requestConfirmation = requestConfirmation;
var type = file.type || '';
var ext = fileExtension(file);
if (type.indexOf('image/') === 0 || /^(jpe?g|png|gif|webp|heic|heif|bmp|tiff?)$/i.test(ext)) return 'image';
- if (type.indexOf('video/') === 0 || /^(mp4|webm)$/i.test(ext)) return 'video';
+ if (type.indexOf('video/') === 0 || /^(mp4|webm|mkv)$/i.test(ext)) return 'video';
return '';
}
diff --git a/static/style.css b/static/style.css
index 5d4e25fb..85a2bc40 100644
--- a/static/style.css
+++ b/static/style.css
@@ -95,6 +95,7 @@ textarea:focus-visible,
outline: 2px solid color-mix(in srgb, var(--border-glow) 78%, white 12%);
outline-offset: 2px;
}
+textarea { resize: none; }
html:not([data-theme]) a:hover { text-shadow: 0 0 6px var(--green-bright); }
html:not([data-theme]) .op,
@@ -1049,7 +1050,6 @@ html.no-js .post-form-wrap {
font-size: 0.85rem;
width: 100%;
outline: none;
- resize: vertical;
min-height: 36px;
border-radius: var(--radius-sm);
transition: border-color 0.2s, box-shadow 0.2s;
From d7e1e4b5f1aedb0fad6dc2bafba1983f3a33be4e Mon Sep 17 00:00:00 2001
From: csd113
Date: Fri, 29 May 2026 17:53:28 -0700
Subject: [PATCH 05/26] Fix onion admin origin handling and Tor logging
---
src/config.rs | 10 ++
src/detect.rs | 78 +++++++++-------
src/handlers/admin/auth.rs | 53 +++++++++++
src/handlers/admin/mod.rs | 183 +++++++++++++++++++++++++++++++++----
4 files changed, 275 insertions(+), 49 deletions(-)
diff --git a/src/config.rs b/src/config.rs
index b1e1b95d..51f8bfb6 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1946,14 +1946,24 @@ port = 8080
let parsed = super::parse_settings_file_str(&template).expect("parse generated template");
assert_eq!(parsed.cookie_secret.as_deref(), Some(secret.as_str()));
assert_eq!(parsed.enable_tor_support, Some(true));
+ assert_eq!(parsed.tor_only, Some(false));
+ assert!(parsed.public_hosts.is_none());
assert_eq!(parsed.tls.as_ref().map(|tls| tls.enabled), Some(false));
assert_eq!(parsed.tls.as_ref().map(|tls| tls.port), Some(8443));
+ assert_eq!(
+ parsed.tls.as_ref().map(|tls| tls.redirect_http),
+ Some(false)
+ );
+ assert!(template.contains("runtime/tor/state/keystore/"));
let reloaded = Config::from_env();
assert_eq!(reloaded.cookie_secret, secret);
assert!(reloaded.enable_tor_support);
+ assert!(!reloaded.tor_only);
+ assert!(reloaded.public_hosts.is_empty());
assert!(!reloaded.tls.enabled);
assert_eq!(reloaded.tls.port, 8443);
+ assert!(!reloaded.tls.redirect_http);
match previous {
Some(contents) => std::fs::write(&path, contents).expect("restore settings file"),
diff --git a/src/detect.rs b/src/detect.rs
index e0e9033f..ba8e0b5b 100644
--- a/src/detect.rs
+++ b/src/detect.rs
@@ -463,6 +463,7 @@ async fn run_arti(
target: "rustchan::detect",
cache_dir = %crate::config::runtime_tor_cache_dir().display(),
state_dir = %crate::config::runtime_tor_state_dir().display(),
+ key_dir = %crate::config::runtime_tor_hidden_service_keys_dir().display(),
"Tor: bootstrapping — first run downloads ~2 MB of directory data"
);
@@ -495,6 +496,12 @@ async fn run_arti(
//
// "rustchan") so operators running multiple instances with a shared
// runtime/tor/state/ directory can assign distinct names and avoid key collisions.
+ tracing::info!(
+ target: "rustchan::detect",
+ nickname = %crate::config::CONFIG.tor_service_nickname,
+ key_dir = %crate::config::runtime_tor_hidden_service_keys_dir().display(),
+ "Tor: launching onion service"
+ );
let svc_config = OnionServiceConfigBuilder::default()
.nickname(crate::config::CONFIG.tor_service_nickname.parse()?)
.build()?;
@@ -520,9 +527,6 @@ async fn run_arti(
found.ok_or("onion_address() still None after 5 s — service key unavailable")?
};
let onion_name = hsid_to_onion_address(hsid);
- publish_onion_address(&onion_name, &onion_address).await;
-
- let mut stream_requests = handle_rend_requests(rend_requests);
// F-05: Cap concurrent proxy tasks to prevent file-descriptor exhaustion
// under a connection flood. Excess connections are dropped (Arti sends
@@ -537,34 +541,20 @@ async fn run_arti(
.into_boxed_str(),
);
+ tracing::info!(
+ target: "rustchan::detect",
+ local_target = %local_addr.as_ref(),
+ max_streams,
+ "Tor: onion service ready and forwarding to local HTTP server"
+ );
+ publish_onion_address(&onion_name, &onion_address).await;
+
+ let mut stream_requests = handle_rend_requests(rend_requests);
+
while let Some(stream_req) = stream_requests.next().await {
match std::sync::Arc::clone(&sem).try_acquire_owned() {
Ok(permit) => {
- let addr = Arc::clone(&local_addr);
- tokio::spawn(async move {
- let _permit = permit; // released on drop
- // unreachable) from normal stream closure (client disconnect,
- // EOF). Infrastructure errors go to ERROR; everything else
- // stays at DEBUG so the log isn't flooded by normal traffic.
- if let Err(e) = proxy_tor_stream(stream_req, &addr).await {
- let msg = e.to_string();
- if msg.contains("local TCP connect failed")
- || msg.contains("timed out connecting to local HTTP server")
- {
- tracing::error!(
- target: "rustchan::detect",
- error = %e,
- "Tor: cannot reach local HTTP server — is axum running?"
- );
- } else {
- tracing::debug!(
- target: "rustchan::detect",
- error = %e,
- "Tor: stream closed"
- );
- }
- }
- });
+ spawn_tor_stream_proxy(stream_req, permit, Arc::clone(&local_addr));
}
Err(_) => {
tracing::warn!(
@@ -622,13 +612,39 @@ async fn publish_onion_address(onion_name: &str, onion_address: &RwLock,
+) {
+ tokio::spawn(async move {
+ let _permit = permit; // released on drop
+ if let Err(e) = proxy_tor_stream(stream_req, &local_addr).await {
+ let msg = e.to_string();
+ if msg.contains("local TCP connect failed")
+ || msg.contains("timed out connecting to local HTTP server")
+ {
+ tracing::error!(
+ target: "rustchan::detect",
+ error = %e,
+ "Tor: cannot reach local HTTP server — is axum running?"
+ );
+ } else {
+ tracing::debug!(
+ target: "rustchan::detect",
+ error = %e,
+ "Tor: stream closed"
+ );
+ }
+ }
+ });
+}
+
// ─── Connection proxy ─────────────────────────────────────────────────────────
async fn proxy_tor_stream(
diff --git a/src/handlers/admin/auth.rs b/src/handlers/admin/auth.rs
index 039c6421..a4cc816d 100644
--- a/src/handlers/admin/auth.rs
+++ b/src/handlers/admin/auth.rs
@@ -428,6 +428,9 @@ mod tests {
const TEST_CSRF_COOKIE: &str = "csrf123";
const TEST_ADMIN_ORIGIN: &str = "http://localhost";
+ const TEST_ONION_HOST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion";
+ const TEST_ONION_ORIGIN: &str =
+ "http://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion";
fn signed_admin_csrf() -> String {
make_scoped_csrf_form_token(
@@ -835,6 +838,56 @@ mod tests {
assert!(location.starts_with("/admin/panel?bootstrap="));
}
+ #[tokio::test]
+ async fn admin_login_over_onion_http_sets_insecure_session_cookie() {
+ let state = crate::test_support::app_state();
+ {
+ let conn = state.db.get().expect("db connection");
+ let password_hash =
+ crate::utils::crypto::hash_password("hunter2").expect("hash password");
+ crate::db::create_admin(&conn, "admin", &password_hash).expect("create admin");
+ crate::db::create_board(&conn, "test", "Test", "", false).expect("create board");
+ }
+
+ let router = Router::new()
+ .route("/admin/login", post(super::admin_login))
+ .with_state(state);
+ let response = router
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri("/admin/login")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::HOST, TEST_ONION_HOST)
+ .header(header::ORIGIN, TEST_ONION_ORIGIN)
+ .header(header::COOKIE, "csrf_token=csrf123")
+ .extension(crate::test_support::connect_info())
+ .body(Body::from(format!(
+ "username=admin&password=hunter2&_csrf={}",
+ signed_admin_csrf()
+ )))
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::SEE_OTHER);
+ let session_cookie = response
+ .headers()
+ .get_all(header::SET_COOKIE)
+ .iter()
+ .filter_map(|value| value.to_str().ok())
+ .find(|value| value.contains(super::super::SESSION_COOKIE))
+ .expect("session cookie");
+ assert!(!session_cookie.contains("; Secure"));
+ let location = response
+ .headers()
+ .get(header::LOCATION)
+ .and_then(|value| value.to_str().ok())
+ .expect("location header");
+ assert!(location.starts_with("/admin/panel?bootstrap="));
+ }
+
#[tokio::test]
async fn admin_login_rejects_raw_readable_csrf_cookie_without_signed_form_token() {
let state = crate::test_support::app_state();
diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs
index eeceeedf..4d891817 100644
--- a/src/handlers/admin/mod.rs
+++ b/src/handlers/admin/mod.rs
@@ -114,15 +114,7 @@ pub(super) fn require_same_origin_request(
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::().ok())
.ok_or_else(|| AppError::Forbidden("Missing Host header.".into()))?;
- let request_scheme =
- if crate::middleware::forwarded_proto_is_https(headers, peer, CONFIG.behind_proxy)
- || (CONFIG.tls.enabled && host_header_uses_https_port(headers))
- || request_origin_uses_https(headers)
- {
- "https"
- } else {
- "http"
- };
+ let request_scheme = request_scheme_for_same_origin(headers, peer, request_authority.host());
let request_port = request_authority
.port_u16()
.unwrap_or(if request_scheme == "https" { 443 } else { 80 });
@@ -333,7 +325,42 @@ fn should_set_secure_cookie_with_config(
|| crate::middleware::forwarded_proto_is_https(headers, context.peer, behind_proxy))
}
-fn host_header_uses_https_port(headers: &HeaderMap) -> bool {
+fn request_scheme_for_same_origin(
+ headers: &HeaderMap,
+ peer: Option,
+ request_host: &str,
+) -> &'static str {
+ request_scheme_for_same_origin_with_config(
+ headers,
+ peer,
+ request_host,
+ CONFIG.behind_proxy,
+ CONFIG.tls.enabled,
+ CONFIG.tls.port,
+ )
+}
+
+fn request_scheme_for_same_origin_with_config(
+ headers: &HeaderMap,
+ peer: Option,
+ request_host: &str,
+ behind_proxy: bool,
+ tls_enabled: bool,
+ tls_port: u16,
+) -> &'static str {
+ if crate::middleware::forwarded_proto_is_https(headers, peer, behind_proxy)
+ || request_origin_uses_https(headers)
+ || (tls_enabled
+ && !is_onion_host(request_host)
+ && host_header_uses_https_port_with_config(headers, tls_port))
+ {
+ "https"
+ } else {
+ "http"
+ }
+}
+
+fn host_header_uses_https_port_with_config(headers: &HeaderMap, tls_port: u16) -> bool {
let Some(host) = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
@@ -346,8 +373,8 @@ fn host_header_uses_https_port(headers: &HeaderMap) -> bool {
};
match authority.port_u16() {
- Some(port) => port == CONFIG.tls.port,
- None => CONFIG.tls.port == 443,
+ Some(port) => port == tls_port,
+ None => tls_port == 443,
}
}
@@ -406,6 +433,15 @@ fn normalize_same_origin_host(host: &str) -> &str {
}
}
+fn is_onion_host(host: &str) -> bool {
+ let host = normalize_same_origin_host(host);
+ let Some((label, suffix)) = host.rsplit_once('.') else {
+ return false;
+ };
+
+ !label.is_empty() && suffix.eq_ignore_ascii_case("onion")
+}
+
fn is_loopback_alias(host: &str) -> bool {
let host = normalize_same_origin_host(host);
@@ -1653,10 +1689,11 @@ pub(super) fn consume_admin_session_bootstrap(token: &str) -> Option {
mod tests {
use super::{
admin_live_log, admin_site_health_jobs, consume_admin_session_bootstrap,
- create_admin_session_bootstrap, host_header_uses_https_port, hosts_match_for_same_origin,
- latest_log_file, read_log_tail, request_origin_uses_https,
- require_same_origin_or_valid_csrf, require_same_origin_request,
- should_set_secure_cookie_with_config, LiveLogQuery, SESSION_COOKIE,
+ create_admin_session_bootstrap, host_header_uses_https_port_with_config,
+ hosts_match_for_same_origin, latest_log_file, read_log_tail, request_origin_uses_https,
+ request_scheme_for_same_origin_with_config, require_same_origin_or_valid_csrf,
+ require_same_origin_request, should_set_secure_cookie_with_config, LiveLogQuery,
+ SESSION_COOKIE,
};
use crate::error::AppError;
use crate::middleware::SecureCookieContext;
@@ -1667,6 +1704,12 @@ mod tests {
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
+ const TEST_ONION_HOST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion";
+ const TEST_ONION_ORIGIN: &str =
+ "http://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion";
+ const TEST_ONION_HTTPS_ORIGIN: &str =
+ "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion";
+
fn same_origin_headers(host: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
@@ -1737,6 +1780,77 @@ mod tests {
assert!(require_same_origin_request(&headers, None).is_ok());
}
+ #[test]
+ fn same_origin_request_accepts_valid_onion_http_origin() {
+ let mut headers = same_origin_headers(TEST_ONION_HOST);
+ headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN));
+
+ assert!(require_same_origin_request(&headers, None).is_ok());
+ }
+
+ #[test]
+ fn same_origin_request_accepts_valid_onion_http_referer() {
+ let mut headers = same_origin_headers(TEST_ONION_HOST);
+ headers.insert(
+ header::REFERER,
+ HeaderValue::from_static(concat!(
+ "http://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion",
+ "/admin/panel"
+ )),
+ );
+
+ assert!(require_same_origin_request(&headers, None).is_ok());
+ }
+
+ #[test]
+ fn same_origin_request_rejects_spoofed_onion_origin() {
+ let mut headers = same_origin_headers(TEST_ONION_HOST);
+ headers.insert(
+ header::ORIGIN,
+ HeaderValue::from_static(
+ "http://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbm2dqd.onion",
+ ),
+ );
+
+ assert!(require_same_origin_request(&headers, None).is_err());
+ }
+
+ #[test]
+ fn same_origin_request_keeps_onion_http_when_tls_uses_default_https_port() {
+ let mut headers = same_origin_headers(TEST_ONION_HOST);
+ headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN));
+
+ assert_eq!(
+ request_scheme_for_same_origin_with_config(
+ &headers,
+ None,
+ TEST_ONION_HOST,
+ false,
+ true,
+ 443,
+ ),
+ "http"
+ );
+ assert!(require_same_origin_request(&headers, None).is_ok());
+ }
+
+ #[test]
+ fn same_origin_request_preserves_default_https_port_for_clearnet_hosts() {
+ let headers = same_origin_headers("example.test");
+
+ assert_eq!(
+ request_scheme_for_same_origin_with_config(
+ &headers,
+ None,
+ "example.test",
+ false,
+ true,
+ 443,
+ ),
+ "https"
+ );
+ }
+
#[test]
fn same_origin_request_accepts_missing_origin_and_referer_with_same_origin_fetch_metadata() {
let mut headers = same_origin_headers("demo.serveo.net");
@@ -1928,14 +2042,20 @@ mod tests {
header::HOST,
HeaderValue::from_str(&host).expect("host header"),
);
- assert!(host_header_uses_https_port(&headers));
+ assert!(host_header_uses_https_port_with_config(
+ &headers,
+ crate::config::CONFIG.tls.port
+ ));
}
#[test]
fn http_host_port_does_not_mark_request_secure() {
let mut headers = HeaderMap::new();
headers.insert(header::HOST, HeaderValue::from_static("example.test:8080"));
- assert!(!host_header_uses_https_port(&headers));
+ assert!(!host_header_uses_https_port_with_config(
+ &headers,
+ crate::config::CONFIG.tls.port
+ ));
}
#[test]
@@ -1992,6 +2112,33 @@ mod tests {
));
}
+ #[test]
+ fn secure_cookie_decision_keeps_onion_plain_http_cookie_insecure() {
+ let mut headers = same_origin_headers(TEST_ONION_HOST);
+ headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN));
+ let context =
+ SecureCookieContext::new(Some("127.0.0.1:41000".parse().expect("peer")), false);
+
+ assert!(!should_set_secure_cookie_with_config(
+ &headers, context, true, false,
+ ));
+ }
+
+ #[test]
+ fn secure_cookie_decision_marks_direct_onion_https_cookie_secure() {
+ let mut headers = same_origin_headers(TEST_ONION_HOST);
+ headers.insert(
+ header::ORIGIN,
+ HeaderValue::from_static(TEST_ONION_HTTPS_ORIGIN),
+ );
+ let context =
+ SecureCookieContext::new(Some("127.0.0.1:41000".parse().expect("peer")), true);
+
+ assert!(should_set_secure_cookie_with_config(
+ &headers, context, true, false,
+ ));
+ }
+
#[test]
fn secure_cookie_decision_requires_trusted_proxy_for_forwarded_proto() {
let mut headers = HeaderMap::new();
From 618b5718cd0b954d12e46a52caa19e4a86184639 Mon Sep 17 00:00:00 2001
From: csd113
Date: Fri, 29 May 2026 17:55:32 -0700
Subject: [PATCH 06/26] Update locked Rust dependencies
---
Cargo.lock | 36 ++++++++++++++++++------------------
1 file changed, 18 insertions(+), 18 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index c8204f1b..29b5054d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -807,9 +807,9 @@ checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101"
[[package]]
name = "cc"
-version = "1.2.62"
+version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2315,9 +2315,9 @@ dependencies = [
[[package]]
name = "hyper"
-version = "1.10.0"
+version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
@@ -2949,9 +2949,9 @@ dependencies = [
[[package]]
name = "mio"
-version = "1.2.0"
+version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
@@ -4561,9 +4561,9 @@ dependencies = [
[[package]]
name = "shlex"
-version = "1.3.0"
+version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
@@ -4671,9 +4671,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
-version = "0.6.3"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
@@ -6449,9 +6449,9 @@ checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
-version = "1.20.0"
+version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uncased"
@@ -6524,9 +6524,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.23.1"
+version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
+checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -7312,18 +7312,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.49"
+version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b"
+checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.49"
+version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e"
+checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
From 521e77220c5df46cdcefe1b37a8a96665ebf1cef Mon Sep 17 00:00:00 2001
From: csd113
Date: Sat, 30 May 2026 17:28:05 -0700
Subject: [PATCH 07/26] Add Tor address copy button to footer
---
src/templates/board.rs | 32 +++++++++++++++-
static/main.js | 83 ++++++++++++++++++++++++++++++++++++++++++
static/style.css | 56 ++++++++++++++++++++++++++++
3 files changed, 169 insertions(+), 2 deletions(-)
diff --git a/src/templates/board.rs b/src/templates/board.rs
index 8ef3d66f..382536cd 100644
--- a/src/templates/board.rs
+++ b/src/templates/board.rs
@@ -712,10 +712,10 @@ pub fn index_page(
let mut access_links = String::new();
if let Some(addr) = onion_address {
+ let escaped_addr = escape_html(addr);
let _ = write!(
access_links,
- r#"{}
"#,
- escape_html(addr)
+ r#"{escaped_addr}Copy
"#
);
}
let onion_html = if access_links.is_empty() {
@@ -1832,6 +1832,34 @@ mod tests {
assert!(html.contains("2.00 GB"));
}
+ #[test]
+ fn index_page_renders_tor_copy_button_as_js_enhancement() {
+ let address = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion";
+ let html = index_page(
+ &[],
+ None,
+ "csrf",
+ None,
+ Some(address),
+ "",
+ &HashMap::new(),
+ &HashMap::new(),
+ None,
+ None,
+ true,
+ false,
+ crate::templates::UserPreferences::default(),
+ );
+
+ assert!(html.contains(r#"aaaaaaaa"#));
+ assert!(html.contains(r#"Copy "#));
+ assert!(html.contains(r#" "#));
+ }
+
#[test]
fn catalog_page_renders_componentized_card_with_state_badges() {
let board = sample_board();
diff --git a/static/main.js b/static/main.js
index 234b9143..dd0622ff 100644
--- a/static/main.js
+++ b/static/main.js
@@ -102,6 +102,89 @@ function applyQueuedPostSubmitAnchor() {
}
}
+// ─── Tor address copy control ────────────────────────────────────────────────
+
+function copyTextWithTextareaFallback(text) {
+ return new Promise(function (resolve, reject) {
+ var textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'fixed';
+ textarea.style.top = '0';
+ textarea.style.left = '0';
+ textarea.style.width = '1px';
+ textarea.style.height = '1px';
+ textarea.style.opacity = '0';
+ document.body.appendChild(textarea);
+ textarea.focus();
+ textarea.select();
+ textarea.setSelectionRange(0, textarea.value.length);
+
+ var copied = false;
+ try {
+ copied = document.execCommand('copy');
+ } catch (e) {
+ copied = false;
+ }
+ textarea.remove();
+
+ if (copied) {
+ resolve();
+ } else {
+ reject(new Error('copy command failed'));
+ }
+ });
+}
+
+function copyTextToClipboard(text) {
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
+ return navigator.clipboard.writeText(text).catch(function () {
+ return copyTextWithTextareaFallback(text);
+ });
+ }
+ return copyTextWithTextareaFallback(text);
+}
+
+function initTorCopyButtons(root) {
+ (root || document).querySelectorAll('.tor-copy-button').forEach(function (button) {
+ var address = button.dataset.torAddress;
+ if (!address) {
+ var addressEl = button.parentNode && button.parentNode.querySelector('.onion-addr');
+ address = addressEl ? addressEl.textContent.trim() : '';
+ }
+ if (!address) return;
+
+ var status = button.parentNode.querySelector('.tor-copy-status');
+ var defaultText = button.textContent;
+ var resetTimer = null;
+ button.hidden = false;
+
+ button.addEventListener('click', function () {
+ copyTextToClipboard(address).then(function () {
+ button.textContent = 'Copied';
+ button.classList.add('is-copied');
+ if (status) status.textContent = 'Copied Tor address';
+ window.clearTimeout(resetTimer);
+ resetTimer = window.setTimeout(function () {
+ button.textContent = defaultText;
+ button.classList.remove('is-copied');
+ if (status) status.textContent = '';
+ }, 1800);
+ }).catch(function () {
+ button.textContent = 'Error';
+ if (status) status.textContent = 'Copy failed';
+ window.clearTimeout(resetTimer);
+ resetTimer = window.setTimeout(function () {
+ button.textContent = defaultText;
+ if (status) status.textContent = '';
+ }, 2200);
+ });
+ });
+ });
+}
+
+initTorCopyButtons(document);
+
// ─── Localize post timestamps to device timezone ──────────────────────────────
function padTwoDigits(value) {
diff --git a/static/style.css b/static/style.css
index 85a2bc40..d8238eea 100644
--- a/static/style.css
+++ b/static/style.css
@@ -426,20 +426,76 @@ html:not([data-theme]) .site-name { text-shadow: 0 0 14px var(--green-bright); }
padding: 0.5rem 0 0.25rem;
}
.index-onion {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem 0.35rem;
color: var(--text-dim);
font-size: 0.82rem;
margin: 0;
+ overflow-wrap: anywhere;
}
.onion-addr {
color: var(--green-pale);
user-select: all;
font-size: 0.82rem;
display: inline-block;
+ min-width: 0;
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
+.index-onion .tor-copy-button[hidden] {
+ display: none;
+}
+.index-onion .tor-copy-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 4.8em;
+ min-height: 24px;
+ margin: 0;
+ padding: 2px 7px;
+ vertical-align: middle;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-dim);
+ box-shadow: none;
+ font-size: 0.72rem;
+ line-height: 1;
+ letter-spacing: 0;
+ white-space: nowrap;
+}
+.index-onion .tor-copy-button:hover,
+.index-onion .tor-copy-button:focus-visible {
+ border-color: var(--green-dim);
+ background: color-mix(in srgb, var(--green-dim) 12%, transparent);
+ color: var(--green-pale);
+ box-shadow: none;
+ text-shadow: none;
+}
+.index-onion .tor-copy-button.is-copied {
+ color: var(--green-pale);
+}
+.tor-copy-status {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+@media (max-width: 700px) {
+ .index-onion .tor-copy-button {
+ min-height: 32px;
+ padding: 4px 8px;
+ }
+}
/* ── Home button ──────────────────────────────────────────────────────────── */
From c1cf7149649fa3d0da5255e81f3aa88f5bb0f5dc Mon Sep 17 00:00:00 2001
From: csd113
Date: Sat, 30 May 2026 20:24:39 -0700
Subject: [PATCH 08/26] Add initial setup wizard and DB support
Introduce a first-run / reopenable setup wizard and persistent setup state.
- Add db/setup.rs: tracks setup state, markers (complete/reopen), helpers to reopen/mark complete, and tests.
- Add handlers/setup.rs: full setup wizard endpoints (GET/review/finish) with form parsing, validation, CSRF, and DB writes to create initial board/admin and apply site settings.
- Extend config: add behind_proxy and https_cookies options and a SetupRuntimeSettingsUpdate + update_settings_file_setup_runtime helper to persist runtime toggles.
- Wire setup into handlers and admin UI: expose setup module, include setup_status in admin snapshot/templates, and add admin endpoint to reopen setup.
- Minor visibility changes for admin session/CSRF helper functions and adjustments to static/admin.css/templates to support the UI.
These changes enable guided initial configuration, allow admins to reopen the wizard safely, and persist runtime-related settings to the config file.
---
src/config.rs | 39 +-
src/db/mod.rs | 2 +
src/db/setup.rs | 189 ++++
src/handlers/admin/mod.rs | 27 +-
src/handlers/mod.rs | 1 +
src/handlers/setup.rs | 1318 ++++++++++++++++++++++++++++
src/server/server/routes.rs | 7 +
src/templates/admin.rs | 17 +-
src/templates/admin/maintenance.rs | 43 +
static/admin.css | 83 ++
10 files changed, 1719 insertions(+), 7 deletions(-)
create mode 100644 src/db/setup.rs
create mode 100644 src/handlers/setup.rs
diff --git a/src/config.rs b/src/config.rs
index 51f8bfb6..e6f2ea0d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -210,6 +210,8 @@ struct SettingsFile {
max_video_size_mb: Option,
max_audio_size_mb: Option,
cookie_secret: Option,
+ behind_proxy: Option,
+ https_cookies: Option,
enable_tor_support: Option,
/// When true, the HTTP server binds exclusively to 127.0.0.1 so it is
/// reachable only through the Tor hidden service. Overrides the host
@@ -694,7 +696,7 @@ impl Config {
} else {
bind_addr
};
- let behind_proxy = env_bool("CHAN_BEHIND_PROXY", false);
+ let behind_proxy = env_bool("CHAN_BEHIND_PROXY", s.behind_proxy.unwrap_or(false));
let https_cookies_default = behind_proxy || tls.enabled;
let trusted_proxy_cidrs = env_list(
"CHAN_TRUSTED_PROXY_CIDRS",
@@ -802,7 +804,10 @@ impl Config {
session_duration: env_parse("CHAN_SESSION_SECS", 8 * 3600),
behind_proxy,
trusted_proxy_cidrs,
- https_cookies: env_bool("CHAN_HTTPS_COOKIES", https_cookies_default),
+ https_cookies: env_bool(
+ "CHAN_HTTPS_COOKIES",
+ s.https_cookies.unwrap_or(https_cookies_default),
+ ),
public_hosts,
wal_checkpoint_interval: env_parse(
"CHAN_WAL_CHECKPOINT_SECS",
@@ -1310,6 +1315,36 @@ pub fn update_settings_file_auto_full_backup(
);
}
+#[derive(Clone, Copy)]
+#[expect(
+ clippy::struct_excessive_bools,
+ reason = "settings update mirrors independent runtime setup toggles"
+)]
+pub struct SetupRuntimeSettingsUpdate {
+ pub enable_tor_support: bool,
+ pub tor_only: bool,
+ pub behind_proxy: bool,
+ pub https_cookies: bool,
+ pub max_image_size_mb: u64,
+ pub max_video_size_mb: u64,
+ pub max_audio_size_mb: u64,
+}
+
+pub fn update_settings_file_setup_runtime(update: SetupRuntimeSettingsUpdate) {
+ update_settings_file_entries(
+ &[
+ ("enable_tor_support", update.enable_tor_support.to_string()),
+ ("tor_only", update.tor_only.to_string()),
+ ("behind_proxy", update.behind_proxy.to_string()),
+ ("https_cookies", update.https_cookies.to_string()),
+ ("max_image_size_mb", update.max_image_size_mb.to_string()),
+ ("max_video_size_mb", update.max_video_size_mb.to_string()),
+ ("max_audio_size_mb", update.max_audio_size_mb.to_string()),
+ ],
+ Some("# Optional explicit ffmpeg binary path."),
+ );
+}
+
pub fn update_settings_file_ffmpeg_timeout(timeout_secs: u64) {
let timeout_secs = timeout_secs.clamp(MIN_FFMPEG_TIMEOUT_SECS, MAX_FFMPEG_TIMEOUT_SECS);
update_settings_file_entries(
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 66c5cde2..d2d27b42 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -14,6 +14,7 @@ mod migrations;
mod pool;
pub mod posts;
mod schema;
+pub mod setup;
pub mod themes;
pub mod threads;
mod types;
@@ -30,6 +31,7 @@ pub use banners::*;
pub use boards::*;
pub use fs_ops::*;
pub use posts::*;
+pub use setup::*;
pub use themes::*;
pub use threads::*;
pub use user_thread_prefs::*;
diff --git a/src/db/setup.rs b/src/db/setup.rs
new file mode 100644
index 00000000..1e70f50a
--- /dev/null
+++ b/src/db/setup.rs
@@ -0,0 +1,189 @@
+use anyhow::{Context as _, Result};
+use rusqlite::{params, OptionalExtension as _};
+
+pub const SETUP_COMPLETED_AT_KEY: &str = "setup_completed_at";
+pub const SETUP_REOPENED_AT_KEY: &str = "setup_reopened_at";
+pub const SETUP_REOPENED_BY_KEY: &str = "setup_reopened_by";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SetupAccess {
+ Fresh,
+ Reopened,
+ Initialized,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SetupState {
+ pub access: SetupAccess,
+ pub admin_count: i64,
+ pub board_count: i64,
+ pub completed: bool,
+ pub reopened: bool,
+}
+
+impl SetupState {
+ #[must_use]
+ pub const fn is_available(self) -> bool {
+ matches!(self.access, SetupAccess::Fresh | SetupAccess::Reopened)
+ }
+
+ #[must_use]
+ pub const fn requires_admin_auth(self) -> bool {
+ matches!(self.access, SetupAccess::Reopened) || self.admin_count > 0
+ }
+}
+
+/// # Errors
+/// Returns an error if setup state cannot be loaded.
+pub fn setup_state(conn: &rusqlite::Connection) -> Result {
+ let completed = setup_flag(conn, SETUP_COMPLETED_AT_KEY)?;
+ let reopened = setup_flag(conn, SETUP_REOPENED_AT_KEY)?;
+ let admin_count = table_count(conn, "admin_users")?;
+ let board_count = table_count(conn, "boards")?;
+ let access = if reopened {
+ SetupAccess::Reopened
+ } else if completed || admin_count > 0 {
+ SetupAccess::Initialized
+ } else {
+ SetupAccess::Fresh
+ };
+ Ok(SetupState {
+ access,
+ admin_count,
+ board_count,
+ completed,
+ reopened,
+ })
+}
+
+fn setup_flag(conn: &rusqlite::Connection, key: &str) -> Result {
+ let value = super::get_site_setting(conn, key)?;
+ Ok(value
+ .as_deref()
+ .is_some_and(|value| !value.trim().is_empty()))
+}
+
+fn table_count(conn: &rusqlite::Connection, table: &str) -> Result {
+ let sql = match table {
+ "admin_users" => "SELECT COUNT(*) FROM admin_users",
+ "boards" => "SELECT COUNT(*) FROM boards",
+ _ => anyhow::bail!("unsupported setup count table"),
+ };
+ conn.query_row(sql, [], |row| row.get(0))
+ .context("count setup state table")
+}
+
+/// # Errors
+/// Returns an error if setup is unavailable.
+pub fn ensure_setup_available(conn: &rusqlite::Connection) -> Result {
+ let state = setup_state(conn)?;
+ if state.is_available() {
+ Ok(state)
+ } else {
+ anyhow::bail!("setup wizard is not available on initialized instances")
+ }
+}
+
+/// # Errors
+/// Returns an error if the reopen marker cannot be persisted.
+pub fn reopen_setup(conn: &rusqlite::Connection, admin_id: i64) -> Result<()> {
+ let now = chrono::Utc::now().timestamp().to_string();
+ super::set_site_setting(conn, SETUP_REOPENED_AT_KEY, &now)?;
+ super::set_site_setting(conn, SETUP_REOPENED_BY_KEY, &admin_id.to_string())?;
+ Ok(())
+}
+
+/// # Errors
+/// Returns an error if the setup completion marker cannot be persisted.
+pub fn mark_setup_complete(conn: &rusqlite::Connection) -> Result<()> {
+ let now = chrono::Utc::now().timestamp().to_string();
+ conn.execute(
+ "INSERT INTO site_settings (key, value) VALUES (?1, ?2)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+ params![SETUP_COMPLETED_AT_KEY, now],
+ )
+ .context("write setup completion marker")?;
+ conn.execute(
+ "DELETE FROM site_settings WHERE key IN (?1, ?2)",
+ params![SETUP_REOPENED_AT_KEY, SETUP_REOPENED_BY_KEY],
+ )
+ .context("clear setup reopen marker")?;
+ Ok(())
+}
+
+/// # Errors
+/// Returns an error if the admin count query fails.
+pub fn admin_count(conn: &rusqlite::Connection) -> Result {
+ table_count(conn, "admin_users")
+}
+
+/// # Errors
+/// Returns an error if the board lookup fails.
+pub fn board_slug_exists(conn: &rusqlite::Connection, slug: &str) -> Result {
+ conn.query_row(
+ "SELECT 1 FROM boards WHERE short_name = ?1 LIMIT 1",
+ params![slug],
+ |_row| Ok(()),
+ )
+ .optional()
+ .context("check board slug conflict")
+ .map(|row| row.is_some())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn fresh_database_allows_setup() {
+ let pool = crate::db::init_test_pool().expect("pool");
+ let conn = pool.get().expect("conn");
+
+ let state = setup_state(&conn).expect("state");
+
+ assert_eq!(state.access, SetupAccess::Fresh);
+ assert!(state.is_available());
+ assert!(!state.requires_admin_auth());
+ }
+
+ #[test]
+ fn admin_without_marker_blocks_setup_as_initialized() {
+ let pool = crate::db::init_test_pool().expect("pool");
+ let conn = pool.get().expect("conn");
+ crate::db::create_admin(&conn, "admin", "hash").expect("admin");
+
+ let state = setup_state(&conn).expect("state");
+
+ assert_eq!(state.access, SetupAccess::Initialized);
+ assert!(!state.is_available());
+ assert!(state.requires_admin_auth());
+ }
+
+ #[test]
+ fn admin_reopen_makes_setup_available_but_admin_authenticated() {
+ let pool = crate::db::init_test_pool().expect("pool");
+ let conn = pool.get().expect("conn");
+ crate::db::create_admin(&conn, "admin", "hash").expect("admin");
+ reopen_setup(&conn, 1).expect("reopen");
+
+ let state = setup_state(&conn).expect("state");
+
+ assert_eq!(state.access, SetupAccess::Reopened);
+ assert!(state.is_available());
+ assert!(state.requires_admin_auth());
+ }
+
+ #[test]
+ fn completion_marker_blocks_setup_after_reopen_is_cleared() {
+ let pool = crate::db::init_test_pool().expect("pool");
+ let conn = pool.get().expect("conn");
+ reopen_setup(&conn, 1).expect("reopen");
+ mark_setup_complete(&conn).expect("complete");
+
+ let state = setup_state(&conn).expect("state");
+
+ assert_eq!(state.access, SetupAccess::Initialized);
+ assert!(state.completed);
+ assert!(!state.reopened);
+ }
+}
diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs
index 4d891817..6e9f622c 100644
--- a/src/handlers/admin/mod.rs
+++ b/src/handlers/admin/mod.rs
@@ -98,7 +98,10 @@ fn require_admin_session_with_name(
/// Check CSRF using the cookie jar. Returns error on mismatch.
/// Verify admin session from a session ID string.
/// For use inside `spawn_blocking` closures where we have an open connection.
-fn require_admin_session_sid(conn: &rusqlite::Connection, session_id: Option<&str>) -> Result {
+pub(in crate::handlers) fn require_admin_session_sid(
+ conn: &rusqlite::Connection,
+ session_id: Option<&str>,
+) -> Result {
let sid = session_id.ok_or_else(|| AppError::Forbidden("Not logged in.".into()))?;
let session = db::get_session(conn, sid)?
.ok_or_else(|| AppError::Forbidden("Session expired or invalid.".into()))?;
@@ -228,7 +231,7 @@ pub(super) fn admin_csrf_is_valid(jar: &CookieJar, form_token: Option<&str>) ->
validate_signed_csrf(csrf_cookie, session_id, form_token.unwrap_or(""))
}
-pub(super) fn require_same_origin_or_valid_csrf(
+pub(in crate::handlers) fn require_same_origin_or_valid_csrf(
headers: &HeaderMap,
peer: Option,
csrf_valid: bool,
@@ -246,7 +249,7 @@ pub(super) fn require_same_origin_or_valid_csrf(
}
}
-pub(super) fn require_admin_post_origin_and_csrf(
+pub(in crate::handlers) fn require_admin_post_origin_and_csrf(
jar: &CookieJar,
headers: &HeaderMap,
peer: Option,
@@ -607,6 +610,7 @@ struct AdminPanelSnapshot {
board_backups: Vec,
db_size_bytes: i64,
db_size_warning: bool,
+ setup_status: crate::templates::AdminPanelSetupStatus,
ffmpeg_timeout_secs: u64,
media_auto_prune_enabled: bool,
media_max_active_content_size_bytes: u64,
@@ -1132,6 +1136,7 @@ fn load_admin_panel_snapshot(
let backups_domain = load_backups_domain_data();
let overview_domain = load_overview_domain_data(&backups_domain.full_backups);
let maintenance_domain = load_maintenance_domain_data(conn, state);
+ let setup_state = db::setup_state(conn)?;
let site_health = load_site_health_snapshot(
conn,
state,
@@ -1170,6 +1175,7 @@ fn load_admin_panel_snapshot(
board_backups: backups_domain.board_backups,
db_size_bytes: maintenance_domain.db_size_bytes,
db_size_warning: maintenance_domain.db_size_warning,
+ setup_status: admin_panel_setup_status(setup_state),
ffmpeg_timeout_secs: maintenance_domain.ffmpeg_timeout_secs,
media_auto_prune_enabled: maintenance_domain.media_auto_prune_enabled,
media_max_active_content_size_bytes: maintenance_domain
@@ -1188,6 +1194,20 @@ fn load_admin_panel_snapshot(
))
}
+const fn admin_panel_setup_status(
+ setup_state: db::SetupState,
+) -> crate::templates::AdminPanelSetupStatus {
+ if setup_state.reopened {
+ crate::templates::AdminPanelSetupStatus::Reopened
+ } else if setup_state.completed {
+ crate::templates::AdminPanelSetupStatus::Complete
+ } else if setup_state.is_available() {
+ crate::templates::AdminPanelSetupStatus::Available
+ } else {
+ crate::templates::AdminPanelSetupStatus::Initialized
+ }
+}
+
fn build_backup_summary(full_backups: &[BackupInfo]) -> BackupSummary {
const BACKUP_WARN_AFTER_HOURS: i64 = 72;
@@ -1292,6 +1312,7 @@ fn render_admin_panel_from_snapshot(
maintenance: crate::templates::AdminPanelMaintenanceView {
db_size_bytes: snapshot.db_size_bytes,
db_size_warning: snapshot.db_size_warning,
+ setup_status: snapshot.setup_status,
ffmpeg_timeout_secs: snapshot.ffmpeg_timeout_secs,
media_auto_prune_enabled: snapshot.media_auto_prune_enabled,
media_max_active_content_size_bytes: snapshot.media_max_active_content_size_bytes,
diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs
index dc6e4ac7..a9834ae8 100644
--- a/src/handlers/mod.rs
+++ b/src/handlers/mod.rs
@@ -7,6 +7,7 @@ pub mod captcha;
pub mod favicon;
pub mod posting;
pub mod render;
+pub mod setup;
pub mod thread;
// ─── Shared multipart form parsing ───────────────────────────────────────────
diff --git a/src/handlers/setup.rs b/src/handlers/setup.rs
new file mode 100644
index 00000000..a5a58efa
--- /dev/null
+++ b/src/handlers/setup.rs
@@ -0,0 +1,1318 @@
+#![allow(clippy::too_many_lines)]
+
+use crate::{
+ config::CONFIG,
+ db,
+ error::{AppError, Result},
+ middleware::AppState,
+ templates,
+ utils::{crypto, sanitize::escape_html},
+};
+use axum::{
+ extract::{Form, Query, State},
+ http::{header, HeaderMap, StatusCode},
+ response::{Html, IntoResponse as _, Redirect, Response},
+};
+use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
+use serde::{Deserialize, Serialize};
+use std::fmt::Write as _;
+
+const SETUP_CSRF_SCOPE: &str = "first-run-setup";
+const MIB: u64 = 1024 * 1024;
+const MIB_I64: i64 = 1024 * 1024;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SetupPreset {
+ Public,
+ Private,
+ Local,
+}
+
+impl SetupPreset {
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::Public => "public",
+ Self::Private => "private",
+ Self::Local => "local",
+ }
+ }
+
+ #[must_use]
+ pub const fn label(self) -> &'static str {
+ match self {
+ Self::Public => "Public instance",
+ Self::Private => "Private instance",
+ Self::Local => "Local/testing",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+#[expect(
+ clippy::struct_excessive_bools,
+ reason = "preset defaults mirror independent setup toggles shown in the form"
+)]
+pub struct PresetDefaults {
+ pub site_name: &'static str,
+ pub board_slug: &'static str,
+ pub board_name: &'static str,
+ pub board_description: &'static str,
+ pub board_visibility: &'static str,
+ pub allow_uploads: bool,
+ pub allow_video: bool,
+ pub allow_audio: bool,
+ pub allow_pdf: bool,
+ pub allow_captcha: bool,
+ pub post_cooldown_secs: i64,
+ pub hide_nsfw_default: bool,
+}
+
+#[must_use]
+pub const fn preset_defaults(preset: SetupPreset) -> PresetDefaults {
+ match preset {
+ SetupPreset::Public => PresetDefaults {
+ site_name: "RustChan",
+ board_slug: "b",
+ board_name: "Random",
+ board_description: "General discussion",
+ board_visibility: "public",
+ allow_uploads: true,
+ allow_video: true,
+ allow_audio: false,
+ allow_pdf: false,
+ allow_captcha: true,
+ post_cooldown_secs: 10,
+ hide_nsfw_default: false,
+ },
+ SetupPreset::Private => PresetDefaults {
+ site_name: "Private RustChan",
+ board_slug: "home",
+ board_name: "Home",
+ board_description: "Private board",
+ board_visibility: "view_password",
+ allow_uploads: true,
+ allow_video: false,
+ allow_audio: false,
+ allow_pdf: false,
+ allow_captcha: false,
+ post_cooldown_secs: 0,
+ hide_nsfw_default: true,
+ },
+ SetupPreset::Local => PresetDefaults {
+ site_name: "Local RustChan",
+ board_slug: "test",
+ board_name: "Testing",
+ board_description: "Local testing board",
+ board_visibility: "public",
+ allow_uploads: true,
+ allow_video: true,
+ allow_audio: true,
+ allow_pdf: true,
+ allow_captcha: false,
+ post_cooldown_secs: 0,
+ hide_nsfw_default: false,
+ },
+ }
+}
+
+fn parse_preset(value: &str) -> SetupPreset {
+ match value.trim() {
+ "private" => SetupPreset::Private,
+ "local" => SetupPreset::Local,
+ _ => SetupPreset::Public,
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SetupWizardForm {
+ #[serde(rename = "_csrf")]
+ pub csrf: Option,
+ pub preset: String,
+ pub site_name: String,
+ pub site_subtitle: Option,
+ pub default_theme: Option,
+ pub admin_username: Option,
+ pub admin_password: Option,
+ pub admin_password_confirm: Option,
+ pub enable_tor: Option,
+ pub tor_only: Option,
+ pub public_url: Option,
+ pub https_cookies: Option,
+ pub behind_proxy: Option,
+ pub board_slug: String,
+ pub board_name: String,
+ pub board_description: Option,
+ pub board_nsfw: Option,
+ pub board_visibility: Option,
+ pub allow_posting: Option,
+ pub allow_uploads: Option,
+ pub allow_video: Option,
+ pub allow_audio: Option,
+ pub allow_pdf: Option,
+ pub allow_video_embeds: Option,
+ pub allow_thread_editing: Option,
+ pub allow_self_delete: Option,
+ pub allow_archive: Option,
+ pub image_limit_mib: String,
+ pub video_limit_mib: String,
+ pub audio_limit_mib: String,
+ pub pdf_limit_mib: String,
+ pub allow_captcha: Option,
+ pub captcha_type: Option,
+ pub post_cooldown_secs: Option,
+ pub homepage_new_thread_badges_enabled: Option,
+ pub homepage_new_reply_badges_enabled: Option,
+ pub thread_new_reply_badges_enabled: Option,
+ pub hide_nsfw_default: Option,
+ pub auto_backup_enabled: Option,
+ pub backup_retention: Option,
+ pub include_tor_keys_in_backups: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+#[expect(
+ clippy::struct_excessive_bools,
+ reason = "parsed setup data carries independent persisted toggles from the review form"
+)]
+pub struct ParsedSetup {
+ pub preset: SetupPreset,
+ pub site_name: String,
+ pub site_subtitle: String,
+ pub default_theme: String,
+ pub admin_username: Option,
+ pub admin_password: Option,
+ pub board_slug: String,
+ pub board_name: String,
+ pub board_description: String,
+ pub board_nsfw: bool,
+ pub board_visibility: String,
+ pub allow_posting: bool,
+ pub allow_uploads: bool,
+ pub allow_video: bool,
+ pub allow_audio: bool,
+ pub allow_pdf: bool,
+ pub allow_video_embeds: bool,
+ pub allow_thread_editing: bool,
+ pub allow_self_delete: bool,
+ pub allow_archive: bool,
+ pub image_limit_bytes: i64,
+ pub video_limit_bytes: i64,
+ pub audio_limit_bytes: i64,
+ pub pdf_limit_bytes: i64,
+ pub allow_captcha: bool,
+ pub captcha_type: String,
+ pub post_cooldown_secs: i64,
+ pub homepage_new_thread_badges_enabled: bool,
+ pub homepage_new_reply_badges_enabled: bool,
+ pub thread_new_reply_badges_enabled: bool,
+ pub hide_nsfw_default: bool,
+ pub enable_tor: bool,
+ pub tor_only: bool,
+ pub public_url: String,
+ pub https_cookies: bool,
+ pub behind_proxy: bool,
+ pub auto_backup_interval_hours: u64,
+ pub backup_retention: u64,
+ pub include_tor_keys_in_backups: bool,
+}
+
+#[must_use]
+pub fn validate_board_slug(raw: &str) -> Option {
+ let slug = raw.trim().to_ascii_lowercase();
+ let valid = !slug.is_empty()
+ && slug.len() <= 8
+ && slug
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit());
+ valid.then_some(slug)
+}
+
+#[must_use]
+pub fn parse_upload_limit_mib(raw: &str) -> Option {
+ let mib = raw.trim().parse::().ok()?;
+ if !(1..=4096).contains(&mib) {
+ return None;
+ }
+ i64::try_from(mib.saturating_mul(MIB)).ok()
+}
+
+#[must_use]
+pub fn validate_password_confirmation(password: &str, confirmation: &str) -> bool {
+ password == confirmation && password.chars().count() >= 12 && password.chars().count() <= 1024
+}
+
+fn checkbox(value: Option<&str>) -> bool {
+ value.is_some_and(|value| matches!(value, "1" | "true" | "on"))
+}
+
+fn checked(value: Option<&str>) -> &'static str {
+ if checkbox(value) {
+ " checked"
+ } else {
+ ""
+ }
+}
+
+fn trimmed_limited(value: Option<&str>, max_chars: usize) -> String {
+ value
+ .unwrap_or_default()
+ .trim()
+ .chars()
+ .take(max_chars)
+ .collect()
+}
+
+pub fn parse_setup_form(
+ form: &SetupWizardForm,
+ admin_count: i64,
+) -> std::result::Result> {
+ let mut errors = Vec::new();
+ let preset = parse_preset(&form.preset);
+ let defaults = preset_defaults(preset);
+ let site_name = form.site_name.trim().chars().take(64).collect::();
+ if site_name.is_empty() {
+ errors.push("Site name is required.".to_owned());
+ }
+ let admin_username = if admin_count == 0 {
+ let username = trimmed_limited(form.admin_username.as_deref(), 32);
+ let username_valid = (3..=32).contains(&username.len())
+ && username
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-');
+ if !username_valid {
+ errors.push(
+ "Admin username must be 3-32 ASCII letters, numbers, underscores, or dashes."
+ .to_owned(),
+ );
+ }
+ let password = form.admin_password.as_deref().unwrap_or_default();
+ let confirmation = form.admin_password_confirm.as_deref().unwrap_or_default();
+ if !validate_password_confirmation(password, confirmation) {
+ errors.push(
+ "Admin password must be at least 12 characters and match confirmation.".to_owned(),
+ );
+ }
+ Some(username)
+ } else {
+ None
+ };
+ let admin_password = if admin_count == 0 {
+ form.admin_password.clone()
+ } else {
+ None
+ };
+ let board_slug = validate_board_slug(&form.board_slug).unwrap_or_else(|| {
+ errors.push("Board slug must be 1-8 lowercase letters or digits.".to_owned());
+ String::new()
+ });
+ let board_name = form.board_name.trim().chars().take(64).collect::();
+ if board_name.is_empty() {
+ errors.push("Board name is required.".to_owned());
+ }
+ let board_visibility = match form
+ .board_visibility
+ .as_deref()
+ .unwrap_or(defaults.board_visibility)
+ {
+ "public" | "view_password" | "post_password" => form
+ .board_visibility
+ .clone()
+ .unwrap_or_else(|| defaults.board_visibility.to_owned()),
+ _ => {
+ errors.push("Board visibility mode is invalid.".to_owned());
+ "public".to_owned()
+ }
+ };
+ let image_limit_bytes = parse_upload_limit_mib(&form.image_limit_mib);
+ let video_limit_bytes = parse_upload_limit_mib(&form.video_limit_mib);
+ let audio_limit_bytes = parse_upload_limit_mib(&form.audio_limit_mib);
+ let pdf_limit_bytes = parse_upload_limit_mib(&form.pdf_limit_mib);
+ if image_limit_bytes.is_none()
+ || video_limit_bytes.is_none()
+ || audio_limit_bytes.is_none()
+ || pdf_limit_bytes.is_none()
+ {
+ errors.push("Upload limits must be whole MiB values from 1 through 4096.".to_owned());
+ }
+ if !errors.is_empty() {
+ return Err(errors);
+ }
+ let post_cooldown_secs = form
+ .post_cooldown_secs
+ .as_deref()
+ .and_then(|value| value.trim().parse::().ok())
+ .unwrap_or(defaults.post_cooldown_secs)
+ .clamp(0, 3600);
+ let backup_retention = form
+ .backup_retention
+ .as_deref()
+ .and_then(|value| value.trim().parse::().ok())
+ .unwrap_or(1)
+ .clamp(1, 1000);
+ Ok(ParsedSetup {
+ preset,
+ site_name,
+ site_subtitle: trimmed_limited(form.site_subtitle.as_deref(), 128),
+ default_theme: form
+ .default_theme
+ .as_deref()
+ .map(db::sanitize_theme_slug)
+ .filter(|theme| !theme.is_empty())
+ .unwrap_or_else(|| crate::theme::HARD_DEFAULT_THEME.to_owned()),
+ admin_username,
+ admin_password,
+ board_slug,
+ board_name,
+ board_description: trimmed_limited(form.board_description.as_deref(), 256),
+ board_nsfw: checkbox(form.board_nsfw.as_deref()),
+ board_visibility,
+ allow_posting: checkbox(form.allow_posting.as_deref()),
+ allow_uploads: checkbox(form.allow_uploads.as_deref()),
+ allow_video: checkbox(form.allow_video.as_deref()),
+ allow_audio: checkbox(form.allow_audio.as_deref()),
+ allow_pdf: checkbox(form.allow_pdf.as_deref()),
+ allow_video_embeds: checkbox(form.allow_video_embeds.as_deref()),
+ allow_thread_editing: checkbox(form.allow_thread_editing.as_deref()),
+ allow_self_delete: checkbox(form.allow_self_delete.as_deref()),
+ allow_archive: checkbox(form.allow_archive.as_deref()),
+ image_limit_bytes: image_limit_bytes.unwrap_or(8 * MIB_I64),
+ video_limit_bytes: video_limit_bytes.unwrap_or(50 * MIB_I64),
+ audio_limit_bytes: audio_limit_bytes.unwrap_or(150 * MIB_I64),
+ pdf_limit_bytes: pdf_limit_bytes.unwrap_or(8 * MIB_I64),
+ allow_captcha: checkbox(form.allow_captcha.as_deref()),
+ captcha_type: form
+ .captcha_type
+ .as_deref()
+ .filter(|value| matches!(*value, "builtin" | "disabled"))
+ .unwrap_or("builtin")
+ .to_owned(),
+ post_cooldown_secs,
+ homepage_new_thread_badges_enabled: checkbox(
+ form.homepage_new_thread_badges_enabled.as_deref(),
+ ),
+ homepage_new_reply_badges_enabled: checkbox(
+ form.homepage_new_reply_badges_enabled.as_deref(),
+ ),
+ thread_new_reply_badges_enabled: checkbox(form.thread_new_reply_badges_enabled.as_deref()),
+ hide_nsfw_default: checkbox(form.hide_nsfw_default.as_deref()),
+ enable_tor: checkbox(form.enable_tor.as_deref()),
+ tor_only: checkbox(form.tor_only.as_deref()),
+ public_url: trimmed_limited(form.public_url.as_deref(), 256),
+ https_cookies: checkbox(form.https_cookies.as_deref()),
+ behind_proxy: checkbox(form.behind_proxy.as_deref()),
+ auto_backup_interval_hours: if checkbox(form.auto_backup_enabled.as_deref()) {
+ 24
+ } else {
+ 0
+ },
+ backup_retention,
+ include_tor_keys_in_backups: checkbox(form.include_tor_keys_in_backups.as_deref()),
+ })
+}
+
+fn ensure_setup_csrf(
+ jar: CookieJar,
+ headers: &HeaderMap,
+ secure_context: crate::middleware::SecureCookieContext,
+) -> (CookieJar, String) {
+ let token = jar
+ .get("csrf_token")
+ .map(|cookie| cookie.value().to_owned())
+ .filter(|value| !value.is_empty())
+ .unwrap_or_else(crypto::new_csrf_token);
+ let mut cookie = Cookie::new("csrf_token", token.clone());
+ cookie.set_http_only(false);
+ cookie.set_same_site(SameSite::Lax);
+ cookie.set_path("/");
+ cookie.set_secure(crate::handlers::admin::should_set_secure_cookie(
+ headers,
+ secure_context,
+ ));
+ (
+ jar.add(cookie),
+ crypto::make_scoped_csrf_form_token(&token, &CONFIG.cookie_secret, SETUP_CSRF_SCOPE),
+ )
+}
+
+fn validate_setup_csrf(
+ jar: &CookieJar,
+ headers: &HeaderMap,
+ peer: Option,
+ token: Option<&str>,
+) -> Result<()> {
+ let csrf_cookie = jar.get("csrf_token").map(Cookie::value);
+ let csrf_valid = crate::middleware::validate_signed_csrf(
+ csrf_cookie,
+ Some(SETUP_CSRF_SCOPE),
+ token.unwrap_or(""),
+ );
+ crate::handlers::admin::require_same_origin_or_valid_csrf(headers, peer, csrf_valid)?;
+ if csrf_valid {
+ Ok(())
+ } else {
+ Err(AppError::Forbidden("CSRF token mismatch.".into()))
+ }
+}
+
+fn admin_session_id(jar: &CookieJar) -> Option {
+ jar.get(crate::handlers::board::ADMIN_SESSION_COOKIE)
+ .map(|cookie| cookie.value().to_owned())
+}
+
+async fn load_setup_state(
+ state: &AppState,
+ session_id: Option,
+) -> Result<(db::SetupState, bool)> {
+ tokio::task::spawn_blocking({
+ let pool = state.db.clone();
+ move || -> Result<(db::SetupState, bool)> {
+ let conn = pool.get()?;
+ let setup_state = db::ensure_setup_available(&conn)
+ .map_err(|_| AppError::NotFound("Setup wizard is not available.".into()))?;
+ let is_admin = session_id
+ .as_deref()
+ .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some());
+ if setup_state.requires_admin_auth() && !is_admin {
+ return Err(AppError::Forbidden(
+ "Current admin authentication is required to reopen setup.".into(),
+ ));
+ }
+ Ok((setup_state, is_admin))
+ }
+ })
+ .await
+ .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?
+}
+
+pub async fn setup_get(
+ State(state): State,
+ Query(query): Query,
+ jar: CookieJar,
+ headers: HeaderMap,
+ secure_context: crate::middleware::SecureCookieContext,
+) -> Result {
+ let (setup_state, _is_admin) = load_setup_state(&state, admin_session_id(&jar)).await?;
+ let (jar, csrf) = ensure_setup_csrf(jar, &headers, secure_context);
+ let body = setup_form_page(
+ &csrf,
+ setup_state,
+ &SetupWizardForm::defaults_for(parse_preset(query.preset.as_deref().unwrap_or("public"))),
+ &[],
+ request_transport_warning(&headers, secure_context).as_deref(),
+ &state,
+ );
+ Ok((jar, Html(body)).into_response())
+}
+
+#[derive(Deserialize)]
+pub struct SetupQuery {
+ preset: Option,
+}
+
+pub async fn setup_review(
+ State(state): State,
+ jar: CookieJar,
+ headers: HeaderMap,
+ secure_context: crate::middleware::SecureCookieContext,
+ Form(form): Form,
+) -> Result {
+ let (setup_state, _is_admin) = load_setup_state(&state, admin_session_id(&jar)).await?;
+ validate_setup_csrf(&jar, &headers, secure_context.peer, form.csrf.as_deref())?;
+ let parsed = match parse_setup_form(&form, setup_state.admin_count) {
+ Ok(parsed) => parsed,
+ Err(errors) => {
+ let (jar, csrf) = ensure_setup_csrf(jar, &headers, secure_context);
+ return Ok((
+ jar,
+ (
+ StatusCode::BAD_REQUEST,
+ Html(setup_form_page(
+ &csrf,
+ setup_state,
+ &form,
+ &errors,
+ request_transport_warning(&headers, secure_context).as_deref(),
+ &state,
+ )),
+ ),
+ )
+ .into_response());
+ }
+ };
+ let (jar, csrf) = ensure_setup_csrf(jar, &headers, secure_context);
+ Ok((
+ jar,
+ Html(setup_review_page(&csrf, setup_state, &form, &parsed)),
+ )
+ .into_response())
+}
+
+pub async fn setup_finish(
+ State(state): State,
+ jar: CookieJar,
+ headers: HeaderMap,
+ secure_context: crate::middleware::SecureCookieContext,
+ Form(form): Form,
+) -> Result {
+ let (setup_state, _is_admin) = load_setup_state(&state, admin_session_id(&jar)).await?;
+ validate_setup_csrf(&jar, &headers, secure_context.peer, form.csrf.as_deref())?;
+ let parsed = parse_setup_form(&form, setup_state.admin_count)
+ .map_err(|errors| AppError::BadRequest(errors.join(" ")))?;
+ let board_slug = parsed.board_slug.clone();
+ let auto_backup_settings = state.auto_full_backup_settings.clone();
+ tokio::task::spawn_blocking({
+ let pool = state.db.clone();
+ move || -> Result<()> {
+ let mut conn = pool.get()?;
+ let tx = conn.transaction()?;
+ db::ensure_setup_available(&tx)
+ .map_err(|_| AppError::NotFound("Setup wizard is not available.".into()))?;
+ if db::admin_count(&tx)? == 0 {
+ let username = parsed.admin_username.as_deref().ok_or_else(|| {
+ AppError::BadRequest("Initial admin username is required.".into())
+ })?;
+ let password = parsed.admin_password.as_deref().ok_or_else(|| {
+ AppError::BadRequest("Initial admin password is required.".into())
+ })?;
+ let password_hash = crypto::hash_password(password)?;
+ db::create_admin(&tx, username, &password_hash)?;
+ }
+ if db::board_slug_exists(&tx, &parsed.board_slug)? {
+ return Err(AppError::Conflict(format!(
+ "Board /{}/ already exists.",
+ parsed.board_slug
+ )));
+ }
+ let board_id = db::create_board_with_media_flags(
+ &tx,
+ &parsed.board_slug,
+ &parsed.board_name,
+ &parsed.board_description,
+ parsed.board_nsfw,
+ parsed.allow_uploads,
+ parsed.allow_uploads && parsed.allow_video,
+ parsed.allow_uploads && parsed.allow_audio,
+ )?;
+ tx.execute(
+ "UPDATE boards SET
+ max_threads = ?1, max_archived_threads = ?2, bump_limit = ?3,
+ max_image_size = ?4, max_video_size = ?5, max_audio_size = ?6,
+ allow_pdf = ?7, allow_any_files = 0, allow_tripcodes = 1,
+ edit_window_secs = 0, allow_editing = ?8, allow_self_delete = ?9,
+ allow_archive = ?10, allow_video_embeds = ?11, allow_captcha = ?12,
+ show_poster_ids = 1, collapse_greentext = 0, post_cooldown_secs = ?13,
+ default_theme = ?14, banner_mode = 'inherit', access_mode = ?15,
+ access_password_hash = ''
+ WHERE id = ?16",
+ rusqlite::params![
+ if parsed.allow_posting { 150 } else { 0 },
+ 150,
+ 500,
+ parsed.image_limit_bytes,
+ parsed.video_limit_bytes,
+ parsed.audio_limit_bytes,
+ i32::from(parsed.allow_uploads && parsed.allow_pdf),
+ i32::from(parsed.allow_thread_editing),
+ i32::from(parsed.allow_self_delete),
+ i32::from(parsed.allow_archive),
+ i32::from(parsed.allow_video_embeds),
+ i32::from(parsed.allow_captcha && parsed.captcha_type == "builtin"),
+ parsed.post_cooldown_secs,
+ parsed.default_theme,
+ parsed.board_visibility,
+ board_id,
+ ],
+ )?;
+ db::set_site_setting(&tx, "site_name", &parsed.site_name)?;
+ db::set_site_setting(&tx, "site_subtitle", &parsed.site_subtitle)?;
+ db::set_site_setting(&tx, "default_theme", &parsed.default_theme)?;
+ db::set_site_setting(
+ &tx,
+ "homepage_new_thread_badges_enabled",
+ if parsed.homepage_new_thread_badges_enabled {
+ "1"
+ } else {
+ "0"
+ },
+ )?;
+ db::set_site_setting(
+ &tx,
+ "homepage_new_reply_badges_enabled",
+ if parsed.homepage_new_reply_badges_enabled {
+ "1"
+ } else {
+ "0"
+ },
+ )?;
+ db::set_site_setting(
+ &tx,
+ "thread_new_reply_badges_enabled",
+ if parsed.thread_new_reply_badges_enabled {
+ "1"
+ } else {
+ "0"
+ },
+ )?;
+ db::set_site_setting(
+ &tx,
+ "default_hide_nsfw_boards",
+ if parsed.hide_nsfw_default { "1" } else { "0" },
+ )?;
+ db::set_site_setting(&tx, "setup_public_url", &parsed.public_url)?;
+ db::set_site_setting(
+ &tx,
+ "setup_backup_destination",
+ &crate::config::full_backups_dir().display().to_string(),
+ )?;
+ db::set_site_setting(
+ &tx,
+ "setup_pdf_upload_limit_bytes",
+ &parsed.pdf_limit_bytes.to_string(),
+ )?;
+ db::mark_setup_complete(&tx)?;
+ tx.commit()?;
+
+ templates::set_live_site_name(&parsed.site_name);
+ templates::set_live_site_subtitle(&parsed.site_subtitle);
+ db::sync_live_theme_state(&conn)?;
+ templates::set_live_boards(db::get_all_boards(&conn)?);
+ auto_backup_settings.update(
+ parsed.auto_backup_interval_hours,
+ parsed.backup_retention,
+ parsed.include_tor_keys_in_backups,
+ "directory",
+ CONFIG.auto_full_backup_split_zip_part_size_bytes,
+ );
+ crate::config::update_settings_file_site_settings(
+ &parsed.site_name,
+ &parsed.site_subtitle,
+ parsed.homepage_new_thread_badges_enabled,
+ parsed.homepage_new_reply_badges_enabled,
+ parsed.thread_new_reply_badges_enabled,
+ &parsed.default_theme,
+ );
+ crate::config::update_settings_file_auto_full_backup(
+ parsed.auto_backup_interval_hours,
+ parsed.backup_retention,
+ parsed.include_tor_keys_in_backups,
+ "directory",
+ crate::handlers::admin::backup::split_zip_part_size_gib(
+ CONFIG.auto_full_backup_split_zip_part_size_bytes,
+ ),
+ );
+ crate::config::update_settings_file_setup_runtime(
+ crate::config::SetupRuntimeSettingsUpdate {
+ enable_tor_support: parsed.enable_tor,
+ tor_only: parsed.tor_only,
+ behind_proxy: parsed.behind_proxy,
+ https_cookies: parsed.https_cookies,
+ max_image_size_mb: u64::try_from(parsed.image_limit_bytes / MIB_I64)
+ .unwrap_or(8),
+ max_video_size_mb: u64::try_from(parsed.video_limit_bytes / MIB_I64)
+ .unwrap_or(50),
+ max_audio_size_mb: u64::try_from(parsed.audio_limit_bytes / MIB_I64)
+ .unwrap_or(150),
+ },
+ );
+ Ok(())
+ }
+ })
+ .await
+ .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))??;
+
+ Ok(Redirect::to(&format!("/{board_slug}")).into_response())
+}
+
+#[derive(Deserialize)]
+pub struct ReopenSetupForm {
+ #[serde(rename = "_csrf")]
+ csrf: Option,
+}
+
+pub async fn admin_reopen_setup(
+ State(state): State,
+ jar: CookieJar,
+ headers: HeaderMap,
+ axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo,
+ Form(form): Form,
+) -> Result {
+ let session_id = admin_session_id(&jar);
+ crate::handlers::admin::require_admin_post_origin_and_csrf(
+ &jar,
+ &headers,
+ Some(peer),
+ form.csrf.as_deref(),
+ )?;
+ tokio::task::spawn_blocking({
+ let pool = state.db.clone();
+ move || -> Result<()> {
+ let conn = pool.get()?;
+ let admin_id =
+ crate::handlers::admin::require_admin_session_sid(&conn, session_id.as_deref())?;
+ db::reopen_setup(&conn, admin_id)?;
+ Ok(())
+ }
+ })
+ .await
+ .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))??;
+ Ok(Redirect::to("/setup").into_response())
+}
+
+impl SetupWizardForm {
+ #[must_use]
+ pub fn defaults_for(preset: SetupPreset) -> Self {
+ let defaults = preset_defaults(preset);
+ Self {
+ csrf: None,
+ preset: preset.as_str().to_owned(),
+ site_name: defaults.site_name.to_owned(),
+ site_subtitle: Some("select board to proceed".to_owned()),
+ default_theme: Some(crate::theme::HARD_DEFAULT_THEME.to_owned()),
+ admin_username: Some("admin".to_owned()),
+ admin_password: None,
+ admin_password_confirm: None,
+ enable_tor: Some("1".to_owned()),
+ tor_only: None,
+ public_url: None,
+ https_cookies: None,
+ behind_proxy: None,
+ board_slug: defaults.board_slug.to_owned(),
+ board_name: defaults.board_name.to_owned(),
+ board_description: Some(defaults.board_description.to_owned()),
+ board_nsfw: None,
+ board_visibility: Some(defaults.board_visibility.to_owned()),
+ allow_posting: Some("1".to_owned()),
+ allow_uploads: defaults.allow_uploads.then(|| "1".to_owned()),
+ allow_video: defaults.allow_video.then(|| "1".to_owned()),
+ allow_audio: defaults.allow_audio.then(|| "1".to_owned()),
+ allow_pdf: defaults.allow_pdf.then(|| "1".to_owned()),
+ allow_video_embeds: Some("1".to_owned()),
+ allow_thread_editing: Some("1".to_owned()),
+ allow_self_delete: Some("1".to_owned()),
+ allow_archive: Some("1".to_owned()),
+ image_limit_mib: "8".to_owned(),
+ video_limit_mib: "50".to_owned(),
+ audio_limit_mib: "150".to_owned(),
+ pdf_limit_mib: "8".to_owned(),
+ allow_captcha: defaults.allow_captcha.then(|| "1".to_owned()),
+ captcha_type: Some("builtin".to_owned()),
+ post_cooldown_secs: Some(defaults.post_cooldown_secs.to_string()),
+ homepage_new_thread_badges_enabled: Some("1".to_owned()),
+ homepage_new_reply_badges_enabled: Some("1".to_owned()),
+ thread_new_reply_badges_enabled: Some("1".to_owned()),
+ hide_nsfw_default: defaults.hide_nsfw_default.then(|| "1".to_owned()),
+ auto_backup_enabled: None,
+ backup_retention: Some("1".to_owned()),
+ include_tor_keys_in_backups: None,
+ }
+ }
+}
+
+fn request_transport_warning(
+ headers: &HeaderMap,
+ context: crate::middleware::SecureCookieContext,
+) -> Option {
+ let secure_now = crate::handlers::admin::should_set_secure_cookie(headers, context);
+ let host = headers
+ .get(header::HOST)
+ .and_then(|value| value.to_str().ok())
+ .unwrap_or("this host");
+ if CONFIG.https_cookies
+ && !secure_now
+ && !host.starts_with("localhost")
+ && !host.starts_with("127.0.0.1")
+ {
+ Some("Secure cookies are enabled in configuration, but this request is not arriving as HTTPS. Admin login may fail until TLS or trusted proxy headers are configured.".to_owned())
+ } else {
+ None
+ }
+}
+
+fn setup_form_page(
+ csrf: &str,
+ state: db::SetupState,
+ form: &SetupWizardForm,
+ errors: &[String],
+ transport_warning: Option<&str>,
+ app_state: &AppState,
+) -> String {
+ let mut alerts = String::new();
+ if state.requires_admin_auth() {
+ alerts.push_str(r#"Setup was reopened by an admin. Existing admin credentials will not be replaced.
"#);
+ }
+ if let Some(warning) = transport_warning {
+ let _ = write!(
+ alerts,
+ r#"{}
"#,
+ escape_html(warning)
+ );
+ }
+ for error in errors {
+ let _ = write!(
+ alerts,
+ r#"{}
"#,
+ escape_html(error)
+ );
+ }
+ let admin_fields = if state.admin_count == 0 {
+ r#""#
+ .to_owned()
+ } else {
+ r#"3. Admin account An admin account already exists. Continue as the currently authenticated admin; this wizard will not replace credentials.
"#.to_owned()
+ };
+ let ffmpeg = if app_state.ffmpeg_available {
+ "detected"
+ } else {
+ "missing"
+ };
+ let ffprobe = if app_state.ffprobe_available {
+ "detected"
+ } else {
+ "missing"
+ };
+ let body = format!(
+ r#"
+
+
RustChan setup
+
Configure this local runtime before opening the instance to users. All controls work without JavaScript.
+
+{alerts}
+
+ "#,
+ alerts = alerts,
+ csrf = escape_html(csrf),
+ preset_options = preset_options(&form.preset),
+ site_name = escape_html(&form.site_name),
+ site_subtitle = escape_html(form.site_subtitle.as_deref().unwrap_or_default()),
+ default_theme = escape_html(
+ form.default_theme
+ .as_deref()
+ .unwrap_or(crate::theme::HARD_DEFAULT_THEME),
+ ),
+ admin_fields = admin_fields,
+ public_url = escape_html(form.public_url.as_deref().unwrap_or_default()),
+ enable_tor = checked(form.enable_tor.as_deref()),
+ tor_only = checked(form.tor_only.as_deref()),
+ https_cookies = checked(form.https_cookies.as_deref()),
+ behind_proxy = checked(form.behind_proxy.as_deref()),
+ board_slug = escape_html(&form.board_slug),
+ board_name = escape_html(&form.board_name),
+ board_description = escape_html(form.board_description.as_deref().unwrap_or_default()),
+ visibility_options =
+ visibility_options(form.board_visibility.as_deref().unwrap_or("public")),
+ allow_posting = checked(form.allow_posting.as_deref()),
+ board_nsfw = checked(form.board_nsfw.as_deref()),
+ allow_thread_editing = checked(form.allow_thread_editing.as_deref()),
+ allow_self_delete = checked(form.allow_self_delete.as_deref()),
+ allow_archive = checked(form.allow_archive.as_deref()),
+ ffmpeg = ffmpeg,
+ ffprobe = ffprobe,
+ allow_uploads = checked(form.allow_uploads.as_deref()),
+ allow_video = checked(form.allow_video.as_deref()),
+ allow_audio = checked(form.allow_audio.as_deref()),
+ allow_pdf = checked(form.allow_pdf.as_deref()),
+ allow_video_embeds = checked(form.allow_video_embeds.as_deref()),
+ image_limit_mib = escape_html(&form.image_limit_mib),
+ video_limit_mib = escape_html(&form.video_limit_mib),
+ audio_limit_mib = escape_html(&form.audio_limit_mib),
+ pdf_limit_mib = escape_html(&form.pdf_limit_mib),
+ allow_captcha = checked(form.allow_captcha.as_deref()),
+ post_cooldown_secs = escape_html(form.post_cooldown_secs.as_deref().unwrap_or("0")),
+ homepage_new_thread_badges_enabled =
+ checked(form.homepage_new_thread_badges_enabled.as_deref()),
+ homepage_new_reply_badges_enabled =
+ checked(form.homepage_new_reply_badges_enabled.as_deref()),
+ thread_new_reply_badges_enabled = checked(form.thread_new_reply_badges_enabled.as_deref()),
+ hide_nsfw_default = checked(form.hide_nsfw_default.as_deref()),
+ backup_dir = escape_html(&crate::config::full_backups_dir().display().to_string()),
+ auto_backup_enabled = checked(form.auto_backup_enabled.as_deref()),
+ backup_retention = escape_html(form.backup_retention.as_deref().unwrap_or("1")),
+ include_tor_keys_in_backups = checked(form.include_tor_keys_in_backups.as_deref()),
+ );
+ let boards = templates::live_boards();
+ templates::base_layout(
+ "setup",
+ None,
+ &body,
+ csrf,
+ boards.as_ref(),
+ None,
+ None,
+ false,
+ "/setup",
+ )
+}
+
+fn setup_review_page(
+ csrf: &str,
+ state: db::SetupState,
+ form: &SetupWizardForm,
+ parsed: &ParsedSetup,
+) -> String {
+ let hidden = hidden_form_fields(csrf, form);
+ let admin_line = if state.admin_count == 0 {
+ format!(
+ "Create initial admin account: {}",
+ escape_html(parsed.admin_username.as_deref().unwrap_or("admin"))
+ )
+ } else {
+ "Keep existing admin credentials; current admin authorization required.".to_owned()
+ };
+ let body = format!(
+ r#"
+Review setup Confirm these settings before RustChan writes setup state.
+
+Summary
+
+Preset {preset}
+Site {site}
+Admin {admin}
+Board /{slug}/ - {board}
+Uploads image {image} MiB, video {video} MiB, audio {audio} MiB, PDF {pdf} MiB
+Network Tor {tor}; HTTPS cookies {https_cookies}; proxy {proxy}
+Backups {backup}
+
+
+
+ "#,
+ preset = parsed.preset.label(),
+ site = escape_html(&parsed.site_name),
+ admin = admin_line,
+ slug = escape_html(&parsed.board_slug),
+ board = escape_html(&parsed.board_name),
+ image = parsed.image_limit_bytes / i64::try_from(MIB).unwrap_or(1),
+ video = parsed.video_limit_bytes / i64::try_from(MIB).unwrap_or(1),
+ audio = parsed.audio_limit_bytes / i64::try_from(MIB).unwrap_or(1),
+ pdf = parsed.pdf_limit_bytes / i64::try_from(MIB).unwrap_or(1),
+ tor = if parsed.enable_tor {
+ "enabled"
+ } else {
+ "disabled"
+ },
+ https_cookies = if parsed.https_cookies {
+ "enabled when HTTPS"
+ } else {
+ "unchanged"
+ },
+ proxy = if parsed.behind_proxy {
+ "configured after restart"
+ } else {
+ "off"
+ },
+ backup = if parsed.auto_backup_interval_hours == 0 {
+ "automatic backups disabled".to_owned()
+ } else {
+ format!(
+ "every {} hours, keep {}",
+ parsed.auto_backup_interval_hours, parsed.backup_retention
+ )
+ },
+ hidden = hidden,
+ );
+ let boards = templates::live_boards();
+ templates::base_layout(
+ "review setup",
+ None,
+ &body,
+ csrf,
+ boards.as_ref(),
+ None,
+ None,
+ false,
+ "/setup",
+ )
+}
+
+fn preset_options(selected: &str) -> String {
+ let mut out = String::new();
+ for preset in [
+ SetupPreset::Public,
+ SetupPreset::Private,
+ SetupPreset::Local,
+ ] {
+ let _ = write!(
+ out,
+ r#" {label} prefill "#,
+ value = preset.as_str(),
+ checked = if selected == preset.as_str() {
+ " checked"
+ } else {
+ ""
+ },
+ label = preset.label(),
+ );
+ }
+ out
+}
+
+fn visibility_options(selected: &str) -> String {
+ let mut out = String::new();
+ for (value, label) in [
+ ("public", "Public"),
+ ("view_password", "Require password to view"),
+ ("post_password", "Require password to post"),
+ ] {
+ let _ = write!(
+ out,
+ r#"{label} "#,
+ selected_attr = if selected == value { " selected" } else { "" },
+ );
+ }
+ out
+}
+
+fn hidden_field(name: &str, value: &str) -> String {
+ format!(
+ r#" "#,
+ name = escape_html(name),
+ value = escape_html(value)
+ )
+}
+
+fn hidden_checkbox(name: &str, value: Option<&str>) -> String {
+ if checkbox(value) {
+ hidden_field(name, "1")
+ } else {
+ String::new()
+ }
+}
+
+fn hidden_form_fields(csrf: &str, form: &SetupWizardForm) -> String {
+ let mut out = String::new();
+ out.push_str(&hidden_field("_csrf", csrf));
+ for (name, value) in [
+ ("preset", form.preset.as_str()),
+ ("site_name", form.site_name.as_str()),
+ (
+ "site_subtitle",
+ form.site_subtitle.as_deref().unwrap_or_default(),
+ ),
+ (
+ "default_theme",
+ form.default_theme.as_deref().unwrap_or_default(),
+ ),
+ (
+ "admin_username",
+ form.admin_username.as_deref().unwrap_or_default(),
+ ),
+ (
+ "admin_password",
+ form.admin_password.as_deref().unwrap_or_default(),
+ ),
+ (
+ "admin_password_confirm",
+ form.admin_password_confirm.as_deref().unwrap_or_default(),
+ ),
+ ("public_url", form.public_url.as_deref().unwrap_or_default()),
+ ("board_slug", form.board_slug.as_str()),
+ ("board_name", form.board_name.as_str()),
+ (
+ "board_description",
+ form.board_description.as_deref().unwrap_or_default(),
+ ),
+ (
+ "board_visibility",
+ form.board_visibility.as_deref().unwrap_or("public"),
+ ),
+ ("image_limit_mib", form.image_limit_mib.as_str()),
+ ("video_limit_mib", form.video_limit_mib.as_str()),
+ ("audio_limit_mib", form.audio_limit_mib.as_str()),
+ ("pdf_limit_mib", form.pdf_limit_mib.as_str()),
+ (
+ "captcha_type",
+ form.captcha_type.as_deref().unwrap_or("builtin"),
+ ),
+ (
+ "post_cooldown_secs",
+ form.post_cooldown_secs.as_deref().unwrap_or("0"),
+ ),
+ (
+ "backup_retention",
+ form.backup_retention.as_deref().unwrap_or("1"),
+ ),
+ ] {
+ out.push_str(&hidden_field(name, value));
+ }
+ for (name, value) in [
+ ("enable_tor", form.enable_tor.as_deref()),
+ ("tor_only", form.tor_only.as_deref()),
+ ("https_cookies", form.https_cookies.as_deref()),
+ ("behind_proxy", form.behind_proxy.as_deref()),
+ ("board_nsfw", form.board_nsfw.as_deref()),
+ ("allow_posting", form.allow_posting.as_deref()),
+ ("allow_uploads", form.allow_uploads.as_deref()),
+ ("allow_video", form.allow_video.as_deref()),
+ ("allow_audio", form.allow_audio.as_deref()),
+ ("allow_pdf", form.allow_pdf.as_deref()),
+ ("allow_video_embeds", form.allow_video_embeds.as_deref()),
+ ("allow_thread_editing", form.allow_thread_editing.as_deref()),
+ ("allow_self_delete", form.allow_self_delete.as_deref()),
+ ("allow_archive", form.allow_archive.as_deref()),
+ ("allow_captcha", form.allow_captcha.as_deref()),
+ (
+ "homepage_new_thread_badges_enabled",
+ form.homepage_new_thread_badges_enabled.as_deref(),
+ ),
+ (
+ "homepage_new_reply_badges_enabled",
+ form.homepage_new_reply_badges_enabled.as_deref(),
+ ),
+ (
+ "thread_new_reply_badges_enabled",
+ form.thread_new_reply_badges_enabled.as_deref(),
+ ),
+ ("hide_nsfw_default", form.hide_nsfw_default.as_deref()),
+ ("auto_backup_enabled", form.auto_backup_enabled.as_deref()),
+ (
+ "include_tor_keys_in_backups",
+ form.include_tor_keys_in_backups.as_deref(),
+ ),
+ ] {
+ out.push_str(&hidden_checkbox(name, value));
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use axum::{
+ body::{to_bytes, Body},
+ http::{header, Request, StatusCode},
+ routing::get,
+ Router,
+ };
+ use tower::ServiceExt as _;
+
+ #[test]
+ fn setup_validation_rejects_bad_slug_and_password_mismatch() {
+ let mut form = SetupWizardForm::defaults_for(SetupPreset::Public);
+ form.board_slug = "../bad".to_owned();
+ form.admin_password = Some("long-enough-password".to_owned());
+ form.admin_password_confirm = Some("different-password".to_owned());
+
+ let errors = parse_setup_form(&form, 0).expect_err("validation errors");
+
+ assert!(errors.iter().any(|error| error.contains("Board slug")));
+ assert!(errors.iter().any(|error| error.contains("Admin password")));
+ }
+
+ #[test]
+ fn upload_limit_parser_requires_clear_mib_units() {
+ assert_eq!(parse_upload_limit_mib("8"), Some(8 * MIB_I64));
+ assert_eq!(parse_upload_limit_mib("0"), None);
+ assert_eq!(parse_upload_limit_mib("nope"), None);
+ }
+
+ #[test]
+ fn preset_defaults_are_conservative_for_public_instances() {
+ let defaults = preset_defaults(SetupPreset::Public);
+ assert!(defaults.allow_uploads);
+ assert!(!defaults.allow_audio);
+ assert!(!defaults.allow_pdf);
+ assert!(defaults.allow_captcha);
+ }
+
+ #[tokio::test]
+ async fn initialized_instance_blocks_setup_route() {
+ let state = crate::test_support::app_state();
+ {
+ let conn = state.db.get().expect("conn");
+ crate::db::create_admin(&conn, "admin", "hash").expect("admin");
+ }
+ let app = Router::new()
+ .route("/setup", get(setup_get))
+ .with_state(state);
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .uri("/setup")
+ .header(header::HOST, "localhost")
+ .extension(crate::test_support::connect_info())
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ let body = to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ let body = String::from_utf8(body.to_vec()).expect("utf8");
+ assert!(body.contains("Setup wizard is not available"));
+ }
+}
diff --git a/src/server/server/routes.rs b/src/server/server/routes.rs
index 135035f3..2aa5d7d4 100644
--- a/src/server/server/routes.rs
+++ b/src/server/server/routes.rs
@@ -69,6 +69,9 @@ pub(super) fn public_routes() -> Router {
"/banner/external/{id}/continue",
get(crate::handlers::banner::external_banner_continue),
)
+ .route("/setup", get(crate::handlers::setup::setup_get))
+ .route("/setup/review", post(crate::handlers::setup::setup_review))
+ .route("/setup/finish", post(crate::handlers::setup::setup_finish))
.route("/", get(crate::handlers::board::index))
.route("/{board}", get(crate::handlers::board::board_index))
.route(
@@ -297,6 +300,10 @@ fn admin_moderation_routes() -> Router {
"/admin/media/settings",
post(crate::handlers::admin::update_media_settings),
)
+ .route(
+ "/admin/setup/reopen",
+ post(crate::handlers::setup::admin_reopen_setup),
+ )
.route(
"/admin/db/repair",
get(crate::handlers::admin::admin_db_repair_status)
diff --git a/src/templates/admin.rs b/src/templates/admin.rs
index e9369774..4a84213d 100644
--- a/src/templates/admin.rs
+++ b/src/templates/admin.rs
@@ -161,12 +161,21 @@ pub struct AdminSiteHealthDependencySummary {
pub struct AdminPanelMaintenanceView {
pub db_size_bytes: i64,
pub db_size_warning: bool,
+ pub setup_status: AdminPanelSetupStatus,
pub ffmpeg_timeout_secs: u64,
pub media_auto_prune_enabled: bool,
pub media_max_active_content_size_bytes: u64,
pub media_detection: AdminMediaDetectionView,
}
+#[derive(Clone, Copy, Eq, PartialEq)]
+pub enum AdminPanelSetupStatus {
+ Available,
+ Complete,
+ Reopened,
+ Initialized,
+}
+
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum AdminDetectionStatus {
Detected,
@@ -1700,8 +1709,8 @@ mod tests {
admin_db_health_result_page, admin_db_repair_idle_page, admin_login_page, admin_panel_page,
render_board_appearance_card, render_board_settings_card, AdminDetectionStatus,
AdminMediaDetectionView, AdminPanelAppearanceView, AdminPanelBackupsView,
- AdminPanelMaintenanceView, AdminPanelModerationView, AdminPanelSiteHealthView,
- AdminPanelViewModel, AdminSiteHealthDependencySummary,
+ AdminPanelMaintenanceView, AdminPanelModerationView, AdminPanelSetupStatus,
+ AdminPanelSiteHealthView, AdminPanelViewModel, AdminSiteHealthDependencySummary,
};
use crate::db::{DbCheckResult, DbHealthReport, DbHealthSnapshot};
use crate::models::{
@@ -1986,6 +1995,7 @@ mod tests {
maintenance: AdminPanelMaintenanceView {
db_size_bytes: 4096,
db_size_warning: false,
+ setup_status: AdminPanelSetupStatus::Complete,
ffmpeg_timeout_secs: crate::config::DEFAULT_FFMPEG_TIMEOUT_SECS,
media_auto_prune_enabled: false,
media_max_active_content_size_bytes: 0,
@@ -2622,6 +2632,7 @@ mod tests {
maintenance: AdminPanelMaintenanceView {
db_size_bytes: 4096,
db_size_warning: false,
+ setup_status: AdminPanelSetupStatus::Complete,
ffmpeg_timeout_secs: crate::config::DEFAULT_FFMPEG_TIMEOUT_SECS,
media_auto_prune_enabled: false,
media_max_active_content_size_bytes: 0,
@@ -2711,6 +2722,7 @@ mod tests {
maintenance: AdminPanelMaintenanceView {
db_size_bytes: 0,
db_size_warning: false,
+ setup_status: AdminPanelSetupStatus::Complete,
ffmpeg_timeout_secs: crate::config::DEFAULT_FFMPEG_TIMEOUT_SECS,
media_auto_prune_enabled: false,
media_max_active_content_size_bytes: 0,
@@ -2791,6 +2803,7 @@ mod tests {
maintenance: AdminPanelMaintenanceView {
db_size_bytes: 0,
db_size_warning: false,
+ setup_status: AdminPanelSetupStatus::Complete,
ffmpeg_timeout_secs: crate::config::DEFAULT_FFMPEG_TIMEOUT_SECS,
media_auto_prune_enabled: false,
media_max_active_content_size_bytes: 0,
diff --git a/src/templates/admin/maintenance.rs b/src/templates/admin/maintenance.rs
index 3583192d..c52cd6c0 100644
--- a/src/templates/admin/maintenance.rs
+++ b/src/templates/admin/maintenance.rs
@@ -14,6 +14,9 @@ struct MaintenanceSectionView<'a> {
media_detection_cards: &'a str,
media_settings_open_attr: &'a str,
database_maintenance_open_attr: &'a str,
+ setup_status: &'a str,
+ setup_status_detail: &'a str,
+ setup_reopen_warning: &'a str,
}
pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String {
@@ -59,6 +62,25 @@ old boards to prevent query performance degradation.
let (media_max_value, media_max_unit) =
media_size_input_parts(view.maintenance.media_max_active_content_size_bytes);
let media_detection_cards = render_media_detection_cards(view);
+ let (setup_status, setup_status_detail) = match view.maintenance.setup_status {
+ super::AdminPanelSetupStatus::Reopened => (
+ "reopened",
+ "The setup wizard has been reopened by an admin and is available only to authenticated admins.",
+ ),
+ super::AdminPanelSetupStatus::Complete => (
+ "complete",
+ "The setup wizard has completed and public setup routes are blocked.",
+ ),
+ super::AdminPanelSetupStatus::Available => (
+ "available",
+ "This instance still appears to be in first-run setup.",
+ ),
+ super::AdminPanelSetupStatus::Initialized => (
+ "initialized",
+ "This instance has existing durable runtime state, so first-run setup routes are blocked.",
+ ),
+ };
+ let setup_reopen_warning = "Reopening setup exposes live settings for editing. It does not replace existing admin credentials and still requires an authenticated admin session.";
let section_view = MaintenanceSectionView {
csrf_token: view.csrf_token,
db_warn_banner: &db_warn_banner,
@@ -72,6 +94,9 @@ old boards to prevent query performance degradation.
media_detection_cards: &media_detection_cards,
media_settings_open_attr,
database_maintenance_open_attr,
+ setup_status,
+ setup_status_detail,
+ setup_reopen_warning,
};
render_admin_maintenance_section(§ion_view)
}
@@ -260,6 +285,21 @@ fn render_admin_maintenance_section(view: &MaintenanceSectionView<'_>) -> String
data-confirm="Run VACUUM? This will briefly block the database while it rebuilds. Continue?">🧹 run VACUUM
+
+
+
+ Setup status: {setup_status} . {setup_status_detail}
+
+
{setup_reopen_warning}
+
+
@@ -300,6 +340,9 @@ fn render_admin_maintenance_section(view: &MaintenanceSectionView<'_>) -> String
ffmpeg_timeout_max = crate::config::MAX_FFMPEG_TIMEOUT_SECS,
media_settings_open_attr = view.media_settings_open_attr,
database_maintenance_open_attr = view.database_maintenance_open_attr,
+ setup_status = escape_html(view.setup_status),
+ setup_status_detail = escape_html(view.setup_status_detail),
+ setup_reopen_warning = escape_html(view.setup_reopen_warning),
tor_section = view.tor_section,
)
}
diff --git a/static/admin.css b/static/admin.css
index 4813ae47..3b7a4791 100644
--- a/static/admin.css
+++ b/static/admin.css
@@ -3184,3 +3184,86 @@ html:not([data-theme]) .admin-login h1 {
border-radius: 0;
backdrop-filter: none;
}
+/* First-run setup wizard */
+.setup-wizard {
+ width: min(1040px, calc(100vw - 24px));
+ margin: 0 auto 48px;
+}
+
+.setup-head {
+ margin: 24px 0 16px;
+}
+
+.setup-head h1 {
+ margin-bottom: 6px;
+}
+
+.setup-section {
+ border: 1px solid var(--border-color, #7f7f7f);
+ background: var(--box-bg, rgba(255, 255, 255, 0.04));
+ padding: 16px;
+ margin: 12px 0;
+ border-radius: 6px;
+}
+
+.setup-section h2 {
+ font-size: 1.05rem;
+ margin: 0 0 12px;
+}
+
+.setup-grid,
+.setup-choice-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 12px;
+}
+
+.setup-grid label,
+.setup-preset {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.setup-check,
+.setup-preset {
+ flex-direction: row !important;
+ align-items: center;
+ min-height: 36px;
+}
+
+.setup-alert {
+ border: 1px solid var(--border-color, #7f7f7f);
+ padding: 10px 12px;
+ margin: 10px 0;
+ border-radius: 6px;
+}
+
+.setup-alert-warn {
+ border-color: #b88719;
+}
+
+.setup-review-list {
+ display: grid;
+ grid-template-columns: minmax(120px, 180px) 1fr;
+ gap: 8px 14px;
+ margin: 0 0 16px;
+}
+
+.setup-review-list dt {
+ font-weight: 700;
+}
+
+@media (max-width: 640px) {
+ .setup-wizard {
+ width: min(100% - 16px, 100%);
+ }
+
+ .setup-section {
+ padding: 12px;
+ }
+
+ .setup-review-list {
+ grid-template-columns: 1fr;
+ }
+}
From 9d94fa1ae90f415d3ab0d8b24a1814254a9858f1 Mon Sep 17 00:00:00 2001
From: csd113
Date: Sat, 30 May 2026 20:52:00 -0700
Subject: [PATCH 09/26] Add PDF upload limit & setup improvements
Add native PDF upload size support and related setup flow and settings refactors.
- Database: add max_pdf_size column (default 157_286_400) and include it in schema migrations, board creation/updating, and test assertions.
- DB mapping: update board row mapping and SQL parameter indices to include max_pdf_size and shift subsequent columns.
- Handlers: thread/post/upload handlers and multipart parsing now accept and enforce max_pdf_size; tests added/updated to cover PDF limits and renamed-file checks.
- Setup flow: introduce pending admin hash token stored in a secure cookie to avoid echoing the admin password on the review page; validate and consume the token during setup finish. Add admin_close_setup endpoint and close_reopened_setup DB helper to clear reopen markers.
- Config: refactor settings file update to return anyhow::Result with atomic tempfile persist and provide a new SetupSettingsFileUpdate struct to persist setup-owned settings in one atomic rewrite; preserve a wrapper that logs on error for compatibility.
- Templates/forms: update setup form and copy to expose PDF limit field and avoid showing raw admin password.
- Misc: various test adjustments to account for the new PDF field and setup behavior.
These changes ensure PDFs have dedicated size limits, improve atomic settings persistence, and make the first-run setup safer by avoiding password echoing.
---
src/config.rs | 159 ++++++----
src/db/boards.rs | 88 +++---
src/db/schema.rs | 17 +-
src/db/setup.rs | 32 +-
src/handlers/admin/content.rs | 1 +
src/handlers/admin/settings/board.rs | 6 +
src/handlers/board/create_thread.rs | 1 +
src/handlers/board/tests.rs | 1 +
src/handlers/mod.rs | 61 +++-
src/handlers/posting.rs | 6 +
src/handlers/setup.rs | 446 +++++++++++++++++++++++----
src/handlers/thread.rs | 1 +
src/models.rs | 10 +
src/server/server/routes.rs | 4 +
src/templates/admin.rs | 12 +-
src/templates/admin/maintenance.rs | 18 ++
src/templates/forms.rs | 4 +-
src/test_fixtures.rs | 1 +
src/test_support.rs | 1 +
src/utils/files/storage.rs | 8 +-
20 files changed, 713 insertions(+), 164 deletions(-)
diff --git a/src/config.rs b/src/config.rs
index e6f2ea0d..58ef3757 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,4 +1,5 @@
// Runtime configuration and settings-file loading.
+use anyhow::Context as _;
use serde::Deserialize;
use std::env;
use std::io::Write as _;
@@ -1195,20 +1196,15 @@ fn rewrite_settings_file_lines(
out
}
-fn update_settings_file_entries(updates: &[(&str, String)], insert_missing_before: Option<&str>) {
+fn update_settings_file_entries_result(
+ updates: &[(&str, String)],
+ insert_missing_before: Option<&str>,
+) -> anyhow::Result<()> {
// Escape backslash and double-quote, then wrap in double quotes.
let path = settings_file_path();
let content = match std::fs::read_to_string(&path) {
- Ok(c) => c,
- Err(e) => {
- tracing::warn!(
- target: "config",
- path = %path.display(),
- error = %e,
- "Could not read settings.toml for update"
- );
- return;
- }
+ Ok(content) => content,
+ Err(error) => anyhow::bail!("could not read {}: {error}", path.display()),
};
// Replace the value portion of `key = ...` lines while preserving file
// order and unrelated comments.
@@ -1217,40 +1213,27 @@ fn update_settings_file_entries(updates: &[(&str, String)], insert_missing_befor
// over the target. This prevents a partial write from corrupting settings.toml
// if the process is killed mid-write (rename(2) is atomic on POSIX).
let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
- match tempfile::Builder::new()
+ let mut tmp = tempfile::Builder::new()
.prefix(".settings_")
.suffix(".tmp")
.tempfile_in(dir)
- {
- Err(e) => {
- tracing::warn!(
- target: "config",
- path = %path.display(),
- error = %e,
- "Could not create temp file for settings.toml update"
- );
- }
- Ok(mut tmp) => {
- use std::io::Write as _;
- let write_result = tmp
- .write_all(out.as_bytes())
- .and_then(|()| tmp.as_file().sync_all());
- if let Err(e) = write_result {
- tracing::warn!(
- target: "config",
- path = %path.display(),
- error = %e,
- "Could not write settings.toml temp file"
- );
- } else if let Err(e) = tmp.persist(&path) {
- tracing::warn!(
- target: "config",
- path = %path.display(),
- error = %e.error,
- "Could not atomically replace settings.toml"
- );
- }
- }
+ .with_context(|| format!("could not create temp file for {}", path.display()))?;
+ tmp.write_all(out.as_bytes())
+ .and_then(|()| tmp.as_file().sync_all())
+ .with_context(|| format!("could not write temp settings file for {}", path.display()))?;
+ tmp.persist(&path)
+ .map_err(|error| error.error)
+ .with_context(|| format!("could not atomically replace {}", path.display()))?;
+ Ok(())
+}
+
+fn update_settings_file_entries(updates: &[(&str, String)], insert_missing_before: Option<&str>) {
+ if let Err(error) = update_settings_file_entries_result(updates, insert_missing_before) {
+ tracing::warn!(
+ target: "config",
+ error = %error,
+ "Could not update settings.toml"
+ );
}
}
@@ -1330,19 +1313,91 @@ pub struct SetupRuntimeSettingsUpdate {
pub max_audio_size_mb: u64,
}
-pub fn update_settings_file_setup_runtime(update: SetupRuntimeSettingsUpdate) {
- update_settings_file_entries(
+#[expect(
+ clippy::struct_excessive_bools,
+ reason = "settings update mirrors independent first-run setup toggles"
+)]
+pub struct SetupSettingsFileUpdate<'a> {
+ pub forum_name: &'a str,
+ pub site_subtitle: &'a str,
+ pub homepage_new_thread_badges_enabled: bool,
+ pub homepage_new_reply_badges_enabled: bool,
+ pub thread_new_reply_badges_enabled: bool,
+ pub default_theme: &'a str,
+ pub auto_full_backup_interval_hours: u64,
+ pub auto_full_backup_copies_to_keep: u64,
+ pub auto_full_backup_include_tor_hidden_service_keys: bool,
+ pub auto_full_backup_storage_mode: &'a str,
+ pub auto_full_backup_split_zip_part_size_gib: u64,
+ pub runtime: SetupRuntimeSettingsUpdate,
+}
+
+/// Persist all setup-owned runtime settings in one atomic settings.toml rewrite.
+///
+/// # Errors
+/// Returns an error if the settings file cannot be read or atomically replaced.
+pub fn update_settings_file_setup(update: &SetupSettingsFileUpdate<'_>) -> anyhow::Result<()> {
+ update_settings_file_entries_result(
&[
- ("enable_tor_support", update.enable_tor_support.to_string()),
- ("tor_only", update.tor_only.to_string()),
- ("behind_proxy", update.behind_proxy.to_string()),
- ("https_cookies", update.https_cookies.to_string()),
- ("max_image_size_mb", update.max_image_size_mb.to_string()),
- ("max_video_size_mb", update.max_video_size_mb.to_string()),
- ("max_audio_size_mb", update.max_audio_size_mb.to_string()),
+ ("forum_name", toml_quote(update.forum_name)),
+ ("site_subtitle", toml_quote(update.site_subtitle)),
+ (
+ "homepage_new_thread_badges_enabled",
+ update.homepage_new_thread_badges_enabled.to_string(),
+ ),
+ (
+ "homepage_new_reply_badges_enabled",
+ update.homepage_new_reply_badges_enabled.to_string(),
+ ),
+ (
+ "thread_new_reply_badges_enabled",
+ update.thread_new_reply_badges_enabled.to_string(),
+ ),
+ ("default_theme", toml_quote(update.default_theme)),
+ (
+ "enable_tor_support",
+ update.runtime.enable_tor_support.to_string(),
+ ),
+ ("tor_only", update.runtime.tor_only.to_string()),
+ ("behind_proxy", update.runtime.behind_proxy.to_string()),
+ ("https_cookies", update.runtime.https_cookies.to_string()),
+ (
+ "max_image_size_mb",
+ update.runtime.max_image_size_mb.to_string(),
+ ),
+ (
+ "max_video_size_mb",
+ update.runtime.max_video_size_mb.to_string(),
+ ),
+ (
+ "max_audio_size_mb",
+ update.runtime.max_audio_size_mb.to_string(),
+ ),
+ (
+ "auto_full_backup_interval_hours",
+ update.auto_full_backup_interval_hours.to_string(),
+ ),
+ (
+ "auto_full_backup_copies_to_keep",
+ update.auto_full_backup_copies_to_keep.max(1).to_string(),
+ ),
+ (
+ "auto_full_backup_include_tor_hidden_service_keys",
+ update
+ .auto_full_backup_include_tor_hidden_service_keys
+ .to_string(),
+ ),
+ (
+ "auto_full_backup_storage_mode",
+ toml_quote(update.auto_full_backup_storage_mode),
+ ),
+ (
+ "auto_full_backup_split_zip_part_size_gib",
+ update.auto_full_backup_split_zip_part_size_gib.to_string(),
+ ),
],
Some("# Optional explicit ffmpeg binary path."),
- );
+ )
}
pub fn update_settings_file_ffmpeg_timeout(timeout_secs: u64) {
diff --git a/src/db/boards.rs b/src/db/boards.rs
index 6314a900..cc30b171 100644
--- a/src/db/boards.rs
+++ b/src/db/boards.rs
@@ -12,14 +12,14 @@ const BOARD_ORDER_SQL: &str = "nsfw ASC, display_order ASC, id ASC";
const BOARD_GROUP_ORDER_SQL: &str = "display_order ASC, id ASC";
const BOARD_SELECT_COLUMNS: &str = "id, display_order, short_name, name, description, nsfw, \
max_threads, max_archived_threads, bump_limit, allow_images, allow_video, allow_audio, \
- max_image_size, max_video_size, max_audio_size, allow_pdf, allow_any_files, allow_tripcodes, \
+ max_image_size, max_video_size, max_audio_size, max_pdf_size, allow_pdf, allow_any_files, allow_tripcodes, \
edit_window_secs, allow_editing, allow_self_delete, allow_archive, \
allow_video_embeds, allow_captcha, show_poster_ids, collapse_greentext, \
post_cooldown_secs, default_theme, banner_mode, access_mode, access_password_hash, created_at";
const BOARD_SELECT_COLUMNS_WITH_ALIAS: &str = "b.id, b.display_order, b.short_name, b.name, \
b.description, b.nsfw, b.max_threads, b.max_archived_threads, b.bump_limit, \
b.allow_images, b.allow_video, b.allow_audio, b.max_image_size, b.max_video_size, b.max_audio_size, \
- b.allow_pdf, b.allow_any_files, b.allow_tripcodes, b.edit_window_secs, b.allow_editing, \
+ b.max_pdf_size, b.allow_pdf, b.allow_any_files, b.allow_tripcodes, b.edit_window_secs, b.allow_editing, \
b.allow_self_delete, b.allow_archive, b.allow_video_embeds, b.allow_captcha, \
b.show_poster_ids, b.collapse_greentext, b.post_cooldown_secs, \
b.default_theme, b.banner_mode, b.access_mode, b.access_password_hash, b.created_at";
@@ -28,8 +28,8 @@ const BOARD_SELECT_COLUMNS_WITH_ALIAS: &str = "b.id, b.display_order, b.short_na
pub(super) fn map_board(row: &rusqlite::Row<'_>) -> rusqlite::Result {
let short_name: String = row.get(2)?;
- let banner_mode_raw: String = row.get(28)?;
- let access_mode_raw: String = row.get(29)?;
+ let banner_mode_raw: String = row.get(29)?;
+ let access_mode_raw: String = row.get(30)?;
let banner_mode = BoardBannerMode::from_db_str(&banner_mode_raw).unwrap_or_else(|| {
tracing::warn!(
target: "db",
@@ -64,23 +64,24 @@ pub(super) fn map_board(row: &rusqlite::Row<'_>) -> rusqlite::Result {
max_image_size: row.get(12)?,
max_video_size: row.get(13)?,
max_audio_size: row.get(14)?,
- allow_pdf: row.get::<_, i32>(15)? != 0,
- allow_any_files: row.get::<_, i32>(16)? != 0,
- allow_tripcodes: row.get::<_, i32>(17)? != 0,
- edit_window_secs: row.get(18)?,
- allow_editing: row.get::<_, i32>(19)? != 0,
- allow_self_delete: row.get::<_, i32>(20)? != 0,
- allow_archive: row.get::<_, i32>(21)? != 0,
- allow_video_embeds: row.get::<_, i32>(22)? != 0,
- allow_captcha: row.get::<_, i32>(23)? != 0,
- show_poster_ids: row.get::<_, i32>(24)? != 0,
- collapse_greentext: row.get::<_, i32>(25)? != 0,
- post_cooldown_secs: row.get(26)?,
- default_theme: row.get(27)?,
+ max_pdf_size: row.get(15)?,
+ allow_pdf: row.get::<_, i32>(16)? != 0,
+ allow_any_files: row.get::<_, i32>(17)? != 0,
+ allow_tripcodes: row.get::<_, i32>(18)? != 0,
+ edit_window_secs: row.get(19)?,
+ allow_editing: row.get::<_, i32>(20)? != 0,
+ allow_self_delete: row.get::<_, i32>(21)? != 0,
+ allow_archive: row.get::<_, i32>(22)? != 0,
+ allow_video_embeds: row.get::<_, i32>(23)? != 0,
+ allow_captcha: row.get::<_, i32>(24)? != 0,
+ show_poster_ids: row.get::<_, i32>(25)? != 0,
+ collapse_greentext: row.get::<_, i32>(26)? != 0,
+ post_cooldown_secs: row.get(27)?,
+ default_theme: row.get(28)?,
banner_mode,
access_mode,
- access_password_hash: row.get(30)?,
- created_at: row.get(31)?,
+ access_password_hash: row.get(31)?,
+ created_at: row.get(32)?,
})
}
@@ -340,7 +341,7 @@ pub fn get_all_boards_with_stats(
let out = stmt
.query_map([], |row| {
let board = map_board(row)?;
- let thread_count: i64 = row.get(32)?;
+ let thread_count: i64 = row.get(33)?;
Ok(crate::models::BoardStats {
board,
thread_count,
@@ -516,9 +517,9 @@ pub fn create_board(
"INSERT INTO boards (
display_order, short_name, name, description, nsfw,
allow_images, allow_video, allow_audio,
- max_image_size, max_video_size, max_audio_size
+ max_image_size, max_video_size, max_audio_size, max_pdf_size
)
- VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, 0, ?6, ?7, ?8)
+ VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, 0, ?6, ?7, ?8, ?9)
RETURNING id",
params![
display_order,
@@ -532,6 +533,8 @@ pub fn create_board(
.context("max_video_size does not fit in i64")?,
i64::try_from(crate::config::CONFIG.max_audio_size)
.context("max_audio_size does not fit in i64")?,
+ i64::try_from(crate::config::CONFIG.max_image_size)
+ .context("max_pdf_size does not fit in i64")?,
],
|r| r.get(0),
)
@@ -565,14 +568,14 @@ pub fn create_board_with_media_flags(
"INSERT INTO boards (
display_order, short_name, name, description, nsfw,
allow_images, allow_video, allow_audio,
- max_image_size, max_video_size, max_audio_size,
+ max_image_size, max_video_size, max_audio_size, max_pdf_size,
allow_tripcodes, allow_editing, allow_self_delete, allow_archive,
allow_video_embeds, allow_captcha, show_poster_ids,
collapse_greentext, post_cooldown_secs, default_theme,
banner_mode, access_mode, access_password_hash
)
VALUES (
- ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11,
+ ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
1, 1, 1, 1,
1, 0, 1,
0, 0, '', 'inherit', 'public', ''
@@ -593,6 +596,8 @@ pub fn create_board_with_media_flags(
.context("max_video_size does not fit in i64")?,
i64::try_from(crate::config::CONFIG.max_audio_size)
.context("max_audio_size does not fit in i64")?,
+ i64::try_from(crate::config::CONFIG.max_image_size)
+ .context("max_pdf_size does not fit in i64")?,
],
|r| r.get(0),
)
@@ -677,6 +682,7 @@ pub fn update_board_settings(
max_image_size: i64,
max_video_size: i64,
max_audio_size: i64,
+ max_pdf_size: i64,
allow_pdf: bool,
allow_any_files: bool,
allow_tripcodes: bool,
@@ -706,12 +712,12 @@ pub fn update_board_settings(
"UPDATE boards SET name=?1, description=?2, nsfw=?3,
bump_limit=?4, max_threads=?5, max_archived_threads=?6,
allow_images=?7, allow_video=?8, allow_audio=?9,
- max_image_size=?10, max_video_size=?11, max_audio_size=?12,
- allow_pdf=?13, allow_any_files=?14, allow_tripcodes=?15, edit_window_secs=?16,
- allow_editing=?17, allow_self_delete=?18, allow_archive=?19, allow_video_embeds=?20,
- allow_captcha=?21, show_poster_ids=?22, collapse_greentext=?23, post_cooldown_secs=?24,
- default_theme=?25, banner_mode=?26, access_mode=?27, access_password_hash=?28
- WHERE id=?29",
+ max_image_size=?10, max_video_size=?11, max_audio_size=?12, max_pdf_size=?13,
+ allow_pdf=?14, allow_any_files=?15, allow_tripcodes=?16, edit_window_secs=?17,
+ allow_editing=?18, allow_self_delete=?19, allow_archive=?20, allow_video_embeds=?21,
+ allow_captcha=?22, show_poster_ids=?23, collapse_greentext=?24, post_cooldown_secs=?25,
+ default_theme=?26, banner_mode=?27, access_mode=?28, access_password_hash=?29
+ WHERE id=?30",
params![
name,
description,
@@ -725,6 +731,7 @@ pub fn update_board_settings(
max_image_size,
max_video_size,
max_audio_size,
+ max_pdf_size,
i32::from(allow_pdf),
i32::from(allow_any_files),
i32::from(allow_tripcodes),
@@ -750,12 +757,12 @@ pub fn update_board_settings(
"UPDATE boards SET name=?1, description=?2, nsfw=?3, display_order=?4,
bump_limit=?5, max_threads=?6, max_archived_threads=?7,
allow_images=?8, allow_video=?9, allow_audio=?10,
- max_image_size=?11, max_video_size=?12, max_audio_size=?13,
- allow_pdf=?14, allow_any_files=?15, allow_tripcodes=?16, edit_window_secs=?17,
- allow_editing=?18, allow_self_delete=?19, allow_archive=?20, allow_video_embeds=?21,
- allow_captcha=?22, show_poster_ids=?23, collapse_greentext=?24, post_cooldown_secs=?25,
- default_theme=?26, banner_mode=?27, access_mode=?28, access_password_hash=?29
- WHERE id=?30",
+ max_image_size=?11, max_video_size=?12, max_audio_size=?13, max_pdf_size=?14,
+ allow_pdf=?15, allow_any_files=?16, allow_tripcodes=?17, edit_window_secs=?18,
+ allow_editing=?19, allow_self_delete=?20, allow_archive=?21, allow_video_embeds=?22,
+ allow_captcha=?23, show_poster_ids=?24, collapse_greentext=?25, post_cooldown_secs=?26,
+ default_theme=?27, banner_mode=?28, access_mode=?29, access_password_hash=?30
+ WHERE id=?31",
params![
name,
description,
@@ -770,6 +777,7 @@ pub fn update_board_settings(
max_image_size,
max_video_size,
max_audio_size,
+ max_pdf_size,
i32::from(allow_pdf),
i32::from(allow_any_files),
i32::from(allow_tripcodes),
@@ -1231,6 +1239,10 @@ mod tests {
assert!(board.allow_video);
assert!(board.allow_audio);
assert!(!board.allow_pdf);
+ assert_eq!(
+ board.max_pdf_size,
+ i64::try_from(crate::config::CONFIG.max_image_size).expect("pdf size fits in i64")
+ );
assert!(board.allow_video_embeds);
assert!(board.show_poster_ids);
assert!(board.allow_editing);
@@ -1279,6 +1291,10 @@ mod tests {
board.max_audio_size,
i64::try_from(crate::config::CONFIG.max_audio_size).expect("audio size fits in i64")
);
+ assert_eq!(
+ board.max_pdf_size,
+ i64::try_from(crate::config::CONFIG.max_image_size).expect("pdf size fits in i64")
+ );
}
#[test]
diff --git a/src/db/schema.rs b/src/db/schema.rs
index d1263464..28f3de23 100644
--- a/src/db/schema.rs
+++ b/src/db/schema.rs
@@ -22,6 +22,7 @@ const BASE_SCHEMA_SQL: &str = "
max_image_size INTEGER NOT NULL DEFAULT 8388608,
max_video_size INTEGER NOT NULL DEFAULT 52428800,
max_audio_size INTEGER NOT NULL DEFAULT 157286400,
+ max_pdf_size INTEGER NOT NULL DEFAULT 157286400,
allow_pdf INTEGER NOT NULL DEFAULT 0,
allow_any_files INTEGER NOT NULL DEFAULT 0,
edit_window_secs INTEGER NOT NULL DEFAULT 0,
@@ -309,7 +310,7 @@ const INDEX_SCHEMA_SQL: &str = "
ON post_submissions(created_at ASC);
";
-const LEGACY_BASELINE_COLUMN_ADDITIONS: [(&str, &str, &str); 33] = [
+const LEGACY_BASELINE_COLUMN_ADDITIONS: [(&str, &str, &str); 34] = [
(
"boards",
"display_order",
@@ -355,6 +356,11 @@ const LEGACY_BASELINE_COLUMN_ADDITIONS: [(&str, &str, &str); 33] = [
"max_audio_size",
"ALTER TABLE boards ADD COLUMN max_audio_size INTEGER NOT NULL DEFAULT 157286400",
),
+ (
+ "boards",
+ "max_pdf_size",
+ "ALTER TABLE boards ADD COLUMN max_pdf_size INTEGER NOT NULL DEFAULT 157286400",
+ ),
(
"boards",
"allow_pdf",
@@ -1413,15 +1419,16 @@ mod tests {
assert!(table_has_column(&conn, "boards", "allow_self_delete"));
assert!(table_has_column(&conn, "boards", "allow_archive"));
assert!(table_has_column(&conn, "boards", "allow_pdf"));
+ assert!(table_has_column(&conn, "boards", "max_pdf_size"));
- let flags: (i64, i64, i64) = conn
+ let flags: (i64, i64, i64, i64) = conn
.query_row(
- "SELECT allow_self_delete, allow_archive, allow_pdf FROM boards WHERE id = 1",
+ "SELECT allow_self_delete, allow_archive, allow_pdf, max_pdf_size FROM boards WHERE id = 1",
[],
- |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
+ |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.expect("read repaired board flags");
- assert_eq!(flags, (0, 1, 0));
+ assert_eq!(flags, (0, 1, 0, 157_286_400));
}
#[test]
diff --git a/src/db/setup.rs b/src/db/setup.rs
index 1e70f50a..477cd910 100644
--- a/src/db/setup.rs
+++ b/src/db/setup.rs
@@ -93,6 +93,17 @@ pub fn reopen_setup(conn: &rusqlite::Connection, admin_id: i64) -> Result<()> {
Ok(())
}
+/// # Errors
+/// Returns an error if the reopen marker cannot be cleared.
+pub fn close_reopened_setup(conn: &rusqlite::Connection) -> Result<()> {
+ conn.execute(
+ "DELETE FROM site_settings WHERE key IN (?1, ?2)",
+ params![SETUP_REOPENED_AT_KEY, SETUP_REOPENED_BY_KEY],
+ )
+ .context("clear setup reopen marker")?;
+ Ok(())
+}
+
/// # Errors
/// Returns an error if the setup completion marker cannot be persisted.
pub fn mark_setup_complete(conn: &rusqlite::Connection) -> Result<()> {
@@ -103,11 +114,7 @@ pub fn mark_setup_complete(conn: &rusqlite::Connection) -> Result<()> {
params![SETUP_COMPLETED_AT_KEY, now],
)
.context("write setup completion marker")?;
- conn.execute(
- "DELETE FROM site_settings WHERE key IN (?1, ?2)",
- params![SETUP_REOPENED_AT_KEY, SETUP_REOPENED_BY_KEY],
- )
- .context("clear setup reopen marker")?;
+ close_reopened_setup(conn)?;
Ok(())
}
@@ -186,4 +193,19 @@ mod tests {
assert!(state.completed);
assert!(!state.reopened);
}
+
+ #[test]
+ fn close_reopened_setup_relocks_completed_instance_without_clearing_completion() {
+ let pool = crate::db::init_test_pool().expect("pool");
+ let conn = pool.get().expect("conn");
+ mark_setup_complete(&conn).expect("complete");
+ reopen_setup(&conn, 1).expect("reopen");
+
+ close_reopened_setup(&conn).expect("close");
+ let state = setup_state(&conn).expect("state");
+
+ assert_eq!(state.access, SetupAccess::Initialized);
+ assert!(state.completed);
+ assert!(!state.reopened);
+ }
}
diff --git a/src/handlers/admin/content.rs b/src/handlers/admin/content.rs
index 97b7dc87..74b845b8 100644
--- a/src/handlers/admin/content.rs
+++ b/src/handlers/admin/content.rs
@@ -775,6 +775,7 @@ mod tests {
max_image_size: 8 * 1024 * 1024,
max_video_size: 50 * 1024 * 1024,
max_audio_size: 150 * 1024 * 1024,
+ max_pdf_size: 8 * 1024 * 1024,
allow_pdf: false,
allow_any_files: false,
allow_tripcodes: true,
diff --git a/src/handlers/admin/settings/board.rs b/src/handlers/admin/settings/board.rs
index ff9b7751..62b3990c 100644
--- a/src/handlers/admin/settings/board.rs
+++ b/src/handlers/admin/settings/board.rs
@@ -19,6 +19,7 @@ pub struct BoardSettingsForm {
max_image_size_mb: Option,
max_video_size_mb: Option,
max_audio_size_mb: Option,
+ max_pdf_size_mb: Option,
allow_pdf: Option,
allow_any_files: Option,
allow_tripcodes: Option,
@@ -194,6 +195,10 @@ pub async fn update_board_settings(
form.max_audio_size_mb.as_deref(),
current_board.max_audio_size,
)?;
+ let max_pdf_size = parse_board_upload_limit_bytes(
+ form.max_pdf_size_mb.as_deref(),
+ current_board.max_pdf_size,
+ )?;
db::update_board_settings(
&mut conn,
board_id,
@@ -209,6 +214,7 @@ pub async fn update_board_settings(
max_image_size,
max_video_size,
max_audio_size,
+ max_pdf_size,
form.allow_pdf.as_deref() == Some("1"),
CONFIG.enable_any_file_uploads_feature
&& form.allow_any_files.as_deref() == Some("1"),
diff --git a/src/handlers/board/create_thread.rs b/src/handlers/board/create_thread.rs
index a492fc1a..deca0a4d 100644
--- a/src/handlers/board/create_thread.rs
+++ b/src/handlers/board/create_thread.rs
@@ -51,6 +51,7 @@ pub async fn create_thread(
access_context.board.max_image_size_bytes(),
access_context.board.max_video_size_bytes(),
access_context.board.max_audio_size_bytes(),
+ access_context.board.max_pdf_size_bytes(),
),
)
.await
diff --git a/src/handlers/board/tests.rs b/src/handlers/board/tests.rs
index 9117be54..d003ccf4 100644
--- a/src/handlers/board/tests.rs
+++ b/src/handlers/board/tests.rs
@@ -2645,6 +2645,7 @@ async fn create_thread_rejects_uploads_on_upload_disabled_board() {
i64::try_from(crate::config::CONFIG.max_image_size).expect("image size fits in i64"),
i64::try_from(crate::config::CONFIG.max_video_size).expect("video size fits in i64"),
i64::try_from(crate::config::CONFIG.max_audio_size).expect("audio size fits in i64"),
+ i64::try_from(crate::config::CONFIG.max_image_size).expect("pdf size fits in i64"),
false,
false,
true,
diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs
index a9834ae8..ce350724 100644
--- a/src/handlers/mod.rs
+++ b/src/handlers/mod.rs
@@ -302,11 +302,13 @@ pub async fn parse_post_multipart(
max_image_size: usize,
max_video_size: usize,
max_audio_size: usize,
+ max_pdf_size: usize,
) -> Result {
tracing::info!(
max_image_bytes = max_image_size,
max_video_bytes = max_video_size,
max_audio_bytes = max_audio_size,
+ max_pdf_bytes = max_pdf_size,
"accepted multipart upload limits for post request"
);
@@ -398,7 +400,10 @@ pub async fn parse_post_multipart(
}
file = read_upload_field(
field,
- max_image_size.max(max_video_size).max(max_audio_size),
+ max_image_size
+ .max(max_video_size)
+ .max(max_audio_size)
+ .max(max_pdf_size),
"upload",
"file",
&mut budget,
@@ -528,6 +533,7 @@ pub fn process_primary_upload(
max_image_size: usize,
max_video_size: usize,
max_audio_size: usize,
+ max_pdf_size: usize,
ffmpeg_available: bool,
ffprobe_available: bool,
ffmpeg_webp_available: bool,
@@ -591,6 +597,7 @@ pub fn process_primary_upload(
max_image_size,
max_video_size,
max_audio_size,
+ max_pdf_size,
ffmpeg_available,
ffprobe_available,
ffmpeg_webp_available,
@@ -666,6 +673,7 @@ pub fn process_primary_upload(
max_image_size,
max_video_size,
max_audio_size,
+ max_pdf_size,
ffmpeg_available,
ffprobe_available,
ffmpeg_webp_available,
@@ -765,6 +773,7 @@ pub fn process_audio_first_uploads(
max_image_size: usize,
max_video_size: usize,
max_audio_size: usize,
+ max_pdf_size: usize,
ffmpeg_available: bool,
ffprobe_available: bool,
ffmpeg_webp_available: bool,
@@ -787,6 +796,7 @@ pub fn process_audio_first_uploads(
max_image_size,
max_video_size,
max_audio_size,
+ max_pdf_size,
ffmpeg_available,
ffprobe_available,
ffmpeg_webp_available,
@@ -1007,7 +1017,8 @@ trailer << /Root 1 0 R >>
async fn parse_scaled_audio_limit(
multipart: axum::extract::Multipart,
) -> crate::error::Result<&'static str> {
- let form = parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 5_000).await?;
+ let form =
+ parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 5_000, 1_024).await?;
let (upload, _) = form.audio_file.expect("audio upload");
assert_eq!(upload.size_bytes, 4_500);
Ok("ok")
@@ -1016,7 +1027,7 @@ trailer << /Root 1 0 R >>
async fn parse_scaled_audio_oversize(
multipart: axum::extract::Multipart,
) -> crate::error::Result<&'static str> {
- parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 5_000).await?;
+ parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 5_000, 1_024).await?;
Ok("ok")
}
@@ -1093,7 +1104,7 @@ trailer << /Root 1 0 R >>
async fn parse_default_limits(
multipart: axum::extract::Multipart,
) -> crate::error::Result<&'static str> {
- parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 1_024).await?;
+ parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 1_024, 1_024).await?;
Ok("ok")
}
@@ -1249,6 +1260,7 @@ trailer << /Root 1 0 R >>
1024 * 1024,
1024 * 1024,
1024 * 1024,
+ 1024 * 1024,
false,
false,
false,
@@ -1301,6 +1313,7 @@ trailer << /Root 1 0 R >>
1024 * 1024,
1024 * 1024,
1024 * 1024,
+ 1024 * 1024,
false,
false,
false,
@@ -1356,6 +1369,7 @@ trailer << /Root 1 0 R >>
1024 * 1024,
1024 * 1024,
1024 * 1024,
+ 1024 * 1024,
false,
false,
false,
@@ -1390,6 +1404,7 @@ trailer << /Root 1 0 R >>
1024 * 1024,
1024 * 1024,
1024 * 1024,
+ 1024 * 1024,
false,
false,
false,
@@ -1402,6 +1417,42 @@ trailer << /Root 1 0 R >>
assert!(!save_root.path().join(&board.short_name).exists());
}
+ #[test]
+ fn primary_upload_rejects_pdf_over_pdf_size_limit() {
+ let conn = rusqlite::Connection::open_in_memory().expect("in-memory sqlite");
+ create_file_hash_table(&conn);
+
+ let board = crate::models::Board {
+ allow_pdf: true,
+ max_pdf_size: 8,
+ max_video_size: 1024 * 1024,
+ max_audio_size: 1024 * 1024,
+ ..crate::test_fixtures::sample_board()
+ };
+ let uploads_dir = tempfile::tempdir().expect("uploads dir");
+ let save_root = tempfile::tempdir().expect("save root");
+ let result = super::process_primary_upload(
+ Some(temp_upload("doc.pdf", valid_pdf())),
+ &board,
+ &conn,
+ uploads_dir.path().to_str().expect("uploads dir path"),
+ save_root.path().to_str().expect("save root path"),
+ 64,
+ 1024 * 1024,
+ 1024 * 1024,
+ 1024 * 1024,
+ board.max_pdf_size_bytes(),
+ false,
+ false,
+ false,
+ );
+
+ match result {
+ Ok(_) => panic!("oversized PDF should be rejected"),
+ Err(error) => assert!(error.to_string().contains("Maximum PDF upload size is 8 B")),
+ }
+ }
+
#[test]
fn primary_upload_rejects_renamed_non_pdf() {
let conn = rusqlite::Connection::open_in_memory().expect("in-memory sqlite");
@@ -1423,6 +1474,7 @@ trailer << /Root 1 0 R >>
1024 * 1024,
1024 * 1024,
1024 * 1024,
+ 1024 * 1024,
false,
false,
false,
@@ -1459,6 +1511,7 @@ trailer << /Root 1 0 R >>
1024 * 1024,
1024 * 1024,
1024 * 1024,
+ 1024 * 1024,
false,
false,
false,
diff --git a/src/handlers/posting.rs b/src/handlers/posting.rs
index d9fff7be..afcb62df 100644
--- a/src/handlers/posting.rs
+++ b/src/handlers/posting.rs
@@ -67,6 +67,7 @@ pub struct UploadConfig<'a> {
pub max_image_size: usize,
pub max_video_size: usize,
pub max_audio_size: usize,
+ pub max_pdf_size: usize,
pub ffmpeg_available: bool,
pub ffprobe_available: bool,
pub ffmpeg_webp_available: bool,
@@ -315,6 +316,7 @@ pub fn process_uploads(
config.max_image_size,
config.max_video_size,
config.max_audio_size,
+ config.max_pdf_size,
config.ffmpeg_available,
config.ffprobe_available,
config.ffmpeg_webp_available,
@@ -424,6 +426,7 @@ pub fn submit_post(
let effective_max_image_size = board.max_image_size_bytes();
let effective_max_video_size = board.max_video_size_bytes();
let effective_max_audio_size = board.max_audio_size_bytes();
+ let effective_max_pdf_size = board.max_pdf_size_bytes();
let reply_context = match &mode {
SubmitPostMode::Reply { thread_id, sage } => {
@@ -517,6 +520,7 @@ pub fn submit_post(
max_image_size: effective_max_image_size,
max_video_size: effective_max_video_size,
max_audio_size: effective_max_audio_size,
+ max_pdf_size: effective_max_pdf_size,
ffmpeg_available,
ffprobe_available,
ffmpeg_webp_available,
@@ -1170,6 +1174,7 @@ mod tests {
max_image_size: 1024 * 1024,
max_video_size: 1024 * 1024,
max_audio_size: 1024 * 1024,
+ max_pdf_size: 1024 * 1024,
ffmpeg_available: false,
ffprobe_available: false,
ffmpeg_webp_available: false,
@@ -1466,6 +1471,7 @@ mod tests {
max_image_size: 1024 * 1024,
max_video_size: 1024 * 1024,
max_audio_size: 1024 * 1024,
+ max_pdf_size: 1024 * 1024,
ffmpeg_available: false,
ffprobe_available: false,
ffmpeg_webp_available: false,
diff --git a/src/handlers/setup.rs b/src/handlers/setup.rs
index a5a58efa..c3fcabd4 100644
--- a/src/handlers/setup.rs
+++ b/src/handlers/setup.rs
@@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize};
use std::fmt::Write as _;
const SETUP_CSRF_SCOPE: &str = "first-run-setup";
+const SETUP_PENDING_ADMIN_HASH_COOKIE: &str = "setup_pending_admin_hash";
const MIB: u64 = 1024 * 1024;
const MIB_I64: i64 = 1024 * 1024;
@@ -135,6 +136,7 @@ pub struct SetupWizardForm {
pub admin_username: Option,
pub admin_password: Option,
pub admin_password_confirm: Option,
+ pub admin_password_token: Option,
pub enable_tor: Option,
pub tor_only: Option,
pub public_url: Option,
@@ -254,6 +256,72 @@ fn checked(value: Option<&str>) -> &'static str {
}
}
+fn pending_admin_hash_signature(token: &str, password_hash_hex: &str) -> String {
+ crypto::sha256_hex(
+ format!(
+ "{}:setup-admin-password:{token}:{password_hash_hex}",
+ CONFIG.cookie_secret
+ )
+ .as_bytes(),
+ )
+}
+
+fn make_pending_admin_hash_cookie(
+ token: &str,
+ password_hash: &str,
+ headers: &HeaderMap,
+ secure_context: crate::middleware::SecureCookieContext,
+) -> Cookie<'static> {
+ let password_hash_hex = hex::encode(password_hash.as_bytes());
+ let signature = pending_admin_hash_signature(token, &password_hash_hex);
+ let mut cookie = Cookie::new(
+ SETUP_PENDING_ADMIN_HASH_COOKIE,
+ format!("{token}:{password_hash_hex}:{signature}"),
+ );
+ cookie.set_http_only(true);
+ cookie.set_same_site(SameSite::Lax);
+ cookie.set_path("/");
+ cookie.set_secure(crate::handlers::admin::should_set_secure_cookie(
+ headers,
+ secure_context,
+ ));
+ cookie
+}
+
+fn pending_admin_hash_from_cookie(jar: &CookieJar, token: Option<&str>) -> Result> {
+ let Some(token) = token.filter(|value| !value.is_empty()) else {
+ return Ok(None);
+ };
+ let Some(cookie) = jar.get(SETUP_PENDING_ADMIN_HASH_COOKIE) else {
+ return Ok(None);
+ };
+ let mut parts = cookie.value().splitn(3, ':');
+ let Some(cookie_token) = parts.next() else {
+ return Ok(None);
+ };
+ let Some(password_hash_hex) = parts.next() else {
+ return Ok(None);
+ };
+ let Some(signature) = parts.next() else {
+ return Ok(None);
+ };
+ if cookie_token != token {
+ return Ok(None);
+ }
+ let expected = pending_admin_hash_signature(token, password_hash_hex);
+ if signature != expected {
+ return Err(AppError::Forbidden(
+ "Setup admin secret token is invalid.".into(),
+ ));
+ }
+ let password_hash_bytes = hex::decode(password_hash_hex)
+ .map_err(|_error| AppError::BadRequest("Setup admin secret token is malformed.".into()))?;
+ let password_hash = String::from_utf8(password_hash_bytes).map_err(|_error| {
+ AppError::BadRequest("Setup admin secret token is not valid UTF-8.".into())
+ })?;
+ Ok(Some(password_hash))
+}
+
fn trimmed_limited(value: Option<&str>, max_chars: usize) -> String {
value
.unwrap_or_default()
@@ -266,6 +334,14 @@ fn trimmed_limited(value: Option<&str>, max_chars: usize) -> String {
pub fn parse_setup_form(
form: &SetupWizardForm,
admin_count: i64,
+) -> std::result::Result> {
+ parse_setup_form_inner(form, admin_count, false)
+}
+
+fn parse_setup_form_inner(
+ form: &SetupWizardForm,
+ admin_count: i64,
+ admin_secret_available: bool,
) -> std::result::Result> {
let mut errors = Vec::new();
let preset = parse_preset(&form.preset);
@@ -288,7 +364,7 @@ pub fn parse_setup_form(
}
let password = form.admin_password.as_deref().unwrap_or_default();
let confirmation = form.admin_password_confirm.as_deref().unwrap_or_default();
- if !validate_password_confirmation(password, confirmation) {
+ if !admin_secret_available && !validate_password_confirmation(password, confirmation) {
errors.push(
"Admin password must be at least 12 characters and match confirmation.".to_owned(),
);
@@ -468,8 +544,15 @@ async fn load_setup_state(
let pool = state.db.clone();
move || -> Result<(db::SetupState, bool)> {
let conn = pool.get()?;
- let setup_state = db::ensure_setup_available(&conn)
- .map_err(|_| AppError::NotFound("Setup wizard is not available.".into()))?;
+ let setup_state = db::setup_state(&conn)?;
+ if !setup_state.is_available() {
+ let message = if setup_state.completed {
+ "Setup is already complete."
+ } else {
+ "Setup wizard is not available."
+ };
+ return Err(AppError::NotFound(message.into()));
+ }
let is_admin = session_id
.as_deref()
.is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some());
@@ -540,10 +623,28 @@ pub async fn setup_review(
.into_response());
}
};
- let (jar, csrf) = ensure_setup_csrf(jar, &headers, secure_context);
+ let (mut jar, csrf) = ensure_setup_csrf(jar, &headers, secure_context);
+ let mut review_form = form.clone();
+ if setup_state.admin_count == 0 {
+ let password = parsed
+ .admin_password
+ .as_deref()
+ .ok_or_else(|| AppError::BadRequest("Initial admin password is required.".into()))?;
+ let password_hash = crypto::hash_password(password)?;
+ let token = crypto::random_hex(32);
+ jar = jar.add(make_pending_admin_hash_cookie(
+ &token,
+ &password_hash,
+ &headers,
+ secure_context,
+ ));
+ review_form.admin_password = None;
+ review_form.admin_password_confirm = None;
+ review_form.admin_password_token = Some(token);
+ }
Ok((
jar,
- Html(setup_review_page(&csrf, setup_state, &form, &parsed)),
+ Html(setup_review_page(&csrf, setup_state, &review_form, &parsed)),
)
.into_response())
}
@@ -557,8 +658,11 @@ pub async fn setup_finish(
) -> Result {
let (setup_state, _is_admin) = load_setup_state(&state, admin_session_id(&jar)).await?;
validate_setup_csrf(&jar, &headers, secure_context.peer, form.csrf.as_deref())?;
- let parsed = parse_setup_form(&form, setup_state.admin_count)
- .map_err(|errors| AppError::BadRequest(errors.join(" ")))?;
+ let pending_admin_hash =
+ pending_admin_hash_from_cookie(&jar, form.admin_password_token.as_deref())?;
+ let parsed =
+ parse_setup_form_inner(&form, setup_state.admin_count, pending_admin_hash.is_some())
+ .map_err(|errors| AppError::BadRequest(errors.join(" ")))?;
let board_slug = parsed.board_slug.clone();
let auto_backup_settings = state.auto_full_backup_settings.clone();
tokio::task::spawn_blocking({
@@ -572,10 +676,14 @@ pub async fn setup_finish(
let username = parsed.admin_username.as_deref().ok_or_else(|| {
AppError::BadRequest("Initial admin username is required.".into())
})?;
- let password = parsed.admin_password.as_deref().ok_or_else(|| {
- AppError::BadRequest("Initial admin password is required.".into())
- })?;
- let password_hash = crypto::hash_password(password)?;
+ let password_hash = if let Some(hash) = pending_admin_hash.as_deref() {
+ hash.to_owned()
+ } else {
+ let password = parsed.admin_password.as_deref().ok_or_else(|| {
+ AppError::BadRequest("Initial admin password is required.".into())
+ })?;
+ crypto::hash_password(password)?
+ };
db::create_admin(&tx, username, &password_hash)?;
}
if db::board_slug_exists(&tx, &parsed.board_slug)? {
@@ -598,13 +706,13 @@ pub async fn setup_finish(
"UPDATE boards SET
max_threads = ?1, max_archived_threads = ?2, bump_limit = ?3,
max_image_size = ?4, max_video_size = ?5, max_audio_size = ?6,
- allow_pdf = ?7, allow_any_files = 0, allow_tripcodes = 1,
- edit_window_secs = 0, allow_editing = ?8, allow_self_delete = ?9,
- allow_archive = ?10, allow_video_embeds = ?11, allow_captcha = ?12,
- show_poster_ids = 1, collapse_greentext = 0, post_cooldown_secs = ?13,
- default_theme = ?14, banner_mode = 'inherit', access_mode = ?15,
+ max_pdf_size = ?7, allow_pdf = ?8, allow_any_files = 0, allow_tripcodes = 1,
+ edit_window_secs = 0, allow_editing = ?9, allow_self_delete = ?10,
+ allow_archive = ?11, allow_video_embeds = ?12, allow_captcha = ?13,
+ show_poster_ids = 1, collapse_greentext = 0, post_cooldown_secs = ?14,
+ default_theme = ?15, banner_mode = 'inherit', access_mode = ?16,
access_password_hash = ''
- WHERE id = ?16",
+ WHERE id = ?17",
rusqlite::params![
if parsed.allow_posting { 150 } else { 0 },
150,
@@ -612,6 +720,7 @@ pub async fn setup_finish(
parsed.image_limit_bytes,
parsed.video_limit_bytes,
parsed.audio_limit_bytes,
+ parsed.pdf_limit_bytes,
i32::from(parsed.allow_uploads && parsed.allow_pdf),
i32::from(parsed.allow_thread_editing),
i32::from(parsed.allow_self_delete),
@@ -670,6 +779,35 @@ pub async fn setup_finish(
"setup_pdf_upload_limit_bytes",
&parsed.pdf_limit_bytes.to_string(),
)?;
+ crate::config::update_settings_file_setup(&crate::config::SetupSettingsFileUpdate {
+ forum_name: &parsed.site_name,
+ site_subtitle: &parsed.site_subtitle,
+ homepage_new_thread_badges_enabled: parsed.homepage_new_thread_badges_enabled,
+ homepage_new_reply_badges_enabled: parsed.homepage_new_reply_badges_enabled,
+ thread_new_reply_badges_enabled: parsed.thread_new_reply_badges_enabled,
+ default_theme: &parsed.default_theme,
+ auto_full_backup_interval_hours: parsed.auto_backup_interval_hours,
+ auto_full_backup_copies_to_keep: parsed.backup_retention,
+ auto_full_backup_include_tor_hidden_service_keys: parsed
+ .include_tor_keys_in_backups,
+ auto_full_backup_storage_mode: "directory",
+ auto_full_backup_split_zip_part_size_gib:
+ crate::handlers::admin::backup::split_zip_part_size_gib(
+ CONFIG.auto_full_backup_split_zip_part_size_bytes,
+ ),
+ runtime: crate::config::SetupRuntimeSettingsUpdate {
+ enable_tor_support: parsed.enable_tor,
+ tor_only: parsed.tor_only,
+ behind_proxy: parsed.behind_proxy,
+ https_cookies: parsed.https_cookies,
+ max_image_size_mb: u64::try_from(parsed.image_limit_bytes / MIB_I64)
+ .unwrap_or(8),
+ max_video_size_mb: u64::try_from(parsed.video_limit_bytes / MIB_I64)
+ .unwrap_or(50),
+ max_audio_size_mb: u64::try_from(parsed.audio_limit_bytes / MIB_I64)
+ .unwrap_or(150),
+ },
+ })?;
db::mark_setup_complete(&tx)?;
tx.commit()?;
@@ -684,44 +822,14 @@ pub async fn setup_finish(
"directory",
CONFIG.auto_full_backup_split_zip_part_size_bytes,
);
- crate::config::update_settings_file_site_settings(
- &parsed.site_name,
- &parsed.site_subtitle,
- parsed.homepage_new_thread_badges_enabled,
- parsed.homepage_new_reply_badges_enabled,
- parsed.thread_new_reply_badges_enabled,
- &parsed.default_theme,
- );
- crate::config::update_settings_file_auto_full_backup(
- parsed.auto_backup_interval_hours,
- parsed.backup_retention,
- parsed.include_tor_keys_in_backups,
- "directory",
- crate::handlers::admin::backup::split_zip_part_size_gib(
- CONFIG.auto_full_backup_split_zip_part_size_bytes,
- ),
- );
- crate::config::update_settings_file_setup_runtime(
- crate::config::SetupRuntimeSettingsUpdate {
- enable_tor_support: parsed.enable_tor,
- tor_only: parsed.tor_only,
- behind_proxy: parsed.behind_proxy,
- https_cookies: parsed.https_cookies,
- max_image_size_mb: u64::try_from(parsed.image_limit_bytes / MIB_I64)
- .unwrap_or(8),
- max_video_size_mb: u64::try_from(parsed.video_limit_bytes / MIB_I64)
- .unwrap_or(50),
- max_audio_size_mb: u64::try_from(parsed.audio_limit_bytes / MIB_I64)
- .unwrap_or(150),
- },
- );
Ok(())
}
})
.await
.map_err(|error| AppError::Internal(anyhow::anyhow!(error)))??;
- Ok(Redirect::to(&format!("/{board_slug}")).into_response())
+ let jar = jar.remove(Cookie::from(SETUP_PENDING_ADMIN_HASH_COOKIE));
+ Ok((jar, Redirect::to(&format!("/{board_slug}"))).into_response())
}
#[derive(Deserialize)]
@@ -759,6 +867,37 @@ pub async fn admin_reopen_setup(
Ok(Redirect::to("/setup").into_response())
}
+pub async fn admin_close_setup(
+ State(state): State,
+ jar: CookieJar,
+ headers: HeaderMap,
+ axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo,
+ Form(form): Form,
+) -> Result {
+ let session_id = admin_session_id(&jar);
+ crate::handlers::admin::require_admin_post_origin_and_csrf(
+ &jar,
+ &headers,
+ Some(peer),
+ form.csrf.as_deref(),
+ )?;
+ tokio::task::spawn_blocking({
+ let pool = state.db.clone();
+ move || -> Result<()> {
+ let conn = pool.get()?;
+ crate::handlers::admin::require_admin_session_sid(&conn, session_id.as_deref())?;
+ db::close_reopened_setup(&conn)?;
+ Ok(())
+ }
+ })
+ .await
+ .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))??;
+ Ok(Redirect::to(
+ "/admin/panel?flash=Setup+wizard+closed.&open=database-maintenance#database-maintenance",
+ )
+ .into_response())
+}
+
impl SetupWizardForm {
#[must_use]
pub fn defaults_for(preset: SetupPreset) -> Self {
@@ -772,6 +911,7 @@ impl SetupWizardForm {
admin_username: Some("admin".to_owned()),
admin_password: None,
admin_password_confirm: None,
+ admin_password_token: None,
enable_tor: Some("1".to_owned()),
tor_only: None,
public_url: None,
@@ -913,7 +1053,7 @@ fn setup_form_page(
Archive overflow threads
6. Uploads and media
-ffmpeg: {ffmpeg} ; ffprobe: {ffprobe} . PDF uploads use RustChan's generic upload guard; the PDF limit is stored for future native PDF-limit support.
+ffmpeg: {ffmpeg} ; ffprobe: {ffprobe} . PDF uploads use the PDF limit shown here; other file types use their matching media limits.
Allow image uploads
Allow video uploads
@@ -1168,12 +1308,8 @@ fn hidden_form_fields(csrf: &str, form: &SetupWizardForm) -> String {
form.admin_username.as_deref().unwrap_or_default(),
),
(
- "admin_password",
- form.admin_password.as_deref().unwrap_or_default(),
- ),
- (
- "admin_password_confirm",
- form.admin_password_confirm.as_deref().unwrap_or_default(),
+ "admin_password_token",
+ form.admin_password_token.as_deref().unwrap_or_default(),
),
("public_url", form.public_url.as_deref().unwrap_or_default()),
("board_slug", form.board_slug.as_str()),
@@ -1251,7 +1387,7 @@ mod tests {
use axum::{
body::{to_bytes, Body},
http::{header, Request, StatusCode},
- routing::get,
+ routing::{get, post},
Router,
};
use tower::ServiceExt as _;
@@ -1285,6 +1421,55 @@ mod tests {
assert!(defaults.allow_captcha);
}
+ fn setup_form_body(csrf: &str, board_slug: &str, password: Option<&str>) -> String {
+ let mut fields = vec![
+ ("_csrf", csrf.to_owned()),
+ ("preset", "public".to_owned()),
+ ("site_name", "Test RustChan".to_owned()),
+ ("site_subtitle", String::new()),
+ ("default_theme", crate::theme::HARD_DEFAULT_THEME.to_owned()),
+ ("admin_username", "admin".to_owned()),
+ ("board_slug", board_slug.to_owned()),
+ ("board_name", "Test Board".to_owned()),
+ ("board_description", String::new()),
+ ("board_visibility", "public".to_owned()),
+ ("allow_posting", "1".to_owned()),
+ ("allow_uploads", "1".to_owned()),
+ ("allow_video", "1".to_owned()),
+ ("allow_pdf", "1".to_owned()),
+ ("allow_video_embeds", "1".to_owned()),
+ ("allow_thread_editing", "1".to_owned()),
+ ("allow_self_delete", "1".to_owned()),
+ ("allow_archive", "1".to_owned()),
+ ("image_limit_mib", "8".to_owned()),
+ ("video_limit_mib", "50".to_owned()),
+ ("audio_limit_mib", "150".to_owned()),
+ ("pdf_limit_mib", "7".to_owned()),
+ ("captcha_type", "builtin".to_owned()),
+ ("post_cooldown_secs", "0".to_owned()),
+ ("homepage_new_thread_badges_enabled", "1".to_owned()),
+ ("homepage_new_reply_badges_enabled", "1".to_owned()),
+ ("thread_new_reply_badges_enabled", "1".to_owned()),
+ ("backup_retention", "1".to_owned()),
+ ];
+ if let Some(password) = password {
+ fields.push(("admin_password", password.to_owned()));
+ fields.push(("admin_password_confirm", password.to_owned()));
+ }
+ fields
+ .into_iter()
+ .map(|(key, value)| format!("{key}={value}"))
+ .collect::
>()
+ .join("&")
+ }
+
+ fn setup_csrf_pair() -> (String, String) {
+ let raw = "csrf123".to_owned();
+ let form =
+ crypto::make_scoped_csrf_form_token(&raw, &CONFIG.cookie_secret, SETUP_CSRF_SCOPE);
+ (raw, form)
+ }
+
#[tokio::test]
async fn initialized_instance_blocks_setup_route() {
let state = crate::test_support::app_state();
@@ -1315,4 +1500,151 @@ mod tests {
let body = String::from_utf8(body.to_vec()).expect("utf8");
assert!(body.contains("Setup wizard is not available"));
}
+
+ #[tokio::test]
+ async fn initialized_instance_blocks_setup_post_routes() {
+ let state = crate::test_support::app_state();
+ {
+ let conn = state.db.get().expect("conn");
+ crate::db::mark_setup_complete(&conn).expect("complete");
+ }
+ let app = Router::new()
+ .route("/setup/review", post(setup_review))
+ .route("/setup/finish", post(setup_finish))
+ .with_state(state);
+ let (_raw_csrf, form_csrf) = setup_csrf_pair();
+ let body = setup_form_body(&form_csrf, "b", Some("long-enough-password"));
+
+ for uri in ["/setup/review", "/setup/finish"] {
+ let response = app
+ .clone()
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri(uri)
+ .header(header::HOST, "localhost")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, "csrf_token=csrf123")
+ .extension(crate::test_support::connect_info())
+ .body(Body::from(body.clone()))
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ }
+ }
+
+ #[tokio::test]
+ async fn setup_review_does_not_echo_admin_password() {
+ let state = crate::test_support::app_state();
+ let app = Router::new()
+ .route("/setup/review", post(setup_review))
+ .with_state(state);
+ let (_raw_csrf, form_csrf) = setup_csrf_pair();
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri("/setup/review")
+ .header(header::HOST, "localhost")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, "csrf_token=csrf123")
+ .extension(crate::test_support::connect_info())
+ .body(Body::from(setup_form_body(
+ &form_csrf,
+ "b",
+ Some("long-enough-password"),
+ )))
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ let body = String::from_utf8(body.to_vec()).expect("utf8");
+ assert!(!body.contains("long-enough-password"));
+ assert!(!body.contains("admin_password\""));
+ assert!(body.contains("admin_password_token"));
+ }
+
+ #[tokio::test]
+ async fn failed_setup_finish_does_not_mark_complete() {
+ let state = crate::test_support::app_state();
+ {
+ let conn = state.db.get().expect("conn");
+ crate::db::create_board(&conn, "b", "Existing", "", false).expect("board");
+ }
+ let app = Router::new()
+ .route("/setup/finish", post(setup_finish))
+ .with_state(state.clone());
+ let (_raw_csrf, form_csrf) = setup_csrf_pair();
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri("/setup/finish")
+ .header(header::HOST, "localhost")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, "csrf_token=csrf123")
+ .extension(crate::test_support::connect_info())
+ .body(Body::from(setup_form_body(
+ &form_csrf,
+ "b",
+ Some("long-enough-password"),
+ )))
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::CONFLICT);
+ let conn = state.db.get().expect("conn");
+ let setup_state = db::setup_state(&conn).expect("state");
+ assert!(!setup_state.completed);
+ }
+
+ #[tokio::test]
+ async fn setup_finish_marks_completion_durably_and_persists_pdf_limit() {
+ let state = crate::test_support::app_state();
+ let app = Router::new()
+ .route("/setup/finish", post(setup_finish))
+ .with_state(state.clone());
+ let (_raw_csrf, form_csrf) = setup_csrf_pair();
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri("/setup/finish")
+ .header(header::HOST, "localhost")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, "csrf_token=csrf123")
+ .extension(crate::test_support::connect_info())
+ .body(Body::from(setup_form_body(
+ &form_csrf,
+ "pdf",
+ Some("long-enough-password"),
+ )))
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::SEE_OTHER);
+ let conn = state.db.get().expect("conn");
+ let setup_state = db::setup_state(&conn).expect("state");
+ assert!(setup_state.completed);
+ assert!(!setup_state.reopened);
+ let board = db::get_board_by_short(&conn, "pdf")
+ .expect("load board")
+ .expect("board");
+ assert_eq!(board.max_pdf_size, 7 * MIB_I64);
+ }
}
diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs
index 80988e26..8788e2a3 100644
--- a/src/handlers/thread.rs
+++ b/src/handlers/thread.rs
@@ -315,6 +315,7 @@ pub async fn post_reply(
access_context.board.max_image_size_bytes(),
access_context.board.max_video_size_bytes(),
access_context.board.max_audio_size_bytes(),
+ access_context.board.max_pdf_size_bytes(),
),
)
.await
diff --git a/src/models.rs b/src/models.rs
index 22a93e67..bb1c3ace 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -280,6 +280,7 @@ pub struct Board {
pub max_image_size: i64, // per-board image upload size limit in bytes
pub max_video_size: i64, // per-board video upload size limit in bytes
pub max_audio_size: i64, // per-board audio upload size limit in bytes
+ pub max_pdf_size: i64, // per-board PDF upload size limit in bytes
pub allow_pdf: bool, // per-board PDF upload toggle (default: off)
pub allow_any_files: bool, // per-board arbitrary file upload toggle (default: off)
pub allow_tripcodes: bool,
@@ -324,11 +325,20 @@ impl Board {
.unwrap_or(crate::config::CONFIG.max_audio_size)
}
+ #[must_use]
+ pub fn max_pdf_size_bytes(&self) -> usize {
+ usize::try_from(self.max_pdf_size)
+ .ok()
+ .filter(|value| *value > 0)
+ .unwrap_or(crate::config::CONFIG.max_image_size)
+ }
+
#[must_use]
pub fn max_generic_upload_size_bytes(&self) -> usize {
self.max_image_size_bytes()
.max(self.max_video_size_bytes())
.max(self.max_audio_size_bytes())
+ .max(self.max_pdf_size_bytes())
}
}
diff --git a/src/server/server/routes.rs b/src/server/server/routes.rs
index 2aa5d7d4..cf02cc99 100644
--- a/src/server/server/routes.rs
+++ b/src/server/server/routes.rs
@@ -304,6 +304,10 @@ fn admin_moderation_routes() -> Router {
"/admin/setup/reopen",
post(crate::handlers::setup::admin_reopen_setup),
)
+ .route(
+ "/admin/setup/close",
+ post(crate::handlers::setup::admin_close_setup),
+ )
.route(
"/admin/db/repair",
get(crate::handlers::admin::admin_db_repair_status)
diff --git a/src/templates/admin.rs b/src/templates/admin.rs
index 4a84213d..de32df7a 100644
--- a/src/templates/admin.rs
+++ b/src/templates/admin.rs
@@ -679,8 +679,11 @@ fn render_board_settings_card(
Audio size limit (MiB)
+
+ PDF size limit (MiB)
+
- PDF and any-file uploads still use the largest enabled cap for this board.
+ PDF uploads use the PDF cap. Any-file uploads use the largest configured cap for this board.
Allow images
Allow video
@@ -801,6 +804,7 @@ fn render_board_settings_card(
bytes_to_mib(board.max_video_size, crate::config::CONFIG.max_video_size),
max_audio_size_mb =
bytes_to_mib(board.max_audio_size, crate::config::CONFIG.max_audio_size),
+ max_pdf_size_mb = bytes_to_mib(board.max_pdf_size, crate::config::CONFIG.max_image_size),
pdf_checked = checked(board.allow_pdf),
tripcodes_checked = checked(board.allow_tripcodes),
video_embeds_checked = checked(board.allow_video_embeds),
@@ -1736,6 +1740,7 @@ mod tests {
max_image_size: 8 * 1024 * 1024,
max_video_size: 50 * 1024 * 1024,
max_audio_size: 150 * 1024 * 1024,
+ max_pdf_size: 8 * 1024 * 1024,
allow_pdf: false,
allow_any_files: false,
allow_tripcodes: true,
@@ -2139,6 +2144,7 @@ mod tests {
max_image_size: 25 * 1024 * 1024,
max_video_size: 500 * 1024 * 1024,
max_audio_size: 300 * 1024 * 1024,
+ max_pdf_size: 12 * 1024 * 1024,
..sample_board()
};
let html = render_board_settings_card(
@@ -2154,14 +2160,16 @@ mod tests {
assert!(html.contains(r#"name="max_image_size_mb""#));
assert!(html.contains(r#"name="max_video_size_mb""#));
assert!(html.contains(r#"name="max_audio_size_mb""#));
+ assert!(html.contains(r#"name="max_pdf_size_mb""#));
assert!(html.contains(r#"value="25""#));
assert!(html.contains(r#"value="500""#));
assert!(html.contains(r#"value="300""#));
+ assert!(html.contains(r#"value="12""#));
assert!(!html.contains("Cannot exceed the site-wide"));
assert!(!html.contains(r#"max="8""#));
assert!(!html.contains(r#"max="50""#));
assert!(!html.contains(r#"max="150""#));
- assert!(html.contains("PDF and any-file uploads still use the largest enabled cap"));
+ assert!(html.contains("PDF uploads use the PDF cap"));
}
#[test]
diff --git a/src/templates/admin/maintenance.rs b/src/templates/admin/maintenance.rs
index c52cd6c0..2a426377 100644
--- a/src/templates/admin/maintenance.rs
+++ b/src/templates/admin/maintenance.rs
@@ -17,6 +17,7 @@ struct MaintenanceSectionView<'a> {
setup_status: &'a str,
setup_status_detail: &'a str,
setup_reopen_warning: &'a str,
+ setup_close_control: &'a str,
}
pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String {
@@ -81,6 +82,18 @@ old boards to prevent query performance degradation.
),
};
let setup_reopen_warning = "Reopening setup exposes live settings for editing. It does not replace existing admin credentials and still requires an authenticated admin session.";
+ let setup_close_control = if matches!(
+ view.maintenance.setup_status,
+ super::AdminPanelSetupStatus::Reopened
+ ) {
+ r#""#
+ } else {
+ ""
+ };
let section_view = MaintenanceSectionView {
csrf_token: view.csrf_token,
db_warn_banner: &db_warn_banner,
@@ -97,6 +110,7 @@ old boards to prevent query performance degradation.
setup_status,
setup_status_detail,
setup_reopen_warning,
+ setup_close_control,
};
render_admin_maintenance_section(§ion_view)
}
@@ -299,6 +313,7 @@ fn render_admin_maintenance_section(view: &MaintenanceSectionView<'_>) -> String
reopen setup wizard
+ {setup_close_control}
@@ -343,6 +358,9 @@ fn render_admin_maintenance_section(view: &MaintenanceSectionView<'_>) -> String
setup_status = escape_html(view.setup_status),
setup_status_detail = escape_html(view.setup_status_detail),
setup_reopen_warning = escape_html(view.setup_reopen_warning),
+ setup_close_control = view
+ .setup_close_control
+ .replace("{csrf}", &escape_html(view.csrf_token)),
tor_section = view.tor_section,
)
}
diff --git a/src/templates/forms.rs b/src/templates/forms.rs
index 89f29e6f..75ca1d98 100644
--- a/src/templates/forms.rs
+++ b/src/templates/forms.rs
@@ -95,9 +95,11 @@ fn render_single_upload_row(board: &Board, audio_image_hint: &str) -> String {
let image_max_bytes = board.max_image_size_bytes();
let video_max_bytes = board.max_video_size_bytes();
let audio_max_bytes = board.max_audio_size_bytes();
+ let pdf_max_bytes = board.max_pdf_size_bytes();
let image_mb = image_max_bytes / 1024 / 1024;
let video_mb = video_max_bytes / 1024 / 1024;
let audio_mb = audio_max_bytes / 1024 / 1024;
+ let pdf_mb = pdf_max_bytes / 1024 / 1024;
let generic_upload_mb = board.max_generic_upload_size_bytes() / 1024 / 1024;
let allow_any_files = CONFIG.enable_any_file_uploads_feature && board.allow_any_files;
let audio_image_dual_mode = board.allow_audio
@@ -125,7 +127,7 @@ fn render_single_upload_row(board: &Board, audio_image_hint: &str) -> String {
}
if board.allow_pdf {
accept_parts.push("application/pdf,.pdf");
- hint_parts.push(format!("pdf · max {generic_upload_mb} MiB"));
+ hint_parts.push(format!("pdf · max {pdf_mb} MiB"));
}
match (board.allow_images, board.allow_video) {
(true, true) => hint_parts.push("oversized images/videos can auto-compress".to_owned()),
diff --git a/src/test_fixtures.rs b/src/test_fixtures.rs
index b8ba9987..eedece57 100644
--- a/src/test_fixtures.rs
+++ b/src/test_fixtures.rs
@@ -27,6 +27,7 @@ pub fn sample_board() -> crate::models::Board {
max_image_size: 8 * 1024 * 1024,
max_video_size: 50 * 1024 * 1024,
max_audio_size: 150 * 1024 * 1024,
+ max_pdf_size: 8 * 1024 * 1024,
allow_pdf: false,
allow_any_files: false,
allow_tripcodes: true,
diff --git a/src/test_support.rs b/src/test_support.rs
index d6e852ea..2329e1d4 100644
--- a/src/test_support.rs
+++ b/src/test_support.rs
@@ -1,6 +1,7 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
pub fn app_state() -> crate::middleware::AppState {
+ crate::config::generate_settings_file_if_missing();
std::fs::create_dir_all(&crate::config::CONFIG.upload_dir).expect("create test upload dir");
std::fs::create_dir_all(crate::config::full_backups_dir())
.expect("create test full backup dir");
diff --git a/src/utils/files/storage.rs b/src/utils/files/storage.rs
index 6579dc9e..11a99349 100644
--- a/src/utils/files/storage.rs
+++ b/src/utils/files/storage.rs
@@ -29,6 +29,7 @@ pub struct SaveUploadOptions<'a> {
pub max_image_size: usize,
pub max_video_size: usize,
pub max_audio_size: usize,
+ pub max_pdf_size: usize,
pub ffmpeg_available: bool,
pub ffprobe_available: bool,
pub ffmpeg_webp_available: bool,
@@ -521,10 +522,12 @@ fn max_size_for_media(
crate::models::MediaType::Video => options.max_video_size,
crate::models::MediaType::Audio => options.max_audio_size,
crate::models::MediaType::Image => options.max_image_size,
- crate::models::MediaType::Pdf | crate::models::MediaType::Other => options
+ crate::models::MediaType::Pdf => options.max_pdf_size,
+ crate::models::MediaType::Other => options
.max_image_size
.max(options.max_video_size)
- .max(options.max_audio_size),
+ .max(options.max_audio_size)
+ .max(options.max_pdf_size),
}
}
@@ -947,6 +950,7 @@ mod tests {
max_image_size: 1024 * 1024,
max_video_size: 1024 * 1024,
max_audio_size: 1024 * 1024,
+ max_pdf_size: 1024 * 1024,
ffmpeg_available: false,
ffprobe_available: false,
ffmpeg_webp_available: false,
From de6f6dcb60a3abded76fff3088f6596f535e6c78 Mon Sep 17 00:00:00 2001
From: csd113
Date: Sun, 31 May 2026 19:37:10 -0700
Subject: [PATCH 10/26] Add thread management UI and backend support
---
src/handlers/admin/mod.rs | 674 +++++++++++++++++++++++++++++++++-
src/templates/admin.rs | 97 ++++-
src/templates/admin/layout.rs | 403 +++++++++++++++++++-
static/admin.css | 216 +++++++++++
4 files changed, 1369 insertions(+), 21 deletions(-)
diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs
index 6e9f622c..39bd034b 100644
--- a/src/handlers/admin/mod.rs
+++ b/src/handlers/admin/mod.rs
@@ -623,6 +623,7 @@ struct AdminPanelSnapshot {
pdf_thumbnail_renderer: Option,
backup_summary: BackupSummary,
site_health: SiteHealthSnapshot,
+ dashboard: AdminDashboardSummary,
}
#[derive(Clone)]
@@ -635,6 +636,76 @@ struct OverviewDomainData {
backup_summary: BackupSummary,
}
+#[derive(Clone)]
+struct AdminDashboardSummary {
+ version: String,
+ build: String,
+ setup_status: String,
+ setup_detail: String,
+ setup_state: crate::templates::AdminDashboardState,
+ site_title: String,
+ public_url: String,
+ db_status: String,
+ db_detail: String,
+ db_state: crate::templates::AdminDashboardState,
+ backup_status: String,
+ backup_detail: String,
+ backup_state: crate::templates::AdminDashboardState,
+ storage_status: String,
+ storage_detail: String,
+ storage_state: crate::templates::AdminDashboardState,
+ tor_status: String,
+ tor_detail: String,
+ tor_state: crate::templates::AdminDashboardState,
+ dependency_status: String,
+ dependency_detail: String,
+ dependency_state: crate::templates::AdminDashboardState,
+ job_status: String,
+ job_detail: String,
+ job_state: crate::templates::AdminDashboardState,
+ board_count: String,
+ thread_count: String,
+ post_count: String,
+ recent_activity: String,
+ media_summary: String,
+ report_status: String,
+ report_detail: String,
+ report_state: crate::templates::AdminDashboardState,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+struct DashboardActivitySnapshot {
+ board_count: usize,
+ active_threads: Option,
+ total_threads: Option,
+ total_posts: Option,
+ posts_24h: Option,
+ posts_7d: Option,
+ upload_posts: Option,
+ total_images: Option,
+ total_videos: Option,
+ total_audio: Option,
+ active_bytes: Option,
+ recent_reports_7d: Option,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct DashboardThreadCounts {
+ active: i64,
+ total: i64,
+}
+
+struct DashboardSummaryInputs<'a> {
+ activity: &'a DashboardActivitySnapshot,
+ moderation: &'a ModerationDomainData,
+ appearance: &'a AppearanceDomainData,
+ backup_summary: &'a BackupSummary,
+ maintenance: &'a MaintenanceDomainData,
+ setup_status: crate::templates::AdminPanelSetupStatus,
+ site_health: &'a SiteHealthSnapshot,
+ tor_address: Option<&'a str>,
+}
+
struct SiteHealthSnapshot {
server_status: String,
database_integrity_status: String,
@@ -787,6 +858,70 @@ fn load_site_health_snapshot(
}
}
+fn load_dashboard_activity_snapshot(
+ conn: &rusqlite::Connection,
+ board_count: usize,
+) -> DashboardActivitySnapshot {
+ let site_stats = db::get_site_stats(conn).ok();
+ let thread_counts = dashboard_thread_counts(conn);
+
+ DashboardActivitySnapshot {
+ board_count,
+ active_threads: thread_counts.map(|counts| counts.active),
+ total_threads: thread_counts.map(|counts| counts.total),
+ total_posts: site_stats.as_ref().map(|stats| stats.total_posts),
+ posts_24h: dashboard_recent_count(conn, "posts", 24 * 60 * 60),
+ posts_7d: dashboard_recent_count(conn, "posts", 7 * 24 * 60 * 60),
+ upload_posts: optional_count_query(
+ conn,
+ "SELECT COUNT(*) FROM posts
+ WHERE file_path IS NOT NULL OR audio_file_path IS NOT NULL",
+ ),
+ total_images: site_stats.as_ref().map(|stats| stats.total_images),
+ total_videos: site_stats.as_ref().map(|stats| stats.total_videos),
+ total_audio: site_stats.as_ref().map(|stats| stats.total_audio),
+ active_bytes: site_stats.map(|stats| stats.active_bytes),
+ recent_reports_7d: dashboard_recent_count(conn, "reports", 7 * 24 * 60 * 60),
+ }
+}
+
+fn dashboard_thread_counts(conn: &rusqlite::Connection) -> Option {
+ conn.query_row(
+ "SELECT
+ COALESCE(SUM(CASE WHEN archived = 0 THEN 1 ELSE 0 END), 0),
+ COUNT(*)
+ FROM threads",
+ [],
+ |row| {
+ Ok(DashboardThreadCounts {
+ active: row.get(0)?,
+ total: row.get(1)?,
+ })
+ },
+ )
+ .ok()
+}
+
+fn dashboard_recent_count(
+ conn: &rusqlite::Connection,
+ table_name: &str,
+ window_secs: i64,
+) -> Option {
+ if !matches!(table_name, "posts" | "reports") {
+ return None;
+ }
+ conn.query_row(
+ &format!("SELECT COUNT(*) FROM {table_name} WHERE created_at >= unixepoch() - ?1"),
+ rusqlite::params![window_secs.max(0)],
+ |row| row.get(0),
+ )
+ .ok()
+}
+
+fn optional_count_query(conn: &rusqlite::Connection, query: &str) -> Option {
+ conn.query_row(query, [], |row| row.get(0)).ok()
+}
+
fn load_site_health_jobs_snapshot(
conn: &rusqlite::Connection,
state: &AppState,
@@ -1137,6 +1272,7 @@ fn load_admin_panel_snapshot(
let overview_domain = load_overview_domain_data(&backups_domain.full_backups);
let maintenance_domain = load_maintenance_domain_data(conn, state);
let setup_state = db::setup_state(conn)?;
+ let setup_status = admin_panel_setup_status(setup_state);
let site_health = load_site_health_snapshot(
conn,
state,
@@ -1144,6 +1280,17 @@ fn load_admin_panel_snapshot(
&auto_full_backup_settings,
onion_address_val.as_deref(),
);
+ let dashboard_activity = load_dashboard_activity_snapshot(conn, boards_domain.boards.len());
+ let dashboard = build_admin_dashboard_summary(DashboardSummaryInputs {
+ activity: &dashboard_activity,
+ moderation: &moderation_domain,
+ appearance: &appearance_domain,
+ backup_summary: &overview_domain.backup_summary,
+ maintenance: &maintenance_domain,
+ setup_status,
+ site_health: &site_health,
+ tor_address: onion_address_val.as_deref(),
+ });
Ok((
AdminPanelSnapshot {
boards: boards_domain.boards,
@@ -1175,7 +1322,7 @@ fn load_admin_panel_snapshot(
board_backups: backups_domain.board_backups,
db_size_bytes: maintenance_domain.db_size_bytes,
db_size_warning: maintenance_domain.db_size_warning,
- setup_status: admin_panel_setup_status(setup_state),
+ setup_status,
ffmpeg_timeout_secs: maintenance_domain.ffmpeg_timeout_secs,
media_auto_prune_enabled: maintenance_domain.media_auto_prune_enabled,
media_max_active_content_size_bytes: maintenance_domain
@@ -1189,6 +1336,7 @@ fn load_admin_panel_snapshot(
pdf_thumbnail_renderer: maintenance_domain.pdf_thumbnail_renderer,
backup_summary: overview_domain.backup_summary,
site_health,
+ dashboard,
},
onion_address_val,
))
@@ -1252,6 +1400,349 @@ fn build_backup_summary(full_backups: &[BackupInfo]) -> BackupSummary {
}
}
+fn build_admin_dashboard_summary(inputs: DashboardSummaryInputs<'_>) -> AdminDashboardSummary {
+ let (setup_status, setup_detail, setup_state) = dashboard_setup_status(inputs.setup_status);
+ let (db_status, db_detail, db_state) = dashboard_database_status(inputs.site_health);
+ let (backup_status, backup_detail, backup_state) =
+ dashboard_backup_status(inputs.backup_summary);
+ let (storage_status, storage_detail, storage_state) =
+ dashboard_storage_status(inputs.activity, inputs.maintenance, inputs.site_health);
+ let (tor_status, tor_detail, tor_state) = dashboard_tor_status(inputs.tor_address);
+ let (dependency_status, dependency_detail, dependency_state) =
+ dashboard_dependency_status(inputs.maintenance);
+ let (job_status, job_detail, job_state) = dashboard_job_status(inputs.site_health);
+ let (report_status, report_detail, report_state) =
+ dashboard_report_status(inputs.activity, inputs.moderation);
+
+ AdminDashboardSummary {
+ version: env!("CARGO_PKG_VERSION").to_owned(),
+ build: format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH),
+ setup_status,
+ setup_detail,
+ setup_state,
+ site_title: inputs.appearance.site_name.clone(),
+ public_url: public_url_label(),
+ db_status,
+ db_detail,
+ db_state,
+ backup_status,
+ backup_detail,
+ backup_state,
+ storage_status,
+ storage_detail,
+ storage_state,
+ tor_status,
+ tor_detail,
+ tor_state,
+ dependency_status,
+ dependency_detail,
+ dependency_state,
+ job_status,
+ job_detail,
+ job_state,
+ board_count: count_label(inputs.activity.board_count, "board", "boards"),
+ thread_count: thread_count_label(inputs.activity),
+ post_count: optional_count_label(inputs.activity.total_posts, "post", "posts"),
+ recent_activity: recent_activity_label(inputs.activity),
+ media_summary: media_summary_label(inputs.activity),
+ report_status,
+ report_detail,
+ report_state,
+ }
+}
+
+fn dashboard_setup_status(
+ status: crate::templates::AdminPanelSetupStatus,
+) -> (String, String, crate::templates::AdminDashboardState) {
+ match status {
+ crate::templates::AdminPanelSetupStatus::Reopened => (
+ "reopened".to_owned(),
+ "Setup wizard is admin-only and currently reopened.".to_owned(),
+ crate::templates::AdminDashboardState::Warning,
+ ),
+ crate::templates::AdminPanelSetupStatus::Complete => (
+ "complete".to_owned(),
+ "Public setup routes are blocked.".to_owned(),
+ crate::templates::AdminDashboardState::Ok,
+ ),
+ crate::templates::AdminPanelSetupStatus::Available => (
+ "available".to_owned(),
+ "First-run setup still needs to be completed.".to_owned(),
+ crate::templates::AdminDashboardState::ActionNeeded,
+ ),
+ crate::templates::AdminPanelSetupStatus::Initialized => (
+ "initialized".to_owned(),
+ "Durable runtime state exists and public setup is blocked.".to_owned(),
+ crate::templates::AdminDashboardState::Ok,
+ ),
+ }
+}
+
+fn dashboard_database_status(
+ site_health: &SiteHealthSnapshot,
+) -> (String, String, crate::templates::AdminDashboardState) {
+ let state = if site_health.server_status != "ready"
+ || site_health.database_integrity_status.contains("failed")
+ {
+ crate::templates::AdminDashboardState::ActionNeeded
+ } else if site_health.database_integrity_status.contains("running") {
+ crate::templates::AdminDashboardState::Warning
+ } else if site_health.database_integrity_status == "not checked" {
+ crate::templates::AdminDashboardState::Unknown
+ } else {
+ crate::templates::AdminDashboardState::Ok
+ };
+ (
+ site_health.server_status.clone(),
+ format!("Integrity: {}.", site_health.database_integrity_status),
+ state,
+ )
+}
+
+fn dashboard_backup_status(
+ backup_summary: &BackupSummary,
+) -> (String, String, crate::templates::AdminDashboardState) {
+ let Some(warning) = backup_summary.warning.as_deref() else {
+ return (
+ "current".to_owned(),
+ backup_summary.status_line.clone(),
+ crate::templates::AdminDashboardState::Ok,
+ );
+ };
+
+ let (status, state) = if warning.starts_with("No saved full backup") {
+ (
+ "no full backup",
+ crate::templates::AdminDashboardState::ActionNeeded,
+ )
+ } else if warning.contains("failed verification") {
+ (
+ "verification failed",
+ crate::templates::AdminDashboardState::ActionNeeded,
+ )
+ } else {
+ (
+ "stale backup",
+ crate::templates::AdminDashboardState::Warning,
+ )
+ };
+ (status.to_owned(), warning.to_owned(), state)
+}
+
+fn dashboard_storage_status(
+ activity: &DashboardActivitySnapshot,
+ maintenance: &MaintenanceDomainData,
+ site_health: &SiteHealthSnapshot,
+) -> (String, String, crate::templates::AdminDashboardState) {
+ let active_media = activity.active_bytes.map_or_else(
+ || "unknown".to_owned(),
+ crate::utils::files::format_file_size,
+ );
+ let storage_known =
+ site_health.data_dir_usage != "unknown" && site_health.upload_dir_size != "unknown";
+ let over_prune_limit = activity.active_bytes.is_some_and(|bytes| {
+ maintenance.media_max_active_content_size_bytes > 0
+ && u64::try_from(bytes)
+ .is_ok_and(|bytes| bytes >= maintenance.media_max_active_content_size_bytes)
+ });
+ let state = if over_prune_limit {
+ crate::templates::AdminDashboardState::Warning
+ } else if storage_known {
+ crate::templates::AdminDashboardState::Ok
+ } else {
+ crate::templates::AdminDashboardState::Unknown
+ };
+ let status = if over_prune_limit {
+ "above prune threshold".to_owned()
+ } else {
+ format!("uploads {}", site_health.upload_dir_size)
+ };
+ (
+ status,
+ format!(
+ "Data directory {}; active media {}.",
+ site_health.data_dir_usage, active_media
+ ),
+ state,
+ )
+}
+
+fn dashboard_tor_status(
+ tor_address: Option<&str>,
+) -> (String, String, crate::templates::AdminDashboardState) {
+ if !CONFIG.enable_tor_support {
+ return (
+ "disabled".to_owned(),
+ "Tor support is disabled in configuration.".to_owned(),
+ crate::templates::AdminDashboardState::Disabled,
+ );
+ }
+ if tor_address.is_some() {
+ (
+ "onion ready".to_owned(),
+ "Tor support is enabled and an onion address is available.".to_owned(),
+ crate::templates::AdminDashboardState::Ok,
+ )
+ } else {
+ (
+ "enabled, address pending".to_owned(),
+ "Tor support is enabled but no onion address is currently available.".to_owned(),
+ crate::templates::AdminDashboardState::Warning,
+ )
+ }
+}
+
+fn dashboard_dependency_status(
+ maintenance: &MaintenanceDomainData,
+) -> (String, String, crate::templates::AdminDashboardState) {
+ let ffmpeg = detection_word(maintenance.ffmpeg_available);
+ let ffprobe = detection_word(maintenance.ffprobe_available);
+ let state = if maintenance.ffmpeg_available && maintenance.ffprobe_available {
+ crate::templates::AdminDashboardState::Ok
+ } else if CONFIG.require_ffmpeg {
+ crate::templates::AdminDashboardState::ActionNeeded
+ } else {
+ crate::templates::AdminDashboardState::Warning
+ };
+ let status = if maintenance.ffmpeg_available && maintenance.ffprobe_available {
+ "ready"
+ } else if CONFIG.require_ffmpeg {
+ "required tool missing"
+ } else {
+ "limited"
+ };
+ (
+ status.to_owned(),
+ format!(
+ "ffmpeg {ffmpeg}; ffprobe {ffprobe}; WebP {}; VP9 {}; Opus {}.",
+ detection_word(maintenance.ffmpeg_webp_available),
+ detection_word(maintenance.ffmpeg_vp9_encoder_available),
+ detection_word(maintenance.ffmpeg_opus_available)
+ ),
+ state,
+ )
+}
+
+fn dashboard_job_status(
+ site_health: &SiteHealthSnapshot,
+) -> (String, String, crate::templates::AdminDashboardState) {
+ let state = if site_health.failed_jobs > 0 {
+ crate::templates::AdminDashboardState::ActionNeeded
+ } else if site_health.running_jobs > 0
+ || site_health.queued_jobs > 0
+ || site_health.backup_jobs != "idle"
+ {
+ crate::templates::AdminDashboardState::Warning
+ } else if site_health.backup_jobs == "unknown" {
+ crate::templates::AdminDashboardState::Unknown
+ } else {
+ crate::templates::AdminDashboardState::Ok
+ };
+ let status = if site_health.failed_jobs > 0 {
+ format!("{} failed", site_health.failed_jobs)
+ } else if site_health.running_jobs > 0 || site_health.queued_jobs > 0 {
+ format!(
+ "{} running / {} queued",
+ site_health.running_jobs, site_health.queued_jobs
+ )
+ } else {
+ "idle".to_owned()
+ };
+ (
+ status,
+ format!(
+ "Recently completed {}; backup job {}; restore jobs {}.",
+ site_health.recent_completed_jobs, site_health.backup_jobs, site_health.restore_jobs
+ ),
+ state,
+ )
+}
+
+fn dashboard_report_status(
+ activity: &DashboardActivitySnapshot,
+ moderation: &ModerationDomainData,
+) -> (String, String, crate::templates::AdminDashboardState) {
+ let open_reports = moderation.reports.len();
+ let open_appeals = moderation.appeals.len();
+ let recent_reports = activity.recent_reports_7d;
+ let state = if open_reports > 0 || open_appeals > 0 {
+ crate::templates::AdminDashboardState::ActionNeeded
+ } else if recent_reports.is_some_and(|count| count > 0) {
+ crate::templates::AdminDashboardState::Warning
+ } else if recent_reports.is_some() {
+ crate::templates::AdminDashboardState::Ok
+ } else {
+ crate::templates::AdminDashboardState::Unknown
+ };
+ let status = if open_reports == 0 {
+ "no open reports".to_owned()
+ } else {
+ count_label(open_reports, "open report", "open reports")
+ };
+ let recent = recent_reports.map_or_else(|| "unknown".to_owned(), |count| count.to_string());
+ (
+ status,
+ format!("{recent} reports in 7d; {open_appeals} open appeals."),
+ state,
+ )
+}
+
+fn public_url_label() -> String {
+ let Some(host) = CONFIG.public_hosts.first().filter(|host| !host.is_empty()) else {
+ return "not configured".to_owned();
+ };
+ let scheme = if CONFIG.tls.enabled { "https" } else { "http" };
+ format!("{scheme}://{host}")
+}
+
+fn count_label(count: usize, singular: &str, plural: &str) -> String {
+ if count == 1 {
+ format!("1 {singular}")
+ } else {
+ format!("{count} {plural}")
+ }
+}
+
+fn optional_count_label(count: Option, singular: &str, plural: &str) -> String {
+ match count {
+ Some(1) => format!("1 {singular}"),
+ Some(count) => format!("{count} {plural}"),
+ None => "unknown".to_owned(),
+ }
+}
+
+fn thread_count_label(activity: &DashboardActivitySnapshot) -> String {
+ match (activity.active_threads, activity.total_threads) {
+ (Some(active), Some(total)) => format!("{active} active / {total} total"),
+ _ => "unknown".to_owned(),
+ }
+}
+
+fn recent_activity_label(activity: &DashboardActivitySnapshot) -> String {
+ match (activity.posts_24h, activity.posts_7d) {
+ (Some(day), Some(week)) => format!("{day} posts in 24h; {week} in 7d"),
+ _ => "recent post activity unknown".to_owned(),
+ }
+}
+
+fn media_summary_label(activity: &DashboardActivitySnapshot) -> String {
+ match (
+ activity.upload_posts,
+ activity.total_images,
+ activity.total_videos,
+ activity.total_audio,
+ activity.active_bytes,
+ ) {
+ (Some(upload_posts), Some(images), Some(videos), Some(audio), Some(active_bytes)) => {
+ let active = crate::utils::files::format_file_size(active_bytes);
+ format!(
+ "{upload_posts} upload posts; {images} images, {videos} video, {audio} audio; {active} active"
+ )
+ }
+ _ => "media summary unknown".to_owned(),
+ }
+}
+
fn render_admin_panel_from_snapshot(
snapshot: AdminPanelSnapshot,
csrf_token: &str,
@@ -1271,6 +1762,7 @@ fn render_admin_panel_from_snapshot(
csrf_token,
boards: &snapshot.boards,
current_theme,
+ dashboard: build_dashboard_view(&snapshot.dashboard),
moderation: crate::templates::AdminPanelModerationView {
bans: &snapshot.bans,
filters: &snapshot.filters,
@@ -1347,6 +1839,46 @@ fn render_admin_panel_from_snapshot(
crate::templates::admin_panel_page(&view)
}
+fn build_dashboard_view(
+ dashboard: &AdminDashboardSummary,
+) -> crate::templates::AdminPanelDashboardView<'_> {
+ crate::templates::AdminPanelDashboardView {
+ version: &dashboard.version,
+ build: &dashboard.build,
+ setup_status: &dashboard.setup_status,
+ setup_detail: &dashboard.setup_detail,
+ setup_state: dashboard.setup_state,
+ site_title: &dashboard.site_title,
+ public_url: &dashboard.public_url,
+ db_status: &dashboard.db_status,
+ db_detail: &dashboard.db_detail,
+ db_state: dashboard.db_state,
+ backup_status: &dashboard.backup_status,
+ backup_detail: &dashboard.backup_detail,
+ backup_state: dashboard.backup_state,
+ storage_status: &dashboard.storage_status,
+ storage_detail: &dashboard.storage_detail,
+ storage_state: dashboard.storage_state,
+ tor_status: &dashboard.tor_status,
+ tor_detail: &dashboard.tor_detail,
+ tor_state: dashboard.tor_state,
+ dependency_status: &dashboard.dependency_status,
+ dependency_detail: &dashboard.dependency_detail,
+ dependency_state: dashboard.dependency_state,
+ job_status: &dashboard.job_status,
+ job_detail: &dashboard.job_detail,
+ job_state: dashboard.job_state,
+ board_count: &dashboard.board_count,
+ thread_count: &dashboard.thread_count,
+ post_count: &dashboard.post_count,
+ recent_activity: &dashboard.recent_activity,
+ media_summary: &dashboard.media_summary,
+ report_status: &dashboard.report_status,
+ report_detail: &dashboard.report_detail,
+ report_state: dashboard.report_state,
+ }
+}
+
fn build_site_health_view<'a>(
snapshot: &'a AdminPanelSnapshot,
tor_address: Option<&'a str>,
@@ -1413,9 +1945,9 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st
Tor enabled: {tor_enabled} ({tor_detail})\n\
TLS enabled: {tls_enabled}\n\
Reverse proxy: {reverse_proxy}\n\
- Data path: {data_path}\n\
- Main log directory: {main_log_dir}\n\
- Dependency log: {dependency_log}\n\
+ Data directory: configured\n\
+ Main log directory: configured\n\
+ Dependency log: configured\n\
Recent warnings:\n{warnings}\n",
version = env!("CARGO_PKG_VERSION"),
os = std::env::consts::OS,
@@ -1423,9 +1955,6 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st
sqlite = rusqlite::version(),
ffmpeg = detection_word(snapshot.ffmpeg_available),
ffprobe = detection_word(snapshot.ffprobe_available),
- data_path = crate::config::data_dir().display(),
- main_log_dir = crate::config::logs_dir().display(),
- dependency_log = crate::logging::dependency_log_path(&crate::config::logs_dir()).display(),
warnings = indent_diagnostics_block(&snapshot.site_health.recent_warnings),
)
}
@@ -1710,11 +2239,13 @@ pub(super) fn consume_admin_session_bootstrap(token: &str) -> Option {
mod tests {
use super::{
admin_live_log, admin_site_health_jobs, consume_admin_session_bootstrap,
- create_admin_session_bootstrap, host_header_uses_https_port_with_config,
- hosts_match_for_same_origin, latest_log_file, read_log_tail, request_origin_uses_https,
+ create_admin_session_bootstrap, dashboard_backup_status, dashboard_recent_count,
+ dashboard_thread_counts, host_header_uses_https_port_with_config,
+ hosts_match_for_same_origin, latest_log_file, load_dashboard_activity_snapshot,
+ optional_count_query, read_log_tail, request_origin_uses_https,
request_scheme_for_same_origin_with_config, require_same_origin_or_valid_csrf,
- require_same_origin_request, should_set_secure_cookie_with_config, LiveLogQuery,
- SESSION_COOKIE,
+ require_same_origin_request, should_set_secure_cookie_with_config, BackupSummary,
+ LiveLogQuery, SESSION_COOKIE,
};
use crate::error::AppError;
use crate::middleware::SecureCookieContext;
@@ -1731,6 +2262,127 @@ mod tests {
const TEST_ONION_HTTPS_ORIGIN: &str =
"https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion";
+ #[test]
+ fn dashboard_count_helpers_fail_closed_when_schema_is_missing() {
+ let conn = rusqlite::Connection::open_in_memory().expect("memory db");
+
+ assert_eq!(
+ optional_count_query(&conn, "SELECT COUNT(*) FROM posts"),
+ None
+ );
+ assert_eq!(dashboard_thread_counts(&conn), None);
+ assert_eq!(dashboard_recent_count(&conn, "posts", 24 * 60 * 60), None);
+ assert_eq!(
+ dashboard_recent_count(&conn, "sqlite_master", 24 * 60 * 60),
+ None
+ );
+ }
+
+ #[test]
+ fn dashboard_activity_snapshot_counts_existing_systems() {
+ let pool = crate::db::init_test_pool().expect("test pool");
+ let conn = pool.get().expect("db connection");
+ let board_id =
+ crate::db::create_board(&conn, "dash", "Dashboard", "", false).expect("board");
+ let now = chrono::Utc::now().timestamp();
+
+ conn.execute(
+ "INSERT INTO threads (board_id, subject, created_at, bumped_at, archived)
+ VALUES (?1, 'active', ?2, ?2, 0)",
+ rusqlite::params![board_id, now],
+ )
+ .expect("active thread");
+ let active_thread_id = conn.last_insert_rowid();
+ conn.execute(
+ "INSERT INTO posts
+ (thread_id, board_id, body, body_html, file_path, file_name, file_size,
+ thumb_path, mime_type, media_type, audio_file_path, audio_file_name,
+ audio_file_size, audio_mime_type, created_at, deletion_token, is_op)
+ VALUES
+ (?1, ?2, 'active body', 'active body', 'dash/image.webp', 'image.webp', 11,
+ 'dash/thumb.webp', 'image/webp', 'image', 'dash/audio.ogg', 'audio.ogg',
+ 7, 'audio/ogg', ?3, 'delete-active', 1)",
+ rusqlite::params![active_thread_id, board_id, now],
+ )
+ .expect("active post");
+ let active_post_id = conn.last_insert_rowid();
+
+ conn.execute(
+ "INSERT INTO threads (board_id, subject, created_at, bumped_at, archived)
+ VALUES (?1, 'archived', ?2, ?2, 1)",
+ rusqlite::params![board_id, now - (8 * 24 * 60 * 60)],
+ )
+ .expect("archived thread");
+ let archived_thread_id = conn.last_insert_rowid();
+ conn.execute(
+ "INSERT INTO posts
+ (thread_id, board_id, body, body_html, file_path, file_name, file_size,
+ thumb_path, mime_type, media_type, created_at, deletion_token, is_op)
+ VALUES
+ (?1, ?2, 'archived body', 'archived body', 'dash/video.webm', 'video.webm',
+ 1000, 'dash/video-thumb.webp', 'video/webm', 'video', ?3, 'delete-archived', 1)",
+ rusqlite::params![archived_thread_id, board_id, now - (8 * 24 * 60 * 60)],
+ )
+ .expect("archived post");
+
+ conn.execute(
+ "INSERT INTO reports (post_id, thread_id, board_id, reason, reporter_hash, created_at)
+ VALUES (?1, ?2, ?3, 'needs review', 'reporter', ?4)",
+ rusqlite::params![active_post_id, active_thread_id, board_id, now],
+ )
+ .expect("report");
+
+ let activity = load_dashboard_activity_snapshot(&conn, 1);
+
+ assert_eq!(activity.board_count, 1);
+ assert_eq!(activity.active_threads, Some(1));
+ assert_eq!(activity.total_threads, Some(2));
+ assert_eq!(activity.total_posts, Some(2));
+ assert_eq!(activity.posts_24h, Some(1));
+ assert_eq!(activity.posts_7d, Some(1));
+ assert_eq!(activity.upload_posts, Some(2));
+ assert_eq!(activity.total_images, Some(1));
+ assert_eq!(activity.total_videos, Some(1));
+ assert_eq!(activity.total_audio, Some(1));
+ assert_eq!(activity.active_bytes, Some(18));
+ assert_eq!(activity.recent_reports_7d, Some(1));
+ }
+
+ #[test]
+ fn dashboard_backup_status_classifies_warning_and_ok_states() {
+ let missing = BackupSummary {
+ warning: Some(
+ "No saved full backup found. Create and download a verified full backup before relying on this node."
+ .to_owned(),
+ ),
+ status_line: "Latest full backup: none saved.".to_owned(),
+ };
+ let stale = BackupSummary {
+ warning: Some(
+ "Latest verified full backup 'backup.zip' is older than 72 hours (96h ago)."
+ .to_owned(),
+ ),
+ status_line: "Latest full backup: backup.zip (96h ago) - verified.".to_owned(),
+ };
+ let ok = BackupSummary {
+ warning: None,
+ status_line: "Latest full backup: backup.zip (1h ago) - verified.".to_owned(),
+ };
+
+ assert_eq!(
+ dashboard_backup_status(&missing).2,
+ crate::templates::AdminDashboardState::ActionNeeded
+ );
+ assert_eq!(
+ dashboard_backup_status(&stale).2,
+ crate::templates::AdminDashboardState::Warning
+ );
+ assert_eq!(
+ dashboard_backup_status(&ok).2,
+ crate::templates::AdminDashboardState::Ok
+ );
+ }
+
fn same_origin_headers(host: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
diff --git a/src/templates/admin.rs b/src/templates/admin.rs
index de32df7a..4af24f1f 100644
--- a/src/templates/admin.rs
+++ b/src/templates/admin.rs
@@ -83,6 +83,7 @@ pub struct AdminPanelViewModel<'a> {
pub csrf_token: &'a str,
pub boards: &'a [Board],
pub current_theme: Option<&'a str>,
+ pub dashboard: AdminPanelDashboardView<'a>,
pub moderation: AdminPanelModerationView<'a>,
pub appearance: AdminPanelAppearanceView<'a>,
pub site_health: AdminPanelSiteHealthView<'a>,
@@ -93,6 +94,51 @@ pub struct AdminPanelViewModel<'a> {
pub open_section: Option<&'a str>,
}
+pub struct AdminPanelDashboardView<'a> {
+ pub version: &'a str,
+ pub build: &'a str,
+ pub setup_status: &'a str,
+ pub setup_detail: &'a str,
+ pub setup_state: AdminDashboardState,
+ pub site_title: &'a str,
+ pub public_url: &'a str,
+ pub db_status: &'a str,
+ pub db_detail: &'a str,
+ pub db_state: AdminDashboardState,
+ pub backup_status: &'a str,
+ pub backup_detail: &'a str,
+ pub backup_state: AdminDashboardState,
+ pub storage_status: &'a str,
+ pub storage_detail: &'a str,
+ pub storage_state: AdminDashboardState,
+ pub tor_status: &'a str,
+ pub tor_detail: &'a str,
+ pub tor_state: AdminDashboardState,
+ pub dependency_status: &'a str,
+ pub dependency_detail: &'a str,
+ pub dependency_state: AdminDashboardState,
+ pub job_status: &'a str,
+ pub job_detail: &'a str,
+ pub job_state: AdminDashboardState,
+ pub board_count: &'a str,
+ pub thread_count: &'a str,
+ pub post_count: &'a str,
+ pub recent_activity: &'a str,
+ pub media_summary: &'a str,
+ pub report_status: &'a str,
+ pub report_detail: &'a str,
+ pub report_state: AdminDashboardState,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AdminDashboardState {
+ Ok,
+ Warning,
+ ActionNeeded,
+ Disabled,
+ Unknown,
+}
+
pub struct AdminPanelModerationView<'a> {
pub bans: &'a [Ban],
pub filters: &'a [WordFilter],
@@ -1711,10 +1757,11 @@ pub fn admin_ip_history_page(
mod tests {
use super::{
admin_db_health_result_page, admin_db_repair_idle_page, admin_login_page, admin_panel_page,
- render_board_appearance_card, render_board_settings_card, AdminDetectionStatus,
- AdminMediaDetectionView, AdminPanelAppearanceView, AdminPanelBackupsView,
- AdminPanelMaintenanceView, AdminPanelModerationView, AdminPanelSetupStatus,
- AdminPanelSiteHealthView, AdminPanelViewModel, AdminSiteHealthDependencySummary,
+ render_board_appearance_card, render_board_settings_card, AdminDashboardState,
+ AdminDetectionStatus, AdminMediaDetectionView, AdminPanelAppearanceView,
+ AdminPanelBackupsView, AdminPanelDashboardView, AdminPanelMaintenanceView,
+ AdminPanelModerationView, AdminPanelSetupStatus, AdminPanelSiteHealthView,
+ AdminPanelViewModel, AdminSiteHealthDependencySummary,
};
use crate::db::{DbCheckResult, DbHealthReport, DbHealthSnapshot};
use crate::models::{
@@ -1932,6 +1979,44 @@ mod tests {
}
}
+ fn sample_dashboard() -> AdminPanelDashboardView<'static> {
+ AdminPanelDashboardView {
+ version: "1.3.0",
+ build: "test/test",
+ setup_status: "complete",
+ setup_detail: "Public setup routes are blocked.",
+ setup_state: AdminDashboardState::Ok,
+ site_title: "RustChan",
+ public_url: "not configured",
+ db_status: "ready",
+ db_detail: "Integrity: not checked.",
+ db_state: AdminDashboardState::Unknown,
+ backup_status: "current",
+ backup_detail: "All saved backups verified.",
+ backup_state: AdminDashboardState::Ok,
+ storage_status: "uploads unknown",
+ storage_detail: "Data directory unknown; active media unknown.",
+ storage_state: AdminDashboardState::Unknown,
+ tor_status: "disabled",
+ tor_detail: "Tor support is disabled in configuration.",
+ tor_state: AdminDashboardState::Disabled,
+ dependency_status: "ready",
+ dependency_detail: "ffmpeg found; ffprobe found; WebP found; VP9 found; Opus found.",
+ dependency_state: AdminDashboardState::Ok,
+ job_status: "idle",
+ job_detail: "Recently completed 0; backup job idle; restore jobs not available.",
+ job_state: AdminDashboardState::Ok,
+ board_count: "1 board",
+ thread_count: "0 active / 0 total",
+ post_count: "0 posts",
+ recent_activity: "0 posts in 24h; 0 in 7d",
+ media_summary: "0 upload posts; 0 images, 0 video, 0 audio; 0 B active",
+ report_status: "no open reports",
+ report_detail: "0 reports in 7d; 0 open appeals.",
+ report_state: AdminDashboardState::Ok,
+ }
+ }
+
fn render_admin_panel_for_test(
boards: &[Board],
reports: &[ReportWithContext],
@@ -1964,6 +2049,7 @@ mod tests {
csrf_token: "csrf",
boards,
current_theme: None,
+ dashboard: sample_dashboard(),
moderation: AdminPanelModerationView {
bans: &[],
filters: &[],
@@ -2604,6 +2690,7 @@ mod tests {
csrf_token: "csrf",
boards: std::slice::from_ref(&board),
current_theme: Some("blue-sky"),
+ dashboard: sample_dashboard(),
moderation: AdminPanelModerationView {
bans: &[],
filters: &[],
@@ -2694,6 +2781,7 @@ mod tests {
csrf_token: "csrf",
boards: std::slice::from_ref(&board),
current_theme: Some("blue-sky"),
+ dashboard: sample_dashboard(),
moderation: AdminPanelModerationView {
bans: &[],
filters: &[],
@@ -2775,6 +2863,7 @@ mod tests {
csrf_token: "csrf",
boards: std::slice::from_ref(&board),
current_theme: Some("blue-sky"),
+ dashboard: sample_dashboard(),
moderation: AdminPanelModerationView {
bans: &[],
filters: &[],
diff --git a/src/templates/admin/layout.rs b/src/templates/admin/layout.rs
index a2f9cf87..3d3959bd 100644
--- a/src/templates/admin/layout.rs
+++ b/src/templates/admin/layout.rs
@@ -1,12 +1,13 @@
use super::{
appearance, backups, base_layout, boards, escape_html, maintenance, moderation, site_health,
};
-use super::{AdminPanelFlash, AdminPanelViewModel};
+use super::{AdminDashboardState, AdminPanelFlash, AdminPanelViewModel};
+use std::fmt::Write as _;
pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String {
let flash_html = render_flash(view.flash);
let section_index = render_admin_section_index();
- let overview_section = render_admin_overview_section();
+ let overview_section = render_admin_overview_section(view);
let site_settings_section = appearance::render_site_settings(view);
let site_health_section = site_health::render(view);
let boards_section = boards::render(view);
@@ -88,6 +89,7 @@ fn render_flash(flash: Option>) -> String {
const fn render_admin_section_index() -> &'static str {
r##"
jump to
+ control center
site settings
site health
boards
@@ -98,8 +100,11 @@ const fn render_admin_section_index() -> &'static str {
"##
}
-fn render_admin_overview_section() -> String {
- r#"
+fn render_admin_overview_section(view: &AdminPanelViewModel<'_>) -> String {
+ let dashboard = render_admin_dashboard_section(view);
+ format!(
+ r#"
+{dashboard}
@@ -122,6 +127,392 @@ fn render_admin_overview_section() -> String {
-
"#
- .to_owned()
+"#,
+ )
+}
+
+fn render_admin_dashboard_section(view: &AdminPanelViewModel<'_>) -> String {
+ let dashboard = &view.dashboard;
+ let overview_cards = render_dashboard_overview_cards(view);
+ let health_cards = render_dashboard_health_cards(view);
+ let activity_cards = render_dashboard_activity_cards(view);
+ let quick_actions = render_dashboard_quick_actions(view);
+
+ format!(
+ r#"
+
+
+
+
+
+
+
+ {quick_actions}
+
+ "#,
+ site_title = escape_html(dashboard.site_title),
+ overall_status = render_dashboard_overall_status(dashboard),
+ )
+}
+
+fn render_dashboard_overall_status(dashboard: &super::AdminPanelDashboardView<'_>) -> String {
+ let state = [
+ dashboard.setup_state,
+ dashboard.db_state,
+ dashboard.backup_state,
+ dashboard.storage_state,
+ dashboard.tor_state,
+ dashboard.dependency_state,
+ dashboard.job_state,
+ dashboard.report_state,
+ ]
+ .into_iter()
+ .max_by_key(|state| state_severity(*state))
+ .unwrap_or(AdminDashboardState::Unknown);
+ let label = match state {
+ AdminDashboardState::Ok => "OK",
+ AdminDashboardState::Warning => "Warning",
+ AdminDashboardState::ActionNeeded => "Action needed",
+ AdminDashboardState::Disabled => "Disabled",
+ AdminDashboardState::Unknown => "Unknown",
+ };
+ render_state_pill(state, label)
+}
+
+const fn state_severity(state: AdminDashboardState) -> u8 {
+ match state {
+ AdminDashboardState::ActionNeeded => 4,
+ AdminDashboardState::Warning => 3,
+ AdminDashboardState::Unknown => 2,
+ AdminDashboardState::Disabled => 1,
+ AdminDashboardState::Ok => 0,
+ }
+}
+
+fn render_dashboard_overview_cards(view: &AdminPanelViewModel<'_>) -> String {
+ let dashboard = &view.dashboard;
+ let mut out = String::new();
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Version and build",
+ value: dashboard.version,
+ detail: dashboard.build,
+ state: AdminDashboardState::Ok,
+ href: Some("#site-health"),
+ action: Some("site health"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Setup status",
+ value: dashboard.setup_status,
+ detail: dashboard.setup_detail,
+ state: dashboard.setup_state,
+ href: Some("#database-maintenance"),
+ action: Some("setup controls"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Site title",
+ value: dashboard.site_title,
+ detail: "Rendered from saved site settings.",
+ state: AdminDashboardState::Ok,
+ href: Some("#site-settings"),
+ action: Some("site settings"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Public URL",
+ value: dashboard.public_url,
+ detail: "Configured public host, when available.",
+ state: if dashboard.public_url == "not configured" {
+ AdminDashboardState::Unknown
+ } else {
+ AdminDashboardState::Ok
+ },
+ href: Some("/"),
+ action: Some("open home"),
+ },
+ );
+ out
+}
+
+fn render_dashboard_health_cards(view: &AdminPanelViewModel<'_>) -> String {
+ let dashboard = &view.dashboard;
+ let mut out = String::new();
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Database",
+ value: dashboard.db_status,
+ detail: dashboard.db_detail,
+ state: dashboard.db_state,
+ href: Some("#database-maintenance"),
+ action: Some("maintenance"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Backups",
+ value: dashboard.backup_status,
+ detail: dashboard.backup_detail,
+ state: dashboard.backup_state,
+ href: Some("#full-backup-restore"),
+ action: Some("backups"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Storage",
+ value: dashboard.storage_status,
+ detail: dashboard.storage_detail,
+ state: dashboard.storage_state,
+ href: Some("#media-settings"),
+ action: Some("media settings"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Tor",
+ value: dashboard.tor_status,
+ detail: dashboard.tor_detail,
+ state: dashboard.tor_state,
+ href: Some("#database-maintenance"),
+ action: Some("maintenance"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Media tools",
+ value: dashboard.dependency_status,
+ detail: dashboard.dependency_detail,
+ state: dashboard.dependency_state,
+ href: Some("#media-settings"),
+ action: Some("dependencies"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Jobs",
+ value: dashboard.job_status,
+ detail: dashboard.job_detail,
+ state: dashboard.job_state,
+ href: Some("#site-health"),
+ action: Some("job details"),
+ },
+ );
+ out
+}
+
+fn render_dashboard_activity_cards(view: &AdminPanelViewModel<'_>) -> String {
+ let dashboard = &view.dashboard;
+ let mut out = String::new();
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Boards",
+ value: dashboard.board_count,
+ detail: "Configured board directory.",
+ state: AdminDashboardState::Ok,
+ href: Some("#boards"),
+ action: Some("manage boards"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Threads",
+ value: dashboard.thread_count,
+ detail: "Live and total thread counts.",
+ state: AdminDashboardState::Ok,
+ href: Some("#boards"),
+ action: Some("board tools"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Posts",
+ value: dashboard.post_count,
+ detail: dashboard.recent_activity,
+ state: AdminDashboardState::Ok,
+ href: Some("/"),
+ action: Some("home"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Uploads",
+ value: dashboard.media_summary,
+ detail: "Active media bytes are counted from stored post metadata.",
+ state: AdminDashboardState::Ok,
+ href: Some("#media-settings"),
+ action: Some("media"),
+ },
+ );
+ append_dashboard_metric(
+ &mut out,
+ DashboardMetric {
+ label: "Reports",
+ value: dashboard.report_status,
+ detail: dashboard.report_detail,
+ state: dashboard.report_state,
+ href: Some("#reports"),
+ action: Some("moderation"),
+ },
+ );
+ out
+}
+
+fn render_dashboard_quick_actions(view: &AdminPanelViewModel<'_>) -> String {
+ let close_setup = if view.dashboard.setup_state == AdminDashboardState::Warning
+ && view.dashboard.setup_status == "reopened"
+ {
+ format!(
+ r#""#,
+ csrf = escape_html(view.csrf_token),
+ )
+ } else {
+ String::new()
+ };
+
+ format!(
+ r##""##,
+ csrf = escape_html(view.csrf_token),
+ close_setup = close_setup,
+ )
+}
+
+#[derive(Clone, Copy)]
+struct DashboardMetric<'a> {
+ label: &'a str,
+ value: &'a str,
+ detail: &'a str,
+ state: AdminDashboardState,
+ href: Option<&'a str>,
+ action: Option<&'a str>,
+}
+
+fn append_dashboard_metric(out: &mut String, metric: DashboardMetric<'_>) {
+ let action = match (metric.href, metric.action) {
+ (Some(href), Some(action)) => format!(
+ r#"{action} "#,
+ href = escape_html(href),
+ action = escape_html(action),
+ ),
+ _ => String::new(),
+ };
+ let _ = write!(
+ out,
+ r#"
+
+ {label}
+ {pill}
+
+ {value}
+ {detail}
+ {action}
+ "#,
+ state_class = state_class(metric.state),
+ label = escape_html(metric.label),
+ pill = render_state_pill(metric.state, state_label(metric.state)),
+ value = escape_html(metric.value),
+ detail = escape_html(metric.detail),
+ action = action,
+ );
+}
+
+fn render_state_pill(state: AdminDashboardState, label: &str) -> String {
+ format!(
+ r#"{label} "#,
+ class = state_class(state),
+ label = escape_html(label),
+ )
+}
+
+const fn state_class(state: AdminDashboardState) -> &'static str {
+ match state {
+ AdminDashboardState::Ok => "ok",
+ AdminDashboardState::Warning => "warning",
+ AdminDashboardState::ActionNeeded => "action-needed",
+ AdminDashboardState::Disabled => "disabled",
+ AdminDashboardState::Unknown => "unknown",
+ }
+}
+
+const fn state_label(state: AdminDashboardState) -> &'static str {
+ match state {
+ AdminDashboardState::Ok => "OK",
+ AdminDashboardState::Warning => "warning",
+ AdminDashboardState::ActionNeeded => "action",
+ AdminDashboardState::Disabled => "disabled",
+ AdminDashboardState::Unknown => "unknown",
+ }
}
diff --git a/static/admin.css b/static/admin.css
index 3b7a4791..bfc2ea02 100644
--- a/static/admin.css
+++ b/static/admin.css
@@ -158,6 +158,191 @@ html:not([data-theme]) .admin-panel h1 {
letter-spacing: 0.04em;
}
+.admin-control-center {
+ display: grid;
+ gap: 1rem;
+}
+
+.admin-control-center-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ min-width: 0;
+}
+
+.admin-control-center-header h2 {
+ margin-bottom: 0.35rem;
+}
+
+.admin-control-center-status {
+ flex: 0 0 auto;
+}
+
+.admin-dashboard-block {
+ display: grid;
+ gap: 0.65rem;
+ min-width: 0;
+}
+
+.admin-dashboard-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
+ gap: 0.75rem;
+}
+
+.admin-dashboard-grid-overview {
+ grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
+}
+
+.admin-dashboard-card {
+ display: grid;
+ gap: 0.45rem;
+ min-width: 0;
+ min-height: 9rem;
+ padding: 0.85rem 0.9rem;
+ border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
+ background: color-mix(in srgb, var(--bg-input) 40%, transparent);
+ border-radius: var(--radius-md, 6px);
+}
+
+.admin-dashboard-card-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.6rem;
+ min-width: 0;
+}
+
+.admin-dashboard-card-top > span:first-child {
+ min-width: 0;
+ color: var(--text-dim);
+ font-size: 0.76rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ overflow-wrap: anywhere;
+}
+
+.admin-dashboard-card strong {
+ min-width: 0;
+ color: var(--text);
+ font-size: 1rem;
+ line-height: 1.25;
+ overflow-wrap: anywhere;
+}
+
+.admin-dashboard-card p {
+ margin: 0;
+ color: var(--text-dim);
+ font-size: 0.78rem;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+
+.admin-dashboard-card-link {
+ align-self: end;
+ justify-self: start;
+ color: var(--green-pale);
+ font-size: 0.78rem;
+ text-decoration: underline;
+ text-underline-offset: 0.14em;
+}
+
+.admin-dashboard-card-link:hover,
+.admin-dashboard-card-link:focus-visible {
+ color: var(--green-bright);
+}
+
+.admin-state-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 1.35rem;
+ padding: 0.08rem 0.45rem;
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ font-size: 0.68rem;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.admin-state-pill-ok {
+ color: var(--green-bright);
+ background: rgba(0, 255, 65, 0.07);
+}
+
+.admin-state-pill-warning {
+ color: var(--yellow, #f4c95d);
+ background: rgba(244, 201, 93, 0.08);
+}
+
+.admin-state-pill-action-needed {
+ color: var(--red-bright, #ff7070);
+ background: rgba(255, 80, 80, 0.1);
+}
+
+.admin-state-pill-disabled,
+.admin-state-pill-unknown {
+ color: var(--text-dim);
+ background: color-mix(in srgb, var(--bg-panel) 66%, transparent);
+}
+
+.admin-dashboard-card-warning {
+ border-color: color-mix(in srgb, var(--yellow, #f4c95d) 48%, var(--border));
+}
+
+.admin-dashboard-card-action-needed {
+ border-color: color-mix(in srgb, var(--red-bright, #ff7070) 50%, var(--border));
+}
+
+.admin-dashboard-card-unknown,
+.admin-dashboard-card-disabled {
+ border-style: dashed;
+}
+
+.admin-dashboard-actions {
+ display: flex;
+ align-items: stretch;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+}
+
+.admin-dashboard-actions > * {
+ flex: 0 1 auto;
+}
+
+.admin-dashboard-action-details {
+ position: relative;
+ min-width: min(100%, 13rem);
+ border: 1px solid var(--border);
+ background: var(--bg-input);
+ border-radius: var(--radius-sm, 3px);
+}
+
+.admin-dashboard-action-details > summary {
+ display: flex;
+ align-items: center;
+ min-height: 2.2rem;
+ padding: 0.35rem 0.7rem;
+ color: var(--green-pale);
+ cursor: pointer;
+ list-style: none;
+}
+
+.admin-dashboard-action-details > summary::-webkit-details-marker {
+ display: none;
+}
+
+.admin-dashboard-action-details[open] {
+ padding-bottom: 0.6rem;
+}
+
+.admin-dashboard-action-details form,
+.admin-dashboard-action-details p,
+.admin-dashboard-action-details a {
+ margin: 0.5rem 0.6rem 0;
+}
+
.admin-section form {
display: flex;
flex-wrap: wrap;
@@ -2712,6 +2897,37 @@ html:not([data-theme]) .admin-login h1 {
grid-template-columns: 1fr;
}
+ .admin-control-center-header,
+ .admin-dashboard-card-top {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .admin-control-center-status {
+ width: 100%;
+ }
+
+ .admin-dashboard-grid,
+ .admin-dashboard-grid-overview {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-dashboard-card {
+ min-height: 0;
+ }
+
+ .admin-dashboard-actions {
+ flex-direction: column;
+ }
+
+ .admin-dashboard-actions > *,
+ .admin-dashboard-action-details,
+ .admin-dashboard-action-details .admin-link-button,
+ .admin-dashboard-action-details button {
+ width: 100%;
+ box-sizing: border-box;
+ }
+
.admin-health-row {
align-items: flex-start;
flex-direction: column;
From c42ff7e7b3ee3a867afa9afd2f1487f1a790035f Mon Sep 17 00:00:00 2001
From: csd113
Date: Sun, 31 May 2026 21:09:55 -0700
Subject: [PATCH 11/26] Add 1.3.0 DB baseline and schema verification
Squash the pre-release migration ladder into a single 1.3.0 baseline: fresh installs create and stamp the baseline schema directly and preexisting databases that match are marked as 1.3.0. Replace numeric legacy versioning with a textual BASELINE_SCHEMA_VERSION and update read/stamp logic to manage the baseline. Implement thorough structural schema verification (object/table/column/index/foreign-key shape, required SQL fragments, quick_check/foreign_key_check) and expose helpers to verify/normalize the database schema; DB health snapshots now include a schema check. Add site-setting backed acknowledgement for failed background jobs and adjust admin counters/queries so acknowledged failures are excluded, plus tests for the new behavior. Documentation (CHANGELOG, README, SETUP) and admin/handler/template plumbing updated to reflect the new baseline and admin tooling.
---
CHANGELOG.md | 1 +
README.md | 7 +
SETUP.md | 7 +
src/db/admin.rs | 23 +-
src/db/migrations.rs | 70 +-
src/db/mod.rs | 30 +
src/db/posts.rs | 98 +-
src/db/schema.rs | 1421 ++++++++-------------
src/handlers/admin/backup/archive.rs | 2 +-
src/handlers/admin/backup/common.rs | 107 +-
src/handlers/admin/backup/restore_full.rs | 6 +
src/handlers/admin/backup/v4.rs | 49 +-
src/handlers/admin/mod.rs | 180 ++-
src/handlers/setup.rs | 118 +-
src/main.rs | 1 +
src/server/cli.rs | 45 +-
src/server/server/observability.rs | 56 +-
src/server/server/router.rs | 1 +
src/server/server/routes.rs | 4 +
src/templates/admin.rs | 66 +-
src/templates/admin/appearance.rs | 11 +
src/templates/admin/layout.rs | 22 +-
src/templates/admin/site_health.rs | 66 +-
src/utils/files.rs | 7 +
src/utils/files/storage.rs | 14 +-
static/admin.css | 38 +
26 files changed, 1444 insertions(+), 1006 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9797a50..a24d55e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ All notable changes to RustChan will be documented in this file.
- Hardened upload handling for empty file controls, zero-byte named uploads, empty thumbnail payloads, invalid media, and cross-board media deduplication.
- Improved activity badge cache behavior, especially on mobile WebKit and browser back/forward navigation.
- Hardened settings validation so invalid config values fail closed instead of silently falling back.
+- Squashed the pre-release internal database migration ladder into a clean `1.3.0` baseline schema. Fresh databases now install that baseline directly, structurally matching in-development databases are stamped as schema version `1.3.0`, and partial or unknown schemas fail closed with diagnostics instead of blind migration attempts.
- Polished responsive layout, long-content wrapping, modal focus behavior, ESC handling, touch targets, and light-theme error contrast.
- Updated backup UI metadata handling and dynamic split-part options.
- Refreshed README screenshots and release documentation.
diff --git a/README.md b/README.md
index 8a109a44..e89ea055 100644
--- a/README.md
+++ b/README.md
@@ -163,6 +163,12 @@ rustchan-data/
That folder is the thing to back up if you want to move the site or keep it safe.
+For RustChan `1.3.0`, fresh databases are created directly from the `1.3.0`
+baseline schema. Earlier internal development migrations were squashed before
+release; a database that already matches the baseline is marked as schema
+version `1.3.0`, while partial or unknown schemas fail closed without deleting
+data.
+
`settings.toml` is generated automatically on first run and documents the available options inline. A few of the more important ones:
```toml
@@ -214,6 +220,7 @@ CLI admin commands include:
- `rustchan-cli admin ban`
- `rustchan-cli admin unban`
- `rustchan-cli admin list-bans`
+- `rustchan-cli admin db-status`
Run `rustchan-cli admin --help` for the full command list and flags.
diff --git a/SETUP.md b/SETUP.md
index 98ed2747..63e028f3 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -599,6 +599,13 @@ Before major updates, back up:
Or use the built-in backup tools from the admin panel.
+RustChan `1.3.0` resets the database baseline: fresh installs create the
+current `1.3.0` schema directly instead of replaying pre-release internal
+migrations. Existing in-development databases that structurally match that
+schema are marked as database schema version `1.3.0`; partial, unknown, or
+corrupt schemas are rejected without deleting data. Future released schema
+changes should add normal forward migrations tied to RustChan release versions.
+
## Troubleshooting
### The TUI shows ffmpeg warnings
diff --git a/src/db/admin.rs b/src/db/admin.rs
index 4c39a507..6cca004d 100644
--- a/src/db/admin.rs
+++ b/src/db/admin.rs
@@ -39,6 +39,7 @@ impl DbCheckResult {
#[derive(Debug, Clone)]
pub struct DbHealthSnapshot {
+ pub schema: DbCheckResult,
pub integrity: DbCheckResult,
pub foreign_keys: DbCheckResult,
}
@@ -46,7 +47,7 @@ pub struct DbHealthSnapshot {
impl DbHealthSnapshot {
#[must_use]
pub const fn ok(&self) -> bool {
- self.integrity.ok && self.foreign_keys.ok
+ self.schema.ok && self.integrity.ok && self.foreign_keys.ok
}
}
@@ -902,11 +903,31 @@ fn foreign_key_check_status(conn: &rusqlite::Connection) -> DbCheckResult {
fn db_health_snapshot(conn: &rusqlite::Connection) -> DbHealthSnapshot {
DbHealthSnapshot {
+ schema: schema_check_status(conn),
integrity: integrity_check_status(conn),
foreign_keys: foreign_key_check_status(conn),
}
}
+fn schema_check_status(conn: &rusqlite::Connection) -> DbCheckResult {
+ match super::schema::verify_database_schema(conn) {
+ Ok(()) => DbCheckResult {
+ ok: true,
+ messages: vec![format!(
+ "{} baseline verified",
+ super::schema::baseline_schema_version()
+ )],
+ },
+ Err(error) => DbCheckResult {
+ ok: false,
+ messages: vec![format!(
+ "{} baseline mismatch: {error}",
+ super::schema::baseline_schema_version()
+ )],
+ },
+ }
+}
+
fn rebuild_posts_fts(conn: &rusqlite::Connection) -> Result<()> {
conn.execute_batch(
r"
diff --git a/src/db/migrations.rs b/src/db/migrations.rs
index 5a9107df..bed1f791 100644
--- a/src/db/migrations.rs
+++ b/src/db/migrations.rs
@@ -1,48 +1,49 @@
// src/db/migrations.rs
use anyhow::{Context as _, Result};
+use rusqlite::OptionalExtension as _;
-/// Post-squash schema version for the canonical baseline.
-///
-/// Earlier development builds used a long numbered ladder up to v41. Fresh
-/// installs now create the complete current schema directly and stamp this
-/// clean baseline as v1; pre-squash databases use one legacy bridge before
-/// joining the same version line.
-pub(super) const POST_SQUASH_SCHEMA_VERSION: i64 = 1;
+/// Database schema version for the `RustChan` 1.3.0 release baseline.
+pub(super) const BASELINE_SCHEMA_VERSION: &str = "1.3.0";
-pub(super) fn read_schema_version(conn: &rusqlite::Connection) -> Result {
+pub(super) fn read_schema_version(conn: &rusqlite::Connection) -> Result> {
if !schema_version_table_exists(conn)? {
- return Ok(0);
+ return Ok(None);
}
conn.query_row(
- "SELECT COALESCE(MAX(version), 0) FROM schema_version",
+ "SELECT CAST(version AS TEXT) FROM schema_version LIMIT 1",
[],
|row| row.get(0),
)
+ .optional()
.context("Failed to read schema_version")
}
-pub(super) fn stamp_schema_version(conn: &rusqlite::Connection, version: i64) -> Result<()> {
- ensure_schema_version_table_has_row(conn)?;
+pub(super) fn stamp_schema_version(conn: &rusqlite::Connection) -> Result<()> {
conn.execute_batch("BEGIN IMMEDIATE")
- .with_context(|| format!("Failed to begin schema_version stamp to v{version}"))?;
+ .context("Failed to begin schema_version stamp to 1.3.0")?;
let result = (|| {
- conn.execute("DELETE FROM schema_version", [])
- .context("Failed to clear schema_version table")?;
+ conn.execute_batch(
+ "DROP TABLE IF EXISTS schema_version;
+ CREATE TABLE schema_version (
+ version TEXT NOT NULL PRIMARY KEY
+ );",
+ )
+ .context("Failed to recreate schema_version table")?;
conn.execute(
"INSERT INTO schema_version (version) VALUES (?1)",
- rusqlite::params![version],
+ rusqlite::params![BASELINE_SCHEMA_VERSION],
)
- .with_context(|| format!("Failed to set schema_version to v{version}"))?;
+ .context("Failed to set schema_version to 1.3.0")?;
Ok(())
})();
match result {
Ok(()) => conn
.execute_batch("COMMIT")
- .with_context(|| format!("Failed to commit schema_version stamp to v{version}")),
+ .context("Failed to commit schema_version stamp to 1.3.0"),
Err(error) => {
let _ = conn.execute_batch("ROLLBACK");
Err(error)
@@ -50,19 +51,6 @@ pub(super) fn stamp_schema_version(conn: &rusqlite::Connection, version: i64) ->
}
}
-fn ensure_schema_version_table_has_row(conn: &rusqlite::Connection) -> Result<()> {
- conn.execute_batch(
- "CREATE TABLE IF NOT EXISTS schema_version (
- version INTEGER NOT NULL DEFAULT 0,
- UNIQUE(version)
- );
- INSERT INTO schema_version (version)
- SELECT 0
- WHERE NOT EXISTS (SELECT 1 FROM schema_version);",
- )
- .context("Failed to create schema_version table")
-}
-
fn schema_version_table_exists(conn: &rusqlite::Connection) -> Result {
conn.query_row(
"SELECT EXISTS (
@@ -78,17 +66,20 @@ fn schema_version_table_exists(conn: &rusqlite::Connection) -> Result {
#[cfg(test)]
mod tests {
- use super::{read_schema_version, stamp_schema_version, POST_SQUASH_SCHEMA_VERSION};
+ use super::{read_schema_version, stamp_schema_version, BASELINE_SCHEMA_VERSION};
#[test]
- fn missing_schema_version_reads_as_legacy_zero() {
+ fn missing_schema_version_reads_as_unversioned() {
let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite");
- assert_eq!(read_schema_version(&conn).expect("read schema version"), 0);
+ assert_eq!(
+ read_schema_version(&conn).expect("read schema version"),
+ None
+ );
}
#[test]
- fn stamp_replaces_existing_version_with_post_squash_baseline() {
+ fn stamp_replaces_existing_version_with_release_baseline() {
let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite");
conn.execute_batch(
"CREATE TABLE schema_version (
@@ -99,11 +90,16 @@ mod tests {
)
.expect("create legacy schema_version");
- stamp_schema_version(&conn, POST_SQUASH_SCHEMA_VERSION).expect("stamp schema version");
+ stamp_schema_version(&conn).expect("stamp schema version");
assert_eq!(
read_schema_version(&conn).expect("read stamped schema version"),
- POST_SQUASH_SCHEMA_VERSION
+ Some(BASELINE_SCHEMA_VERSION.to_owned())
);
}
+
+ #[test]
+ fn baseline_schema_version_matches_package_release() {
+ assert_eq!(BASELINE_SCHEMA_VERSION, env!("CARGO_PKG_VERSION"));
+ }
}
diff --git a/src/db/mod.rs b/src/db/mod.rs
index d2d27b42..d59e1391 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -36,6 +36,36 @@ pub use themes::*;
pub use threads::*;
pub use user_thread_prefs::*;
+/// Return the database schema version for the current release baseline.
+#[must_use]
+pub const fn baseline_schema_version() -> &'static str {
+ schema::baseline_schema_version()
+}
+
+/// Verify that the open database exactly matches the current release baseline.
+///
+/// # Errors
+/// Returns an error if integrity checks fail, schema objects differ from the
+/// baseline, or the recorded schema version is not current.
+pub fn verify_database_schema(conn: &rusqlite::Connection) -> Result<()> {
+ schema::verify_database_schema(conn)
+}
+
+/// Verify the database baseline and stamp the current schema version when safe.
+///
+/// # Errors
+/// Returns an error if the database does not structurally match the current
+/// release baseline or cannot be stamped with the current schema version.
+pub fn normalize_database_schema_version(conn: &rusqlite::Connection) -> Result<()> {
+ schema::normalize_database_schema_version(conn)
+}
+
+/// Return a human-readable database schema status label for diagnostics.
+#[must_use]
+pub fn database_schema_status_label(conn: &rusqlite::Connection) -> String {
+ schema::database_schema_status_label(conn)
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeletePathsResult {
pub paths: Vec,
diff --git a/src/db/posts.rs b/src/db/posts.rs
index 2cb73cb6..f95de126 100644
--- a/src/db/posts.rs
+++ b/src/db/posts.rs
@@ -1122,6 +1122,8 @@ pub fn cleanup_expired_poll_votes(
// Jobs flow through: pending → running → done | failed
// claim_next_job uses UPDATE … RETURNING for atomic claim with no TOCTOU race.
+const FAILED_BACKGROUND_JOBS_ACK_ID_KEY: &str = "failed_background_jobs_acknowledged_through_id";
+
/// Persist a new job in the pending state. Returns the new row id.
///
/// INSERT … RETURNING id replaces execute + `last_insert_rowid()`.
@@ -1244,14 +1246,15 @@ pub fn pending_job_count(conn: &rusqlite::Connection) -> Result {
/// # Errors
/// Returns an error if the database queries fail.
pub fn background_job_summary(conn: &rusqlite::Connection) -> Result {
+ let acknowledged_failed_id = acknowledged_failed_background_job_id(conn)?;
let (running, queued, recent_completed, failed) = conn.query_row(
"SELECT
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'done' AND updated_at >= unixepoch() - 86400 THEN 1 ELSE 0 END),
- SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END)
+ SUM(CASE WHEN status = 'failed' AND id > ?1 THEN 1 ELSE 0 END)
FROM background_jobs",
- [],
+ params![acknowledged_failed_id],
|row| {
Ok((
row.get::<_, Option>(0)?.unwrap_or(0),
@@ -1269,6 +1272,37 @@ pub fn background_job_summary(conn: &rusqlite::Connection) -> Result Result {
+ let value = super::get_site_setting(conn, FAILED_BACKGROUND_JOBS_ACK_ID_KEY)?;
+ Ok(value
+ .as_deref()
+ .and_then(|value| value.parse::().ok())
+ .unwrap_or(0)
+ .max(0))
+}
+
+/// Mark all currently failed background jobs as acknowledged for admin counters.
+///
+/// This intentionally preserves the `background_jobs` rows, payloads, and
+/// errors. Only the admin attention counter is reset until a newer failure
+/// appears.
+///
+/// # Errors
+/// Returns an error if the database queries or site-setting update fail.
+pub fn acknowledge_failed_background_jobs(conn: &rusqlite::Connection) -> Result {
+ let max_failed_id = conn.query_row(
+ "SELECT COALESCE(MAX(id), 0) FROM background_jobs WHERE status = 'failed'",
+ [],
+ |row| row.get::<_, i64>(0),
+ )?;
+ super::set_site_setting(
+ conn,
+ FAILED_BACKGROUND_JOBS_ACK_ID_KEY,
+ &max_failed_id.to_string(),
+ )?;
+ Ok(max_failed_id)
+}
+
/// Return recent terminal background jobs for bounded admin diagnostics.
///
/// # Errors
@@ -1573,12 +1607,13 @@ pub fn delete_file_hash_by_path(conn: &rusqlite::Connection, file_path: &str) ->
#[cfg(test)]
mod tests {
use super::{
- claim_next_job, count_posts_by_media_processing_state, count_search_results, get_post,
- get_post_submission, get_posts_for_thread, is_stale_media_target_error,
- recent_background_jobs, record_post_submission, recover_interrupted_background_jobs,
- replace_transcoded_media, search_posts, search_terms, self_delete_post,
- set_post_media_processing_state, to_fts_query, update_post_thumb_path, SelfDeleteOutcome,
- MEDIA_PROCESSING_FAILED, MEDIA_PROCESSING_PENDING,
+ acknowledge_failed_background_jobs, background_job_summary, claim_next_job,
+ count_posts_by_media_processing_state, count_search_results, get_post, get_post_submission,
+ get_posts_for_thread, is_stale_media_target_error, recent_background_jobs,
+ record_post_submission, recover_interrupted_background_jobs, replace_transcoded_media,
+ search_posts, search_terms, self_delete_post, set_post_media_processing_state,
+ to_fts_query, update_post_thumb_path, SelfDeleteOutcome, MEDIA_PROCESSING_FAILED,
+ MEDIA_PROCESSING_PENDING,
};
use crate::db::{
create_board, create_reply_with_thread_update, create_thread_with_optional_poll,
@@ -1709,6 +1744,53 @@ mod tests {
assert!(older < newer);
}
+ #[test]
+ fn failed_background_job_acknowledgement_preserves_history() {
+ let conn = test_conn();
+ let payload = r#"{"t":"SpamCheck","d":{"post_id":1,"ip_hash":"hash","body_len":5}}"#;
+ insert_background_job(&conn, "spam_check", payload, "failed", 3, Some("older"));
+ let acknowledged =
+ insert_background_job(&conn, "thread_prune", payload, "failed", 3, Some("newer"));
+
+ assert_eq!(
+ background_job_summary(&conn)
+ .expect("summary before ack")
+ .failed,
+ 2
+ );
+ assert_eq!(
+ acknowledge_failed_background_jobs(&conn).expect("acknowledge failed jobs"),
+ acknowledged
+ );
+ assert_eq!(
+ background_job_summary(&conn)
+ .expect("summary after ack")
+ .failed,
+ 0
+ );
+ assert_eq!(
+ recent_background_jobs(&conn, "failed", 10)
+ .expect("recent failed jobs")
+ .len(),
+ 2
+ );
+
+ insert_background_job(
+ &conn,
+ "spam_check",
+ payload,
+ "failed",
+ 3,
+ Some("new failure"),
+ );
+ assert_eq!(
+ background_job_summary(&conn)
+ .expect("summary after new failure")
+ .failed,
+ 1
+ );
+ }
+
#[test]
fn search_query_ignores_punctuation_only_input() {
assert_eq!(to_fts_query("'"), None);
diff --git a/src/db/schema.rs b/src/db/schema.rs
index 28f3de23..fce3e588 100644
--- a/src/db/schema.rs
+++ b/src/db/schema.rs
@@ -1,8 +1,9 @@
// src/db/schema.rs
-use anyhow::{Context as _, Result};
+use anyhow::{bail, Context as _, Result};
+use std::collections::{BTreeMap, BTreeSet};
-use super::migrations::{read_schema_version, stamp_schema_version, POST_SQUASH_SCHEMA_VERSION};
+use super::migrations::{read_schema_version, stamp_schema_version, BASELINE_SCHEMA_VERSION};
const BASE_SCHEMA_SQL: &str = "
CREATE TABLE IF NOT EXISTS boards (
@@ -310,235 +311,57 @@ const INDEX_SCHEMA_SQL: &str = "
ON post_submissions(created_at ASC);
";
-const LEGACY_BASELINE_COLUMN_ADDITIONS: [(&str, &str, &str); 34] = [
- (
- "boards",
- "display_order",
- "ALTER TABLE boards ADD COLUMN display_order INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "max_archived_threads",
- "ALTER TABLE boards ADD COLUMN max_archived_threads INTEGER NOT NULL DEFAULT 150",
- ),
- (
- "boards",
- "allow_video",
- "ALTER TABLE boards ADD COLUMN allow_video INTEGER NOT NULL DEFAULT 1",
- ),
- (
- "boards",
- "allow_tripcodes",
- "ALTER TABLE boards ADD COLUMN allow_tripcodes INTEGER NOT NULL DEFAULT 1",
- ),
- (
- "boards",
- "allow_images",
- "ALTER TABLE boards ADD COLUMN allow_images INTEGER NOT NULL DEFAULT 1",
- ),
- (
- "boards",
- "allow_audio",
- "ALTER TABLE boards ADD COLUMN allow_audio INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "max_image_size",
- "ALTER TABLE boards ADD COLUMN max_image_size INTEGER NOT NULL DEFAULT 8388608",
- ),
- (
- "boards",
- "max_video_size",
- "ALTER TABLE boards ADD COLUMN max_video_size INTEGER NOT NULL DEFAULT 52428800",
- ),
- (
- "boards",
- "max_audio_size",
- "ALTER TABLE boards ADD COLUMN max_audio_size INTEGER NOT NULL DEFAULT 157286400",
- ),
- (
- "boards",
- "max_pdf_size",
- "ALTER TABLE boards ADD COLUMN max_pdf_size INTEGER NOT NULL DEFAULT 157286400",
- ),
- (
- "boards",
- "allow_pdf",
- "ALTER TABLE boards ADD COLUMN allow_pdf INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "allow_any_files",
- "ALTER TABLE boards ADD COLUMN allow_any_files INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "edit_window_secs",
- "ALTER TABLE boards ADD COLUMN edit_window_secs INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "allow_editing",
- "ALTER TABLE boards ADD COLUMN allow_editing INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "allow_self_delete",
- "ALTER TABLE boards ADD COLUMN allow_self_delete INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "allow_archive",
- "ALTER TABLE boards ADD COLUMN allow_archive INTEGER NOT NULL DEFAULT 1",
- ),
- (
- "boards",
- "allow_video_embeds",
- "ALTER TABLE boards ADD COLUMN allow_video_embeds INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "allow_captcha",
- "ALTER TABLE boards ADD COLUMN allow_captcha INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "show_poster_ids",
- "ALTER TABLE boards ADD COLUMN show_poster_ids INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "collapse_greentext",
- "ALTER TABLE boards ADD COLUMN collapse_greentext INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "post_cooldown_secs",
- "ALTER TABLE boards ADD COLUMN post_cooldown_secs INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "boards",
- "default_theme",
- "ALTER TABLE boards ADD COLUMN default_theme TEXT NOT NULL DEFAULT ''",
- ),
- (
- "boards",
- "banner_mode",
- "ALTER TABLE boards ADD COLUMN banner_mode TEXT NOT NULL DEFAULT 'inherit'
- CHECK (banner_mode IN ('inherit', 'none', 'override'))",
- ),
- (
- "boards",
- "access_mode",
- "ALTER TABLE boards ADD COLUMN access_mode TEXT NOT NULL DEFAULT 'public'
- CHECK (access_mode IN ('public', 'view_password', 'post_password'))",
- ),
- (
- "boards",
- "access_password_hash",
- "ALTER TABLE boards ADD COLUMN access_password_hash TEXT NOT NULL DEFAULT ''",
- ),
- (
- "threads",
- "archived",
- "ALTER TABLE threads ADD COLUMN archived INTEGER NOT NULL DEFAULT 0",
- ),
- (
- "posts",
- "media_type",
- "ALTER TABLE posts ADD COLUMN media_type TEXT",
- ),
- (
- "posts",
- "audio_file_path",
- "ALTER TABLE posts ADD COLUMN audio_file_path TEXT",
- ),
- (
- "posts",
- "audio_file_name",
- "ALTER TABLE posts ADD COLUMN audio_file_name TEXT",
- ),
- (
- "posts",
- "audio_file_size",
- "ALTER TABLE posts ADD COLUMN audio_file_size INTEGER",
- ),
- (
- "posts",
- "audio_mime_type",
- "ALTER TABLE posts ADD COLUMN audio_mime_type TEXT",
- ),
- (
- "posts",
- "edited_at",
- "ALTER TABLE posts ADD COLUMN edited_at INTEGER",
- ),
- (
- "posts",
- "media_processing_state",
- "ALTER TABLE posts ADD COLUMN media_processing_state TEXT NOT NULL DEFAULT ''",
- ),
- (
- "posts",
- "media_processing_error",
- "ALTER TABLE posts ADD COLUMN media_processing_error TEXT",
- ),
-];
-
pub(super) fn install_or_migrate_schema(conn: &rusqlite::Connection) -> Result<()> {
- let fresh_database = is_fresh_database(conn)?;
- if fresh_database {
- install_post_squash_baseline(conn)?;
- } else {
- let schema_version = read_schema_version(conn)?;
- if schema_version == POST_SQUASH_SCHEMA_VERSION && has_post_squash_baseline_markers(conn)? {
- finish_baseline_schema(conn)?;
- } else {
- upgrade_pre_squash_database_to_v1(conn)?;
+ if is_fresh_database(conn)? {
+ install_baseline_schema(conn)?;
+ return Ok(());
+ }
+
+ normalize_database_schema_version(conn)
+}
+
+pub(super) const fn baseline_schema_version() -> &'static str {
+ BASELINE_SCHEMA_VERSION
+}
+
+pub(super) fn verify_database_schema(conn: &rusqlite::Connection) -> Result<()> {
+ verify_database_schema_structure(conn)?;
+ match read_schema_version(conn)? {
+ Some(version) if version == BASELINE_SCHEMA_VERSION => Ok(()),
+ Some(version) => {
+ bail!("database schema version is {version}, expected {BASELINE_SCHEMA_VERSION}")
}
+ None => bail!("database schema version is missing, expected {BASELINE_SCHEMA_VERSION}"),
}
+}
- Ok(())
+pub(super) fn normalize_database_schema_version(conn: &rusqlite::Connection) -> Result<()> {
+ verify_database_schema_structure(conn)?;
+ if read_schema_version(conn)?.as_deref() != Some(BASELINE_SCHEMA_VERSION) {
+ stamp_schema_version(conn)?;
+ }
+ verify_database_schema(conn)
}
-fn install_post_squash_baseline(conn: &rusqlite::Connection) -> Result<()> {
- finish_baseline_schema(conn)?;
- stamp_schema_version(conn, POST_SQUASH_SCHEMA_VERSION)
+pub(super) fn database_schema_status_label(conn: &rusqlite::Connection) -> String {
+ match verify_database_schema(conn) {
+ Ok(()) => format!("{BASELINE_SCHEMA_VERSION} baseline verified"),
+ Err(error) => format!("{BASELINE_SCHEMA_VERSION} baseline mismatch: {error}"),
+ }
}
-fn finish_baseline_schema(conn: &rusqlite::Connection) -> Result<()> {
- create_base_tables(conn)?;
- // Post-squash databases can already be stamped at v1 while still missing
- // additive baseline columns introduced later. Keep the baseline repair
- // idempotent so existing installs receive new board/post/thread columns.
- ensure_legacy_columns_for_baseline(conn)?;
- create_indexes(conn)?;
- ensure_reports_table_integrity(conn)?;
- ensure_posts_ip_hash_nullable(conn)?;
- backfill_media_type(conn)?;
- // The posts table may be rebuilt by compatibility repairs above, so create
- // FTS and post triggers only after those table-level repairs are complete.
- ensure_posts_search_index(conn)?;
- ensure_post_invariants(conn)?;
- ensure_board_access_invariants(conn)?;
- Ok(())
+fn install_baseline_schema(conn: &rusqlite::Connection) -> Result<()> {
+ create_baseline_schema_objects(conn)?;
+ stamp_schema_version(conn)?;
+ verify_database_schema(conn)
}
-fn upgrade_pre_squash_database_to_v1(conn: &rusqlite::Connection) -> Result<()> {
- // Legacy databases may have any old historical schema_version up through
- // the removed early-development ladder. Bring them to the same canonical
- // post-squash baseline as a fresh install, then stamp v1 only after every
- // compatibility step succeeds.
+fn create_baseline_schema_objects(conn: &rusqlite::Connection) -> Result<()> {
create_base_tables(conn)?;
- ensure_legacy_columns_for_baseline(conn)?;
create_indexes(conn)?;
- ensure_reports_table_integrity(conn)?;
- ensure_posts_ip_hash_nullable(conn)?;
- backfill_media_type(conn)?;
ensure_posts_search_index(conn)?;
ensure_post_invariants(conn)?;
- ensure_board_access_invariants(conn)?;
- stamp_schema_version(conn, POST_SQUASH_SCHEMA_VERSION)
+ ensure_board_access_invariants(conn)
}
fn create_base_tables(conn: &rusqlite::Connection) -> Result<()> {
@@ -558,71 +381,11 @@ fn is_fresh_database(conn: &rusqlite::Connection) -> Result {
.context("Failed to detect whether database is fresh")
}
-fn has_post_squash_baseline_markers(conn: &rusqlite::Connection) -> Result {
- Ok(object_exists(conn, "table", "banner_assets")?
- && column_exists(conn, "boards", "banner_mode")?
- && column_exists(conn, "boards", "access_mode")?
- && column_exists(conn, "threads", "archived")?
- && column_exists(conn, "posts", "media_processing_state")?)
-}
-
-fn object_exists(conn: &rusqlite::Connection, kind: &str, name: &str) -> Result {
- conn.query_row(
- "SELECT EXISTS (
- SELECT 1
- FROM sqlite_master
- WHERE type = ?1 AND name = ?2
- )",
- rusqlite::params![kind, name],
- |row| row.get(0),
- )
- .with_context(|| format!("Failed to inspect schema object {kind}:{name}"))
-}
-
fn create_indexes(conn: &rusqlite::Connection) -> Result<()> {
conn.execute_batch(INDEX_SCHEMA_SQL)
.context("Schema index creation failed")
}
-fn ensure_legacy_columns_for_baseline(conn: &rusqlite::Connection) -> Result<()> {
- conn.execute_batch("BEGIN IMMEDIATE")
- .context("Begin legacy baseline column bridge failed")?;
- let result = (|| {
- for (table, column, sql) in LEGACY_BASELINE_COLUMN_ADDITIONS {
- ensure_column(conn, table, column, sql)?;
- }
- conn.execute_batch(
- "UPDATE boards
- SET display_order = id
- WHERE display_order = 0;
-
- UPDATE boards
- SET collapse_greentext = CASE
- WHEN EXISTS (
- SELECT 1
- FROM site_settings
- WHERE key = 'collapse_greentext'
- AND (value = '1' OR lower(value) = 'true')
- ) THEN 1
- ELSE 0
- END
- WHERE collapse_greentext = 0;",
- )
- .context("Backfill legacy board baseline columns failed")?;
- Ok(())
- })();
-
- match result {
- Ok(()) => conn
- .execute_batch("COMMIT")
- .context("Commit legacy baseline column bridge failed"),
- Err(error) => {
- let _ = conn.execute_batch("ROLLBACK");
- Err(error)
- }
- }
-}
-
fn ensure_posts_search_index(conn: &rusqlite::Connection) -> Result<()> {
conn.execute_batch(
r"
@@ -728,273 +491,460 @@ fn ensure_board_access_invariants(conn: &rusqlite::Connection) -> Result<()> {
.context("Board access invariant creation failed")
}
-fn read_column_notnull(conn: &rusqlite::Connection, table: &str, column: &str) -> Result {
- let query = format!("SELECT \"notnull\" FROM pragma_table_info('{table}') WHERE name = ?1");
- let notnull: i64 = conn
- .query_row(&query, [column], |row| row.get(0))
- .with_context(|| format!("Failed to read {table}.{column} nullability"))?;
- Ok(notnull == 1)
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct SchemaShape {
+ objects: BTreeMap,
+ tables: BTreeMap,
}
-fn ensure_column(
- conn: &rusqlite::Connection,
- table: &str,
- column: &str,
- add_column_sql: &str,
-) -> Result<()> {
- if column_exists(conn, table, column)? {
- return Ok(());
- }
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct SchemaObject {
+ kind: String,
+ sql: Option,
+}
- conn.execute_batch(add_column_sql)
- .with_context(|| format!("Add legacy baseline column {table}.{column} failed"))
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct TableShape {
+ columns: BTreeMap,
+ foreign_keys: BTreeSet,
+ indexes: BTreeMap,
}
-fn column_exists(conn: &rusqlite::Connection, table: &str, column: &str) -> Result {
- conn.query_row(
- "SELECT EXISTS (
- SELECT 1
- FROM pragma_table_info(?1)
- WHERE name = ?2
- )",
- rusqlite::params![table, column],
- |row| row.get(0),
- )
- .with_context(|| format!("Failed to inspect schema column {table}.{column}"))
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct ColumnShape {
+ decl_type: String,
+ not_null: bool,
+ default_value: Option,
+ primary_key_position: i64,
+ hidden: i64,
}
-fn run_structural_migration(
- conn: &rusqlite::Connection,
- sql: &str,
- failure_context: &str,
- success_log: &str,
-) -> Result<()> {
- conn.execute_batch("PRAGMA foreign_keys = OFF;")
- .with_context(|| format!("Disable foreign keys for {failure_context}"))?;
- conn.execute_batch("BEGIN IMMEDIATE")
- .with_context(|| format!("Begin transaction for {failure_context}"))?;
-
- match conn.execute_batch(sql) {
- Ok(()) => {
- if let Err(error) = conn.execute_batch("COMMIT") {
- let _ = conn.execute_batch("ROLLBACK");
- let _ = conn.execute_batch("PRAGMA foreign_keys = ON;");
- return Err(error).with_context(|| format!("Commit {failure_context}"));
- }
- conn.execute_batch("PRAGMA foreign_keys = ON;")
- .with_context(|| format!("Re-enable foreign keys after {failure_context}"))?;
- tracing::info!(target: "db", "{success_log}");
- Ok(())
- }
- Err(error) => {
- let _ = conn.execute_batch("ROLLBACK");
- let _ = conn.execute_batch("PRAGMA foreign_keys = ON;");
- Err(error).context(failure_context.to_owned())
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+struct ForeignKeyShape {
+ table_name: String,
+ from_column: String,
+ to_column: Option,
+ on_update: String,
+ on_delete: String,
+ match_rule: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct IndexShape {
+ unique: bool,
+ origin: String,
+ partial: bool,
+ columns: Vec,
+ where_clause: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct IndexColumnShape {
+ seqno: i64,
+ cid: i64,
+ name: Option,
+ descending: bool,
+ collation: Option,
+}
+
+fn verify_database_schema_structure(conn: &rusqlite::Connection) -> Result<()> {
+ let expected = expected_schema_shape()?;
+ let actual = schema_shape(conn)?;
+ let mut issues = Vec::new();
+
+ compare_schema_objects(&expected, &actual, &mut issues);
+ compare_tables(&expected, &actual, &mut issues);
+ verify_required_sql_fragments(&actual, &mut issues);
+ verify_sqlite_health(conn, &mut issues);
+
+ if issues.is_empty() {
+ Ok(())
+ } else {
+ bail!(
+ "database does not match RustChan {BASELINE_SCHEMA_VERSION} baseline: {}",
+ issues.join("; ")
+ )
+ }
+}
+
+fn expected_schema_shape() -> Result {
+ let expected = rusqlite::Connection::open_in_memory()
+ .context("Open in-memory baseline schema connection failed")?;
+ create_baseline_schema_objects(&expected)?;
+ schema_shape(&expected)
+}
+
+fn schema_shape(conn: &rusqlite::Connection) -> Result {
+ let objects = schema_objects(conn)?;
+ let mut tables = BTreeMap::new();
+ for (name, object) in &objects {
+ if object.kind == "table" {
+ tables.insert(name.clone(), table_shape(conn, name)?);
}
}
+ Ok(SchemaShape { objects, tables })
}
-fn reports_has_full_foreign_keys(conn: &rusqlite::Connection) -> Result {
+fn schema_objects(conn: &rusqlite::Connection) -> Result> {
let mut stmt = conn
- .prepare("SELECT \"from\", \"table\", on_delete FROM pragma_foreign_key_list('reports')")
- .context("Prepare reports foreign-key inspection failed")?;
- let foreign_keys = stmt
+ .prepare(
+ "SELECT type, name, sql
+ FROM sqlite_schema
+ WHERE type IN ('table', 'index', 'trigger', 'view')
+ AND name NOT LIKE 'sqlite_%'
+ AND name <> 'schema_version'
+ ORDER BY type, name",
+ )
+ .context("Prepare schema object inspection failed")?;
+ let rows = stmt
.query_map([], |row| {
+ let kind: String = row.get(0)?;
+ let name: String = row.get(1)?;
+ let sql: Option = row.get(2)?;
Ok((
- row.get::<_, String>(0)?,
- row.get::<_, String>(1)?,
- row.get::<_, String>(2)?,
+ name,
+ SchemaObject {
+ kind,
+ sql: sql.map(|sql| normalize_schema_sql(&sql)),
+ },
))
})
- .context("Query reports foreign keys failed")?
- .collect::>>()
- .context("Read reports foreign keys failed")?;
-
- Ok(foreign_keys.iter().any(|(from, table, on_delete)| {
- from == "post_id" && table == "posts" && on_delete.eq_ignore_ascii_case("CASCADE")
- }) && foreign_keys.iter().any(|(from, table, on_delete)| {
- from == "thread_id" && table == "threads" && on_delete.eq_ignore_ascii_case("CASCADE")
- }) && foreign_keys.iter().any(|(from, table, on_delete)| {
- from == "board_id" && table == "boards" && on_delete.eq_ignore_ascii_case("CASCADE")
- }) && foreign_keys.iter().any(|(from, table, on_delete)| {
- from == "resolved_by"
- && table == "admin_users"
- && on_delete.eq_ignore_ascii_case("SET NULL")
- }))
+ .context("Query schema objects failed")?;
+
+ let mut objects = BTreeMap::new();
+ for row in rows {
+ let (name, object) = row.context("Read schema object failed")?;
+ objects.insert(name, object);
+ }
+ Ok(objects)
}
-fn ensure_reports_table_integrity(conn: &rusqlite::Connection) -> Result<()> {
- if reports_has_full_foreign_keys(conn)? {
- conn.execute_batch(
- "CREATE UNIQUE INDEX IF NOT EXISTS idx_reports_open_unique
- ON reports(post_id, reporter_hash)
- WHERE status = 'open';",
+fn table_shape(conn: &rusqlite::Connection, table: &str) -> Result {
+ Ok(TableShape {
+ columns: table_columns(conn, table)?,
+ foreign_keys: table_foreign_keys(conn, table)?,
+ indexes: table_indexes(conn, table)?,
+ })
+}
+
+fn table_columns(
+ conn: &rusqlite::Connection,
+ table: &str,
+) -> Result> {
+ let mut stmt = conn
+ .prepare(
+ "SELECT name, type, \"notnull\", dflt_value, pk, hidden
+ FROM pragma_table_xinfo(?1)
+ ORDER BY cid",
)
- .context("Reports unique-index creation failed")?;
- return Ok(());
+ .with_context(|| format!("Prepare column inspection for {table} failed"))?;
+ let rows = stmt
+ .query_map([table], |row| {
+ let name: String = row.get(0)?;
+ Ok((
+ name,
+ ColumnShape {
+ decl_type: row.get(1)?,
+ not_null: row.get::<_, i64>(2)? == 1,
+ default_value: row.get(3)?,
+ primary_key_position: row.get(4)?,
+ hidden: row.get(5)?,
+ },
+ ))
+ })
+ .with_context(|| format!("Query columns for {table} failed"))?;
+
+ let mut columns = BTreeMap::new();
+ for row in rows {
+ let (name, column) = row.with_context(|| format!("Read column for {table} failed"))?;
+ columns.insert(name, column);
}
+ Ok(columns)
+}
- run_structural_migration(
- conn,
- r"
- CREATE TABLE reports_new (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
- board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
- reason TEXT NOT NULL DEFAULT '',
- reporter_hash TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'open',
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
- resolved_at INTEGER,
- resolved_by INTEGER REFERENCES admin_users(id) ON DELETE SET NULL
+fn table_foreign_keys(
+ conn: &rusqlite::Connection,
+ table: &str,
+) -> Result> {
+ let mut stmt = conn
+ .prepare(
+ "SELECT \"table\", \"from\", \"to\", on_update, on_delete, match
+ FROM pragma_foreign_key_list(?1)
+ ORDER BY id, seq",
+ )
+ .with_context(|| format!("Prepare foreign-key inspection for {table} failed"))?;
+ let rows = stmt
+ .query_map([table], |row| {
+ Ok(ForeignKeyShape {
+ table_name: row.get(0)?,
+ from_column: row.get(1)?,
+ to_column: row.get(2)?,
+ on_update: row.get(3)?,
+ on_delete: row.get(4)?,
+ match_rule: row.get(5)?,
+ })
+ })
+ .with_context(|| format!("Query foreign keys for {table} failed"))?;
+
+ let mut foreign_keys = BTreeSet::new();
+ for row in rows {
+ foreign_keys.insert(row.with_context(|| format!("Read foreign key for {table} failed"))?);
+ }
+ Ok(foreign_keys)
+}
+
+fn table_indexes(conn: &rusqlite::Connection, table: &str) -> Result> {
+ let mut stmt = conn
+ .prepare(
+ "SELECT name, \"unique\", origin, partial
+ FROM pragma_index_list(?1)
+ ORDER BY name",
+ )
+ .with_context(|| format!("Prepare index inspection for {table} failed"))?;
+ let rows = stmt
+ .query_map([table], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, i64>(1)? == 1,
+ row.get::<_, String>(2)?,
+ row.get::<_, i64>(3)? == 1,
+ ))
+ })
+ .with_context(|| format!("Query indexes for {table} failed"))?;
+
+ let mut indexes = BTreeMap::new();
+ for row in rows {
+ let (name, unique, origin, partial) =
+ row.with_context(|| format!("Read index for {table} failed"))?;
+ let sql = if name.starts_with("sqlite_") {
+ None
+ } else {
+ schema_sql_for_object(conn, "index", &name)?
+ };
+ indexes.insert(
+ name.clone(),
+ IndexShape {
+ unique,
+ origin,
+ partial,
+ columns: index_columns(conn, &name)?,
+ where_clause: sql.as_deref().and_then(index_where_clause),
+ },
);
+ }
+ Ok(indexes)
+}
- INSERT INTO reports_new
- (id, post_id, thread_id, board_id, reason, reporter_hash,
- status, created_at, resolved_at, resolved_by)
- SELECT r.id,
- r.post_id,
- p.thread_id,
- p.board_id,
- r.reason,
- r.reporter_hash,
- r.status,
- r.created_at,
- r.resolved_at,
- CASE
- WHEN r.resolved_by IS NULL THEN NULL
- WHEN EXISTS (
- SELECT 1 FROM admin_users au
- WHERE au.id = r.resolved_by
- ) THEN r.resolved_by
- ELSE NULL
- END
- FROM reports r
- JOIN posts p ON p.id = r.post_id;
-
- DROP TABLE reports;
- ALTER TABLE reports_new RENAME TO reports;
-
- CREATE INDEX idx_reports_status
- ON reports(status, created_at DESC);
- CREATE UNIQUE INDEX idx_reports_open_unique
- ON reports(post_id, reporter_hash)
- WHERE status = 'open';
- ",
- "Structural migration: rebuild reports table with full foreign keys failed",
- "Applied structural migration: reports table integrity hardened",
+fn index_columns(conn: &rusqlite::Connection, index: &str) -> Result> {
+ let mut stmt = conn
+ .prepare(
+ "SELECT seqno, cid, name, \"desc\", coll, key
+ FROM pragma_index_xinfo(?1)
+ ORDER BY seqno",
+ )
+ .with_context(|| format!("Prepare index-column inspection for {index} failed"))?;
+ let rows = stmt
+ .query_map([index], |row| {
+ Ok((
+ row.get::<_, i64>(5)? == 1,
+ IndexColumnShape {
+ seqno: row.get(0)?,
+ cid: row.get(1)?,
+ name: row.get(2)?,
+ descending: row.get::<_, i64>(3)? == 1,
+ collation: row.get(4)?,
+ },
+ ))
+ })
+ .with_context(|| format!("Query index columns for {index} failed"))?;
+
+ let mut columns = Vec::new();
+ for row in rows {
+ let (is_key_column, column) =
+ row.with_context(|| format!("Read index column for {index} failed"))?;
+ if is_key_column {
+ columns.push(column);
+ }
+ }
+ Ok(columns)
+}
+
+fn schema_sql_for_object(
+ conn: &rusqlite::Connection,
+ kind: &str,
+ name: &str,
+) -> Result> {
+ conn.query_row(
+ "SELECT sql FROM sqlite_schema WHERE type = ?1 AND name = ?2",
+ rusqlite::params![kind, name],
+ |row| row.get::<_, Option>(0),
)
+ .with_context(|| format!("Inspect SQL for schema object {kind}:{name}"))
}
-fn ensure_posts_ip_hash_nullable(conn: &rusqlite::Connection) -> Result<()> {
- if read_column_notnull(conn, "posts", "ip_hash")? {
- run_structural_migration(
- conn,
- "CREATE TABLE posts_new (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
- board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
- name TEXT NOT NULL DEFAULT 'Anonymous',
- tripcode TEXT,
- subject TEXT,
- body TEXT NOT NULL,
- body_html TEXT NOT NULL,
- ip_hash TEXT,
- file_path TEXT,
- file_name TEXT,
- file_size INTEGER,
- thumb_path TEXT,
- mime_type TEXT,
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
- deletion_token TEXT NOT NULL,
- is_op INTEGER NOT NULL DEFAULT 0,
- media_type TEXT,
- audio_file_path TEXT,
- audio_file_name TEXT,
- audio_file_size INTEGER,
- audio_mime_type TEXT,
- edited_at INTEGER,
- media_processing_state TEXT NOT NULL DEFAULT '',
- media_processing_error TEXT
- );
-
- INSERT INTO posts_new (
- id, thread_id, board_id, name, tripcode, subject, body, body_html,
- ip_hash, file_path, file_name, file_size, thumb_path, mime_type,
- created_at, deletion_token, is_op, media_type, audio_file_path,
- audio_file_name, audio_file_size, audio_mime_type, edited_at,
- media_processing_state, media_processing_error
- )
- SELECT
- id, thread_id, board_id, name, tripcode, subject, body, body_html,
- ip_hash, file_path, file_name, file_size, thumb_path, mime_type,
- created_at, deletion_token, is_op, media_type, audio_file_path,
- audio_file_name, audio_file_size, audio_mime_type, edited_at,
- '' AS media_processing_state,
- NULL AS media_processing_error
- FROM posts;
- DROP TABLE posts;
- ALTER TABLE posts_new RENAME TO posts;
-
- CREATE INDEX IF NOT EXISTS idx_posts_thread
- ON posts(thread_id, created_at ASC);
- CREATE INDEX IF NOT EXISTS idx_posts_board
- ON posts(board_id, created_at DESC);
- CREATE INDEX IF NOT EXISTS idx_posts_thread_id
- ON posts(thread_id);
- CREATE INDEX IF NOT EXISTS idx_posts_media_processing_state
- ON posts(media_processing_state);
- CREATE INDEX IF NOT EXISTS idx_posts_ip_hash
- ON posts(ip_hash);",
- "Structural migration: make posts.ip_hash nullable failed",
- "Applied structural migration: posts.ip_hash is now nullable",
- )?;
+fn compare_schema_objects(expected: &SchemaShape, actual: &SchemaShape, issues: &mut Vec) {
+ for (name, expected_object) in &expected.objects {
+ match actual.objects.get(name) {
+ Some(actual_object) if actual_object.kind == expected_object.kind => {
+ if matches!(expected_object.kind.as_str(), "index" | "trigger")
+ && actual_object.sql != expected_object.sql
+ {
+ issues.push(format!("{name} definition differs from baseline"));
+ }
+ }
+ Some(actual_object) => issues.push(format!(
+ "{name} is a {}, expected {}",
+ actual_object.kind, expected_object.kind
+ )),
+ None => issues.push(format!("missing {} {name}", expected_object.kind)),
+ }
}
- Ok(())
+ for (name, actual_object) in &actual.objects {
+ if !expected.objects.contains_key(name) {
+ issues.push(format!("unexpected {} {name}", actual_object.kind));
+ }
+ }
}
-fn backfill_media_type(conn: &rusqlite::Connection) -> Result<()> {
- let needs_backfill: i64 = conn
- .query_row(
- "SELECT COUNT(*) FROM posts WHERE media_type IS NULL AND file_path IS NOT NULL",
- [],
- |r| r.get(0),
- )
- .context("Failed to count posts needing media_type backfill")?;
+fn compare_tables(expected: &SchemaShape, actual: &SchemaShape, issues: &mut Vec) {
+ for (table, expected_table) in &expected.tables {
+ let Some(actual_table) = actual.tables.get(table) else {
+ continue;
+ };
+ compare_columns(
+ table,
+ &expected_table.columns,
+ &actual_table.columns,
+ issues,
+ );
+ if actual_table.foreign_keys != expected_table.foreign_keys {
+ issues.push(format!("{table} foreign keys differ from baseline"));
+ }
+ if actual_table.indexes != expected_table.indexes {
+ issues.push(format!("{table} indexes differ from baseline"));
+ }
+ }
+}
- if needs_backfill > 0 {
- conn.execute_batch(
- "UPDATE posts
- SET media_type = CASE
- WHEN file_path LIKE '%.jpg' OR file_path LIKE '%.jpeg' OR
- file_path LIKE '%.png' OR file_path LIKE '%.gif' OR
- file_path LIKE '%.webp' OR file_path LIKE '%.heic' OR
- file_path LIKE '%.heif' THEN 'image'
- WHEN file_path LIKE '%.mp4' OR file_path LIKE '%.webm' OR
- file_path LIKE '%.mkv' THEN 'video'
- WHEN file_path LIKE '%.mp3' OR file_path LIKE '%.ogg' OR
- file_path LIKE '%.flac' OR file_path LIKE '%.wav' OR
- file_path LIKE '%.m4a' OR file_path LIKE '%.aac' OR
- file_path LIKE '%.opus' THEN 'audio'
- WHEN file_path LIKE '%.pdf' THEN 'pdf'
- ELSE 'other'
- END
- WHERE media_type IS NULL AND file_path IS NOT NULL;",
- )
- .context("Failed to backfill media_type column")?;
+fn compare_columns(
+ table: &str,
+ expected: &BTreeMap,
+ actual: &BTreeMap,
+ issues: &mut Vec,
+) {
+ for (column, expected_shape) in expected {
+ match actual.get(column) {
+ Some(actual_shape) if actual_shape == expected_shape => {}
+ Some(_) => issues.push(format!("{table}.{column} definition differs from baseline")),
+ None => issues.push(format!("missing column {table}.{column}")),
+ }
}
- Ok(())
+ for column in actual.keys() {
+ if !expected.contains_key(column) {
+ issues.push(format!("unexpected column {table}.{column}"));
+ }
+ }
+}
+
+const REQUIRED_TABLE_SQL_FRAGMENTS: [(&str, &str); 2] = [
+ (
+ "boards",
+ "CHECK (banner_mode IN ('inherit', 'none', 'override'))",
+ ),
+ (
+ "boards",
+ "CHECK (access_mode IN ('public', 'view_password', 'post_password'))",
+ ),
+];
+
+fn verify_required_sql_fragments(shape: &SchemaShape, issues: &mut Vec) {
+ for (table, fragment) in REQUIRED_TABLE_SQL_FRAGMENTS {
+ let Some(object) = shape.objects.get(table) else {
+ continue;
+ };
+ let Some(sql) = object.sql.as_deref() else {
+ issues.push(format!("{table} SQL definition is missing"));
+ continue;
+ };
+ let normalized_fragment = normalize_schema_sql(fragment);
+ if !sql.contains(&normalized_fragment) {
+ issues.push(format!("{table} is missing required constraint {fragment}"));
+ }
+ }
+}
+
+fn verify_sqlite_health(conn: &rusqlite::Connection, issues: &mut Vec) {
+ match conn.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0)) {
+ Ok(result) if result.eq_ignore_ascii_case("ok") => {}
+ Ok(result) => issues.push(format!("quick_check reported {result}")),
+ Err(error) => issues.push(format!("quick_check failed: {error}")),
+ }
+
+ let mut stmt = match conn.prepare("PRAGMA foreign_key_check") {
+ Ok(stmt) => stmt,
+ Err(error) => {
+ issues.push(format!("foreign_key_check failed: {error}"));
+ return;
+ }
+ };
+ let rows = match stmt.query_map([], |row| {
+ let table: String = row.get(0)?;
+ let rowid: Option = row.get(1)?;
+ let parent: String = row.get(2)?;
+ Ok(format!(
+ "{table} rowid={} parent={parent}",
+ rowid.map_or_else(|| "unknown".to_owned(), |rowid| rowid.to_string())
+ ))
+ }) {
+ Ok(rows) => rows,
+ Err(error) => {
+ issues.push(format!("foreign_key_check failed: {error}"));
+ return;
+ }
+ };
+
+ let mut violations = Vec::new();
+ for row in rows {
+ match row {
+ Ok(message) => violations.push(message),
+ Err(error) => {
+ issues.push(format!("foreign_key_check row failed: {error}"));
+ return;
+ }
+ }
+ }
+ if !violations.is_empty() {
+ issues.push(format!(
+ "foreign_key_check reported {} violation(s): {}",
+ violations.len(),
+ violations.join(", ")
+ ));
+ }
+}
+
+fn index_where_clause(sql: &str) -> Option {
+ let lower = sql.to_ascii_lowercase();
+ let where_index = lower.find(" where ")?;
+ sql.get(where_index..).map(str::trim).map(str::to_owned)
+}
+
+fn normalize_schema_sql(sql: &str) -> String {
+ sql.split_whitespace()
+ .collect::>()
+ .join(" ")
+ .replace('"', "")
}
#[cfg(test)]
mod tests {
- use super::install_or_migrate_schema;
- use crate::db::migrations::POST_SQUASH_SCHEMA_VERSION;
+ use super::{
+ baseline_schema_version, create_baseline_schema_objects, install_or_migrate_schema,
+ normalize_database_schema_version, read_schema_version, verify_database_schema,
+ };
- fn schema_version(conn: &rusqlite::Connection) -> i64 {
+ fn schema_version(conn: &rusqlite::Connection) -> String {
conn.query_row("SELECT version FROM schema_version", [], |row| row.get(0))
.expect("read schema_version")
}
@@ -1011,217 +961,13 @@ mod tests {
.expect("inspect sqlite object")
}
- fn table_has_column(conn: &rusqlite::Connection, table: &str, column: &str) -> bool {
- conn.query_row(
- "SELECT EXISTS (
- SELECT 1 FROM pragma_table_info(?1)
- WHERE name = ?2
- )",
- rusqlite::params![table, column],
- |row| row.get(0),
- )
- .expect("inspect table column")
- }
-
- fn create_representative_legacy_schema(conn: &rusqlite::Connection, version: i64) {
- conn.execute_batch(
- r"
- CREATE TABLE boards (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- short_name TEXT NOT NULL UNIQUE,
- name TEXT NOT NULL,
- description TEXT NOT NULL DEFAULT '',
- nsfw INTEGER NOT NULL DEFAULT 0,
- max_threads INTEGER NOT NULL DEFAULT 150,
- bump_limit INTEGER NOT NULL DEFAULT 500,
- created_at INTEGER NOT NULL DEFAULT (unixepoch())
- );
- CREATE TABLE threads (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
- subject TEXT,
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
- bumped_at INTEGER NOT NULL DEFAULT (unixepoch()),
- locked INTEGER NOT NULL DEFAULT 0,
- sticky INTEGER NOT NULL DEFAULT 0,
- reply_count INTEGER NOT NULL DEFAULT 0
- );
- CREATE TABLE posts (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
- board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
- name TEXT NOT NULL DEFAULT 'Anonymous',
- tripcode TEXT,
- subject TEXT,
- body TEXT NOT NULL,
- body_html TEXT NOT NULL,
- ip_hash TEXT NOT NULL,
- file_path TEXT,
- file_name TEXT,
- file_size INTEGER,
- thumb_path TEXT,
- mime_type TEXT,
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
- deletion_token TEXT NOT NULL,
- is_op INTEGER NOT NULL DEFAULT 0
- );
- CREATE TABLE site_settings (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
- );
- CREATE TABLE schema_version (
- version INTEGER NOT NULL DEFAULT 0,
- UNIQUE(version)
- );",
- )
- .expect("create representative legacy schema");
-
- conn.execute(
- "INSERT INTO schema_version (version) VALUES (?1)",
- [version],
- )
- .expect("insert legacy schema version");
- }
-
- fn insert_legacy_thread_with_post(conn: &rusqlite::Connection) {
- conn.execute_batch(
- r"
- INSERT INTO site_settings (key, value) VALUES ('collapse_greentext', 'true');
- INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random');
- INSERT INTO threads (id, board_id, subject) VALUES (10, 1, 'legacy subject');
- INSERT INTO posts (
- id, thread_id, board_id, body, body_html, ip_hash,
- file_path, file_name, mime_type, deletion_token, is_op
- )
- VALUES (
- 100, 10, 1, 'legacy searchable body', 'legacy searchable body
',
- 'old-ip', 'uploads/a.png', 'a.png', 'image/png', 'tok', 1
- );",
- )
- .expect("insert representative legacy data");
- }
-
- fn insert_legacy_duplicate_ops(conn: &rusqlite::Connection) {
- conn.execute_batch(
- r"
- INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random');
- INSERT INTO threads (id, board_id, subject) VALUES (10, 1, 'legacy subject');
- INSERT INTO posts (id, thread_id, board_id, body, body_html, ip_hash, deletion_token, is_op)
- VALUES (100, 10, 1, 'first op', 'first op', 'ip-a', 'tok-a', 1);
- INSERT INTO posts (id, thread_id, board_id, body, body_html, ip_hash, deletion_token, is_op)
- VALUES (101, 10, 1, 'duplicate op', 'duplicate op', 'ip-b', 'tok-b', 1);",
- )
- .expect("insert invalid legacy data");
- }
-
- fn create_partial_post_squash_schema(conn: &rusqlite::Connection) {
- conn.execute_batch(
- r"
- CREATE TABLE boards (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- display_order INTEGER NOT NULL DEFAULT 0,
- short_name TEXT NOT NULL UNIQUE,
- name TEXT NOT NULL,
- description TEXT NOT NULL DEFAULT '',
- nsfw INTEGER NOT NULL DEFAULT 0,
- max_threads INTEGER NOT NULL DEFAULT 150,
- max_archived_threads INTEGER NOT NULL DEFAULT 150,
- bump_limit INTEGER NOT NULL DEFAULT 500,
- allow_video INTEGER NOT NULL DEFAULT 1,
- allow_tripcodes INTEGER NOT NULL DEFAULT 1,
- allow_images INTEGER NOT NULL DEFAULT 1,
- allow_audio INTEGER NOT NULL DEFAULT 0,
- allow_any_files INTEGER NOT NULL DEFAULT 0,
- edit_window_secs INTEGER NOT NULL DEFAULT 0,
- allow_editing INTEGER NOT NULL DEFAULT 1,
- allow_video_embeds INTEGER NOT NULL DEFAULT 1,
- allow_captcha INTEGER NOT NULL DEFAULT 0,
- show_poster_ids INTEGER NOT NULL DEFAULT 1,
- collapse_greentext INTEGER NOT NULL DEFAULT 0,
- post_cooldown_secs INTEGER NOT NULL DEFAULT 0,
- default_theme TEXT NOT NULL DEFAULT '',
- banner_mode TEXT NOT NULL DEFAULT 'inherit'
- CHECK (banner_mode IN ('inherit', 'none', 'override')),
- access_mode TEXT NOT NULL DEFAULT 'public'
- CHECK (access_mode IN ('public', 'view_password', 'post_password')),
- access_password_hash TEXT NOT NULL DEFAULT '',
- created_at INTEGER NOT NULL DEFAULT (unixepoch())
- );
- CREATE TABLE threads (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
- subject TEXT,
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
- bumped_at INTEGER NOT NULL DEFAULT (unixepoch()),
- locked INTEGER NOT NULL DEFAULT 0,
- sticky INTEGER NOT NULL DEFAULT 0,
- archived INTEGER NOT NULL DEFAULT 0,
- reply_count INTEGER NOT NULL DEFAULT 0
- );
- CREATE TABLE posts (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
- board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
- name TEXT NOT NULL DEFAULT 'Anonymous',
- tripcode TEXT,
- subject TEXT,
- body TEXT NOT NULL,
- body_html TEXT NOT NULL,
- ip_hash TEXT,
- file_path TEXT,
- file_name TEXT,
- file_size INTEGER,
- thumb_path TEXT,
- mime_type TEXT,
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
- deletion_token TEXT NOT NULL,
- is_op INTEGER NOT NULL DEFAULT 0,
- media_type TEXT,
- audio_file_path TEXT,
- audio_file_name TEXT,
- audio_file_size INTEGER,
- audio_mime_type TEXT,
- edited_at INTEGER,
- media_processing_state TEXT NOT NULL DEFAULT '',
- media_processing_error TEXT
- );
- CREATE TABLE site_settings (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
- );
- CREATE TABLE banner_assets (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- scope_type TEXT NOT NULL,
- board_id INTEGER REFERENCES boards(id) ON DELETE CASCADE,
- storage_key TEXT NOT NULL UNIQUE,
- width INTEGER NOT NULL,
- height INTEGER NOT NULL,
- file_size INTEGER NOT NULL,
- enabled INTEGER NOT NULL DEFAULT 1,
- sort_order INTEGER NOT NULL DEFAULT 0,
- target_type TEXT NOT NULL DEFAULT 'none',
- target_value TEXT NOT NULL DEFAULT '',
- show_on_index INTEGER NOT NULL DEFAULT 1,
- show_on_catalog INTEGER NOT NULL DEFAULT 1,
- created_at INTEGER NOT NULL DEFAULT (unixepoch())
- );
- CREATE TABLE schema_version (
- version INTEGER NOT NULL DEFAULT 0,
- UNIQUE(version)
- );
- INSERT INTO schema_version (version) VALUES (1);
- INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random');
- ",
- )
- .expect("create partial post-squash schema");
- }
-
#[test]
- fn fresh_database_installs_canonical_post_squash_baseline() {
+ fn fresh_database_installs_130_baseline_directly() {
let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite");
install_or_migrate_schema(&conn).expect("install schema");
- assert_eq!(schema_version(&conn), POST_SQUASH_SCHEMA_VERSION);
+ assert_eq!(schema_version(&conn), "1.3.0");
+ assert_eq!(baseline_schema_version(), "1.3.0");
assert!(object_exists(&conn, "table", "boards"));
assert!(object_exists(&conn, "table", "posts"));
assert!(object_exists(&conn, "table", "posts_fts"));
@@ -1234,201 +980,84 @@ mod tests {
assert!(object_exists(&conn, "trigger", "posts_ai"));
assert!(object_exists(&conn, "trigger", "posts_board_match_insert"));
assert!(object_exists(&conn, "trigger", "boards_access_mode_insert"));
+ verify_database_schema(&conn).expect("fresh schema verifies");
}
#[test]
- fn legacy_posts_ip_hash_rebuild_keeps_posts_triggers_and_indexes() {
+ fn existing_current_schema_is_adopted_as_130_without_data_loss() {
let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite");
+ create_baseline_schema_objects(&conn).expect("create baseline objects");
conn.execute_batch(
- r"
- CREATE TABLE posts (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
- board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
- name TEXT NOT NULL DEFAULT 'Anonymous',
- tripcode TEXT,
- subject TEXT,
- body TEXT NOT NULL,
- body_html TEXT NOT NULL,
- ip_hash TEXT NOT NULL,
- file_path TEXT,
- file_name TEXT,
- file_size INTEGER,
- thumb_path TEXT,
- mime_type TEXT,
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
- deletion_token TEXT NOT NULL,
- is_op INTEGER NOT NULL DEFAULT 0,
- media_type TEXT,
- audio_file_path TEXT,
- audio_file_name TEXT,
- audio_file_size INTEGER,
- audio_mime_type TEXT,
- edited_at INTEGER,
- media_processing_state TEXT NOT NULL DEFAULT '',
- media_processing_error TEXT
- );
- CREATE TABLE schema_version (
+ "CREATE TABLE schema_version (
version INTEGER NOT NULL DEFAULT 0,
UNIQUE(version)
);
INSERT INTO schema_version (version) VALUES (41);
- ",
+ INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random');
+ INSERT INTO threads (id, board_id, subject) VALUES (10, 1, 'subject');
+ INSERT INTO posts (id, thread_id, board_id, body, body_html, deletion_token, is_op)
+ VALUES (100, 10, 1, 'preserved body', 'preserved body
', 'tok', 1);",
)
- .expect("create legacy posts table");
-
- install_or_migrate_schema(&conn).expect("install schema");
-
- let posts_ai_exists: bool = conn
- .query_row(
- "SELECT EXISTS (
- SELECT 1 FROM sqlite_master
- WHERE type = 'trigger' AND name = 'posts_ai'
- )",
- [],
- |row| row.get(0),
- )
- .expect("check posts_ai trigger");
- let board_match_trigger_exists: bool = conn
- .query_row(
- "SELECT EXISTS (
- SELECT 1 FROM sqlite_master
- WHERE type = 'trigger' AND name = 'posts_board_match_insert'
- )",
- [],
- |row| row.get(0),
- )
- .expect("check board-match trigger");
- let one_op_index_exists: bool = conn
- .query_row(
- "SELECT EXISTS (
- SELECT 1 FROM sqlite_master
- WHERE type = 'index' AND name = 'idx_posts_one_op_per_thread'
- )",
- [],
- |row| row.get(0),
- )
- .expect("check one-op index");
+ .expect("seed adoptable current schema");
- assert!(posts_ai_exists);
- assert!(board_match_trigger_exists);
- assert!(one_op_index_exists);
-
- conn.execute(
- "INSERT INTO boards (short_name, name) VALUES ('test', 'Test')",
- [],
- )
- .expect("insert board");
- let board_id = conn.last_insert_rowid();
- conn.execute(
- "INSERT INTO threads (board_id, subject) VALUES (?1, 'subject')",
- [board_id],
- )
- .expect("insert thread");
- let thread_id = conn.last_insert_rowid();
- conn.execute(
- "INSERT INTO posts (thread_id, board_id, body, body_html, deletion_token, is_op)
- VALUES (?1, ?2, 'searchable body', 'searchable body', 'token', 1)",
- (thread_id, board_id),
- )
- .expect("insert post");
+ install_or_migrate_schema(&conn).expect("adopt current schema");
- let fts_count: i64 = conn
- .query_row("SELECT COUNT(*) FROM posts_fts", [], |row| row.get(0))
- .expect("read posts_fts count");
- assert_eq!(fts_count, 1);
+ assert_eq!(schema_version(&conn), "1.3.0");
+ let body: String = conn
+ .query_row("SELECT body FROM posts WHERE id = 100", [], |row| {
+ row.get(0)
+ })
+ .expect("read preserved post");
+ assert_eq!(body, "preserved body");
}
#[test]
- fn legacy_database_upgrades_to_post_squash_baseline_and_preserves_data() {
+ fn partial_schema_fails_closed_without_creating_missing_tables() {
let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite");
- create_representative_legacy_schema(&conn, 4);
- insert_legacy_thread_with_post(&conn);
-
- install_or_migrate_schema(&conn).expect("upgrade legacy schema");
+ conn.execute_batch(
+ "CREATE TABLE boards (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ short_name TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL
+ );
+ CREATE TABLE schema_version (
+ version INTEGER NOT NULL DEFAULT 0,
+ UNIQUE(version)
+ );
+ INSERT INTO schema_version (version) VALUES (36);
+ INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random');",
+ )
+ .expect("create partial schema");
- assert_eq!(schema_version(&conn), POST_SQUASH_SCHEMA_VERSION);
- assert!(table_has_column(&conn, "boards", "banner_mode"));
- assert!(table_has_column(&conn, "boards", "access_password_hash"));
- assert!(table_has_column(&conn, "threads", "archived"));
- assert!(table_has_column(&conn, "posts", "media_processing_state"));
- assert!(object_exists(&conn, "table", "banner_assets"));
- assert!(object_exists(&conn, "table", "posts_fts"));
- assert!(object_exists(
- &conn,
- "index",
- "idx_posts_media_processing_state"
- ));
- assert!(object_exists(&conn, "trigger", "posts_ai"));
- assert!(object_exists(&conn, "trigger", "posts_board_match_insert"));
+ let error = install_or_migrate_schema(&conn).expect_err("partial schema should fail");
+ assert!(
+ error
+ .to_string()
+ .contains("does not match RustChan 1.3.0 baseline"),
+ "unexpected error: {error:#}"
+ );
- let post: (String, Option, String) = conn
- .query_row(
- "SELECT body, media_type, ip_hash FROM posts WHERE id = 100",
- [],
- |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
- )
- .expect("read preserved post");
+ assert!(!object_exists(&conn, "table", "posts"));
assert_eq!(
- post,
- (
- "legacy searchable body".to_owned(),
- Some("image".to_owned()),
- "old-ip".to_owned()
- )
+ read_schema_version(&conn).expect("read version"),
+ Some("36".to_owned())
);
-
- let fts_count: i64 = conn
- .query_row("SELECT COUNT(*) FROM posts_fts", [], |row| row.get(0))
- .expect("read posts_fts count");
- assert_eq!(fts_count, 1);
-
- let collapse_greentext: i64 = conn
- .query_row(
- "SELECT collapse_greentext FROM boards WHERE id = 1",
- [],
- |row| row.get(0),
- )
- .expect("read collapse_greentext");
- assert_eq!(collapse_greentext, 1);
}
#[test]
- fn historical_v1_database_is_still_detected_as_legacy() {
+ fn unknown_schema_object_fails_closed() {
let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite");
- create_representative_legacy_schema(&conn, POST_SQUASH_SCHEMA_VERSION);
- insert_legacy_thread_with_post(&conn);
-
- install_or_migrate_schema(&conn).expect("upgrade historical v1 schema");
-
- assert_eq!(schema_version(&conn), POST_SQUASH_SCHEMA_VERSION);
- assert!(table_has_column(&conn, "boards", "banner_mode"));
- assert!(table_has_column(&conn, "posts", "media_processing_state"));
- assert!(object_exists(&conn, "table", "banner_assets"));
- assert!(object_exists(&conn, "trigger", "posts_ai"));
- }
-
- #[test]
- fn post_squash_database_repairs_missing_additive_board_columns() {
- let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite");
- create_partial_post_squash_schema(&conn);
-
- install_or_migrate_schema(&conn).expect("repair partial post-squash schema");
-
- assert_eq!(schema_version(&conn), POST_SQUASH_SCHEMA_VERSION);
- assert!(table_has_column(&conn, "boards", "allow_self_delete"));
- assert!(table_has_column(&conn, "boards", "allow_archive"));
- assert!(table_has_column(&conn, "boards", "allow_pdf"));
- assert!(table_has_column(&conn, "boards", "max_pdf_size"));
+ install_or_migrate_schema(&conn).expect("install schema");
+ conn.execute("CREATE TABLE operator_notes (body TEXT)", [])
+ .expect("create unexpected table");
- let flags: (i64, i64, i64, i64) = conn
- .query_row(
- "SELECT allow_self_delete, allow_archive, allow_pdf, max_pdf_size FROM boards WHERE id = 1",
- [],
- |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
- )
- .expect("read repaired board flags");
- assert_eq!(flags, (0, 1, 0, 157_286_400));
+ let error = normalize_database_schema_version(&conn).expect_err("unknown table fails");
+ assert!(
+ error
+ .to_string()
+ .contains("unexpected table operator_notes"),
+ "unexpected error: {error:#}"
+ );
}
#[test]
@@ -1471,17 +1100,29 @@ mod tests {
}
#[test]
- fn failed_legacy_upgrade_does_not_stamp_post_squash_version() {
+ fn invalid_structural_change_does_not_stamp_130() {
let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite");
- create_representative_legacy_schema(&conn, 36);
- insert_legacy_duplicate_ops(&conn);
+ create_baseline_schema_objects(&conn).expect("create baseline objects");
+ conn.execute_batch(
+ "CREATE TABLE schema_version (
+ version INTEGER NOT NULL DEFAULT 0,
+ UNIQUE(version)
+ );
+ INSERT INTO schema_version (version) VALUES (41);
+ DROP TRIGGER posts_board_match_insert;",
+ )
+ .expect("make schema invalid");
- let error = install_or_migrate_schema(&conn).expect_err("legacy upgrade should fail");
+ let error = install_or_migrate_schema(&conn).expect_err("invalid schema should fail");
assert!(
- error.to_string().contains("Schema index creation failed")
- || error.to_string().contains("Post invariant creation failed"),
+ error
+ .to_string()
+ .contains("missing trigger posts_board_match_insert"),
"unexpected error: {error:#}"
);
- assert_eq!(schema_version(&conn), 36);
+ assert_eq!(
+ read_schema_version(&conn).expect("read version"),
+ Some("41".to_owned())
+ );
}
}
diff --git a/src/handlers/admin/backup/archive.rs b/src/handlers/admin/backup/archive.rs
index 06209ffb..35e156f5 100644
--- a/src/handlers/admin/backup/archive.rs
+++ b/src/handlers/admin/backup/archive.rs
@@ -623,7 +623,7 @@ mod tests {
&root,
v4::BackupScope::FullSite,
v4::board_fixture_files_for_test(),
- Some(b"sqlite".to_vec()),
+ Some(v4::valid_db_snapshot_for_test()),
1_715_010_000_i64,
);
(dir, root)
diff --git a/src/handlers/admin/backup/common.rs b/src/handlers/admin/backup/common.rs
index c5baecb7..c4f2f1c3 100644
--- a/src/handlers/admin/backup/common.rs
+++ b/src/handlers/admin/backup/common.rs
@@ -430,7 +430,15 @@ pub(super) fn verify_full_backup_archive(
"Invalid full backup: chan.db does not look like a SQLite database.".into(),
));
}
+ if db_entry.size() != manifest.db_bytes {
+ return Err(AppError::BadRequest(format!(
+ "Invalid full backup: manifest database size {} does not match archive size {}.",
+ manifest.db_bytes,
+ db_entry.size()
+ )));
+ }
drop(db_entry);
+ verify_full_backup_db_schema(archive, manifest.db_bytes)?;
let mut upload_file_count = 0u64;
let mut favicon_file_count = 0u64;
@@ -490,6 +498,45 @@ pub(super) fn verify_full_backup_archive(
Ok(manifest)
}
+fn verify_full_backup_db_schema(
+ archive: &mut zip::ZipArchive,
+ expected_db_bytes: u64,
+) -> Result<()> {
+ if expected_db_bytes > ZIP_ENTRY_MAX_BYTES {
+ return Err(AppError::BadRequest(
+ "Invalid full backup: chan.db exceeds the restore entry size limit.".into(),
+ ));
+ }
+ let mut db_entry = archive.by_name("chan.db").map_err(|_error| {
+ AppError::BadRequest("Invalid full backup: zip must contain 'chan.db' at the root.".into())
+ })?;
+ let mut temp_db = tempfile::NamedTempFile::new().map_err(|error| {
+ AppError::Internal(anyhow::anyhow!(
+ "Create temporary database validation file: {error}"
+ ))
+ })?;
+ let copied = std::io::copy(&mut db_entry, temp_db.as_file_mut()).map_err(|error| {
+ AppError::BadRequest(format!("Invalid full backup database entry: {error}"))
+ })?;
+ if copied != expected_db_bytes {
+ return Err(AppError::BadRequest(format!(
+ "Invalid full backup: copied database size {copied} does not match manifest size {expected_db_bytes}."
+ )));
+ }
+ let conn = rusqlite::Connection::open(temp_db.path()).map_err(|error| {
+ AppError::BadRequest(format!(
+ "Invalid full backup: chan.db could not be opened as SQLite: {error}"
+ ))
+ })?;
+ crate::db::normalize_database_schema_version(&conn).map_err(|error| {
+ AppError::BadRequest(format!(
+ "Invalid full backup: chan.db does not match the RustChan {} database baseline: {error}",
+ crate::db::baseline_schema_version()
+ ))
+ })?;
+ Ok(())
+}
+
pub(super) fn verify_full_backup_zip(path: &Path) -> Result {
let file = std::fs::File::open(path).map_err(|error| {
AppError::Internal(anyhow::anyhow!("Open backup {}: {error}", path.display()))
@@ -563,6 +610,16 @@ mod tests {
zip.finish().expect("finish zip");
}
+ fn partial_sqlite_db_bytes_for_test() -> Vec {
+ let temp_dir = tempfile::tempdir().expect("tempdir");
+ let db_path = temp_dir.path().join("partial.sqlite3");
+ let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
+ conn.execute("CREATE TABLE boards (id INTEGER PRIMARY KEY)", [])
+ .expect("create partial table");
+ drop(conn);
+ std::fs::read(db_path).expect("read partial db")
+ }
+
#[test]
fn validate_board_short_name_rejects_path_traversal() {
assert!(validate_board_short_name("test").is_ok());
@@ -720,11 +777,12 @@ mod tests {
fn verify_full_backup_zip_accepts_manifest_backed_archive() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let zip_path = temp_dir.path().join("full.zip");
+ let db_bytes = super::super::v4::valid_db_snapshot_for_test();
let manifest = FullBackupManifest {
version: 1,
generated_at: 1_700_000_000,
rustchan_version: "1.1.3".into(),
- db_bytes: 4096,
+ db_bytes: u64::try_from(db_bytes.len()).expect("db size"),
upload_file_count: 1,
favicon_file_count: 1,
banner_file_count: 0,
@@ -740,7 +798,7 @@ mod tests {
&zip_path,
&[
(FULL_BACKUP_MANIFEST_NAME, &manifest_json),
- ("chan.db", b"SQLite format 3\0rest of db"),
+ ("chan.db", db_bytes.as_slice()),
("uploads/b/test.webp", b"img"),
("favicon/favicon-32x32.png", b"icon"),
],
@@ -752,6 +810,41 @@ mod tests {
assert_eq!(verified.boards.len(), 1);
}
+ #[test]
+ fn verify_full_backup_zip_rejects_structurally_invalid_database() {
+ let temp_dir = tempfile::tempdir().expect("tempdir");
+ let zip_path = temp_dir.path().join("invalid-db.zip");
+ let db_bytes = partial_sqlite_db_bytes_for_test();
+ let manifest = FullBackupManifest {
+ version: 3,
+ generated_at: 1_700_000_000,
+ rustchan_version: "1.3.0".into(),
+ db_bytes: u64::try_from(db_bytes.len()).expect("db size"),
+ upload_file_count: 0,
+ favicon_file_count: 0,
+ banner_file_count: 0,
+ tor_hidden_service_keys_included: false,
+ tor_hidden_service_key_file_count: 0,
+ boards: Vec::new(),
+ };
+ let manifest_json = serde_json::to_vec(&manifest).expect("manifest json");
+ write_zip(
+ &zip_path,
+ &[
+ (FULL_BACKUP_MANIFEST_NAME, &manifest_json),
+ ("chan.db", db_bytes.as_slice()),
+ ],
+ );
+
+ let error = verify_full_backup_zip(&zip_path).expect_err("invalid db rejected");
+ assert!(
+ error
+ .to_string()
+ .contains("does not match the RustChan 1.3.0 database baseline"),
+ "unexpected error: {error:#}"
+ );
+ }
+
#[test]
fn verify_full_backup_zip_rejects_missing_manifest() {
let temp_dir = tempfile::tempdir().expect("tempdir");
@@ -767,11 +860,12 @@ mod tests {
fn verify_full_backup_zip_defaults_legacy_tor_metadata_to_not_included() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let zip_path = temp_dir.path().join("legacy-full.zip");
+ let db_bytes = super::super::v4::valid_db_snapshot_for_test();
let manifest = json!({
"version": 2,
"generated_at": 1_700_000_000_i64,
"rustchan_version": "1.1.3",
- "db_bytes": 4096_u64,
+ "db_bytes": u64::try_from(db_bytes.len()).expect("db size"),
"upload_file_count": 1_u64,
"favicon_file_count": 0_u64,
"banner_file_count": 0_u64,
@@ -782,7 +876,7 @@ mod tests {
&zip_path,
&[
(FULL_BACKUP_MANIFEST_NAME, &manifest_bytes),
- ("chan.db", b"SQLite format 3\0db"),
+ ("chan.db", db_bytes.as_slice()),
("uploads/tech/file.txt", b"ok"),
],
);
@@ -796,11 +890,12 @@ mod tests {
fn verify_full_backup_zip_rejects_tor_manifest_mismatch() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let zip_path = temp_dir.path().join("tor-mismatch.zip");
+ let db_bytes = super::super::v4::valid_db_snapshot_for_test();
let manifest = FullBackupManifest {
version: 3,
generated_at: 1_700_000_000,
rustchan_version: "1.1.3".into(),
- db_bytes: 4096,
+ db_bytes: u64::try_from(db_bytes.len()).expect("db size"),
upload_file_count: 0,
favicon_file_count: 0,
banner_file_count: 0,
@@ -813,7 +908,7 @@ mod tests {
&zip_path,
&[
(FULL_BACKUP_MANIFEST_NAME, &manifest_bytes),
- ("chan.db", b"SQLite format 3\0db"),
+ ("chan.db", db_bytes.as_slice()),
],
);
diff --git a/src/handlers/admin/backup/restore_full.rs b/src/handlers/admin/backup/restore_full.rs
index a52d358d..8db09af0 100644
--- a/src/handlers/admin/backup/restore_full.rs
+++ b/src/handlers/admin/backup/restore_full.rs
@@ -542,6 +542,12 @@ pub(super) fn execute_full_restore(
let backup_result = (|| -> Result<()> {
let src = rusqlite::Connection::open(&temp_db)
.map_err(|error| AppError::Internal(anyhow::anyhow!("Open backup source: {error}")))?;
+ db::normalize_database_schema_version(&src).map_err(|error| {
+ AppError::BadRequest(format!(
+ "Restored database does not match the RustChan {} baseline: {error}",
+ db::baseline_schema_version()
+ ))
+ })?;
validate_full_restore_db_trust_boundary(&src)?;
scrub_full_restore_runtime_state(&src)?;
db::rebuild_pending_fs_ops_for_restore(&src)?;
diff --git a/src/handlers/admin/backup/v4.rs b/src/handlers/admin/backup/v4.rs
index 235773f1..825aec2c 100644
--- a/src/handlers/admin/backup/v4.rs
+++ b/src/handlers/admin/backup/v4.rs
@@ -1543,6 +1543,9 @@ pub(crate) fn verify_saved_v4_root(
}
None => None,
};
+ if let Some(snapshot) = &db_snapshot {
+ verify_db_snapshot_schema(snapshot)?;
+ }
match manifest.scope {
BackupScope::FullSite | BackupScope::PreMaintenance => {
@@ -1800,6 +1803,38 @@ pub(crate) fn verify_saved_v4_root(
})
}
+fn verify_db_snapshot_schema(snapshot: &VerifiedSavedV4DbSnapshot) -> Result<()> {
+ let mut temp_db = tempfile::NamedTempFile::new().map_err(|error| {
+ AppError::Internal(anyhow::anyhow!(
+ "Create temporary Backup v4 DB validation file: {error}"
+ ))
+ })?;
+ copy_verified_file_to_writer(&snapshot.file, temp_db.as_file_mut())?;
+ let conn = rusqlite::Connection::open(temp_db.path()).map_err(|error| {
+ AppError::BadRequest(format!(
+ "Backup v4 DB snapshot could not be opened as SQLite: {error}"
+ ))
+ })?;
+ crate::db::normalize_database_schema_version(&conn).map_err(|error| {
+ AppError::BadRequest(format!(
+ "Backup v4 DB snapshot does not match the RustChan {} database baseline: {error}",
+ crate::db::baseline_schema_version()
+ ))
+ })
+}
+
+#[cfg(test)]
+pub(crate) fn valid_db_snapshot_for_test() -> Vec {
+ let temp_dir = tempfile::tempdir().expect("tempdir");
+ let db_path = temp_dir.path().join("snapshot.sqlite3");
+ let pool = crate::db::init_test_pool().expect("test pool");
+ let conn = pool.get().expect("db conn");
+ let db_path_sql = db_path.to_str().expect("db path").replace('\'', "''");
+ conn.execute_batch(&format!("VACUUM INTO '{db_path_sql}'"))
+ .expect("vacuum snapshot");
+ std::fs::read(db_path).expect("read db snapshot")
+}
+
#[cfg(test)]
pub(crate) fn write_saved_v4_fixture_for_test(
root_dir: &Path,
@@ -1980,7 +2015,7 @@ mod tests {
&root,
BackupScope::FullSite,
board_fixture_files_for_test(),
- Some(b"sqlite".to_vec()),
+ Some(valid_db_snapshot_for_test()),
1_715_000_000_i64,
);
(dir, root, manifest)
@@ -2144,7 +2179,7 @@ mod tests {
&root,
BackupScope::FullSite,
board_fixture_files_for_test(),
- Some(b"sqlite".to_vec()),
+ Some(valid_db_snapshot_for_test()),
1_715_000_000_i64,
);
@@ -2253,7 +2288,7 @@ mod tests {
&root,
BackupScope::FullSite,
board_fixture_files_for_test(),
- Some(b"sqlite".to_vec()),
+ Some(valid_db_snapshot_for_test()),
1_715_000_100_i64,
);
std::fs::write(root.join("boards/tech/media/src/example.txt"), b"other")
@@ -2273,7 +2308,7 @@ mod tests {
&root,
BackupScope::FullSite,
board_fixture_files_for_test(),
- Some(b"sqlite".to_vec()),
+ Some(valid_db_snapshot_for_test()),
1_715_000_200_i64,
);
std::fs::remove_file(root.join("boards/tech/media/src/example.txt")).expect("remove file");
@@ -2291,7 +2326,7 @@ mod tests {
&root,
BackupScope::FullSite,
board_fixture_files_for_test(),
- Some(b"sqlite".to_vec()),
+ Some(valid_db_snapshot_for_test()),
1_715_000_250_i64,
);
convert_fixture_to_split(&root, 16);
@@ -2529,7 +2564,7 @@ mod tests {
&root,
BackupScope::FullSite,
board_fixture_files_for_test(),
- Some(b"sqlite".to_vec()),
+ Some(valid_db_snapshot_for_test()),
1_715_000_300_i64,
);
manifest.files.push(
@@ -2558,7 +2593,7 @@ mod tests {
&root,
BackupScope::FullSite,
board_fixture_files_for_test(),
- Some(b"sqlite".to_vec()),
+ Some(valid_db_snapshot_for_test()),
1_715_000_400_i64,
);
let outside_dir = dir.path().join("outside");
diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs
index 39bd034b..cb705cd9 100644
--- a/src/handlers/admin/mod.rs
+++ b/src/handlers/admin/mod.rs
@@ -708,12 +708,16 @@ struct DashboardSummaryInputs<'a> {
struct SiteHealthSnapshot {
server_status: String,
+ database_schema_status: String,
database_integrity_status: String,
last_successful_backup: String,
next_scheduled_backup: String,
data_dir_usage: String,
upload_dir_size: String,
tor_status: String,
+ tor_service_status: String,
+ tor_mode: String,
+ tor_config_summary: String,
running_jobs: i64,
queued_jobs: i64,
recent_completed_jobs: i64,
@@ -817,7 +821,7 @@ fn load_site_health_snapshot(
state: &AppState,
full_backups: &[BackupInfo],
auto_full_backup_settings: &crate::middleware::AutoFullBackupSettingsSnapshot,
- _onion_address_val: Option<&str>,
+ onion_address_val: Option<&str>,
) -> SiteHealthSnapshot {
let server_status = conn
.query_row("SELECT 1", [], |row| row.get::<_, i64>(0))
@@ -825,6 +829,7 @@ fn load_site_health_snapshot(
.filter(|value| *value == 1)
.map_or_else(|| "degraded".to_owned(), |_| "ready".to_owned());
let database_integrity_status = db_integrity_status(&state.db_maintenance_jobs.snapshot());
+ let database_schema_status = db::database_schema_status_label(conn);
let last_successful_backup = full_backups
.iter()
.find(|backup| backup.verified)
@@ -835,9 +840,16 @@ fn load_site_health_snapshot(
let upload_dir_size = safe_dir_size_label(Path::new(&CONFIG.upload_dir));
let jobs = load_site_health_jobs_snapshot(conn, state);
let recent_warnings = recent_warning_lines().unwrap_or_else(|| "not available".to_owned());
+ let tor_service_status = tor_service_status_label(onion_address_val);
+ let tor_mode = tor_mode_label();
+ let tor_config_summary = format!(
+ "bootstrap timeout {}s; max streams {}",
+ CONFIG.tor_bootstrap_timeout_secs, CONFIG.tor_max_concurrent_streams
+ );
SiteHealthSnapshot {
server_status,
+ database_schema_status,
database_integrity_status,
last_successful_backup,
next_scheduled_backup,
@@ -848,6 +860,9 @@ fn load_site_health_snapshot(
} else {
"disabled".to_owned()
},
+ tor_service_status,
+ tor_mode,
+ tor_config_summary,
running_jobs: jobs.running,
queued_jobs: jobs.queued,
recent_completed_jobs: jobs.recent_completed,
@@ -858,6 +873,26 @@ fn load_site_health_snapshot(
}
}
+fn tor_service_status_label(onion_address: Option<&str>) -> String {
+ if !CONFIG.enable_tor_support {
+ "disabled".to_owned()
+ } else if onion_address.is_some() {
+ "onion service ready".to_owned()
+ } else {
+ "starting or unavailable".to_owned()
+ }
+}
+
+fn tor_mode_label() -> String {
+ if !CONFIG.enable_tor_support {
+ "clearnet only".to_owned()
+ } else if CONFIG.tor_only {
+ "Tor only".to_owned()
+ } else {
+ "clearnet and Tor".to_owned()
+ }
+}
+
fn load_dashboard_activity_snapshot(
conn: &rusqlite::Connection,
board_count: usize,
@@ -1482,6 +1517,7 @@ fn dashboard_database_status(
site_health: &SiteHealthSnapshot,
) -> (String, String, crate::templates::AdminDashboardState) {
let state = if site_health.server_status != "ready"
+ || site_health.database_schema_status.contains("mismatch")
|| site_health.database_integrity_status.contains("failed")
{
crate::templates::AdminDashboardState::ActionNeeded
@@ -1494,7 +1530,10 @@ fn dashboard_database_status(
};
(
site_health.server_status.clone(),
- format!("Integrity: {}.", site_health.database_integrity_status),
+ format!(
+ "Schema: {}; integrity: {}.",
+ site_health.database_schema_status, site_health.database_integrity_status
+ ),
state,
)
}
@@ -1887,6 +1926,7 @@ fn build_site_health_view<'a>(
crate::templates::AdminPanelSiteHealthView {
server_status: &snapshot.site_health.server_status,
rustchan_version: env!("CARGO_PKG_VERSION"),
+ database_schema_status: &snapshot.site_health.database_schema_status,
database_integrity_status: &snapshot.site_health.database_integrity_status,
last_successful_backup: &snapshot.site_health.last_successful_backup,
next_scheduled_backup: &snapshot.site_health.next_scheduled_backup,
@@ -1894,6 +1934,10 @@ fn build_site_health_view<'a>(
upload_dir_size: &snapshot.site_health.upload_dir_size,
tor_status: &snapshot.site_health.tor_status,
tor_onion_address: tor_address,
+ tor_service_status: &snapshot.site_health.tor_service_status,
+ tor_mode: &snapshot.site_health.tor_mode,
+ tor_config_summary: &snapshot.site_health.tor_config_summary,
+ tor_detail: &snapshot.dashboard.tor_detail,
dependency_summary: crate::templates::AdminSiteHealthDependencySummary {
ffmpeg: detection_status(snapshot.ffmpeg_available),
ffprobe: detection_status(snapshot.ffprobe_available),
@@ -1938,6 +1982,7 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st
let tor_detail = tor_address.unwrap_or("not available");
format!(
"RustChan version: {version}\n\
+ Database schema: {schema}\n\
OS: {os}-{arch}\n\
SQLite: {sqlite}\n\
ffmpeg: {ffmpeg}\n\
@@ -1950,6 +1995,7 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st
Dependency log: configured\n\
Recent warnings:\n{warnings}\n",
version = env!("CARGO_PKG_VERSION"),
+ schema = snapshot.site_health.database_schema_status.as_str(),
os = std::env::consts::OS,
arch = std::env::consts::ARCH,
sqlite = rusqlite::version(),
@@ -2100,6 +2146,54 @@ pub async fn admin_site_health_jobs(
.into_response())
}
+#[derive(Deserialize)]
+pub struct DismissFailedJobsForm {
+ #[serde(rename = "_csrf")]
+ csrf: Option,
+}
+
+pub async fn dismiss_failed_site_health_jobs(
+ State(state): State,
+ jar: CookieJar,
+ headers: HeaderMap,
+ axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo,
+ axum::extract::Form(form): axum::extract::Form,
+) -> Result {
+ let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_owned());
+ require_admin_post_origin_and_csrf(&jar, &headers, Some(peer), form.csrf.as_deref())?;
+
+ let failed_before = tokio::task::spawn_blocking({
+ let pool = state.db.clone();
+ move || -> Result {
+ let conn = pool.get()?;
+ let (admin_id, admin_name) =
+ require_admin_session_with_name(&conn, session_id.as_deref())?;
+ let failed_before = db::background_job_summary(&conn)?.failed;
+ let acknowledged_through = db::acknowledge_failed_background_jobs(&conn)?;
+ db::log_mod_action(
+ &conn,
+ admin_id,
+ &admin_name,
+ "dismiss_failed_jobs",
+ "background_jobs",
+ None,
+ "",
+ &format!("acknowledged failed background jobs through id {acknowledged_through}"),
+ )?;
+ Ok(failed_before)
+ }
+ })
+ .await
+ .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??;
+
+ let message = if failed_before == 0 {
+ "Failed job counter was already clear."
+ } else {
+ "Failed job counter dismissed."
+ };
+ Ok(admin_panel_redirect_anchor_open(message, "site-health", "site-health").into_response())
+}
+
pub async fn admin_live_log(
State(state): State,
jar: CookieJar,
@@ -2240,18 +2334,19 @@ mod tests {
use super::{
admin_live_log, admin_site_health_jobs, consume_admin_session_bootstrap,
create_admin_session_bootstrap, dashboard_backup_status, dashboard_recent_count,
- dashboard_thread_counts, host_header_uses_https_port_with_config,
- hosts_match_for_same_origin, latest_log_file, load_dashboard_activity_snapshot,
- optional_count_query, read_log_tail, request_origin_uses_https,
- request_scheme_for_same_origin_with_config, require_same_origin_or_valid_csrf,
- require_same_origin_request, should_set_secure_cookie_with_config, BackupSummary,
- LiveLogQuery, SESSION_COOKIE,
+ dashboard_thread_counts, dismiss_failed_site_health_jobs,
+ host_header_uses_https_port_with_config, hosts_match_for_same_origin, latest_log_file,
+ load_dashboard_activity_snapshot, optional_count_query, read_log_tail,
+ request_origin_uses_https, request_scheme_for_same_origin_with_config,
+ require_same_origin_or_valid_csrf, require_same_origin_request,
+ should_set_secure_cookie_with_config, BackupSummary, DismissFailedJobsForm, LiveLogQuery,
+ SESSION_COOKIE,
};
use crate::error::AppError;
use crate::middleware::SecureCookieContext;
use axum::{
body::to_bytes,
- extract::{Query, State},
+ extract::{ConnectInfo, Form, Query, State},
http::{header, HeaderMap, HeaderValue, StatusCode},
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
@@ -2897,6 +2992,14 @@ mod tests {
.expect("create session");
}
+ fn admin_signed_csrf() -> String {
+ crate::utils::crypto::make_scoped_csrf_form_token(
+ "csrf123",
+ &crate::config::CONFIG.cookie_secret,
+ "session123",
+ )
+ }
+
#[tokio::test]
async fn live_log_requires_admin_auth() {
let state = crate::test_support::app_state();
@@ -3029,6 +3132,65 @@ mod tests {
assert!(error.chars().count() <= 183);
}
+ #[tokio::test]
+ async fn dismiss_failed_site_health_jobs_resets_counter_without_deleting_history() {
+ let state = crate::test_support::app_state();
+ install_admin_session(&state);
+ {
+ let conn = state.db.get().expect("db connection");
+ conn.execute(
+ "INSERT INTO background_jobs
+ (job_type, payload, status, attempts, last_error, updated_at)
+ VALUES
+ ('video_transcode', '{}', 'failed', 3, 'ffmpeg failed', unixepoch())",
+ [],
+ )
+ .expect("insert failed job");
+ assert_eq!(
+ crate::db::background_job_summary(&conn)
+ .expect("summary before dismiss")
+ .failed,
+ 1
+ );
+ }
+ let mut headers = same_origin_headers("localhost");
+ headers.insert(header::ORIGIN, HeaderValue::from_static("http://localhost"));
+ let response = dismiss_failed_site_health_jobs(
+ State(state.clone()),
+ CookieJar::new()
+ .add(Cookie::new("csrf_token", "csrf123"))
+ .add(Cookie::new(SESSION_COOKIE, "session123")),
+ headers,
+ ConnectInfo("127.0.0.1:3000".parse().expect("peer address")),
+ Form(DismissFailedJobsForm {
+ csrf: Some(admin_signed_csrf()),
+ }),
+ )
+ .await
+ .expect("dismiss response");
+
+ assert_eq!(response.status(), StatusCode::SEE_OTHER);
+ assert_eq!(
+ response.headers().get(header::LOCATION),
+ Some(&HeaderValue::from_static(
+ "/admin/panel?flash=Failed%20job%20counter%20dismissed.&open=site-health#site-health"
+ ))
+ );
+ let conn = state.db.get().expect("db connection");
+ assert_eq!(
+ crate::db::background_job_summary(&conn)
+ .expect("summary after dismiss")
+ .failed,
+ 0
+ );
+ assert_eq!(
+ crate::db::recent_background_jobs(&conn, "failed", 10)
+ .expect("recent failed history")
+ .len(),
+ 1
+ );
+ }
+
#[tokio::test]
async fn live_log_returns_no_store_headers_and_json_body() {
let state = crate::test_support::app_state();
diff --git a/src/handlers/setup.rs b/src/handlers/setup.rs
index c3fcabd4..b7742a70 100644
--- a/src/handlers/setup.rs
+++ b/src/handlers/setup.rs
@@ -576,14 +576,18 @@ pub async fn setup_get(
secure_context: crate::middleware::SecureCookieContext,
) -> Result {
let (setup_state, _is_admin) = load_setup_state(&state, admin_session_id(&jar)).await?;
+ let current_theme = crate::handlers::board::current_theme_from_jar(&jar);
let (jar, csrf) = ensure_setup_csrf(jar, &headers, secure_context);
+ let form =
+ SetupWizardForm::defaults_for(parse_preset(query.preset.as_deref().unwrap_or("public")));
let body = setup_form_page(
&csrf,
setup_state,
- &SetupWizardForm::defaults_for(parse_preset(query.preset.as_deref().unwrap_or("public"))),
+ &form,
&[],
request_transport_warning(&headers, secure_context).as_deref(),
&state,
+ current_theme.as_deref(),
);
Ok((jar, Html(body)).into_response())
}
@@ -601,6 +605,7 @@ pub async fn setup_review(
Form(form): Form,
) -> Result {
let (setup_state, _is_admin) = load_setup_state(&state, admin_session_id(&jar)).await?;
+ let current_theme = crate::handlers::board::current_theme_from_jar(&jar);
validate_setup_csrf(&jar, &headers, secure_context.peer, form.csrf.as_deref())?;
let parsed = match parse_setup_form(&form, setup_state.admin_count) {
Ok(parsed) => parsed,
@@ -617,6 +622,7 @@ pub async fn setup_review(
&errors,
request_transport_warning(&headers, secure_context).as_deref(),
&state,
+ current_theme.as_deref(),
)),
),
)
@@ -644,7 +650,13 @@ pub async fn setup_review(
}
Ok((
jar,
- Html(setup_review_page(&csrf, setup_state, &review_form, &parsed)),
+ Html(setup_review_page(
+ &csrf,
+ setup_state,
+ &review_form,
+ &parsed,
+ current_theme.as_deref(),
+ )),
)
.into_response())
}
@@ -976,6 +988,7 @@ fn setup_form_page(
errors: &[String],
transport_warning: Option<&str>,
app_state: &AppState,
+ current_theme: Option<&str>,
) -> String {
let mut alerts = String::new();
if state.requires_admin_auth() {
@@ -1144,8 +1157,8 @@ fn setup_form_page(
&body,
csrf,
boards.as_ref(),
- None,
- None,
+ current_theme,
+ form.default_theme.as_deref(),
false,
"/setup",
)
@@ -1156,6 +1169,7 @@ fn setup_review_page(
state: db::SetupState,
form: &SetupWizardForm,
parsed: &ParsedSetup,
+ current_theme: Option<&str>,
) -> String {
let hidden = hidden_form_fields(csrf, form);
let admin_line = if state.admin_count == 0 {
@@ -1228,8 +1242,8 @@ fn setup_review_page(
&body,
csrf,
boards.as_ref(),
- None,
- None,
+ current_theme,
+ Some(&parsed.default_theme),
false,
"/setup",
)
@@ -1470,6 +1484,32 @@ mod tests {
(raw, form)
}
+ fn install_setup_theme_test_state() {
+ crate::templates::set_live_default_theme("forest");
+ crate::templates::set_live_themes(vec![
+ crate::models::Theme {
+ slug: "forest".to_owned(),
+ display_name: "Forest".to_owned(),
+ description: "Forest theme".to_owned(),
+ swatch_hex: "#7ab84e".to_owned(),
+ enabled: true,
+ sort_order: 1,
+ is_builtin: true,
+ custom_css: String::new(),
+ },
+ crate::models::Theme {
+ slug: "blue-sky".to_owned(),
+ display_name: "Blue Sky".to_owned(),
+ description: "Bright theme".to_owned(),
+ swatch_hex: "#66aaff".to_owned(),
+ enabled: true,
+ sort_order: 2,
+ is_builtin: true,
+ custom_css: String::new(),
+ },
+ ]);
+ }
+
#[tokio::test]
async fn initialized_instance_blocks_setup_route() {
let state = crate::test_support::app_state();
@@ -1536,6 +1576,72 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn setup_get_uses_current_theme_cookie() {
+ install_setup_theme_test_state();
+ let state = crate::test_support::app_state();
+ let app = Router::new()
+ .route("/setup", get(setup_get))
+ .with_state(state);
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .uri("/setup")
+ .header(header::HOST, "localhost")
+ .header(header::COOKIE, "rustchan_theme=blue-sky")
+ .extension(crate::test_support::connect_info())
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ let body = String::from_utf8(body.to_vec()).expect("utf8");
+ assert!(body.contains(r#"data-active-theme="blue-sky""#));
+ assert!(body.contains(r#"data-theme="blue-sky""#));
+ }
+
+ #[tokio::test]
+ async fn setup_review_uses_selected_default_theme_without_js() {
+ install_setup_theme_test_state();
+ let state = crate::test_support::app_state();
+ let app = Router::new()
+ .route("/setup/review", post(setup_review))
+ .with_state(state);
+ let (_raw_csrf, form_csrf) = setup_csrf_pair();
+ let body = setup_form_body(&form_csrf, "b", Some("long-enough-password"))
+ .replace("default_theme=forest", "default_theme=blue-sky");
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri("/setup/review")
+ .header(header::HOST, "localhost")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, "csrf_token=csrf123")
+ .extension(crate::test_support::connect_info())
+ .body(Body::from(body))
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ let body = String::from_utf8(body.to_vec()).expect("utf8");
+ assert!(body.contains(r#"data-active-theme="blue-sky""#));
+ assert!(body.contains(r#"data-theme="blue-sky""#));
+ assert!(body.contains(r#"name="default_theme" value="blue-sky""#));
+ }
+
#[tokio::test]
async fn setup_review_does_not_echo_admin_password() {
let state = crate::test_support::app_state();
diff --git a/src/main.rs b/src/main.rs
index 76f6b767..df96e970 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -12,6 +12,7 @@
// rustchan-cli admin ban [hours]
// rustchan-cli admin unban
// rustchan-cli admin list-bans
+// rustchan-cli admin db-status
//
// Data lives in /rustchan-data/ (override with CHAN_DB / CHAN_UPLOADS)
// Static CSS is compiled into the binary — no external files needed.
diff --git a/src/server/cli.rs b/src/server/cli.rs
index fb7cffec..8e988c0d 100644
--- a/src/server/cli.rs
+++ b/src/server/cli.rs
@@ -82,10 +82,21 @@ pub enum AdminAction {
ban_id: i64,
},
ListBans,
+ DbStatus,
}
// ─── Admin CLI mode ───────────────────────────────────────────────────────────
+fn write_db_status_output(
+ mut writer: W,
+ schema_status: &str,
+ sqlite_version: &str,
+) -> std::io::Result<()> {
+ writeln!(writer, "Database schema: {schema_status}")?;
+ writeln!(writer, "SQLite: {sqlite_version}")?;
+ Ok(())
+}
+
#[expect(clippy::too_many_lines)]
pub fn run_admin(action: AdminAction) -> anyhow::Result<()> {
use crate::{db, utils::crypto};
@@ -294,13 +305,21 @@ pub fn run_admin(action: AdminAction) -> anyhow::Result<()> {
}
}
}
+ AdminAction::DbStatus => {
+ let schema_status = db::database_schema_status_label(&conn);
+ write_db_status_output(
+ std::io::stdout().lock(),
+ &schema_status,
+ rusqlite::version(),
+ )?;
+ }
}
Ok(())
}
#[cfg(test)]
mod tests {
- use super::{AdminAction, Cli, Command};
+ use super::{write_db_status_output, AdminAction, Cli, Command};
use clap::Parser as _;
#[test]
@@ -363,4 +382,28 @@ mod tests {
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
+
+ #[test]
+ fn db_status_command_is_available() {
+ let cli = Cli::parse_from(["rustchan-cli", "admin", "db-status"]);
+
+ let Some(Command::Admin {
+ action: AdminAction::DbStatus,
+ }) = cli.command
+ else {
+ panic!("expected db-status command");
+ };
+ }
+
+ #[test]
+ fn db_status_output_uses_release_schema_version() {
+ let mut out = Vec::new();
+
+ write_db_status_output(&mut out, "1.3.0 baseline verified", "3.test")
+ .expect("write db status output");
+
+ let output = String::from_utf8(out).expect("utf8 output");
+ assert!(output.contains("Database schema: 1.3.0 baseline verified"));
+ assert!(output.contains("SQLite: 3.test"));
+ }
}
diff --git a/src/server/server/observability.rs b/src/server/server/observability.rs
index 0a334598..d51794f4 100644
--- a/src/server/server/observability.rs
+++ b/src/server/server/observability.rs
@@ -39,6 +39,8 @@ struct HealthPayload {
struct ReadyPayload {
status: &'static str,
database_ready: bool,
+ database_schema_version: &'static str,
+ database_schema_valid: bool,
tor_enabled: bool,
tor_onion_ready: bool,
worker_queue_pending: i64,
@@ -61,12 +63,13 @@ pub(super) async fn healthz() -> impl IntoResponse {
pub(super) async fn readyz(State(state): State) -> Response {
let (
database_ready,
+ database_schema_valid,
media_processing_failed,
latest_full_backup_verified,
latest_full_backup_age_hours,
) = tokio::task::spawn_blocking({
let pool = state.db.clone();
- move || -> (bool, i64, bool, Option) {
+ move || -> (bool, bool, i64, bool, Option) {
let full_backups = list_backup_files(&full_backup_dir(), BackupListKind::Full);
let latest_backup = full_backups.first().cloned();
let latest_full_backup_verified =
@@ -82,19 +85,22 @@ pub(super) async fn readyz(State(state): State) -> Response {
.query_row("SELECT 1", [], |row| row.get::<_, i64>(0))
.ok()
.is_some_and(|value| value == 1);
+ let schema_valid = crate::db::verify_database_schema(&conn).is_ok();
let failed = crate::db::count_posts_by_media_processing_state(
&conn,
crate::db::MEDIA_PROCESSING_FAILED,
)
.unwrap_or(0);
(
- ready,
+ ready && schema_valid,
+ schema_valid,
failed,
latest_full_backup_verified,
latest_full_backup_age_hours,
)
}
Err(_) => (
+ false,
false,
0,
latest_full_backup_verified,
@@ -104,7 +110,7 @@ pub(super) async fn readyz(State(state): State) -> Response {
}
})
.await
- .unwrap_or((false, 0, false, None));
+ .unwrap_or((false, false, 0, false, None));
let tor_onion_ready = if CONFIG.enable_tor_support {
state.onion_address.read().await.is_some()
@@ -114,6 +120,8 @@ pub(super) async fn readyz(State(state): State) -> Response {
let payload = ReadyPayload {
status: if database_ready { "ready" } else { "degraded" },
database_ready,
+ database_schema_version: crate::db::baseline_schema_version(),
+ database_schema_valid,
tor_enabled: CONFIG.enable_tor_support,
tor_onion_ready,
worker_queue_pending: state.job_queue.pending_count(),
@@ -141,12 +149,13 @@ pub(super) async fn metrics(State(state): State) -> Response {
let (
media_processing_pending,
media_processing_failed,
+ database_schema_valid,
full_backup_count,
latest_full_backup_verified,
latest_full_backup_age_seconds,
) = tokio::task::spawn_blocking({
let pool = state.db.clone();
- move || -> (i64, i64, i64, bool, i64) {
+ move || -> (i64, i64, bool, i64, bool, i64) {
let full_backups = list_backup_files(&full_backup_dir(), BackupListKind::Full);
let full_backup_count = i64::try_from(full_backups.len()).unwrap_or(i64::MAX);
let latest_full_backup_verified =
@@ -157,24 +166,29 @@ pub(super) async fn metrics(State(state): State) -> Response {
.map(|ts| chrono::Utc::now().timestamp().saturating_sub(ts).max(0))
.unwrap_or(-1);
match pool.get() {
- Ok(conn) => (
- crate::db::count_posts_by_media_processing_state(
- &conn,
- crate::db::MEDIA_PROCESSING_PENDING,
- )
- .unwrap_or(0),
- crate::db::count_posts_by_media_processing_state(
- &conn,
- crate::db::MEDIA_PROCESSING_FAILED,
+ Ok(conn) => {
+ let database_schema_valid = crate::db::verify_database_schema(&conn).is_ok();
+ (
+ crate::db::count_posts_by_media_processing_state(
+ &conn,
+ crate::db::MEDIA_PROCESSING_PENDING,
+ )
+ .unwrap_or(0),
+ crate::db::count_posts_by_media_processing_state(
+ &conn,
+ crate::db::MEDIA_PROCESSING_FAILED,
+ )
+ .unwrap_or(0),
+ database_schema_valid,
+ full_backup_count,
+ latest_full_backup_verified,
+ latest_full_backup_age_seconds,
)
- .unwrap_or(0),
- full_backup_count,
- latest_full_backup_verified,
- latest_full_backup_age_seconds,
- ),
+ }
Err(_) => (
0,
0,
+ false,
full_backup_count,
latest_full_backup_verified,
latest_full_backup_age_seconds,
@@ -183,7 +197,7 @@ pub(super) async fn metrics(State(state): State) -> Response {
}
})
.await
- .unwrap_or((0, 0, 0, false, -1));
+ .unwrap_or((0, 0, false, 0, false, -1));
let body = format!(
concat!(
@@ -203,6 +217,8 @@ pub(super) async fn metrics(State(state): State) -> Response {
"rustchan_media_processing_pending {}\n",
"# TYPE rustchan_media_processing_failed gauge\n",
"rustchan_media_processing_failed {}\n",
+ "# TYPE rustchan_database_schema_valid gauge\n",
+ "rustchan_database_schema_valid{{version=\"{}\"}} {}\n",
"# TYPE rustchan_full_backups_saved gauge\n",
"rustchan_full_backups_saved {}\n",
"# TYPE rustchan_latest_full_backup_verified gauge\n",
@@ -234,6 +250,8 @@ pub(super) async fn metrics(State(state): State) -> Response {
state.job_queue.dropped_count(),
media_processing_pending,
media_processing_failed,
+ crate::db::baseline_schema_version(),
+ u8::from(database_schema_valid),
full_backup_count,
u8::from(latest_full_backup_verified),
latest_full_backup_age_seconds,
diff --git a/src/server/server/router.rs b/src/server/server/router.rs
index 1616f633..3355d62f 100644
--- a/src/server/server/router.rs
+++ b/src/server/server/router.rs
@@ -277,6 +277,7 @@ mod tests {
let body = String::from_utf8(body.to_vec()).expect("utf8 metrics");
assert!(body.contains("rustchan_requests_total"));
assert!(body.contains("rustchan_job_queue_pending"));
+ assert!(body.contains("rustchan_database_schema_valid{version=\"1.3.0\"} 1"));
}
#[tokio::test]
diff --git a/src/server/server/routes.rs b/src/server/server/routes.rs
index cf02cc99..b6919abd 100644
--- a/src/server/server/routes.rs
+++ b/src/server/server/routes.rs
@@ -173,6 +173,10 @@ fn admin_auth_routes() -> Router {
"/admin/site-health/jobs",
get(crate::handlers::admin::admin_site_health_jobs),
)
+ .route(
+ "/admin/site-health/jobs/dismiss",
+ post(crate::handlers::admin::dismiss_failed_site_health_jobs),
+ )
.route(
"/admin/log/live",
get(crate::handlers::admin::admin_live_log),
diff --git a/src/templates/admin.rs b/src/templates/admin.rs
index 4af24f1f..8705d31f 100644
--- a/src/templates/admin.rs
+++ b/src/templates/admin.rs
@@ -178,6 +178,7 @@ pub struct AdminPanelBackupsView<'a> {
pub struct AdminPanelSiteHealthView<'a> {
pub server_status: &'a str,
pub rustchan_version: &'a str,
+ pub database_schema_status: &'a str,
pub database_integrity_status: &'a str,
pub last_successful_backup: &'a str,
pub next_scheduled_backup: &'a str,
@@ -185,6 +186,10 @@ pub struct AdminPanelSiteHealthView<'a> {
pub upload_dir_size: &'a str,
pub tor_status: &'a str,
pub tor_onion_address: Option<&'a str>,
+ pub tor_service_status: &'a str,
+ pub tor_mode: &'a str,
+ pub tor_config_summary: &'a str,
+ pub tor_detail: &'a str,
pub dependency_summary: AdminSiteHealthDependencySummary,
pub running_jobs: i64,
pub queued_jobs: i64,
@@ -1478,7 +1483,8 @@ pub fn admin_db_repair_failed_page(
fn render_db_health_snapshot(snapshot: &crate::db::DbHealthSnapshot) -> String {
format!(
- "{integrity}{foreign_keys}",
+ "{schema}{integrity}{foreign_keys}",
+ schema = render_db_check_result("schema baseline", &snapshot.schema),
integrity = render_db_check_result("integrity check", &snapshot.integrity),
foreign_keys = render_db_check_result("foreign key check", &snapshot.foreign_keys),
)
@@ -1955,6 +1961,7 @@ mod tests {
AdminPanelSiteHealthView {
server_status: "ready",
rustchan_version: "1.3.0",
+ database_schema_status: "1.3.0 baseline verified",
database_integrity_status: "not checked",
last_successful_backup: "none saved",
next_scheduled_backup: "not scheduled",
@@ -1962,6 +1969,10 @@ mod tests {
upload_dir_size: "unknown",
tor_status: "disabled",
tor_onion_address: None,
+ tor_service_status: "disabled",
+ tor_mode: "clearnet only",
+ tor_config_summary: "bootstrap timeout 30s; max streams 64",
+ tor_detail: "Tor support is disabled in configuration.",
dependency_summary: AdminSiteHealthDependencySummary {
ffmpeg: AdminDetectionStatus::Detected,
ffprobe: AdminDetectionStatus::Detected,
@@ -1975,7 +1986,8 @@ mod tests {
failed_jobs: 0,
backup_jobs: "idle",
restore_jobs: "not available",
- diagnostics_text: "RustChan version: 1.3.0\nRecent warnings:\n none",
+ diagnostics_text:
+ "RustChan version: 1.3.0\nDatabase schema: 1.3.0 baseline verified\nRecent warnings:\n none",
}
}
@@ -2351,13 +2363,22 @@ mod tests {
assert!(html.contains("open media panel"));
assert!(html.contains("copy diagnostics"));
assert!(html.contains("RustChan version: 1.3.0"));
+ assert!(html.contains("Database schema"));
+ assert!(html.contains("1.3.0 baseline verified"));
assert!(html.contains(r#"data-admin-health-jobs-url="/admin/site-health/jobs""#));
assert!(html.contains(r#"data-admin-health-job="running_jobs""#));
assert!(html.contains(r#"data-admin-health-job="queued_jobs""#));
assert!(html.contains(r#"data-admin-health-toggle="failed""#));
assert!(html.contains(r#"data-admin-health-job-list="failed""#));
+ assert!(html.contains(r#"action="/admin/site-health/jobs/dismiss""#));
+ assert!(html.contains(r#"name="_csrf" value="csrf""#));
+ assert!(html.contains("dismiss counter"));
assert!(html.contains(r"data-admin-health-close"));
- assert!(!html.contains("Tor bootstrap state"));
+ assert!(html.contains(r#"id="tor-status""#));
+ assert!(html.contains("// Tor diagnostics"));
+ assert!(html.contains("Onion service"));
+ assert!(html.contains("Runtime config"));
+ assert!(html.contains("Tor support is disabled in configuration."));
assert!(!html.contains("Thumbnail/transcode jobs"));
assert!(!html.contains("Repair/VACUUM jobs"));
}
@@ -2378,6 +2399,41 @@ mod tests {
));
}
+ #[test]
+ fn admin_panel_control_center_uses_persistent_dropdown_pattern() {
+ let board = sample_board();
+ let themes = vec![sample_theme()];
+ let html = render_admin_panel_for_test(std::slice::from_ref(&board), &[], &themes, None);
+
+ assert!(html.contains(
+ r#""#
+ ));
+ assert!(html.contains(r##"href="#public-url-settings""##));
+ assert!(html.contains(r##"href="#tor-status""##));
+ assert!(html.contains(r#"id="public-url-settings""#));
+ assert!(html.contains("settings.toml public_hosts"));
+ }
+
+ #[test]
+ fn admin_panel_control_center_honors_open_target() {
+ let board = sample_board();
+ let themes = vec![sample_theme()];
+ let html = render_admin_panel_for_test(
+ std::slice::from_ref(&board),
+ &[],
+ &themes,
+ Some("control-center"),
+ );
+
+ assert!(html.contains(
+ r#""#
+ ));
+ }
+
#[test]
fn board_appearance_card_keeps_nsfw_tag() {
let mut board = sample_board();
@@ -2457,6 +2513,10 @@ mod tests {
fn admin_db_result_pages_use_shared_status_surfaces() {
let report = DbHealthReport {
before: DbHealthSnapshot {
+ schema: DbCheckResult {
+ ok: true,
+ messages: vec!["1.3.0 baseline verified".into()],
+ },
integrity: DbCheckResult {
ok: false,
messages: vec!["row 1".into(), "row 2".into()],
diff --git a/src/templates/admin/appearance.rs b/src/templates/admin/appearance.rs
index 0a3d100c..c2c5f677 100644
--- a/src/templates/admin/appearance.rs
+++ b/src/templates/admin/appearance.rs
@@ -44,6 +44,7 @@ pub(super) fn render_site_settings(view: &AdminPanelViewModel<'_>) -> String {
view.appearance.homepage_new_reply_badges_enabled,
view.appearance.thread_new_reply_badges_enabled,
&render_enabled_theme_options(view),
+ view.dashboard.public_url,
&global_favicon_preview,
global_favicon_label,
global_favicon_button,
@@ -718,6 +719,7 @@ fn render_admin_site_settings_section(
homepage_new_reply_badges_enabled: bool,
thread_new_reply_badges_enabled: bool,
enabled_theme_options: &str,
+ public_url: &str,
global_favicon_preview: &str,
global_favicon_label: &str,
global_favicon_button: &str,
@@ -768,6 +770,14 @@ fn render_admin_site_settings_section(
save settings
+
+
+
{public_url}
+
Runtime host trust is read from settings.toml public_hosts.
+