diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..26bb334 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +## v0.1.3 + +### Search +- Search now has a cleaner page layout and clearer result cards. +- It handles posts, usernames, mentions, and hashtags more consistently. +- Bad or unusual search input no longer sends people to an error page. +- Search results can be liked or opened without leaving the search flow. + +### Profiles and Media +- Profile pictures now use thumbnails in compact places, which keeps feeds faster and cleaner. +- Full-size profile pages still show the original image. +- The database now tracks thumbnail paths for stored media. + +### Admin +- The admin media jobs view is shorter and easier to read. +- It now shows the most useful job status details and recent failures at a glance. +- Admins can upload a custom favicon and reset back to the built-in one. + +### Login and Registration +- Login now shows specific errors for missing accounts, wrong passwords, and unavailable accounts. +- Registration now shows a clear message when a username is already taken. + +### Threads +- Thread pages no longer show an extra generic thread header. +- The root post no longer links back to itself, which makes navigation less confusing. diff --git a/src/admin.rs b/src/admin.rs index 5b31a2c..b491760 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -4,6 +4,28 @@ use crate::auth; use crate::config::Settings; use crate::db::SqlitePool; +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MediaJobsReport { + pub total: i64, + pub pending: i64, + pub running: i64, + pub succeeded: i64, + pub failed: i64, + pub newest_pending_age_seconds: Option, + pub oldest_pending_age_seconds: Option, + pub recent_failures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MediaJobFailure { + pub id: i64, + pub media_id: Option, + pub media_path: Option, + pub job_kind: Option, + pub age_seconds: Option, + pub error_summary: String, +} + pub async fn create_admin( pool: &SqlitePool, settings: &Settings, @@ -152,3 +174,75 @@ pub async fn recent_media_jobs(pool: &SqlitePool) -> anyhow::Result anyhow::Result { + pool.call(|conn| { + let (total, pending, running, succeeded, failed, newest_pending_age, oldest_pending_age) = + conn.query_row( + r" + SELECT + COUNT(*), + COALESCE(SUM(CASE WHEN status IN ('pending', 'queued') THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status IN ('succeeded', 'success', 'converted') THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status IN ('failed', 'error', 'fallback') THEN 1 ELSE 0 END), 0), + MIN(CASE WHEN status IN ('pending', 'queued') THEN CAST(strftime('%s', 'now') - strftime('%s', created_at) AS INTEGER) END), + MAX(CASE WHEN status IN ('pending', 'queued') THEN CAST(strftime('%s', 'now') - strftime('%s', created_at) AS INTEGER) END) + FROM media_jobs + ", + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }, + )?; + + let mut stmt = conn.prepare( + r" + SELECT + j.id, + j.media_id, + COALESCE(NULLIF(m.public_path, ''), NULLIF(m.original_filename, ''), NULLIF(m.stored_path, '')), + NULLIF(m.media_kind, ''), + CAST(strftime('%s', 'now') - strftime('%s', COALESCE(j.finished_at, j.created_at)) AS INTEGER), + j.stderr_summary + FROM media_jobs j + LEFT JOIN media m ON m.id = j.media_id + WHERE j.status IN ('failed', 'error', 'fallback') + ORDER BY j.id DESC + LIMIT 5 + ", + )?; + let recent_failures = stmt + .query_map([], |row| { + Ok(MediaJobFailure { + id: row.get(0)?, + media_id: row.get(1)?, + media_path: row.get(2)?, + job_kind: row.get(3)?, + age_seconds: row.get(4)?, + error_summary: row.get(5)?, + }) + })? + .collect::, _>>()?; + + Ok(MediaJobsReport { + total, + pending, + running, + succeeded, + failed, + newest_pending_age_seconds: newest_pending_age, + oldest_pending_age_seconds: oldest_pending_age, + recent_failures, + }) + }) + .await +} diff --git a/src/auth.rs b/src/auth.rs index 42b68af..7136596 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -26,6 +26,15 @@ pub struct Session { pub csrf_token: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginFailure { + NoAccount, + InvalidPassword, + UnavailableAccount, +} + +pub const USERNAME_TAKEN_MESSAGE: &str = "username is already taken"; + pub async fn register_user( pool: &SqlitePool, settings: &Settings, @@ -47,7 +56,7 @@ pub async fn register_user( .optional()? .is_some(); if existing { - anyhow::bail!("username is already taken"); + anyhow::bail!(USERNAME_TAKEN_MESSAGE); } conn.execute( "INSERT INTO users (username, normalized_username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, ?)", @@ -62,7 +71,7 @@ pub async fn login( pool: &SqlitePool, username: &str, password: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { let normalized = username.trim().to_ascii_lowercase(); let row = pool .call(move |conn| { @@ -83,15 +92,15 @@ pub async fn login( }) .await?; let Some(row) = row else { - return Ok(None); + return Ok(Err(LoginFailure::NoAccount)); }; if row.2 != 0 || row.3 != 0 { - return Ok(None); + return Ok(Err(LoginFailure::UnavailableAccount)); } if !verify_password(password, &row.1)? { - return Ok(None); + return Ok(Err(LoginFailure::InvalidPassword)); } - create_session(pool, row.0).await.map(Some) + create_session(pool, row.0).await.map(Ok) } pub async fn create_session(pool: &SqlitePool, user_id: i64) -> anyhow::Result { diff --git a/src/db.rs b/src/db.rs index 536eb5b..870a40b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -92,6 +92,10 @@ pub async fn migrate(pool: &Db) -> anyhow::Result<()> { tx.execute_batch(MIGRATION_2)?; tx.execute("INSERT INTO schema_migrations (version) VALUES (2)", [])?; } + if applied.unwrap_or(0) < 3 { + tx.execute_batch(MIGRATION_3)?; + tx.execute("INSERT INTO schema_migrations (version) VALUES (3)", [])?; + } tx.commit()?; Ok(()) }) @@ -279,6 +283,11 @@ const MIGRATION_2: &str = r#" ALTER TABLE sessions ADD COLUMN previous_csrf_token_hash TEXT; "#; +const MIGRATION_3: &str = r#" +ALTER TABLE media ADD COLUMN thumbnail_path TEXT; +ALTER TABLE media ADD COLUMN thumbnail_public_path TEXT; +"#; + #[cfg(test)] mod tests { use super::*; @@ -320,7 +329,7 @@ mod tests { .await .expect("settings"); - assert_eq!(versions, 2); + assert_eq!(versions, 3); assert_eq!(foreign_keys, 1); assert_eq!(journal_mode, "wal"); assert!(busy_timeout >= 5_000); diff --git a/src/favicon.rs b/src/favicon.rs new file mode 100644 index 0000000..230c023 --- /dev/null +++ b/src/favicon.rs @@ -0,0 +1,307 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Context as _; +use axum::body::Body; +use axum::extract::multipart::Field; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse as _, Response}; +use tokio::io::AsyncWriteExt as _; +use uuid::Uuid; + +use crate::media; +use crate::runtime::RuntimePaths; + +const FAVICON_BASENAME: &str = "favicon"; +const FAVICON_EXTENSIONS: [&str; 3] = ["ico", "png", "svg"]; +const DEFAULT_FAVICON: &[u8] = &[ + 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x20, 0x00, 0x45, 0x00, + 0x00, 0x00, 0x16, 0x00, 0x00, 0x00, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, + 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, + 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x04, 0x00, 0x01, 0xfe, 0xa7, 0x69, 0x9d, 0x16, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +// Favicons are small browser chrome assets. 256 KiB leaves room for ICO files +// with several embedded sizes while rejecting accidental large media uploads. +const MAX_FAVICON_BYTES: u64 = 256 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FaviconKind { + Ico, + Png, + Svg, +} + +impl FaviconKind { + const fn extension(self) -> &'static str { + match self { + Self::Ico => "ico", + Self::Png => "png", + Self::Svg => "svg", + } + } + + pub const fn content_type(self) -> &'static str { + match self { + Self::Ico => "image/x-icon", + Self::Png => "image/png", + Self::Svg => "image/svg+xml; charset=utf-8", + } + } +} + +#[derive(Debug, Clone)] +pub struct FaviconAsset { + path: PathBuf, + kind: FaviconKind, + custom: bool, +} + +impl FaviconAsset { + pub fn state_label(&self) -> &'static str { + if self.custom { + "Custom favicon configured" + } else { + "Using built-in default favicon" + } + } + + pub const fn content_type(&self) -> &'static str { + self.kind.content_type() + } + + pub const fn is_custom(&self) -> bool { + self.custom + } +} + +pub fn current(paths: &RuntimePaths) -> FaviconAsset { + for ext in FAVICON_EXTENSIONS { + let path = favicon_path(paths, ext); + if path.is_file() { + return FaviconAsset { + path, + kind: kind_from_extension(ext).unwrap_or(FaviconKind::Ico), + custom: true, + }; + } + } + FaviconAsset { + path: PathBuf::new(), + kind: FaviconKind::Ico, + custom: false, + } +} + +pub async fn response(paths: &RuntimePaths) -> Response { + let asset = current(paths); + let (status, content_type, bytes) = if asset.custom { + match tokio::fs::read(&asset.path).await { + Ok(bytes) => (StatusCode::OK, asset.content_type(), bytes), + Err(error) => { + tracing::warn!(error = %error, "failed to read custom favicon; serving default"); + ( + StatusCode::OK, + FaviconKind::Ico.content_type(), + DEFAULT_FAVICON.to_vec(), + ) + } + } + } else { + ( + StatusCode::OK, + FaviconKind::Ico.content_type(), + DEFAULT_FAVICON.to_vec(), + ) + }; + let mut response = (status, Body::from(bytes)).into_response(); + let headers = response.headers_mut(); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=3600"), + ); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + response +} + +pub async fn save_upload(paths: &RuntimePaths, mut field: Field<'_>) -> anyhow::Result<()> { + let original_filename = field.file_name().unwrap_or("favicon").to_owned(); + media::reject_path_tricks(&original_filename)?; + let kind = extension_kind(&original_filename)?; + let staging = paths + .tmp_uploads + .join(format!("favicon-{}.upload", Uuid::new_v4().simple())); + let mut file = tokio::fs::File::create(&staging).await?; + let mut bytes = 0_u64; + while let Some(chunk) = field.chunk().await? { + bytes = bytes.saturating_add(u64::try_from(chunk.len())?); + if bytes > MAX_FAVICON_BYTES { + let _ = tokio::fs::remove_file(&staging).await; + anyhow::bail!("favicon exceeds maximum size of 256 KiB"); + } + file.write_all(&chunk).await?; + } + file.flush().await?; + file.sync_all().await?; + drop(file); + + let data = tokio::fs::read(&staging).await?; + if let Err(error) = validate_bytes(kind, &data) { + let _ = tokio::fs::remove_file(&staging).await; + return Err(error); + } + + tokio::fs::create_dir_all(&paths.assets_dir) + .await + .with_context(|| { + format!( + "failed to create asset directory {}", + paths.assets_dir.display() + ) + })?; + let final_path = favicon_path(paths, kind.extension()); + if let Err(error) = tokio::fs::rename(&staging, &final_path).await { + let _ = tokio::fs::remove_file(&staging).await; + return Err(error.into()); + } + remove_stale_favicons(paths, kind.extension()).await; + Ok(()) +} + +pub async fn reset(paths: &RuntimePaths) -> anyhow::Result<()> { + remove_stale_favicons(paths, "").await; + Ok(()) +} + +async fn remove_stale_favicons(paths: &RuntimePaths, keep_extension: &str) { + for ext in FAVICON_EXTENSIONS { + if ext == keep_extension { + continue; + } + let path = favicon_path(paths, ext); + if let Err(error) = tokio::fs::remove_file(&path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::debug!(path = %path.display(), error = %error, "failed to remove stale favicon"); + } + } +} + +fn favicon_path(paths: &RuntimePaths, ext: &str) -> PathBuf { + paths.assets_dir.join(format!("{FAVICON_BASENAME}.{ext}")) +} + +fn extension_kind(filename: &str) -> anyhow::Result { + let ext = Path::new(filename) + .extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase) + .ok_or_else(|| anyhow::anyhow!("favicon must be a .ico, .png, or .svg file"))?; + kind_from_extension(&ext) + .ok_or_else(|| anyhow::anyhow!("unsupported favicon type; upload .ico, .png, or .svg")) +} + +fn kind_from_extension(ext: &str) -> Option { + match ext { + "ico" => Some(FaviconKind::Ico), + "png" => Some(FaviconKind::Png), + "svg" => Some(FaviconKind::Svg), + _ => None, + } +} + +fn validate_bytes(kind: FaviconKind, data: &[u8]) -> anyhow::Result<()> { + if data.is_empty() { + anyhow::bail!("favicon file is empty"); + } + match kind { + FaviconKind::Png => validate_png(data), + FaviconKind::Ico => validate_ico(data), + FaviconKind::Svg => validate_svg(data), + } +} + +fn validate_png(data: &[u8]) -> anyhow::Result<()> { + const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + if data.starts_with(PNG_SIGNATURE) { + Ok(()) + } else { + anyhow::bail!("PNG favicon has an invalid file signature") + } +} + +fn validate_ico(data: &[u8]) -> anyhow::Result<()> { + if data.len() < 6 { + anyhow::bail!("ICO favicon is too small"); + } + let reserved = u16::from_le_bytes([data[0], data[1]]); + let image_type = u16::from_le_bytes([data[2], data[3]]); + let count = u16::from_le_bytes([data[4], data[5]]); + if reserved == 0 && matches!(image_type, 1 | 2) && count > 0 { + Ok(()) + } else { + anyhow::bail!("ICO favicon has an invalid header") + } +} + +fn validate_svg(data: &[u8]) -> anyhow::Result<()> { + let text = std::str::from_utf8(data).context("SVG favicon must be UTF-8 text")?; + let lower = text.to_ascii_lowercase(); + let trimmed = lower.trim_start(); + if !trimmed.starts_with(", +) -> anyhow::Result { + let media_id = save_upload(pool, settings, paths, ffmpeg, Some(owner_user_id), field).await?; + generate_profile_picture_thumbnail(pool, settings, paths, ffmpeg, media_id).await?; + Ok(media_id) +} + +async fn generate_profile_picture_thumbnail( + pool: &SqlitePool, + settings: &Settings, + paths: &RuntimePaths, + ffmpeg: &FfmpegStatus, + media_id: i64, +) -> anyhow::Result<()> { + let media: Option<(String, String)> = pool + .call(move |conn| { + conn.query_row( + "SELECT stored_path, media_kind FROM media WHERE id = ?", + [media_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(Into::into) + }) + .await?; + let Some((stored_path, media_kind)) = media else { + return Ok(()); + }; + if media_kind != MediaKind::Image.as_str() { + return Ok(()); + } + if !settings.media.convert_images_to_webp || !ffmpeg.available || !ffmpeg.supports_webp { + tracing::warn!( + media_id, + ffmpeg = %ffmpeg.summary(), + "profile picture thumbnail unavailable; compact avatars will use original image" + ); + return Ok(()); + } + let output = paths + .uploads_thumbs + .join(format!("{media_id}-profile.webp")); + match crate::ffmpeg::convert_image_to_webp_thumbnail( + &settings.media, + Path::new(&stored_path), + &output, + PROFILE_PICTURE_THUMB_SIZE, + ) + .await + { + Ok(_) => { + let thumbnail_path = output.to_string_lossy().to_string(); + let thumbnail_public_path = format!("/uploads/thumbs/{media_id}-profile.webp"); + pool.call(move |conn| { + conn.execute( + "UPDATE media SET thumbnail_path = ?, thumbnail_public_path = ? WHERE id = ?", + params![thumbnail_path, thumbnail_public_path, media_id], + )?; + Ok(()) + }) + .await?; + } + Err(error) => { + tracing::warn!( + media_id, + error = %error, + "profile picture thumbnail generation failed; compact avatars will use original image" + ); + } + } + Ok(()) +} + async fn remove_staged_upload(path: &Path) { if let Err(error) = tokio::fs::remove_file(path).await { tracing::debug!(error = %error, "failed to remove rejected staged upload"); @@ -202,21 +283,24 @@ pub async fn clear_profile_media( } pub async fn delete_media(pool: &SqlitePool, media_id: i64) -> anyhow::Result<()> { - let stored_path: Option = pool + let media_paths: Option<(String, Option)> = pool .call(move |conn| { conn.query_row( - "SELECT stored_path FROM media WHERE id = ?", + "SELECT stored_path, thumbnail_path FROM media WHERE id = ?", [media_id], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .map_err(Into::into) }) .await?; - if let Some(stored_path) = stored_path { + if let Some((stored_path, thumbnail_path)) = media_paths { if !stored_path.is_empty() { let _ = tokio::fs::remove_file(stored_path).await; } + if let Some(thumbnail_path) = thumbnail_path.filter(|path| !path.is_empty()) { + let _ = tokio::fs::remove_file(thumbnail_path).await; + } pool.call(move |conn| { conn.execute("DELETE FROM media WHERE id = ?", [media_id])?; Ok(()) @@ -384,6 +468,8 @@ fn safe_extension(mime: &str, kind: MediaKind) -> &'static str { #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; #[test] fn filename_safety() { @@ -415,11 +501,16 @@ mod tests { .await .expect("user"); let first = temp.path().join("first.webp"); + let first_thumb = temp.path().join("first-thumb.webp"); let second = temp.path().join("second.webp"); tokio::fs::write(&first, b"first").await.expect("first"); + tokio::fs::write(&first_thumb, b"first-thumb") + .await + .expect("first thumb"); tokio::fs::write(&second, b"second").await.expect("second"); let first_id = insert_test_media(&pool, user_id, &first, "image").await; let second_id = insert_test_media(&pool, user_id, &second, "image").await; + set_test_thumbnail(&pool, first_id, &first_thumb).await; set_profile_media(&pool, user_id, ProfileMediaSlot::Picture, first_id) .await @@ -429,6 +520,7 @@ mod tests { .expect("set second"); assert!(!first.exists()); + assert!(!first_thumb.exists()); assert!(second.exists()); } @@ -456,6 +548,99 @@ mod tests { assert!(!video.exists()); } + #[cfg(unix)] + #[tokio::test] + async fn profile_picture_thumbnail_generation_creates_webp_metadata() { + let temp = tempfile::tempdir().expect("temp dir"); + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + paths.ensure().expect("paths"); + let pool = crate::db::connect(&paths.database_path) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let mut settings = Settings::default(); + settings.media.ffmpeg_path = fake_ffmpeg(&temp).display().to_string(); + let ffmpeg = FfmpegStatus { + available: true, + version: "fake ffmpeg".to_owned(), + supports_webp: true, + supports_vp9: false, + error: None, + }; + let user_id = + crate::auth::register_user(&pool, &settings, "alice", "very secure password", false) + .await + .expect("user"); + let source = paths.uploads_images.join("source.webp"); + tokio::fs::write(&source, b"source").await.expect("source"); + let media_id = insert_test_media(&pool, user_id, &source, "image").await; + + generate_profile_picture_thumbnail(&pool, &settings, &paths, &ffmpeg, media_id) + .await + .expect("thumbnail"); + + let (thumbnail_path, thumbnail_public_path): (Option, Option) = pool + .call(move |conn| { + conn.query_row( + "SELECT thumbnail_path, thumbnail_public_path FROM media WHERE id = ?", + [media_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(Into::into) + }) + .await + .expect("thumbnail row"); + assert_eq!( + thumbnail_public_path.as_deref(), + Some("/uploads/thumbs/1-profile.webp") + ); + let thumbnail_path = thumbnail_path.expect("thumbnail path"); + assert!(Path::new(&thumbnail_path).exists()); + } + + #[tokio::test] + async fn profile_picture_thumbnail_generation_is_optional() { + let temp = tempfile::tempdir().expect("temp dir"); + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + paths.ensure().expect("paths"); + let pool = crate::db::connect(&paths.database_path) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let ffmpeg = FfmpegStatus { + available: false, + version: String::new(), + supports_webp: false, + supports_vp9: false, + error: Some("ffmpeg command not found".to_owned()), + }; + let user_id = + crate::auth::register_user(&pool, &settings, "alice", "very secure password", false) + .await + .expect("user"); + let source = paths.uploads_originals.join("source.png"); + tokio::fs::write(&source, b"source").await.expect("source"); + let media_id = insert_test_media(&pool, user_id, &source, "image").await; + + generate_profile_picture_thumbnail(&pool, &settings, &paths, &ffmpeg, media_id) + .await + .expect("thumbnail fallback"); + + let thumbnail_public_path: Option = pool + .call(move |conn| { + conn.query_row( + "SELECT thumbnail_public_path FROM media WHERE id = ?", + [media_id], + |row| row.get(0), + ) + .map_err(Into::into) + }) + .await + .expect("thumbnail row"); + assert!(thumbnail_public_path.is_none()); + } + async fn insert_test_media( pool: &SqlitePool, user_id: i64, @@ -474,4 +659,33 @@ mod tests { .await .expect("media") } + + async fn set_test_thumbnail(pool: &SqlitePool, media_id: i64, thumbnail_path: &Path) { + let thumbnail_path = thumbnail_path.to_string_lossy().to_string(); + pool.call(move |conn| { + conn.execute( + "UPDATE media SET thumbnail_path = ?, thumbnail_public_path = '/uploads/thumbs/x.webp' WHERE id = ?", + params![thumbnail_path, media_id], + )?; + Ok(()) + }) + .await + .expect("thumbnail"); + } + + #[cfg(unix)] + fn fake_ffmpeg(temp: &tempfile::TempDir) -> PathBuf { + let path = temp.path().join("fake-ffmpeg"); + std::fs::write( + &path, + "#!/bin/sh\nlast=\"\"\nfor arg do\n last=\"$arg\"\ndone\nprintf webp > \"$last\"\n", + ) + .expect("fake ffmpeg"); + let mut permissions = std::fs::metadata(&path) + .expect("fake ffmpeg metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("fake ffmpeg permissions"); + path + } } diff --git a/src/render.rs b/src/render.rs index 652b120..f5b55c7 100644 --- a/src/render.rs +++ b/src/render.rs @@ -2,12 +2,25 @@ use crate::auth::CurrentUser; use crate::social::{AccountView, MediaView, PostView, TimelineEventKind}; use axum::http::StatusCode; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct LayoutContext { pub anonymous_mode_enabled: bool, pub tor_onion_address: Option, pub follower_count: Option, pub following_count: Option, + pub favicon_content_type: &'static str, +} + +impl Default for LayoutContext { + fn default() -> Self { + Self { + anonymous_mode_enabled: false, + tor_onion_address: None, + follower_count: None, + following_count: None, + favicon_content_type: "image/x-icon", + } + } } #[derive(Debug, Clone, Copy)] @@ -101,6 +114,7 @@ pub fn layout_with_context( + {} - {} @@ -111,6 +125,7 @@ pub fn layout_with_context(
{} alpha{}
"#, + html_escape::encode_double_quoted_attribute(context.favicon_content_type), html_escape::encode_text(title), html_escape::encode_text(site_name), CSS, @@ -207,6 +222,18 @@ document.addEventListener("keydown", (event) => { window.location.assign(event.target.getAttribute("data-card-href")); }); +document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + const backLink = event.target.closest("[data-history-back]"); + if (!backLink || window.history.length <= 1) { + return; + } + event.preventDefault(); + window.history.back(); +}); + document.addEventListener("click", (event) => { const button = event.target.closest("[data-password-toggle]"); if (!button) { @@ -472,6 +499,112 @@ pub fn accounts(accounts: &[AccountView], csrf: &str) -> String { format!(r#""#) } +pub fn search_page( + site_name: &str, + query: &str, + users: &[AccountView], + posts: &[PostView], + user: Option<&CurrentUser>, + csrf: Option<&str>, +) -> String { + let form = search_form(site_name, query); + let state = if query.is_empty() { + empty_state( + "Find posts and people", + "Search for posts, usernames, mentions, or hashtags.", + ) + } else if users.is_empty() && posts.is_empty() { + empty_state("No results found", &format!(r#"No matches for "{query}"."#)) + } else { + search_results(query, users, posts, user, csrf) + }; + format!("{form}{state}") +} + +fn search_form(site_name: &str, query: &str) -> String { + format!( + r#"

