Security audit, bug fixes, auto-save, and writing assistant improvements
Backend security & correctness: - Add rate_limit.py: sliding-window rate limiter (asyncio) applied to login, register, forgot/reset password endpoints (10/60s or 5/300s per IP) - app.py: add security headers in after_request (X-Frame-Options, CSP, X-Content-Type-Options, Referrer-Policy) using setdefault to preserve SSE headers - auth.py: refactor duplicate login_required/admin_required into shared _check_auth() - config.py: add TRUST_PROXY_HEADERS for proxy-aware client IP resolution - routes/auth.py: rate limiting, _client_ip() helper, cleaned-up reset_password route - routes/chat.py, notes.py, tasks.py: int() DoS fix on last_event_id; limit capped at 500; date.fromisoformat() wrapped in try/except → 400 on invalid dates - services/auth.py: fix Setting.user_id update bug (filter on NULL not user.id); reset_password_with_token returns int|None (user_id) instead of bool - services/backup.py: add _security_notice to full backup JSON export - services/assist.py: system prompt explicitly preserves markdown list structure and nested indented sub-items Infrastructure: - docker-compose.yml: add healthcheck on app service (/api/health, 10s interval) - .dockerignore: prevent secrets/node_modules/__pycache__/.env.* leaking into build Frontend bug fixes: - TaskCard.vue, TaskViewerView.vue: fix isOverdue() timezone bug (ISO string compare) - useAssist.ts: accept() now resets state to idle when document changed since proposal - stores/chat.ts: fix memory leak in _pollUntilLoaded() (try/catch around fetchStatus) - TiptapEditor.vue: selection offset uses closest-match strategy (not first-match) - utils/markdown.ts: explicit DOMPurify config with FORCE_BODY; remove as const (DOMPurify expects mutable string[]) New features: - Auto-save (5-minute interval) in NoteEditorView and TaskEditorView — only when editing an existing dirty record; silent on error, shows "Auto-saved" toast - sectionParser.ts: top-level bullet/numbered list items are now individual sections in the AI Assist panel (previously treated as one undifferentiated block) - editor-shared.css: extracted ~500 lines of CSS duplicated between both editors; includes .inline-assist-btn at global scope (required for teleported elements) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+33
-19
@@ -12,7 +12,7 @@
|
||||
> Include file-level details in the commit body when the change is non-trivial.
|
||||
|
||||
## Last Updated
|
||||
2026-02-19 — Phase 13: Writing assistant UI redesign (right-side panel, diff view, proofread, floating inline assist, fallback section detection, increased site-wide max-width)
|
||||
2026-02-19 — Phase 14: Security audit, bug fixes, auto-save, and writing assistant improvements
|
||||
|
||||
## Project Overview
|
||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||
@@ -104,6 +104,16 @@ for AI-assisted features.
|
||||
flag controlled by `SECURE_COOKIES` env var (enable when behind TLS).
|
||||
- **Default SECRET_KEY warning:** App logs a WARNING on startup if the default
|
||||
`dev-secret-change-me` key is in use.
|
||||
- **Security headers:** Applied in `app.py` `after_request` using `setdefault` so
|
||||
SSE/streaming responses that set their own headers aren't clobbered: `X-Content-Type-Options`,
|
||||
`X-Frame-Options: DENY`, `Referrer-Policy`, and a restrictive `Content-Security-Policy`.
|
||||
- **Rate limiting:** In-memory sliding-window rate limiter (`rate_limit.py`) applied to
|
||||
auth endpoints: login (10/60s), register (5/300s), forgot-password (5/300s, silent drop
|
||||
to avoid timing info), reset-password (10/60s). Keys are per-IP.
|
||||
- **Proxy-aware client IP:** `TRUST_PROXY_HEADERS` env var enables reading real client IP
|
||||
from `X-Forwarded-For` / `X-Real-IP` when behind a trusted reverse proxy. Off by default.
|
||||
- **Auto-save:** Both note and task editors auto-save every 5 minutes when editing an
|
||||
existing dirty record. Silent on error; shows "Auto-saved" toast on success.
|
||||
|
||||
### High-Level Component Diagram
|
||||
```
|
||||
@@ -215,7 +225,8 @@ fabledassistant/
|
||||
├── summary.md # This file — canonical project context
|
||||
├── pyproject.toml # Python project config (deps include caldav, icalendar)
|
||||
├── Dockerfile # Multi-stage build (Node → Python)
|
||||
├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama)
|
||||
├── .dockerignore # Prevents secrets/node_modules/__pycache__/.env.* leaking into build
|
||||
├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama) — app service has healthcheck
|
||||
├── docker-compose.prod.yml # Production stack (Docker Swarm, secrets, network isolation)
|
||||
├── alembic.ini # Alembic config (prepend_sys_path = src)
|
||||
├── alembic/
|
||||
@@ -237,9 +248,10 @@ fabledassistant/
|
||||
├── src/
|
||||
│ └── fabledassistant/
|
||||
│ ├── __init__.py
|
||||
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API, request logging middleware
|
||||
│ ├── auth.py # Auth decorators: login_required, admin_required, get_current_user_id
|
||||
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret) + SECURE_COOKIES flag
|
||||
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API, request logging, security headers (after_request)
|
||||
│ ├── auth.py # Auth decorators: login_required, admin_required, get_current_user_id — shared _check_auth() helper
|
||||
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret) + SECURE_COOKIES + TRUST_PROXY_HEADERS flags
|
||||
│ ├── rate_limit.py # In-memory sliding-window rate limiter (asyncio.Lock + defaultdict); is_rate_limited(key, max, window)
|
||||
│ ├── models/
|
||||
│ │ ├── __init__.py # async_session factory, Base, imports all models
|
||||
│ │ ├── user.py # User model (id, username, email, password_hash, role, created_at)
|
||||
@@ -251,14 +263,14 @@ fabledassistant/
|
||||
│ ├── routes/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── api.py # /api blueprint with /health endpoint (public)
|
||||
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status, password reset, invitation registration
|
||||
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status, password reset, invitation registration — rate limiting + _client_ip() helper
|
||||
│ │ ├── admin.py # /api/admin blueprint: backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only)
|
||||
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
|
||||
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks + tag suggestions (all @login_required)
|
||||
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
|
||||
│ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required)
|
||||
│ ├── services/
|
||||
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id, is_registration_open, list_users, delete_user, set_registration_open, password reset tokens, invitation tokens (create, validate, register_with_invitation, list_pending, revoke)
|
||||
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id, is_registration_open, list_users, delete_user, set_registration_open, password reset tokens (reset_password_with_token returns int|None), invitation tokens (create, validate, register_with_invitation, list_pending, revoke)
|
||||
│ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup
|
||||
│ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context
|
||||
│ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming (stream_chat + stream_chat_with_tools), ChatChunk dataclass, URL fetching
|
||||
@@ -285,19 +297,20 @@ fabledassistant/
|
||||
│ ├── App.vue # Shell: .app-shell (100dvh flex column) with AppHeader + .app-content (flex:1, scrollable) wrapping router-view; starts status polling + loads settings on mount
|
||||
│ ├── main.ts # App init, imports theme.css
|
||||
│ ├── assets/
|
||||
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset, design tokens, responsive breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), mobile touch targets; global .inline-assist-btn (teleported floating pill)
|
||||
│ │ ├── theme.css # CSS custom properties: light/dark themes, body reset, design tokens, responsive breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), mobile touch targets
|
||||
│ │ └── editor-shared.css # Shared editor styles for NoteEditorView + TaskEditorView: layout, toolbar, tag suggestions, assist panel, diff view, modal, responsive, .inline-assist-btn (global — required for teleported elements)
|
||||
│ ├── api/
|
||||
│ │ └── client.ts # ApiError class, apiGet/apiPost/apiPut/apiPatch/apiDelete + apiSSEStream (fetch+ReadableStream), apiStreamPost (legacy), auto 401→login redirect
|
||||
│ ├── composables/
|
||||
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
|
||||
│ │ ├── useShortcuts.ts # Shared showShortcuts ref + open/close/toggle helpers (used by App.vue + AppHeader.vue)
|
||||
│ │ ├── useAutocomplete.ts # Legacy textarea autocomplete (replaced by Tiptap suggestion extensions)
|
||||
│ │ └── useAssist.ts # AI Assist composable: section parsing, target selection, two-step POST+SSE streaming (apiPost → apiSSEStream), accept/reject, proofread (full-document), LCS line diff (DiffLine/computeDiff), isProofreading ref; watches body ref for auto-sync
|
||||
│ │ └── useAssist.ts # AI Assist composable: section parsing, target selection, two-step POST+SSE streaming (apiPost → apiSSEStream), accept/reject (accept() resets to idle on doc-change error), proofread (full-document), LCS line diff (DiffLine/computeDiff), isProofreading ref; watches body ref for auto-sync
|
||||
│ ├── stores/
|
||||
│ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, login/register/logout/checkAuth
|
||||
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors)
|
||||
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (with toast errors)
|
||||
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), status polling, running models, model warming, updateConversationModel (with toast errors)
|
||||
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), status polling (memory-leak-safe _pollUntilLoaded), running models, model warming, updateConversationModel (with toast errors)
|
||||
│ │ ├── settings.ts # App settings: assistantName, defaultModel, pullModel, deleteModel (with toast errors)
|
||||
│ │ └── toast.ts # Toast notification state (success/error/warning), 4s auto-dismiss, dismiss(id)
|
||||
│ ├── types/
|
||||
@@ -314,9 +327,9 @@ fabledassistant/
|
||||
│ │ └── suggestionRenderer.ts # Shared Vue renderer for suggestion dropdowns (creates/positions SuggestionDropdown)
|
||||
│ ├── utils/
|
||||
│ │ ├── tags.ts # extractTags(), linkifyTags() (with (?<!&) to skip HTML entities), linkifyWikilinks()
|
||||
│ │ ├── markdown.ts # renderMarkdown() (full) + renderPreview() (strips links/images) — decodes HTML entities before marked, replaces ' after sanitization
|
||||
│ │ ├── markdown.ts # renderMarkdown() (full) + renderPreview() (strips links/images) — decodes HTML entities before marked, replaces ' after sanitization; explicit DOMPurify config with FORCE_BODY
|
||||
│ │ ├── markdownSerializer.ts # Tiptap JSON → markdown serializer: handles all StarterKit nodes + marks
|
||||
│ │ └── sectionParser.ts # parseMarkdownSections() (heading-based) + parseFallbackSections() (paragraph/Q&A-style — single-line ≤120 char paragraphs become pseudo-headings)
|
||||
│ │ └── sectionParser.ts # parseMarkdownSections() (heading-based) + parseFallbackSections() (paragraph/Q&A-style — single-line ≤120 char paragraphs become pseudo-headings; top-level bullet/numbered list items become individual sections)
|
||||
│ ├── views/
|
||||
│ │ ├── LoginView.vue # Login form with error display, link to register
|
||||
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
|
||||
@@ -326,11 +339,11 @@ fabledassistant/
|
||||
│ │ ├── HomeView.vue # Actionable dashboard: overdue, due today, due this week, high priority, in progress tasks; recent chats with inline chat input; recently edited notes; model warming on mount
|
||||
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
|
||||
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
|
||||
│ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions, Ctrl+S, unsaved guard
|
||||
│ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions, Ctrl+S, auto-save (5min), unsaved guard; styles from editor-shared.css
|
||||
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks, table of contents sidebar
|
||||
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
|
||||
│ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions, Ctrl+S, dirty guard
|
||||
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note, backlinks, table of contents sidebar
|
||||
│ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions, Ctrl+S, auto-save (5min), dirty guard; styles from editor-shared.css + task-specific scoped styles
|
||||
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges (isOverdue uses ISO string comparison), convert-to-note, backlinks, table of contents sidebar
|
||||
│ ├── components/
|
||||
│ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with expandable detail rows
|
||||
│ │ ├── AppHeader.vue # Nav bar: brand, nav links (incl. admin Logs), status indicator, theme toggle, user info + logout, hamburger menu (mobile)
|
||||
@@ -344,7 +357,7 @@ fabledassistant/
|
||||
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
|
||||
│ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none")
|
||||
│ │ ├── MarkdownToolbar.vue # Tiptap command-based toolbar: bold/italic/link/list/heading with active state highlighting
|
||||
│ │ ├── TiptapEditor.vue # Tiptap wrapper: markdown↔HTML round-trip, paste handling, selection change emit, expose editor
|
||||
│ │ ├── TiptapEditor.vue # Tiptap wrapper: markdown↔HTML round-trip, paste handling, selection change emit (closest-match offset strategy), expose editor
|
||||
│ │ ├── SuggestionDropdown.vue # Shared autocomplete dropdown for tag/wikilink suggestions
|
||||
│ │ ├── SearchBar.vue # Debounced search input
|
||||
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
|
||||
@@ -665,8 +678,11 @@ When adding a new migration, follow these conventions:
|
||||
|
||||
### Infrastructure
|
||||
- Multi-stage Dockerfile: Node build → Python runtime, auto-migration on startup
|
||||
- Docker Compose (dev) + Docker Swarm production stack with secrets, overlay networks, health checks
|
||||
- Docker Compose (dev) with app service healthcheck (`/api/health`, 10s interval, 30s start period) + Docker Swarm production stack with secrets, overlay networks, health checks
|
||||
- `.dockerignore` prevents secrets, `node_modules`, `__pycache__`, `.env.*` from leaking into build context
|
||||
- Quart serves Vue SPA from static + REST API under `/api/`; 404 handler for SPA routing
|
||||
- Security headers applied in `after_request`: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Content-Security-Policy`
|
||||
- In-memory sliding-window rate limiter on all auth endpoints (login, register, forgot/reset password); proxy-aware client IP with `TRUST_PROXY_HEADERS`
|
||||
- Alembic migrations: 13 migrations, all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
|
||||
- Config from env vars + Docker secrets file support (`_read_secret`)
|
||||
|
||||
@@ -683,8 +699,6 @@ When adding a new migration, follow these conventions:
|
||||
- Import/export (Markdown files, JSON)
|
||||
- Email integration (read/send emails from chat via IMAP/SMTP tools)
|
||||
- Web search support (LLM tool to search the web and summarize results)
|
||||
- Application-level rate limiting on auth endpoints
|
||||
- Security headers middleware (CSP, X-Frame-Options, etc.) — currently handled at reverse proxy level
|
||||
- Session invalidation on user deletion
|
||||
- **Note relation map (Obsidian-style graph):** Interactive force-directed graph visualizing connections between
|
||||
notes via wikilinks (`[[Title]]`) and shared tags. Nodes = notes/tasks; edges = wikilink references or
|
||||
|
||||
Reference in New Issue
Block a user