feat: permanently delete archived lists
This commit is contained in:
+250
-3
@@ -1,11 +1,14 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path, PureWindowsPath
|
||||||
from uuid import UUID
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
||||||
from fastapi.openapi.docs import get_swagger_ui_html
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
@@ -26,7 +29,19 @@ from .auth import (
|
|||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from .db import create_schema, get_db
|
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 audit, occurrences
|
||||||
from .mvp import router as mvp_router
|
from .mvp import router as mvp_router
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
@@ -99,6 +114,7 @@ async def openapi(_: User = Depends(current_user)):
|
|||||||
|
|
||||||
|
|
||||||
app.include_router(mvp_router)
|
app.include_router(mvp_router)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
|
||||||
@@ -569,6 +585,237 @@ async def restore_list(
|
|||||||
return item
|
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:
|
def _encode_cursor(position: int, created_at: datetime, task_id: UUID) -> str:
|
||||||
raw = json.dumps([position, created_at.isoformat(), str(task_id)]).encode()
|
raw = json.dumps([position, created_at.isoformat(), str(task_id)]).encode()
|
||||||
|
|||||||
@@ -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):
|
class Task(Base):
|
||||||
__tablename__ = "tasks"
|
__tablename__ = "tasks"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
+66
-2
@@ -10,6 +10,7 @@ import { formatApiErrorDetail, isTaskView, nextTotalAfterLocalTaskAdd, normalize
|
|||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
import { createCompletionPulse } from './lib/completion-motion'
|
import { createCompletionPulse } from './lib/completion-motion'
|
||||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||||
|
import { nextDialogFocusIndex } from './lib/list-purge'
|
||||||
import MvpPanel from './MvpPanel.vue'
|
import MvpPanel from './MvpPanel.vue'
|
||||||
import CountdownPanel from './CountdownPanel.vue'
|
import CountdownPanel from './CountdownPanel.vue'
|
||||||
import FloatingAddButton from './components/FloatingAddButton.vue'
|
import FloatingAddButton from './components/FloatingAddButton.vue'
|
||||||
@@ -30,6 +31,12 @@ const password = ref('')
|
|||||||
const folders = ref<FolderItem[]>([])
|
const folders = ref<FolderItem[]>([])
|
||||||
const lists = ref<TaskList[]>([])
|
const lists = ref<TaskList[]>([])
|
||||||
const archivedLists = ref<TaskList[]>([])
|
const archivedLists = ref<TaskList[]>([])
|
||||||
|
const purgeListTarget = ref<TaskList | null>(null)
|
||||||
|
const purgeListSubmitting = ref(false)
|
||||||
|
const purgeListError = ref('')
|
||||||
|
const purgeCancelButton = ref<HTMLButtonElement | null>(null)
|
||||||
|
const purgeListDialog = ref<HTMLElement | null>(null)
|
||||||
|
let purgeListTrigger: HTMLElement | null = null
|
||||||
const tasks = ref<Task[]>([])
|
const tasks = ref<Task[]>([])
|
||||||
const overdueTasks = ref<Task[]>([])
|
const overdueTasks = ref<Task[]>([])
|
||||||
const trash = ref<Task[]>([])
|
const trash = ref<Task[]>([])
|
||||||
@@ -779,7 +786,19 @@ async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskLi
|
|||||||
if (kind === 'lists') {
|
if (kind === 'lists') {
|
||||||
const answer = await askText(`归档清单「${item.name}」?`, '', '', '归档')
|
const answer = await askText(`归档清单「${item.name}」?`, '', '', '归档')
|
||||||
if (answer === null) return
|
if (answer === null) return
|
||||||
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await loadArchivedLists(); await refreshAll(); toast('清单已归档') } catch (reason) { fail(reason) }
|
try {
|
||||||
|
const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id
|
||||||
|
await api(`/${kind}/${item.id}`, { method: 'DELETE' })
|
||||||
|
await loadArchivedLists()
|
||||||
|
await refreshAll()
|
||||||
|
if (wasCurrentList) {
|
||||||
|
selectedTask.value = null
|
||||||
|
mobileDetail.value = false
|
||||||
|
const inboxId = lists.value.find((list) => list.is_inbox)?.id || ''
|
||||||
|
await switchView('tasks', inboxId)
|
||||||
|
}
|
||||||
|
toast('清单已归档')
|
||||||
|
} catch (reason) { fail(reason) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const answer = await askText(`删除文件夹「${item.name}」?`, '', '', '删除')
|
const answer = await askText(`删除文件夹「${item.name}」?`, '', '', '删除')
|
||||||
@@ -792,6 +811,43 @@ async function loadArchivedLists() {
|
|||||||
async function restoreList(item: TaskList) {
|
async function restoreList(item: TaskList) {
|
||||||
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
|
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
|
||||||
}
|
}
|
||||||
|
function openPurgeList(item: TaskList, trigger?: EventTarget | null) {
|
||||||
|
purgeListTarget.value = item
|
||||||
|
purgeListError.value = ''
|
||||||
|
purgeListTrigger = trigger instanceof HTMLElement ? trigger : document.activeElement as HTMLElement | null
|
||||||
|
nextTick(() => purgeCancelButton.value?.focus())
|
||||||
|
}
|
||||||
|
function closePurgeList() {
|
||||||
|
if (purgeListSubmitting.value) return
|
||||||
|
purgeListTarget.value = null
|
||||||
|
purgeListError.value = ''
|
||||||
|
nextTick(() => purgeListTrigger?.focus())
|
||||||
|
}
|
||||||
|
function handlePurgeDialogKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()
|
||||||
|
if (event.key !== 'Tab' || !purgeListDialog.value) return
|
||||||
|
const controls = [...purgeListDialog.value.querySelectorAll<HTMLElement>('button:not(:disabled)')]
|
||||||
|
if (!controls.length) return
|
||||||
|
const activeIndex = controls.indexOf(document.activeElement as HTMLElement)
|
||||||
|
const nextIndex = nextDialogFocusIndex(activeIndex, controls.length, event.shiftKey)
|
||||||
|
if (nextIndex !== null) { event.preventDefault(); controls[nextIndex].focus() }
|
||||||
|
}
|
||||||
|
async function confirmPurgeList() {
|
||||||
|
if (!purgeListTarget.value || purgeListSubmitting.value) return
|
||||||
|
purgeListSubmitting.value = true
|
||||||
|
purgeListError.value = ''
|
||||||
|
try {
|
||||||
|
const purgedId = purgeListTarget.value.id
|
||||||
|
await api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' })
|
||||||
|
archivedLists.value = archivedLists.value.filter((list) => list.id !== purgedId)
|
||||||
|
purgeListTarget.value = null
|
||||||
|
toast('清单已永久删除')
|
||||||
|
} catch (reason) {
|
||||||
|
purgeListError.value = reason instanceof Error ? reason.message : '永久删除失败'
|
||||||
|
} finally {
|
||||||
|
purgeListSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null }
|
function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null }
|
||||||
function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
||||||
sidebarAction.value = sidebarAction.value?.item.id === item.id ? null : { kind, item }
|
sidebarAction.value = sidebarAction.value?.item.id === item.id ? null : { kind, item }
|
||||||
@@ -984,7 +1040,7 @@ onMounted(bootstrap)
|
|||||||
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
|
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
|
||||||
<template v-if="archivedLists.length">
|
<template v-if="archivedLists.length">
|
||||||
<div class="section-title"><span>已归档清单</span></div>
|
<div class="section-title"><span>已归档清单</span></div>
|
||||||
<div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><div class="list-row-main archived-row-label" :title="list.name" :aria-label="`已归档清单:${list.name}`"><ArchiveRestore/><span>{{list.name}}</span></div><span class="row-actions archived-actions"><button aria-label="恢复清单" @click="restoreList(list)"><ArchiveRestore/>恢复</button></span></div>
|
<div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><div class="list-row-main archived-row-label" :title="list.name" :aria-label="`已归档清单:${list.name}`"><ArchiveRestore/><span>{{list.name}}</span></div><span class="row-actions archived-actions"><button aria-label="恢复清单" @click="restoreList(list)"><ArchiveRestore/>恢复</button><button class="danger-text" aria-label="永久删除清单" @click="openPurgeList(list,$event.currentTarget)"><Trash2/>永久删除</button></span></div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-management" aria-label="管理">
|
<nav class="sidebar-management" aria-label="管理">
|
||||||
@@ -1098,6 +1154,14 @@ onMounted(bootstrap)
|
|||||||
</Transition>
|
</Transition>
|
||||||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
||||||
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||||||
|
<div v-if="purgeListTarget" class="modal-mask purge-list-mask" @click.self="closePurgeList">
|
||||||
|
<section ref="purgeListDialog" class="modal-box purge-list-dialog" role="alertdialog" aria-modal="true" aria-labelledby="purge-list-title" aria-describedby="purge-list-description" @keydown="handlePurgeDialogKeydown">
|
||||||
|
<h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3>
|
||||||
|
<p id="purge-list-description">将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。</p>
|
||||||
|
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</p>
|
||||||
|
<div class="modal-actions"><button ref="purgeCancelButton" class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除…' : '永久删除' }}</button></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
|
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
|
||||||
<div class="modal-box" role="dialog" aria-modal="true">
|
<div class="modal-box" role="dialog" aria-modal="true">
|
||||||
<h3>{{ modalTitle }}</h3>
|
<h3>{{ modalTitle }}</h3>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { nextDialogFocusIndex } from './list-purge'
|
||||||
|
|
||||||
|
describe('archived list purge dialog behavior', () => {
|
||||||
|
it('wraps Tab focus between the cancel and destructive actions', () => {
|
||||||
|
expect(nextDialogFocusIndex(0, 2, true)).toBe(1)
|
||||||
|
expect(nextDialogFocusIndex(1, 2, false)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves focus alone while moving between interior controls', () => {
|
||||||
|
expect(nextDialogFocusIndex(1, 3, true)).toBeNull()
|
||||||
|
expect(nextDialogFocusIndex(1, 3, false)).toBeNull()
|
||||||
|
expect(nextDialogFocusIndex(0, 0, false)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function nextDialogFocusIndex(currentIndex: number, controlCount: number, shiftKey: boolean) {
|
||||||
|
if (controlCount < 2) return null
|
||||||
|
if (shiftKey && currentIndex === 0) return controlCount - 1
|
||||||
|
if (!shiftKey && currentIndex === controlCount - 1) return 0
|
||||||
|
return null
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -737,4 +737,32 @@ describe('sidebar layout', () => {
|
|||||||
it('styles archived rows through the new list-row-main structure', () => {
|
it('styles archived rows through the new list-row-main structure', () => {
|
||||||
expect(css).toContain('.archived-row .list-row-main>svg{color:#c9a45c}')
|
expect(css).toContain('.archived-row .list-row-main>svg{color:#c9a45c}')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers permanent deletion only beside archived lists', () => {
|
||||||
|
expect(app).toContain('aria-label="永久删除清单" @click="openPurgeList(list,$event.currentTarget)"')
|
||||||
|
expect(app).toContain("api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' })")
|
||||||
|
expect(app).not.toMatch(/list\.is_inbox[^\n]*openPurgeList/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a guarded custom confirmation that keeps failures visible', () => {
|
||||||
|
expect(app).toContain('将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。')
|
||||||
|
expect(app).toContain('ref="purgeCancelButton"')
|
||||||
|
expect(app).toContain('purgeCancelButton.value?.focus()')
|
||||||
|
expect(app).toContain('@keydown="handlePurgeDialogKeydown"')
|
||||||
|
expect(app).toContain("if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()")
|
||||||
|
expect(app).toContain('if (purgeListSubmitting.value) return')
|
||||||
|
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
|
||||||
|
expect(app).toContain(':disabled="purgeListSubmitting"')
|
||||||
|
expect(app).toContain('role="alert" class="purge-list-error"')
|
||||||
|
expect(css).toContain('.purge-list-dialog button{min-height:44px;')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes a purged row and persists inbox navigation after archiving the current list', () => {
|
||||||
|
expect(app).toContain('archivedLists.value = archivedLists.value.filter((list) => list.id !== purgedId)')
|
||||||
|
expect(app).toContain("const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id")
|
||||||
|
expect(app).toContain('if (wasCurrentList)')
|
||||||
|
expect(app).toContain("await switchView('tasks', inboxId)")
|
||||||
|
expect(app).toContain('selectedTask.value = null')
|
||||||
|
expect(app).toContain("writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""track retryable task-list purge cleanup
|
||||||
|
|
||||||
|
Revision ID: 0015_purge_operations
|
||||||
|
Revises: 0014_preserve_task_due_times
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0015_purge_operations"
|
||||||
|
down_revision = "0014_preserve_task_due_times"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"purge_operations",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("list_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("trash_dir", sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("list_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_purge_operations_list_id", "purge_operations", ["list_id"])
|
||||||
|
op.create_index("ix_purge_operations_user_id", "purge_operations", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_purge_operations_user_id", table_name="purge_operations")
|
||||||
|
op.drop_index("ix_purge_operations_list_id", table_name="purge_operations")
|
||||||
|
op.drop_table("purge_operations")
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from backend.config import get_settings
|
||||||
|
from backend.db import get_engine
|
||||||
|
from backend.models import (
|
||||||
|
Attachment,
|
||||||
|
PurgeOperation,
|
||||||
|
RecurrenceException,
|
||||||
|
RecurrenceTemplate,
|
||||||
|
Task,
|
||||||
|
TaskList,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def boot(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/setup/initialize",
|
||||||
|
json={"username": "owner", "password": "correct horse battery staple"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def create_list(client, name="待永久删除"):
|
||||||
|
response = client.post("/api/v1/lists", json={"name": name})
|
||||||
|
assert response.status_code == 201
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_storage_name(attachment_id: str) -> str:
|
||||||
|
async def load():
|
||||||
|
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||||
|
async with session_factory() as db:
|
||||||
|
return (await db.get(Attachment, UUID(attachment_id))).storage_name
|
||||||
|
|
||||||
|
return asyncio.run(load())
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_requires_archive_protects_inbox_and_hides_other_users_lists(client):
|
||||||
|
boot(client)
|
||||||
|
inbox = client.get("/api/v1/lists").json()[0]
|
||||||
|
active = create_list(client, "活动清单")
|
||||||
|
|
||||||
|
assert client.delete(f"/api/v1/lists/{inbox['id']}/purge").status_code == 409
|
||||||
|
active_response = client.delete(f"/api/v1/lists/{active['id']}/purge")
|
||||||
|
assert active_response.status_code == 409
|
||||||
|
assert active_response.json()["detail"] == "请先归档再永久删除"
|
||||||
|
|
||||||
|
async def seed_foreign_archived_list():
|
||||||
|
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||||
|
async with session_factory() as db:
|
||||||
|
foreign = User(username="other", password_hash="unused")
|
||||||
|
db.add(foreign)
|
||||||
|
await db.flush()
|
||||||
|
foreign_list = TaskList(
|
||||||
|
user_id=foreign.id,
|
||||||
|
name="别人的归档清单",
|
||||||
|
deleted_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(foreign_list)
|
||||||
|
await db.commit()
|
||||||
|
return foreign_list.id
|
||||||
|
|
||||||
|
foreign_list_id = asyncio.run(seed_foreign_archived_list())
|
||||||
|
assert client.delete(f"/api/v1/lists/{foreign_list_id}/purge").status_code == 404
|
||||||
|
assert asyncio.run(row_counts(foreign_list_id))["lists"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_purge_is_not_found_and_does_not_touch_other_lists(client):
|
||||||
|
boot(client)
|
||||||
|
target = create_list(client, "目标")
|
||||||
|
survivor = create_list(client, "保留")
|
||||||
|
survivor_task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "必须保留", "list_id": survivor["id"]}
|
||||||
|
).json()
|
||||||
|
assert client.delete(f"/api/v1/lists/{target['id']}").status_code == 204
|
||||||
|
assert client.delete(f"/api/v1/lists/{target['id']}/purge").status_code == 204
|
||||||
|
|
||||||
|
assert client.delete(f"/api/v1/lists/{target['id']}/purge").status_code == 404
|
||||||
|
assert client.get(f"/api/v1/tasks/{survivor_task['id']}").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_uses_unique_operation_directory_and_preserves_legacy_collision(
|
||||||
|
client, tmp_path: Path
|
||||||
|
):
|
||||||
|
boot(client)
|
||||||
|
attachment_root = tmp_path / "attachments"
|
||||||
|
get_settings().attachment_dir = str(attachment_root)
|
||||||
|
task_list = create_list(client)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
upload = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("proof.txt", b"proof", "text/plain")},
|
||||||
|
)
|
||||||
|
assert upload.status_code == 201
|
||||||
|
legacy_collision = attachment_root / ".purge-trash" / task_list["id"]
|
||||||
|
legacy_collision.mkdir(parents=True)
|
||||||
|
sentinel = legacy_collision / "owned-by-another-request"
|
||||||
|
sentinel.write_text("keep", encoding="utf-8")
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
|
||||||
|
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert sentinel.read_text(encoding="utf-8") == "keep"
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_operation_directory_collision_does_not_delete_unowned_directory(
|
||||||
|
client, tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
import backend.main as main_module
|
||||||
|
|
||||||
|
boot(client)
|
||||||
|
attachment_root = tmp_path / "attachments"
|
||||||
|
get_settings().attachment_dir = str(attachment_root)
|
||||||
|
task_list = create_list(client)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("proof.txt", b"proof", "text/plain")},
|
||||||
|
)
|
||||||
|
operation_id = UUID("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa")
|
||||||
|
collision = attachment_root / ".purge-trash" / str(operation_id)
|
||||||
|
collision.mkdir(parents=True)
|
||||||
|
sentinel = collision / "owned-by-another-request"
|
||||||
|
sentinel.write_text("keep", encoding="utf-8")
|
||||||
|
operation_ids = iter(
|
||||||
|
[operation_id, UUID("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb")]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(main_module, "uuid4", lambda: next(operation_ids))
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
|
||||||
|
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert sentinel.read_text(encoding="utf-8") == "keep"
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_quarantine_allocation_uses_distinct_owned_directories(tmp_path: Path):
|
||||||
|
from backend.main import _create_unique_quarantine
|
||||||
|
|
||||||
|
attachment_root = tmp_path / "attachments"
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
allocations = list(pool.map(lambda _: _create_unique_quarantine(attachment_root), range(20)))
|
||||||
|
|
||||||
|
operation_ids = [operation_id for operation_id, _ in allocations]
|
||||||
|
trash_dirs = [trash_dir for _, trash_dir in allocations]
|
||||||
|
assert len(set(operation_ids)) == 20
|
||||||
|
assert len(set(trash_dirs)) == 20
|
||||||
|
assert all(trash_dir.is_dir() for trash_dir in trash_dirs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_pre_move_failure_keeps_database_and_file(
|
||||||
|
client, tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
boot(client)
|
||||||
|
get_settings().attachment_dir = str(tmp_path / "attachments")
|
||||||
|
task_list = create_list(client)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
upload = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("proof.txt", b"proof", "text/plain")},
|
||||||
|
)
|
||||||
|
attachment_path = Path(get_settings().attachment_dir) / attachment_storage_name(upload.json()["id"])
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
|
||||||
|
def fail_replace(source, destination):
|
||||||
|
raise OSError("disk unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "replace", fail_replace)
|
||||||
|
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.json()["detail"] == "附件隔离失败,清单未删除"
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 1
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["attachments"] == 1
|
||||||
|
assert attachment_path.read_bytes() == b"proof"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_move_restore_failure_preserves_quarantined_copy(
|
||||||
|
client, tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
boot(client)
|
||||||
|
attachment_root = tmp_path / "attachments"
|
||||||
|
get_settings().attachment_dir = str(attachment_root)
|
||||||
|
task_list = create_list(client)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "两个附件", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
first = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("one.txt", b"one", "text/plain")},
|
||||||
|
).json()
|
||||||
|
second = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("two.txt", b"two", "text/plain")},
|
||||||
|
).json()
|
||||||
|
first_name = attachment_storage_name(first["id"])
|
||||||
|
second_name = attachment_storage_name(second["id"])
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
original_replace = os.replace
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def fail_second_move_and_restore(source, destination):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls >= 2:
|
||||||
|
raise OSError("disk unavailable")
|
||||||
|
return original_replace(source, destination)
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "replace", fail_second_move_and_restore)
|
||||||
|
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert "部分附件恢复失败" in response.json()["detail"]
|
||||||
|
trash_dirs = list((attachment_root / ".purge-trash").iterdir())
|
||||||
|
assert len(trash_dirs) == 1
|
||||||
|
assert (trash_dirs[0] / first_name).read_bytes() == b"one"
|
||||||
|
assert (attachment_root / second_name).read_bytes() == b"two"
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 1
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["attachments"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_commit_failure_restores_quarantined_file_and_rows(
|
||||||
|
client, tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
boot(client)
|
||||||
|
get_settings().attachment_dir = str(tmp_path / "attachments")
|
||||||
|
task_list = create_list(client)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
upload = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("proof.txt", b"proof", "text/plain")},
|
||||||
|
)
|
||||||
|
attachment_path = Path(get_settings().attachment_dir) / attachment_storage_name(upload.json()["id"])
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
original_commit = AsyncSession.commit
|
||||||
|
|
||||||
|
async def fail_commit(db):
|
||||||
|
raise OSError("database unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(AsyncSession, "commit", fail_commit)
|
||||||
|
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
|
||||||
|
monkeypatch.setattr(AsyncSession, "commit", original_commit)
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.json()["detail"] == "数据库提交失败,清单未删除"
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 1
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["attachments"] == 1
|
||||||
|
assert attachment_path.read_bytes() == b"proof"
|
||||||
|
assert not (Path(get_settings().attachment_dir) / ".purge-trash").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_failure_can_be_retried_by_original_list_id(
|
||||||
|
client, tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
boot(client)
|
||||||
|
attachment_root = tmp_path / "attachments"
|
||||||
|
get_settings().attachment_dir = str(attachment_root)
|
||||||
|
task_list = create_list(client)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
upload = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("proof.txt", b"proof", "text/plain")},
|
||||||
|
)
|
||||||
|
storage_name = attachment_storage_name(upload.json()["id"])
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
original_rmtree = shutil.rmtree
|
||||||
|
cleanup_attempts = 0
|
||||||
|
|
||||||
|
def fail_cleanup_once(path, *args, **kwargs):
|
||||||
|
nonlocal cleanup_attempts
|
||||||
|
if Path(path).parent.name == ".purge-trash" and cleanup_attempts == 0:
|
||||||
|
cleanup_attempts += 1
|
||||||
|
raise OSError("disk unavailable")
|
||||||
|
return original_rmtree(path, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(shutil, "rmtree", fail_cleanup_once)
|
||||||
|
first = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
|
||||||
|
assert first.status_code == 500
|
||||||
|
operation_id = first.json()["detail"].split("操作ID: ", 1)[1].split(",", 1)[0]
|
||||||
|
trash_dir = attachment_root / ".purge-trash" / operation_id
|
||||||
|
assert (trash_dir / storage_name).read_bytes() == b"proof"
|
||||||
|
|
||||||
|
second = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
|
||||||
|
|
||||||
|
assert second.status_code == 204
|
||||||
|
assert not trash_dir.exists()
|
||||||
|
assert asyncio.run(purge_operation_count(task_list["id"])) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_operation_count(list_id):
|
||||||
|
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||||
|
async with session_factory() as db:
|
||||||
|
return await db.scalar(
|
||||||
|
select(func.count()).select_from(PurgeOperation).where(
|
||||||
|
PurgeOperation.list_id == UUID(str(list_id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_quarantine_cleanup_failure_is_reported_and_keeps_residue_isolated(
|
||||||
|
client, tmp_path: Path, monkeypatch, caplog
|
||||||
|
):
|
||||||
|
boot(client)
|
||||||
|
attachment_root = tmp_path / "attachments"
|
||||||
|
get_settings().attachment_dir = str(attachment_root)
|
||||||
|
task_list = create_list(client)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "带附件", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
upload = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("proof.txt", b"proof", "text/plain")},
|
||||||
|
)
|
||||||
|
storage_name = attachment_storage_name(upload.json()["id"])
|
||||||
|
attachment_path = attachment_root / storage_name
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
original_rmtree = shutil.rmtree
|
||||||
|
|
||||||
|
def fail_trash_cleanup(path, *args, **kwargs):
|
||||||
|
if Path(path).parent.name == ".purge-trash":
|
||||||
|
raise OSError("disk unavailable")
|
||||||
|
return original_rmtree(path, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(shutil, "rmtree", fail_trash_cleanup)
|
||||||
|
with caplog.at_level("ERROR", logger="backend.main"):
|
||||||
|
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
|
||||||
|
|
||||||
|
trash_dirs = [path for path in (attachment_root / ".purge-trash").iterdir()]
|
||||||
|
assert len(trash_dirs) == 1
|
||||||
|
trash_dir = trash_dirs[0]
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert "清单数据已删除,但附件清理未完成" in response.json()["detail"]
|
||||||
|
assert f"操作ID: {trash_dir.name}" in response.json()["detail"]
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 0
|
||||||
|
assert not attachment_path.exists()
|
||||||
|
assert (trash_dir / storage_name).read_bytes() == b"proof"
|
||||||
|
assert str(trash_dir) in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_malicious_attachment_path_rejects_purge_without_touching_file_or_database(
|
||||||
|
client, tmp_path: Path
|
||||||
|
):
|
||||||
|
boot(client)
|
||||||
|
attachment_root = tmp_path / "attachments"
|
||||||
|
get_settings().attachment_dir = str(attachment_root)
|
||||||
|
task_list = create_list(client)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "恶意附件", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
outside_path = tmp_path / "outside.txt"
|
||||||
|
outside_path.write_text("keep", encoding="utf-8")
|
||||||
|
|
||||||
|
async def seed_malicious_attachment():
|
||||||
|
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||||
|
async with session_factory() as db:
|
||||||
|
owner_id = await db.scalar(select(TaskList.user_id).where(TaskList.id == UUID(task_list["id"])))
|
||||||
|
db.add(
|
||||||
|
Attachment(
|
||||||
|
user_id=owner_id,
|
||||||
|
task_id=UUID(task["id"]),
|
||||||
|
filename="outside.txt",
|
||||||
|
storage_name="../outside.txt",
|
||||||
|
mime_type="text/plain",
|
||||||
|
size=4,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
asyncio.run(seed_malicious_attachment())
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
|
||||||
|
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["detail"] == "附件存储路径无效,无法永久删除清单"
|
||||||
|
assert outside_path.read_text(encoding="utf-8") == "keep"
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 1
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["attachments"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"storage_name",
|
||||||
|
[
|
||||||
|
"/tmp/dodo-outside.txt",
|
||||||
|
"nested/../proof.txt",
|
||||||
|
"nested/./proof.txt",
|
||||||
|
"nested//proof.txt",
|
||||||
|
"C:\\temp\\proof.txt",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_absolute_navigation_or_empty_attachment_path_segment_is_rejected(
|
||||||
|
client, tmp_path: Path, storage_name: str
|
||||||
|
):
|
||||||
|
boot(client)
|
||||||
|
get_settings().attachment_dir = str(tmp_path / "attachments")
|
||||||
|
task_list = create_list(client)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "恶意附件", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
async def seed_malicious_attachment():
|
||||||
|
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||||
|
async with session_factory() as db:
|
||||||
|
owner_id = await db.scalar(select(TaskList.user_id).where(TaskList.id == UUID(task_list["id"])))
|
||||||
|
db.add(
|
||||||
|
Attachment(
|
||||||
|
user_id=owner_id,
|
||||||
|
task_id=UUID(task["id"]),
|
||||||
|
filename="proof.txt",
|
||||||
|
storage_name=storage_name,
|
||||||
|
mime_type="text/plain",
|
||||||
|
size=4,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
asyncio.run(seed_malicious_attachment())
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}/purge").status_code == 409
|
||||||
|
assert asyncio.run(row_counts(task_list["id"]))["lists"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def row_counts(list_id):
|
||||||
|
list_id = UUID(str(list_id))
|
||||||
|
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||||
|
async with session_factory() as db:
|
||||||
|
task_ids = list((await db.scalars(select(Task.id).where(Task.list_id == list_id))).all())
|
||||||
|
template_ids = list(
|
||||||
|
(await db.scalars(select(RecurrenceTemplate.id).where(RecurrenceTemplate.task_id.in_(task_ids)))).all()
|
||||||
|
) if task_ids else []
|
||||||
|
return {
|
||||||
|
"lists": await db.scalar(select(func.count()).select_from(TaskList).where(TaskList.id == list_id)),
|
||||||
|
"tasks": len(task_ids),
|
||||||
|
"templates": len(template_ids),
|
||||||
|
"exceptions": await db.scalar(
|
||||||
|
select(func.count()).select_from(RecurrenceException).where(
|
||||||
|
RecurrenceException.template_id.in_(template_ids)
|
||||||
|
)
|
||||||
|
) if template_ids else 0,
|
||||||
|
"attachments": await db.scalar(
|
||||||
|
select(func.count()).select_from(Attachment).where(Attachment.task_id.in_(task_ids))
|
||||||
|
) if task_ids else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_archived_list_removes_all_task_data_and_attachment_files(client, tmp_path: Path):
|
||||||
|
boot(client)
|
||||||
|
get_settings().attachment_dir = str(tmp_path / "attachments")
|
||||||
|
task_list = create_list(client)
|
||||||
|
parent = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={
|
||||||
|
"title": "重复父任务",
|
||||||
|
"list_id": task_list["id"],
|
||||||
|
"due_at": "2030-01-01T09:00:00Z",
|
||||||
|
"rrule": "FREQ=DAILY",
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
child = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "子任务", "list_id": task_list["id"], "parent_id": parent["id"]},
|
||||||
|
).json()
|
||||||
|
completed = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "已完成", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
completed = client.patch(
|
||||||
|
f"/api/v1/tasks/{completed['id']}", json={"completed": True, "version": completed["version"]}
|
||||||
|
).json()
|
||||||
|
soft_deleted = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "软删除", "list_id": task_list["id"]}
|
||||||
|
).json()
|
||||||
|
assert client.delete(f"/api/v1/tasks/{soft_deleted['id']}").status_code == 204
|
||||||
|
|
||||||
|
upload = client.post(
|
||||||
|
f"/api/v1/tasks/{child['id']}/attachments",
|
||||||
|
files={"file": ("proof.txt", b"proof", "text/plain")},
|
||||||
|
)
|
||||||
|
assert upload.status_code == 201
|
||||||
|
attachment_id = upload.json()["id"]
|
||||||
|
|
||||||
|
async def seed_exception_and_get_storage_name():
|
||||||
|
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||||
|
async with session_factory() as db:
|
||||||
|
template = await db.scalar(
|
||||||
|
select(RecurrenceTemplate).where(RecurrenceTemplate.task_id == UUID(parent["id"]))
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
RecurrenceException(
|
||||||
|
template_id=template.id,
|
||||||
|
occurrence_at=datetime(2030, 1, 2, 9, tzinfo=UTC),
|
||||||
|
completed=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
attachment = await db.get(Attachment, UUID(attachment_id))
|
||||||
|
storage_name = attachment.storage_name
|
||||||
|
await db.commit()
|
||||||
|
return storage_name
|
||||||
|
|
||||||
|
storage_name = asyncio.run(seed_exception_and_get_storage_name())
|
||||||
|
attachment_path = Path(get_settings().attachment_dir) / storage_name
|
||||||
|
assert attachment_path.is_file()
|
||||||
|
assert asyncio.run(row_counts(task_list["id"])) == {
|
||||||
|
"lists": 1,
|
||||||
|
"tasks": 4,
|
||||||
|
"templates": 1,
|
||||||
|
"exceptions": 1,
|
||||||
|
"attachments": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
|
||||||
|
response = client.delete(f"/api/v1/lists/{task_list['id']}/purge")
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert asyncio.run(row_counts(task_list["id"])) == {
|
||||||
|
"lists": 0,
|
||||||
|
"tasks": 0,
|
||||||
|
"templates": 0,
|
||||||
|
"exceptions": 0,
|
||||||
|
"attachments": 0,
|
||||||
|
}
|
||||||
|
assert not attachment_path.exists()
|
||||||
Reference in New Issue
Block a user