Search

"#, + html_escape::encode_text(site_name), + html_escape::encode_double_quoted_attribute(query) + ) +} + +fn search_results( + query: &str, + users: &[AccountView], + posts: &[PostView], + user: Option<&CurrentUser>, + csrf: Option<&str>, +) -> String { + let total = users.len() + posts.len(); + let plural = if total == 1 { "result" } else { "results" }; + let users_section = search_user_results(users); + let posts_heading = if users.is_empty() || posts.is_empty() { + String::new() + } else { + r#"

Posts

"#.to_owned() + }; + let posts_section = if posts.is_empty() { + String::new() + } else { + format!( + "{posts_heading}{}", + posts_with_options(posts, user, csrf, PostRenderOptions::timeline()) + ) + }; + format!( + r#"

{} {} for "{}"

{}{}
"#, + total, + plural, + html_escape::encode_text(query), + users_section, + posts_section + ) +} + +fn search_user_results(users: &[AccountView]) -> String { + if users.is_empty() { + return String::new(); + } + let rows = users + .iter() + .map(|account| { + let avatar = account.profile_picture_path.as_ref().map_or_else( + || { + let initial = account.display_name.chars().next().unwrap_or('R'); + format!( + r#""#, + html_escape::encode_text(&initial.to_string()) + ) + }, + |path| { + format!( + r#""#, + html_escape::encode_double_quoted_attribute(path) + ) + }, + ); + let bio = if account.bio.trim().is_empty() { + String::new() + } else { + format!("

