feat: permanently delete archived lists
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s

This commit is contained in:
2026-09-09 21:34:49 +08:00
parent 0e291ddb9c
commit d49c81212d
9 changed files with 957 additions and 6 deletions
+250 -3
View File
@@ -1,11 +1,14 @@
import base64
import json
import logging
import os
import shutil
import time
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from pathlib import Path
from uuid import UUID
from pathlib import Path, PureWindowsPath
from uuid import UUID, uuid4
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
from fastapi.openapi.docs import get_swagger_ui_html
@@ -26,7 +29,19 @@ from .auth import (
verify_password,
)
from .db import create_schema, get_db
from .models import AppState, Folder, RecurrenceTemplate, Session, Task, TaskList, User, utcnow
from .models import (
AppState,
Attachment,
Folder,
PurgeOperation,
RecurrenceException,
RecurrenceTemplate,
Session,
Task,
TaskList,
User,
utcnow,
)
from .mvp import audit, occurrences
from .mvp import router as mvp_router
from .schemas import (
@@ -99,6 +114,7 @@ async def openapi(_: User = Depends(current_user)):
app.include_router(mvp_router)
logger = logging.getLogger(__name__)
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
@@ -569,6 +585,237 @@ async def restore_list(
return item
def _attachment_path(attachment_root: Path, storage_name: str) -> Path:
if not storage_name or "\x00" in storage_name:
raise ValueError(storage_name)
windows_path = PureWindowsPath(storage_name)
if windows_path.is_absolute() or windows_path.drive:
raise ValueError(storage_name)
parts = storage_name.replace("\\", "/").split("/")
if any(part in {"", ".", ".."} for part in parts):
raise ValueError(storage_name)
relative_path = Path(*parts)
resolved_path = (attachment_root / relative_path).resolve()
try:
resolved_path.relative_to(attachment_root)
except ValueError as exc:
raise ValueError(storage_name) from exc
return resolved_path
def _create_unique_quarantine(attachment_root: Path) -> tuple[UUID, Path]:
while True:
operation_id = uuid4()
trash_dir = attachment_root / ".purge-trash" / str(operation_id)
try:
trash_dir.mkdir(parents=True, exist_ok=False)
except FileExistsError:
continue
return operation_id, trash_dir
def _remove_owned_quarantine(trash_dir: Path) -> None:
shutil.rmtree(trash_dir)
try:
trash_dir.parent.rmdir()
except OSError:
pass
def _restore_quarantined_files(
moved_files: list[tuple[Path, Path]], *, list_id: UUID, operation_id: UUID
) -> list[str]:
restore_errors: list[str] = []
for source_path, trash_path in reversed(moved_files):
try:
source_path.parent.mkdir(parents=True, exist_ok=True)
os.replace(trash_path, source_path)
except OSError:
restore_errors.append(str(trash_path))
logger.exception(
"List purge attachment restore failed",
extra={
"list_id": str(list_id),
"operation_id": str(operation_id),
"trash_path": str(trash_path),
},
)
return restore_errors
async def _retry_purge_cleanup(db: AsyncSession, operation: PurgeOperation) -> Response:
trash_dir = Path(operation.trash_dir)
try:
if trash_dir.exists():
_remove_owned_quarantine(trash_dir)
except OSError as exc:
logger.exception(
"List purge retry cleanup failed: %s",
trash_dir,
extra={
"list_id": str(operation.list_id),
"operation_id": str(operation.id),
"trash_path": str(trash_dir),
},
)
raise HTTPException(
status_code=500,
detail=(
"清单数据已删除,但附件清理未完成,"
f"操作ID: {operation.id}, 隔离路径: {trash_dir}"
),
) from exc
await db.delete(operation)
await db.commit()
return Response(status_code=204)
@app.delete("/api/v1/lists/{list_id}/purge", status_code=204)
async def purge_list(
list_id: UUID,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
from .config import get_settings
item = await db.scalar(
select(TaskList)
.where(TaskList.id == list_id, TaskList.user_id == user.id)
.with_for_update()
)
if item is None:
pending_operation = await db.scalar(
select(PurgeOperation)
.where(PurgeOperation.list_id == list_id, PurgeOperation.user_id == user.id)
.with_for_update()
)
if pending_operation is not None:
return await _retry_purge_cleanup(db, pending_operation)
raise HTTPException(status_code=404, detail="清单不存在")
if item.is_inbox:
raise HTTPException(status_code=409, detail="系统收集箱不能永久删除")
if item.deleted_at is None:
raise HTTPException(status_code=409, detail="请先归档再永久删除")
task_ids = select(Task.id).where(Task.list_id == item.id)
template_ids = select(RecurrenceTemplate.id).where(RecurrenceTemplate.task_id.in_(task_ids))
storage_names = list(
(await db.scalars(select(Attachment.storage_name).where(Attachment.task_id.in_(task_ids)))).all()
)
attachment_root = Path(get_settings().attachment_dir).resolve()
try:
attachment_paths = [_attachment_path(attachment_root, name) for name in storage_names]
except ValueError as exc:
await db.rollback()
logger.error(
"List purge rejected unsafe attachment path",
extra={"list_id": str(list_id), "storage_name": str(exc)},
)
raise HTTPException(
status_code=409, detail="附件存储路径无效,无法永久删除清单"
) from exc
moved_files: list[tuple[Path, Path]] = []
owns_trash_dir = False
operation_id = uuid4()
trash_dir = attachment_root / ".purge-trash" / str(operation_id)
try:
operation_id, trash_dir = _create_unique_quarantine(attachment_root)
owns_trash_dir = True
for source_path in attachment_paths:
if not source_path.is_file():
continue
trash_path = trash_dir / source_path.relative_to(attachment_root)
trash_path.parent.mkdir(parents=True, exist_ok=True)
os.replace(source_path, trash_path)
moved_files.append((source_path, trash_path))
except OSError as exc:
restore_errors = _restore_quarantined_files(
moved_files, list_id=list_id, operation_id=operation_id
)
if owns_trash_dir and not restore_errors:
_remove_owned_quarantine(trash_dir)
await db.rollback()
if restore_errors:
raise HTTPException(
status_code=500,
detail=(
"附件隔离失败且部分附件恢复失败,"
f"操作ID: {operation_id}, 隔离路径: {trash_dir}, "
f"未恢复: {', '.join(restore_errors)}"
),
) from exc
raise HTTPException(status_code=500, detail="附件隔离失败,清单未删除") from exc
operation = PurgeOperation(
id=operation_id,
list_id=list_id,
user_id=user.id,
trash_dir=str(trash_dir),
status="cleanup_pending",
)
try:
await db.execute(
delete(RecurrenceException).where(RecurrenceException.template_id.in_(template_ids))
)
await db.execute(delete(RecurrenceTemplate).where(RecurrenceTemplate.task_id.in_(task_ids)))
await db.execute(delete(Attachment).where(Attachment.task_id.in_(task_ids)))
await db.execute(delete(Task).where(Task.list_id == item.id))
await db.delete(item)
db.add(operation)
await db.commit()
except Exception as exc:
await db.rollback()
restore_errors = _restore_quarantined_files(
moved_files, list_id=list_id, operation_id=operation_id
)
if not restore_errors:
_remove_owned_quarantine(trash_dir)
raise HTTPException(status_code=500, detail="数据库提交失败,清单未删除") from exc
raise HTTPException(
status_code=500,
detail=(
"数据库提交失败且部分附件恢复失败,"
f"操作ID: {operation_id}, 隔离路径: {trash_dir}, "
f"未恢复: {', '.join(restore_errors)}"
),
) from exc
try:
_remove_owned_quarantine(trash_dir)
except OSError as exc:
logger.exception(
"List purge committed but quarantine cleanup failed: %s",
trash_dir,
extra={
"list_id": str(list_id),
"operation_id": str(operation_id),
"trash_path": str(trash_dir),
},
)
raise HTTPException(
status_code=500,
detail=(
"清单数据已删除,但附件清理未完成,"
f"操作ID: {operation_id}, 隔离路径: {trash_dir}"
),
) from exc
await db.delete(operation)
try:
await db.commit()
except Exception as exc:
await db.rollback()
raise HTTPException(
status_code=500,
detail=(
"清单数据及附件已删除,但清理操作状态未完成,"
f"操作ID: {operation_id};请重试永久删除"
),
) from exc
return Response(status_code=204)
def _encode_cursor(position: int, created_at: datetime, task_id: UUID) -> str:
raw = json.dumps([position, created_at.isoformat(), str(task_id)]).encode()
+10
View File
@@ -78,6 +78,16 @@ class TaskList(Base):
class PurgeOperation(Base):
__tablename__ = "purge_operations"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
list_id: Mapped[UUID] = mapped_column(unique=True, index=True)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
trash_dir: Mapped[str] = mapped_column(String(1024))
status: Mapped[str] = mapped_column(String(32), default="cleanup_pending")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Task(Base):
__tablename__ = "tasks"
__table_args__ = (