feat: recurring tasks, habits, attachments, import/export, audit and security

This commit is contained in:
2026-09-05 13:35:17 +08:00
parent 57145c198b
commit 067d27a477
22 changed files with 1231 additions and 33 deletions
+103 -7
View File
@@ -1,12 +1,15 @@
import base64
import json
import time
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from uuid import UUID
from fastapi import Depends, FastAPI, HTTPException, Query, Response
from fastapi.responses import FileResponse
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import delete, exists, func, or_, select, update
from sqlalchemy.exc import IntegrityError
@@ -14,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from .auth import (
COOKIE_NAME,
CSRF_COOKIE_NAME,
current_user,
hash_password,
hash_token,
@@ -23,6 +27,8 @@ from .auth import (
)
from .db import create_schema, get_db
from .models import AppState, Folder, Session, Tag, Task, TaskList, TaskTag, User, utcnow
from .mvp import audit
from .mvp import router as mvp_router
from .schemas import (
BatchResult,
BatchTaskUpdate,
@@ -55,13 +61,44 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="dodo",
version="0.1.0",
version="0.2.0",
lifespan=lifespan,
docs_url="/api/docs",
openapi_url="/api/openapi.json",
docs_url=None,
openapi_url=None,
)
@app.middleware("http")
async def security(request: Request, call_next):
if request.method not in {"GET", "HEAD", "OPTIONS"} and request.cookies.get(COOKIE_NAME):
csrf_cookie = request.cookies.get(CSRF_COOKIE_NAME)
csrf_header = request.headers.get("x-csrf-token")
# SameSite cookies plus same-origin validation; API clients may explicitly double-submit.
origin = request.headers.get("origin")
if origin and (not csrf_cookie or csrf_header != csrf_cookie):
return JSONResponse({"detail": "CSRF 校验失败"}, status_code=403)
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'"
return response
@app.get("/api/docs", include_in_schema=False)
async def docs(_: User = Depends(current_user)):
return get_swagger_ui_html(openapi_url="/api/openapi.json", title="dodo API")
@app.get("/api/openapi.json", include_in_schema=False)
async def openapi(_: User = Depends(current_user)):
return app.openapi()
app.include_router(mvp_router)
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
@app.get("/health/live")
async def live():
return {"status": "ok"}
@@ -83,6 +120,7 @@ async def setup_status(db: AsyncSession = Depends(get_db)):
async def initialize(
payload: InitializeRequest,
response: Response,
request: Request,
db: AsyncSession = Depends(get_db),
):
count = await db.scalar(select(func.count()).select_from(User))
@@ -99,7 +137,7 @@ async def initialize(
db.add(TaskList(user_id=user.id, name="收集箱", is_inbox=True))
await db.commit()
await db.refresh(user)
await issue_session(db, response, user)
await issue_session(db, response, user, request)
return user
@@ -107,12 +145,26 @@ async def initialize(
async def login(
payload: LoginRequest,
response: Response,
request: Request,
db: AsyncSession = Depends(get_db),
):
from .config import get_settings
ip = request.client.host if request.client else "unknown"
key = (payload.username.casefold(), ip)
now = time.monotonic()
attempts = _login_attempts[key]
window = get_settings().login_window_seconds
while attempts and attempts[0] < now - window:
attempts.popleft()
if len(attempts) >= get_settings().login_attempts:
raise HTTPException(status_code=429, detail="登录尝试过多,请稍后再试", headers={"Retry-After": str(window)})
user = await db.scalar(select(User).where(User.username == payload.username))
if user is None or not verify_password(user.password_hash, payload.password):
attempts.append(now)
raise HTTPException(status_code=401, detail="用户名或密码错误")
await issue_session(db, response, user)
attempts.clear()
await issue_session(db, response, user, request)
return user
@@ -132,6 +184,35 @@ async def logout(
await db.delete(session)
await db.commit()
response.delete_cookie(COOKIE_NAME, path="/")
response.delete_cookie(CSRF_COOKIE_NAME, path="/")
return Response(status_code=204, headers=response.headers)
@app.get("/api/v1/sessions")
async def list_sessions(
token: str = Depends(session_token),
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
current_hash = hash_token(token)
rows = (await db.scalars(select(Session).where(Session.user_id == user.id).order_by(Session.created_at.desc()))).all()
return [{"id": row.id, "current": row.token_hash == current_hash, "created_at": row.created_at, "last_seen_at": row.last_seen_at, "expires_at": row.expires_at, "ip_address": row.ip_address, "user_agent": row.user_agent} for row in rows]
@app.delete("/api/v1/sessions/{session_id}", status_code=204)
async def revoke_session(
session_id: UUID,
response: Response,
token: str = Depends(session_token),
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
row = await db.scalar(select(Session).where(Session.id == session_id, Session.user_id == user.id))
if not row: raise HTTPException(404, "会话不存在")
current = row.token_hash == hash_token(token)
await db.delete(row); await db.commit()
if current:
response.delete_cookie(COOKIE_NAME, path="/"); response.delete_cookie(CSRF_COOKIE_NAME, path="/")
return Response(status_code=204, headers=response.headers)
@@ -169,6 +250,8 @@ async def rename_folder(
if folder is None:
raise HTTPException(status_code=404, detail="文件夹不存在")
folder.name = payload.name
await db.flush()
audit(db, user.id, "update", "folder", folder.id)
await db.commit()
return folder
@@ -187,6 +270,7 @@ async def delete_folder(
if folder is None:
raise HTTPException(status_code=404, detail="文件夹不存在")
folder.deleted_at = utcnow()
audit(db, user.id, "delete", "folder", folder.id)
await db.execute(
update(TaskList)
.where(TaskList.user_id == user.id, TaskList.folder_id == folder.id)
@@ -212,6 +296,8 @@ async def create_list(
raise HTTPException(status_code=404, detail="文件夹不存在")
item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name)
db.add(item)
await db.flush()
audit(db, user.id, "create", "list", item.id)
await db.commit()
await db.refresh(item)
return item
@@ -248,7 +334,10 @@ async def rename_list(
if item.is_inbox:
raise HTTPException(status_code=409, detail="系统收集箱不能重命名")
item.name = payload.name
await db.flush()
audit(db, user.id, "update", "list", item.id)
await db.commit()
await db.refresh(item)
return item
@@ -364,6 +453,7 @@ async def create_task(
task = Task(user_id=user.id, **data)
db.add(task)
await db.flush()
audit(db, user.id, "create", "task", task.id)
if tag_ids:
db.add_all(TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tag_ids)
await db.commit()
@@ -499,6 +589,10 @@ async def update_task(
.where(Task.parent_id == task.id, Task.user_id == user.id, Task.deleted_at.is_(None))
.values(list_id=task.list_id, version=Task.version + 1, updated_at=utcnow())
)
changed = {k for k in data if k not in {"version", "updated_at"}}
if changed & {"title", "description", "priority", "due_at", "list_id", "completed"}:
action = "complete" if data.get("completed") is True else "update"
audit(db, user.id, action, "task", task.id, fields=sorted(changed))
await db.commit()
return await _task_detail(db, task)
@@ -520,6 +614,7 @@ async def delete_task(
.where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id)
.values(deleted_at=deleted_at, version=Task.version + 1, updated_at=deleted_at)
)
audit(db, user.id, "delete", "task", task.id)
await db.commit()
return Response(status_code=204)
@@ -564,6 +659,7 @@ async def restore_task(
.where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id)
.values(deleted_at=None, version=Task.version + 1, updated_at=now)
)
audit(db, user.id, "restore", "task", task.id)
await db.commit()
await db.refresh(task)
return await _task_detail(db, task)