Peisear

A minimal, self-hostable issue management system written in Rust (Edition 2024).
- Sophisticated — Typed domain model, server-side rendering, robust error handling with
IntoResponse, argon2id password hashing, JWT sessions. - Solid —
sqlxparameterized queries (no string concatenation, no injection),CHECKconstraints on enum columns, foreign keys withON DELETE CASCADE, WAL-mode SQLite for concurrent reads. - Really Easy — Single binary + a single
.dbfile. No Node.js toolchain, no external services. Backups are justcp app.db backup.db. - Good UI/UX — Tailwind + daisyUI, board/list toggle, drag-and-drop kanban, mobile-responsive layout.
Stack
| Layer | Choice |
|---|---|
| Language | Rust 1.85+ (Edition 2024) |
| Web | axum 0.8 |
| Async | tokio |
| Storage | sqlx 0.8 + SQLite (WAL, FKs on) |
| Templates | askama 0.14 (compile-time, auto-escaping) + askama_web (axum-0.8 integration) |
| Styling | Tailwind CSS + daisyUI (via CDN by default) |
| Auth | jsonwebtoken 9, argon2 0.5, HTTP-only cookies |
| Validation | validator 0.18 |
A note on Leptos
The original specification called for leptos with SSR + hydration. The full-stack Leptos build requires a wasm32-unknown-unknown Rust target. On systems where Rust is installed via rustup that is a single command (rustup target add wasm32-unknown-unknown), but the apt-packaged Rust 1.91 used during initial development on this codebase does not ship that target. Rather than block on toolchain issues, this implementation renders HTML server-side with askama (compile-time templates, identical XSS protection, same axum handlers) and does a small amount of browser-side interactivity in vanilla JS for the kanban drag-and-drop. The architecture — typed models, AppError: IntoResponse, FromRequestParts extractors, the split of handlers/ vs db/ vs views — is the same one a Leptos SSR app would want, so migration later is a layer swap rather than a rewrite. See Migrating to Leptos at the bottom.
Project layout
peisear/
├── Cargo.toml
├── Cargo.lock
├── LICENSE-MIT
├── LICENSE-APACHE
├── README.md
├── .env.example
├── .gitignore
├── migrations/
│ └── 0001_initial.sql Users, projects, issues — FK + CHECK constraints
├── src/
│ ├── main.rs Axum server bootstrap + route table
│ ├── lib.rs Re-exports; `AppState { db, jwt_secret }`
│ ├── config.rs Environment loader
│ ├── error.rs `AppError` + `IntoResponse` (renders HTML or redirects)
│ ├── models.rs Domain types: `Issue`, `Project`, `IssueStatus`, `Priority`
│ ├── views.rs Askama template structs (view-models)
│ ├── auth.rs `AuthUser` / `MaybeAuthUser` extractors
│ ├── auth/
│ │ ├── jwt.rs JWT issue + verify (7-day TTL)
│ │ └── password.rs Argon2id hash + verify
│ ├── db.rs DB module root
│ ├── db/
│ │ ├── pool.rs WAL + FK pragmas, migrations
│ │ ├── users.rs
│ │ ├── projects.rs
│ │ └── issues.rs All queries parameterized via `?N` binds
│ ├── handlers.rs Shared validation-error formatter
│ └── handlers/
│ ├── root.rs Index redirect, /health
│ ├── auth.rs Register / login / logout (+ timing-attack mitigation)
│ ├── projects.rs Projects CRUD
│ └── issues.rs Issues CRUD + JSON status-change endpoint
├── templates/ Askama HTML templates
└── static/
└── app.css Tiny supplemental CSS
The source tree follows the Rust 2018+ module layout: a module foo is declared in src/foo.rs, and any submodules live in src/foo/. No mod.rs files.
Getting started
1. Install Rust
Any Rust with Edition 2024 support (1.85+). On Debian/Ubuntu 24.04:
Or with rustup (recommended when you need extra targets like wasm32):
|
2. Configure
# Generate a real JWT secret for production:
3. Build and run
Open http://localhost:3000. The SQLite file is created at ./data/app.db on first run, and migrations run automatically at startup.
4. Register and use
/register— create an account (8+ character password)/projects— create a project- Inside a project — create issues, toggle between Board (kanban) and List views, drag cards across columns to change status.
Configuration
All configuration is via environment variables (or .env):
| Variable | Default | Notes |
|---|---|---|
DATABASE_URL |
sqlite://data/app.db |
Parent directory is auto-created. |
JWT_SECRET |
insecure dev default (with a warning) | MUST be set to a long random string in production. |
BIND_ADDR |
0.0.0.0:3000 |
Use 127.0.0.1:3000 to bind to localhost only. |
COOKIE_SECURE |
0 |
Set to 1 when serving over HTTPS. |
RUST_LOG |
info,sqlx=warn,… |
Any tracing_subscriber env filter works. |
Security notes
- SQL injection — every query uses
?Nparameter binding throughsqlx. No string interpolation. - XSS — Askama escapes all
{{ expression }}interpolations by default. The one place we emit a raw UUID into JavaScript is boundary-safe because the source is always a freshly generated v4 UUID. - CSRF — state-changing routes are all
POSTand require theit_sessioncookie (not a bearer token), which the browser sends only from same-site requests whenSameSite=Laxis set (our default). - Password storage —
argon2idvia the officialargon2crate, default parameters (19 MiB, t=2, p=1). - Session cookie —
HttpOnly,SameSite=Lax, andSecurewhenCOOKIE_SECURE=1. TTL 7 days. - Timing attacks on login — if the email is not found we still run a dummy verification against a fixed hash so the response time is indistinguishable from a wrong-password case.
- Access control — all DB mutations scope by
(owner_id, project_id). Even if a handler misses a check, the query itself will return 0 rows.
Operations
Backup
# While the server is running WAL does concurrent readers; this is safe:
# Or just stop the server and cp:
Cross-compiling / single-binary deploy
Run cargo build --release and ship target/release/peisear + the templates/, migrations/, and static/ folders. Templates are compiled into the binary, so only migrations/ and static/ actually need to travel alongside. Sample systemd unit:
[Unit]
Description=Issue Tracker
After=network.target
[Service]
WorkingDirectory=/var/lib/peisear
ExecStart=/usr/local/bin/peisear
EnvironmentFile=/etc/peisear.env
Restart=on-failure
User=peisear
[Install]
WantedBy=multi-user.target
Shipping Tailwind locally instead of via CDN
The default templates/base.html references cdn.tailwindcss.com (Tailwind Play CDN) and a daisyUI CSS bundle. This is fine for self-hosted deployments where outbound HTTPS is available. To remove the CDN dependency entirely:
Then change the <link> / <script> tags in templates/base.html to point at /static/app.css.
Migrating to Leptos (future work)
The server-side layer is already shaped to accept it:
- Install the wasm target and
cargo-leptos: - Replace
askama = ...withleptos = { features = ["ssr"] }andleptos_axum. - Each template in
templates/*.htmlbecomes a Leptos component insrc/frontend/. The view-model structs insrc/views.rsbecome props. - Router is swapped from
axum::Routeralone toleptos_axum::generate_route_list+ LeptosRoutes. main.rs's state extraction remains the same; theAppState { db, jwt_secret }is passed as Leptos context.- Handlers in
src/handlers/become#[server]functions.
The DB, auth, and error layers carry over unchanged.
Roadmap
Lifted from the spec:
- Workload-fairness features: per-issue effort estimates, per-period capacity limits per assignee, project-health score, AI assistant per user.
- Pluggable backends (PostgreSQL via the same sqlx layer — the core queries are already portable).
- IdP / IDaaS integration (OIDC).
- CI/CD integration and IaC support.