{}

", html_escape::encode_text(&account.bio)) + }; + format!( + r#""#, + avatar, + html_escape::encode_double_quoted_attribute(&account.username), + html_escape::encode_text(&account.display_name), + html_escape::encode_text(&account.username), + bio + ) + }) + .collect::>() + .join(""); + format!( + r#"

People

"# + ) +} + pub fn follow_form(user_id: i64, csrf: &str, following: bool) -> String { let (action, label, aria_label) = if following { ( @@ -504,7 +637,27 @@ pub fn posts(posts: &[PostView], user: Option<&CurrentUser>, csrf: Option<&str>) } pub fn thread_posts(posts: &[PostView], user: Option<&CurrentUser>, csrf: Option<&str>) -> String { - posts_with_options(posts, user, csrf, PostRenderOptions::thread()) + if posts.is_empty() { + return empty_state( + "No posts yet", + "The timeline will fill in once people start posting.", + ); + } + format!( + r#"
{}
"#, + posts + .iter() + .enumerate() + .map(|(index, post)| { + let mut options = PostRenderOptions::thread(); + if index == 0 { + options.clickable_card = false; + } + post_card_with_options(post, user, csrf, options) + }) + .collect::>() + .join("") + ) } fn posts_with_options( @@ -824,6 +977,11 @@ pub fn page_header(title: &str, subtitle: &str) -> String { ) } +pub fn thread_back_control() -> String { + r##""## + .to_owned() +} + pub fn notice(kind: &str, message: &str) -> String { format!( r#"

{}

