Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Changelog

## v0.1.4 - Layout Foundation

### Core UI Layout
- Rebuilt the shared page shell around a centered three-column layout for desktop with a stable primary reading column.
- Collapsed smaller screens to a single readable column with reachable navigation and no horizontal drift.
- Standardized feed, thread, profile, search, settings, form, and admin surfaces around shared card and column primitives.
- Added objective Playwright layout coverage for Chromium and WebKit across mobile, tablet, and desktop viewports.

### Navigation and Screenshots
- Added matching icons to the main navigation links, including home, following, search, notifications, bookmarks, profile, admin, login, register, and logout.
- Refreshed the README/changelog screenshots from the updated local demo layout.

| Home feed | Profile |
|---|---|
| ![RustPost home feed with centered layout and navigation icons](docs/screenshots/home-feed.png) | ![RustPost profile page with centered layout and navigation icons](docs/screenshots/profile.png) |

| Threaded replies | Media posts |
|---|---|
| ![RustPost post thread using the centered reading column](docs/screenshots/post-thread.png) | ![RustPost media post thread with updated card layout](docs/screenshots/media-posts.png) |

| Mobile layout |
|---|
| ![RustPost mobile feed with compact navigation icons](docs/screenshots/mobile.png) |

## v0.1.3 - Control Room

