feat(project): 3단계 구조 백엔드 기반 — 세션 삭제자·하위업무·멀티호밍
프로젝트→세션→업무 3단계(아사나식) 전환의 1단계(백엔드). "세션"은 새 개념이
아니라 기존 "단계"(project_stages, 칸반 컬럼)를 그대로 재사용한다.
- project_stages 에 created_by 추가 — 세션 삭제도 "만든 사람만"(_can_delete
재사용) 규칙 적용. api_delete_stage 를 _require_manage 단독 체크에서
생성자 체크로 변경.
- 프로젝트 생성을 관리자 전용 → 로그인 사용자 전체로 개방
(api_create_project: _require_admin → _require_user). 홈 화면의 "새
프로젝트"/수정 모달·버튼도 is_admin 게이트를 걷어냄(생성자가 자동으로
owner 가 되어 _can_manage 통과 — 기존 로직 재사용). 멤버 배정 API는 계속
관리자 전용이라, 비관리자가 새 프로젝트 모달을 열면 멤버 선택란은 안내
문구만 보여주고 시도하지 않는다(불필요한 403 방지).
- 하위 업무 — 새 테이블 없이 tasks.parent_task_id 재사용(부모 삭제 시
CASCADE). list_tasks 는 기본적으로 최상위만 반환(보드/캘린더/리스트에
하위업무가 카드로 안 뜨게) + comment_count 와 같은 방식으로 subtask_total/
subtask_done 집계 추가. create_task 에 parent_task_id 지원(하위업무는
부모와 같은 프로젝트, 세션 없음).
- 멀티호밍(task_project_links, 아사나 기능) — 업무 하나가 여러 프로젝트/세션에
동시에 속할 수 있다. list_tasks(project_id=X, 비재귀)가 자동으로 그
프로젝트에 연결된 업무까지 포함하도록 확장(다른 호출 경로는 안 건드림 —
홈/내업무 전체보기에서 중복 노출 방지). 신규 API: GET /api/tasks/{id}(하위
업무·연결 포함 상세), POST/DELETE /api/tasks/{id}/links.
- 설명란 이미지 삽입 준비: store.sanitize_description_html — 허용 태그만
남기고 스크립트/이벤트속성/위험 URL 스킴 제거(표준 html.parser, 외부
라이브러리 없음). api_create_task/api_update_task 저장 직전 통과.
⚠️ 배포 전 필수: scripts/sql/project_db_003_sections_subtasks_links.sql 를
project_db 에 적용해야 한다(project_stages.created_by, tasks.parent_task_id,
task_project_links 없이는 이 커밋의 쿼리가 전부 실패한다). 그 안의 기존
설명 텍스트→HTML 이스케이프 UPDATE 문은 1회만 실행할 것.
프론트엔드(세션 드래그 재정렬, 사이드바 프로젝트별 보기, 하위업무/멀티호밍
UI, 설명란 이미지 붙여넣기 편집기)는 다음 커밋에서 이어간다.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ DB 없이 단위 테스트 가능한 순수 함수만 둔다(malaysia.store 패
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
|
||||
# 프로젝트 생성 시 자동으로 깔리는 기본 진행 단계(칸반 컬럼).
|
||||
@@ -134,3 +135,119 @@ def build_subject_completed(*, project_name: str, task_title: str) -> str:
|
||||
|
||||
def build_subject_project_done(*, project_name: str) -> str:
|
||||
return f"[DBX 프로젝트] 프로젝트 완료: {project_name}"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 업무 설명 HTML 정리 (이미지 붙여넣기/업로드 삽입 지원)
|
||||
#
|
||||
# 업무 편집창의 설명란은 contenteditable 이라 브라우저가 임의의 HTML을
|
||||
# 만들어 보낼 수 있다. 여기서 허용 태그만 남기고 스크립트/이벤트 속성/위험
|
||||
# URL 스킴을 제거한다 — 외부 라이브러리(bleach 등) 없이 표준 라이브러리
|
||||
# html.parser 만으로 구현한다(카페24 모듈 자체호스팅 원칙과 동일).
|
||||
#
|
||||
# 기존에 일반 텍스트로 저장돼 있던 설명은 마이그레이션(project_db_003)에서
|
||||
# 한 번에 안전한 HTML로 변환해뒀다 — 여기서는 "이게 HTML이냐 텍스트냐"를
|
||||
# 런타임에 추측하지 않는다(추측은 오탐이 난다). description 컬럼은 이제
|
||||
# 항상 HTML이라고 가정한다.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
_DESC_ALLOWED_TAGS: frozenset[str] = frozenset({
|
||||
"p", "br", "b", "strong", "i", "em", "u", "a", "img",
|
||||
"ul", "ol", "li", "blockquote", "code", "pre", "span", "div",
|
||||
})
|
||||
_DESC_VOID_TAGS: frozenset[str] = frozenset({"br", "img"})
|
||||
_DESC_ALLOWED_ATTRS: dict[str, frozenset[str]] = {
|
||||
"a": frozenset({"href"}),
|
||||
"img": frozenset({"src", "alt", "width", "height"}),
|
||||
}
|
||||
_DESC_DANGEROUS_URL_PREFIXES: tuple[str, ...] = ("javascript:", "vbscript:", "data:text/html")
|
||||
# script/style 은 태그 자체는 물론 그 안의 텍스트(코드)도 통째로 버린다 —
|
||||
# 허용 태그 목록에 없어 태그는 이미 안 남지만, HTMLParser는 이 둘의 내용을
|
||||
# 별도 데이터로 넘기므로 handle_data 에서 따로 걸러야 한다.
|
||||
_DESC_RAW_TEXT_TAGS: frozenset[str] = frozenset({"script", "style"})
|
||||
|
||||
|
||||
def _desc_escape_text(text: str) -> str:
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def _desc_escape_attr(value: str) -> str:
|
||||
return _desc_escape_text(value).replace('"', """)
|
||||
|
||||
|
||||
def _desc_url_is_safe(value: str) -> bool:
|
||||
v = value.strip().lower()
|
||||
return not any(v.startswith(prefix) for prefix in _DESC_DANGEROUS_URL_PREFIXES)
|
||||
|
||||
|
||||
class _DescriptionSanitizer(HTMLParser):
|
||||
"""허용 태그/속성만 통과시키는 최소 HTML 정리기."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.out: list[str] = []
|
||||
self._open: list[str] = []
|
||||
self._raw = False # <script>/<style> 내부(태그 자체와 별개로 텍스트도 버림)
|
||||
|
||||
def _emit_start(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag in _DESC_RAW_TEXT_TAGS:
|
||||
self._raw = True
|
||||
return
|
||||
if tag not in _DESC_ALLOWED_TAGS:
|
||||
return
|
||||
allowed = _DESC_ALLOWED_ATTRS.get(tag, frozenset())
|
||||
kept: list[str] = []
|
||||
for name, value in attrs:
|
||||
if name not in allowed:
|
||||
continue
|
||||
value = value or ""
|
||||
if name in ("href", "src") and not _desc_url_is_safe(value):
|
||||
continue
|
||||
kept.append(f'{name}="{_desc_escape_attr(value)}"')
|
||||
attr_text = (" " + " ".join(kept)) if kept else ""
|
||||
self.out.append(f"<{tag}{attr_text}>")
|
||||
if tag not in _DESC_VOID_TAGS:
|
||||
self._open.append(tag)
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
self._emit_start(tag, attrs)
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
self._emit_start(tag, attrs)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in _DESC_RAW_TEXT_TAGS:
|
||||
self._raw = False
|
||||
return
|
||||
if tag not in _DESC_ALLOWED_TAGS or tag in _DESC_VOID_TAGS:
|
||||
return
|
||||
if tag not in self._open:
|
||||
return
|
||||
# 짝이 안 맞는 깨진 HTML 방어 — 스택에서 같은 태그가 나올 때까지 닫는다.
|
||||
while self._open:
|
||||
top = self._open.pop()
|
||||
self.out.append(f"</{top}>")
|
||||
if top == tag:
|
||||
break
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._raw:
|
||||
return
|
||||
self.out.append(_desc_escape_text(data))
|
||||
|
||||
def close(self) -> None:
|
||||
super().close()
|
||||
while self._open:
|
||||
self.out.append(f"</{self._open.pop()}>")
|
||||
|
||||
|
||||
def sanitize_description_html(html: str) -> str:
|
||||
"""업무 설명 HTML을 허용 태그만 남기고 정리한다.
|
||||
|
||||
스크립트·이벤트 속성(onerror 등)·위험 URL 스킴(javascript: 등)을 제거한다.
|
||||
저장 직전 항상 이 함수를 거친다 — 화면에서 붙여넣은 이미지/서식은 거의
|
||||
그대로 남고, 위험한 부분만 사라진다.
|
||||
"""
|
||||
parser = _DescriptionSanitizer()
|
||||
parser.feed(html or "")
|
||||
parser.close()
|
||||
return "".join(parser.out).strip()
|
||||
|
||||
Reference in New Issue
Block a user