feat: init 3016c43a
Steve · 2026-03-30 20:34 2 file(s) · +941 −0
darkmatter-css/SKILL.md (added) +286 −0
1 +
---
2 +
name: darkmatter-css
3 +
description: Use when building any web UI for Steve - applies his personal dark aesthetic with Commit Mono font, #121113 background, white borders, minimal layout, and max-width centered content. Use for new pages, components, or when asked to match his existing style.
4 +
---
5 +
6 +
# Darkmatter CSS
7 +
8 +
## Overview
9 +
10 +
Steve's personal web aesthetic: dark, minimal, monospace. No frameworks, no decorative flourishes. Everything is functional and stark.
11 +
12 +
## Core Palette
13 +
14 +
| Token | Value | Usage |
15 +
|-------|-------|-------|
16 +
| Background | `#121113` | All surfaces — html, inputs, buttons, textarea |
17 +
| Foreground | `#ffffff` | All text and borders |
18 +
| Border | `1px solid white` | Inputs, buttons, textarea |
19 +
| Gray Dark | `#1e1c1f` | Code block backgrounds |
20 +
| Gray Mid | `#333` | Dividers, list item borders, section separators |
21 +
| Gray Light | `#555` | Tertiary borders (blockquote borders) |
22 +
23 +
**No accent colors, no gradients.** Background, white, and grays only.
24 +
25 +
### Visual Hierarchy via Opacity (NOT gray color values)
26 +
27 +
Use opacity on white text instead of gray hex colors for secondary/tertiary text:
28 +
29 +
| Level | Opacity | Usage |
30 +
|-------|---------|-------|
31 +
| Primary | 1.0 | Headings, body text, links |
32 +
| Secondary | 0.7 | Labels, form labels, blockquotes |
33 +
| Tertiary | 0.5 | Nav links dimmed, table headers, dates, metadata, empty states |
34 +
| Muted | 0.3 | Null/placeholder values |
35 +
| Error | 0.8 | Error messages |
36 +
37 +
**Do NOT use `color: #888` for secondary text.** Always use `opacity` on white text instead.
38 +
39 +
## Typography
40 +
41 +
- **Font:** `"Commit Mono"` (self-hosted .otf), fallback `monospace, sans-serif`
42 +
- Applied globally via `* { font-family: ... }`
43 +
- **Body font-size:** 14px
44 +
- **Line-height:** 1.6
45 +
46 +
### Font Size Scale
47 +
48 +
| Size | Usage |
49 +
|------|-------|
50 +
| 28px | Site logo/title (bold, uppercase) |
51 +
| 18px | Markdown h1 |
52 +
| 16px | Markdown h2, note/item titles, primary labels |
53 +
| 15px | Markdown h3 |
54 +
| 14px | Body text, inputs, buttons, markdown h4-h6 |
55 +
| 13px | Inline code, error messages |
56 +
| 12px | Nav links, form labels, metadata, dates, table headers, action links |
57 +
58 +
### Font Face Declarations
59 +
60 +
```css
61 +
@font-face {
62 +
  font-family: "Commit Mono";
63 +
  src: url("./assets/fonts/CommitMono-400-Regular.otf") format("opentype");
64 +
  font-weight: 400;
65 +
  font-style: normal;
66 +
}
67 +
68 +
@font-face {
69 +
  font-family: "Commit Mono";
70 +
  src: url("./assets/fonts/CommitMono-700-Regular.otf") format("opentype");
71 +
  font-weight: 700;
72 +
  font-style: normal;
73 +
}
74 +
```
75 +
76 +
## Base Reset
77 +
78 +
```css
79 +
* {
80 +
  padding: 0;
81 +
  margin: 0;
82 +
  box-sizing: border-box;
83 +
  font-family: "Commit Mono", monospace, sans-serif;
84 +
  scrollbar-width: none;
85 +
  -ms-overflow-style: none;
86 +
}
87 +
88 +
html {
89 +
  background: #121113;
90 +
  color: #ffffff;
91 +
  font-size: 14px;
92 +
  line-height: 1.6;
93 +
}
94 +
95 +
html::-webkit-scrollbar {
96 +
  display: none;
97 +
}
98 +
```
99 +
100 +
## Layout
101 +
102 +
Single-column, centered, max 700px wide. No top body padding — top spacing comes from header `margin-top`:
103 +
104 +
```css
105 +
body {
106 +
  display: flex;
107 +
  flex-direction: column;
108 +
  justify-content: start;
109 +
  align-items: start;
110 +
  gap: 1.5rem;
111 +
  min-height: 100vh;
112 +
  max-width: 700px;
113 +
  margin: auto;
114 +
  padding: 0 1rem;
115 +
}
116 +
117 +
@media (max-width: 480px) {
118 +
  body {
119 +
    padding: 1rem;
120 +
    gap: 1rem;
121 +
  }
122 +
}
123 +
```
124 +
125 +
## Header
126 +
127 +
The header uses a border-bottom separator and `margin-top: 2rem` for top spacing. The site title/logo is **always uppercase**, 28px bold:
128 +
129 +
```css
130 +
.header {
131 +
  display: flex;
132 +
  flex-direction: column;
133 +
  gap: 0.5rem;
134 +
  width: 100%;
135 +
  margin-top: 2rem;
136 +
  border-bottom: 1px solid #333;
137 +
  padding-bottom: 1rem;
138 +
}
139 +
140 +
.logo {
141 +
  font-size: 28px;
142 +
  font-weight: 700;
143 +
  text-decoration: none;
144 +
  text-transform: uppercase;
145 +
}
146 +
```
147 +
148 +
## Navigation Links
149 +
150 +
Compact gap, small font:
151 +
152 +
```css
153 +
.links {
154 +
  display: flex;
155 +
  align-items: center;
156 +
  gap: 0.75rem;
157 +
  font-size: 12px;
158 +
}
159 +
```
160 +
161 +
## Interactive Elements
162 +
163 +
All inputs, textareas, and buttons match the background — they blend into the surface with only a white border. **No border-radius**, padding uses `0.4rem 0.75rem`:
164 +
165 +
```css
166 +
input, textarea {
167 +
  background: #121113;
168 +
  color: #ffffff;
169 +
  border: 1px solid white;
170 +
  padding: 0.4rem 0.75rem;
171 +
  font-size: 14px;
172 +
  width: 100%;
173 +
  border-radius: 0;
174 +
}
175 +
176 +
textarea {
177 +
  min-height: 400px;
178 +
  resize: vertical;
179 +
}
180 +
181 +
button {
182 +
  background: #121113;
183 +
  color: #ffffff;
184 +
  padding: 0.4rem 0.75rem;
185 +
  border: 1px solid white;
186 +
  cursor: pointer;
187 +
  width: fit-content;
188 +
  font-size: 14px;
189 +
  border-radius: 0;
190 +
}
191 +
192 +
button:hover, a:hover {
193 +
  opacity: 0.7;
194 +
}
195 +
196 +
a {
197 +
  color: #ffffff;
198 +
  text-decoration: none;
199 +
}
200 +
```
201 +
202 +
## Labels
203 +
204 +
```css
205 +
label {
206 +
  font-size: 12px;
207 +
  opacity: 0.7;
208 +
}
209 +
```
210 +
211 +
## Errors
212 +
213 +
Use a left border accent, not a full box border:
214 +
215 +
```css
216 +
.error {
217 +
  color: #ffffff;
218 +
  border-left: 2px solid #ffffff;
219 +
  padding-left: 0.5rem;
220 +
  font-size: 13px;
221 +
  opacity: 0.8;
222 +
}
223 +
```
224 +
225 +
## List Items
226 +
227 +
Vertical stacking with bottom borders as dividers, 16px title size:
228 +
229 +
```css
230 +
.item-list {
231 +
  display: flex;
232 +
  flex-direction: column;
233 +
  width: 100%;
234 +
}
235 +
236 +
.item {
237 +
  display: flex;
238 +
  flex-direction: column;
239 +
  gap: 0.25rem;
240 +
  padding: 0.75rem 0;
241 +
  border-bottom: 1px solid #333;
242 +
}
243 +
244 +
.item:hover {
245 +
  opacity: 0.7;
246 +
}
247 +
248 +
.item-title {
249 +
  font-size: 16px;
250 +
}
251 +
252 +
.item-meta {
253 +
  font-size: 12px;
254 +
  opacity: 0.5;
255 +
}
256 +
```
257 +
258 +
## Table Headers
259 +
260 +
Uppercase, dimmed, lightweight:
261 +
262 +
```css
263 +
th {
264 +
  opacity: 0.5;
265 +
  font-weight: 400;
266 +
  font-size: 12px;
267 +
  text-transform: uppercase;
268 +
}
269 +
```
270 +
271 +
## Meta Tags
272 +
273 +
Always include:
274 +
```html
275 +
<meta name="theme-color" content="#121113" />
276 +
```
277 +
278 +
## What NOT to Do
279 +
280 +
- No `border-radius` — keep all corners sharp (explicitly set `border-radius: 0` on inputs/buttons)
281 +
- No box shadows or drop shadows
282 +
- No color other than `#121113`, `#ffffff`, and the gray tones (`#1e1c1f`, `#333`, `#555`)
283 +
- **No `color: #888`** — use `opacity` on white text for visual hierarchy instead
284 +
- No external font CDNs — fonts are self-hosted
285 +
- No utility frameworks (no Tailwind, no Bootstrap)
286 +
- No decorative elements, icons, or emojis
rust-crud/SKILL.md (added) +655 −0
1 +
---
2 +
name: rust-crud
3 +
description: Scaffold a Rust CRUD web application with Axum, SQLite, Askama templates, API key auth, embedded static assets, and Docker deployment. Use when the user wants to build a new Rust web server with CRUD operations.
4 +
---
5 +
6 +
# Rust CRUD Web App
7 +
8 +
## Overview
9 +
10 +
Scaffold and build Rust CRUD web applications using Axum + SQLite + Askama templates. The result is a single binary web server with HTML pages, a JSON API, optional API key auth, and Docker deployment.
11 +
12 +
## Project Structure
13 +
14 +
```
15 +
project-name/
16 +
├── src/
17 +
│   ├── main.rs         # Entry point, starts the server
18 +
│   ├── server.rs       # Axum routes, middleware, handlers, static asset serving
19 +
│   ├── db.rs           # SQLite schema, CRUD functions, error types
20 +
│   └── auth.rs         # Session/cookie auth (when using login-based auth)
21 +
├── templates/          # Askama HTML templates
22 +
│   ├── base.html       # Base layout with blocks (title, content)
23 +
│   └── *.html          # Pages extend base.html
24 +
├── static/             # CSS, fonts, favicons (embedded via rust_embed or served via tower-http)
25 +
│   └── styles.css
26 +
├── assets/             # Favicons, fonts, images (embedded via rust_embed)
27 +
├── .env.example        # Environment variable reference
28 +
├── Dockerfile          # Multi-stage build
29 +
└── docker-compose.yml  # Compose config with volume for DB persistence
30 +
```
31 +
32 +
## Dependencies (Cargo.toml)
33 +
34 +
Use these exact crates and features:
35 +
36 +
```toml
37 +
[dependencies]
38 +
axum = "0.8"
39 +
tokio = { version = "1", features = ["full"] }
40 +
askama = "0.15"
41 +
askama_web = { version = "0.15", features = ["axum-0.8"] }
42 +
rusqlite = { version = "0.38", features = ["bundled"] }
43 +
serde = { version = "1", features = ["derive"] }
44 +
serde_json = "1"
45 +
nanoid = "0.4.0"
46 +
rust-embed = "8"
47 +
dotenvy = "0.15"
48 +
subtle = "2"
49 +
tracing = "0.1"
50 +
tracing-subscriber = "0.3"
51 +
rand = "0.8"
52 +
tower-http = { version = "0.6", features = ["fs"] }
53 +
```
54 +
55 +
Only add additional crates when the specific app requires them. Do NOT include TUI crates (ratatui, crossterm), CLI crates (clap), or HTTP client crates (reqwest) unless explicitly requested.
56 +
57 +
- `tracing` + `tracing-subscriber` — structured logging, always include
58 +
- `rand` — needed for session token generation when using session-based auth
59 +
- `tower-http` — alternative to `rust-embed` for serving static files from disk (simpler during development, no recompile on asset changes)
60 +
61 +
## Database Layer (db.rs)
62 +
63 +
Pattern: single-file module with `Arc<Mutex<Connection>>` for thread-safe SQLite access.
64 +
65 +
### Structure
66 +
67 +
```rust
68 +
use nanoid::nanoid;
69 +
use rusqlite::{Connection, params};
70 +
use serde::{Deserialize, Serialize};
71 +
use std::fmt;
72 +
use std::sync::{Arc, Mutex};
73 +
74 +
pub type Db = Arc<Mutex<Connection>>;
75 +
76 +
#[derive(Debug)]
77 +
pub enum DbError {
78 +
    Sqlite(rusqlite::Error),
79 +
    LockPoisoned,
80 +
}
81 +
82 +
impl fmt::Display for DbError {
83 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 +
        match self {
85 +
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
86 +
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
87 +
        }
88 +
    }
89 +
}
90 +
91 +
impl std::error::Error for DbError {}
92 +
93 +
impl From<rusqlite::Error> for DbError {
94 +
    fn from(e: rusqlite::Error) -> Self {
95 +
        DbError::Sqlite(e)
96 +
    }
97 +
}
98 +
```
99 +
100 +
### Key patterns
101 +
102 +
- **Model struct**: derive `Serialize, Deserialize`, all fields `pub`
103 +
- **ID generation**: `nanoid!(10)` for short unique IDs
104 +
- **DB path from env**: `std::env::var("APP_DB_PATH").unwrap_or_else(|_| "app.sqlite".to_string())`
105 +
- **init_db()**: opens connection, runs `CREATE TABLE IF NOT EXISTS`, returns `Arc<Mutex<Connection>>`
106 +
- **CRUD functions**: standalone functions that take `&Db` as first param
107 +
  - `create_*` — INSERT, return created model with `last_insert_rowid()`
