Kyle Pope 1aaa2b3a74 Fix code review findings: security hardening and frontend fixes
Backend:
- Add rate limiting to login (5 attempts / 5 min window)
- Add secure flag to session cookies with helper function
- Add PIN min-length validation via Pydantic field_validator
- Fix naive datetime usage in todos.py (datetime.now() not UTC)
- Disable SQLAlchemy echo in production
- Remove auto-commit from get_db to prevent double commits
- Add lower bound filter to upcoming events query
- Add SECRET_KEY default warning on startup
- Remove create_all from lifespan (Alembic handles migrations)

Frontend:
- Fix ReminderForm remind_at slice for datetime-local input
- Add window.confirm() dialogs on all destructive actions
- Redirect authenticated users away from login screen
- Replace error: any with getErrorMessage helper in LockScreen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:21 +08:00

150 lines
4.1 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from datetime import datetime
from app.database import get_db
from app.models.todo import Todo
from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
router = APIRouter()
@router.get("/", response_model=List[TodoResponse])
async def get_todos(
completed: Optional[bool] = Query(None),
priority: Optional[str] = Query(None),
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all todos with optional filters."""
query = select(Todo)
if completed is not None:
query = query.where(Todo.completed == completed)
if priority:
query = query.where(Todo.priority == priority)
if category:
query = query.where(Todo.category == category)
if search:
query = query.where(Todo.title.ilike(f"%{search}%"))
query = query.order_by(Todo.created_at.desc())
result = await db.execute(query)
todos = result.scalars().all()
return todos
@router.post("/", response_model=TodoResponse, status_code=201)
async def create_todo(
todo: TodoCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new todo."""
new_todo = Todo(**todo.model_dump())
db.add(new_todo)
await db.commit()
await db.refresh(new_todo)
return new_todo
@router.get("/{todo_id}", response_model=TodoResponse)
async def get_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get a specific todo by ID."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
return todo
@router.put("/{todo_id}", response_model=TodoResponse)
async def update_todo(
todo_id: int,
todo_update: TodoUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
update_data = todo_update.model_dump(exclude_unset=True)
# Handle completion timestamp
if "completed" in update_data:
if update_data["completed"] and not todo.completed:
update_data["completed_at"] = datetime.now()
elif not update_data["completed"]:
update_data["completed_at"] = None
for key, value in update_data.items():
setattr(todo, key, value)
await db.commit()
await db.refresh(todo)
return todo
@router.delete("/{todo_id}", status_code=204)
async def delete_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
await db.delete(todo)
await db.commit()
return None
@router.patch("/{todo_id}/toggle", response_model=TodoResponse)
async def toggle_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Toggle todo completion status."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
todo.completed = not todo.completed
todo.completed_at = datetime.now() if todo.completed else None
await db.commit()
await db.refresh(todo)
return todo