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:
2026-02-19 20:08:44 -05:00
parent 7b92d13863
commit 667ccd70cd
24 changed files with 865 additions and 1193 deletions
+4 -1
View File
@@ -24,7 +24,10 @@ def build_assist_messages(
"your output MUST also start with a heading at the same level. "
"You may revise the heading text but do not remove it. "
"Do not include other sections, explanatory text, or markdown code fences around the output. "
"Match the document's existing tone and style."
"Match the document's existing tone and style. "
"IMPORTANT: Preserve all markdown formatting exactly — including bullet lists (- item), "
"numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), "
"italic (_text_), and code blocks. Never flatten nested lists into plain text."
)
user_content = (
+8 -8
View File
@@ -65,7 +65,7 @@ async def create_user(
)
await session.execute(
update(Setting)
.where(Setting.user_id == user.id)
.where(Setting.user_id.is_(None))
.values(user_id=user.id)
)
# Auto-close registration after first user setup
@@ -194,8 +194,8 @@ async def create_password_reset_token(user_id: int) -> str:
return raw_token
async def reset_password_with_token(raw_token: str, new_password: str) -> bool:
"""Validate a reset token and update the user's password. Returns True on success."""
async def reset_password_with_token(raw_token: str, new_password: str) -> int | None:
"""Validate a reset token and update the user's password. Returns user_id on success."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
async with async_session() as session:
@@ -205,22 +205,22 @@ async def reset_password_with_token(raw_token: str, new_password: str) -> bool:
reset_token = result.scalars().first()
if not reset_token:
return False
return None
if reset_token.used:
return False
return None
if reset_token.expires_at < datetime.now(timezone.utc):
return False
return None
user = await session.get(User, reset_token.user_id)
if not user:
return False
return None
user.password_hash = hash_password(new_password)
reset_token.used = True
await session.commit()
logger.info("Password reset via token for user %d (%s)", user.id, user.username)
return True
return user.id
async def create_invitation(email: str, invited_by: int) -> str:
+4
View File
@@ -27,6 +27,10 @@ async def export_full_backup() -> dict:
return {
"version": 1,
"scope": "full",
"_security_notice": (
"This backup contains hashed passwords. "
"Store it securely and restrict access."
),
"users": [
{
"id": u.id,