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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# Changelog

## v0.1.6 - Pinned Profiles

### Profiles and Timelines
- Added profile timeline tabs for posts, replies, media, and liked posts.
- Added pinned profile posts with owner-only pin/unpin controls and automatic cleanup when a pinned post is deleted.
- Added a liked-posts visibility setting so users can make their likes private while still seeing their own likes.
- Preserved relationship and moderation visibility rules across profile tabs, pinned posts, private likes, blocked users, muted users, suspended accounts, and anonymous viewers.

### Posting and Composer
- Added server-enforced post editing during a configurable short edit window, with no-JS edit forms and edited markers on changed posts.
- Added live mention autocomplete to composer text areas backed by bounded `/mentions` suggestions.
- Greyed out composer submit buttons when text exceeds the configured character limit.

### Onboarding and Notifications
- Added first-run account onboarding for new users with profile setup, optional avatar upload, and follow suggestions.
- Grouped notifications by activity target, added grouped read/open handling, and improved empty-state rendering across feeds, lists, search, bookmarks, and notifications.

### Admin, Settings, and Operations
- Added `posts.post_edit_window_seconds` to settings and deep server settings, including validation from `0` to `300` seconds.
- Added an admin backups page for creating backup archives from the web UI.
- Refactored the backup restore workflow around staged extraction and clearer restore handling.

### Privacy, Security, and UI Fixes
- Hardened mention suggestions so wildcard characters cannot broaden searches and unavailable or hidden users stay excluded.
- Hid private likes from profile likes tabs, notifications, and visible like counts except for the liker.
- Rejected non-image onboarding/profile-picture uploads before creating profile avatar state.
- Fixed the no-JS banner so it appears only when JavaScript is disabled.
- Restored the compact Tor onion address pill with a disclosure and copy control.

### Migrations and Compatibility
- Added migrations for onboarding completion state and liked-post visibility.
- Existing active users are marked as already onboarded, deleted users remain incomplete, and existing users keep public likes by default.
- Bumped the crate version to `0.1.6` and refreshed `Cargo.lock`.

## v0.1.5 - Safety, Media, and Link Previews

### Posts and Composer
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.5"
version = "0.1.6"
edition = "2024"
license = "MIT"
description = "A single-binary self-hosted microblogging app"
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,15 @@ registration_captcha_enabled = false

`registration_captcha_enabled` adds a single-use CAPTCHA challenge to registration only. Login is unchanged.

### Post editing

```toml
[posts]
post_edit_window_seconds = 15
```

Users can edit their own post text only during this short server-enforced window. The default is 15 seconds; set it to `0` to disable post editing.

### Clearnet only (default)