108 +
  - `get_*_by_short_id` — SELECT, return `Result<Option<Model>, DbError>`
109 +
  - `get_all_*` — SELECT with ORDER BY id DESC
110 +
  - `delete_*_by_short_id` — DELETE, return `Result<bool, DbError>` (rows_affected > 0)
111 +
  - `update_*_by_short_id` — UPDATE then SELECT to return updated model
112 +
- **Error handling**: `QueryReturnedNoRows` maps to `Ok(None)`, not an error
113 +
114 +
## Server Layer (server.rs)
115 +
116 +
### Embedded assets with rust_embed
117 +
118 +
```rust
119 +
use rust_embed::Embed;
120 +
121 +
#[derive(Embed)]
122 +
#[folder = "assets/"]
123 +
struct Assets;
124 +
125 +
#[derive(Embed)]
126 +
#[folder = "static/"]
127 +
struct Static;
128 +
```
129 +
130 +
Serve with handlers that match on file path and return correct MIME types:
131 +
132 +
```rust
133 +
fn mime_from_path(path: &str) -> &'static str {
134 +
    match path.rsplit('.').next().unwrap_or("") {
135 +
        "css" => "text/css",
136 +
        "js" => "application/javascript",
137 +
        "html" => "text/html",
138 +
        "png" => "image/png",
139 +
        "ico" => "image/x-icon",
140 +
        "svg" => "image/svg+xml",
141 +
        "woff" | "woff2" => "font/woff2",
142 +
        "ttf" => "font/ttf",
143 +
        "otf" => "font/otf",
144 +
        "json" | "webmanifest" => "application/json",
145 +
        _ => "application/octet-stream",
146 +
    }
147 +
}
148 +
```
149 +
150 +
### App state
151 +
152 +
```rust
153 +
#[derive(Clone)]
154 +
struct AppState {
155 +
    db: Db,
156 +
    server_config: ServerConfig,
157 +
}
158 +
```
159 +
160 +
Add domain-specific fields as needed (e.g., a highlighter, cache, etc).
161 +
162 +
### Askama templates
163 +
164 +
```rust
165 +
use askama::Template;
166 +
use askama_web::WebTemplate;
167 +
168 +
#[derive(Template)]
169 +
#[template(path = "index.html")]
170 +
struct IndexTemplate;
171 +
172 +
// Templates with data:
173 +
#[derive(Template)]
174 +
#[template(path = "item.html")]
175 +
struct ItemTemplate {
176 +
    name: String,
177 +
    content: String,
178 +
}
179 +
```
180 +
181 +
Render with `WebTemplate(MyTemplate { ... })`.
182 +
183 +
### Route structure
184 +
185 +
Two sets of routes: **web routes** (HTML pages + form submissions) and **API routes** (JSON).
186 +
187 +
**Web routes:**
188 +
- `GET /` — index page (template)
189 +
- `GET /admin` — admin panel (template)
190 +
- `POST /items` — form submission, redirects on success
191 +
- `GET /items/{short_id}` — view single item (template)
192 +
193 +
**API routes:**
194 +
- `GET /api/items` — list all (JSON)
195 +
- `POST /api/items` — create (JSON body → 201 + JSON)
196 +
- `GET /api/items/{short_id}` — get one (JSON)
197 +
- `PUT /api/items/{short_id}` — update (JSON body → JSON)
198 +
- `DELETE /api/items/{short_id}` — delete (JSON)
199 +
200 +
**Static asset routes:**
201 +
- `GET /assets/{*path}` — embedded assets (favicons, fonts, images)
202 +
- `GET /static/{*path}` — embedded static files (CSS)
203 +
204 +
### Form deserialization
205 +
206 +
```rust
207 +
#[derive(Deserialize)]
208 +
struct CreateItemForm {
209 +
    name: String,
210 +
    content: String,
211 +
}
212 +
```
213 +
214 +
Use `Form(form): Form<CreateItemForm>` for HTML forms, `Json(body): Json<CreateItem>` for API.
215 +
216 +
### Error responses
217 +
218 +
- Web handlers return `Result<..., (StatusCode, Html<String>)>`
219 +
- API handlers return `Result<..., (StatusCode, Json<serde_json::Value>)>`
220 +
- Use `serde_json::json!({"error": "message"})` for API error bodies
221 +
222 +
## Authentication
223 +
224 +
### API key auth middleware
225 +
226 +
Configurable per-endpoint authentication using an API key in the `x-api-key` header. Uses constant-time comparison via the `subtle` crate.
227 +
228 +
```rust
229 +
#[derive(Clone)]
230 +
struct ServerConfig {
231 +
    api_key: Option<String>,
232 +
    auth_endpoints: HashSet<String>,
233 +
    max_content_size: usize,
234 +
}
235 +
236 +
impl ServerConfig {
237 +
    fn from_env() -> Self {
238 +
        let api_key = std::env::var("APP_API_KEY").ok();
239 +
        let auth_endpoints = match std::env::var("APP_AUTH_ENDPOINTS") {
240 +
            Ok(val) if val.trim().eq_ignore_ascii_case("none") => HashSet::new(),
241 +
            Ok(val) => val.split(',').map(|s| s.trim().to_lowercase()).collect(),
242 +
            Err(_) => ["api_delete", "api_list", "api_update"]
243 +
                .iter().map(|s| s.to_string()).collect(),
244 +
        };
245 +
        let max_content_size = std::env::var("APP_MAX_CONTENT_SIZE")
246 +
            .ok()
247 +
            .and_then(|v| v.parse().ok())
248 +
            .unwrap_or(512_000);
249 +
        ServerConfig { api_key, auth_endpoints, max_content_size }
250 +
    }
251 +
252 +
    fn requires_auth(&self, name: &str) -> bool {
253 +
        self.auth_endpoints.contains("all") || self.auth_endpoints.contains(name)
254 +
    }
255 +
}
256 +
```
257 +
258 +
### Auth middleware function
259 +
260 +
```rust
261 +
async fn require_api_key(
262 +
    State(state): State<AppState>,
263 +
    headers: HeaderMap,
264 +
    request: Request,
265 +
    next: Next,
266 +
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
267 +
    let server_key = match &state.server_config.api_key {
268 +
        Some(k) => k,
269 +
        None => return Err((
270 +
            StatusCode::FORBIDDEN,
271 +
            Json(serde_json::json!({"error": "No API key configured on server"})),
272 +
        )),
273 +
    };
274 +
    let provided = headers.get("x-api-key").and_then(|v| v.to_str().ok());
275 +
    match provided {
276 +
        Some(k) if k.as_bytes().ct_eq(server_key.as_bytes()).into() => {
277 +
            Ok(next.run(request).await)
278 +
        }
279 +
        _ => Err((
280 +
            StatusCode::UNAUTHORIZED,
281 +
            Json(serde_json::json!({"error": "Invalid or missing API key"})),
282 +
        )),
283 +
    }
284 +
}
285 +
```
286 +
287 +
### Dynamic route building with selective auth
288 +
289 +
Build routes dynamically based on which endpoints require auth. Authed routes get the middleware layer; open routes don't.
290 +
291 +
```rust
292 +
fn build_api_routes(state: &AppState) -> Router<AppState> {
293 +
    let config = &state.server_config;
294 +
    let auth_layer = middleware::from_fn_with_state(state.clone(), require_api_key);
295 +
296 +
    let mut authed = Router::new();
297 +
    let mut open = Router::new();
298 +
299 +
    // For each endpoint, add to authed or open router based on config
300 +
    if config.requires_auth("api_list") {
301 +
        authed = authed.route("/api/items", get(api_list));
302 +
    } else {
303 +
        open = open.route("/api/items", get(api_list));
304 +
    }
305 +
    // ... repeat for each endpoint
306 +
307 +
    let authed = authed.route_layer(auth_layer);
308 +
    authed.merge(open)
309 +
}
310 +
```
311 +
312 +
### Session/cookie auth (for web-facing apps)
313 +
314 +
When the app needs a login page instead of API key auth (e.g., personal dashboards), use session-based authentication with a custom Axum extractor. Create a separate `auth.rs` module.
315 +
316 +
**Sessions table in db.rs:**
317 +
318 +
```sql
319 +
CREATE TABLE IF NOT EXISTS sessions (
320 +
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
321 +
    token      TEXT NOT NULL UNIQUE,
322 +
    expires_at TEXT NOT NULL
323 +
);
324 +
```
325 +
326 +
**Session DB functions in db.rs:**
327 +
328 +
```rust
329 +
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
330 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
331 +
    conn.execute("INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", params![token, expires_at])?;
332 +
    Ok(())
333 +
}
334 +
335 +
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
336 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
337 +
    match conn.query_row("SELECT expires_at FROM sessions WHERE token = ?1", params![token], |row| row.get(0)) {
338 +
        Ok(val) => Ok(Some(val)),
339 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
340 +
        Err(e) => Err(DbError::Sqlite(e)),
341 +
    }
342 +
}
343 +
344 +
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
345 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
346 +
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
347 +
    Ok(())
348 +
}
349 +
350 +
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
351 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
352 +
    conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')", [])?;
353 +
    Ok(())
354 +
}
355 +
```
356 +
357 +
**auth.rs module:**
358 +
359 +
```rust
360 +
use axum::{
361 +
    extract::{FromRef, FromRequestParts},
362 +
    http::request::Parts,
363 +
    response::{IntoResponse, Redirect, Response},
364 +
};
365 +
use rand::RngCore;
366 +
use subtle::ConstantTimeEq;
367 +
368 +
use crate::AppState;
369 +
370 +
/// Constant-time password comparison with fixed-length buffers.
371 +
pub fn verify_password(input: &str, expected: &str) -> bool {
372 +
    const LEN: usize = 256;
373 +
    let mut a = [0u8; LEN];
374 +
    let mut b = [0u8; LEN];
375 +
    let ib = input.as_bytes();
376 +
    let eb = expected.as_bytes();
377 +
    a[..ib.len().min(LEN)].copy_from_slice(&ib[..ib.len().min(LEN)]);
378 +
    b[..eb.len().min(LEN)].copy_from_slice(&eb[..eb.len().min(LEN)]);
379 +
    let lengths_match = subtle::Choice::from((ib.len() == eb.len()) as u8);
380 +
    (lengths_match & a.ct_eq(&b)).into()
381 +
}
382 +
383 +
/// Generate a 32-byte cryptographically random hex token.
384 +
pub fn generate_session_token() -> String {
385 +
    let mut bytes = [0u8; 32];
386 +
    rand::rngs::OsRng.fill_bytes(&mut bytes);
387 +
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
388 +
}
389 +
390 +
/// Build a session cookie string.
391 +
pub fn build_session_cookie(token: &str, secure: bool) -> String {
392 +
    let mut cookie = format!("session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800", token);
393 +
    if secure { cookie.push_str("; Secure"); }
394 +
    cookie
395 +
}
396 +
397 +
pub fn clear_session_cookie() -> String {
398 +
    "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0".to_string()
399 +
}
400 +
401 +
/// Axum extractor — guards routes behind login. Redirects to /login if invalid.
402 +
pub struct AuthSession;
403 +
404 +
impl<S> FromRequestParts<S> for AuthSession
405 +
where
406 +
    S: Send + Sync,
407 +
    Arc<AppState>: FromRef<S>,
408 +
{
409 +
    type Rejection = Response;
410 +
411 +
    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
412 +
        let state = Arc::<AppState>::from_ref(state);
413 +
        let token = extract_session_cookie(&parts.headers);
414 +
        if let Some(token) = token {
415 +
            if is_valid_session(&state, &token) {
416 +
                return Ok(AuthSession);
417 +
            }
418 +
        }
419 +
        Err(Redirect::to("/login").into_response())
420 +
    }
421 +
}
422 +
423 +
fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
424 +
    let cookie_header = headers.get("cookie")?.to_str().ok()?;
425 +
    for part in cookie_header.split(';') {
426 +
        let part = part.trim();
427 +
        if let Some(val) = part.strip_prefix("session=") {
428 +
            let val = val.trim().to_string();
429 +
            if !val.is_empty() { return Some(val); }
430 +
        }
431 +
    }
432 +
    None
433 +
}
434 +
```
435 +
436 +
**Usage in handlers** — just add `_session: auth::AuthSession` as a parameter to protect a route:
437 +
438 +
```rust
439 +
async fn get_index(_session: auth::AuthSession, State(state): State<Arc<AppState>>) -> Response {
440 +
    // only reachable if session is valid
441 +
}
442 +
```
443 +
444 +
**Login/logout routes:**
445 +
446 +
```rust
447 +
// GET /login — render login form
448 +
// POST /login — verify password, create session, set cookie, redirect to /
449 +
// GET /logout — delete session from DB, clear cookie, redirect to /login
450 +
```
451 +
452 +
**AppState for session auth:**
453 +
454 +
```rust
455 +
pub struct AppState {
456 +
    pub db: Db,
457 +
    pub app_password: String,
458 +
    pub cookie_secure: bool,
459 +
}
460 +
```
461 +
462 +
### Environment variables
463 +
464 +
Prefix all env vars with the app name (e.g., `MYAPP_`):
465 +
466 +
| Variable | Purpose | Default |
467 +
|----------|---------|---------|
468 +
| `APP_API_KEY` | API key for auth | None (auth disabled) |
469 +
| `APP_AUTH_ENDPOINTS` | Comma-separated endpoint names, "all", or "none" | `api_delete,api_list,api_update` |
470 +
| `APP_MAX_CONTENT_SIZE` | Max request body size in bytes | `512000` |
471 +
| `APP_DB_PATH` | SQLite file path | `app.sqlite` |
472 +
| `APP_PASSWORD` | Single password for session auth (web apps) | None |
473 +
| `COOKIE_SECURE` | Set `true` for HTTPS-only cookies | `false` |
474 +
475 +
## Templates (Askama)
476 +
477 +
HTML templates live in `templates/` and use Askama syntax. Key patterns:
478 +
479 +
- Link CSS via `/static/styles.css`
480 +
- Link assets via `/assets/filename`
481 +
- Include `<meta name="theme-color" content="#121113" />`
482 +
- Forms POST to web routes (not API routes)
483 +
- Use `{{ variable }}` for template interpolation
484 +
- Use `{{ variable|safe }}` for pre-rendered HTML (e.g., syntax highlighted content)
485 +
486 +
### Template inheritance
487 +
488 +
Use a `base.html` with block sections. All pages extend it:
489 +
490 +
**templates/base.html:**
491 +
```html
492 +
<!DOCTYPE html>
493 +
<html lang="en">
494 +
<head>
495 +
  <meta charset="UTF-8">
496 +
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
497 +
  <title>{% block title %}APP_NAME{% endblock %}</title>
498 +
  <meta name="theme-color" content="#121113" />
499 +
  <style>
500 +
    /* base styles here */
501 +
  </style>
502 +
</head>
503 +
<body>
504 +
  <div class="container">
505 +
    {% block content %}{% endblock %}
506 +
  </div>
507 +
</body>
508 +
</html>
509 +
```
510 +
511 +
**templates/index.html:**
512 +
```html
513 +
{% extends "base.html" %}
514 +
{% block title %}Items{% endblock %}
515 +
{% block content %}
516 +
  {% if let Some(error) = error %}
517 +
    <p class="error">{{ error }}</p>
518 +
  {% endif %}
519 +
  {% for item in items %}
520 +
    <div>{{ item.name }}</div>
521 +
  {% endfor %}
522 +
{% endblock %}
523 +
```
524 +
525 +
### Flash messages via query params
526 +
527 +
Pass transient error/success messages through redirects using query parameters. No session flash needed.
528 +
529 +
**Query param struct:**
530 +
```rust
531 +
#[derive(Deserialize, Default)]
532 +
pub struct FlashQuery {
533 +
    pub error: Option<String>,
534 +
}
535 +
```
536 +
537 +
**In handlers** — redirect with message:
538 +
```rust
539 +
Redirect::to("/items/add?error=Name+is+required.").into_response()
540 +
```
541 +
542 +
**In receiving handler** — extract and pass to template:
543 +
```rust
544 +
async fn get_add(Query(q): Query<FlashQuery>) -> Response {
545 +
    render(AddTemplate { error: q.error })
546 +
}
547 +
```
548 +
549 +
**In template** — conditionally render:
550 +
```html
551 +
{% if let Some(error) = error %}
552 +
  <p class="error">{{ error }}</p>
553 +
{% endif %}
554 +
```
555 +
556 +
## Logging (tracing)
557 +
558 +
Always initialize tracing in `main()` before anything else:
559 +
560 +
```rust
561 +
tracing_subscriber::fmt::init();
562 +
```
563 +
564 +
Use throughout the app:
565 +
- `tracing::error!("DB error: {}", e)` — unrecoverable failures
566 +
- `tracing::warn!("Non-critical issue: {}", e)` — degraded but functional
567 +
- `tracing::info!("Listening on {}", addr)` — startup/lifecycle events
568 +
569 +
## main.rs
570 +
571 +
Minimal — just starts the server:
572 +
573 +
```rust
574 +
mod db;
575 +
mod server;
576 +
577 +
#[tokio::main]
578 +
async fn main() {
579 +
    tracing_subscriber::fmt::init();
580 +
    let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
581 +
    let port: u16 = std::env::var("PORT")
582 +
        .ok()
583 +
        .and_then(|v| v.parse().ok())
584 +
        .unwrap_or(3000);
585 +
    server::run(host, port).await;
586 +
}
587 +
```
588 +
589 +
If the app needs CLI arguments (e.g., `--port`, `--host`), add `clap` and a simple arg struct. But default to env vars and keep main.rs minimal.
590 +
591 +
## Dockerfile
592 +
593 +
Multi-stage build:
594 +
595 +
```dockerfile
596 +
FROM rust:1-slim-bookworm AS builder
597 +
WORKDIR /app
598 +
COPY . .
599 +
RUN cargo build --release
600 +
601 +
FROM debian:bookworm-slim
602 +
COPY --from=builder /app/target/release/APP_NAME /usr/local/bin/APP_NAME
603 +
WORKDIR /data
604 +
EXPOSE 3000
605 +
CMD ["APP_NAME", "--port", "3000", "--host", "0.0.0.0"]
606 +
```
607 +
608 +
Replace `APP_NAME` with the actual binary name.
609 +
610 +
## docker-compose.yml
611 +
612 +
```yaml
613 +
services:
614 +
  app:
615 +
    build: .
616 +
    ports:
617 +
      - "3000:3000"
618 +
    environment:
619 +
      - APP_API_KEY=${APP_API_KEY:-changeme}
620 +
      - APP_AUTH_ENDPOINTS=api_delete,api_list
621 +
    volumes:
622 +
      - app-data:/data
623 +
    restart: unless-stopped
624 +
625 +
volumes:
626 +
  app-data:
627 +
```
628 +
629 +
Key: use a named volume to persist the SQLite database across container restarts.
630 +
631 +
## .env.example
632 +
633 +
Always create one with all configurable env vars and sensible comments.
634 +
635 +
## Checklist
636 +
637 +
When scaffolding a new app with this pattern:
638 +
639 +
1. Create project structure (`cargo init`, add directories)
640 +
2. Set up `Cargo.toml` with dependencies
641 +
3. Write `db.rs` — schema, model struct, CRUD functions
642 +
4. Write `server.rs` — config, state, templates, handlers, auth, routes
643 +
5. Write `main.rs` — minimal entry point
644 +
6. Create `templates/` with at least an index page
645 +
7. Create `static/styles.css`
646 +
8. Create `.env.example`
647 +
9. Create `Dockerfile` and `docker-compose.yml`
648 +
10. Test: `cargo run`, verify routes work
649 +
650 +
## What NOT to include
651 +
652 +
- No external CSS frameworks unless specified 
653 +
- No ORMs — use raw rusqlite
654 +
- No connection pools — `Arc<Mutex<Connection>>` is sufficient for SQLite
655 +
- No async database drivers — rusqlite is synchronous and that's fine