Add TOTP authentication by default, and tweak included themes

This commit is contained in:
mrkmntal 2026-09-29 10:24:44 -04:00
commit 4470b0b9c6
17 changed files with 3482 additions and 429 deletions

3
.gitignore vendored
View file

@ -1,2 +1,5 @@
/target
/notes/*.md
# TOTP secret — never commit this
.totp_secret

491
AGENTS.md
View file

@ -1,21 +1,21 @@
# AGENTS.md
Welcome AI assistant! You are working on **Rasuti Notes** (ラスティ Notes)—a server-side rendered (SSR) copyleft Markdown notes application written in Rust.
Welcome AI assistant! You are working on **EternaLibre Notes** — a server-side rendered (SSR) copyleft Markdown notes application written in Rust.
Rasuti Notes combines **GNU software freedom** (GPL v3.0-or-later) with the **fluid elegance of Apple Notes** and the **predictable utility of classic desktop GUI applications** (menu bars, split panes, and file organization).
EternaLibre Notes combines **GNU software freedom** (GPL v3.0-or-later) with the **fluid elegance of Apple Notes** and the **predictable utility of classic desktop GUI applications** (menu bars, split panes, and file organization).
The name splits into its two halves: **Eterna** for permanence and **Libre** for freedom. Notes you keep forever, software that stays free. It borrows Apple's polish and then does the opposite of what Apple does with your data.
---
## 1. Project Philosophy & Identity
* **Name**: Rasuti Notes (ラスティ Notes—from Japanese *Rasuti* / ラスティ for Rusty).
* **Name**: EternaLibre Notes — "Free notes, forever."
* **Mission**: Reclaiming high-performance Rust web development under copyleft terms (GPLv3+). Rejecting heavy client-side JavaScript frameworks and proprietary note silos in favor of software freedom, privacy, and near-zero memory footprint.
* **UI Paradigm**: **"Apple Elegance meets Classic Desktop GUI"**
* **Apple Notes Polish**: Fluid sidebars, smooth note item previews, refined typography, generous padding, subtle contrast, and graceful theme transitions.
* **Traditional Desktop Utility**: Top menu bar (`File`, `Edit`, `View`, `Theme`, `Help`), quick action toolbars, split-pane resizers, keyboard shortcuts, and visible document state.
---
## 2. UI/UX Design System & Layout Architecture
@ -24,19 +24,17 @@ The application layout follows a classic 3-column / top-menu layout rendered cle
```
+-----------------------------------------------------------------------------------+
| [File] [Edit] [View] [Theme] [Help] [Rasuti Notes ラスティ v0.1] | <-- Classic Menu Bar
| [File] [Edit] [View] [Theme] [Help] [EternaLibre Notes v0.1] | <-- Classic Menu Bar
+-----------------------------------------------------------------------------------+
| [+ New Note] [Delete] | Search... | Theme: [ Dark v ] [Preview] | <-- Apple-style Toolbar
+-------------------+-----------------------------------+---------------------------+
| Sidebar | Editor Pane | Live Preview Pane |
| ----------------- | --------------------------------- | ------------------------- |
| 📝 Project Notes | # Welcome to Rasuti Notes | Welcome to Rasuti Notes |
| Sep 28, 2026 | | |
| GPLv3+ Markdown| Fast, copyleft Markdown editor... | Fast, copyleft Markdown...|
| | | |
| 📝 Rust Snippets | - [x] High performance SSR | • [x] High performance SSR|
| Sep 25, 2026 | - [ ] Zero heavy JS runtime | • [ ] Zero heavy JS |
| fn main() ... | | |
| Notes | [Untitled ] | Welcome to EternaLibre Notes |
| Project Notes | | |
| Sep 28, 2026 | # Welcome | Fast, copyleft Markdown... |
| GPLv3+ Markdown | | |
| fn main() ... | Fast, copyleft Markdown editor... | |
+-------------------+-----------------------------------+---------------------------+
| Status: 2 Notes | Saved | UTF-8 | Markdown | Theme: Dark | <-- Status Bar
+-----------------------------------------------------------------------------------+
@ -45,22 +43,58 @@ The application layout follows a classic 3-column / top-menu layout rendered cle
### Key UX Principles:
1. **Fluid Typography**: Inter/SF Pro or high-quality system sans-serif for the interface; Monospace for Markdown source; clean serif or clean sans for HTML preview.
2. **Subtle Elevation & Borders**: Soft 1px borders using `--ansi-0` / `--app-bg` contrast shades rather than heavy drop shadows.
1. **Fluid Typography**: Inter/SF Pro or high-quality system sans-serif for the interface; Monospace for Markdown source; clean sans for HTML preview.
2. **Subtle Elevation & Borders**: Soft 1px borders using `--ui-border` / `--app-bg` contrast shades rather than heavy drop shadows.
3. **Apple-style Note List**: Sidebar lists show note title, relative timestamp, and a single line preview snippet in muted text.
4. **Progressive Enhancement**: Full functionality works with zero JavaScript (via HTML forms and page reloads). With lightweight inline JS or HTMX enabled, typing in the editor triggers real-time live preview swaps without full-page refreshes.
4. **Progressive Enhancement**: Note editing, search, theming, and the login form all work with JavaScript disabled, via HTML forms and page reloads. Three things are JS-dependent by design: the menu bar (dropdowns open from an Alpine-driven `.open` class), the mobile tab bar, and live preview.
---
## 3. Architecture & Tech Stack
* **Language**: Rust (`edition = "2021"`)
* **Server Framework**: **Axum** or **Actix-web**
* **SSR Templating**: **`maud`** (compiled, type-safe HTML macros) or **`askama`**
* **Reactivity / Live Swap**: **HTMX** (or lightweight SSE / fetch script under 5KB) for seamless live preview typing updates and instant theme swapping.
* **Markdown Engine**: `pulldown-cmark` (with table, tasklist, and strikethrough extensions enabled).
* **Server Framework**: **Axum 0.7** (async, Tower ecosystem)
* **Auth**: `totp-rs` 6 (TOTP), `qrcode` 0.14 (inline SVG QR), `rand` 0.10 (session tokens)
* **SSR Templating**: **`maud` 0.26** (compiled, type-safe HTML macros)
* **Reactivity / Live Swap**: **Alpine.js 3.14** (vendored locally in `static/`, ~15 KB)
* **Markdown Engine**: `pulldown-cmark` (with table, tasklist, strikethrough, and footnote extensions)
* **Syntax Highlighting**: `syntect` 5 (for fenced code blocks, themed per active palette)
* **Serialization**: `serde` / `serde_json`
* **Dates**: `chrono`
* **IDs**: `uuid` v4
* **Error Handling**: `anyhow` for application errors, `thiserror` for library-style errors
* **CLI Parsing**: `clap` 4 (derive API)
* **Static Assets**: `tower-http` `ServeDir` for `/static`
* **License**: **GNU General Public License v3.0 or later** (`GPL-3.0-or-later`). All dependency crates in `Cargo.toml` must be compatible with GPLv3+.
### Actual Project Structure
```
eternalibre-notes/
├── Cargo.toml
├── LICENSE # GPL-3.0 full text
├── README.md
├── notes/ # User data: one .md file per note
├── src/
│ ├── main.rs # Entry point, CLI args, terminal setup, router assembly
│ ├── lib.rs # Library root, so tests/ can drive the HTTP layer
│ ├── auth.rs # TOTP secret store, session store, verification
│ ├── setup.rs # First-run terminal enrolment (QR to stdout, reads stdin)
│ ├── routes.rs # HTTP routes, auth gate, base-path joining
│ ├── pages.rs # Maud SSR templates + the `Base` URL helper
│ ├── notes.rs # Note model + file-system persistence
│ ├── markdown.rs # Markdown → HTML with code block interception
│ ├── syntax.rs # syntect engine + per-theme tmTheme generation
│ ├── themes.rs # 24 predefined themes + CSS variable engine
│ └── storage.rs # Storage re-export module
├── static/
│ ├── alpine.min.js # Vendored Alpine.js
│ ├── app.js # Alpine component
│ └── style.css # Full application stylesheet
└── tests/
└── auth_gating.rs # End-to-end tests for the TOTP auth gate
```
---
## 4. Theme System & ANSI Palette Data
@ -85,191 +119,15 @@ pub struct ANSIPalette {
pub ansi_normal: [&'static str; 8],
pub ansi_bright: [&'static str; 8],
}
```
### Predefined Themes Vector
`PREDEFINED_THEMES` contains 24 themes: Dark, Light, Solarized Dark, Solarized Light, Dracula, Gruvbox Dark, Gruvbox Light, Nord, Monokai, Tokyo Night, One Dark, Cyberpunk 2077, Cyan on Black, Red on Black, Lime Green on Black, Chartreuse on Black, Amber on Black, Blueberry on Black, Spring Blossom, Summer Sunset, Autumn Forest, Winter Frost, Stormy Night, and Foggy Morning.
```rust
pub static PREDEFINED_THEMES: &[Theme] = &[
Theme {
name: "Dark",
fg: "#d0d0d0",
bg: "#1c1c1c",
ansi_normal: ["#1c1c1c", "#c50f1f", "#23a523", "#b58900", "#268bd2", "#d33682", "#2aa198", "#d0d0d0"],
ansi_bright: ["#666666", "#ff6666", "#66ff66", "#ffff66", "#6666ff", "#ff66ff", "#66ffff", "#ffffff"],
},
Theme {
name: "Light",
fg: "#1c1c1c",
bg: "#d0d0d0",
ansi_normal: ["#1c1c1c", "#c50f1f", "#23a523", "#b58900", "#268bd2", "#d33682", "#2aa198", "#d0d0d0"],
ansi_bright: ["#666666", "#ff6666", "#66ff66", "#ffff66", "#6666ff", "#ff66ff", "#66ffff", "#ffffff"],
},
Theme {
name: "Solarized Dark",
fg: "#839496",
bg: "#002b36",
ansi_normal: ["#073642", "#dc322f", "#859900", "#b58900", "#268bd2", "#d33682", "#2aa198", "#eee8d5"],
ansi_bright: ["#002b36", "#cb4b16", "#586e75", "#657b83", "#839496", "#6c71c4", "#93a1a1", "#fdf6e3"],
},
Theme {
name: "Solarized Light",
fg: "#657b83",
bg: "#fdf6e3",
ansi_normal: ["#073642", "#dc322f", "#859900", "#b58900", "#268bd2", "#d33682", "#2aa198", "#eee8d5"],
ansi_bright: ["#002b36", "#cb4b16", "#586e75", "#657b83", "#839496", "#6c71c4", "#93a1a1", "#fdf6e3"],
},
Theme {
name: "Dracula",
fg: "#f8f8f2",
bg: "#282a36",
ansi_normal: ["#21222c", "#ff5555", "#50fa7b", "#f1fa8c", "#bd93f9", "#ff79c6", "#8be9fd", "#f8f8f2"],
ansi_bright: ["#6272a4", "#ff6e6e", "#69ff94", "#ffffa5", "#d6acff", "#ff92df", "#a4ffff", "#ffffff"],
},
Theme {
name: "Gruvbox Dark",
fg: "#ebdbb2",
bg: "#282828",
ansi_normal: ["#282828", "#cc241d", "#98971a", "#d79921", "#458588", "#b16286", "#689d6a", "#a89984"],
ansi_bright: ["#928374", "#fb4934", "#b8bb26", "#fabd2f", "#83a598", "#d3869b", "#8ec07c", "#ebdbb2"],
},
Theme {
name: "Gruvbox Light",
fg: "#3c3836",
bg: "#fbf1c7",
ansi_normal: ["#fbf1c7", "#cc241d", "#98971a", "#d79921", "#458588", "#b16286", "#689d6a", "#7c6f64"],
ansi_bright: ["#928374", "#9d0006", "#79740e", "#b57614", "#076678", "#8f3f71", "#427b58", "#3c3836"],
},
Theme {
name: "Nord",
fg: "#d8dee9",
bg: "#2e3440",
ansi_normal: ["#3b4252", "#bf616a", "#a3be8c", "#ebcb8b", "#81a1c1", "#b48ead", "#88c0d0", "#e5e9f0"],
ansi_bright: ["#4c566a", "#bf616a", "#a3be8c", "#ebcb8b", "#81a1c1", "#b48ead", "#8fbcbb", "#eceff4"],
},
Theme {
name: "Monokai",
fg: "#f8f8f2",
bg: "#272822",
ansi_normal: ["#272822", "#f92672", "#a6e22e", "#f4bf75", "#66d9ef", "#ae81ff", "#a1efe4", "#f8f8f2"],
ansi_bright: ["#75715e", "#f92672", "#a6e22e", "#e6db74", "#66d9ef", "#ae81ff", "#a1efe4", "#f9f8f5"],
},
Theme {
name: "Tokyo Night",
fg: "#c0caf5",
bg: "#1a1b26",
ansi_normal: ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#a9b1d6"],
ansi_bright: ["#414868", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"],
},
Theme {
name: "One Dark",
fg: "#abb2bf",
bg: "#282c34",
ansi_normal: ["#282c34", "#e06c75", "#98c379", "#e5c07b", "#61afef", "#c678dd", "#56b6c2", "#abb2bf"],
ansi_bright: ["#5c6370", "#e06c75", "#98c379", "#e5c07b", "#61afef", "#c678dd", "#56b6c2", "#ffffff"],
},
Theme {
name: "Cyberpunk 2077",
fg: "#f9f227",
bg: "#0d0d0d",
ansi_normal: ["#0d0d0d", "#ff0055", "#00ff41", "#f9f227", "#00b8ff", "#ff00cc", "#00ffff", "#f9f227"],
ansi_bright: ["#333333", "#ff3377", "#33ff66", "#ffff33", "#33ccff", "#ff33ff", "#33ffff", "#ffff66"],
},
Theme {
name: "Cyan on Black",
fg: "#00ffff",
bg: "#000000",
ansi_normal: ["#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc"],
ansi_bright: ["#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff"],
},
Theme {
name: "Red on Black",
fg: "#ff0000",
bg: "#000000",
ansi_normal: ["#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc"],
ansi_bright: ["#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff"],
},
Theme {
name: "Lime Green on Black",
fg: "#00ff00",
bg: "#000000",
ansi_normal: ["#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc"],
ansi_bright: ["#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff"],
},
Theme {
name: "Chartreuse on Black",
fg: "#7fff00",
bg: "#000000",
ansi_normal: ["#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc"],
ansi_bright: ["#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff"],
},
Theme {
name: "Amber on Black",
fg: "#ffbf00",
bg: "#000000",
ansi_normal: ["#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc"],
ansi_bright: ["#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff"],
},
Theme {
name: "Blueberry on Black",
fg: "#8ec5fc",
bg: "#000000",
ansi_normal: ["#000000", "#0d47a1", "#1565c0", "#1976d2", "#1e88e5", "#42a5f5", "#64b5f6", "#90caf9"],
ansi_bright: ["#37474f", "#1e88e5", "#42a5f5", "#64b5f6", "#90caf9", "#bbdefb", "#e3f2fd", "#ffffff"],
},
Theme {
name: "Spring Blossom",
fg: "#ffb7c5",
bg: "#2d5016",
ansi_normal: ["#2d5016", "#c2185b", "#7cb342", "#fbc02d", "#00897b", "#d81b60", "#aed581", "#f8bbd0"],
ansi_bright: ["#558b2f", "#e91e63", "#8bc34a", "#ffeb3b", "#009688", "#ec407a", "#c5e1a5", "#fce4ec"],
},
Theme {
name: "Summer Sunset",
fg: "#ff9a5a",
bg: "#1a1a2e",
ansi_normal: ["#1a1a2e", "#e64a19", "#ff7043", "#ffab40", "#d81b60", "#8e24aa", "#5c6bc0", "#ffb74d"],
ansi_bright: ["#424242", "#ff5722", "#ff8a65", "#ffc107", "#e91e63", "#9c27b0", "#3f51b5", "#ffe0b2"],
},
Theme {
name: "Autumn Forest",
fg: "#d4a373",
bg: "#3e2723",
ansi_normal: ["#3e2723", "#bf360c", "#e65100", "#8d6e63", "#5d4037", "#33691e", "#9e9d24", "#a1887f"],
ansi_bright: ["#6d4c41", "#e64a19", "#f57c00", "#a1887f", "#795548", "#558b2f", "#c0ca33", "#d7ccc8"],
},
Theme {
name: "Winter Frost",
fg: "#e0f7fa",
bg: "#1a237e",
ansi_normal: ["#1a237e", "#006064", "#00838f", "#0097a7", "#00acc1", "#26c6da", "#4dd0e1", "#80deea"],
ansi_bright: ["#283593", "#00897b", "#00acc1", "#26c6da", "#4dd0e1", "#80deea", "#b2ebf2", "#ffffff"],
},
Theme {
name: "Stormy Night",
fg: "#b0bec5",
bg: "#263238",
ansi_normal: ["#263238", "#37474f", "#455a64", "#546e7a", "#607d8b", "#78909c", "#90a4ae", "#b0bec5"],
ansi_bright: ["#455a64", "#607d8b", "#78909c", "#90a4ae", "#b0bec5", "#cfd8dc", "#eceff1", "#ffffff"],
},
Theme {
name: "Sunny Day",
fg: "#fff59d",
bg: "#33691e",
ansi_normal: ["#33691e", "#f57f17", "#c0ca33", "#00acc1", "#00897b", "#5c6bc0", "#7cb342", "#fff59d"],
ansi_bright: ["#558b2f", "#fbc02d", "#afb42b", "#26c6da", "#0097a7", "#3f51b5", "#9ccc65", "#fff9c4"],
},
Theme {
name: "Foggy Morning",
fg: "#cfd8dc",
bg: "#455a64",
ansi_normal: ["#455a64", "#546e7a", "#607d8b", "#78909c", "#90a4ae", "#b0bec5", "#cfd8dc", "#eceff1"],
ansi_bright: ["#607d8b", "#78909c", "#90a4ae", "#b0bec5", "#cfd8dc", "#eceff1", "#f5f5f5", "#ffffff"],
},
];
"Sunny Day" was removed: a mid-green background with a pale yellow foreground left almost nothing legible on it, and no amount of syntax tuning rescues a palette that bad.
```
### Theme Selection
The active theme is stored in a `theme` cookie. The server reads it from the `Cookie` header on every request and injects the matching CSS variable block into the `<head>`. No JavaScript is required to change themes — a plain `<form method="post" action="/theme">` sets a new cookie and redirects.
---
@ -286,28 +144,243 @@ impl Theme {
--app-bg: {};\n\
--ui-border: {}33;\n\
--ui-hover: {}1a;\n\
--btn-primary-bg: {};\n\
--btn-primary-fg: {};\n\
--btn-danger-bg: {};\n\
--btn-danger-fg: {};\n\
--ansi-0: {}; --ansi-1: {}; --ansi-2: {}; --ansi-3: {};\n\
--ansi-4: {}; --ansi-5: {}; --ansi-6: {}; --ansi-7: {};\n\
--ansi-8: {}; --ansi-9: {}; --ansi-10: {}; --ansi-11: {};\n\
--ansi-12: {}; --ansi-13: {}; --ansi-14: {}; --ansi-15: {};\n\
}}",
self.fg, self.bg,
self.fg, self.fg,
self.ansi_normal[0], self.ansi_normal[1], self.ansi_normal[2], self.ansi_normal[3],
self.ansi_normal[4], self.ansi_normal[5], self.ansi_normal[6], self.ansi_normal[7],
self.ansi_bright[0], self.ansi_bright[1], self.ansi_bright[2], self.ansi_bright[3],
self.ansi_bright[4], self.ansi_bright[5], self.ansi_bright[6], self.ansi_bright[7],
...
)
}
}
```
### Accessibility Guard: Button Color Derivation
Some themes ship accent colors that are nearly invisible on their own background. Rather than hand-tuning every theme, `Theme::button_colors(accent)` computes a relative luminance and falls back to the theme foreground when an accent is too dark to read:
```rust
fn button_colors(&self, accent: usize) -> (&'static str, &'static str) {
let bright = self.ansi_bright[accent];
if relative_luminance(bright) < 0.15 {
(self.fg, self.bg)
} else {
(bright, self.bg)
}
}
```
Themes that previously used `#0000ff` on a pure black background were also corrected at the source: that slot is now `#a9a9f9` in the affected palettes (Cyan on Black, Red on Black, Lime Green on Black, Chartreuse on Black, and Amber on Black).
---
## 6. Rules & Directives for AI Developers
## 6. Syntax Highlighting
`src/syntax.rs` generates a `.tmTheme` plist **at runtime** from each EternaLibre theme, then loads it into `syntect`. This means code highlighting always matches the active palette instead of shipping one fixed color scheme.
```rust
pub struct SyntaxState {
pub syntax_set: SyntaxSet,
pub themes: HashMap<String, SyntectTheme>,
}
```
The state is held in a `OnceLock` and initialized eagerly at startup so the first request is not slow. `src/markdown.rs` walks the `pulldown-cmark` event stream, intercepts `Event::Start(Tag::CodeBlock(..))`, collects the text, highlights it, and re-emits it as `Event::Html`. Unknown language tags fall back to plain text.
Scope → ANSI mapping, by ANSI slot:
| Scope | Slot |
|-------|------|
| `comment` | black (see below) + italic |
| `string` | green |
| `constant.numeric` / `constant` | yellow |
| `keyword` / `storage` | magenta |
| `entity.name.function` / `support.function` | blue |
| `entity.name.type` | cyan |
| `variable` / `invalid` | red |
### Colors are chosen by measured contrast, not by index
An earlier revision hardcoded the **bright** variant of every slot. That is correct for a dark background and disastrous for a light one: the Light theme rendered `#66ff66` strings against a `#d0d0d0` background at **1.17:1**, which is not "low contrast", it is invisible. Nothing about the palette data flagged it — both colors were valid hex.
`syntax_color` therefore picks between `ansi_normal[slot]` and `ansi_bright[slot]` by **WCAG contrast ratio against the theme background**, and `comment_color` does the same over the two blacks plus the foreground. This is the same reasoning as the `button_colors` luminance guard in §5: prefer the measured property over a hand-maintained table.
Two things to know if you touch this:
* **`syntax.rs` has its own `relative_luminance`, and it is not the one in `themes.rs`.** The `themes.rs` version sums gamma-encoded channels and is only good enough for the coarse `< 0.15` button guard. Contrast ratios need the linearized sRGB curve, or the numbers are wrong in precisely the range that matters (dark colors on light backgrounds). They are deliberately different functions with nearly identical names.
* **Candidates are sorted by contrast, not kept in a fixed order.** Which ANSI black is the dimmer one flips between light and dark backgrounds, so a hardcoded preference order silently picks the loud option on half the palettes.
### Comments optimize for the *opposite* end of the tradeoff
`syntax_color` takes the **most** readable candidate. `comment_color` takes the **least** prominent one that still clears `COMMENT_MIN_CONTRAST` (2.5:1). Maximizing comment contrast is maximally legible and practically useless — on the Light theme it paints every comment the same color as the surrounding prose, so a commented block becomes a wall of text with no hierarchy left to scan.
Where no candidate clears the floor, the palette is at fault, not the code. **Solarized Dark ships `#002b36` as its bright black, which is exactly its background** — comments there were not faint, they were invisible at 1.00:1. The fallback to the foreground is what rescues it (4.86:1).
Comments also carry `fontStyle: italic` in the tmTheme, which syntect renders as `font-style:italic` on the span. Color alone is not a reliable signal, since a comment that falls back to the foreground is by definition the same color as the prose.
The three failure modes are each pinned by a test, and each test was verified to fail when the corresponding bug is reintroduced: `comments_are_readable_in_every_theme`, `no_theme_uses_its_background_for_comments`, and `comments_stay_distinct_from_the_foreground_when_they_can`. `syntax_color_always_picks_the_more_readable_variant` additionally pins the selection rule itself across all 24 themes and all 8 slots.
### The Light theme has a data ceiling, not a logic bug
`Light` is a literal inversion of `Dark` — the same 16 colors with `fg` and `bg` swapped, on a mid-gray `#d0d0d0` background. After this change its worst scope bottoms out at **2.05:1** (type/cyan, `#2aa198`), because no slot in that palette offers a darker or more saturated cyan. The selection logic is already returning the best available option; the palette is what limits it.
So **do not "fix" a low-contrast Light theme by changing the selection logic** — it is provably optimal per slot, and `syntax_color_always_picks_the_more_readable_variant` will fail if it stops being so. The only real remedies are palette changes: lightening the background to near-white lifts type/cyan from 2.05:1 to 3.16:1 and string/green from 2.10:1 to 3.24:1, or replacing the normal ANSI colors outright. Both change how the whole application looks, not just code, so they are a design decision rather than a bug fix.
---
## 7. Responsive / Mobile Design
Desktop keeps the 3-column grid. At `max-width: 600px` (which covers iPhone 11 at 390px CSS width) the layout collapses to a **tabbed single-pane interface** with a **Notes / Write / Preview** tab bar.
Implementation notes:
* The whole page is a flex column using `height: 100dvh`, so the mobile browser chrome that hides on scroll does not break the layout. This replaced an earlier brittle `calc(100vh - 32px - 44px - 24px)` height calculation.
* The default tab (`write` when a note is open, `notes` otherwise) is rendered **server-side** onto the correct pane as `mobile-active`, so the layout still works with JavaScript disabled. Alpine only takes over switching afterward.
* Touch targets are at least 44px tall (Apple HIG minimum). Inputs and textareas use a 16px font size on mobile to prevent iOS Safari zoom-on-focus.
* The toolbar wraps rather than overflowing, and `min-width: 0` is set on flex children so they shrink instead of stretching off-screen.
### CSS Stacking Context Rule
`overflow` on an **ancestor** creates a new stacking context, which traps absolutely positioned descendants. An earlier revision set `overflow-x: auto` on `.menu-list` to allow horizontal menu scrolling, which silently pushed the menu dropdowns *underneath* the toolbar. The fix is `flex-wrap: wrap` with `overflow: visible`.
> **Do not reintroduce `overflow`, `transform`, `filter`, or `opacity` on `.menu-bar`, `.menu-list`, or `.menu-item`.** `overflow` on the dropdown *itself* is safe (it only clips its own contents), but on an ancestor it traps the dropdown.
The 24-entry Theme menu can exceed the viewport height, so `.menu-dropdown` itself carries `max-height` plus `overflow-y: auto`. This is the safe location for that property.
---
## 8. Menu Bar Behavior
Menus are opened by **click**, not CSS `:hover`, driven by a single Alpine state variable. Keeping one name in one slot makes mutual exclusion automatic — opening Theme while Help is open simply reassigns the value, so Help hides itself with no extra bookkeeping.
```javascript
openMenu: null,
toggleMenu(name) {
this.openMenu = this.openMenu === name ? null : name;
},
```
Dropdowns are revealed by a class on the trigger rather than `x-show`, because Alpine's `x-show` works by *removing* an inline `display` when true, at which point the stylesheet's `display: none` would take over again and the menu would stay hidden. Driving it from `.menu-item.open .menu-dropdown` also means the menus stay closed if JavaScript never loads, instead of all five appearing at once.
**Consequence: the menu bar requires JavaScript.** Since `.open` is applied by Alpine, no dropdown opens without it. This is a deliberate trade-off (see section 2, directive 4) — note CRUD, search, and theming are unaffected. If you change this, keep `.desktop-only` and the `.open` rule consistent; see section 8.
Outside-click detection is a manual `document` listener in an `init()` hook with a matching `destroy()`. `Escape` closes any open menu.
### Maud + Alpine Modifier Caveat
**Maud treats `.` in an attribute name as CSS class shorthand.** `x-on:click.outside="..."` parses as the attribute `x-on:click` plus a class named `outside`, producing a `duplicate attribute class` compile error. The same applies to `x-on:input.debounce.300ms` and `x-on:click.stop`. Use plain Alpine directives in Maud templates and bind modifiers manually in `static/app.js`.
---
## 9. Routes
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/login` | TOTP unlock form (public) |
| `POST` | `/login` | Verify code, issue session (public) |
| `POST` | `/logout` | Drop the session server-side and clear the cookie |
| `GET` | `/` | App shell with note list (accepts `?q=` for search) |
| `GET` | `/notes/:id` | App shell with a note open in the editor |
| `POST` | `/notes` | Create a new note, redirect to it |
| `POST` | `/notes/:id` | Update or delete based on the `_method` form field |
| `POST` | `/preview` | Returns rendered HTML for live preview |
| `POST` | `/theme` | Sets the `theme` cookie, redirects to `/` |
| `GET` | `/about` | Static about page |
| `GET` | `/export` | Downloads all notes as a single Markdown file |
| `*` | `/static/*` | Vendored Alpine.js, app.js, and style.css |
Notes are stored as `<uuid>.md` files in `notes/` with YAML frontmatter carrying `title`, `created`, and `updated` timestamps. When no frontmatter is present, the title is derived from the first `# ` heading. The directory is overridable via the `--notes-dir` flag or the `ETERNALIBRE_NOTES_DIR` env var, which is how the integration tests stay out of real user data.
---
## 9b. TOTP Authentication
Every route except `/login` and `/logout` sits behind a `route_layer` middleware. This is a hard gate: an unauthenticated caller gets `303` to `/login` and never reaches a handler that could observe note content.
### Enrolment happens in the terminal, not on a page
**There is no `/setup` route.** `setup::run_interactive_setup` runs in `main()` *before* the listener binds, prints the QR as `Dense1x2` blocks (plus the Base32 secret and `otpauth://` URI as manual-entry fallbacks), and blocks on stdin for the code. Only then does the socket open.
This is the security property, not a convenience: an HTTP-reachable enrolment endpoint means anyone who can reach the port before the operator finishes could claim the device. Because setup completes before `axum::serve` is called, that window does not exist. There is a test asserting `GET /setup` returns 404.
If the secret file is deleted while the server runs, `/login` returns 503 with a "restart and complete setup in the terminal" message rather than redirecting to a dead route.
### Design decisions
**The secret is only committed after a successful verification.** `begin_setup` generates a secret and stashes it as `pending_secret` in memory. `verify_setup` checks the entered code against that pending secret and only then writes `.totp_secret` to disk (mode `0600`). A half-finished setup therefore cannot lock anyone out, and a wrong code leaves no file behind.
**Regenerating setup is safe.** Every call to `begin_setup` replaces `pending_secret` and invalidates any previously displayed QR code. The code on screen is always the one that will work.
**Sessions are in-memory only.** A restart drops every session, which matches the "authenticate on launch" requirement. Tokens are 32 hex-encoded bytes from the OS-seeded generator, held in a `HashMap<String, Instant>` behind an `RwLock`. Expired entries are pruned on every validation.
**`/preview` returns 401, not a redirect.** It is fetched by `fetch()` from the live-preview JS. Redirecting would hand that `fetch` the login HTML, and the `x-html` swap would silently paste a login page into the preview pane. A 401 fails loudly instead.
**Logout forgets the token server-side**, not just the cookie. Clearing only the cookie would leave a captured copy of the token valid for the rest of its hour.
**A corrupt `.totp_secret` fails closed.** `verify_code` parses the secret before looking at the code, so a truncated or corrupted file makes every login impossible rather than accepting anything.
**Recovery is deleting the file.** `rm .totp_secret` and restart reopens the terminal enrolment flow. There is no recovery-code fallback by design — the file *is* the recovery mechanism.
### Session cookie
```
eternalibre_session=<64 hex chars>; Path=/; HttpOnly; SameSite=Strict; Max-Age=3600
```
`Secure` is deliberately omitted so the app also works over plain HTTP on a trusted LAN, which is the intended deployment (it binds `0.0.0.0` by default). `Path` becomes the base path when one is configured; see 9c.
### skew
`with_skew(1)` accepts the neighbouring 30s step, so roughly ±30s of clock drift still validates. Two steps out is rejected — there are tests pinning both edges of that window.
---
## 9c. Base Path (reverse-proxy subdirectory)
`--base-path /forgejo` makes the app answer behind a reverse proxy that mounts it at a subdirectory. Empty (the default) means the site root, which is what most installs use and is unchanged behaviour.
**The app is mounted at the proxy's root.** It receives app-absolute paths (`/about`, not `/forgejo/about`) because `ProxyPass` strips the prefix. The base path is used *only when generating URLs*: hrefs, form actions, the static asset `src`, and every `Location` header.
```apache
ProxyPass /forgejo/ http://127.0.0.1:3000/ timeout=10
ProxyPassReverse /forgejo/ http://127.0.0.1:3000/
```
Consequences that are easy to get wrong:
- **Cookie `Path` is scoped to the prefix** (`Path=/forgejo`), so the session token is not sent to sibling apps on the same host. At the root it is `/`, the default-path.
- **Static assets stay app-absolute** (`nest_service("/static")` in `routes::app`). This is the one place the base path is *not* applied, and getting it wrong is subtle: the asset 404s, `x-data="app()"` never initialises, and every Alpine directive — including the File menu dropdowns — silently stops working. There is a test asserting `GET /static/app.js` returns 200 while a base path is set. `app.js` is inlined via `include_str!` and has no URL to rewrite; only `alpine.min.js` and `style.css` are externally loaded.
- **Curl will 404 on prefixed paths.** Hitting `http://host:3000/forgejo/login` directly bypasses Apache, so nothing strips the prefix. Test through the proxy, or curl the app-absolute path.
- **Never hardcode a path in JS.** `static/app.js` matches the new-note form with `form[action$="/notes"]` rather than an exact attribute, so `Ctrl+N` keeps working under any prefix.
---
## 10. CLI
```
Usage: eternalibre-notes [OPTIONS]
Options:
--host <HOST> Host address to bind to [default: 0.0.0.0]
-p, --port <PORT> Port to listen on [default: 3000]
--notes-dir <NOTES_DIR> Directory holding note files [default: notes]
--base-path <BASE_PATH> Path prefix when served behind a reverse proxy subdirectory [default: ]
-h, --help Print help
```
Binding to `0.0.0.0` by default makes the app reachable from other devices on the local network, which is how you would use it from a phone. Because the listener is exposed to the network, TOTP is not optional — it is the only thing between an unauthenticated peer and your notes.
---
## 11. Rules & Directives for AI Developers
1. **Licensing**: Keep all dependencies compatible with **GNU GPLv3+**. Verify crate licenses before introducing new Cargo dependencies.
2. **SSR First**: Ensure basic editing and rendering functionality operates without JavaScript enabled. Use standard HTML forms, query parameters, or cookies for persistence.
3. **Fluid Micro-interactions**: When applying CSS, use smooth `transition: background-color 0.15s ease, color 0.15s ease` so theme switching feels fluid and premium.
4. **Clean Code**: Enforce strict idiomatic Rust (`cargo clippy -- -D warnings`). Avoid `unsafe` blocks.
5. **Respect the stacking context rule**: See section 7 before touching overflow or transform on menu-related selectors.
6. **Remember the Maud attribute caveat**: See section 8 before adding any Alpine modifier to a template.
7. **Test before claiming done**: Run `cargo fmt -- --check`, `cargo clippy -- -D warnings`, `cargo test`, and `cargo build --release`. If a browser is unavailable, say so rather than implying the UI was visually verified.

540
Cargo.lock generated
View file

@ -124,7 +124,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower 0.5.3",
"tower",
"tower-layer",
"tower-service",
"tracing",
@ -151,6 +151,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "base32"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
[[package]]
name = "base64"
version = "0.23.1"
@ -172,6 +178,15 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@ -200,6 +215,17 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
]
[[package]]
name = "chrono"
version = "0.4.45"
@ -254,18 +280,39 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -275,12 +322,52 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"crypto-common",
"ctutils",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -297,6 +384,29 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "eternalibre-notes"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"chrono",
"clap",
"maud",
"percent-encoding",
"pulldown-cmark",
"qrcode",
"rand",
"serde",
"serde_json",
"syntect",
"thiserror 1.0.69",
"tokio",
"totp-rs",
"tower",
"uuid",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@ -344,12 +454,6 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
@ -386,6 +490,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core",
]
[[package]]
@ -400,6 +505,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.5.0"
@ -433,12 +547,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@ -451,6 +559,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.11.1"
@ -510,6 +627,110 @@ dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.2"
@ -555,6 +776,12 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "lock_api"
version = "0.4.14"
@ -610,16 +837,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@ -744,6 +961,15 @@ dependencies = [
"time",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@ -801,6 +1027,12 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]]
name = "quick-xml"
version = "0.42.0"
@ -826,26 +1058,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rasuti-notes"
version = "0.1.0"
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"anyhow",
"axum",
"chrono",
"clap",
"maud",
"percent-encoding",
"pulldown-cmark",
"serde",
"serde_json",
"syntect",
"thiserror 1.0.69",
"tokio",
"tower 0.4.13",
"tower-http",
"uuid",
"chacha20",
"getrandom",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -954,6 +1182,28 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@ -998,6 +1248,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strsim"
version = "0.11.1"
@ -1032,6 +1288,17 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "synstructure"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "syntect"
version = "5.3.0"
@ -1123,6 +1390,16 @@ dependencies = [
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "tokio"
version = "1.53.1"
@ -1152,27 +1429,21 @@ dependencies = [
]
[[package]]
name = "tokio-util"
version = "0.7.19"
name = "totp-rs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
checksum = "49d0f9b7aa5def86d3a8d8ba926fe8c1990924dd715d2b5fc54a4aa3788c11c1"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"tower-layer",
"tower-service",
"tracing",
"base32",
"constant_time_eq",
"hmac",
"percent-encoding",
"rand",
"serde",
"sha1",
"sha2",
"url",
"zeroize",
]
[[package]]
@ -1191,31 +1462,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@ -1248,6 +1494,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.9.0"
@ -1266,6 +1518,23 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
@ -1427,6 +1696,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "yaml-rust"
version = "0.4.5"
@ -1436,6 +1711,103 @@ dependencies = [
"linked-hash-map",
]
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"

View file

@ -1,15 +1,13 @@
[package]
name = "rasuti-notes"
name = "eternalibre-notes"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-or-later"
description = "A server-side rendered Markdown notes application in Rust"
description = "EternaLibre Notes - a server-side rendered, copyleft Markdown notes application"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["fs"] }
maud = "0.26"
pulldown-cmark = "0.11"
serde = { version = "1", features = ["derive"] }
@ -21,3 +19,10 @@ thiserror = "1"
percent-encoding = "2"
clap = { version = "4", features = ["derive"] }
syntect = "5"
totp-rs = { version = "6.0.0", features = ["gen_secret", "otpauth"] }
qrcode = { version = "0.14.1", default-features = false, features = ["svg"] }
rand = "0.10.3"
[dev-dependencies]
# Integration tests drive the router via `oneshot`.
tower = { version = "0.5", features = ["util"] }

View file

@ -1,18 +1,27 @@
# Rasuti Notes (ラスティ Notes)
# EternaLibre Notes
**Free notes, forever.**
A server-side rendered (SSR) Markdown notes application written in Rust.
Rasuti Notes combines **GNU software freedom** (GPL v3.0-or-later) with the
EternaLibre Notes combines **GNU software freedom** (GPL v3.0-or-later) with the
fluid elegance of Apple Notes and the predictable utility of classic desktop
GUI applications: a menu bar, split-pane editor, and file-based note storage.
It is a toolkit for making a **private notes cloud**. You run the server
yourself, on your own hardware, and the notes go nowhere else. The server
binds to all interfaces by default, so you can reach your notes from a phone or
another machine on your network, protected by a TOTP code from your
authenticator app. No telemetry, no accounts, no third-party services.
## Features
- **TOTP-locked by default** — authenticator app required on first launch and after every restart
- **Server-side rendered** with Axum and Maud — fast, type-safe HTML templates
- **Zero-JS fallback** — every action works via standard HTML forms
- **Works without JavaScript** — note editing, search, theming, and the login form are plain HTML; the menus, mobile tabs, and live preview are the JS-dependent parts
- **Alpine.js enhancements** — live Markdown preview, instant search, keyboard shortcuts
- **File-based storage** — notes are persisted as Markdown files in `notes/`
- **28 built-in themes** — from Dark/Light to Solarized, Dracula, Nord, Monokai, and seasonal palettes
- **24 built-in themes** — from Dark/Light to Solarized, Dracula, Nord, Monokai, and seasonal palettes
- **Markdown extras** — tables, tasklists, strikethrough, and footnotes via `pulldown-cmark`
- **Syntax highlighting** — code blocks are highlighted using the current theme's ANSI palette via `syntect`
- **Mobile responsive** — collapses to a Notes/Write/Preview tab layout on phones, with 44px touch targets
@ -25,12 +34,61 @@ cargo run
# Use a custom host/port
cargo run -- --host 127.0.0.1 --port 8080
# Serve behind a reverse proxy at a subdirectory
cargo run -- --base-path /forgejo
```
Then open http://127.0.0.1:3000 (or your chosen address).
Notes are stored in the `notes/` directory as `.md` files with YAML frontmatter.
## Authentication
EternaLibre Notes is locked with TOTP. Because the server binds `0.0.0.0` by default, a code is required before any note can be read.
**First launch:** the server prints a QR code to your terminal and waits for a 6-digit code. Scan it with any TOTP authenticator (oathtool, Aegis, Bitwarden, 1Password, …), or type the Base32 secret by hand if you would rather not scan. Only after a valid code is the secret committed to `.totp_secret` in the project root with mode `0600` — and only then does the port start listening.
Enrolment deliberately happens in the terminal rather than on a web page. There is no `/setup` route to reach: an HTTP-reachable setup endpoint would let anyone who can reach the port before you finish claim the device.
**Every launch after that:** you land on `/login` and need a current code. Sessions last one hour and live only in memory, so restarting the server invalidates them.
**Lost your authenticator?** Delete the secret and restart:
```bash
rm .totp_secret
```
There is no recovery-code fallback by design — that file is the only thing holding the secret, and it stays on your machine.
## Behind a reverse proxy
To serve at a subdirectory, pass `--base-path`:
```bash
cargo run -- --base-path /forgejo
```
```apache
ProxyPass /forgejo/ http://127.0.0.1:3000/ timeout=10
ProxyPassReverse /forgejo/ http://127.0.0.1:3000/
```
The proxy strips the prefix, so the app still sees ordinary paths and adds the prefix back when generating links, form actions, and redirects. The session cookie's `Path` is scoped to the prefix, so the token is not sent to sibling apps on the same host.
## Command Line Options
```
Usage: eternalibre-notes [OPTIONS]
Options:
--host <HOST> Host address to bind to [default: 0.0.0.0]
-p, --port <PORT> Port to listen on [default: 3000]
--notes-dir <NOTES_DIR> Directory holding note files [default: notes]
--base-path <BASE_PATH> Path prefix when behind a reverse proxy subdirectory
-h, --help Print help
```
## Development
```bash
@ -50,6 +108,25 @@ cargo fmt
|----------|--------|
| `Ctrl/Cmd + N` | Create a new note |
| `Ctrl/Cmd + S` | Save the current note |
| `Escape` | Close any open menu |
## Project Structure
| Path | Purpose |
|------|---------|
| `src/main.rs` | Server entry point, CLI parsing, first-run terminal setup |
| `src/lib.rs` | Library root, so `tests/` can drive the HTTP layer |
| `src/auth.rs` | TOTP secret, session store, and verification |
| `src/setup.rs` | First-run terminal enrolment |
| `src/routes.rs` | HTTP routes, auth gate, base-path joining |
| `src/pages.rs` | Maud SSR templates and URL generation |
| `src/notes.rs` | Note model and file-system persistence |
| `src/markdown.rs` | Markdown to HTML with syntax highlighting |
| `src/syntax.rs` | syntect integration and per-theme tmTheme generation |
| `src/themes.rs` | 24 predefined themes and CSS variable engine |
| `static/style.css` | Full application stylesheet |
| `static/app.js` | Alpine.js component: preview, search, menus, tabs |
| `tests/auth_gating.rs` | End-to-end tests for the TOTP auth gate |
## License

559
src/auth.rs Normal file
View file

@ -0,0 +1,559 @@
use qrcode::render::svg;
use qrcode::QrCode;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::time::{Duration, Instant};
use totp_rs::{Algorithm, Builder, Secret, Totp};
/// File holding the Base32 TOTP secret. Deleting it re-runs setup, which is
/// the documented recovery path for a lost authenticator.
const SECRET_FILE: &str = ".totp_secret";
/// Sessions are intentionally short: this app may be reachable from the
/// network, so a one-hour lifetime bounds the value of a stolen cookie.
const SESSION_TTL: Duration = Duration::from_secs(60 * 60);
pub const SESSION_COOKIE: &str = "eternalibre_session";
/// Issuer label shown in the authenticator app.
const ISSUER: &str = "EternaLibre Notes";
const ACCOUNT: &str = "local";
const DIGITS: u8 = 6;
const PERIOD: u64 = 30;
/// Accept the neighbouring time step too, so ~30s of clock drift either way
/// still validates.
const SKEW: u16 = 1;
struct Inner {
/// Committed secret, loaded from disk at startup.
secret: Option<String>,
/// Secret shown in the QR code but not yet confirmed by the user. Only
/// written to disk once a valid code proves the user can read it.
pending_secret: Option<String>,
sessions: HashMap<String, Instant>,
}
pub struct AuthState {
inner: RwLock<Inner>,
path: PathBuf,
}
impl Default for AuthState {
fn default() -> Self {
Self::new()
}
}
impl AuthState {
pub fn new() -> Self {
Self::with_secret_path(SECRET_FILE)
}
/// `path` is injectable so tests never touch the real `.totp_secret`.
pub fn with_secret_path(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let secret = std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Self {
inner: RwLock::new(Inner {
secret,
pending_secret: None,
sessions: HashMap::new(),
}),
path,
}
}
/// True until a TOTP secret has been committed to disk.
///
/// The server consults this once at startup to decide whether to run the
/// terminal enrolment flow before binding the listener.
pub fn needs_setup(&self) -> bool {
self.inner.read().unwrap().secret.is_none()
}
/// Generates a fresh secret and returns everything the terminal enrolment
/// flow needs: an inline SVG QR code, the plaintext secret for manual
/// entry, and the `otpauth://` URI.
///
/// Regenerating replaces `pending_secret` and invalidates any previously
/// displayed QR code, so the code on screen is always the one that works.
pub fn begin_setup(&self) -> Result<SetupMaterial, String> {
let totp = build_totp(Secret::generate()).map_err(|e| e.to_string())?;
let encoded = totp.secret().to_base32();
let uri = totp.to_url().map_err(|e| e.to_string())?;
let code = QrCode::new(uri.as_bytes()).map_err(|e| e.to_string())?;
let qr_svg = code
.render::<svg::Color>()
.min_dimensions(240, 240)
.quiet_zone(true)
.build();
self.inner.write().unwrap().pending_secret = Some(encoded.clone());
Ok(SetupMaterial {
secret: encoded,
qr_svg,
qr_uri: uri,
})
}
/// Verifies a code against the pending secret. On success the secret is
/// committed to disk and a session token is returned.
pub fn verify_setup(&self, code: &str) -> Result<String, AuthError> {
let mut guard = self.inner.write().unwrap();
let pending = guard
.pending_secret
.clone()
.ok_or(AuthError::NoPendingSecret)?;
if !verify_code(&pending, code) {
return Err(AuthError::InvalidCode);
}
// Only persist once the user has proven they can read the code, so a
// half-finished setup never locks them out.
write_secret_file(&self.path, &pending).map_err(|_| AuthError::PersistFailed)?;
guard.secret = Some(pending);
guard.pending_secret = None;
Ok(new_session_token(&mut guard))
}
pub fn verify_login(&self, code: &str) -> Result<String, AuthError> {
let mut guard = self.inner.write().unwrap();
let secret = guard.secret.as_ref().ok_or(AuthError::NotProvisioned)?;
if !verify_code(secret, code) {
return Err(AuthError::InvalidCode);
}
Ok(new_session_token(&mut guard))
}
/// Validates a session token, pruning expired entries as a side effect.
pub fn validate_session(&self, token: &str) -> bool {
let mut guard = self.inner.write().unwrap();
prune(&mut guard);
guard.sessions.contains_key(token)
}
pub fn destroy_session(&self, token: &str) {
self.inner.write().unwrap().sessions.remove(token);
}
/// Number of live sessions, after pruning expired ones. Used by the
/// integration tests to assert that a token is really tracked server-side.
#[allow(dead_code)]
pub fn session_count(&self) -> usize {
let mut guard = self.inner.write().unwrap();
prune(&mut guard);
guard.sessions.len()
}
}
pub struct SetupMaterial {
pub secret: String,
pub qr_svg: String,
/// The `otpauth://` URI encoded in `qr_svg`. Kept alongside the rendered
/// markup so the terminal setup flow can print it as text without
/// regenerating the QR.
pub qr_uri: String,
}
#[derive(Debug, PartialEq, Eq)]
pub enum AuthError {
InvalidCode,
NoPendingSecret,
NotProvisioned,
PersistFailed,
}
impl std::fmt::Display for AuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let msg = match self {
AuthError::InvalidCode => {
"That code was not accepted. Check your authenticator and try again."
}
AuthError::NoPendingSecret => "No setup is in progress. Reload the setup page.",
AuthError::NotProvisioned => "This device has not been set up yet.",
AuthError::PersistFailed => "Could not write the secret to disk.",
};
f.write_str(msg)
}
}
fn build_totp(secret: Secret) -> Result<Totp, totp_rs::TotpError> {
Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_digits(DIGITS)
.with_skew(SKEW)
.with_step_duration(PERIOD)
.with_account_name(ACCOUNT)
.with_issuer(Some(ISSUER))
.with_secret(secret)
.build()
}
/// The secret must parse to a valid length before we even look at the code,
/// so a corrupt `.totp_secret` fails closed rather than accepting anything.
fn verify_code(encoded_secret: &str, code: &str) -> bool {
let secret = match Secret::try_from_base32(encoded_secret) {
Ok(s) => s,
Err(_) => return false,
};
let totp = match build_totp(secret) {
Ok(t) => t,
Err(_) => return false,
};
let code = code.trim().replace([' ', '-'], "");
if code.len() != DIGITS as usize || !code.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
totp.check_current(&code).is_some()
}
fn prune(guard: &mut Inner) {
let now = Instant::now();
guard.sessions.retain(|_, expiry| *expiry > now);
}
fn new_session_token(guard: &mut Inner) -> String {
let token = generate_token();
guard
.sessions
.insert(token.clone(), Instant::now() + SESSION_TTL);
token
}
/// 32 hex-encoded bytes (64 chars) of OS-seeded randomness. `rand::rng()` is
/// a thread-local generator seeded from the operating system, which is
/// appropriate for session tokens.
fn generate_token() -> String {
use rand::RngExt;
let mut rng = rand::rng();
(0..32)
.map(|_| {
let b: u8 = rng.random();
format!("{b:02x}")
})
.collect()
}
fn write_secret_file(path: &Path, secret: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, secret)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// The secret is the only thing standing between a stolen filesystem
// and a forged TOTP stream.
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_secret_path(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!(
"eternalibre-auth-{}-{}-{}",
tag,
std::process::id(),
n
));
p.set_extension("secret");
let _ = std::fs::remove_file(&p);
p
}
/// A TOTP for the same secret the AuthState holds, i.e. what a real
/// authenticator app displays right now.
fn current_code(encoded_secret: &str) -> String {
let totp = build_totp(Secret::try_from_base32(encoded_secret).unwrap()).unwrap();
totp.generate_current().to_string()
}
#[test]
fn fresh_state_needs_setup() {
let path = temp_secret_path("fresh");
let auth = AuthState::with_secret_path(&path);
assert!(auth.needs_setup());
assert_eq!(auth.session_count(), 0);
}
#[test]
fn setup_material_contains_qr_and_secret() {
let path = temp_secret_path("material");
let auth = AuthState::with_secret_path(&path);
let material = auth.begin_setup().unwrap();
assert!(material.qr_svg.contains("<svg"));
assert!(material.secret.len() >= 16);
}
#[test]
fn setup_commits_secret_and_issues_session() {
let path = temp_secret_path("setup");
let auth = AuthState::with_secret_path(&path);
let material = auth.begin_setup().unwrap();
let token = auth.verify_setup(&current_code(&material.secret)).unwrap();
assert!(!auth.needs_setup());
assert!(auth.validate_session(&token));
assert_eq!(
std::fs::read_to_string(&path).unwrap().trim(),
material.secret
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn setup_rejects_wrong_code_and_writes_nothing() {
let path = temp_secret_path("badsetup");
let auth = AuthState::with_secret_path(&path);
auth.begin_setup().unwrap();
assert_eq!(auth.verify_setup("000000"), Err(AuthError::InvalidCode));
assert!(auth.needs_setup(), "secret must not commit on failure");
assert!(!path.exists(), "no file should be written on failure");
}
#[test]
fn login_verifies_against_stored_secret() {
let path = temp_secret_path("login");
let auth = AuthState::with_secret_path(&path);
let material = auth.begin_setup().unwrap();
auth.verify_setup(&current_code(&material.secret)).unwrap();
let token = auth.verify_login(&current_code(&material.secret)).unwrap();
assert!(auth.validate_session(&token));
let _ = std::fs::remove_file(&path);
}
#[test]
fn login_rejects_wrong_code() {
let path = temp_secret_path("badlogin");
let auth = AuthState::with_secret_path(&path);
let material = auth.begin_setup().unwrap();
auth.verify_setup(&current_code(&material.secret)).unwrap();
assert_eq!(auth.verify_login("111111"), Err(AuthError::InvalidCode));
let _ = std::fs::remove_file(&path);
}
#[test]
fn login_before_setup_is_rejected() {
let path = temp_secret_path("nosetup");
let auth = AuthState::with_secret_path(&path);
assert_eq!(auth.verify_login("123456"), Err(AuthError::NotProvisioned));
}
#[test]
fn malformed_codes_are_rejected_without_panicking() {
let path = temp_secret_path("malformed");
let auth = AuthState::with_secret_path(&path);
let material = auth.begin_setup().unwrap();
auth.verify_setup(&current_code(&material.secret)).unwrap();
for bad in [
"",
" ",
"abcdef",
"12345",
"1234567",
"12 34 56 78",
"𝟙𝟚𝟛𝟒𝟓𝟔",
] {
assert_eq!(
auth.verify_login(bad),
Err(AuthError::InvalidCode),
"expected {bad:?} to be rejected"
);
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn spaced_codes_are_accepted() {
let path = temp_secret_path("spaced");
let auth = AuthState::with_secret_path(&path);
let material = auth.begin_setup().unwrap();
auth.verify_setup(&current_code(&material.secret)).unwrap();
let code = current_code(&material.secret);
let spaced = format!("{} {}", &code[..3], &code[3..]);
let token = auth.verify_login(&spaced).unwrap();
assert!(auth.validate_session(&token));
let _ = std::fs::remove_file(&path);
}
#[test]
fn corrupt_secret_file_fails_closed() {
let path = temp_secret_path("corrupt");
std::fs::write(&path, "not!valid!base32!!").unwrap();
let auth = AuthState::with_secret_path(&path);
// Provisioned (a file exists) but unusable, so login can never pass.
for code in ["000000", "123456", "999999"] {
assert_eq!(auth.verify_login(code), Err(AuthError::InvalidCode));
}
assert_eq!(auth.session_count(), 0);
let _ = std::fs::remove_file(&path);
}
#[test]
fn unknown_session_token_is_invalid() {
let path = temp_secret_path("unknown");
let auth = AuthState::with_secret_path(&path);
assert!(!auth.validate_session("not-a-real-token"));
}
#[test]
fn destroyed_session_stops_validating() {
let path = temp_secret_path("destroy");
let auth = AuthState::with_secret_path(&path);
let material = auth.begin_setup().unwrap();
let token = auth.verify_setup(&current_code(&material.secret)).unwrap();
assert!(auth.validate_session(&token));
auth.destroy_session(&token);
assert!(!auth.validate_session(&token));
let _ = std::fs::remove_file(&path);
}
#[test]
fn expired_sessions_are_pruned() {
let path = temp_secret_path("expire");
let auth = AuthState::with_secret_path(&path);
{
let mut guard = auth.inner.write().unwrap();
guard
.sessions
.insert("stale".to_string(), Instant::now() - Duration::from_secs(1));
guard
.sessions
.insert("fresh".to_string(), Instant::now() + SESSION_TTL);
}
assert!(auth.validate_session("fresh"));
assert!(!auth.validate_session("stale"));
assert_eq!(auth.session_count(), 1);
}
#[test]
fn deleting_secret_file_reopens_setup() {
let path = temp_secret_path("recover");
let auth = AuthState::with_secret_path(&path);
let material = auth.begin_setup().unwrap();
auth.verify_setup(&current_code(&material.secret)).unwrap();
assert!(!auth.needs_setup());
std::fs::remove_file(&path).unwrap();
let reopened = AuthState::with_secret_path(&path);
assert!(
reopened.needs_setup(),
"removing .totp_secret should allow a fresh setup"
);
}
#[test]
fn regenerating_setup_replaces_the_pending_secret() {
let path = temp_secret_path("regen");
let auth = AuthState::with_secret_path(&path);
let first = auth.begin_setup().unwrap().secret;
let second = auth.begin_setup().unwrap().secret;
assert_ne!(first, second);
// The QR code actually on screen is the one that must work.
let token = auth.verify_setup(&current_code(&second)).unwrap();
assert!(auth.validate_session(&token));
let _ = std::fs::remove_file(&path);
}
#[test]
fn each_setup_generates_a_distinct_secret() {
let a = build_totp(Secret::generate()).unwrap().secret().to_base32();
let b = build_totp(Secret::generate()).unwrap().secret().to_base32();
assert_ne!(a, b);
}
#[test]
fn session_tokens_are_unique_and_long() {
let a = generate_token();
let b = generate_token();
assert_ne!(a, b);
assert_eq!(a.len(), 64, "32 bytes hex-encoded");
}
/// Confirms the ±1 step window: a code from the previous or next 30s
/// window must still be accepted, since phone clocks drift.
#[test]
fn neighbouring_time_steps_are_accepted() {
let path = temp_secret_path("skew");
let auth = AuthState::with_secret_path(&path);
let material = auth.begin_setup().unwrap();
let totp = build_totp(Secret::try_from_base32(&material.secret).unwrap()).unwrap();
auth.verify_setup(&current_code(&material.secret)).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
// skew = 1 means exactly one 30s step either side, not two.
for offset in [-30i64, 30] {
let code = totp.generate((now as i64 + offset) as u64).to_string();
assert!(
verify_code(&material.secret, &code),
"code from {offset}s should be accepted"
);
}
let _ = std::fs::remove_file(&path);
}
/// Two windows away is outside the ±1 skew and must be rejected, so the
/// tolerance is not accidentally wider than intended.
#[test]
fn distant_time_steps_are_rejected() {
let path = temp_secret_path("skew-far");
let auth = AuthState::with_secret_path(&path);
let material = auth.begin_setup().unwrap();
let totp = build_totp(Secret::try_from_base32(&material.secret).unwrap()).unwrap();
auth.verify_setup(&current_code(&material.secret)).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
// Two or more steps out is outside the ±1 window.
for offset in [-60i64, 60, -300, 300] {
let code = totp.generate((now as i64 + offset) as u64).to_string();
assert!(
!verify_code(&material.secret, &code),
"code from {offset}s is outside the skew window and must be rejected"
);
}
let _ = std::fs::remove_file(&path);
}
}

13
src/lib.rs Normal file
View file

@ -0,0 +1,13 @@
//! EternaLibre Notes — a server-side rendered, copyleft Markdown notes application.
//!
//! The crate is split into a library plus a thin binary so that the HTTP layer
//! can be exercised from integration tests in `tests/`.
pub mod auth;
pub mod markdown;
pub mod notes;
pub mod pages;
pub mod routes;
pub mod setup;
pub mod syntax;
pub mod themes;

View file

@ -1,18 +1,10 @@
mod markdown;
mod notes;
mod pages;
mod routes;
mod storage;
mod syntax;
mod themes;
use axum::Router;
use clap::Parser;
use eternalibre_notes::routes::{self, AppState};
use eternalibre_notes::{auth, notes, setup, syntax};
use std::net::SocketAddr;
use tower_http::services::ServeDir;
#[derive(Parser, Debug)]
#[command(name = "rasuti-notes")]
#[command(name = "eternalibre-notes")]
#[command(about = "A server-side rendered Markdown notes application")]
struct Args {
/// Host address to bind to
@ -22,26 +14,72 @@ struct Args {
/// Port to listen on
#[arg(short, long, default_value_t = 3000)]
port: u16,
/// Directory holding note files
#[arg(long, default_value = "notes")]
notes_dir: String,
/// Path prefix when served behind a reverse proxy subdirectory.
/// Example: --base-path /forgejo when the app answers at
/// https://host/forgejo/. Leave empty when mounted at the site root.
#[arg(long, default_value = "")]
base_path: String,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
// Ensure notes directory exists
std::fs::create_dir_all("notes").expect("Failed to create notes directory");
// Route note storage through the env var the library reads, so the binary
// flag and the library agree on one location.
std::env::set_var("ETERNALIBRE_NOTES_DIR", &args.notes_dir);
// Eagerly initialize syntax highlighting state so the first request isn't slow.
std::fs::create_dir_all(notes::notes_dir()).expect("Failed to create notes directory");
// Eagerly initialize syntax highlighting so the first request isn't slow.
let _ = syntax::syntax_state();
let app = Router::new()
.merge(routes::router())
.nest_service("/static", ServeDir::new("static"));
let auth = std::sync::Arc::new(auth::AuthState::new());
// First-run TOTP enrolment happens here, on the terminal, before the
// listener binds. Nothing is listening yet, so there is no window in
// which an unauthenticated peer could claim the device.
if auth.needs_setup() {
if let Err(e) = setup::run_interactive_setup(&auth) {
eprintln!("\n Setup failed: {e}");
std::process::exit(1);
}
} else {
println!("TOTP is enabled; a code is required on every visit.");
}
let base_path = routes::normalize_base_path(&args.base_path);
if !base_path.is_empty() {
println!("Serving under base path {base_path}");
}
let state = AppState {
auth,
base_path: base_path.into(),
};
let base = state.base_path.to_string();
// NOTE: static assets are mounted app-absolute by `routes::app`, never
// under the base path. A reverse proxy strips the prefix before
// forwarding, so `/forgejo/static/app.js` arrives here as
// `/static/app.js`. See the comment on `routes::app` for the full
// invariant and the failure it causes when broken.
let app = routes::app(state);
let host: std::net::IpAddr = args.host.parse().expect("Invalid host address");
let addr = SocketAddr::from((host, args.port));
println!("Rasuti Notes running at http://{}", addr);
let url = if base.is_empty() {
format!("http://{addr}")
} else {
format!("http://{addr}{base}/")
};
println!("EternaLibre Notes on {url}");
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();

View file

@ -2,7 +2,19 @@ use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
/// Directory holding note files.
///
/// Overridable via `ETERNALIBRE_NOTES_DIR` so integration tests can run against a
/// throwaway directory instead of the user's real notes.
pub fn notes_dir() -> PathBuf {
PathBuf::from(std::env::var("ETERNALIBRE_NOTES_DIR").unwrap_or_else(|_| "notes".to_string()))
}
fn note_path(id: &str) -> PathBuf {
notes_dir().join(format!("{id}.md"))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Note {
@ -43,7 +55,11 @@ impl Note {
pub fn list_notes() -> Result<Vec<Note>> {
let mut notes = Vec::new();
let entries = fs::read_dir("notes")?;
let dir = notes_dir();
if !dir.exists() {
return Ok(notes);
}
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
@ -60,8 +76,7 @@ pub fn list_notes() -> Result<Vec<Note>> {
}
pub fn load_note(id: &str) -> Result<Note> {
let path = format!("notes/{}.md", id);
load_note_from_path(Path::new(&path))
load_note_from_path(&note_path(id))
}
fn load_note_from_path(path: &Path) -> Result<Note> {
@ -123,7 +138,7 @@ fn parse_frontmatter(content: &str) -> (String, String, DateTime<Utc>, DateTime<
}
pub fn save_note(note: &Note) -> Result<()> {
let path = format!("notes/{}.md", note.id);
fs::create_dir_all(notes_dir())?;
let frontmatter = format!(
"---\ntitle: {}\ncreated: {}\nupdated: {}\n---\n{}",
note.title,
@ -131,13 +146,12 @@ pub fn save_note(note: &Note) -> Result<()> {
note.updated_at.to_rfc3339(),
note.content
);
fs::write(path, frontmatter)?;
fs::write(note_path(&note.id), frontmatter)?;
Ok(())
}
pub fn delete_note(id: &str) -> Result<()> {
let path = format!("notes/{}.md", id);
fs::remove_file(path)?;
fs::remove_file(note_path(id))?;
Ok(())
}
@ -173,7 +187,7 @@ mod tests {
#[test]
fn crud_lifecycle() {
std::fs::create_dir_all("notes").unwrap();
std::fs::create_dir_all(notes_dir()).unwrap();
let note = create_note("CRUD Test", "Initial content").unwrap();
let loaded = load_note(&note.id).unwrap();
assert_eq!(loaded.title, "CRUD Test");
@ -190,7 +204,7 @@ mod tests {
#[test]
fn derives_title_from_heading() {
std::fs::create_dir_all("notes").unwrap();
std::fs::create_dir_all(notes_dir()).unwrap();
let note = create_note("Fallback", "# Real Title\n\nSome content.").unwrap();
let loaded = load_note(&note.id).unwrap();
// The stored title is what was passed to create_note, but the file content has a heading.

View file

@ -4,12 +4,32 @@ use crate::themes::{get_theme, PREDEFINED_THEMES};
use chrono::{DateTime, Utc};
use maud::{html, Markup, PreEscaped, DOCTYPE};
/// Base path prefix for generated URLs.
///
/// Wraps the configured prefix so every `href`/`action`/`src` in the templates
/// goes through one call. Empty means mounted at the root, which is the
/// default and what most installs use.
#[derive(Clone, Copy)]
pub struct Base<'a>(&'a str);
impl<'a> Base<'a> {
pub fn new(prefix: &'a str) -> Self {
Base(prefix)
}
/// Joins the prefix onto an app-absolute path.
pub fn url(&self, path: &str) -> String {
format!("{}{}", self.0, path)
}
}
pub fn render_app(
theme_name: &str,
notes: &[Note],
current_note: Option<&Note>,
query: Option<&str>,
message: Option<&str>,
base: &Base<'_>,
) -> Markup {
let theme = get_theme(theme_name);
let css = theme.to_css_block();
@ -31,21 +51,22 @@ pub fn render_app(
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover";
title { "Rasuti Notes" }
title { "EternaLibre Notes" }
style { (PreEscaped(css)) }
style { (PreEscaped(include_str!("../static/style.css"))) }
script defer src="/static/alpine.min.js" {}
script defer src=(base.url("/static/alpine.min.js")) {}
}
body x-data="app()" x-on:keydown="handleKeydown($event)" {
(render_menu_bar())
(render_toolbar(current_note, query, theme_name))
(render_menu_bar(base))
(render_toolbar(current_note, query, theme_name, base))
@if let Some(msg) = message {
div class="flash-message" { (msg) }
}
(render_tab_bar(default_tab))
main class="app-body" {
(render_sidebar(notes, current_note, default_tab))
(render_editor(current_note, default_tab))
main class="app-body"
x-bind:class="{ 'hide-editor': !editorVisible, 'hide-preview': !previewVisible }" {
(render_sidebar(notes, current_note, default_tab, base))
(render_editor(current_note, default_tab, base))
(render_preview(&rendered_preview, default_tab))
}
(render_status_bar(notes.len(), theme_name))
@ -97,7 +118,7 @@ fn render_tab_bar(default_tab: &str) -> Markup {
}
}
fn render_menu_bar() -> Markup {
fn render_menu_bar(base: &Base<'_>) -> Markup {
html! {
nav class="menu-bar" {
ul class="menu-list" {
@ -107,13 +128,13 @@ fn render_menu_bar() -> Markup {
span { "File" }
ul class="menu-dropdown" {
li {
form action="/notes" method="post" {
form action=(base.url("/notes")) method="post" {
button type="submit" { "New Note" }
}
}
li { hr; }
li {
a href="/export" { "Export All Notes" }
a href=(base.url("/export")) { "Export All Notes" }
}
}
}
@ -131,8 +152,26 @@ fn render_menu_bar() -> Markup {
x-bind:class="{ 'open': openMenu === 'view' }" {
span { "View" }
ul class="menu-dropdown" {
li { a href="/" { "Show All Notes" } }
li { button x-on:click="togglePreview()" { "Toggle Preview" } }
li { a href=(base.url("/")) { "Show All Notes" } }
// Pane collapsing is a desktop split-view affordance.
// On mobile the tab bar already decides which single
// pane is shown, so these are hidden there; see
// `.desktop-only` in style.css.
li class="desktop-only" { hr; }
li class="desktop-only" {
button x-on:click="toggleEditor()"
x-bind:class="{ 'active-view': !editorVisible }"
x-text="editorVisible ? 'Hide Editor' : 'Show Editor'" {
"Toggle Editor"
}
}
li class="desktop-only" {
button x-on:click="togglePreview()"
x-bind:class="{ 'active-view': !previewVisible }"
x-text="previewVisible ? 'Hide Preview' : 'Show Preview'" {
"Toggle Preview"
}
}
}
}
li class="menu-item" tabindex="0"
@ -142,7 +181,7 @@ fn render_menu_bar() -> Markup {
ul class="menu-dropdown" {
@for theme in PREDEFINED_THEMES {
li {
form action="/theme" method="post" {
form action=(base.url("/theme")) method="post" {
input type="hidden" name="theme" value=(theme.name);
button type="submit" { (theme.name) }
}
@ -155,32 +194,37 @@ fn render_menu_bar() -> Markup {
x-bind:class="{ 'open': openMenu === 'help' }" {
span { "Help" }
ul class="menu-dropdown" {
li { a href="/about" { "About Rasuti Notes" } }
li { a href=(base.url("/about")) { "About EternaLibre Notes" } }
li { a href="https://www.gnu.org/licenses/gpl-3.0.html" target="_blank" { "GPL v3 License" } }
}
}
}
span class="app-title" { "Rasuti Notes ラスティ v0.1" }
span class="app-title" { "EternaLibre Notes v0.1" }
}
}
}
fn render_toolbar(current_note: Option<&Note>, query: Option<&str>, theme_name: &str) -> Markup {
fn render_toolbar(
current_note: Option<&Note>,
query: Option<&str>,
theme_name: &str,
base: &Base<'_>,
) -> Markup {
html! {
div class="toolbar" {
div class="toolbar-group" {
form action="/notes" method="post" {
form action=(base.url("/notes")) method="post" {
button type="submit" class="btn btn-primary" { "+ New Note" }
}
@if let Some(note) = current_note {
form action=(format!("/notes/{}", note.id)) method="post" {
form action=(base.url(&format!("/notes/{}", note.id))) method="post" {
input type="hidden" name="_method" value="delete";
button type="submit" class="btn btn-danger" { "Delete" }
}
}
}
div class="toolbar-group" {
form action="/" method="get" class="search-form" {
form action=(base.url("/")) method="get" class="search-form" {
input
type="search"
name="q"
@ -190,7 +234,7 @@ fn render_toolbar(current_note: Option<&Note>, query: Option<&str>, theme_name:
}
}
div class="toolbar-group" {
form action="/theme" method="post" class="theme-form" x-on:change="$event.target.form.submit()" {
form action=(base.url("/theme")) method="post" class="theme-form" x-on:change="$event.target.form.submit()" {
label { "Theme:" }
select name="theme" {
@for theme in PREDEFINED_THEMES {
@ -198,13 +242,17 @@ fn render_toolbar(current_note: Option<&Note>, query: Option<&str>, theme_name:
}
}
}
button type="button" class="btn" x-on:click="togglePreview()" { "Preview" }
}
}
}
}
fn render_sidebar(notes: &[Note], current_note: Option<&Note>, default_tab: &str) -> Markup {
fn render_sidebar(
notes: &[Note],
current_note: Option<&Note>,
default_tab: &str,
base: &Base<'_>,
) -> Markup {
let active = if default_tab == "notes" {
" mobile-active"
} else {
@ -222,7 +270,7 @@ fn render_sidebar(notes: &[Note], current_note: Option<&Note>, default_tab: &str
li class={"note-item " (if current_note.map(|n| n.id == note.id).unwrap_or(false) { "active" } else { "" })}
data-title=(note.title.to_lowercase())
data-content=(note.content.to_lowercase()) {
a href=(format!("/notes/{}", note.id)) {
a href=(base.url(&format!("/notes/{}", note.id))) {
div class="note-title" { (note.title) }
div class="note-meta" { (relative_time(note.updated_at)) }
div class="note-preview" { (note.preview(80)) }
@ -235,7 +283,7 @@ fn render_sidebar(notes: &[Note], current_note: Option<&Note>, default_tab: &str
}
}
fn render_editor(current_note: Option<&Note>, default_tab: &str) -> Markup {
fn render_editor(current_note: Option<&Note>, default_tab: &str, base: &Base<'_>) -> Markup {
let active = if default_tab == "write" {
" mobile-active"
} else {
@ -248,7 +296,7 @@ fn render_editor(current_note: Option<&Note>, default_tab: &str) -> Markup {
@if let Some(note) = current_note {
form
id="note-form"
action=(format!("/notes/{}", note.id))
action=(base.url(&format!("/notes/{}", note.id)))
method="post"
x-on:submit="saving = true"
class="note-form" {
@ -291,7 +339,7 @@ fn render_preview(html_content: &str, default_tab: &str) -> Markup {
};
html! {
section class={"preview-pane" (active)} data-tab-pane="preview"
x-bind:class="{ 'hidden': !previewVisible, 'mobile-active': tab === 'preview' }" {
x-bind:class="{ 'mobile-active': tab === 'preview' }" {
div id="preview" class="markdown-body" x-html="previewHtml" {
(PreEscaped(html_content))
}
@ -317,24 +365,105 @@ pub fn render_preview_fragment(html_content: &str) -> Markup {
}
}
pub fn render_about(theme_name: &str) -> Markup {
/// Renders the `/login` page.
///
/// TOTP enrolment lives in the terminal (`setup::run_interactive_setup`) and
/// completes before the listener binds, so there is deliberately no setup
/// variant of this page: an HTTP-reachable enrolment endpoint would let
/// anyone who can reach the port before the operator finish claim the
/// device. `error` is displayed inline above the form.
pub fn render_auth_page(theme_name: &str, error: Option<&str>, base: &Base<'_>) -> Markup {
let theme = get_theme(theme_name);
html! {
(DOCTYPE)
html lang="en" data-theme=(theme_name) {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "Unlock - EternaLibre Notes" }
style { (PreEscaped(theme.to_css_block())) }
style { (PreEscaped(include_str!("../static/style.css"))) }
}
body class="auth-page" {
main class="auth-card" {
h1 { "EternaLibre Notes" }
p class="auth-subtitle" {
"Enter the 6-digit code from your authenticator app."
}
@if let Some(err) = error {
div class="auth-error" role="alert" { (err) }
}
form method="post" action=(base.url("/login")) class="auth-form" {
label for="code" { "6-digit code" }
input
type="text"
id="code"
name="code"
class="auth-input"
placeholder="000000"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
maxlength="6"
required
autofocus
spellcheck="false";
button type="submit" class="btn btn-primary" { "Unlock" }
}
p class="auth-footer" { "EternaLibre Notes" }
}
}
}
}
}
pub fn render_about(theme_name: &str, base: &Base<'_>) -> Markup {
let theme = get_theme(theme_name);
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
title { "About - Rasuti Notes" }
title { "About - EternaLibre Notes" }
style { (PreEscaped(theme.to_css_block())) }
style { (PreEscaped(include_str!("../static/style.css"))) }
}
body class="about-page" {
(render_menu_bar())
(render_menu_bar(base))
main {
h1 { "Rasuti Notes" }
p { "A server-side rendered Markdown notes application written in Rust." }
p { "Licensed under the GNU General Public License v3.0 or later." }
a href="/" { "Back to notes" }
h1 { "EternaLibre Notes" }
p class="tagline" { "Free notes, forever." }
p {
"A server-side rendered, copyleft Markdown notes application written in Rust. \
A toolkit for making a private notes cloud: run the server yourself, \
on your own hardware, and the notes go nowhere else."
}
p {
"The server binds to all interfaces by default, so you can reach your notes \
from a phone or another machine on your network. It is protected by a TOTP \
code from your authenticator app, and it never talks to a third party."
}
ul class="about-features" {
// Counted from the palette itself. This number was
// previously hardcoded and had drifted to "28" for
// what was really 25 themes.
li {
(format!("{} built-in themes with a full ANSI palette",
crate::themes::PREDEFINED_THEMES.len()))
}
li { "Syntax highlighting for fenced code blocks" }
li { "Notes stored as plain Markdown files on disk" }
li { "TOTP-locked, with no accounts or third-party services" }
li { "Note editing, search, and theming work without JavaScript" }
}
p {
"Licensed under the GNU General Public License v3.0 or later. \
No telemetry, no accounts, no vendor."
}
a href=(base.url("/")) { "Back to notes" }
}
(render_status_bar(0, theme_name))
}

View file

@ -1,19 +1,117 @@
use axum::{
extract::{Form, Path, Query},
http::HeaderMap,
response::{Html, IntoResponse, Redirect},
extract::{Form, Path, Query, State},
http::{header, HeaderMap, StatusCode},
middleware::{self, Next},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
Router,
};
use serde::Deserialize;
use std::sync::Arc;
use crate::auth::{AuthError, AuthState, SESSION_COOKIE};
use crate::markdown::render_markdown;
use crate::notes::{self, Note};
use crate::pages;
use crate::pages::{self, Base};
use crate::themes::get_theme;
pub fn router() -> Router {
Router::new()
#[derive(Clone)]
pub struct AppState {
pub auth: Arc<AuthState>,
/// Path prefix when served behind a reverse proxy subdirectory.
/// Empty means mounted at the root, which is the default and the only
/// configuration most installs need.
pub base_path: Arc<str>,
}
impl AppState {
/// Joins an app-absolute path onto the configured base path.
///
/// Used for every `Location` header. HTML links and form actions get the
/// same treatment via `Base` in `pages.rs`.
pub fn url(&self, path: &str) -> String {
format!("{}{}", self.base_path, path)
}
}
/// Normalizes a user-supplied base path.
///
/// `""` and `"/"` both mean "mounted at the root". Anything else keeps a
/// leading slash and loses any trailing one, so `--base-path forgejo/`,
/// `/forgejo`, and `/forgejo/` all normalize to `/forgejo`.
pub fn normalize_base_path(raw: &str) -> String {
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
String::new()
} else if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{trimmed}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_and_root_both_mean_no_prefix() {
assert_eq!(normalize_base_path(""), "");
assert_eq!(normalize_base_path("/"), "");
assert_eq!(normalize_base_path(" "), "");
}
#[test]
fn prefix_keeps_one_leading_slash_and_drops_trailing_ones() {
assert_eq!(normalize_base_path("/forgejo"), "/forgejo");
assert_eq!(normalize_base_path("forgejo"), "/forgejo");
assert_eq!(normalize_base_path("/forgejo/"), "/forgejo");
assert_eq!(normalize_base_path("forgejo/"), "/forgejo");
assert_eq!(normalize_base_path("/forgejo///"), "/forgejo");
assert_eq!(normalize_base_path(" /forgejo "), "/forgejo");
}
#[test]
fn nested_prefixes_survive() {
assert_eq!(normalize_base_path("/a/b"), "/a/b");
assert_eq!(normalize_base_path("a/b/"), "/a/b");
}
#[test]
fn url_joins_prefix_onto_app_paths() {
let root = AppState {
auth: Arc::new(AuthState::with_secret_path("/nonexistent-test-secret")),
base_path: "".into(),
};
assert_eq!(root.url("/"), "/");
assert_eq!(root.url("/about"), "/about");
let mounted = AppState {
auth: root.auth.clone(),
base_path: "/forgejo".into(),
};
assert_eq!(mounted.url("/"), "/forgejo/");
assert_eq!(mounted.url("/about"), "/forgejo/about");
assert_eq!(mounted.url("/notes/abc"), "/forgejo/notes/abc");
}
#[test]
fn cookie_path_is_scoped_or_default() {
assert_eq!(cookie_path(""), "/");
assert_eq!(cookie_path("/forgejo"), "/forgejo");
}
}
pub fn router(state: AppState) -> Router {
// Auth routes must be reachable while logged out, so they are merged
// outside the protected layer. TOTP setup is deliberately absent: it
// completes in the terminal before the listener ever binds, so there is
// no window in which an unauthenticated peer could claim the device.
let public = Router::new()
.route("/login", get(login_page).post(login_submit))
.route("/logout", post(logout));
let protected = Router::new()
.route("/", get(index))
.route("/notes", post(create_note_route))
.route(
@ -24,23 +122,199 @@ pub fn router() -> Router {
.route("/theme", post(theme_route))
.route("/about", get(about_route))
.route("/export", get(export_route))
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth));
public.merge(protected).with_state(state)
}
fn get_theme_from_cookie(headers: &HeaderMap) -> String {
/// Alpine.js, embedded at compile time.
///
/// This is the only asset served as an external file. `style.css` and
/// `app.js` are already inlined into every page with `include_str!`, so
/// embedding this one file makes the binary fully self-contained: there is
/// no way for a deployed instance to be missing its JavaScript because the
/// working directory did not have a `static/` folder next to it.
const ALPINE_JS: &[u8] = include_bytes!("../static/alpine.min.js");
/// Assembles the full application, including static assets.
///
/// The static route is app-absolute at `/static` regardless of the base
/// path. A reverse proxy strips the base path before forwarding, so the
/// browser's `{base}/static/alpine.min.js` arrives here as
/// `/static/alpine.min.js`.
///
/// The invariant: *emitted* URLs carry the base path, *received* paths never
/// do. Mounting this at `{base}/static` makes the asset 404, which leaves
/// `x-data="app()"` uninitialised and every Alpine directive — including the
/// menu dropdowns — silently dead.
///
/// This lives here rather than in `main.rs` so tests exercise the same
/// assembly the binary uses, rather than a hand-rolled copy of it.
pub fn app(state: AppState) -> Router {
Router::new()
.merge(router(state))
// Outside the auth gate: the login page is styled by this same bundle
// and must render before anyone can authenticate.
.route("/static/alpine.min.js", get(serve_alpine_js))
}
async fn serve_alpine_js() -> Response {
(
[
(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
),
// The bytes are fixed at compile time, so they can never change
// for a given binary. Safe to cache indefinitely.
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
ALPINE_JS,
)
.into_response()
}
/// Rejects unauthenticated requests before they reach any note data.
///
/// This is a hard gate, not a redirect-to-login nicety: unauthenticated
/// callers get 401 and never observe note content, titles, or counts.
async fn require_auth(
State(state): State<AppState>,
headers: HeaderMap,
request: axum::extract::Request,
next: Next,
) -> Response {
if let Some(token) = read_cookie(&headers, SESSION_COOKIE) {
if state.auth.validate_session(&token) {
return next.run(request).await;
}
}
// API-ish endpoints stay JSON-ish/short rather than redirecting, so a
// stale fetch() in the page fails loudly instead of silently receiving
// the login HTML.
if request.uri().path() == "/preview" {
return (StatusCode::UNAUTHORIZED, "Authentication required").into_response();
}
// Setup is handled in the terminal before the listener binds, so the
// only reachable unauthenticated state is "not logged in".
Redirect::to(&state.url("/login")).into_response()
}
fn read_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get_all(axum::http::header::COOKIE)
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|s| s.split(';'))
.find_map(|part| {
let (key, value) = part.trim().split_once('=')?;
if key == "theme" {
Some(value.to_string())
} else {
None
}
(key == name).then(|| value.to_string())
})
.unwrap_or_else(|| "Dark".to_string())
}
fn session_cookie(token: &str, base_path: &str) -> String {
// HttpOnly keeps the token out of reach of any injected script;
// SameSite=Strict limits cross-site replay; Secure is omitted so the app
// also works over plain HTTP on a trusted LAN.
//
// `Path` is scoped to the base path so the token is not sent to sibling
// apps sharing the host. When mounted at the root this is "/" (RFC 6265
// treats a bare path as the default-path, i.e. the whole origin).
let path = if base_path.is_empty() { "/" } else { base_path };
format!("{SESSION_COOKIE}={token}; Path={path}; HttpOnly; SameSite=Strict; Max-Age=3600")
}
fn get_theme_from_cookie(headers: &HeaderMap) -> String {
read_cookie(headers, "theme").unwrap_or_else(|| "Dark".to_string())
}
#[derive(Deserialize)]
struct CodeForm {
code: String,
}
async fn login_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
let theme = get_theme_from_cookie(&headers);
// Setup happens in the terminal before the listener binds, so an
// unprovisioned secret file can only mean the operator deleted it while
// the server was running. Say so plainly rather than looping them
// through a login form that can never succeed.
if state.auth.needs_setup() {
return (
StatusCode::SERVICE_UNAVAILABLE,
"This device is not set up yet. Delete nothing, restart the server, \
and complete setup in the terminal.",
)
.into_response();
}
let base = Base::new(&state.base_path);
Html(pages::render_auth_page(&theme, None, &base).into_string()).into_response()
}
async fn login_submit(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<CodeForm>,
) -> Response {
let theme = get_theme_from_cookie(&headers);
let base = Base::new(&state.base_path);
match state.auth.verify_login(&form.code) {
Ok(token) => {
let mut res = Redirect::to(&state.url("/")).into_response();
res.headers_mut().insert(
header::SET_COOKIE,
session_cookie(&token, &state.base_path).parse().unwrap(),
);
res
}
Err(AuthError::InvalidCode) => Html(
pages::render_auth_page(
&theme,
Some("That code was not accepted. Check your authenticator and try again."),
&base,
)
.into_string(),
)
.into_response(),
Err(AuthError::NotProvisioned) => (
StatusCode::SERVICE_UNAVAILABLE,
"This device is not set up yet. Restart the server and complete setup in the terminal.",
)
.into_response(),
Err(e) => Html(pages::render_auth_page(&theme, Some(&e.to_string()), &base).into_string())
.into_response(),
}
}
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(token) = read_cookie(&headers, SESSION_COOKIE) {
// Drop the token server-side so the cookie clearing is not merely
// cosmetic: a captured copy of the token stops working immediately.
state.auth.destroy_session(&token);
}
let mut res = Redirect::to(&state.url("/login")).into_response();
res.headers_mut().insert(
header::SET_COOKIE,
format!(
"{SESSION_COOKIE}=; Path={}; HttpOnly; SameSite=Strict; Max-Age=0",
cookie_path(&state.base_path)
)
.parse()
.unwrap(),
);
res
}
fn cookie_path(base_path: &str) -> &str {
if base_path.is_empty() {
"/"
} else {
base_path
}
}
#[derive(Deserialize)]
@ -49,6 +323,7 @@ struct IndexQuery {
}
async fn index(
State(state): State<AppState>,
Query(query): Query<IndexQuery>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
@ -67,11 +342,13 @@ async fn index(
all_notes
};
let html = pages::render_app(&theme, &notes, None, query.q.as_deref(), None);
let base = Base::new(&state.base_path);
let html = pages::render_app(&theme, &notes, None, query.q.as_deref(), None, &base);
Ok(Html(html.into_string()))
}
async fn show_note_route(
State(state): State<AppState>,
Path(id): Path<String>,
Query(query): Query<IndexQuery>,
headers: HeaderMap,
@ -92,7 +369,8 @@ async fn show_note_route(
all_notes
};
let html = pages::render_app(&theme, &notes, Some(&note), query.q.as_deref(), None);
let base = Base::new(&state.base_path);
let html = pages::render_app(&theme, &notes, Some(&note), query.q.as_deref(), None, &base);
Ok(Html(html.into_string()))
}
@ -104,29 +382,26 @@ struct NoteForm {
_method: String,
}
async fn create_note_route(headers: HeaderMap) -> Result<impl IntoResponse, AppError> {
let _theme = get_theme_from_cookie(&headers);
async fn create_note_route(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
let note = notes::create_note("Untitled", "")?;
Ok(Redirect::to(&format!("/notes/{}", note.id)))
Ok(Redirect::to(&state.url(&format!("/notes/{}", note.id))))
}
async fn update_or_delete_note_route(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Form(form): Form<NoteForm>,
) -> Result<impl IntoResponse, AppError> {
let _theme = get_theme_from_cookie(&headers);
match form._method.as_str() {
"delete" => {
notes::delete_note(&id)?;
Ok(Redirect::to("/").into_response())
Ok(Redirect::to(&state.url("/")).into_response())
}
_ => {
let title = form.title.unwrap_or_else(|| "Untitled".to_string());
let content = form.content.unwrap_or_default();
notes::update_note(&id, title, content)?;
Ok(Redirect::to(&format!("/notes/{}", id)).into_response())
Ok(Redirect::to(&state.url(&format!("/notes/{id}"))).into_response())
}
}
}
@ -152,16 +427,17 @@ struct ThemeForm {
}
async fn theme_route(
_headers: HeaderMap,
State(state): State<AppState>,
Form(form): Form<ThemeForm>,
) -> Result<impl IntoResponse, AppError> {
// Validate theme name
let _ = get_theme(&form.theme);
let mut response = Redirect::to("/").into_response();
let mut response = Redirect::to(&state.url("/")).into_response();
let cookie = format!(
"theme={}; Path=/; Max-Age=31536000; SameSite=Lax",
form.theme
"theme={}; Path={}; Max-Age=31536000; SameSite=Lax",
form.theme,
cookie_path(&state.base_path)
);
response
.headers_mut()
@ -169,14 +445,18 @@ async fn theme_route(
Ok(response)
}
async fn about_route(headers: HeaderMap) -> Result<impl IntoResponse, AppError> {
async fn about_route(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
let theme = get_theme_from_cookie(&headers);
Ok(Html(pages::render_about(&theme).into_string()))
let base = Base::new(&state.base_path);
Ok(Html(pages::render_about(&theme, &base).into_string()))
}
async fn export_route(_headers: HeaderMap) -> Result<impl IntoResponse, AppError> {
let all_notes = notes::list_notes()?;
let mut export = String::from("# Rasuti Notes Export\n\n");
let mut export = String::from("# EternaLibre Notes Export\n\n");
for note in all_notes {
export.push_str(&format!("## {}\n\n{}\n\n---\n\n", note.title, note.content));
}
@ -184,7 +464,7 @@ async fn export_route(_headers: HeaderMap) -> Result<impl IntoResponse, AppError
let mut response = Html(export).into_response();
response.headers_mut().insert(
axum::http::header::CONTENT_DISPOSITION,
"attachment; filename=\"rasuti-notes-export.md\""
"attachment; filename=\"eternalibre-notes-export.md\""
.parse()
.unwrap(),
);

148
src/setup.rs Normal file
View file

@ -0,0 +1,148 @@
use qrcode::render::unicode::Dense1x2;
use qrcode::QrCode;
use std::io::{BufRead, Write};
use crate::auth::{AuthError, AuthState, SetupMaterial};
/// First-run TOTP provisioning.
///
/// This runs in the terminal *before* the listener binds, so the secret is
/// never sent over the network. That is the entire reason it lives here
/// rather than on a web page: an HTTP-reachable `/setup` would let anyone
/// who can reach the port before you finish claim the device.
pub fn run_interactive_setup(auth: &AuthState) -> Result<(), SetupError> {
let material = auth.begin_setup().map_err(SetupError::Generate)?;
print_instructions(&material);
flush();
let stdin = std::io::stdin();
let mut line = String::new();
loop {
print!(" Code: ");
flush();
line.clear();
let read = stdin
.lock()
.read_line(&mut line)
.map_err(|e| SetupError::Io(e.to_string()))?;
if read == 0 {
// EOF: the process was closed or stdin was closed out from under us.
return Err(SetupError::Aborted);
}
match auth.verify_setup(line.trim()) {
Ok(_) => {
println!("\n Verified. Secret saved to the secret file (mode 0600).");
println!(" Delete that file and restart to enrol a new device.\n");
return Ok(());
}
Err(AuthError::InvalidCode) => {
println!(" That code was not accepted. Waiting for the next one.");
}
Err(e) => return Err(SetupError::Auth(e)),
}
}
}
fn print_instructions(material: &SetupMaterial) {
println!("\n EternaLibre Notes — first-run setup\n");
println!(" Add this to an authenticator app, then type the code it shows.\n");
print_qr(&material.qr_uri);
println!("\n Can't scan? Enter this key by hand:");
println!(" {}\n", group_secret(&material.secret));
println!(" {}\n", material.qr_uri);
}
fn print_qr(uri: &str) {
let code = match QrCode::new(uri.as_bytes()) {
Ok(c) => c,
// The URI is also printed as text below, so a failure here is cosmetic.
Err(_) => return,
};
// Dense1x2 packs two pixel rows per character cell, so a terminal QR
// stays roughly square. `Dark` renders filled blocks for dark modules,
// which reads correctly on the light background most terminals use.
let rendered = code
.render::<Dense1x2>()
.min_dimensions(33, 33)
.quiet_zone(true)
.build();
for line in rendered.lines() {
println!(" {line}");
}
}
/// Groups the Base32 secret into 4-character blocks so it can be typed or
/// transcribed without losing your place.
fn group_secret(secret: &str) -> String {
secret
.as_bytes()
.chunks(4)
.map(|c| std::str::from_utf8(c).unwrap_or(""))
.collect::<Vec<_>>()
.join(" ")
}
fn flush() {
let _ = std::io::stdout().flush();
}
#[derive(Debug)]
pub enum SetupError {
Generate(String),
Io(String),
Auth(AuthError),
Aborted,
}
impl std::fmt::Display for SetupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SetupError::Generate(e) => write!(f, "Could not generate a TOTP secret: {e}"),
SetupError::Io(e) => write!(f, "Could not read from the terminal: {e}"),
SetupError::Auth(e) => write!(f, "{e}"),
SetupError::Aborted => {
f.write_str("Setup was cancelled before a valid code was entered.")
}
}
}
}
impl std::error::Error for SetupError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_is_grouped_for_manual_entry() {
assert_eq!(group_secret("JBSWY3DPEHPK3PXP"), "JBSW Y3DP EHPK 3PXP");
}
#[test]
fn grouping_handles_odd_lengths_without_panicking() {
assert_eq!(group_secret("ABC"), "ABC");
assert_eq!(group_secret("ABCDE"), "ABCD E");
assert_eq!(group_secret(""), "");
}
#[test]
fn qr_renders_as_block_characters() {
let code =
QrCode::new(b"otpauth://totp/EternaLibre:local?secret=JBSWY3DPEHPK3PXP").unwrap();
let out = code
.render::<Dense1x2>()
.min_dimensions(33, 33)
.quiet_zone(true)
.build();
assert!(out.contains('\u{2588}'), "expected block characters");
assert!(out.lines().count() > 5, "expected a multi-line QR");
}
}

View file

@ -10,6 +10,12 @@ pub struct SyntaxState {
pub themes: HashMap<String, SyntectTheme>,
}
impl Default for SyntaxState {
fn default() -> Self {
Self::new()
}
}
impl SyntaxState {
pub fn new() -> Self {
let syntax_set = SyntaxSet::load_defaults_newlines();
@ -38,7 +44,7 @@ pub fn highlight_code(
let syntect_theme = state
.themes
.get(theme.name)
.expect("Syntect theme should exist for every Rasuti theme");
.expect("Syntect theme should exist for every EternaLibre theme");
let syntax = match lang {
Some(lang) => state.syntax_set.find_syntax_by_token(lang),
@ -62,23 +68,114 @@ fn html_escape(s: &str) -> String {
.replace('>', "&gt;")
}
fn generate_tmtheme(theme: &crate::themes::Theme) -> String {
let scopes = [
("comment", theme.ansi_bright[0]),
("string", theme.ansi_bright[2]),
("constant.numeric", theme.ansi_bright[3]),
("constant", theme.ansi_bright[3]),
("keyword", theme.ansi_bright[5]),
("storage", theme.ansi_bright[5]),
("entity.name.function", theme.ansi_bright[4]),
("support.function", theme.ansi_bright[4]),
("entity.name.type", theme.ansi_bright[6]),
("variable", theme.ansi_bright[1]),
("invalid", theme.ansi_bright[1]),
/// WCAG 2.x relative luminance.
///
/// Deliberately *not* the same as `themes::relative_luminance`, which sums
/// gamma-encoded channels and is only good enough for the coarse
/// `button_colors` guard. Contrast ratios need the real sRGB curve, or the
/// numbers are wrong in exactly the range we care about (dark colors on
/// light backgrounds).
fn relative_luminance(hex: &str) -> f64 {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return 0.0;
}
let channel = |i: usize| -> f64 {
let raw = u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(0) as f64 / 255.0;
if raw <= 0.040_45 {
raw / 12.92
} else {
((raw + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
}
/// WCAG contrast ratio between two opaque colors. Ranges 1.0 to 21.0.
fn contrast_ratio(a: &str, b: &str) -> f64 {
let (la, lb) = (relative_luminance(a), relative_luminance(b));
let (hi, lo) = if la > lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
/// Picks the more readable of an ANSI slot's normal and bright variants.
///
/// The bright variants are tuned for dark backgrounds. Using them
/// unconditionally meant light themes rendered `#66ff66` strings against a
/// `#d0d0d0` background — 1.17:1, effectively invisible. Choosing by
/// measured contrast fixes every light theme at once instead of hand-tuning
/// each palette.
fn syntax_color(theme: &Theme, slot: usize) -> &'static str {
let normal = theme.ansi_normal[slot];
let bright = theme.ansi_bright[slot];
if contrast_ratio(normal, theme.bg) >= contrast_ratio(bright, theme.bg) {
normal
} else {
bright
}
}
/// The contrast a comment must reach before we consider it readable. Below
/// this a comment is not "muted", it is gone.
const COMMENT_MIN_CONTRAST: f64 = 2.5;
/// Picks a comment color that is readable *and* still recedes.
///
/// The rule is deliberately the inverse of [`syntax_color`]: among the
/// candidates, take the **least** prominent one that still clears
/// [`COMMENT_MIN_CONTRAST`]. Taking the *most* prominent one is maximally
/// legible and practically useless — on the Light theme it paints every
/// comment the same color as the surrounding prose, so a commented block
/// becomes a wall of text with no hierarchy left to scan.
///
/// Candidates are sorted by measured contrast rather than kept in a fixed
/// order, because which ANSI black is the dimmer one flips between light
/// and dark backgrounds.
fn comment_color(theme: &Theme) -> String {
let mut candidates = [theme.ansi_normal[0], theme.ansi_bright[0], theme.fg];
candidates.sort_by(|a, b| {
contrast_ratio(a, theme.bg)
.partial_cmp(&contrast_ratio(b, theme.bg))
.unwrap_or(std::cmp::Ordering::Equal)
});
// Ascending sort, so the first match is the dimmest readable option and
// the last element is the most legible thing the palette has.
candidates
.iter()
.find(|c| contrast_ratio(c, theme.bg) >= COMMENT_MIN_CONTRAST)
.copied()
.or_else(|| candidates.last().copied())
.unwrap_or(theme.fg)
.to_string()
}
fn generate_tmtheme(theme: &Theme) -> String {
let scopes: [(&str, String); 11] = [
("comment", comment_color(theme)),
("string", syntax_color(theme, 2).to_string()),
("constant.numeric", syntax_color(theme, 3).to_string()),
("constant", syntax_color(theme, 3).to_string()),
("keyword", syntax_color(theme, 5).to_string()),
("storage", syntax_color(theme, 5).to_string()),
("entity.name.function", syntax_color(theme, 4).to_string()),
("support.function", syntax_color(theme, 4).to_string()),
("entity.name.type", syntax_color(theme, 6).to_string()),
("variable", syntax_color(theme, 1).to_string()),
("invalid", syntax_color(theme, 1).to_string()),
];
let mut scope_entries = String::new();
for (scope, color) in scopes {
for (scope, color) in &scopes {
// Comments carry a font style as well as a colour. Colour alone is a
// poor signal — a comment that falls back to the foreground is the
// same colour as the prose, and italic is the distinction every
// editor uses.
let font_style = if *scope == "comment" {
"\n <key>fontStyle</key>\n <string>italic</string>"
} else {
""
};
scope_entries.push_str(&format!(
" <dict>\n\
<key>name</key>\n\
@ -88,7 +185,7 @@ fn generate_tmtheme(theme: &crate::themes::Theme) -> String {
<key>settings</key>\n\
<dict>\n\
<key>foreground</key>\n\
<string>{color}</string>\n\
<string>{color}</string>{font_style}\n\
</dict>\n\
</dict>\n"
));
@ -100,7 +197,7 @@ fn generate_tmtheme(theme: &crate::themes::Theme) -> String {
<plist version=\"1.0\">\n\
<dict>\n\
<key>name</key>\n\
<string>Rasuti {}</string>\n\
<string>EternaLibre {}</string>\n\
<key>settings</key>\n\
<array>\n\
<dict>\n\
@ -119,3 +216,149 @@ fn generate_tmtheme(theme: &crate::themes::Theme) -> String {
theme.name, theme.bg, theme.fg, scope_entries
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::themes::get_theme;
/// The regression that motivated all of this: Solarized Dark ships
/// `#002b36` as its bright black, which is *exactly* its background, so
/// comments rendered completely invisible.
#[test]
fn comments_are_readable_in_every_theme() {
for theme in PREDEFINED_THEMES {
let color = comment_color(theme);
let ratio = contrast_ratio(&color, theme.bg);
assert!(
ratio >= COMMENT_MIN_CONTRAST,
"{}: comment {color} on {} is only {ratio:.2}:1",
theme.name,
theme.bg
);
}
}
/// The specific Solarized Dark failure deserves its own assertion, since
/// it is the worst possible outcome: a comment you cannot see at all.
#[test]
fn no_theme_uses_its_background_for_comments() {
for theme in PREDEFINED_THEMES {
assert_ne!(
comment_color(theme).to_lowercase(),
theme.bg.to_lowercase(),
"{}: comment color is the background color",
theme.name
);
}
}
/// A comment that is merely readable is not enough if it is as loud as
/// the code around it. Guard the property the max-contrast rule broke.
#[test]
fn comments_stay_distinct_from_the_foreground_when_they_can() {
// On Light, `#1c1c1c` reads fine but is the prose color; the muted
// `#666666` should win because it clears the floor.
let light = get_theme("Light");
assert_eq!(comment_color(light), light.ansi_bright[0]);
assert!(contrast_ratio(&comment_color(light), light.bg) < 8.0);
}
/// The contract `syntax_color` actually promises: it returns the more
/// readable of the two candidates. Checking this over every theme and
/// every slot pins the rule itself, so it cannot silently regress to
/// "always bright" without a failure naming the offending theme.
#[test]
fn syntax_color_always_picks_the_more_readable_variant() {
for theme in PREDEFINED_THEMES {
for slot in 0..8 {
let picked = syntax_color(theme, slot);
let normal = contrast_ratio(theme.ansi_normal[slot], theme.bg);
let bright = contrast_ratio(theme.ansi_bright[slot], theme.bg);
let best = normal.max(bright);
let got = contrast_ratio(picked, theme.bg);
assert!(
(got - best).abs() < 1e-9,
"{} slot {slot}: picked {picked} at {got:.2}:1 but the \
better option is {best:.2}:1",
theme.name
);
}
}
}
/// Light themes used to render their bright, high-luminance variants,
/// which put `#66ff66` strings on a light gray background at 1.17:1.
///
/// The 2.0:1 floor is deliberately low. The Light palette is a literal
/// inversion of Dark (same 16 colors, fg and bg swapped) on a mid-gray
/// `#d0d0d0` background, and its darkest available cyan tops out at
/// 2.05:1. No selection logic can do better without changing the
/// palette. This test guards against a *regression* to the old
/// bright-variant behavior (1.27:1 for that same slot), not against the
/// palette being mediocre.
#[test]
fn light_theme_syntax_colors_are_readable() {
let light = get_theme("Light");
// Slots 1-6 are the ones actually bound to a scope; slot 0 is
// handled by `comment_color` and slot 7 is unused.
for slot in 1..=6 {
let color = syntax_color(light, slot);
let ratio = contrast_ratio(color, light.bg);
assert!(
ratio >= 2.0,
"Light slot {slot} {color} is only {ratio:.2}:1 on {}",
light.bg
);
}
}
/// Comment scope carries an italic font style; the rest do not.
#[test]
fn comment_scope_is_italic_and_others_are_not() {
let plist = generate_tmtheme(get_theme("Dark"));
let comment = scope_entry(&plist, "comment");
assert!(
comment.contains("<key>fontStyle</key>") && comment.contains("italic"),
"comment scope should be italic"
);
assert!(
!scope_entry(&plist, "string").contains("fontStyle"),
"only comments should be italic"
);
}
/// The italic has to survive the whole pipeline, not just parse. If
/// syntect ever stops emitting `font-style`, this catches it.
#[test]
fn comments_render_italic_in_the_final_html() {
let state = syntax_state();
let html = highlight_code("// hi\n", Some("rust"), get_theme("Dark"), state);
assert!(
html.contains("font-style:italic"),
"expected italic comments in rendered output, got: {html}"
);
}
/// Hand-editing the plist template is exactly the kind of change that
/// silently emits invalid XML that syntect then rejects at load time.
#[test]
fn every_theme_generates_a_loadable_tmtheme() {
for theme in PREDEFINED_THEMES {
let plist = generate_tmtheme(theme);
ThemeSet::load_from_reader(&mut std::io::Cursor::new(plist))
.unwrap_or_else(|e| panic!("{}: {e}", theme.name));
}
}
/// Pull one scope's `<dict>` block out of the generated plist.
fn scope_entry(plist: &str, scope: &str) -> String {
let marker = format!("<string>{scope}</string>");
let start = plist
.find(&marker)
.unwrap_or_else(|| panic!("no scope entry for {scope}"));
let rest = &plist[start..];
let end = rest.find("</dict>").expect("unterminated scope entry");
rest[..end].to_string()
}
}

View file

@ -340,17 +340,6 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
"#455a64", "#607d8b", "#78909c", "#90a4ae", "#b0bec5", "#cfd8dc", "#eceff1", "#ffffff",
],
},
Theme {
name: "Sunny Day",
fg: "#fff59d",
bg: "#33691e",
ansi_normal: [
"#33691e", "#f57f17", "#c0ca33", "#00acc1", "#00897b", "#5c6bc0", "#7cb342", "#fff59d",
],
ansi_bright: [
"#558b2f", "#fbc02d", "#afb42b", "#26c6da", "#0097a7", "#3f51b5", "#9ccc65", "#fff9c4",
],
},
Theme {
name: "Foggy Morning",
fg: "#cfd8dc",
@ -412,4 +401,28 @@ mod tests {
assert_eq!(theme.ansi_bright.len(), 8);
}
}
#[test]
fn sunny_day_is_gone() {
// Removed: a green background with a pale yellow foreground left
// almost nothing readable on it.
assert!(
!PREDEFINED_THEMES.iter().any(|t| t.name == "Sunny Day"),
"Sunny Day should have been deleted"
);
// Dropping a palette shrinks the Theme menu, so the docs and the
// about page count have to move with it. This assertion caught the
// count being wrong in the docs before this change: they claimed 28
// for what was really 25.
assert_eq!(PREDEFINED_THEMES.len(), 24);
}
#[test]
fn theme_names_are_unique() {
let mut names: Vec<&str> = PREDEFINED_THEMES.iter().map(|t| t.name).collect();
names.sort_unstable();
let total = names.len();
names.dedup();
assert_eq!(names.len(), total, "duplicate theme name");
}
}

View file

@ -2,6 +2,7 @@ function app() {
return {
previewHtml: window.__INITIAL_PREVIEW__ || '',
previewVisible: true,
editorVisible: true,
tab: window.__DEFAULT_TAB__ || 'notes',
// Only one menu can be open at a time. Keeping a single name here (rather
// than a set of booleans) makes mutual exclusion automatic: opening Theme
@ -38,9 +39,10 @@ function app() {
setTab(name) {
this.tab = name;
// Selecting the Preview tab should always reveal the preview, even if
// the toolbar toggle had hidden it on desktop.
// Selecting a tab should always reveal its pane, even if that pane had
// been collapsed from the View menu.
if (name === 'preview') this.previewVisible = true;
if (name === 'write') this.editorVisible = true;
},
updatePreview(content) {
@ -68,6 +70,10 @@ function app() {
this.previewVisible = !this.previewVisible;
},
toggleEditor() {
this.editorVisible = !this.editorVisible;
},
focusEditor() {
const editor = document.getElementById('editor');
if (editor) editor.focus();
@ -98,7 +104,11 @@ function app() {
this.saveCurrentNote();
} else if (event.key.toLowerCase() === 'n') {
event.preventDefault();
const newNoteForm = document.querySelector('form[action="/notes"]');
// Match on the path ending in /notes rather than an exact action,
// so this still works when the app is mounted under a base path.
const newNoteForm = document.querySelector(
'form[action$="/notes"], form[action$="/notes/"]'
);
if (newNoteForm) newNoteForm.submit();
}
}

View file

@ -1,4 +1,4 @@
/* Rasuti Notes — Apple Elegance meets Classic Desktop GUI */
/* EternaLibre Notes — Apple Elegance meets Classic Desktop GUI */
* {
box-sizing: border-box;
@ -126,6 +126,13 @@ button, input, select, textarea {
text-decoration: none;
}
/* Marks a View menu item whose pane is currently collapsed, so the state is
readable at a glance instead of only via the Hide/Show label. */
.menu-dropdown button.active-view::before {
content: "\2713\00a0";
opacity: 0.7;
}
.menu-dropdown hr {
border: none;
border-top: 1px solid var(--ui-border);
@ -239,6 +246,45 @@ button, input, select, textarea {
overflow: hidden;
}
/* When a pane is collapsed via the View menu, the remaining pane reclaims the
space. Each state sets its own column count so a lone pane always spans the
full remaining width instead of sitting in a half-width track.
Scoped to desktop only: on mobile the tab bar already decides which single
pane is shown, and these rules would outrank `.app-body > .mobile-active`
on specificity, leaving the Preview tab permanently empty. */
@media (min-width: 901px) {
.app-body.hide-preview,
.app-body.hide-editor {
grid-template-columns: 280px 1fr;
}
.app-body.hide-editor.hide-preview {
grid-template-columns: 280px;
}
}
@media (min-width: 601px) and (max-width: 900px) {
.app-body.hide-preview,
.app-body.hide-editor {
grid-template-columns: 240px 1fr;
}
.app-body.hide-editor.hide-preview {
grid-template-columns: 240px;
}
}
@media (min-width: 601px) {
.app-body.hide-editor .editor-pane {
display: none;
}
.app-body.hide-preview .preview-pane {
display: none;
}
}
/* Sidebar */
.sidebar {
background-color: var(--app-bg);
@ -392,10 +438,6 @@ button, input, select, textarea {
transition: background-color 0.15s ease;
}
.preview-pane.hidden {
display: none;
}
.markdown-body {
flex: 1;
padding: 20px 24px;
@ -508,6 +550,147 @@ button, input, select, textarea {
transition: background-color 0.15s ease, border-color 0.15s ease;
}
/* Auth Pages (/setup, /login) */
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: 24px 16px;
overflow-y: auto;
}
.auth-card {
width: 100%;
max-width: 420px;
padding: 32px 28px;
background-color: var(--app-bg);
border: 1px solid var(--ui-border);
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
}
.auth-card h1 {
margin: 0 0 6px;
font-size: 22px;
font-weight: 700;
}
.auth-subtitle {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.5;
opacity: 0.75;
}
.auth-error {
margin-bottom: 16px;
padding: 10px 12px;
border: 1px solid var(--ansi-1);
border-radius: 6px;
background-color: color-mix(in srgb, var(--ansi-1) 12%, transparent);
color: var(--ansi-9);
font-size: 13px;
line-height: 1.4;
}
.auth-qr {
display: flex;
justify-content: center;
margin-bottom: 18px;
padding: 12px;
background-color: #ffffff;
border-radius: 8px;
}
.auth-qr svg {
display: block;
width: 100%;
max-width: 240px;
height: auto;
}
.auth-manual {
margin-bottom: 16px;
text-align: center;
}
.auth-manual p {
margin: 0 0 6px;
font-size: 12px;
opacity: 0.7;
}
.auth-secret {
display: block;
padding: 8px 10px;
background-color: var(--ui-hover);
border: 1px solid var(--ui-border);
border-radius: 6px;
font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
font-size: 13px;
word-break: break-all;
user-select: all;
}
.auth-note {
margin: 0 0 18px;
font-size: 12px;
line-height: 1.5;
opacity: 0.65;
}
.auth-note code {
padding: 1px 5px;
background-color: var(--ui-hover);
border-radius: 4px;
font-size: 11px;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.auth-form label {
font-size: 13px;
font-weight: 500;
}
.auth-input {
width: 100%;
min-height: 52px;
padding: 12px 14px;
background-color: var(--app-bg);
border: 1px solid var(--ui-border);
border-radius: 8px;
color: var(--app-fg);
font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
font-size: 24px;
letter-spacing: 0.35em;
text-align: center;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.auth-input:focus {
border-color: var(--ansi-4);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ansi-4) 25%, transparent);
}
.auth-form .btn {
min-height: 44px;
margin-top: 4px;
}
.auth-footer {
margin: 22px 0 0;
text-align: center;
font-size: 11px;
opacity: 0.5;
}
/* About Page */
.about-page {
display: flex;
@ -519,6 +702,20 @@ button, input, select, textarea {
flex: 1;
padding: 40px;
max-width: 720px;
overflow-y: auto;
}
.about-page .tagline {
font-size: 18px;
font-weight: 600;
color: var(--ansi-4);
margin-top: 0;
}
.about-features {
margin: 0 0 20px;
padding-left: 22px;
line-height: 1.9;
}
/* Mobile Tab Bar — hidden on desktop */
@ -544,6 +741,13 @@ button, input, select, textarea {
height: 100dvh;
}
/* Desktop-only affordances, hidden once the tab bar takes over.
Paired with the `min-width: 601px` rules for the pane toggles above —
keep the two breakpoints in sync. */
.desktop-only {
display: none;
}
/* Menu bar wraps to a second row.
NOTE: do not use `overflow: auto` here — it would create a new stacking
context and trap the absolutely-positioned `.menu-dropdown` underneath

872
tests/auth_gating.rs Normal file
View file

@ -0,0 +1,872 @@
//! End-to-end tests for the TOTP auth gate.
//!
//! These drive the real router over HTTP (through `tower`'s `oneshot`) rather
//! than calling handlers directly, because the thing worth protecting is the
//! gate itself: an unauthenticated caller must never observe note content.
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use eternalibre_notes::auth::{AuthError, AuthState};
use eternalibre_notes::routes::{self, AppState};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, OnceLock};
use tower::util::ServiceExt;
/// One shared notes directory for the whole test binary.
///
/// `ETERNALIBRE_NOTES_DIR` is process-global, so it must be set exactly once
/// before any test can race to change it.
fn shared_notes_dir() -> &'static Path {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let mut p = std::env::temp_dir();
p.push(format!("eternalibre-it-notes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
std::env::set_var("ETERNALIBRE_NOTES_DIR", &p);
p
})
}
fn temp_secret_path(tag: &str) -> PathBuf {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!(
"eternalibre-it-{}-{}-{}",
tag,
std::process::id(),
n
));
p.set_extension("secret");
let _ = std::fs::remove_file(&p);
p
}
struct Fixture {
app: axum::Router,
auth: Arc<AuthState>,
secret_file: PathBuf,
token: Option<String>,
secret: String,
base_path: String,
}
impl Fixture {
/// An app whose secret file does not exist yet.
///
/// TOTP enrolment happens in the terminal before the listener binds, so
/// this state is only reachable in-process. The router still handles it,
/// which is what lets these tests assert the defensive 503 path.
fn fresh(tag: &str) -> Self {
Self::build(tag, "")
}
/// An app mounted under a reverse-proxy subdirectory.
fn with_base_path(tag: &str, base_path: &str) -> Self {
Self::build(tag, base_path)
}
fn build(tag: &str, base_path: &str) -> Self {
let secret_file = temp_secret_path(tag);
let auth = Arc::new(AuthState::with_secret_path(&secret_file));
Self {
// `routes::app`, not `routes::router`: the real binary mounts
// static assets alongside the gated routes, and a test that skips
// that assembly cannot see asset regressions.
app: routes::app(AppState {
auth: auth.clone(),
base_path: base_path.into(),
}),
auth,
secret_file,
token: None,
secret: String::new(),
base_path: base_path.to_string(),
}
}
/// Completes enrolment against the `AuthState` directly, which is what the
/// terminal setup flow does before the server starts.
fn provision(mut self) -> Self {
let material = self.auth.begin_setup().unwrap();
self.secret = material.secret;
let code = valid_code(&self.secret);
self.token = Some(self.auth.verify_setup(&code).expect("provisioning"));
self
}
/// An app that has completed setup, with a live session cookie.
fn provisioned(tag: &str) -> Self {
Self::fresh(tag).provision()
}
/// Logs in over HTTP, exercising the real `/login` route.
///
/// A rejected code just means the 30s TOTP window rolled over between
/// enrolment and this call, so retry against the live secret rather than
/// failing on a timing coincidence.
async fn login_over_http(self) -> Self {
// Enrol first if the caller hasn't, so this can be chained onto a
// bare fixture.
let mut f = if self.secret.is_empty() {
self.provision()
} else {
self
};
for _ in 0..3 {
let code = valid_code(&f.secret);
let (status, _, cookie) = f.post_form("/login", &format!("code={code}")).await;
if status == StatusCode::SEE_OTHER {
f.token = Some(session_value(&cookie.expect("login sets a cookie")));
return f;
}
}
panic!("could not log in over HTTP with a valid code");
}
/// The URL the app is *expected* to emit for an app-absolute path.
///
/// Requests themselves are always app-absolute: a reverse proxy strips
/// the base prefix before forwarding, so the app never sees it. The base
/// path only shows up in generated hrefs, form actions, and redirects.
fn expect(&self, app_path: &str) -> String {
format!("{}{}", self.base_path, app_path)
}
fn cookie_header(&self) -> String {
format!("eternalibre_session={}", self.token.clone().unwrap())
}
async fn get(&self, uri: &str) -> (StatusCode, String) {
let res = self
.app
.clone()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
finish(res).await
}
/// GET carrying the session cookie.
async fn get_authed(&self, uri: &str) -> (StatusCode, String) {
let res = self
.app
.clone()
.oneshot(
Request::builder()
.uri(uri)
.header(header::COOKIE, self.cookie_header())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
finish(res).await
}
/// POST as an anonymous visitor.
async fn post_form(&self, uri: &str, body: &str) -> (StatusCode, String, Option<String>) {
let res = self
.app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
respond(res).await
}
/// POST carrying the session cookie.
async fn post_authed(&self, uri: &str, body: &str) -> (StatusCode, String, Option<String>) {
let res = self
.app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, self.cookie_header())
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
respond(res).await
}
/// Creates a note and returns its id.
async fn create_note(&self) -> String {
let (_, _, loc) = self.post_authed("/notes", "").await;
loc.expect("create redirects to the new note")
.rsplit('/')
.next()
.unwrap()
.to_string()
}
/// GET with an arbitrary cookie string, for spoofed-credential checks.
async fn get_with_cookie(&self, cookie: &str) -> (StatusCode, String) {
let res = self
.app
.clone()
.oneshot(
Request::builder()
.uri("/")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
finish(res).await
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.secret_file);
}
}
async fn finish(res: axum::response::Response) -> (StatusCode, String) {
let status = res.status();
let loc = res
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(|s| format!("LOCATION: {s}"));
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
.await
.unwrap();
let mut text = String::from_utf8_lossy(&bytes).into_owned();
if let Some(l) = loc {
text.push('\n');
text.push_str(&l);
}
(status, text)
}
async fn respond(res: axum::response::Response) -> (StatusCode, String, Option<String>) {
let status = res.status();
let cookie = res
.headers()
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let loc = res
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
.await
.unwrap();
let text = String::from_utf8_lossy(&bytes).into_owned();
(status, text, cookie.or(loc))
}
fn session_value(set_cookie: &str) -> String {
set_cookie
.split(';')
.next()
.expect("cookie pair")
.split_once('=')
.expect("key=value")
.1
.to_string()
}
/// What a real authenticator app would display for this secret right now.
fn current_code(encoded_secret: &str) -> String {
use totp_rs::{Algorithm, Builder, Secret};
Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_digits(6)
.with_skew(1)
.with_step_duration(30)
.with_account_name("local")
.with_issuer(Some("EternaLibre Notes"))
.with_secret(Secret::try_from_base32(encoded_secret).unwrap())
.build()
.unwrap()
.generate_current()
.to_string()
}
/// Rejects a secret that is not a usable Base32 TOTP key, with a readable
/// message. A silently empty code would otherwise look like a TOTP mismatch.
fn valid_code(secret: &str) -> String {
assert!(
secret.len() >= 16 && !secret.is_empty(),
"test fixture has an unusable secret: {secret:?}"
);
let code = current_code(secret);
assert_eq!(
code.len(),
6,
"expected a 6-digit code, got {code:?} from a bad secret"
);
code
}
// ---------------------------------------------------------------- gating ----
/// Every data-bearing route must refuse an anonymous caller. This is the core
/// security assertion: note content must not leak to an unauthenticated peer.
#[tokio::test]
async fn anonymous_cannot_read_any_note_route() {
shared_notes_dir();
let f = Fixture::provisioned("anon");
for uri in ["/", "/notes/some-id", "/about", "/export", "/preview"] {
let (status, body) = f.get(uri).await;
assert!(
status == StatusCode::SEE_OTHER || status == StatusCode::UNAUTHORIZED,
"{uri} returned {status}, expected a redirect or 401"
);
assert!(
!body.contains("app-body"),
"{uri} leaked the app shell to an anonymous caller"
);
}
}
#[tokio::test]
async fn anonymous_always_redirects_to_login() {
let f = Fixture::provisioned("anon-login");
let (status, body) = f.get("/").await;
assert_eq!(status, StatusCode::SEE_OTHER);
assert!(body.contains("LOCATION: /login"), "got: {body}");
}
#[tokio::test]
async fn forged_session_cookie_is_rejected() {
let f = Fixture::provisioned("forged");
let res = f
.app
.clone()
.oneshot(
Request::builder()
.uri("/")
.header(header::COOKIE, "eternalibre_session=0000000000000000")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::SEE_OTHER);
assert_eq!(f.auth.session_count(), 1, "forged token created no session");
}
#[tokio::test]
async fn empty_session_cookie_is_rejected() {
let f = Fixture::provisioned("emptycookie");
let (status, _) = f.get_with_cookie("eternalibre_session=").await;
assert_eq!(status, StatusCode::SEE_OTHER);
}
// ------------------------------------------------------------ access with ----
#[tokio::test]
async fn authenticated_user_can_reach_the_app() {
let f = Fixture::provisioned("ok");
let (status, body) = f.get_authed("/").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("app-body"), "expected the app shell");
assert!(body.contains("EternaLibre Notes"));
}
#[tokio::test]
async fn note_content_is_invisible_until_authenticated() {
let f = Fixture::provisioned("leak");
let id = f.create_note().await;
let marker = "TOPSECRET-CANARY-9f2a";
let (status, _, _) = f
.post_authed(
&format!("/notes/{id}"),
&format!("_method=put&title=Canary&content={marker}"),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
// The authenticated user does see it, proving the marker exists at all.
let (status, authed) = f.get_authed(&format!("/notes/{id}")).await;
assert_eq!(status, StatusCode::OK);
assert!(authed.contains(marker), "test is not exercising real data");
// An anonymous request for the same note must not echo it back.
let (status, anon) = f.get(&format!("/notes/{id}")).await;
assert_eq!(status, StatusCode::SEE_OTHER);
assert!(
!anon.contains(marker),
"note content leaked to an unauthenticated request"
);
assert!(!anon.contains("Canary"), "note title leaked");
// Nor does the index page list it.
let (_, index) = f.get("/").await;
assert!(!index.contains("Canary"), "note title leaked via index");
}
#[tokio::test]
async fn preview_requires_authentication() {
let f = Fixture::provisioned("preview-gate");
let (status, body, _) = f.post_form("/preview", "content=%23+Hello").await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(
!body.contains("<h1>"),
"markdown was rendered for an anonymous caller"
);
}
#[tokio::test]
async fn preview_works_once_authenticated() {
let f = Fixture::provisioned("preview-ok");
let (status, body, _) = f.post_authed("/preview", "content=%23+Hello").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("<h1>Hello</h1>"), "got: {body}");
}
#[tokio::test]
async fn export_requires_authentication() {
let f = Fixture::provisioned("export-gate");
let (status, body) = f.get("/export").await;
assert_eq!(status, StatusCode::SEE_OTHER);
assert!(!body.contains("EternaLibre Notes Export"));
}
#[tokio::test]
async fn write_routes_require_authentication() {
let f = Fixture::provisioned("write-gate");
// Creating a note anonymously must not create anything.
let before = std::fs::read_dir(shared_notes_dir()).unwrap().count();
let (status, _, _) = f.post_form("/notes", "").await;
assert_eq!(status, StatusCode::SEE_OTHER);
let after = std::fs::read_dir(shared_notes_dir()).unwrap().count();
assert_eq!(before, after, "anonymous POST created a note");
// Theme changes are gated too.
let (status, _, _) = f.post_form("/theme", "theme=Dracula").await;
assert_eq!(status, StatusCode::SEE_OTHER);
}
#[tokio::test]
async fn deleting_a_note_requires_authentication() {
let f = Fixture::provisioned("delete-gate");
let id = f.create_note().await;
let (status, _, _) = f.post_form(&format!("/notes/{id}"), "_method=delete").await;
assert_eq!(status, StatusCode::SEE_OTHER);
// Still present, i.e. the anonymous delete did not take effect.
let (status, _) = f.get_authed(&format!("/notes/{id}")).await;
assert_eq!(status, StatusCode::OK);
}
// ------------------------------------------------------------------ auth ----
/// There is deliberately no `/setup` route. Enrolment happens in the
/// terminal before the listener binds, so an HTTP-reachable setup endpoint
/// could be claimed by anyone who reaches the port first.
#[tokio::test]
async fn there_is_no_web_setup_route() {
let f = Fixture::fresh("no-setup-route");
let (status, _) = f.get("/setup").await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"/setup must not exist over HTTP"
);
}
/// A wrong code during terminal enrolment must leave no trace: no secret file
/// written and the device still unprovisioned.
#[tokio::test]
async fn wrong_enrolment_code_writes_nothing() {
let f = Fixture::fresh("bad-enrol");
f.auth.begin_setup().unwrap();
assert_eq!(f.auth.verify_setup("000000"), Err(AuthError::InvalidCode));
assert!(f.auth.needs_setup());
assert!(
!f.secret_file.exists(),
"a failed enrolment must not write the secret"
);
}
#[tokio::test]
async fn login_page_is_shown_after_provisioning() {
let f = Fixture::provisioned("loginpage");
let (status, body) = f.get("/login").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("Unlock"));
assert!(!body.contains("<svg"), "login must not show a QR code");
}
#[tokio::test]
async fn wrong_login_code_is_rejected_without_cookie() {
let f = Fixture::provisioned("badlogin");
let (status, body, cookie) = f.post_form("/login", "code=111111").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("was not accepted"));
assert!(cookie.is_none());
}
#[tokio::test]
async fn valid_login_code_issues_a_hardened_session_cookie() {
let f = Fixture::provisioned("goodlogin");
let code = valid_code(&f.secret);
let (status, _, cookie) = f.post_form("/login", &format!("code={code}")).await;
assert_eq!(status, StatusCode::SEE_OTHER);
let set_cookie = cookie.expect("login should set a session");
assert!(set_cookie.contains("HttpOnly"), "cookie must be HttpOnly");
assert!(set_cookie.contains("SameSite=Strict"), "got: {set_cookie}");
assert!(
set_cookie.contains("Max-Age=3600"),
"expected a 1 hour session, got: {set_cookie}"
);
assert!(
set_cookie.contains("Path=/;"),
"at the root the cookie path should be the default path, got: {set_cookie}"
);
}
/// Unprovisioned is only reachable in-process now, so the login page says so
/// instead of redirecting to a route that no longer exists.
#[tokio::test]
async fn login_reports_unprovisioned_device() {
let f = Fixture::fresh("login-unprov");
let (status, body) = f.get("/login").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(body.contains("terminal"), "got: {body}");
assert!(!body.contains("/setup"), "must not point at a dead route");
}
#[tokio::test]
async fn logout_invalidates_the_session() {
let f = Fixture::provisioned("logout");
let token = f.token.clone().unwrap();
assert!(f.auth.validate_session(&token));
let res = f
.app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/logout")
.header(header::COOKIE, f.cookie_header())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::SEE_OTHER);
assert!(
!f.auth.validate_session(&token),
"server must forget the token, not just clear the cookie"
);
// The now-dead cookie no longer grants access.
let res = f
.app
.clone()
.oneshot(
Request::builder()
.uri("/")
.header(header::COOKIE, format!("eternalibre_session={token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::SEE_OTHER);
}
#[tokio::test]
async fn sessions_survive_across_requests() {
let f = Fixture::provisioned("persist");
for _ in 0..3 {
let (status, _) = f.get_authed("/").await;
assert_eq!(status, StatusCode::OK, "session should persist");
}
assert_eq!(f.auth.session_count(), 1);
}
#[tokio::test]
async fn separate_logins_get_separate_sessions() {
let f = Fixture::provisioned("multi");
let code = valid_code(&f.secret);
let (_, _, cookie) = f.post_form("/login", &format!("code={code}")).await;
let second = session_value(&cookie.unwrap());
assert_ne!(second, f.token.clone().unwrap());
assert_eq!(f.auth.session_count(), 2);
}
#[tokio::test]
async fn theme_cookie_alongside_session_still_works() {
let f = Fixture::provisioned("themecookie");
let res = f
.app
.clone()
.oneshot(
Request::builder()
.uri("/")
.header(
header::COOKIE,
format!(
"theme=Dracula; eternalibre_session={}",
f.token.clone().unwrap()
),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
.await
.unwrap();
assert!(String::from_utf8_lossy(&bytes).contains("data-theme=\"Dracula\""));
}
// ------------------------------------------------------------- base path ----
/// Every generated URL must carry the base prefix, or a browser behind the
/// proxy would resolve `/about` against the site root and 404.
#[tokio::test]
async fn base_path_prefixes_generated_links() {
let f = Fixture::with_base_path("base-links", "/forgejo")
.login_over_http()
.await;
let (status, body) = f.get_authed("/").await;
assert_eq!(status, StatusCode::OK);
// `/static/alpine.min.js` is the only externally-loaded asset;
// `app.js` is inlined via `include_str!`, so it has no URL to rewrite.
for target in [
"/about",
"/export",
"/notes",
"/theme",
"/static/alpine.min.js",
] {
let expected = f.expect(target);
assert!(
body.contains(&format!("\"{expected}\"")) || body.contains(&format!("'{expected}'")),
"expected {expected} in generated markup"
);
}
assert!(
!body.contains("href=\"/about\""),
"found a root-absolute link that would escape the prefix"
);
assert!(
!body.contains("src=\"/static/"),
"found a root-absolute asset URL that would escape the prefix"
);
}
#[tokio::test]
async fn base_path_prefixes_redirects() {
let f = Fixture::with_base_path("base-redirect", "/forgejo")
.login_over_http()
.await;
// Creating a note redirects to the new note's URL.
let (_, _, loc) = f.post_authed("/notes", "").await;
let loc = loc.expect("create redirects");
assert!(
loc.starts_with("/forgejo/notes/"),
"redirect escaped the base path: {loc}"
);
// An unauthenticated request must bounce within the prefix too.
let anon = Fixture::with_base_path("base-anon", "/forgejo");
let (status, body) = anon.get("/").await;
assert_eq!(status, StatusCode::SEE_OTHER);
assert!(
body.contains("LOCATION: /forgejo/login"),
"login redirect escaped the base path: {body}"
);
}
#[tokio::test]
async fn base_path_scopes_the_session_cookie() {
let f = Fixture::with_base_path("base-cookie", "/forgejo")
.login_over_http()
.await;
// The fixture captured only the token value, so log in again to inspect
// the raw Set-Cookie header.
let code = valid_code(&f.secret);
let (_, _, set_cookie) = f.post_form("/login", &format!("code={code}")).await;
let raw = set_cookie.expect("login sets a cookie");
assert!(
raw.contains("Path=/forgejo"),
"cookie must be scoped to the prefix, got: {raw}"
);
}
#[tokio::test]
async fn base_path_login_form_posts_to_the_prefix() {
let f = Fixture::with_base_path("base-form", "/forgejo").provision();
let (status, body) = f.get("/login").await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains(r#"action="/forgejo/login""#),
"login form action escaped the prefix"
);
}
/// A reverse proxy strips the base path before forwarding, so the path the
/// app actually receives for a static asset is app-absolute.
///
/// This is the assertion that catches the bug where the static nest was
/// registered at `{base}/static`: the asset 404'd, `x-data="app()"` never
/// initialised, and every Alpine directive — including the File menu
/// dropdowns — silently stopped working.
///
/// It drives `routes::app`, the same assembly `main.rs` uses, rather than
/// hand-rolling a router, so it fails if the real configuration regresses.
#[tokio::test]
async fn alpine_is_served_app_absolute_even_with_a_base_path() {
use eternalibre_notes::routes::{self, AppState};
let secret_file = temp_secret_path("static-abs");
let auth = Arc::new(AuthState::with_secret_path(&secret_file));
let app = routes::app(AppState {
auth,
base_path: "/forgejo".into(),
});
let (status, body) = fetch(&app, "/static/alpine.min.js").await;
assert_eq!(
status,
StatusCode::OK,
"Alpine must be served at the app-absolute path; a miss here leaves \
x-data=\"app()\" uninitialised and the menus dead"
);
assert!(
body.contains("Alpine"),
"expected the Alpine bundle, got {} bytes",
body.len()
);
// The prefixed path is not a route on the app: the proxy is what strips
// the prefix, so the app must not also accept it.
assert_ne!(
fetch(&app, "/forgejo/static/alpine.min.js").await.0,
StatusCode::OK,
"static should not be reachable at the prefixed path"
);
let _ = std::fs::remove_file(&secret_file);
}
/// Alpine must be reachable *without* a session: it is what makes every
/// Alpine directive work, and it sits outside the auth gate.
#[tokio::test]
async fn alpine_is_served_without_a_session() {
let f = Fixture::fresh("static-anon");
let (status, body) = f.get("/static/alpine.min.js").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("Alpine"));
}
/// The bytes are baked in at compile time, so the response can be cached
/// indefinitely and is unaffected by the working directory.
#[tokio::test]
async fn alpine_response_is_immutably_cacheable() {
let f = Fixture::fresh("static-cache");
let res = f
.app
.clone()
.oneshot(
Request::builder()
.uri("/static/alpine.min.js")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let cache = res
.headers()
.get(header::CACHE_CONTROL)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
assert!(
cache.contains("immutable"),
"expected an immutable cache header, got {cache:?}"
);
let ctype = res
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
assert!(
ctype.contains("javascript"),
"expected a JavaScript content type, got {ctype:?}"
);
}
/// The app must not depend on a `static/` directory existing at runtime.
/// This is the regression that bit a real deployment: the binary was moved
/// without its `static/` folder and every Alpine directive went dead.
#[tokio::test]
async fn alpine_is_available_without_a_static_directory() {
use eternalibre_notes::routes::{self, AppState};
let secret_file = temp_secret_path("static-nodir");
let auth = Arc::new(AuthState::with_secret_path(&secret_file));
let app = routes::app(AppState {
auth,
base_path: "".into(),
});
// Nothing is read from disk at request time; the bundle is linked in.
let (status, body) = fetch(&app, "/static/alpine.min.js").await;
assert_eq!(status, StatusCode::OK);
assert!(
body.len() > 10_000,
"bundle looks truncated: {} bytes",
body.len()
);
let _ = std::fs::remove_file(&secret_file);
}
/// GET a path and return its status and body.
async fn fetch(app: &axum::Router, path: &str) -> (StatusCode, String) {
let res = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
let status = res.status();
let bytes = axum::body::to_bytes(res.into_body(), 4 << 20)
.await
.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}