max / multithreaded
18 files changed,
+185 insertions,
-42 deletions
| @@ -15,5 +15,9 @@ | |||
| 15 | 15 | # macOS | |
| 16 | 16 | .DS_Store | |
| 17 | 17 | ||
| 18 | + | # Deploy environment files | |
| 19 | + | deploy/env.* | |
| 20 | + | !deploy/env.example | |
| 21 | + | ||
| 18 | 22 | # Release artifacts | |
| 19 | 23 | dist/ |
| @@ -2060,14 +2060,14 @@ dependencies = [ | |||
| 2060 | 2060 | ||
| 2061 | 2061 | [[package]] | |
| 2062 | 2062 | name = "mt-core" | |
| 2063 | - | version = "0.3.0" | |
| 2063 | + | version = "0.3.1" | |
| 2064 | 2064 | dependencies = [ | |
| 2065 | 2065 | "chrono", | |
| 2066 | 2066 | ] | |
| 2067 | 2067 | ||
| 2068 | 2068 | [[package]] | |
| 2069 | 2069 | name = "mt-db" | |
| 2070 | - | version = "0.3.0" | |
| 2070 | + | version = "0.3.1" | |
| 2071 | 2071 | dependencies = [ | |
| 2072 | 2072 | "chrono", | |
| 2073 | 2073 | "serde", | |
| @@ -2095,7 +2095,7 @@ dependencies = [ | |||
| 2095 | 2095 | ||
| 2096 | 2096 | [[package]] | |
| 2097 | 2097 | name = "multithreaded" | |
| 2098 | - | version = "0.3.0" | |
| 2098 | + | version = "0.3.1" | |
| 2099 | 2099 | dependencies = [ | |
| 2100 | 2100 | "ammonia", | |
| 2101 | 2101 | "askama", |
| @@ -7,7 +7,7 @@ members = [ | |||
| 7 | 7 | default-members = ["."] | |
| 8 | 8 | ||
| 9 | 9 | [workspace.package] | |
| 10 | - | version = "0.3.0" | |
| 10 | + | version = "0.3.1" | |
| 11 | 11 | edition = "2024" | |
| 12 | 12 | license-file = "LICENSE" | |
| 13 | 13 |
| @@ -892,14 +892,18 @@ pub async fn search_users( | |||
| 892 | 892 | pool: &PgPool, | |
| 893 | 893 | query: &str, | |
| 894 | 894 | ) -> Result<Vec<AdminUserRow>, sqlx::Error> { | |
| 895 | + | let escaped = query | |
| 896 | + | .replace('\\', "\\\\") | |
| 897 | + | .replace('%', "\\%") | |
| 898 | + | .replace('_', "\\_"); | |
| 895 | 899 | sqlx::query_as::<_, AdminUserRow>( | |
| 896 | 900 | "SELECT mnw_account_id AS id, username, display_name, suspended_at, suspension_reason | |
| 897 | 901 | FROM users | |
| 898 | - | WHERE username ILIKE $1 | |
| 902 | + | WHERE username ILIKE $1 ESCAPE '\\' | |
| 899 | 903 | ORDER BY username | |
| 900 | 904 | LIMIT 50", | |
| 901 | 905 | ) | |
| 902 | - | .bind(format!("{query}%")) | |
| 906 | + | .bind(format!("{escaped}%")) | |
| 903 | 907 | .fetch_all(pool) | |
| 904 | 908 | .await | |
| 905 | 909 | } | |
| @@ -1289,12 +1293,6 @@ pub async fn search_threads( | |||
| 1289 | 1293 | community_slug: Option<&str>, | |
| 1290 | 1294 | limit: i64, | |
| 1291 | 1295 | ) -> Result<Vec<SearchResultRow>, sqlx::Error> { | |
| 1292 | - | let tsquery = query | |
| 1293 | - | .split_whitespace() | |
| 1294 | - | .map(|w| format!("{w}:*")) | |
| 1295 | - | .collect::<Vec<_>>() | |
| 1296 | - | .join(" & "); | |
| 1297 | - | ||
| 1298 | 1296 | sqlx::query_as::<_, SearchResultRow>( | |
| 1299 | 1297 | "WITH thread_matches AS ( | |
| 1300 | 1298 | SELECT t.id AS thread_id, | |
| @@ -1306,7 +1304,7 @@ pub async fn search_threads( | |||
| 1306 | 1304 | c.slug AS category_slug, | |
| 1307 | 1305 | LEFT(t.title, 200) AS snippet, | |
| 1308 | 1306 | t.last_activity_at, | |
| 1309 | - | (ts_rank(t.search_tsv, to_tsquery('english', $1)) * 2.0 | |
| 1307 | + | (ts_rank(t.search_tsv, websearch_to_tsquery('english', $1)) * 2.0 | |
| 1310 | 1308 | + similarity(t.title, $2)) AS rank | |
| 1311 | 1309 | FROM threads t | |
| 1312 | 1310 | JOIN categories c ON c.id = t.category_id | |
| @@ -1314,7 +1312,7 @@ pub async fn search_threads( | |||
| 1314 | 1312 | JOIN users u ON u.mnw_account_id = t.author_id | |
| 1315 | 1313 | WHERE t.deleted_at IS NULL | |
| 1316 | 1314 | AND co.suspended_at IS NULL | |
| 1317 | - | AND (t.search_tsv @@ to_tsquery('english', $1) | |
| 1315 | + | AND (t.search_tsv @@ websearch_to_tsquery('english', $1) | |
| 1318 | 1316 | OR similarity(t.title, $2) > 0.1) | |
| 1319 | 1317 | AND ($3::text IS NULL OR co.slug = $3) | |
| 1320 | 1318 | ), | |
| @@ -1329,7 +1327,7 @@ pub async fn search_threads( | |||
| 1329 | 1327 | c.slug AS category_slug, | |
| 1330 | 1328 | LEFT(p.body_markdown, 200) AS snippet, | |
| 1331 | 1329 | t.last_activity_at, | |
| 1332 | - | ts_rank(p.search_tsv, to_tsquery('english', $1)) AS rank | |
| 1330 | + | ts_rank(p.search_tsv, websearch_to_tsquery('english', $1)) AS rank | |
| 1333 | 1331 | FROM posts p | |
| 1334 | 1332 | JOIN threads t ON t.id = p.thread_id | |
| 1335 | 1333 | JOIN categories c ON c.id = t.category_id | |
| @@ -1338,10 +1336,10 @@ pub async fn search_threads( | |||
| 1338 | 1336 | WHERE t.deleted_at IS NULL | |
| 1339 | 1337 | AND co.suspended_at IS NULL | |
| 1340 | 1338 | AND p.removed_at IS NULL | |
| 1341 | - | AND p.search_tsv @@ to_tsquery('english', $1) | |
| 1339 | + | AND p.search_tsv @@ websearch_to_tsquery('english', $1) | |
| 1342 | 1340 | AND ($3::text IS NULL OR co.slug = $3) | |
| 1343 | 1341 | AND t.id NOT IN (SELECT thread_id FROM thread_matches) | |
| 1344 | - | ORDER BY t.id, ts_rank(p.search_tsv, to_tsquery('english', $1)) DESC | |
| 1342 | + | ORDER BY t.id, ts_rank(p.search_tsv, websearch_to_tsquery('english', $1)) DESC | |
| 1345 | 1343 | ) | |
| 1346 | 1344 | SELECT * FROM thread_matches | |
| 1347 | 1345 | UNION ALL | |
| @@ -1349,7 +1347,7 @@ pub async fn search_threads( | |||
| 1349 | 1347 | ORDER BY rank DESC, last_activity_at DESC | |
| 1350 | 1348 | LIMIT $4", | |
| 1351 | 1349 | ) | |
| 1352 | - | .bind(&tsquery) | |
| 1350 | + | .bind(query) | |
| 1353 | 1351 | .bind(query) | |
| 1354 | 1352 | .bind(community_slug) | |
| 1355 | 1353 | .bind(limit) |
| @@ -1,8 +0,0 @@ | |||
| 1 | - | HOST=127.0.0.1 | |
| 2 | - | PORT=3400 | |
| 3 | - | DATABASE_URL=postgres:///multithreaded | |
| 4 | - | MNW_BASE_URL=https://makenot.work | |
| 5 | - | OAUTH_CLIENT_ID=mt-forums-6378957b452bbbc906c3db8edd072d64 | |
| 6 | - | OAUTH_REDIRECT_URI=https://forums.makenot.work/auth/callback | |
| 7 | - | PLATFORM_ADMIN_ID=8d940646-480e-4a17-baf5-bc21fc3a4198 | |
| 8 | - | COOKIE_SECURE=true |
| @@ -1,7 +0,0 @@ | |||
| 1 | - | HOST=0.0.0.0 | |
| 2 | - | PORT=3400 | |
| 3 | - | DATABASE_URL=postgres:///multithreaded | |
| 4 | - | MNW_BASE_URL=http://127.0.0.1:3000 | |
| 5 | - | OAUTH_CLIENT_ID=PLACEHOLDER_SET_AFTER_REGISTERING_APP | |
| 6 | - | OAUTH_REDIRECT_URI=http://100.106.221.39:3400/auth/callback | |
| 7 | - | PLATFORM_ADMIN_ID= |
| @@ -312,12 +312,15 @@ pub async fn callback( | |||
| 312 | 312 | display_name: info.display_name, | |
| 313 | 313 | }; | |
| 314 | 314 | session_user.save_to_session(&session).await; | |
| 315 | + | if let Err(e) = session.cycle_id().await { | |
| 316 | + | tracing::warn!(error = %e, "Failed to cycle session ID"); | |
| 317 | + | } | |
| 315 | 318 | tracing::info!("session saved, redirecting to /"); | |
| 316 | 319 | ||
| 317 | 320 | Redirect::to("/") | |
| 318 | 321 | } | |
| 319 | 322 | ||
| 320 | - | /// `GET /auth/logout` — flush session, redirect home. | |
| 323 | + | /// `POST /auth/logout` — flush session, redirect home. | |
| 321 | 324 | #[tracing::instrument(skip_all)] | |
| 322 | 325 | pub async fn logout(session: Session) -> impl IntoResponse { | |
| 323 | 326 | let _ = session.flush().await; |
| @@ -20,5 +20,7 @@ pub struct AppState { | |||
| 20 | 20 | pub db: PgPool, | |
| 21 | 21 | pub config: Config, | |
| 22 | 22 | pub http: reqwest::Client, | |
| 23 | + | /// SSRF-safe client for link preview fetching (validates URLs on redirects). | |
| 24 | + | pub preview_http: reqwest::Client, | |
| 23 | 25 | pub s3: Option<Arc<storage::S3Storage>>, | |
| 24 | 26 | } |
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | //! Link preview — server-side OpenGraph metadata fetch for post URLs. | |
| 2 | 2 | ||
| 3 | 3 | use pulldown_cmark::{Event, Parser, Tag}; | |
| 4 | + | use reqwest::header::CONTENT_TYPE; | |
| 4 | 5 | ||
| 5 | 6 | /// Maximum number of URLs to extract per post. | |
| 6 | 7 | const MAX_URLS: usize = 3; | |
| @@ -8,6 +9,50 @@ const MAX_URLS: usize = 3; | |||
| 8 | 9 | /// Maximum response body size to read (1 MB). | |
| 9 | 10 | const MAX_BODY_SIZE: usize = 1_048_576; | |
| 10 | 11 | ||
| 12 | + | /// Validate that a URL is safe to fetch (no SSRF to internal networks). | |
| 13 | + | fn validate_url(url: &str) -> bool { | |
| 14 | + | let lower = url.to_ascii_lowercase(); | |
| 15 | + | if !lower.starts_with("http://") && !lower.starts_with("https://") { | |
| 16 | + | return false; | |
| 17 | + | } | |
| 18 | + | let host_part = lower | |
| 19 | + | .strip_prefix("http://") | |
| 20 | + | .or_else(|| lower.strip_prefix("https://")) | |
| 21 | + | .unwrap_or(""); | |
| 22 | + | let host_and_port = host_part.split('/').next().unwrap_or(""); | |
| 23 | + | let host = if host_and_port.starts_with('[') { | |
| 24 | + | host_and_port | |
| 25 | + | .split(']') | |
| 26 | + | .next() | |
| 27 | + | .map(|s| format!("{}]", s)) | |
| 28 | + | .unwrap_or_default() | |
| 29 | + | } else { | |
| 30 | + | host_and_port.split(':').next().unwrap_or("").to_string() | |
| 31 | + | }; | |
| 32 | + | let host = host.as_str(); | |
| 33 | + | if host == "localhost" | |
| 34 | + | || host == "127.0.0.1" | |
| 35 | + | || host == "[::1]" | |
| 36 | + | || host == "0.0.0.0" | |
| 37 | + | || host.starts_with("10.") | |
| 38 | + | || host.starts_with("192.168.") | |
| 39 | + | || host.starts_with("169.254.") | |
| 40 | + | || host.starts_with("[fd") | |
| 41 | + | || host.starts_with("[fe80:") | |
| 42 | + | { | |
| 43 | + | return false; | |
| 44 | + | } | |
| 45 | + | // Block 172.16.0.0/12 | |
| 46 | + | if let Some(rest) = host.strip_prefix("172.") | |
| 47 | + | && let Some(second) = rest.split('.').next() | |
| 48 | + | && let Ok(n) = second.parse::<u8>() | |
| 49 | + | && (16..=31).contains(&n) | |
| 50 | + | { | |
| 51 | + | return false; | |
| 52 | + | } | |
| 53 | + | true | |
| 54 | + | } | |
| 55 | + | ||
| 11 | 56 | /// Extract unique http/https URLs from markdown text via pulldown_cmark link parsing. | |
| 12 | 57 | /// Returns at most `MAX_URLS` URLs. | |
| 13 | 58 | pub fn extract_urls(input: &str) -> Vec<String> { | |
| @@ -32,6 +77,20 @@ pub fn extract_urls(input: &str) -> Vec<String> { | |||
| 32 | 77 | urls | |
| 33 | 78 | } | |
| 34 | 79 | ||
| 80 | + | /// Build a reqwest client for link preview fetching with SSRF-safe redirect policy. | |
| 81 | + | pub fn build_preview_client() -> reqwest::Client { | |
| 82 | + | reqwest::Client::builder() | |
| 83 | + | .redirect(reqwest::redirect::Policy::custom(|attempt| { | |
| 84 | + | if !validate_url(attempt.url().as_str()) || attempt.previous().len() >= 5 { | |
| 85 | + | attempt.stop() | |
| 86 | + | } else { | |
| 87 | + | attempt.follow() | |
| 88 | + | } | |
| 89 | + | })) | |
| 90 | + | .build() | |
| 91 | + | .expect("failed to build preview HTTP client") | |
| 92 | + | } | |
| 93 | + | ||
| 35 | 94 | /// Fetch OpenGraph metadata from a URL. Returns `(og:title, og:description)`. | |
| 36 | 95 | /// Best-effort: returns None on any error (timeout, too large, parse failure). | |
| 37 | 96 | #[tracing::instrument(skip_all)] | |
| @@ -39,6 +98,10 @@ pub async fn fetch_og_metadata( | |||
| 39 | 98 | http: &reqwest::Client, | |
| 40 | 99 | url: &str, | |
| 41 | 100 | ) -> Option<(Option<String>, Option<String>)> { | |
| 101 | + | if !validate_url(url) { | |
| 102 | + | return None; | |
| 103 | + | } | |
| 104 | + | ||
| 42 | 105 | let resp = http | |
| 43 | 106 | .get(url) | |
| 44 | 107 | .timeout(std::time::Duration::from_secs(5)) | |
| @@ -51,6 +114,14 @@ pub async fn fetch_og_metadata( | |||
| 51 | 114 | return None; | |
| 52 | 115 | } | |
| 53 | 116 | ||
| 117 | + | // Only fetch HTML content | |
| 118 | + | if let Some(ct) = resp.headers().get(CONTENT_TYPE) { | |
| 119 | + | let ct_str = ct.to_str().unwrap_or(""); | |
| 120 | + | if !ct_str.starts_with("text/html") { | |
| 121 | + | return None; | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 54 | 125 | // Read body in chunks, capping at MAX_BODY_SIZE | |
| 55 | 126 | let mut body = Vec::new(); | |
| 56 | 127 | let mut stream = resp; | |
| @@ -187,4 +258,75 @@ mod tests { | |||
| 187 | 258 | let html = "<html><head></head></html>"; | |
| 188 | 259 | assert_eq!(extract_html_title(html), None); | |
| 189 | 260 | } | |
| 261 | + | ||
| 262 | + | // -- validate_url tests -- | |
| 263 | + | ||
| 264 | + | #[test] | |
| 265 | + | fn validate_url_allows_https() { | |
| 266 | + | assert!(validate_url("https://example.com")); | |
| 267 | + | assert!(validate_url("https://example.com/path?q=1")); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn validate_url_allows_http() { | |
| 272 | + | assert!(validate_url("http://example.com")); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | #[test] | |
| 276 | + | fn validate_url_blocks_non_http_schemes() { | |
| 277 | + | assert!(!validate_url("ftp://example.com")); | |
| 278 | + | assert!(!validate_url("file:///etc/passwd")); | |
| 279 | + | assert!(!validate_url("javascript:alert(1)")); | |
| 280 | + | assert!(!validate_url("data:text/html,<h1>hi</h1>")); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | #[test] | |
| 284 | + | fn validate_url_blocks_localhost() { | |
| 285 | + | assert!(!validate_url("http://localhost")); | |
| 286 | + | assert!(!validate_url("http://localhost:8080")); | |
| 287 | + | assert!(!validate_url("http://127.0.0.1")); | |
| 288 | + | assert!(!validate_url("http://127.0.0.1:3000")); | |
| 289 | + | assert!(!validate_url("http://0.0.0.0")); | |
| 290 | + | assert!(!validate_url("http://[::1]")); | |
| 291 | + | assert!(!validate_url("http://[::1]:8080")); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn validate_url_blocks_private_10() { | |
| 296 | + | assert!(!validate_url("http://10.0.0.1")); | |
| 297 | + | assert!(!validate_url("http://10.255.255.255")); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[test] | |
| 301 | + | fn validate_url_blocks_private_192_168() { | |
| 302 | + | assert!(!validate_url("http://192.168.0.1")); | |
| 303 | + | assert!(!validate_url("http://192.168.1.100:8080")); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn validate_url_blocks_private_172_16() { | |
| 308 | + | assert!(!validate_url("http://172.16.0.1")); | |
| 309 | + | assert!(!validate_url("http://172.31.255.255")); | |
| 310 | + | // 172.15 and 172.32 are public | |
| 311 | + | assert!(validate_url("http://172.15.0.1")); | |
| 312 | + | assert!(validate_url("http://172.32.0.1")); | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | #[test] | |
| 316 | + | fn validate_url_blocks_link_local() { | |
| 317 | + | assert!(!validate_url("http://169.254.0.1")); | |
| 318 | + | assert!(!validate_url("http://169.254.169.254")); // AWS metadata | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | #[test] | |
| 322 | + | fn validate_url_blocks_ipv6_private() { | |
| 323 | + | assert!(!validate_url("http://[fd00::1]")); | |
| 324 | + | assert!(!validate_url("http://[fe80::1]")); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn validate_url_allows_public_ips() { | |
| 329 | + | assert!(validate_url("http://8.8.8.8")); | |
| 330 | + | assert!(validate_url("https://93.184.216.34")); | |
| 331 | + | } | |
| 190 | 332 | } |
| @@ -64,6 +64,7 @@ async fn main() { | |||
| 64 | 64 | .connect_timeout(std::time::Duration::from_secs(5)) | |
| 65 | 65 | .build() | |
| 66 | 66 | .expect("failed to build HTTP client"), | |
| 67 | + | preview_http: multithreaded::link_preview::build_preview_client(), | |
| 67 | 68 | s3, | |
| 68 | 69 | }; | |
| 69 | 70 |
| @@ -162,7 +162,7 @@ async fn resolve_and_render_mentions( | |||
| 162 | 162 | async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uuid) { | |
| 163 | 163 | let urls = crate::link_preview::extract_urls(body); | |
| 164 | 164 | for url in urls { | |
| 165 | - | match crate::link_preview::fetch_og_metadata(&state.http, &url).await { | |
| 165 | + | match crate::link_preview::fetch_og_metadata(&state.preview_http, &url).await { | |
| 166 | 166 | Some((title, description)) => { | |
| 167 | 167 | if let Err(e) = mt_db::mutations::insert_link_preview( | |
| 168 | 168 | &state.db, |
| @@ -106,7 +106,7 @@ pub fn forum_routes(state: AppState) -> Router { | |||
| 106 | 106 | .route("/_admin", get(admin::admin_dashboard)) | |
| 107 | 107 | .route("/auth/login", get(auth::login)) | |
| 108 | 108 | .route("/auth/callback", get(auth::callback)) | |
| 109 | - | .route("/auth/logout", get(auth::logout)) | |
| 109 | + | .route("/auth/logout", post(auth::logout)) | |
| 110 | 110 | .route("/api/user/{user_id}/summary", get(forum::user_summary_api)) | |
| 111 | 111 | .route("/uploads/{id}", get(uploads::serve_image_handler)) | |
| 112 | 112 | .route("/api/health", get(health)); |
| @@ -82,9 +82,10 @@ pub(super) async fn upload_image_handler( | |||
| 82 | 82 | }; | |
| 83 | 83 | ||
| 84 | 84 | let s3_key = storage::generate_image_key(&slug, ext); | |
| 85 | + | let data_len = data.len() as i64; | |
| 85 | 86 | ||
| 86 | 87 | // Upload to S3 | |
| 87 | - | s3.upload(&s3_key, validated_ct, data.clone()).await.map_err(|e| { | |
| 88 | + | s3.upload(&s3_key, validated_ct, data).await.map_err(|e| { | |
| 88 | 89 | tracing::error!(error = %e, "S3 upload failed"); | |
| 89 | 90 | (StatusCode::INTERNAL_SERVER_ERROR, "Upload failed.").into_response() | |
| 90 | 91 | })?; | |
| @@ -97,7 +98,7 @@ pub(super) async fn upload_image_handler( | |||
| 97 | 98 | &s3_key, | |
| 98 | 99 | &filename, | |
| 99 | 100 | validated_ct, | |
| 100 | - | data.len() as i64, | |
| 101 | + | data_len, | |
| 101 | 102 | ) | |
| 102 | 103 | .await | |
| 103 | 104 | .map_err(|e| { |
| @@ -16,7 +16,7 @@ | |||
| 16 | 16 | <a href="{{ mnw_base_url }}/u/{{ user.username }}">{{ user.username }}</a> | |
| 17 | 17 | <a href="{{ mnw_base_url }}/dashboard">Dashboard</a> | |
| 18 | 18 | {% if user.is_platform_admin %}<a href="/_admin">Admin</a>{% endif %} | |
| 19 | - | <a href="/auth/logout" class="link-button" aria-label="Log out">Log Out</a> | |
| 19 | + | <form method="post" action="/auth/logout" style="display:inline"><button type="submit" class="link-button" aria-label="Log out">Log Out</button></form> | |
| 20 | 20 | {% else %} | |
| 21 | 21 | <a href="/auth/login">Login</a> | |
| 22 | 22 | <a href="{{ mnw_base_url }}/join">Join</a> |
| @@ -50,6 +50,7 @@ impl TestHarness { | |||
| 50 | 50 | db: pool.clone(), | |
| 51 | 51 | config, | |
| 52 | 52 | http: reqwest::Client::new(), | |
| 53 | + | preview_http: multithreaded::link_preview::build_preview_client(), | |
| 53 | 54 | s3: None, | |
| 54 | 55 | }; | |
| 55 | 56 | ||
| @@ -191,6 +192,7 @@ impl TestHarness { | |||
| 191 | 192 | db: pool.clone(), | |
| 192 | 193 | config, | |
| 193 | 194 | http: reqwest::Client::new(), | |
| 195 | + | preview_http: multithreaded::link_preview::build_preview_client(), | |
| 194 | 196 | s3: None, | |
| 195 | 197 | }; | |
| 196 | 198 |
| @@ -37,8 +37,8 @@ async fn logout_clears_session() { | |||
| 37 | 37 | let resp = h.client.get("/").await; | |
| 38 | 38 | assert!(resp.text.contains("logouttest")); | |
| 39 | 39 | ||
| 40 | - | // Logout | |
| 41 | - | h.client.get("/auth/logout").await; | |
| 40 | + | // Logout (POST) | |
| 41 | + | h.client.post_form("/auth/logout", "").await; | |
| 42 | 42 | ||
| 43 | 43 | // Should show Login link again | |
| 44 | 44 | let resp = h.client.get("/").await; |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Multithreaded — Todo | |
| 2 | 2 | ||
| 3 | - | Done: All pre-beta phases (0-11, 13-24). 222 tests (150 integration + 56 unit lib + 16 unit mt-core). v0.2.5. Audit grade: A. Deployed to hetzner+astra (forums.makenot.work). All 20 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set). | |
| 3 | + | Done: All pre-beta phases (0-11, 13-24). 222 tests (150 integration + 56 unit lib + 16 unit mt-core). v0.3.0. Audit grade: A (Run 8). Deployed to hetzner+astra (forums.makenot.work). All 20 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set). | |
| 4 | 4 | ||
| 5 | 5 | Completed work archived in [todo_done.md](todo_done.md). | |
| 6 | 6 | ||
| @@ -8,6 +8,8 @@ Alpha = forum is usable end-to-end: users can sign in via MNW OAuth, browse proj | |||
| 8 | 8 | ||
| 9 | 9 | No remaining pre-beta items. Only deferred post-beta items below. | |
| 10 | 10 | ||
| 11 | + | Run 8 audit items resolved. Moved to `todo_done.md`. | |
| 12 | + | ||
| 11 | 13 | --- | |
| 12 | 14 | ||
| 13 | 15 | ## Deferred (Post-Beta) |
| @@ -342,3 +342,6 @@ Archived completed phases from todo.md. All items here are done. | |||
| 342 | 342 | ||
| 343 | 343 | ### MNW Forums tab | |
| 344 | 344 | - [x] Already implemented in MNW — dashboard tab, HTMX partial, MT_BASE_URL config | |
| 345 | + | ||
| 346 | + | ## Run 8 Audit Items (Mar 2026) | |
| 347 | + | - [x] Remove unnecessary `data.clone()` in uploads.rs (saves up to 5MB allocation per image upload) |