| 1 |
# Contributing to Multithreaded |
| 2 |
|
| 3 |
Patterns, conventions, and rules for working on the Multithreaded forum codebase. |
| 4 |
|
| 5 |
## Project Structure |
| 6 |
|
| 7 |
``` |
| 8 |
multithreaded/ (workspace root) |
| 9 |
Cargo.toml # Workspace + root crate deps |
| 10 |
src/ |
| 11 |
lib.rs # AppState, module declarations |
| 12 |
main.rs # Entry point, migrations, server bind |
| 13 |
auth.rs # MNW OAuth (PKCE), session extractors |
| 14 |
csrf.rs # CSRF middleware (synchronizer token) |
| 15 |
config.rs # Config from environment |
| 16 |
internal_auth.rs # HMAC-SHA256 auth for MNW→MT internal API |
| 17 |
link_preview.rs # OG metadata extraction |
| 18 |
storage.rs # S3 wrapper around s3-storage crate |
| 19 |
seed.rs # Seed data for development |
| 20 |
routes/ |
| 21 |
mod.rs # Route tree, rate limiting |
| 22 |
helpers.rs # Reusable route helpers (get_community, render_markdown) |
| 23 |
forum/ # Forum views, thread, posts, actions |
| 24 |
moderation.rs # Mod tools |
| 25 |
flagging.rs # Content flagging |
| 26 |
settings.rs # Community settings |
| 27 |
admin.rs # Platform admin |
| 28 |
search.rs # Full-text search |
| 29 |
tracking.rs # Read state tracking |
| 30 |
uploads.rs # Image uploads (S3) |
| 31 |
templates/ |
| 32 |
mod.rs # Template structs + IntoResponse macro |
| 33 |
public.rs # Public page templates |
| 34 |
admin.rs # Admin templates |
| 35 |
crates/ |
| 36 |
mt-core/ # Domain types (enums, time formatting) |
| 37 |
mt-db/ # Database queries and mutations (sqlx) |
| 38 |
templates/ # Askama HTML templates |
| 39 |
static/ # CSS, JS, fonts |
| 40 |
migrations/ # PostgreSQL migrations (auto-applied on boot) |
| 41 |
tests/ # Integration tests |
| 42 |
deploy/ # Deploy scripts, systemd unit |
| 43 |
``` |
| 44 |
|
| 45 |
### Crate Boundaries |
| 46 |
|
| 47 |
|
| 48 |
|
| 49 |
| `mt-core` | Domain enums, time formatting | Nothing internal | |
| 50 |
| `mt-db` | SQL queries and mutations | `mt-core` | |
| 51 |
| root crate | Routes, templates, auth, config | `mt-core`, `mt-db`, shared libs | |
| 52 |
|
| 53 |
Library crates (`mt-core`, `mt-db`) contain no web framework types. Routes and templates live in the root crate only. |
| 54 |
|
| 55 |
## MNW OAuth Integration |
| 56 |
|
| 57 |
Multithreaded delegates all authentication to MNW via OAuth 2.0 with PKCE. There are no local passwords or signup forms. See [architecture.md § Authentication](docs/architecture.md#4-authentication) for the full flow (PKCE parameters, state nonce validation, retry behavior, session cycling). |
| 58 |
|
| 59 |
**Extractors:** |
| 60 |
- `MaybeUser(Option<SessionUser>)` — optional auth, infallible (never rejects) |
| 61 |
- `PlatformAdmin(SessionUser)` — admin-only, returns 404 to hide admin routes from non-admins |
| 62 |
|
| 63 |
## Route Handlers |
| 64 |
|
| 65 |
### Signature Pattern |
| 66 |
|
| 67 |
```rust |
| 68 |
#[tracing::instrument(skip_all)] |
| 69 |
pub(in crate::routes) async fn handler_name( |
| 70 |
axum::extract::State(state): axum::extract::State<AppState>, |
| 71 |
session: Session, |
| 72 |
MaybeUser(session_user): MaybeUser, |
| 73 |
Path((slug, category)): Path<(String, String)>, |
| 74 |
Query(page_query): Query<PageQuery>, |
| 75 |
) -> Result<impl IntoResponse, Response> { |
| 76 |
let csrf_token = Some(csrf::get_or_create_token(&session).await); |
| 77 |
let community = get_community(&state.db, &slug).await?; |
| 78 |
// ... build template struct ... |
| 79 |
Ok(MyTemplate { csrf_token, session_user, /* ... */ }) |
| 80 |
} |
| 81 |
``` |
| 82 |
|
| 83 |
### Error Handling |
| 84 |
|
| 85 |
Multithreaded uses `Result<impl IntoResponse, Response>` directly — no centralized `AppError` type. Errors are converted to `Response` inline via helper functions in `routes/helpers.rs`: |
| 86 |
|
| 87 |
```rust |
| 88 |
pub(crate) async fn get_community(db: &PgPool, slug: &str) -> Result<CommunityRow, Response> { |
| 89 |
mt_db::queries::get_community_by_slug(db, slug) |
| 90 |
.await |
| 91 |
.map_err(|e| { |
| 92 |
tracing::error!(error = ?e, "db error fetching community"); |
| 93 |
StatusCode::INTERNAL_SERVER_ERROR.into_response() |
| 94 |
})? |
| 95 |
.ok_or_else(|| StatusCode::NOT_FOUND.into_response()) |
| 96 |
} |
| 97 |
``` |
| 98 |
|
| 99 |
Use these helpers for consistent error responses. All DB errors log with `tracing::error!` and return 500. Missing resources return 404. |
| 100 |
|
| 101 |
### Rate Limiting |
| 102 |
|
| 103 |
Write routes (POST) use `tower_governor` rate limiting. Read routes (GET) have no rate limit. The rate limiter is applied via `.route_layer()` on the write routes group. |
| 104 |
|
| 105 |
## Templates |
| 106 |
|
| 107 |
Askama templates with Jinja2-like syntax. Template structs live in `src/templates/`, HTML files in `templates/`. |
| 108 |
|
| 109 |
### IntoResponse Macro |
| 110 |
|
| 111 |
Template structs get `IntoResponse` via a bulk macro: |
| 112 |
|
| 113 |
```rust |
| 114 |
impl_into_response!( |
| 115 |
ForumDirectoryTemplate, |
| 116 |
CommunityTemplate, |
| 117 |
ThreadTemplate, |
| 118 |
// ... |
| 119 |
); |
| 120 |
``` |
| 121 |
|
| 122 |
This handles rendering and returns 500 if template rendering fails. |
| 123 |
|
| 124 |
### Layout Inheritance |
| 125 |
|
| 126 |
All full pages extend `templates/base.html`: |
| 127 |
```html |
| 128 |
{% extends "base.html" %} |
| 129 |
{% block title %}Page Title{% endblock %} |
| 130 |
{% block header %}{% include "partials/site_header.html" %}{% endblock %} |
| 131 |
{% block content %} |
| 132 |
<!-- page content --> |
| 133 |
{% endblock %} |
| 134 |
``` |
| 135 |
|
| 136 |
Every full-page template struct needs: |
| 137 |
- `csrf_token: Option<String>` — for the CSRF meta tag |
| 138 |
- `session_user: Option<TemplateSessionUser>` — for header login state |
| 139 |
- `mnw_base_url: Arc<str>` — for links back to MNW |
| 140 |
|
| 141 |
## Database Layer |
| 142 |
|
| 143 |
Queries and mutations live in `crates/mt-db/`. Same sqlx patterns as MNW server: |
| 144 |
|
| 145 |
```rust |
| 146 |
pub async fn list_communities(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<CommunityListRow>, sqlx::Error> { |
| 147 |
sqlx::query_as::<_, CommunityListRow>( |
| 148 |
"SELECT co.name, co.slug, co.description, ... FROM communities co ... |
| 149 |
LIMIT $1 OFFSET $2", |
| 150 |
) |
| 151 |
.bind(limit).bind(offset) |
| 152 |
.fetch_all(pool).await |
| 153 |
} |
| 154 |
``` |
| 155 |
|
| 156 |
**Rules:** |
| 157 |
- Always use positional parameters (`$1`, `$2`). Never interpolate. |
| 158 |
- Projection structs derive `sqlx::FromRow` and are shaped for templates, not domain models. |
| 159 |
- Queries in `queries.rs`, mutations in `mutations.rs`. |
| 160 |
- Use `ON CONFLICT ... DO UPDATE` for upserts (user sync from MNW). |
| 161 |
|
| 162 |
Migrations are in `migrations/` and auto-apply on boot via `sqlx::migrate!()`. |
| 163 |
|
| 164 |
## Shared Dependencies |
| 165 |
|
| 166 |
MT uses three shared crates from `MNW/shared/`: |
| 167 |
|
| 168 |
|
| 169 |
|
| 170 |
| `docengine` | Markdown rendering with `render_strict()`, @mention resolution, quote post-processing | |
| 171 |
| `tagtree` | Tag name validation (`validate_with(&tag, &MT_TAG_CONFIG)`) | |
| 172 |
| `s3-storage` | Image uploads via `S3Storage` wrapper | |
| 173 |
|
| 174 |
**Deploy note:** `deploy.sh` syncs shared deps to `~/src/shared/` on Astra because Cargo.toml references `../shared/X`. If you add a new shared dep, update the rsync loop in `deploy.sh`. |
| 175 |
|
| 176 |
## CSRF Protection |
| 177 |
|
| 178 |
Synchronizer token pattern, same as MNW server. CSRF middleware validates `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. Exempt paths: `/auth/`, `/api/health`, `/_test/`. |
| 179 |
|
| 180 |
Client-side: HTMX sends the token automatically via a `htmx:configRequest` listener that reads from `<meta name="csrf-token">`. |
| 181 |
|
| 182 |
## Internal API (MNW → MT) |
| 183 |
|
| 184 |
MNW server can call MT's internal API (e.g., to auto-create communities for new projects). These requests use HMAC-SHA256 authentication: |
| 185 |
|
| 186 |
- `X-Internal-Timestamp` — Unix timestamp (must be within 60 seconds of server time) |
| 187 |
- `X-Internal-Signature` — HMAC-SHA256 of `"timestamp\nbody"` using shared secret |
| 188 |
|
| 189 |
The `InternalAuth` extractor validates both before allowing access. |
| 190 |
|
| 191 |
## Testing |
| 192 |
|
| 193 |
Integration tests use the same pattern as MNW: each test creates and drops its own PostgreSQL database. |
| 194 |
|
| 195 |
```rust |
| 196 |
let harness = TestHarness::new().await; // Creates mnw_test_<uuid> DB |
| 197 |
let user_id = harness.login_as("alice").await; // Inserts user + sets session |
| 198 |
// ... test routes via harness.client ... |
| 199 |
// Database dropped on harness drop |
| 200 |
``` |
| 201 |
|
| 202 |
A `/_test/login` route (only available in tests) bypasses OAuth for session setup. Tests are organized by domain in `tests/workflows/`. |
| 203 |
|
| 204 |
## Deployment |
| 205 |
|
| 206 |
MT builds natively on Astra (aarch64). Deploy from `multithreaded/`: |
| 207 |
|
| 208 |
```bash |
| 209 |
./deploy/deploy.sh # rsync source + shared deps, build on Astra, deploy binary + assets |
| 210 |
``` |
| 211 |
|
| 212 |
The deploy script: |
| 213 |
1. Rsyncs source to `~/src/multithreaded/` on Astra |
| 214 |
2. Rsyncs shared deps (docengine, tagtree, s3-storage) to `~/src/shared/` |
| 215 |
3. Builds release binary on Astra |
| 216 |
4. Copies binary + static + migrations to `/opt/multithreaded/` |
| 217 |
5. Restarts the systemd service |
| 218 |
|