Skip to main content

max / multithreaded

Auth, uploads, link preview, forum actions, and deploy cleanup Auth and route improvements. Upload handling updates. Link preview fixes. Forum action refinements. Deploy env files consolidated. Test harness updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-18 20:51 UTC
Commit: 504f3ea8937af5fe4a1aa8b40d7ff1ec1b47548a
Parent: 778e053
18 files changed, +185 insertions, -42 deletions
M .gitignore +4
@@ -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/
M Cargo.lock +3 -3
@@ -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",
M Cargo.toml +1 -1
@@ -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=
M src/auth.rs +4 -1
@@ -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;
M src/lib.rs +2
@@ -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;
M todo.md +3 -1
@@ -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)