```toml
Expand Down
54 changes: 52 additions & 2 deletions src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use rusqlite::{Row, params, params_from_iter};
use serde::Deserialize;

use crate::auth;
use crate::config::Settings;
use crate::config::{MAX_POST_EDIT_WINDOW_SECONDS, Settings};
use crate::db::SqlitePool;

const MIB: u64 = 1024 * 1024;
Expand All @@ -18,6 +18,7 @@ pub struct DeepSettingsForm {
pub intent: Option<String>,
pub site_name: String,
pub max_text_chars: String,
pub post_edit_window_seconds: String,
pub max_images_per_post: String,
pub max_videos_per_post: String,
pub max_media_per_post: String,
Expand Down Expand Up @@ -45,6 +46,7 @@ pub struct DeepSettingsForm {
pub enum DeepSettingsField {
SiteName,
MaxTextChars,
PostEditWindowSeconds,
MaxImagesPerPost,
MaxVideosPerPost,
MaxMediaPerPost,
Expand Down Expand Up @@ -79,6 +81,7 @@ pub enum DeepSettingsInputKind {
pub struct DeepSettingsValues {
pub site_name: String,
pub max_text_chars: usize,
pub post_edit_window_seconds: u64,
pub max_images_per_post: usize,
pub max_videos_per_post: usize,
pub max_media_per_post: usize,
Expand Down Expand Up @@ -110,9 +113,10 @@ pub struct DeepSettingsChange {
}

impl DeepSettingsField {
pub const ALL: [Self; 23] = [
pub const ALL: [Self; 24] = [
Self::SiteName,
Self::MaxTextChars,
Self::PostEditWindowSeconds,
Self::MaxImagesPerPost,
Self::MaxVideosPerPost,
Self::MaxMediaPerPost,
Expand Down Expand Up @@ -141,6 +145,7 @@ impl DeepSettingsField {
match self {
Self::SiteName => "Site",
Self::MaxTextChars
| Self::PostEditWindowSeconds
| Self::MaxImagesPerPost
| Self::MaxVideosPerPost
| Self::MaxMediaPerPost
Expand Down Expand Up @@ -168,6 +173,7 @@ impl DeepSettingsField {
match self {
Self::SiteName => "site",
Self::MaxTextChars
| Self::PostEditWindowSeconds
| Self::MaxImagesPerPost
| Self::MaxVideosPerPost
| Self::MaxMediaPerPost
Expand Down Expand Up @@ -195,6 +201,7 @@ impl DeepSettingsField {
match self {
Self::SiteName => "name",
Self::MaxTextChars => "max_text_chars",
Self::PostEditWindowSeconds => "post_edit_window_seconds",
Self::MaxImagesPerPost => "max_images_per_post",
Self::MaxVideosPerPost => "max_videos_per_post",
Self::MaxMediaPerPost => "max_media_per_post",
Expand Down Expand Up @@ -224,6 +231,7 @@ impl DeepSettingsField {
match self {
Self::SiteName => "site_name",
Self::MaxTextChars => "max_text_chars",
Self::PostEditWindowSeconds => "post_edit_window_seconds",
Self::MaxImagesPerPost => "max_images_per_post",
Self::MaxVideosPerPost => "max_videos_per_post",
Self::MaxMediaPerPost => "max_media_per_post",
Expand Down Expand Up @@ -253,6 +261,7 @@ impl DeepSettingsField {
match self {
Self::SiteName => "Site name",
Self::MaxTextChars => "Maximum post text length",
Self::PostEditWindowSeconds => "Post edit window",
Self::MaxImagesPerPost => "Maximum images per post",
Self::MaxVideosPerPost => "Maximum videos per post",
Self::MaxMediaPerPost => "Maximum total media per post",
Expand Down Expand Up @@ -285,6 +294,9 @@ impl DeepSettingsField {
| Self::MaxUsernameLen
| Self::MaxDisplayNameLen
| Self::MaxBioLen => Some("Characters."),
Self::PostEditWindowSeconds => {
Some("Seconds. Default is 15; valid range is 0 to 300. Set 0 to disable.")
}
Self::MaxImagesPerPost | Self::MaxVideosPerPost | Self::MaxMediaPerPost => {
Some("Attachments per post.")
}
Expand Down Expand Up @@ -325,6 +337,7 @@ impl DeepSettingsValues {
Self {
site_name: settings.site.name.clone(),
max_text_chars: settings.posts.max_text_chars,
post_edit_window_seconds: settings.posts.post_edit_window_seconds,
max_images_per_post: settings.posts.max_images_per_post,
max_videos_per_post: settings.posts.max_videos_per_post,
max_media_per_post: settings.posts.max_media_per_post,
Expand Down Expand Up @@ -354,6 +367,7 @@ impl DeepSettingsValues {
match field {
DeepSettingsField::SiteName => self.site_name.clone(),
DeepSettingsField::MaxTextChars => self.max_text_chars.to_string(),
DeepSettingsField::PostEditWindowSeconds => self.post_edit_window_seconds.to_string(),
DeepSettingsField::MaxImagesPerPost => self.max_images_per_post.to_string(),
DeepSettingsField::MaxVideosPerPost => self.max_videos_per_post.to_string(),
DeepSettingsField::MaxMediaPerPost => self.max_media_per_post.to_string(),
Expand Down Expand Up @@ -389,6 +403,7 @@ impl DeepSettingsValues {
| DeepSettingsField::MaxUsernameLen
| DeepSettingsField::MaxDisplayNameLen
| DeepSettingsField::MaxBioLen => format!("{value} characters"),
DeepSettingsField::PostEditWindowSeconds => format!("{value} seconds"),
DeepSettingsField::MaxImageSizeMb | DeepSettingsField::MaxVideoSizeMb => {
format!("{value} MB")
}
Expand All @@ -401,6 +416,7 @@ impl DeepSettingsValues {
let mut updated = current.clone();
updated.site.name.clone_from(&self.site_name);
updated.posts.max_text_chars = self.max_text_chars;
updated.posts.post_edit_window_seconds = self.post_edit_window_seconds;
updated.posts.max_images_per_post = self.max_images_per_post;
updated.posts.max_videos_per_post = self.max_videos_per_post;
updated.posts.max_media_per_post = self.max_media_per_post;
Expand Down Expand Up @@ -433,6 +449,7 @@ pub fn parse_deep_settings_form(
let values = DeepSettingsValues {
site_name: form.site_name.clone(),
max_text_chars: parse_usize(&form.max_text_chars, DeepSettingsField::MaxTextChars)?,
post_edit_window_seconds: parse_post_edit_window_seconds(&form.post_edit_window_seconds)?,
max_images_per_post: parse_usize(
&form.max_images_per_post,
DeepSettingsField::MaxImagesPerPost,
Expand Down Expand Up @@ -539,6 +556,23 @@ fn parse_usize(value: &str, field: DeepSettingsField) -> anyhow::Result<usize> {
.with_context(|| format!("{} must be a whole number", field.label()))
}

fn parse_post_edit_window_seconds(value: &str) -> anyhow::Result<u64> {
let trimmed = value.trim();
if trimmed.is_empty() {
anyhow::bail!("Post edit window is required");
}
if trimmed.starts_with('-') {
anyhow::bail!("Post edit window must not be negative");
}
let seconds = trimmed
.parse::<u64>()
.with_context(|| "Post edit window must be a whole number of seconds")?;
if seconds > MAX_POST_EDIT_WINDOW_SECONDS {
anyhow::bail!("Post edit window must be {MAX_POST_EDIT_WINDOW_SECONDS} seconds or less");
}
Ok(seconds)
}

fn parse_mb(value: &str, field: DeepSettingsField) -> anyhow::Result<u64> {
let trimmed = value.trim();
if trimmed.is_empty() {
Expand Down Expand Up @@ -667,6 +701,9 @@ fn toml_value(field: DeepSettingsField, settings: &Settings) -> String {
match field {
DeepSettingsField::SiteName => toml::Value::String(settings.site.name.clone()).to_string(),
DeepSettingsField::MaxTextChars => settings.posts.max_text_chars.to_string(),
DeepSettingsField::PostEditWindowSeconds => {
settings.posts.post_edit_window_seconds.to_string()
}
DeepSettingsField::MaxImagesPerPost => settings.posts.max_images_per_post.to_string(),
DeepSettingsField::MaxVideosPerPost => settings.posts.max_videos_per_post.to_string(),
DeepSettingsField::MaxMediaPerPost => settings.posts.max_media_per_post.to_string(),
Expand Down Expand Up @@ -1301,6 +1338,7 @@ mod tests {
intent: Some("preview".to_owned()),
site_name: values.site_name,
max_text_chars: values.max_text_chars.to_string(),
post_edit_window_seconds: values.post_edit_window_seconds.to_string(),
max_images_per_post: values.max_images_per_post.to_string(),
max_videos_per_post: values.max_videos_per_post.to_string(),
max_media_per_post: values.max_media_per_post.to_string(),
Expand Down Expand Up @@ -1547,6 +1585,18 @@ mod tests {
assert_eq!(updated.accounts.min_password_length, 5);
}

#[test]
fn deep_settings_accepts_zero_post_edit_window_as_disabled() {
let settings = Settings::default();
let mut form = form_from_settings(&settings);
form.post_edit_window_seconds = "0".to_owned();

let parsed = parse_deep_settings_form(&form, &settings).expect("valid form");
let updated = parsed.apply_to(&settings);

assert_eq!(updated.posts.post_edit_window_seconds, 0);
}

#[test]
fn deep_settings_writeback_preserves_unrelated_values_and_comments() {
let temp = tempdir().expect("temp dir");
Expand Down
57 changes: 57 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ use std::path::Path;

use serde::{Deserialize, Serialize};

pub const DEFAULT_POST_EDIT_WINDOW_SECONDS: u64 = 15;
pub const MAX_POST_EDIT_WINDOW_SECONDS: u64 = 300;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default)]
Expand Down Expand Up @@ -56,6 +59,8 @@ pub struct AccountSettings {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostSettings {
pub max_text_chars: usize,
#[serde(default = "default_post_edit_window_seconds")]
pub post_edit_window_seconds: u64,
pub max_images_per_post: usize,
pub max_videos_per_post: usize,
pub max_media_per_post: usize,
Expand Down Expand Up @@ -143,6 +148,7 @@ impl Default for Settings {
},
posts: PostSettings {
max_text_chars: 280,
post_edit_window_seconds: DEFAULT_POST_EDIT_WINDOW_SECONDS,
max_images_per_post: 4,
max_videos_per_post: 1,
max_media_per_post: 4,
Expand Down Expand Up @@ -227,10 +233,24 @@ impl Settings {
}
validate_onion_service_name(&self.tor.onion_service_name)?;
validate_display_onion_address(&self.tor.display_onion_address)?;
validate_post_edit_window(self.posts.post_edit_window_seconds)?;
Ok(())
}
}

const fn default_post_edit_window_seconds() -> u64 {
DEFAULT_POST_EDIT_WINDOW_SECONDS
}

fn validate_post_edit_window(seconds: u64) -> anyhow::Result<()> {
if seconds > MAX_POST_EDIT_WINDOW_SECONDS {
anyhow::bail!(
"posts.post_edit_window_seconds must be {MAX_POST_EDIT_WINDOW_SECONDS} seconds or less"
);
}
Ok(())
}

pub fn write_default_if_missing(path: &Path) -> anyhow::Result<()> {
if path.exists() {
return Ok(());
Expand Down Expand Up @@ -320,6 +340,9 @@ allow_profile_pictures = {allow_profile_pictures}
# Maximum post text length in characters.
max_text_chars = {max_text_chars}

# Seconds after creation that a user may edit their own post. Set to 0 to disable editing.
post_edit_window_seconds = {post_edit_window_seconds}

# Maximum image attachments allowed on one post.
max_images_per_post = {max_images_per_post}

Expand Down Expand Up @@ -478,6 +501,7 @@ backup_dir = {backup_dir}
allow_profile_banners = settings.accounts.allow_profile_banners,
allow_profile_pictures = settings.accounts.allow_profile_pictures,
max_text_chars = settings.posts.max_text_chars,
post_edit_window_seconds = settings.posts.post_edit_window_seconds,
max_images_per_post = settings.posts.max_images_per_post,
max_videos_per_post = settings.posts.max_videos_per_post,
max_media_per_post = settings.posts.max_media_per_post,
Expand Down Expand Up @@ -643,6 +667,39 @@ mod tests {
assert!(parsed.tor.display_onion_address.is_empty());
}

#[test]
fn missing_post_edit_window_defaults_to_fifteen_seconds() {
let raw = toml::to_string(&Settings::default())
.expect("settings toml")
.lines()
.filter(|line| !line.trim_start().starts_with("post_edit_window_seconds"))
.collect::<Vec<_>>()
.join("\n");
let parsed: Settings = toml::from_str(&raw).expect("legacy settings parse");

assert_eq!(
parsed.posts.post_edit_window_seconds,
DEFAULT_POST_EDIT_WINDOW_SECONDS
);
parsed.validate().expect("defaulted setting validates");
}

#[test]
fn accepts_zero_post_edit_window_as_disabled() {
let mut settings = Settings::default();
settings.posts.post_edit_window_seconds = 0;

settings.validate().expect("zero disables post editing");
}

#[test]
fn rejects_unsafe_post_edit_windows() {
let mut settings = Settings::default();

settings.posts.post_edit_window_seconds = MAX_POST_EDIT_WINDOW_SECONDS + 1;
assert!(settings.validate().is_err());
}

#[test]
fn tor_only_requires_tor_enabled() {
let mut settings = Settings::default();
Expand Down
Loading
Loading