"#, @@ -865,15 +1023,17 @@ nav a:hover,.button-link:hover{background:#eef3f0;text-decoration:none}nav form, main{padding:1.25rem}.content-shell{max-width:1120px;margin:0 auto;display:grid;grid-template-columns:minmax(0,720px) 280px;gap:1.25rem;align-items:start}.content-column{min-width:0}.side-panel{position:sticky;top:5rem;background:#fff;border:1px solid #dfe4dc;border-radius:8px;padding:1rem;color:#59625a}.side-panel h2{margin:.1rem 0 .6rem;font-size:1rem;color:#202124}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:#202124}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:#202124}.dashboard-account:hover{text-decoration:none}.quick-links{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:1120px;margin:0 auto;padding:1rem;color:#687068;font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} .page-header,.post,.composer,.panel,.empty-state,.notice{background:#fff;border:1px solid #dfe4dc;border-radius:8px;margin:0 0 .7rem;padding:.85rem;box-shadow:0 1px 2px rgba(20,35,30,.04)} .page-header h1,.section-heading h1,.panel h1{margin:0;font-size:1.45rem;line-height:1.2}.panel h1+table,.panel h1+form,.panel h1+p,.panel h1+dl{margin-top:.85rem}.page-header p,.muted,.empty-state p{color:#667064;margin:.35rem 0 0}.section-heading{display:flex;justify-content:space-between;gap:1rem;align-items:baseline;margin-bottom:.8rem} -label{display:block;font-weight:700;margin:.85rem 0 .35rem}input,textarea,button{font:inherit}input[type=text],input[type=password],input[type=url],input:not([type]),textarea{width:100%;padding:.72rem .8rem;border:1px solid #b9c2b8;border-radius:7px;background:#fff}textarea{resize:vertical;min-height:7rem} +label{display:block;font-weight:700;margin:.85rem 0 .35rem}input,textarea,button{font:inherit}input[type=text],input[type=search],input[type=password],input[type=url],input:not([type]),textarea{width:100%;padding:.72rem .8rem;border:1px solid #b9c2b8;border-radius:7px;background:#fff}textarea{resize:vertical;min-height:7rem} input[type=text].password-visible{padding-right:.8rem}.password-control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.45rem;align-items:center}.password-control input{min-width:0}.password-toggle{background:#fff;color:#24445f;border-color:#cdd7d0;min-width:4.5rem}.auth-submit{margin-top:1.15rem}.auth-form{margin-top:.35rem} +.search-panel h1{margin-bottom:.75rem}.search-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center}.search-form input{min-width:0}.search-results{display:grid;gap:.65rem}.section-title{margin:.2rem 0 .65rem;font-size:1.05rem;color:#202124}.search-users{margin-bottom:0}.search-users .section-title{margin-top:0}.search-account{grid-template-columns:auto minmax(0,1fr)} input:focus,textarea:focus,button:focus-visible,a:focus-visible{outline:3px solid #93c5fd;outline-offset:2px}button,.primary{border:1px solid #163b2f;background:#163b2f;color:#fff;border-radius:7px;padding:.5rem .8rem;cursor:pointer;font-weight:700}button:hover,.primary:hover{background:#235544;text-decoration:none} .composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem}.file-control{display:inline-flex;align-items:center;gap:.6rem;margin:0;color:#24445f;font-weight:700}.file-control input{max-width:15rem} +.thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:#24445f}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:#eef3f0;text-decoration:none} .timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.post[data-card-href]{cursor:pointer}.post[data-card-href]:hover{border-color:#c7d2ca}.post[data-card-href]:focus-visible{outline:3px solid #93c5fd;outline-offset:2px}.reply-post{margin-left:1.1rem;border-left:4px solid #c8d8d0;background:#fbfcfa}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid #c8d8d0}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid #d0d8d2;background:#eef3f0;flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:#526159;font-weight:800}.author-name{font-weight:800;color:#202124}.username,.post-time,.counts{color:#687068;font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid #d9ded6;margin-top:.5rem;background:#f6f7f4}.post img.post-avatar{display:block;margin:0;border-radius:999px} .counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.actions{display:flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid #cdd7d0;border-radius:7px;background:#fff;color:#24445f;padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:#eef3f0;color:#163b2f;text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:#9aa39d;background:#f4f5f2;border-color:#dfe4dc;cursor:not-allowed}.icon-button.disabled:hover,.icon-button:disabled:hover{background:#f4f5f2;color:#9aa39d}.follow-button{min-width:6.6rem}.follow-button.active{background:#eef3f0;color:#163b2f;border-color:#9fb9ad}.profile-actions{margin-top:0}.profile-secondary button{background:#fff;color:#8a3d2d;border-color:#e0c4bb;padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:#fff8f5;color:#6f2f22}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:#4b655d;font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:#667064}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:#e6b8a8;background:#fff8f5}.notice.success{border-color:#add7b4;background:#f4fbf5}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:#6d766e;font-size:.78rem} -.profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid #d9ded6;background:#dfe9e1}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:8px;border:1px solid #d9ded6;background:#eef3f0;flex:0 0 auto}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:#fff;border:1px solid #dfe4dc;border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:#59625a;overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid #e3e7e0;text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} +.profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid #d9ded6;background:#dfe9e1}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:8px;border:1px solid #d9ded6;background:#eef3f0;flex:0 0 auto}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid #d9ded6;border-radius:6px;background:#fff}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:#fff;border:1px solid #dfe4dc;border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:#59625a;overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid #e3e7e0;text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} @media (max-width:900px){.content-shell{grid-template-columns:1fr}.side-panel{position:static;display:none}} -@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.site-header{position:static}nav{justify-content:flex-start}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.file-control{display:block}.file-control input{display:block;max-width:100%;margin-top:.35rem}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1{font-size:1.25rem}} +@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.site-header{position:static}nav{justify-content:flex-start}.search-form{grid-template-columns:1fr}.search-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.file-control{display:block}.file-control input{display:block;max-width:100%;margin-top:.35rem}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1{font-size:1.25rem}} "#; #[cfg(test)] @@ -991,6 +1151,90 @@ mod tests { assert!(body.contains(r#"class="post-time" href="/posts/42">2026-05-18 10:30"#)); } + #[test] + fn thread_posts_do_not_make_root_card_self_navigating() { + let root = test_post(); + let mut reply = test_post(); + reply.id = 43; + reply.parent_post_id = Some(42); + reply.event_id = "p:43".to_owned(); + + let body = thread_posts(&[root, reply], None, None); + + let root_card = body + .split_once(r#"id="post-42""#) + .and_then(|(_, rest)| rest.split_once(r#"id="post-43""#)) + .map(|(card, _)| card) + .expect("root card"); + assert!(!root_card.contains(r#"data-card-href="/posts/42""#)); + assert!(!root_card.contains(r#"tabindex="0""#)); + assert!(body.contains(r#"data-card-href="/posts/43""#)); + } + + #[test] + fn thread_back_control_has_history_hook_and_home_fallback() { + let body = thread_back_control(); + + assert!(body.contains(r#"aria-label="Back""#)); + assert!(body.contains(r#"href="/home""#)); + assert!(body.contains("data-history-back")); + } + + #[test] + fn compact_post_avatar_uses_profile_picture_path() { + let mut post = test_post(); + post.profile_picture_path = Some("/uploads/thumbs/ada-profile.webp".to_owned()); + + let body = post_card(&post, None, None); + + assert!( + body.contains(r#"A"#) + ); + } + + #[test] + fn search_page_preserves_and_escapes_query() { + let body = search_page("RustPost", r#" "query""#, &[], &[], None, None); + + assert!(body.contains(r#"value="<rust> "query"""#)); + assert!(body.contains("No results found")); + assert!(body.contains("No matches for")); + assert!(body.contains("<rust>")); + assert!(body.contains(""query"")); + assert!(!body.contains(r#" "query""#)); + } + + #[test] + fn search_page_uses_post_cards_for_results() { + let post = test_post(); + let account = AccountView { + id: 1, + username: "ada".to_owned(), + display_name: "Ada".to_owned(), + bio: "Computing notes".to_owned(), + profile_picture_path: None, + viewer_following: false, + }; + + let body = search_page("RustPost", "ada", &[account], &[post], None, None); + + assert!(body.contains(r#"2 results for "ada""#)); + assert!(body.contains(r#"id="search-users-title">People"#)); + assert!(body.contains(r#"class="account-row search-account""#)); + assert!(body.contains(r#"data-card-href="/posts/42""#)); + } + #[test] fn card_actions_stay_inside_compact_action_row() { let user = CurrentUser { diff --git a/src/runtime.rs b/src/runtime.rs index 0520353..227d19c 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -15,6 +15,7 @@ pub struct RuntimePaths { pub uploads_images: PathBuf, pub uploads_videos: PathBuf, pub uploads_thumbs: PathBuf, + pub assets_dir: PathBuf, pub tmp_dir: PathBuf, pub tmp_uploads: PathBuf, pub backups_dir: PathBuf, @@ -36,6 +37,7 @@ impl RuntimePaths { pub fn from_data_dir(data_dir: PathBuf) -> Self { let db_dir = data_dir.join("db"); let uploads_dir = data_dir.join("uploads"); + let assets_dir = data_dir.join("assets"); let tmp_dir = data_dir.join("tmp"); Self { settings_path: data_dir.join("settings.toml"), @@ -45,6 +47,7 @@ impl RuntimePaths { uploads_images: uploads_dir.join("images"), uploads_videos: uploads_dir.join("videos"), uploads_thumbs: uploads_dir.join("thumbs"), + assets_dir, tmp_uploads: tmp_dir.join("uploads"), tmp_dir, backups_dir: data_dir.join("backups"), @@ -70,6 +73,7 @@ impl RuntimePaths { &self.uploads_images, &self.uploads_videos, &self.uploads_thumbs, + &self.assets_dir, &self.tmp_dir, &self.tmp_uploads, &self.backups_dir, @@ -236,6 +240,7 @@ mod tests { paths.staged_upload_path("abc"), PathBuf::from("/tmp/rustpost-data/tmp/uploads/abc.upload") ); + assert_eq!(paths.assets_dir, PathBuf::from("/tmp/rustpost-data/assets")); } #[test] diff --git a/src/server.rs b/src/server.rs index 76b1365..b00adb0 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,3 +1,4 @@ +use std::fmt::Write as _; use std::net::SocketAddr; use std::sync::Arc; @@ -19,7 +20,7 @@ use crate::db::SqlitePool; use crate::errors::{AppError, AppResult}; use crate::ffmpeg::FfmpegStatus; use crate::runtime::RuntimePaths; -use crate::{admin, backup, csrf, media, rate_limit, render, social}; +use crate::{admin, backup, csrf, favicon, media, rate_limit, render, social}; const CSRF_TOKEN_HISTORY_LIMIT: usize = 32; @@ -56,6 +57,7 @@ pub fn router(state: Arc) -> Router { Router::new() .route("/", get(home)) .route("/assets/rustpost.js", get(client_script)) + .route("/favicon.ico", get(site_favicon)) .route("/local", get(local_redirect)) .route("/home", get(home)) .route("/login", get(login_form).post(login)) @@ -87,6 +89,8 @@ pub fn router(state: Arc) -> Router { .route("/admin/posts/{id}/delete", post(admin_delete_post)) .route("/admin/health", get(admin_health)) .route("/admin/media", get(admin_media)) + .route("/admin/favicon", post(admin_favicon_upload)) + .route("/admin/favicon/remove", post(admin_favicon_remove)) .route( "/admin/backups", get(admin_backups).post(admin_create_backup), @@ -103,6 +107,10 @@ pub fn router(state: Arc) -> Router { "/uploads/videos", ServeDir::new(state.paths.uploads_videos.clone()), ) + .nest_service( + "/uploads/thumbs", + ServeDir::new(state.paths.uploads_thumbs.clone()), + ) .layer(DefaultBodyLimit::max(upload_body_limit)) .layer(TraceLayer::new_for_http()) .with_state(state) @@ -127,6 +135,10 @@ async fn client_script() -> Response { .into_response() } +async fn site_favicon(State(state): State>) -> Response { + favicon::response(&state.paths).await +} + #[derive(Deserialize)] struct AuthForm { username: String, @@ -172,6 +184,10 @@ struct ParsedProfileUpdate { banner_media_id: Option, } +struct ParsedFaviconUpload { + uploaded: bool, +} + struct ParsedPostCreate { csrf_token: String, text: String, @@ -259,6 +275,7 @@ async fn layout_context( tor_onion_address: state.tor.onion_address(), follower_count: counts.map(|(followers, _following)| followers), following_count: counts.map(|(_followers, following)| following), + favicon_content_type: favicon::current(&state.paths).content_type(), }) } @@ -308,9 +325,17 @@ async fn login( ) .await .map_err(|err| AppError::RateLimited(err.to_string()))?; - let Some(session) = auth::login(&state.pool, &form.username, &form.password).await? else { - rate_limit::record(&state.pool, rate_limit::Scope::FailedLogin, &actor).await?; - return Err(AppError::Unauthorized); + let session = match auth::login(&state.pool, &form.username, &form.password).await? { + Ok(session) => session, + Err(failure) => { + let message = match failure { + auth::LoginFailure::NoAccount => "No account with that username.", + auth::LoginFailure::InvalidPassword => "The password is incorrect.", + auth::LoginFailure::UnavailableAccount => "This account cannot log in.", + }; + rate_limit::record(&state.pool, rate_limit::Scope::FailedLogin, &actor).await?; + return auth_form_response(&state, StatusCode::UNAUTHORIZED, message).await; + } }; let mut response = Redirect::to("/home").into_response(); response.headers_mut().insert( @@ -360,7 +385,7 @@ async fn register( ) .await .map_err(|err| AppError::RateLimited(err.to_string()))?; - let user_id = auth::register_user( + let user_id = match auth::register_user( &state.pool, &state.settings, &form.username, @@ -368,7 +393,21 @@ async fn register( false, ) .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; + { + Ok(user_id) => user_id, + Err(err) => { + let message = err.to_string(); + if message == auth::USERNAME_TAKEN_MESSAGE { + return register_form_response( + &state, + StatusCode::BAD_REQUEST, + "That username is already taken.", + ) + .await; + } + return Err(AppError::BadRequest(message)); + } + }; let session = auth::create_session(&state.pool, user_id).await?; let mut response = Redirect::to("/home").into_response(); response.headers_mut().insert( @@ -382,6 +421,32 @@ async fn register( Ok(response) } +async fn auth_form_response( + state: &AppState, + status: StatusCode, + message: &str, +) -> AppResult { + let body = render::login_form(Some(message)); + Ok(( + status, + Html(page_layout(state, None, None, "Login", &body).await?), + ) + .into_response()) +} + +async fn register_form_response( + state: &AppState, + status: StatusCode, + message: &str, +) -> AppResult { + let body = render::register_form(Some(message)); + Ok(( + status, + Html(page_layout(state, None, None, "Register", &body).await?), + ) + .into_response()) +} + async fn logout( State(state): State>, headers: HeaderMap, @@ -606,7 +671,7 @@ async fn thread( }; let body = format!( "{}{}{}", - render::page_header("Thread", "Read the conversation and add a reply."), + render::thread_back_control(), render::thread_posts(&posts, user.as_ref(), csrf.as_deref()), composer ); @@ -1189,12 +1254,12 @@ async fn parse_profile_update( continue; } form.profile_picture_media_id = Some( - media::save_upload( + media::save_profile_picture_upload( &state.pool, &state.settings, &state.paths, &state.ffmpeg, - Some(user_id), + user_id, field, ) .await @@ -1593,41 +1658,30 @@ async fn search( Query(query): Query, ) -> AppResult> { let user = current(&state, &headers).await?; - let q = query.q.unwrap_or_default(); - let (users, posts) = if q.trim().is_empty() { + let q = normalize_search_query(query.q.as_deref().unwrap_or_default()); + let (users, posts) = if q.is_empty() { (Vec::new(), Vec::new()) } else { - social::search(&state.pool, user.as_ref().map(|u| u.id), q.trim()).await? + social::search(&state.pool, user.as_ref().map(|u| u.id), &q).await? }; - let user_results = users - .into_iter() - .map(|(_, username, display)| { - format!( - r#"
  • {}
  • "#, - html_escape::encode_double_quoted_attribute(&username), - html_escape::encode_text(&display) - ) - }) - .collect::>() - .join(""); let csrf = form_csrf(&state, &headers).await; - let user_results = if user_results.is_empty() { - render::empty_state("No users found", "Try a username or display name.") - } else { - format!(r#"
      {user_results}
    "#) - }; - let body = format!( - r#"

    Search

    Users

    {}

    Posts

    {}
    "#, - html_escape::encode_text(&state.settings.site.name), - html_escape::encode_double_quoted_attribute(&q), - user_results, - render::posts(&posts, user.as_ref(), csrf.as_deref()) + let body = render::search_page( + &state.settings.site.name, + &q, + &users, + &posts, + user.as_ref(), + csrf.as_deref(), ); Ok(Html( page_layout(&state, user.as_ref(), csrf.as_deref(), "Search", &body).await?, )) } +fn normalize_search_query(query: &str) -> String { + query.split_whitespace().collect::>().join(" ") +} + async fn tag( State(state): State>, headers: HeaderMap, @@ -1649,44 +1703,119 @@ async fn admin_dashboard( ) -> AppResult> { let user = require_admin(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let favicon_asset = favicon::current(&state.paths); + let remove_form = if favicon_asset.is_custom() { + small_form( + "/admin/favicon/remove", + &csrf, + "Remove favicon", + "Reset to the built-in favicon", + ) + } else { + String::new() + }; + let favicon_panel = format!( + r#"

    Favicon

    {}

    Current favicon

    Accepted: .ico, .png, .svg. Maximum size: 256 KiB.

    {}
    "#, + html_escape::encode_text(favicon_asset.state_label()), + html_escape::encode_double_quoted_attribute(&csrf), + remove_form + ); let body = format!( - "{}{}", + "{}{}{}", render::page_header( "Admin", "Manage site health, users, media jobs, and backups." ), - r#"
    Site healthUsersMedia jobsBackups
    "# + r#"
    Site healthUsersMedia jobsBackups
    "#, + favicon_panel ); Ok(Html( page_layout(&state, Some(&user), Some(&csrf), "Admin", &body).await?, )) } +async fn admin_favicon_upload( + State(state): State>, + headers: HeaderMap, + multipart: Multipart, +) -> AppResult { + require_admin(&state, &headers).await?; + let parsed = parse_favicon_upload(&state, &headers, multipart).await?; + if !parsed.uploaded { + return Err(AppError::BadRequest( + "choose a .ico, .png, or .svg favicon to upload".to_owned(), + )); + } + Ok(Redirect::to("/admin").into_response()) +} + +async fn admin_favicon_remove( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult { + require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + favicon::reset(&state.paths).await?; + Ok(Redirect::to("/admin").into_response()) +} + +async fn parse_favicon_upload( + state: &AppState, + headers: &HeaderMap, + mut multipart: Multipart, +) -> AppResult { + let mut parsed = ParsedFaviconUpload { uploaded: false }; + let mut csrf_validated = false; + while let Some(field) = multipart + .next_field() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? + { + let Some(name) = field.name().map(ToOwned::to_owned) else { + continue; + }; + match name.as_str() { + "csrf" => { + let token = field + .text() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + validate_csrf(&state.pool, headers, &token).await?; + csrf_validated = true; + } + "favicon" if field.file_name().is_some() => { + if field.file_name().is_none_or(|name| name.trim().is_empty()) { + continue; + } + if !csrf_validated { + return Err(AppError::Forbidden); + } + favicon::save_upload(&state.paths, field) + .await + .map_err(|err| { + tracing::warn!(error = %err, "favicon upload rejected"); + AppError::BadRequest(err.to_string()) + })?; + parsed.uploaded = true; + } + _ => {} + } + } + if !csrf_validated { + return Err(AppError::Forbidden); + } + Ok(parsed) +} + async fn admin_health( State(state): State>, headers: HeaderMap, ) -> AppResult> { let user = require_admin(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let recent_jobs = admin::recent_media_jobs(&state.pool).await?; - let jobs = recent_jobs - .into_iter() - .take(5) - .map(|(id, status, stderr)| { - format!( - r#"
  • #{} {}
    {}
  • "#, - id, - html_escape::encode_text(&status), - html_escape::encode_text(&stderr) - ) - }) - .collect::>() - .join(""); - let jobs = if jobs.is_empty() { - r#"

    No media jobs yet.

    "#.to_owned() - } else { - format!(r#"
      {jobs}
    "#) - }; + let media_jobs = admin::media_jobs_report(&state.pool).await?; + let jobs = render_media_jobs_report(&media_jobs); let onion = state .tor .onion_address() @@ -1801,33 +1930,110 @@ async fn admin_media( ) -> AppResult> { let user = require_admin(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let jobs = admin::recent_media_jobs(&state.pool).await?; - let rows = jobs - .into_iter() - .map(|(id, status, stderr)| { - format!( - r#"{}{}
    {}
    "#, - id, - html_escape::encode_text(&status), - html_escape::encode_text(&stderr) - ) - }) - .collect::>() - .join(""); - let body = if rows.is_empty() { - r#"

    Media jobs

    No media jobs yet.

    "# - .to_owned() - } else { - format!( - r#"

    Media jobs

    {}
    IDStatusSummary
    "#, - rows - ) - }; + let jobs = admin::media_jobs_report(&state.pool).await?; + let body = format!( + r#"

    Media jobs

    {}
    "#, + render_media_jobs_report(&jobs) + ); Ok(Html( page_layout(&state, Some(&user), Some(&csrf), "Media jobs", &body).await?, )) } +fn render_media_jobs_report(report: &admin::MediaJobsReport) -> String { + if report.total == 0 { + return r#"

    No media jobs yet.

    "#.to_owned(); + } + + let mut out = String::new(); + let pending_age = match ( + report.newest_pending_age_seconds, + report.oldest_pending_age_seconds, + ) { + (Some(newest), Some(oldest)) => { + format!( + "{} newest / {} oldest", + format_age(newest), + format_age(oldest) + ) + } + _ => "none".to_owned(), + }; + let _ = write!( + out, + r#"
    TotalPendingRunningSucceededFailedPending age
    {}{}{}{}{}{}
    "#, + report.total, + report.pending, + report.running, + report.succeeded, + report.failed, + html_escape::encode_text(&pending_age), + ); + + if report.recent_failures.is_empty() { + if report.failed == 0 { + out.push_str(r#"

    No recent media job failures.

    "#); + } + return out; + } + + out.push_str( + r#"

    Recent failures

    "#, + ); + for failure in &report.recent_failures { + let media = failure + .media_path + .as_deref() + .map(|path| compact_text(path, 48)) + .or_else(|| failure.media_id.map(|id| format!("#{id}"))) + .unwrap_or_else(|| "unknown".to_owned()); + let kind = failure.job_kind.as_deref().unwrap_or("media"); + let age = failure + .age_seconds + .map_or_else(|| "unknown".to_owned(), format_age); + let error = compact_text(&failure.error_summary, 80); + let _ = write!( + out, + r#""#, + failure.id, + html_escape::encode_text(&media), + html_escape::encode_text(kind), + html_escape::encode_text(&age), + html_escape::encode_text(&error), + ); + } + out.push_str("
    JobMediaKindAgeError
    #{}{}{}{}{}
    "); + out +} + +fn compact_text(input: &str, max_chars: usize) -> String { + let compact = input.split_whitespace().collect::>().join(" "); + if compact.chars().count() <= max_chars { + return compact; + } + + let take = max_chars.saturating_sub(3); + let mut shortened = compact.chars().take(take).collect::(); + shortened.push_str("..."); + shortened +} + +fn format_age(seconds: i64) -> String { + let seconds = seconds.max(0); + if seconds < 60 { + return format!("{seconds}s"); + } + let minutes = seconds / 60; + if minutes < 60 { + return format!("{minutes}m"); + } + let hours = minutes / 60; + if hours < 48 { + return format!("{hours}h"); + } + format!("{}d", hours / 24) +} + async fn admin_backups( State(state): State>, headers: HeaderMap, @@ -1989,10 +2195,12 @@ fn user_actor(user_id: i64) -> String { #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; struct TestServer { base_url: String, + data_dir: PathBuf, _task: tokio::task::JoinHandle<()>, _temp: tempfile::TempDir, } @@ -2019,6 +2227,65 @@ mod tests { assert_eq!(hashes[CSRF_TOKEN_HISTORY_LIMIT - 2], "token-30"); } + #[test] + fn compact_text_collapses_whitespace_and_truncates() { + let compact = compact_text( + "/uploads/originals/a/very/long/path.png\nffmpeg stderr repeated detail", + 24, + ); + + assert_eq!(compact, "/uploads/originals/a/..."); + } + + #[test] + fn media_jobs_report_keeps_healthy_state_short() { + let report = admin::MediaJobsReport { + total: 3, + succeeded: 3, + ..admin::MediaJobsReport::default() + }; + + let output = render_media_jobs_report(&report); + + assert!(output.contains("Total")); + assert!(output.contains("3")); + assert!(output.contains("No recent media job failures.")); + assert!(!output.contains("Recent failures")); + assert!(!output.contains("
    "));
    +    }
    +
    +    #[test]
    +    fn media_jobs_report_shows_compact_recent_failures() {
    +        let report = admin::MediaJobsReport {
    +            total: 8,
    +            pending: 1,
    +            running: 1,
    +            succeeded: 4,
    +            failed: 2,
    +            newest_pending_age_seconds: Some(90),
    +            oldest_pending_age_seconds: Some(3_900),
    +            recent_failures: vec![admin::MediaJobFailure {
    +                id: 42,
    +                media_id: Some(7),
    +                media_path: Some("/media/uploads/a/really/long/path/that/should/not/dominate/report/video.webm".to_owned()),
    +                job_kind: Some("video".to_owned()),
    +                age_seconds: Some(3_600),
    +                error_summary: "ffmpeg failed\nwith a very long diagnostic that should be clipped before it fills the admin table".to_owned(),
    +            }],
    +        };
    +
    +        let output = render_media_jobs_report(&report);
    +
    +        assert!(output.contains("Recent failures"));
    +        assert!(output.contains("#42"));
    +        assert!(output.contains("video"));
    +        assert!(output.contains("1h"));
    +        assert!(output.contains("1m newest / 1h oldest"));
    +        assert!(output.contains("..."));
    +        assert!(!output.contains("should be clipped before it fills"));
    +        assert!(!output.contains("
    "));
    +    }
    +
         #[test]
         fn delete_return_target_rejects_self_referential_confirmation_paths() {
             assert_eq!(safe_delete_return_target("/posts/42/delete", 42), None);
    @@ -2037,6 +2304,63 @@ mod tests {
             );
         }
     
    +    #[tokio::test]
    +    async fn missing_login_account_renders_login_form_message() {
    +        let server = spawn_test_server().await;
    +
    +        let response = request(
    +            &server.base_url,
    +            "POST",
    +            "/login",
    +            &[("content-type", "application/x-www-form-urlencoded")],
    +            b"username=missing-user&password=not%20the%20password".to_vec(),
    +        )
    +        .await;
    +
    +        assert_eq!(response.status, 401);
    +        assert!(response.body.contains("

    Log in

    ")); + assert!(response.body.contains("No account with that username.")); + assert!( + response + .body + .contains(r#""#) + ); + assert!(!response.body.contains("Authentication required")); + } + + #[tokio::test] + async fn duplicate_registration_renders_register_form_message() { + let server = spawn_test_server().await; + let first = request( + &server.base_url, + "POST", + "/register", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=alice&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), + ) + .await; + assert_eq!(first.status, 303); + + let duplicate = request( + &server.base_url, + "POST", + "/register", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=Alice&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(), + ) + .await; + + assert_eq!(duplicate.status, 400); + assert!(duplicate.body.contains("

    Create account

    ")); + assert!(duplicate.body.contains("That username is already taken.")); + assert!( + duplicate + .body + .contains(r#""#) + ); + assert!(!duplicate.body.contains("Check the form")); + } + #[tokio::test] async fn logged_in_ui_post_succeeds_with_empty_media_part_and_appears_on_home_feed() { let server = spawn_test_server().await; @@ -2114,7 +2438,16 @@ mod tests { ) .await; assert_eq!(thread.status, 200); + assert!(thread.body.contains(r#"class="thread-nav""#)); + assert!(thread.body.contains(r#"aria-label="Back""#)); + assert!(!thread.body.contains(r#"class="page-header""#)); + assert!( + !thread + .body + .contains("Read the conversation and add a reply.") + ); assert!(thread.body.contains(r#"class="post-time""#)); + assert!(!thread.body.contains(r#"data-card-href="/posts/1""#)); assert!(!thread.body.contains(r#"href="/posts/1">Open post"#)); } @@ -2816,6 +3149,219 @@ mod tests { assert!(followed.body.contains(r#""action":"/users/1/unfollow""#)); } + #[tokio::test] + async fn default_favicon_route_and_html_link_are_present() { + let server = spawn_test_server().await; + + let favicon = request(&server.base_url, "GET", "/favicon.ico", &[], Vec::new()).await; + assert_eq!(favicon.status, 200); + assert_header(&favicon, "content-type", "image/x-icon"); + assert_header(&favicon, "cache-control", "public, max-age=3600"); + + let home = request(&server.base_url, "GET", "/home", &[], Vec::new()).await; + assert_eq!(home.status, 200); + assert!( + home.body + .contains(r#""#) + ); + } + + #[tokio::test] + async fn admin_can_upload_replace_and_remove_png_favicon() { + let server = spawn_test_server_with_admin().await; + let cookie = admin_session_cookie(&server).await; + let admin = request( + &server.base_url, + "GET", + "/admin", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + assert_eq!(admin.status, 200); + assert!(admin.body.contains("Using built-in default favicon")); + let csrf = csrf_token(&admin.body); + + let uploaded = request( + &server.base_url, + "POST", + "/admin/favicon", + &[ + ("cookie", &cookie), + ( + "content-type", + "multipart/form-data; boundary=favicon-boundary", + ), + ], + multipart_body_with_file( + "favicon-boundary", + &[("csrf", csrf.as_str())], + "favicon", + "site.png", + "image/png", + &tiny_png_bytes(), + ), + ) + .await; + assert_eq!(uploaded.status, 303); + assert_eq!(location(&uploaded), "/admin"); + assert!(server.data_dir.join("assets/favicon.png").is_file()); + + let favicon = request(&server.base_url, "GET", "/favicon.ico", &[], Vec::new()).await; + assert_eq!(favicon.status, 200); + assert_header(&favicon, "content-type", "image/png"); + + let admin = request( + &server.base_url, + "GET", + "/admin", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + assert!(admin.body.contains("Custom favicon configured")); + assert!(admin.body.contains("Remove favicon")); + let csrf = csrf_token(&admin.body); + + let replacement = request( + &server.base_url, + "POST", + "/admin/favicon", + &[ + ("cookie", &cookie), + ( + "content-type", + "multipart/form-data; boundary=favicon-boundary", + ), + ], + multipart_body_with_file( + "favicon-boundary", + &[("csrf", csrf.as_str())], + "favicon", + "site.ico", + "image/x-icon", + &[0, 0, 1, 0, 1, 0], + ), + ) + .await; + assert_eq!(replacement.status, 303); + assert!(server.data_dir.join("assets/favicon.ico").is_file()); + assert!(!server.data_dir.join("assets/favicon.png").exists()); + + let admin = request( + &server.base_url, + "GET", + "/admin", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + let csrf = csrf_token(&admin.body); + let removed = request( + &server.base_url, + "POST", + "/admin/favicon/remove", + &[ + ("cookie", &cookie), + ("content-type", "application/x-www-form-urlencoded"), + ], + format!("csrf={csrf}").into_bytes(), + ) + .await; + assert_eq!(removed.status, 303); + assert!(!server.data_dir.join("assets/favicon.ico").exists()); + } + + #[tokio::test] + async fn favicon_upload_rejects_unsupported_content_and_unsafe_names() { + let server = spawn_test_server_with_admin().await; + let cookie = admin_session_cookie(&server).await; + let admin = request( + &server.base_url, + "GET", + "/admin", + &[("cookie", &cookie)], + Vec::new(), + ) + .await; + let csrf = csrf_token(&admin.body); + + let unsupported = request( + &server.base_url, + "POST", + "/admin/favicon", + &[ + ("cookie", &cookie), + ( + "content-type", + "multipart/form-data; boundary=favicon-boundary", + ), + ], + multipart_body_with_file( + "favicon-boundary", + &[("csrf", csrf.as_str())], + "favicon", + "favicon.gif", + "image/gif", + b"GIF89a", + ), + ) + .await; + assert_eq!(unsupported.status, 400); + assert!(unsupported.body.contains("unsupported favicon type")); + assert!(!server.data_dir.join("assets/favicon.gif").exists()); + + let invalid_png = request( + &server.base_url, + "POST", + "/admin/favicon", + &[ + ("cookie", &cookie), + ( + "content-type", + "multipart/form-data; boundary=favicon-boundary", + ), + ], + multipart_body_with_file( + "favicon-boundary", + &[("csrf", csrf.as_str())], + "favicon", + "favicon.png", + "image/png", + b"", + ), + ) + .await; + assert_eq!(invalid_png.status, 400); + assert!(invalid_png.body.contains("invalid file signature")); + assert!(!server.data_dir.join("assets/favicon.png").exists()); + + let traversal = request( + &server.base_url, + "POST", + "/admin/favicon", + &[ + ("cookie", &cookie), + ( + "content-type", + "multipart/form-data; boundary=favicon-boundary", + ), + ], + multipart_body_with_file( + "favicon-boundary", + &[("csrf", csrf.as_str())], + "favicon", + "../favicon.png", + "image/png", + &tiny_png_bytes(), + ), + ) + .await; + assert_eq!(traversal.status, 400); + assert!(traversal.body.contains("unsafe upload filename")); + assert!(!server.data_dir.join("assets/favicon.png").exists()); + } + #[tokio::test] async fn delete_requires_confirmation_and_preserves_cancel_target() { let server = spawn_test_server().await; @@ -2951,14 +3497,28 @@ mod tests { } async fn spawn_test_server() -> TestServer { + spawn_test_server_inner(false).await + } + + async fn spawn_test_server_with_admin() -> TestServer { + spawn_test_server_inner(true).await + } + + async fn spawn_test_server_inner(create_admin: bool) -> TestServer { let temp = tempfile::tempdir().expect("temp dir"); let paths = RuntimePaths::from_data_dir(temp.path().to_path_buf()); paths.ensure().expect("paths"); + let data_dir = paths.data_dir.clone(); let pool = crate::db::connect(&paths.database_path) .await .expect("connect"); crate::db::migrate(&pool).await.expect("migrate"); let settings = Settings::default(); + if create_admin { + crate::admin::create_admin(&pool, &settings, "siteowner", "very secure password") + .await + .expect("admin"); + } let ffmpeg = FfmpegStatus { available: false, version: String::new(), @@ -2982,11 +3542,25 @@ mod tests { }); TestServer { base_url: format!("127.0.0.1:{}", addr.port()), + data_dir, _task: task, _temp: temp, } } + async fn admin_session_cookie(server: &TestServer) -> String { + let login = request( + &server.base_url, + "POST", + "/login", + &[("content-type", "application/x-www-form-urlencoded")], + b"username=siteowner&password=very%20secure%20password".to_vec(), + ) + .await; + assert_eq!(login.status, 303); + session_cookie(&login) + } + async fn request( base_url: &str, method: &str, @@ -3058,6 +3632,17 @@ mod tests { .expect("location") } + fn assert_header(response: &TestResponse, name: &str, expected: &str) { + assert_eq!( + response + .headers + .iter() + .find(|(header_name, _)| header_name == name) + .map(|(_, value)| value.as_str()), + Some(expected) + ); + } + fn csrf_token(body: &str) -> String { let marker = r#"name="csrf" value=""#; let start = body.find(marker).expect("csrf marker") + marker.len(); diff --git a/src/social.rs b/src/social.rs index 81ce6a8..df3269a 100644 --- a/src/social.rs +++ b/src/social.rs @@ -352,7 +352,8 @@ pub async fn following_accounts( pool.call(move |conn| { let mut stmt = conn.prepare( r#" - SELECT u.id, u.username, u.display_name, u.bio, pic.public_path + SELECT u.id, u.username, u.display_name, u.bio, + COALESCE(pic.thumbnail_public_path, pic.public_path) FROM follows f JOIN users u ON u.id = f.followed_id LEFT JOIN media pic ON pic.id = u.profile_picture_media_id @@ -527,17 +528,39 @@ pub async fn search( pool: &SqlitePool, viewer_id: Option, query: &str, -) -> anyhow::Result<(Vec<(i64, String, String)>, Vec)> { - let username_query = format!("%{}%", query.to_ascii_lowercase()); - let display_query = format!("%{query}%"); +) -> anyhow::Result<(Vec, Vec)> { + let user_query = user_query_from_input(query); let users = pool .call(move |conn| { + let Some(user_query) = user_query else { + return Ok(Vec::new()); + }; + let username_query = format!("%{}%", user_query.to_ascii_lowercase()); + let display_query = format!("%{user_query}%"); + let viewer_id = viewer_id.unwrap_or(-1); let mut stmt = conn.prepare( - "SELECT id, username, display_name FROM users WHERE is_deleted = 0 AND (normalized_username LIKE ? OR display_name LIKE ?) LIMIT 20", + r#" + SELECT u.id, u.username, u.display_name, u.bio, + COALESCE(pic.thumbnail_public_path, pic.public_path), + EXISTS(SELECT 1 FROM follows WHERE follower_id = ? AND followed_id = u.id) + FROM users u + LEFT JOIN media pic ON pic.id = u.profile_picture_media_id + WHERE u.is_deleted = 0 + AND (u.normalized_username LIKE ? OR u.display_name LIKE ?) + ORDER BY lower(u.username) + LIMIT 20 + "#, )?; let rows = stmt - .query_map(params![username_query, display_query], |row| { - Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + .query_map(params![viewer_id, username_query, display_query], |row| { + Ok(AccountView { + id: row.get(0)?, + username: row.get(1)?, + display_name: row.get(2)?, + bio: row.get(3)?, + profile_picture_path: row.get(4)?, + viewer_following: row.get::<_, i64>(5)? != 0, + }) })? .collect::, _>>()?; Ok(rows) @@ -556,6 +579,18 @@ pub async fn search( Ok((users, rows_to_posts(pool, rows, viewer_id).await?)) } +fn user_query_from_input(query: &str) -> Option { + let trimmed = query.trim().trim_start_matches('@').trim(); + let value = trimmed + .split_whitespace() + .next() + .unwrap_or_default() + .trim_matches(|character: char| { + !(character.is_alphanumeric() || character == '_' || character == '-') + }); + (!value.is_empty()).then(|| value.to_owned()) +} + fn fts_query_from_user_input(query: &str) -> Option { let terms = query .split_whitespace() @@ -568,7 +603,13 @@ fn fts_query_from_user_input(query: &str) -> Option { if terms.is_empty() { None } else { - Some(terms.join(" ")) + Some( + terms + .into_iter() + .map(|term| format!(r#""{term}""#)) + .collect::>() + .join(" "), + ) } } @@ -606,7 +647,8 @@ fn base_post_query() -> String { SELECT 'post' AS event_kind, 'p:' || p.id AS event_id, p.created_at AS event_created_at, NULL AS repost_user_id, NULL AS repost_username, NULL AS repost_display_name, NULL AS repost_created_at, 0 AS original_unavailable, - p.id, p.user_id, u.username, u.display_name, pic.public_path AS profile_picture_path, + p.id, p.user_id, u.username, u.display_name, + COALESCE(pic.thumbnail_public_path, pic.public_path) AS profile_picture_path, p.anonymous_label, p.text, p.parent_post_id, p.created_at, (SELECT COUNT(*) FROM likes WHERE post_id = p.id) AS like_count, (SELECT COUNT(*) FROM reposts WHERE post_id = p.id) AS repost_count, @@ -624,7 +666,7 @@ fn base_repost_query() -> String { ru.id AS repost_user_id, ru.username AS repost_username, ru.display_name AS repost_display_name, r.created_at AS repost_created_at, CASE WHEN p.id IS NULL OR p.is_deleted != 0 THEN 1 ELSE 0 END AS original_unavailable, COALESCE(p.id, r.post_id) AS id, p.user_id, u.username, u.display_name, - pic.public_path AS profile_picture_path, p.anonymous_label, + COALESCE(pic.thumbnail_public_path, pic.public_path) AS profile_picture_path, p.anonymous_label, COALESCE(p.text, '') AS text, p.parent_post_id, COALESCE(p.created_at, r.created_at) AS created_at, CASE WHEN p.id IS NULL OR p.is_deleted != 0 THEN 0 ELSE (SELECT COUNT(*) FROM likes WHERE post_id = p.id) END AS like_count, CASE WHEN p.id IS NULL OR p.is_deleted != 0 THEN 0 ELSE (SELECT COUNT(*) FROM reposts WHERE post_id = p.id) END AS repost_count, @@ -966,11 +1008,22 @@ mod reply_tests { fn search_fts_query_uses_safe_terms() { assert_eq!( fts_query_from_user_input("#self-hosted @ada"), - Some("self hosted ada".to_owned()) + Some(r#""self" "hosted" "ada""#.to_owned()) + ); + assert_eq!( + fts_query_from_user_input("NEAR OR rust"), + Some(r#""NEAR" "OR" "rust""#.to_owned()) ); assert_eq!(fts_query_from_user_input("#"), None); } + #[test] + fn search_user_query_supports_mentions() { + assert_eq!(user_query_from_input("@alice"), Some("alice".to_owned())); + assert_eq!(user_query_from_input(" @alice, "), Some("alice".to_owned())); + assert_eq!(user_query_from_input("@"), None); + } + #[tokio::test] async fn reply_is_linked_to_parent_and_rendered_in_thread() { let (_temp, pool, settings) = test_pool().await; @@ -1202,6 +1255,67 @@ mod tests { assert_eq!(original.repost_count, 1); } + #[tokio::test] + async fn compact_post_avatar_prefers_profile_thumbnail() { + let (pool, settings, alice, _) = fixture().await; + set_profile_picture( + &pool, + alice, + "/uploads/images/alice.webp", + Some("/uploads/thumbs/alice-profile.webp"), + ) + .await; + let post = create_post(&pool, &settings, Some(alice), "hello", None, &[]) + .await + .expect("post"); + + let local = timeline(&pool, Some(alice), "local", None) + .await + .expect("timeline"); + let original = local + .iter() + .find(|event| event.event_kind == TimelineEventKind::Post && event.id == post) + .expect("original post"); + + assert_eq!( + original.profile_picture_path.as_deref(), + Some("/uploads/thumbs/alice-profile.webp") + ); + } + + #[tokio::test] + async fn compact_post_avatar_falls_back_to_original_without_thumbnail() { + let (pool, settings, alice, _) = fixture().await; + set_profile_picture(&pool, alice, "/uploads/images/alice.webp", None).await; + let post = create_post(&pool, &settings, Some(alice), "hello", None, &[]) + .await + .expect("post"); + + let local = timeline(&pool, Some(alice), "local", None) + .await + .expect("timeline"); + let original = local + .iter() + .find(|event| event.event_kind == TimelineEventKind::Post && event.id == post) + .expect("original post"); + + assert_eq!( + original.profile_picture_path.as_deref(), + Some("/uploads/images/alice.webp") + ); + } + + #[tokio::test] + async fn following_accounts_keep_default_avatar_when_no_profile_picture() { + let (pool, _settings, alice, bob) = fixture().await; + follow(&pool, bob, alice).await.expect("follow"); + + let accounts = following_accounts(&pool, bob).await.expect("accounts"); + + assert_eq!(accounts.len(), 1); + assert!(accounts[0].profile_picture_path.is_none()); + } + #[tokio::test] async fn profile_timeline_includes_user_reposts() { let (pool, settings, alice, bob) = fixture().await; @@ -1247,6 +1361,30 @@ mod tests { assert!(repost_event.media.is_empty()); } + async fn set_profile_picture( + pool: &SqlitePool, + user_id: i64, + public_path: &str, + thumbnail_public_path: Option<&str>, + ) { + let public_path = public_path.to_owned(); + let thumbnail_public_path = thumbnail_public_path.map(ToOwned::to_owned); + pool.call(move |conn| { + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, thumbnail_public_path) VALUES (?, 'avatar.webp', '/tmp/avatar.webp', ?, 'image/webp', 'image', 1, ?)", + params![user_id, public_path, thumbnail_public_path], + )?; + let media_id = conn.last_insert_rowid(); + conn.execute( + "UPDATE users SET profile_picture_media_id = ? WHERE id = ?", + params![media_id, user_id], + )?; + Ok(()) + }) + .await + .expect("profile picture"); + } + #[tokio::test] async fn repost_notification_created_only_once() { let (pool, settings, alice, bob) = fixture().await; diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 8882b5f..8840e9f 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -52,7 +52,7 @@ export async function goForwardToHealthyPage(page: Page): Promise { export async function openPostThread(page: Page, postId: string): Promise { await page.goto(`/posts/${postId}`); - await expect(page.getByRole('heading', { name: 'Thread' })).toBeVisible(); + await expect(page.locator(`article[data-post-id="${postId}"]`)).toBeVisible(); await expectNotErrorPage(page); } diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index 75964e4..4c975ca 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -54,7 +54,7 @@ test('normal post actions preserve feed, thread, anchors, and profile context', await expect(homeArticle.getByRole('button', { name: 'Unbookmark' })).toBeVisible(); await page.goto(`/posts/${postId}`); - await expect(page.getByRole('heading', { name: 'Thread' })).toBeVisible(); + await expect(page.locator(`article[data-post-id="${postId}"]`)).toBeVisible(); await expectNotErrorPage(page); const threadUrl = new RegExp(`/posts/${postId}$`); const threadArticle = postArticle(page, postText); @@ -120,6 +120,63 @@ test('normal post actions preserve feed, thread, anchors, and profile context', ); }); +test('post cards open threads except for the current root post inside its own thread', async ({ page }) => { + await registerFresh(page, 'threadnav'); + const postText = `thread navigation ${uniqueName('post')}`; + const postId = await createPost(page, postText); + const homeArticle = postArticle(page, postText); + + await Promise.all([ + page.waitForURL(new RegExp(`/posts/${postId}$`)), + homeArticle.locator('.text').click(), + ]); + await expectNotErrorPage(page); + await expect(page.getByRole('heading', { name: 'Thread' })).toHaveCount(0); + const backLink = page.getByRole('link', { name: 'Back' }); + await expect(backLink).toHaveAttribute('href', '/home'); + + const rootArticle = postArticle(page, postText); + const backBox = await backLink.boundingBox(); + const rootBox = await rootArticle.boundingBox(); + expect(backBox, 'back control should be visible').not.toBeNull(); + expect(rootBox, 'root post should be visible').not.toBeNull(); + expect(backBox!.y).toBeLessThan(rootBox!.y); + expect(backBox!.x).toBeLessThan(rootBox!.x + 80); + + await Promise.all([ + page.waitForURL(new RegExp(`/home#post-${postId}$`)), + backLink.click(), + ]); + await expect(postArticle(page, postText)).toBeVisible(); + await Promise.all([ + page.waitForURL(new RegExp(`/posts/${postId}$`)), + postArticle(page, postText).locator('.text').click(), + ]); + await expectNotErrorPage(page); + + const reopenedRootArticle = postArticle(page, postText); + await expect(reopenedRootArticle).not.toHaveAttribute('data-card-href', `/posts/${postId}`); + await page.evaluate(() => { + (window as Window & { __threadRootClickMarker?: string }).__threadRootClickMarker = 'alive'; + }); + const threadUrl = page.url(); + await reopenedRootArticle.locator('.text').click(); + await page.waitForTimeout(300); + expect(page.url()).toBe(threadUrl); + await expect + .poll(() => + page.evaluate(() => (window as Window & { __threadRootClickMarker?: string }).__threadRootClickMarker), + ) + .toBe('alive'); + + await page.goto('/home'); + await Promise.all([ + page.waitForURL(new RegExp(`/posts/${postId}$`)), + postArticle(page, postText).locator('.text').click(), + ]); + await expectNotErrorPage(page); +}); + test('Back and Forward restore current like, bookmark, repost, follow, reply, and settings state', async ({ page }) => { const alice = await registerFresh(page, 'histalice'); const postText = `history state ${uniqueName('post')}`; diff --git a/tests/e2e/rustpost.spec.ts b/tests/e2e/rustpost.spec.ts index a98d00c..f7a8fbc 100644 --- a/tests/e2e/rustpost.spec.ts +++ b/tests/e2e/rustpost.spec.ts @@ -1,4 +1,6 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; import { adminPassword, adminUser, @@ -14,6 +16,27 @@ import { submitFirstPostAction, } from './helpers'; +const SECOND_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; + +function writeGeneratedFixture(name: string, data: Buffer | string): string { + const file = path.join(process.cwd(), 'output/playwright/generated-fixtures', name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, data); + return file; +} + +function generatedPng(name: string, base64 = SECOND_PNG_BASE64): string { + const bytes = Buffer.from(base64, 'base64'); + return writeGeneratedFixture(name, Buffer.concat([bytes, Buffer.from(name)])); +} + +async function expectReachableImage(page: Page, src: string) { + const response = await page.request.get(src); + expect(response.status(), `${src} should be reachable`).toBe(200); + expect(response.headers()['content-type'] ?? '', `${src} content type`).toMatch(/^image\//); +} + test.beforeEach(async ({ page }, testInfo) => { await installQaGuards(page, testInfo); }); @@ -47,7 +70,8 @@ test('logged-out navigation, registration validation, and public links are healt page.getByRole('button', { name: 'Log in' }).click(), ]).then(([response]) => response); expect(badLogin.status()).toBe(401); - await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); + await expect(page.getByText('No account with that username.')).toBeVisible(); await page.goto('/home'); await crawlVisibleInternalLinks(page); @@ -66,7 +90,7 @@ test('account, posting, thread, profile, search, and social actions work', async const postId = await createPost(page, postText, fixturePath('tiny.png')); await page.goto(`/posts/${postId}`); - await expect(page.getByRole('heading', { name: 'Thread' })).toBeVisible(); + await expect(page.locator(`article[data-post-id="${postId}"]`)).toBeVisible(); await page.getByLabel('What is happening?').fill('Reply from the same account'); await Promise.all([ page.waitForURL(/\/posts\/\d+#reply-\d+/), @@ -176,7 +200,20 @@ test('normal UI forms post to real routes and forbidden duplicates are intention page.getByRole('button', { name: 'Create account' }).click(), ]).then(([response]) => response); expect(duplicate.status()).toBe(400); - await expect(page.getByRole('heading', { name: 'Check the form' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Create account' })).toBeVisible(); + await expect(page.getByText('That username is already taken.')).toBeVisible(); + + await page.goto('/login'); + await page.getByLabel('Username').fill(user); + await page.getByLabel('Password', { exact: true }).fill('not the password'); + const badPassword = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/login') && response.request().method() === 'POST'), + page.getByRole('button', { name: 'Log in' }).click(), + ]).then(([response]) => response); + expect(badPassword.status()).toBe(401); + await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); + await expect(page.getByText('The password is incorrect.')).toBeVisible(); + await expect(page.getByText('No account with that username.')).not.toBeVisible(); await login(page, user, password); await page.goto('/home'); @@ -189,3 +226,148 @@ test('normal UI forms post to real routes and forbidden duplicates are intention expect(emptyPost.status()).toBe(400); await expect(page.getByRole('heading', { name: 'Check the form' })).toBeVisible(); }); + +test('profile-picture thumbnails are used for compact avatars with original images preserved for full profile UI', async ({ page }) => { + const user = `pfp_${Date.now()}`; + const password = 'very secure password'; + const firstPicture = fixturePath('tiny.png'); + const secondPicture = generatedPng('replacement-profile-picture.png'); + + await register(page, user, password); + await page.goto('/settings'); + await page.getByLabel('Display name').fill('Profile Thumb QA'); + await page.getByLabel('Profile picture', { exact: true }).setInputFiles(firstPicture); + await Promise.all([ + page.waitForURL('/settings'), + page.getByRole('button', { name: 'Save settings' }).click(), + ]); + + const settingsPicture = page.locator('img.profile-picture').first(); + await expect(settingsPicture).toBeVisible(); + const originalSrc = await settingsPicture.getAttribute('src'); + expect(originalSrc).toMatch(/^\/uploads\/(?:images|originals)\//); + expect(originalSrc).not.toContain('/uploads/thumbs/'); + await expectReachableImage(page, originalSrc!); + + await page.goto(`/users/${user}`); + const fullProfileSrc = await page.locator('section.profile img.profile-picture').getAttribute('src'); + expect(fullProfileSrc).toBe(originalSrc); + + const postText = `Profile thumbnail check ${Date.now()}`; + await createPost(page, postText); + const article = page.locator('article[data-post-id]').filter({ hasText: postText }).first(); + const compactAvatar = article.locator('img.post-avatar'); + await expect(compactAvatar).toBeVisible(); + const compactSrc = await compactAvatar.getAttribute('src'); + expect(compactSrc).toMatch(/^\/uploads\/(?:thumbs|images|originals)\//); + await expectReachableImage(page, compactSrc!); + if (compactSrc!.includes('/uploads/thumbs/')) { + expect(compactSrc).toMatch(/^\/uploads\/thumbs\/\d+-profile\.webp$/); + const thumbnail = await page.request.get(compactSrc!); + expect(thumbnail.headers()['content-type'] ?? '').toMatch(/^image\/webp\b/); + } else { + expect(compactSrc).toBe(originalSrc); + } + + await page.goto('/settings'); + await page.getByLabel('Profile picture', { exact: true }).setInputFiles(secondPicture); + await Promise.all([ + page.waitForURL('/settings'), + page.getByRole('button', { name: 'Save settings' }).click(), + ]); + const replacementOriginalSrc = await page.locator('img.profile-picture').first().getAttribute('src'); + expect(replacementOriginalSrc).toMatch(/^\/uploads\/(?:images|originals)\//); + expect(replacementOriginalSrc).not.toBe(originalSrc); + expect(replacementOriginalSrc).not.toContain('/uploads/thumbs/'); + await expectReachableImage(page, replacementOriginalSrc!); + + await page.goto('/home'); + const replacedCompactSrc = await article.locator('img.post-avatar').getAttribute('src'); + expect(replacedCompactSrc).not.toBe(compactSrc); + expect(replacedCompactSrc).not.toBe(originalSrc); + await expectReachableImage(page, replacedCompactSrc!); + if (replacedCompactSrc!.includes('/uploads/thumbs/')) { + expect(replacedCompactSrc).toMatch(/^\/uploads\/thumbs\/\d+-profile\.webp$/); + const replacementThumbnail = await page.request.get(replacedCompactSrc!); + expect(replacementThumbnail.headers()['content-type'] ?? '').toMatch(/^image\/webp\b/); + } else { + expect(replacedCompactSrc).toBe(replacementOriginalSrc); + } + + await page.goto('/settings'); + await page.getByLabel('Delete profile picture').check(); + await Promise.all([ + page.waitForURL('/settings'), + page.getByRole('button', { name: 'Save settings' }).click(), + ]); + await expect(page.locator('img.profile-picture')).toHaveCount(0); + + await page.goto('/home'); + const placeholderArticle = page.locator('article[data-post-id]').filter({ hasText: postText }).first(); + await expect(placeholderArticle.locator('span.post-avatar.placeholder')).toBeVisible(); + await expect(placeholderArticle.locator('img.post-avatar')).toHaveCount(0); +}); + +test('custom favicon can be uploaded, replaced, reset, and rejects unsupported uploads', async ({ page }) => { + const firstFavicon = fixturePath('tiny.png'); + const secondFavicon = generatedPng('replacement-favicon.png'); + const invalidFavicon = writeGeneratedFixture('invalid-favicon.txt', 'not a favicon'); + + const home = await page.goto('/home'); + expect(home?.status()).toBeLessThan(400); + const faviconLink = page.locator('link[rel="icon"]'); + await expect(faviconLink).toHaveAttribute('href', '/favicon.ico'); + + let favicon = await page.request.get('/favicon.ico'); + expect(favicon.status()).toBe(200); + expect(favicon.headers()['content-type']).toMatch(/^image\/x-icon\b/); + expect(favicon.headers()['cache-control']).toBe('public, max-age=3600'); + expect(favicon.headers()['x-content-type-options']).toBe('nosniff'); + const defaultBytes = await favicon.body(); + + await login(page, adminUser, adminPassword); + await page.goto('/admin'); + await page.getByLabel('Upload favicon').setInputFiles(firstFavicon); + await Promise.all([ + page.waitForURL('/admin'), + page.getByRole('button', { name: 'Save favicon' }).click(), + ]); + await expect(page.getByText('Custom favicon configured')).toBeVisible(); + + favicon = await page.request.get('/favicon.ico'); + expect(favicon.status()).toBe(200); + expect(favicon.headers()['content-type']).toMatch(/^image\/png\b/); + expect(favicon.headers()['cache-control']).toBe('public, max-age=3600'); + expect(favicon.headers()['x-content-type-options']).toBe('nosniff'); + const firstBytes = await favicon.body(); + expect(firstBytes.equals(defaultBytes)).toBe(false); + + await page.getByLabel('Upload favicon').setInputFiles(secondFavicon); + await Promise.all([ + page.waitForURL('/admin'), + page.getByRole('button', { name: 'Save favicon' }).click(), + ]); + favicon = await page.request.get('/favicon.ico'); + const replacementBytes = await favicon.body(); + expect(favicon.headers()['content-type']).toMatch(/^image\/png\b/); + expect(replacementBytes.equals(firstBytes)).toBe(false); + + await page.getByLabel('Upload favicon').setInputFiles(invalidFavicon); + const invalidUpload = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/admin/favicon') && response.request().method() === 'POST'), + page.getByRole('button', { name: 'Save favicon' }).click(), + ]).then(([response]) => response); + expect(invalidUpload.status()).toBe(400); + await expect(page.getByRole('heading', { name: 'Check the form' })).toBeVisible(); + await expect(page.getByText('unsupported favicon type; upload .ico, .png, or .svg')).toBeVisible(); + favicon = await page.request.get('/favicon.ico'); + expect((await favicon.body()).equals(replacementBytes)).toBe(true); + + await page.goto('/admin'); + await page.getByRole('button', { name: 'Reset to the built-in favicon' }).click(); + await expect(page).toHaveURL(/\/admin/); + await expect(page.getByText('Using built-in default favicon')).toBeVisible(); + favicon = await page.request.get('/favicon.ico'); + expect(favicon.headers()['content-type']).toMatch(/^image\/x-icon\b/); + expect((await favicon.body()).equals(defaultBytes)).toBe(true); +}); diff --git a/tests/e2e/search.spec.ts b/tests/e2e/search.spec.ts new file mode 100644 index 0000000..b7a31f4 --- /dev/null +++ b/tests/e2e/search.spec.ts @@ -0,0 +1,115 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; +import { + assertNoQaFailures, + createPost, + expectNotErrorPage, + installQaGuards, + logout, + register, + uniqueName, +} from './helpers'; + +const password = 'very secure password'; + +test.beforeEach(async ({ page }, testInfo) => { + await installQaGuards(page, testInfo); +}); + +test.afterEach(async ({ page }) => { + assertNoQaFailures(page); +}); + +function resultArticle(page: Page, text: string): Locator { + return page.locator('article[data-post-id]').filter({ hasText: text }).first(); +} + +async function submitSearch(page: Page, query: string): Promise { + await page.getByLabel(/Search RustPost/).fill(query); + await Promise.all([ + page.waitForURL(/\/search\?q=/), + page.getByRole('button', { name: 'Search' }).click(), + ]); + await expectNotErrorPage(page); +} + +test('logged-out search page loads and shows the empty prompt', async ({ page }) => { + const response = await page.goto('/search'); + + expect(response?.status()).toBeLessThan(400); + await expect(page.getByRole('heading', { name: 'Search', exact: true })).toBeVisible(); + await expect(page.getByPlaceholder('Search posts, @users, or #tags')).toBeVisible(); + await expect(page.getByText('Search for posts, usernames, mentions, or hashtags.')).toBeVisible(); +}); + +test('search states, result cards, and actions work while logged in', async ({ page }) => { + const username = uniqueName('searchalice'); + const token = uniqueName('needle'); + const postText = `Searchable ${token} post about #rust mentions @${username}`; + + await register(page, username, password); + await page.goto('/search'); + await expect(page.getByRole('heading', { name: 'Search', exact: true })).toBeVisible(); + await expect(page.getByText('Search for posts, usernames, mentions, or hashtags.')).toBeVisible(); + + const postId = await createPost(page, postText); + + await page.goto('/search'); + await submitSearch(page, token); + await expect(page.getByRole('searchbox', { name: /Search RustPost/ })).toHaveValue(token); + await expect(page.getByRole('heading', { name: `1 result for "${token}"` })).toBeVisible(); + await expect(resultArticle(page, postText)).toBeVisible(); + + await resultArticle(page, postText).getByRole('button', { name: 'Like' }).click(); + await expect(page).toHaveURL(new RegExp(`/search\\?q=${token}$`)); + await expect(resultArticle(page, postText).getByRole('button', { name: 'Unlike' })).toBeVisible(); + + await Promise.all([ + page.waitForURL(new RegExp(`/posts/${postId}$`)), + resultArticle(page, postText).locator('.text').click(), + ]); + await expect(page.locator(`article[data-post-id="${postId}"]`)).toBeVisible(); + + await page.goto('/search'); + await submitSearch(page, '#rust'); + await expect(resultArticle(page, postText)).toBeVisible(); + + await page.goto('/search'); + await submitSearch(page, `@${username}`); + await expect(page.getByLabel('People').getByRole('link', { name: username, exact: true })).toBeVisible(); + await expect(resultArticle(page, postText)).toBeVisible(); + + await page.goto('/search'); + await submitSearch(page, 'NEAR OR !!!'); + await expectNotErrorPage(page); + + const missing = uniqueName('missing'); + await page.goto('/search'); + await submitSearch(page, missing); + await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible(); + await expect(page.getByText(`No matches for "${missing}".`)).toBeVisible(); +}); + +test('mobile viewport keeps the search input and results readable', async ({ page }) => { + const username = uniqueName('searchmobile'); + const token = uniqueName('mobile'); + const postText = `Mobile search result ${token} #rust`; + + await page.setViewportSize({ width: 390, height: 844 }); + await register(page, username, password); + await createPost(page, postText); + + await page.goto('/search'); + await submitSearch(page, token); + + const input = page.getByRole('searchbox', { name: /Search RustPost/ }); + const article = resultArticle(page, postText); + await expect(input).toBeVisible(); + await expect(article).toBeVisible(); + + const inputBox = await input.boundingBox(); + const articleBox = await article.boundingBox(); + expect(inputBox?.width).toBeLessThanOrEqual(390); + expect(articleBox?.width).toBeLessThanOrEqual(390); + + await logout(page); +});