### Admin Control Room
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rustpost"
version = "0.1.3"
version = "0.1.4"
edition = "2024"
license = "MIT"
description = "A single-binary self-hosted microblogging app"
Expand Down
Binary file modified docs/screenshots/home-feed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/media-posts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/post-thread.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/profile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 5 additions & 10 deletions src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ impl DeepSettingsField {
Self::MaxImagesPerPost | Self::MaxVideosPerPost | Self::MaxMediaPerPost => {
Some("Attachments per post.")
}
Self::MinPasswordLength => Some("Characters. Values below 10 are rejected."),
Self::MinPasswordLength => Some("Characters. Recommended default is 10."),
Self::MaxImageSizeMb | Self::MaxVideoSizeMb => Some("MB."),
_ => None,
}
Expand Down Expand Up @@ -515,9 +515,6 @@ fn parse_mb(value: &str, field: DeepSettingsField) -> anyhow::Result<u64> {
let mb = trimmed
.parse::<u64>()
.with_context(|| format!("{} must be a whole number of MB", field.label()))?;
if mb == 0 {
anyhow::bail!("{} must be at least 1 MB", field.label());
}
mb.checked_mul(MIB)
.with_context(|| format!("{} is too large to convert from MB", field.label()))?;
Ok(mb)
Expand Down Expand Up @@ -1073,17 +1070,15 @@ mod tests {
}

#[test]
fn deep_settings_validation_rejects_short_minimum_password_length() {
fn deep_settings_accepts_operator_chosen_minimum_password_length() {
let settings = Settings::default();
let mut form = form_from_settings(&settings);
form.min_password_length = "5".to_owned();

let err = parse_deep_settings_form(&form, &settings).expect_err("invalid password length");
let parsed = parse_deep_settings_form(&form, &settings).expect("valid form");
let updated = parsed.apply_to(&settings);

assert!(
err.to_string()
.contains("accounts.min_password_length must be at least 10")
);
assert_eq!(updated.accounts.min_password_length, 5);
}

#[test]
Expand Down
21 changes: 15 additions & 6 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,12 +573,10 @@ fn validate_first_admin_credentials(
if username.is_empty() {
anyhow::bail!("username cannot be empty");
}
if password.is_empty() {
anyhow::bail!("password cannot be empty");
}
if password != confirm {
anyhow::bail!("passwords do not match");
}
crate::validation::validate_password(&password, settings)?;
let display_name = display_name.trim().to_owned();
if !display_name.is_empty() {
crate::validation::validate_profile_text(&display_name, "", settings)?;
Expand Down Expand Up @@ -688,7 +686,7 @@ mod tests {
}

#[test]
fn first_admin_credentials_reject_empty_password() {
fn first_admin_credentials_validate_password_policy() {
let result = validate_first_admin_credentials(
&config::Settings::default(),
"admin-user",
Expand All @@ -699,11 +697,22 @@ mod tests {

assert!(result.is_err());
assert_eq!(
result.expect_err("empty password").to_string(),
"password cannot be empty"
result.expect_err("short password").to_string(),
"password is too short"
);
}

#[test]
fn first_admin_credentials_allow_empty_password_when_configured() {
let mut settings = config::Settings::default();
settings.accounts.min_password_length = 0;
let credentials =
validate_first_admin_credentials(&settings, "admin-user", "", String::new(), "")
.expect("valid credentials");

assert_eq!(credentials.password, "");
}

#[test]
fn first_admin_credentials_reject_password_mismatch() {
let result = validate_first_admin_credentials(
Expand Down
77 changes: 18 additions & 59 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,67 +205,18 @@ impl Settings {

pub fn validate(&self) -> anyhow::Result<()> {
let site_name = self.site.name.trim();
if site_name.is_empty() || site_name.len() > 80 {
anyhow::bail!("site.name must be between 1 and 80 bytes");
}
if site_name != self.site.name {
anyhow::bail!("site.name must not contain surrounding whitespace");
}
if self.site.name.chars().any(char::is_control) {
anyhow::bail!("site.name must not contain control characters");
}
if self.accounts.min_password_length < 10 {
anyhow::bail!("accounts.min_password_length must be at least 10");
}
if self.accounts.max_username_len == 0 || self.accounts.max_username_len > 64 {
anyhow::bail!("accounts.max_username_len must be between 1 and 64");
}
if self.posts.max_text_chars == 0 || self.posts.max_text_chars > 280 {
anyhow::bail!("posts.max_text_chars must be between 1 and 280");
}
if self.posts.max_media_per_post > 4 {
anyhow::bail!("posts.max_media_per_post must be at most 4");
}
if self.posts.max_images_per_post > self.posts.max_media_per_post {
anyhow::bail!("posts.max_images_per_post cannot exceed max_media_per_post");
}
if self.media.max_image_size > 52_428_800 {
anyhow::bail!("media.max_image_size cannot exceed 50 MiB");
}
if self.media.max_video_size > 157_286_400 {
anyhow::bail!("media.max_video_size cannot exceed 150 MiB");
}
if self.media.webp_quality > 100 {
anyhow::bail!("media.webp_quality must be 0..=100");
}
if self.media.vp9_crf > 63 {
anyhow::bail!("media.vp9_crf must be 0..=63");
}
if [
self.moderation.posts_per_minute,
self.moderation.replies_per_minute,
self.moderation.reposts_per_minute,
self.moderation.account_creations_per_ip_per_day,
self.moderation.failed_login_attempts_per_15m,
self.moderation.anonymous_posts_per_ip_per_hour,
]
.iter()
.any(|limit| *limit <= 0)
{
anyhow::bail!("moderation rate limits must be positive");
}
validate_relative_path(&self.tor.data_dir, "tor.data_dir")?;
validate_relative_path(&self.backup.backup_dir, "backup.backup_dir")?;
if self.tor.tor_only && !self.tor.enabled {
anyhow::bail!("tor.tor_only requires tor.enabled");
}
validate_onion_service_name(&self.tor.onion_service_name)?;
if !(5..=600).contains(&self.tor.bootstrap_timeout_secs) {
anyhow::bail!("tor.bootstrap_timeout_secs must be between 5 and 600");
}
if self.tor.max_concurrent_streams == 0 || self.tor.max_concurrent_streams > 65_535 {
anyhow::bail!("tor.max_concurrent_streams must be between 1 and 65535");
}
Ok(())
}
}
Expand Down Expand Up @@ -331,7 +282,7 @@ registration_enabled = {registration_enabled}
# Allow posting without accounts. Rate limits are keyed by client IP.
anonymous_mode_enabled = {anonymous_mode_enabled}

# Minimum account password length. Values below 10 are rejected.
# Minimum account password length. Recommended default is 10.
min_password_length = {min_password_length}

# Maximum username length in bytes. Usernames are also format-validated.
Expand Down Expand Up @@ -416,10 +367,10 @@ allowed_image_mime_types = {allowed_image_mime_types}
# SECURITY: Accepted video MIME types after content sniffing.
allowed_video_mime_types = {allowed_video_mime_types}

# WebP image quality, 0 to 100. Higher means larger files.
# WebP image quality. Recommended default is 82.
webp_quality = {webp_quality}

# VP9 quality, 0 to 63. Lower means higher quality and larger files.
# VP9 quality. Recommended default is 32; lower means higher quality and larger files.
vp9_crf = {vp9_crf}

# VP9 encoder deadline. Typical values are "good", "best", or "realtime".
Expand Down Expand Up @@ -582,9 +533,6 @@ pub fn validate_relative_path(value: &str, field: &str) -> anyhow::Result<()> {

fn validate_onion_service_name(value: &str) -> anyhow::Result<()> {
let name = value.trim();
if name.is_empty() || name.len() > 63 {
anyhow::bail!("tor.onion_service_name must be between 1 and 63 characters");
}
if name != value {
anyhow::bail!("tor.onion_service_name must not contain surrounding whitespace");
}
Expand Down Expand Up @@ -640,14 +588,25 @@ mod tests {
let mut settings = Settings::default();
settings.tor.onion_service_name = "bad/name".to_owned();
assert!(settings.validate().is_err());
}

#[test]
fn allows_operator_chosen_numeric_limits() {
let mut settings = Settings::default();
settings.accounts.min_password_length = 0;
settings.accounts.max_username_len = 0;
settings.posts.max_text_chars = 0;
settings.posts.max_media_per_post = 99;
settings.posts.max_images_per_post = 99;
settings.media.max_image_size = 0;
settings.media.max_video_size = 0;
settings.moderation.posts_per_minute = 0;
settings.tor.bootstrap_timeout_secs = 0;
assert!(settings.validate().is_err());

let mut settings = Settings::default();
settings.tor.max_concurrent_streams = 0;
assert!(settings.validate().is_err());

settings
.validate()
.expect("operator-chosen limits validate");
}

#[test]
Expand Down
50 changes: 49 additions & 1 deletion src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ pub async fn migrate(pool: &Db) -> anyhow::Result<()> {
tx.execute_batch(MIGRATION_7)?;
tx.execute("INSERT INTO schema_migrations (version) VALUES (7)", [])?;
}
if applied.unwrap_or(0) < 8 {
tx.execute_batch(MIGRATION_8)?;
tx.execute("INSERT INTO schema_migrations (version) VALUES (8)", [])?;
}
if applied.unwrap_or(0) < 9 {
tx.execute_batch(MIGRATION_9)?;
tx.execute("INSERT INTO schema_migrations (version) VALUES (9)", [])?;
}
tx.commit()?;
Ok(())
})
Expand Down Expand Up @@ -330,6 +338,20 @@ ALTER TABLE sessions ADD COLUMN delete_account_token_hash TEXT;
ALTER TABLE sessions ADD COLUMN delete_account_token_expires_at TEXT;
"#;

const MIGRATION_8: &str = r#"
ALTER TABLE posts ADD COLUMN quote_post_id INTEGER REFERENCES posts(id) ON DELETE SET NULL;

CREATE INDEX idx_posts_quote ON posts(quote_post_id, created_at DESC, id DESC);
CREATE UNIQUE INDEX idx_posts_quote_dedupe
ON posts(user_id, quote_post_id, text)
WHERE quote_post_id IS NOT NULL AND is_deleted = 0;
"#;

const MIGRATION_9: &str = r#"
CREATE INDEX IF NOT EXISTS idx_notifications_user_id_desc ON notifications(user_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_dedupe ON notifications(user_id, actor_user_id, post_id, kind);
"#;

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -371,7 +393,7 @@ mod tests {
.await
.expect("settings");

assert_eq!(versions, 7);
assert_eq!(versions, 9);
assert_eq!(foreign_keys, 1);
assert_eq!(journal_mode, "wal");
assert!(busy_timeout >= 5_000);
Expand Down Expand Up @@ -438,4 +460,30 @@ mod tests {

assert_eq!(location, "");
}

#[tokio::test]
async fn posts_support_quote_repost_references() {
let temp = tempfile::tempdir().expect("temp dir");
let pool = connect(&temp.path().join("test.sqlite3"))
.await
.expect("connect");
migrate(&pool).await.expect("migrate");

let quote_count: i64 = pool
.call(|conn| {
conn.execute(
"INSERT INTO posts (text, quote_post_id) VALUES ('quote', 1)",
[],
)?;
Ok(conn.query_row(
"SELECT COUNT(*) FROM posts WHERE quote_post_id = 1",
[],
|row| row.get(0),
)?)
})
.await
.expect("quote count");

assert_eq!(quote_count, 1);
}
}
Loading
Loading