667ccd70cd
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>
260 lines
9.7 KiB
Python
260 lines
9.7 KiB
Python
import logging
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.conversation import Conversation, Message
|
|
from fabledassistant.models.note import Note
|
|
from fabledassistant.models.setting import Setting
|
|
from fabledassistant.models.user import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def export_full_backup() -> dict:
|
|
"""Export all users, notes, conversations+messages, and settings as JSON."""
|
|
async with async_session() as session:
|
|
users = (await session.execute(select(User))).scalars().all()
|
|
notes = (await session.execute(select(Note))).scalars().all()
|
|
conversations = (
|
|
await session.execute(
|
|
select(Conversation).options(selectinload(Conversation.messages))
|
|
)
|
|
).scalars().all()
|
|
settings = (await session.execute(select(Setting))).scalars().all()
|
|
|
|
return {
|
|
"version": 1,
|
|
"scope": "full",
|
|
"_security_notice": (
|
|
"This backup contains hashed passwords. "
|
|
"Store it securely and restrict access."
|
|
),
|
|
"users": [
|
|
{
|
|
"id": u.id,
|
|
"username": u.username,
|
|
"email": u.email,
|
|
"password_hash": u.password_hash,
|
|
"role": u.role,
|
|
"created_at": u.created_at.isoformat(),
|
|
}
|
|
for u in users
|
|
],
|
|
"notes": [
|
|
{
|
|
"id": n.id,
|
|
"user_id": n.user_id,
|
|
"title": n.title,
|
|
"body": n.body,
|
|
"tags": n.tags or [],
|
|
"parent_id": n.parent_id,
|
|
"status": n.status,
|
|
"priority": n.priority,
|
|
"due_date": n.due_date.isoformat() if n.due_date else None,
|
|
"created_at": n.created_at.isoformat(),
|
|
"updated_at": n.updated_at.isoformat(),
|
|
}
|
|
for n in notes
|
|
],
|
|
"conversations": [
|
|
{
|
|
"id": c.id,
|
|
"user_id": c.user_id,
|
|
"title": c.title,
|
|
"model": c.model,
|
|
"created_at": c.created_at.isoformat(),
|
|
"updated_at": c.updated_at.isoformat(),
|
|
"messages": [
|
|
{
|
|
"id": m.id,
|
|
"role": m.role,
|
|
"content": m.content,
|
|
"context_note_id": m.context_note_id,
|
|
"created_at": m.created_at.isoformat(),
|
|
}
|
|
for m in c.messages
|
|
],
|
|
}
|
|
for c in conversations
|
|
],
|
|
"settings": [
|
|
{
|
|
"user_id": s.user_id,
|
|
"key": s.key,
|
|
"value": s.value,
|
|
}
|
|
for s in settings
|
|
],
|
|
}
|
|
|
|
|
|
async def export_user_backup(user_id: int) -> dict:
|
|
"""Export a single user's data as JSON."""
|
|
async with async_session() as session:
|
|
user = await session.get(User, user_id)
|
|
notes = (
|
|
await session.execute(select(Note).where(Note.user_id == user_id))
|
|
).scalars().all()
|
|
conversations = (
|
|
await session.execute(
|
|
select(Conversation)
|
|
.options(selectinload(Conversation.messages))
|
|
.where(Conversation.user_id == user_id)
|
|
)
|
|
).scalars().all()
|
|
settings = (
|
|
await session.execute(
|
|
select(Setting).where(Setting.user_id == user_id)
|
|
)
|
|
).scalars().all()
|
|
|
|
return {
|
|
"version": 1,
|
|
"scope": "user",
|
|
"user": {
|
|
"id": user.id,
|
|
"username": user.username,
|
|
"email": user.email,
|
|
"role": user.role,
|
|
"created_at": user.created_at.isoformat(),
|
|
} if user else None,
|
|
"notes": [
|
|
{
|
|
"id": n.id,
|
|
"title": n.title,
|
|
"body": n.body,
|
|
"tags": n.tags or [],
|
|
"parent_id": n.parent_id,
|
|
"status": n.status,
|
|
"priority": n.priority,
|
|
"due_date": n.due_date.isoformat() if n.due_date else None,
|
|
"created_at": n.created_at.isoformat(),
|
|
"updated_at": n.updated_at.isoformat(),
|
|
}
|
|
for n in notes
|
|
],
|
|
"conversations": [
|
|
{
|
|
"id": c.id,
|
|
"title": c.title,
|
|
"model": c.model,
|
|
"created_at": c.created_at.isoformat(),
|
|
"updated_at": c.updated_at.isoformat(),
|
|
"messages": [
|
|
{
|
|
"id": m.id,
|
|
"role": m.role,
|
|
"content": m.content,
|
|
"context_note_id": m.context_note_id,
|
|
"created_at": m.created_at.isoformat(),
|
|
}
|
|
for m in c.messages
|
|
],
|
|
}
|
|
for c in conversations
|
|
],
|
|
"settings": [
|
|
{"key": s.key, "value": s.value}
|
|
for s in settings
|
|
],
|
|
}
|
|
|
|
|
|
async def restore_full_backup(data: dict) -> dict:
|
|
"""Restore from a full backup JSON. Returns stats about what was restored."""
|
|
from datetime import date, datetime, timezone
|
|
|
|
stats = {"users": 0, "notes": 0, "conversations": 0, "messages": 0, "settings": 0}
|
|
|
|
async with async_session() as session:
|
|
# Restore users
|
|
user_id_map: dict[int, int] = {}
|
|
for u_data in data.get("users", []):
|
|
old_id = u_data["id"]
|
|
user = User(
|
|
username=u_data["username"],
|
|
email=u_data.get("email"),
|
|
password_hash=u_data["password_hash"],
|
|
role=u_data.get("role", "user"),
|
|
created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc),
|
|
)
|
|
session.add(user)
|
|
await session.flush()
|
|
user_id_map[old_id] = user.id
|
|
stats["users"] += 1
|
|
|
|
# Restore notes
|
|
note_id_map: dict[int, int] = {}
|
|
for n_data in data.get("notes", []):
|
|
old_id = n_data.get("id")
|
|
mapped_user_id = user_id_map.get(n_data.get("user_id", 0))
|
|
if mapped_user_id is None:
|
|
continue
|
|
due = None
|
|
if n_data.get("due_date"):
|
|
due = date.fromisoformat(n_data["due_date"])
|
|
note = Note(
|
|
user_id=mapped_user_id,
|
|
title=n_data.get("title", ""),
|
|
body=n_data.get("body", ""),
|
|
tags=n_data.get("tags", []),
|
|
parent_id=n_data.get("parent_id"),
|
|
status=n_data.get("status"),
|
|
priority=n_data.get("priority"),
|
|
due_date=due,
|
|
created_at=datetime.fromisoformat(n_data["created_at"]) if n_data.get("created_at") else datetime.now(timezone.utc),
|
|
updated_at=datetime.fromisoformat(n_data["updated_at"]) if n_data.get("updated_at") else datetime.now(timezone.utc),
|
|
)
|
|
session.add(note)
|
|
await session.flush()
|
|
if old_id is not None:
|
|
note_id_map[old_id] = note.id
|
|
stats["notes"] += 1
|
|
|
|
# Restore conversations + messages
|
|
for c_data in data.get("conversations", []):
|
|
mapped_user_id = user_id_map.get(c_data.get("user_id", 0))
|
|
if mapped_user_id is None:
|
|
continue
|
|
conv = Conversation(
|
|
user_id=mapped_user_id,
|
|
title=c_data.get("title", ""),
|
|
model=c_data.get("model", ""),
|
|
created_at=datetime.fromisoformat(c_data["created_at"]) if c_data.get("created_at") else datetime.now(timezone.utc),
|
|
updated_at=datetime.fromisoformat(c_data["updated_at"]) if c_data.get("updated_at") else datetime.now(timezone.utc),
|
|
)
|
|
session.add(conv)
|
|
await session.flush()
|
|
stats["conversations"] += 1
|
|
|
|
for m_data in c_data.get("messages", []):
|
|
msg = Message(
|
|
conversation_id=conv.id,
|
|
role=m_data["role"],
|
|
content=m_data.get("content", ""),
|
|
context_note_id=note_id_map.get(m_data.get("context_note_id")) if m_data.get("context_note_id") else None,
|
|
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
|
|
)
|
|
session.add(msg)
|
|
stats["messages"] += 1
|
|
|
|
# Restore settings
|
|
for s_data in data.get("settings", []):
|
|
mapped_user_id = user_id_map.get(s_data.get("user_id", 0))
|
|
if mapped_user_id is None:
|
|
continue
|
|
setting = Setting(
|
|
user_id=mapped_user_id,
|
|
key=s_data["key"],
|
|
value=s_data.get("value", ""),
|
|
)
|
|
session.add(setting)
|
|
stats["settings"] += 1
|
|
|
|
await session.commit()
|
|
|
|
logger.info("Restored backup: %s", stats)
|
|
return stats
|