feat: recurring tasks, habits, attachments, import/export, audit and security
This commit is contained in:
+103
-7
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user