Changelog
v2.1.17 — 2026/06/17
Fix makemigrations
[default: …] was still dropped on enum columns: the 2.1.15 fix made render_column_def emit .default(), but only on the non-enum branch — the ColumnType::Enum branch was rendered with {null}{uniq} and never appended {default}. A choice [enum(X)] column with a [default: "Y"] therefore lost its default, and a required (NOT NULL) enum column added to a populated table failed at migration time with column "…" contains null values. Fixed: the enum branch of render_column_def now appends {default} like every other column, so an ADD COLUMN NOT NULL DEFAULT '' lets Postgres backfill existing rows instead of erroring. The emission is engine-independent (Postgres/MySQL/SQLite); the Postgres-only CREATE TYPE gating is unchanged. 3 pipeline tests added (tests_pipeline.rs, now 14): the enum default reaches the parsed ParsedColumn, is emitted in CREATE across all three engines, and is emitted on the extend!{} ADD COLUMN path across all three engines (with CREATE TYPE still Postgres-only).
Fix Rich-text field
Rich-text field values were double-escaped in the admin detail/list views: a richtext field is sanitized at write time by ammonia, which normalizes a stray > in text to the entity > (valid HTML). The admin detail and list templates rendered the stored value through {{ value }} (auto-escaped), so the already-encoded > was escaped again to > and shown literally as > on screen. Fixed without weakening security, via output-side sanitization: the admin now knows which columns are rich (RICH_CONTENT_FIELDS, the same classification used on write, injected as rich_fields), and renders them through new filters instead of trusting storage.
Fix Plain-text
Plain-text fields silently deleted legitimate <, >, &: TextField::set_value ran value.replace(['<', '>', '&'], "") whenever the input contained < or >, so any non-rich field stored mangled data — one bug => fix became one bug = fix, R&D became RD, a < b became a b. The replacement also short-circuited sanitize_strict entirely. Fixed: non-rich fields now go through sanitize_strict alone, which already strips every tag (scripts included) via ammonia and decodes entities, leaving legitimate punctuation intact. XSS protection is unchanged — it was never that input mutation but the output-side auto-escaping (and sanitize_strict's tag stripping). Two tests added in sanitizer.rs: legitimate => / & survive,
Fix session DB backup
The DB session backup froze its expiry and logged users out after a restart: the CleaningMemoryStore is memory-first with a DB fallback (eihwaz_sessions) that lets authenticated sessions survive a server restart. save() persisted via update_session_data, which only wrote session_data and never refreshed expires_at. The row's expiry stayed frozen at whatever create() first wrote — and at login time that was still the 5-minute anonymous inactivity window, because the TTL-upgrade middleware only promotes the session to the authenticated duration from the next request. Result: a few minutes after login the DB row looked expired (find_by_cookie_id filters expires_at > now), so any restart logged the user out, with no error to explain it. Fixed on three fronts: (1) a single persist_to_db(record) is now the only DB write path for both create() and save() — it writes the whole snapshot (cookie_id, user_id, expiry, data) via upsert_session, so a partial/stale backup is impossible by construction (the now-unused update_session_data was removed); (2) login() promotes the session TTL to the authenticated duration on the login request itself, so the first persisted row already carries the long expiry; (3) the DB read in load() and the backup write now log their errors instead of swallowing them (if let Ok(...) / .ok()), so a future schema/connection problem is visible instead of silently killing the fallback. Three regression tests added in tests/middleware/test_session_db.rs, including a second upsert_session that must move expires_at forward rather than leave it frozen
Fix password reset: DB-persisted, hashed, IDOR-hardened tokens
Reset tokens are persisted in the database, survive a restart, and work across instances. The forgot/reset-password flow kept its tokens in process memory (LazyLock>), so any redeploy between sending the email and clicking the link killed the link, and the flow broke under horizontal scaling. Tokens now live in a new framework table eihwaz_reset_tokens (EihwazResetTokensMigration, auto-injected into the project Migrator by the makemigrations CLI like the sessions table, and excluded from the user-model scan). Hardening: the table stores the token's SHA-256 hash, never the raw token, so a DB read leak cannot be replayed; single-use is atomic (the row is deleted — only the delete that affects one row wins); requesting a new token drops the user's previous ones. The lifetime is configurable via PasswordResetConfig::token_ttl(Duration) (default 1 hour). encrypt_email/decrypt_email (email never cleartext in the URL) are unchanged.
Fix IDOR hardening of the reset mutation
The password update now keys on the user_id carried by the (secret) token row — resolved server-side — instead of the email field coming from the URL/form. A new UserEntity::update_password_by_id (default impl, non-breaking; overridden by the built-in user for a single query) performs the update; the URL email is only a UX cross-check against the token-bound user.
Fix tracing: security-critical sites no longer silent by default
A handful of swallowed-Result sites that break a guarantee now log at WARN even when their tracing category is off. A new TraceResult::trace_or(level, default, ctx) floors the level so session-ID rotation (anti-fixation), exclusive-login invalidation, login session persistence, reset-email dispatch and error-template render failure are no longer silent in a default production config. The errors tracing node (http/render) is now wired onto the error middleware (handled HTTP errors are gated; critical 500s stay direct error!). Emission tests via tracing-test validate that the swallowed error's file:line is captured
Fix error pages: responsive + i18n
The debug error page is now responsive and operator-facing messages are localized. The diagnostic page (stack traces, template source, headers table) had no media queries and a fixed 80 % width, overflowing on mobile/tablet for content that varies a lot per error: added overflow-safety on long values, breakpoints (992/768/600/480) and a horizontally-scrolling headers table on small screens. The production 404/429/500 pages gained overflow-wrap (a long/custom message no longer overflows) and the three templates are now consistent and fully i18n-driven (the hardcoded English status lines were removed). Startup banners, the subscriber warnings and the "ACME feature missing" hints now go through t()/tf() (keys added in all 9 languages); internal tracing context tags stay in fixed English for log aggregation/grep
Ajouté sanitize
New | sanitize filter (rich HTML, output-sanitized): re-runs sanitize_rich (ammonia) on the value at render time, and the template preprocessor forces | safe on every | sanitize (mirroring | markdown). The | safe therefore only ever emits ammonia's own XSS-free output, re-cleaned regardless of how the value reached the database — sanitize-on-output, not trust-on-input. Used by the admin detail view to render rich fields as real HTML.
Ajouté plaintext
New | plaintext filter (text preview): projects a value to plain text via sanitize_strict — strips every tag and decodes entities, so a stored > becomes a real > that Tera then escapes once. No | safe is forced (the output is plain text and stays auto-escaped). Used by the admin list cells, where rendering block-level rich HTML would break the truncated single-line layout.
Ajouté tracing: file/JSON outputs, custom sinks, external subscriber
Per-module tracing can now fan out to files and custom destinations, not just the console. RuniqueLog gained a repeatable .output(LogOutput): LogOutput::stdout() (colored console), LogOutput::file("logs/app.json") (non-blocking rolling file — format inferred from the extension, JSON for .json else plain text; rotation Daily/Hourly/Never), and LogOutput::sink(impl LogSink) for a developer-provided destination (database, HTTP collector, queue) that receives a Runique-owned LogRecord without any tracing type leaking into the public API. RUNIQUE_LOG_FILE adds a file output at runtime without recompiling. File writers are non-blocking; their WorkerGuards live in RuniqueApp and are flushed on shutdown. No built-in database sink ships on purpose — LogSink is the hook to wire one.
Ajouté runique (tracing: file/JSON outputs, custom sinks, external subscriber)
.with_log(|l| l.external()) lets the application own the single global subscriber. A process can install only one global tracing subscriber; when the app installs its own (custom layer stack, OpenTelemetry…), external() makes Runique install nothing while still emitting its events to the tracing facade, so the app's subscriber receives them (filter the runique target to drop them). No Cargo feature, no breaking change.