Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00c66f9df8 | ||
|
|
bdf9d0bae7 |
@@ -99,11 +99,12 @@ pytest tests/test_habits.py::test_name # 단일 테스트
|
||||
|
||||
## Docker 배포
|
||||
|
||||
`Dockerfile` + `docker-compose.yml` + `scripts/docker-entrypoint.sh`로 구성했다(README "Docker로 배포하기" 참고). 이 저장소가 만들어진 개발 환경에는 Docker가 설치되어 있지 않아서 **이미지를 직접 빌드/실행해 검증한 적은 없다** — 실제 배포 서버(Docker 있는 곳)에서 처음 빌드할 때 이 문서에 적은 가정들이 맞는지 확인할 것.
|
||||
`Dockerfile` + `docker-compose.yml` + `scripts/docker-entrypoint.sh`로 구성했다(README "Docker로 배포하기" 참고). 이 저장소가 만들어진 개발 환경 자체에는 Docker가 없지만, `scripts/deploy_sftp.py`로 실제 배포 서버(시놀로지 NAS, `deploy.env` 참고)에 소스를 올린 뒤 그 서버에서 SSH로 `docker compose build && docker compose up -d`를 실행해 검증하는 흐름은 실제로 여러 번 써봤다.
|
||||
|
||||
- `pip install .`(non-editable)로 설치하지만 `app/main.py`의 `StaticFiles(directory="app/static")`/`Jinja2Templates(directory="app/templates")`는 **상대경로**라 컨테이너의 현재 작업 디렉터리(`WORKDIR /app`)에 실제 소스 트리가 `/app/app/...`로 그대로 COPY되어 있어야 동작한다 — 로컬 개발 시 "저장소 루트에서 uvicorn 실행" 관례와 동일한 이유. Dockerfile의 `COPY app ./app` 구조를 바꾸면 이 상대경로도 깨진다.
|
||||
- `scripts/docker-entrypoint.sh`가 컨테이너 시작마다 `alembic upgrade head`를 먼저 실행한 뒤 `uvicorn`을 `exec`한다 — 이미 적용된 리비전은 건너뛰므로 재시작마다 실행돼도 안전(idempotent)하다.
|
||||
- `.env`는 이미지에 COPY하지 않고(`.dockerignore`) `docker-compose.yml`의 `env_file`로 런타임에 주입한다 — 이미지 레이어에 비밀번호가 남지 않게 하기 위함.
|
||||
- **컨테이너는 반드시 1개만 실행**해야 한다 — `scheduler_service`가 프로세스 안에서 APScheduler를 직접 돌리므로, replica를 늘리면 각자 스케줄러를 따로 띄워 같은 알림을 중복 처리하려 든다(`_claim_notification_slot`의 유니크 제약 경합 방지 덕에 죽지는 않지만 애초에 여러 개 띄울 이유가 없다).
|
||||
- **저널 첨부파일(`app/media/`)은 반드시 볼륨 마운트해야 한다**: `docker-compose.yml`에 `volumes: ["./media:/app/app/media"]`가 있는데, 이게 없으면 `docker compose up -d`로 컨테이너를 재생성할 때마다(이미지 재빌드 후 흔히 하는 작업) 그 안에 쌓인 유저 업로드 사진이 컨테이너의 임시 쓰기 레이어와 함께 통째로 사라진다 — 실제로 이 마운트가 빠진 채로 배포를 여러 번 반복하다 발견한 문제였다. 또한 `scripts/deploy_sftp.py`의 `SKIP_NAMES`에 `"media"`가 들어있는 것도 같은 이유다 — 이게 없으면 로컬에서 테스트하며 쌓인 진짜 유저 사진이 파일 동기화 스크립트를 통해 원격 빌드 컨텍스트(`app/media/`)로 그대로 올라가버린다(소스 코드가 아니라 런타임 데이터인데도). 새로 추가되는 유저 업로드 디렉터리가 있다면 똑같이 볼륨 마운트 + `deploy_sftp.py` 제외 둘 다 챙길 것.
|
||||
- **타임존**: `date.today()`(`/today`, 완료율/스트릭 계산 등 날짜 관련 로직 전반)는 컨테이너의 시스템 로컬 타임존을 그대로 쓴다. `python:3.13-slim` 베이스 이미지는 기본 타임존이 UTC라서, `Dockerfile`에 `TZ=Asia/Seoul` + `tzdata` 설치 + `/etc/localtime` 심볼릭 링크를 명시하지 않으면 자정~오전 9시(KST) 사이에 서버가 "아직 어제"로 날짜를 계산한다 — 실제로 이 때문에 매일 아침 `/today`가 전날 체크 상태 그대로 보이고 날짜가 안 넘어가는 버그가 있었다. 코드 로직(`date.today()`) 자체는 문제가 아니라 컨테이너 타임존 설정 누락이 원인이었으니, 비슷한 날짜 관련 이상 증상이 배포 환경에서만 재현되면 먼저 컨테이너 타임존을 의심할 것.
|
||||
- **HTTPS는 배포 대상에 따라 둘 중 하나**: (1) 집 PC를 직접 서버로 쓰는 경우 → Tailscale(`tailscale serve --bg 8000`), 컨테이너 8000번이 호스트 8000번에 그대로 매핑되므로(`ports: ["8000:8000"]`) 프로세스로 직접 띄우든 컨테이너로 띄우든 Tailscale 입장에서 차이 없음. (2) **이미 리버스 프록시(nginx 등)가 앞단에 있는 서버에 배포하는 경우 → Tailscale 불필요**, 프록시가 도메인의 TLS를 처리하고 컨테이너의 8000번으로 평문 HTTP 프록시하면 된다. 이 앱은 리버스 프록시가 보내주는 `X-Forwarded-Proto` 헤더를 보고 `http`면 301로 `https`로 리다이렉트한다(`app/main.py`의 `redirect_http_to_https` 미들웨어) — 프록시가 이 헤더를 안 보내주면(로컬 `uvicorn` 직접 실행 등) 그냥 통과하므로 로컬 개발엔 영향 없다. 이 미들웨어가 실제로 동작하려면 **프록시가 HTTP(80)와 HTTPS(443) 요청을 모두 앱까지 전달하면서 각각 `X-Forwarded-Proto: http`/`https`를 명시적으로 설정**해야 한다 — 시놀로지 NAS 역방향 프록시처럼 리다이렉트 기능 자체가 없는 프록시 뒤에 배포할 때 특히 이 헤더 설정을 빠뜨리기 쉽다(80번 포트에 대한 프록시 규칙 자체가 없으면 트래픽이 앱에 도달하지도 못하고 NAS 자체 관리 페이지 등 엉뚱한 곳으로 샐 수 있음 — 실제로 이 문제가 있었음). 프록시가 컨테이너와 같은 호스트에서 돈다면 `docker-compose.yml`의 포트 매핑을 `"127.0.0.1:8000:8000"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import bleach
|
||||
import markdown
|
||||
from markupsafe import Markup
|
||||
|
||||
# nl2br: 빈 줄 없이 그냥 엔터만 쳐도 줄바꿈되게 한다 — 지금까지 백엔드가 순수 텍스트를
|
||||
# white-space: pre-wrap으로 보여주던 것과 체감이 최대한 비슷하도록.
|
||||
_MARKDOWN_EXTENSIONS = ["nl2br", "sane_lists"]
|
||||
|
||||
_ALLOWED_TAGS = [
|
||||
"p", "br", "strong", "em", "del",
|
||||
"h1", "h2", "h3", "h4",
|
||||
"ul", "ol", "li",
|
||||
"blockquote", "code", "pre", "hr", "a", "img",
|
||||
]
|
||||
_ALLOWED_ATTRS = {"a": ["href", "title"], "img": ["src", "alt", "title"]}
|
||||
|
||||
|
||||
def render_markdown(text: str) -> Markup:
|
||||
"""저널 기록 내용을 마크다운 HTML로 렌더링한다. markdown 라이브러리는 기본적으로 원본 HTML을
|
||||
그대로 통과시키므로(<script> 등 포함) bleach로 허용 태그만 남기고 나머지는 전부 지운다 —
|
||||
이 함수가 반환하는 Markup만 템플릿에서 이스케이프 없이(그대로 안전하게) 렌더링해야 한다."""
|
||||
html = markdown.markdown(text, extensions=_MARKDOWN_EXTENSIONS)
|
||||
return Markup(bleach.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRS, strip=True))
|
||||
+21
-1
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -26,6 +26,26 @@ def get_media(
|
||||
return FileResponse(path, filename=attachment.original_filename)
|
||||
|
||||
|
||||
@router.post("/paste-image")
|
||||
def paste_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_login),
|
||||
):
|
||||
try:
|
||||
filename = journal_service.save_pasted_image(current_user.id, file)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"url": f"/api/journal/pasted-media/{filename}"}
|
||||
|
||||
|
||||
@router.get("/pasted-media/{filename}")
|
||||
def get_pasted_image(filename: str, current_user: User = Depends(require_login)):
|
||||
path = journal_service.get_pasted_image_path(current_user.id, filename)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="이미지를 찾을 수 없습니다")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
@router.post("/categories/reorder")
|
||||
def reorder_categories(
|
||||
data: JournalCategoryReorderRequest,
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.markdown_utils import render_markdown
|
||||
from app.models.journal import JournalMood
|
||||
from app.routers.pages import _current_user_or_redirect, templates
|
||||
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate
|
||||
@@ -33,6 +34,7 @@ templates.env.globals["journal_mood_emoji"] = {m.value: emoji for m, emoji, _ in
|
||||
# Jinja2 내장 |tojson 필터가 이 policy를 읽어서 json.dumps에 넘긴다 — 기본값(ensure_ascii=True)이면
|
||||
# 한글이 \uXXXX로 이스케이프돼 응답 본문에서 읽기 힘들어진다.
|
||||
templates.env.policies["json.dumps_kwargs"] = {"ensure_ascii": False}
|
||||
templates.env.filters["markdown"] = render_markdown
|
||||
|
||||
|
||||
def _parse_moods(raw: str) -> list[JournalMood]:
|
||||
@@ -133,6 +135,17 @@ def journal_day_detail(request: Request, entry_date: date, db: Session = Depends
|
||||
return _render_day_detail(request, db, current.id, entry_date)
|
||||
|
||||
|
||||
@router.post("/journal/preview")
|
||||
def preview_entry_content(request: Request, content: str = Form(""), db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
if not content.strip():
|
||||
return HTMLResponse('<span class="empty-state">미리보기가 여기에 표시돼요</span>')
|
||||
return HTMLResponse(render_markdown(content))
|
||||
|
||||
|
||||
@router.post("/journal/new")
|
||||
def create_entry_page(
|
||||
request: Request,
|
||||
@@ -149,6 +162,9 @@ def create_entry_page(
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
if journal_service.get_category(db, category_id, current.id) is None:
|
||||
return HTMLResponse("카테고리를 찾을 수 없어요")
|
||||
|
||||
try:
|
||||
parsed_date = date.fromisoformat(entry_date)
|
||||
data = JournalEntryCreate(
|
||||
@@ -202,6 +218,16 @@ def edit_entry_page(
|
||||
|
||||
original_date = entry.entry_date
|
||||
|
||||
if journal_service.get_category(db, category_id, current.id) is None:
|
||||
return _render_day_detail(
|
||||
request,
|
||||
db,
|
||||
current.id,
|
||||
original_date,
|
||||
edit_error="카테고리를 찾을 수 없어요",
|
||||
editing_entry_id=entry_id,
|
||||
)
|
||||
|
||||
try:
|
||||
parsed_date = date.fromisoformat(entry_date)
|
||||
data = JournalEntryUpdate(
|
||||
|
||||
@@ -29,10 +29,13 @@ class JournalCategoryBase(BaseModel):
|
||||
@field_validator("content_template")
|
||||
@classmethod
|
||||
def blank_template_to_none(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
# color/name과 달리 여기선 .strip()으로 값 자체를 바꾸지 않는다 — 템플릿 맨 끝의
|
||||
# "- "(대시+공백)처럼 의미 있는 trailing whitespace가 있을 수 있고, 그걸 지우면
|
||||
# markdown이 그 줄을 목록으로 인식하지 못하게 된다(빈 값인지 판단만 strip으로 하고,
|
||||
# 실제로 저장하는 값은 원본을 그대로 쓴다).
|
||||
if v is None or not v.strip():
|
||||
return None
|
||||
v = v.strip()
|
||||
return v or None
|
||||
return v
|
||||
|
||||
|
||||
class JournalCategoryCreate(JournalCategoryBase):
|
||||
|
||||
@@ -34,27 +34,27 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
|
||||
THUMBNAIL_WIDTH = 400
|
||||
DEFAULT_CATEGORY_NAME = "일상"
|
||||
DEFAULT_CATEGORY_TEMPLATE = """**1. Story : 오늘 무슨 일이 있었나요?**
|
||||
|
||||
-
|
||||
|
||||
**2. Feelings : 오늘 들었던 생각과 나의 감정은?**
|
||||
|
||||
-
|
||||
|
||||
**3. Decisions : 오늘 내가 내린 결정이 있나요?**
|
||||
|
||||
-
|
||||
|
||||
**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**
|
||||
|
||||
-
|
||||
|
||||
→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.
|
||||
|
||||
**5. Actions : 내가 다음에 할 행동은 무엇인가요?**
|
||||
|
||||
- """
|
||||
# 목록 기호("- ") 뒤에 공백이 없으면(그냥 "-"만 있으면) markdown 라이브러리가 목록으로 안 잡고
|
||||
# 그냥 문단 텍스트로 렌더링한다 — 그래서 다섯 줄 다 "- "(대시+공백)로 통일해야 실제로
|
||||
# 빈 체크리스트 항목(<li></li>)이 만들어진다. 제목 줄과 "-" 사이에 빈 줄이 없으면 markdown이
|
||||
# 그 "-"를 목록이 아니라 제목 밑줄(setext heading)로 오인해서 제목 자체가 사라지므로 빈 줄도 필수.
|
||||
_BULLET = "- " # 뒤 공백이 핵심 — 트리플쿼트 문자열 끝의 trailing space는 도구를 거치며 잘려나가서
|
||||
# 여기서는 따옴표 "안쪽"에 명시적으로 넣어 안 잘리게 한다.
|
||||
DEFAULT_CATEGORY_TEMPLATE = "\n\n".join(
|
||||
[
|
||||
"**1. Story : 오늘 무슨 일이 있었나요?**",
|
||||
_BULLET,
|
||||
"**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
|
||||
_BULLET,
|
||||
"**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
|
||||
_BULLET,
|
||||
"**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
|
||||
_BULLET,
|
||||
"→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
|
||||
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
|
||||
_BULLET,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---- 카테고리 ----
|
||||
@@ -294,6 +294,41 @@ def delete_attachment(db: Session, attachment: JournalAttachment) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---- 에디터에 붙여넣은 이미지 ----
|
||||
# 글을 쓰는 중(아직 엔트리가 저장되기 전)에 클립보드로 붙여넣은 이미지라 JournalAttachment처럼
|
||||
# entry_id에 묶을 수가 없다 — DB 행 없이 유저별 폴더에만 저장하고, 마크다운 본문에
|
||||
#  형태로 직접 참조한다. 그래서 첨부파일 갤러리(삭제 버튼 등)에는 안 뜨고, 엔트리를
|
||||
# 지워도 자동으로 같이 지워지지 않는다(개인 규모 사용량이라 감수할 만한 트레이드오프).
|
||||
def pasted_image_dir(user_id: int) -> Path:
|
||||
return Path(settings.journal_media_root) / str(user_id) / "pasted"
|
||||
|
||||
|
||||
def save_pasted_image(user_id: int, upload_file: UploadFile) -> str:
|
||||
"""붙여넣은 이미지를 저장하고 파일명(서빙 URL에 쓸 값)을 반환한다."""
|
||||
content_type = upload_file.content_type or ""
|
||||
if content_type not in ALLOWED_IMAGE_TYPES:
|
||||
raise ValueError("이미지 파일만 붙여넣을 수 있어요 (jpg/png/webp/gif)")
|
||||
|
||||
data = upload_file.file.read()
|
||||
max_bytes = settings.journal_max_upload_mb * 1024 * 1024
|
||||
if len(data) > max_bytes:
|
||||
raise ValueError(f"파일 용량은 {settings.journal_max_upload_mb}MB를 넘을 수 없어요")
|
||||
|
||||
target_dir = pasted_image_dir(user_id)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = mimetypes.guess_extension(content_type) or ".png"
|
||||
filename = f"{uuid.uuid4().hex}{ext}"
|
||||
(target_dir / filename).write_bytes(data)
|
||||
return filename
|
||||
|
||||
|
||||
def get_pasted_image_path(user_id: int, filename: str) -> Path | None:
|
||||
# Path(...).name이 디렉터리 구분자를 전부 제거해줘서 "../"류 경로 탈출을 막아준다.
|
||||
safe_name = Path(filename).name
|
||||
path = pasted_image_dir(user_id) / safe_name
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
# ---- 캘린더 / day-detail / 회상 ----
|
||||
|
||||
|
||||
|
||||
+139
-1
@@ -902,8 +902,146 @@ label {
|
||||
}
|
||||
|
||||
.journal-entry-content {
|
||||
white-space: pre-wrap;
|
||||
margin-top: 4px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.journal-preview {
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-bg);
|
||||
min-height: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 마크다운 에디터 툴바 (EasyMDE는 toolbar:false로 끄고 여기서 자체 버튼으로 대체 —
|
||||
EasyMDE 기본 툴바는 Font Awesome CDN을 전제로 해서 이 앱의 "CDN 금지" 원칙과 안 맞는다) */
|
||||
.markdown-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.md-tool-btn {
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.md-tool-btn:active {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다 */
|
||||
.markdown-editor + .EasyMDEContainer .CodeMirror {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
padding: 6px 8px;
|
||||
overflow: hidden; /* 안에서 뭐가 카드 폭보다 커지려 해도 밖으로 안 새어나가게 */
|
||||
}
|
||||
|
||||
.markdown-editor + .EasyMDEContainer .CodeMirror-cursor {
|
||||
border-left-color: var(--color-text);
|
||||
}
|
||||
|
||||
.markdown-editor + .EasyMDEContainer .editor-statusbar {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 원본 해상도가 큰 이미지를 붙여넣었을 때 에디터/미리보기 폭을 넘어가지 않게 캡핑.
|
||||
.journal-entry-content img가 미리보기(.journal-preview)는 이미 커버하지만, 에디터 쪽
|
||||
(CodeMirror가 마크다운 이미지를 인라인 위젯으로 그리는 경우)도 같은 규칙을 강제로 적용. */
|
||||
.markdown-editor + .EasyMDEContainer .CodeMirror img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.journal-entry-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.journal-entry-content > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.journal-entry-content p,
|
||||
.journal-entry-content ul,
|
||||
.journal-entry-content ol,
|
||||
.journal-entry-content blockquote,
|
||||
.journal-entry-content pre {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.journal-entry-content h1,
|
||||
.journal-entry-content h2,
|
||||
.journal-entry-content h3,
|
||||
.journal-entry-content h4 {
|
||||
margin: 12px 0 6px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.journal-entry-content h1 { font-size: 19px; }
|
||||
.journal-entry-content h2 { font-size: 17px; }
|
||||
.journal-entry-content h3,
|
||||
.journal-entry-content h4 { font-size: 15px; }
|
||||
|
||||
.journal-entry-content ul,
|
||||
.journal-entry-content ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.journal-entry-content blockquote {
|
||||
margin-left: 0;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--color-accent);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.journal-entry-content code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.journal-entry-content pre {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.journal-entry-content pre code {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.journal-entry-content a {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.journal-entry-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-control);
|
||||
display: block;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.journal-entry-tags {
|
||||
|
||||
+7
File diff suppressed because one or more lines are too long
@@ -0,0 +1,105 @@
|
||||
(function () {
|
||||
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을 전제로
|
||||
// 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal.html/journal_day_detail.html에서
|
||||
// 직접 만든 텍스트 버튼이 아래 EDITOR_ACTIONS로 EasyMDE 인스턴스 메서드를 호출한다.
|
||||
function initEditors(root) {
|
||||
if (typeof EasyMDE === "undefined") return;
|
||||
var scope = root instanceof Element ? root : document;
|
||||
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-easymde-initialized])");
|
||||
|
||||
textareas.forEach(function (textarea) {
|
||||
textarea.setAttribute("data-easymde-initialized", "true");
|
||||
|
||||
var easymde = new EasyMDE({
|
||||
element: textarea,
|
||||
toolbar: false,
|
||||
spellChecker: false,
|
||||
autoDownloadFontAwesome: false,
|
||||
status: false,
|
||||
placeholder: textarea.getAttribute("placeholder") || "",
|
||||
minHeight: (textarea.getAttribute("rows") || 6) * 24 + "px",
|
||||
// 글머리 목록 버튼/Enter 자동 이어쓰기가 기본 "*" 대신 "-"를 쓰게 한다.
|
||||
// 서버 렌더링(app/markdown_utils.py)은 -/*/+ 전부 동일하게 처리하니 렌더링과는 무관하고
|
||||
// 순수하게 에디터가 새로 만들어주는 글머리 기호에 대한 취향 설정이다.
|
||||
unorderedListStyle: "-",
|
||||
});
|
||||
|
||||
// CodeMirror는 원본 textarea와 실시간으로 값이 동기화되지 않는다(.save()를 명시적으로
|
||||
// 불러야 함) — 매 변경마다 저장하고, htmx 미리보기(hx-trigger="input ...")와
|
||||
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
|
||||
easymde.codemirror.on("change", function () {
|
||||
easymde.codemirror.save();
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
easymde.codemirror.on("paste", function (cm, event) {
|
||||
handleImagePaste(cm, event);
|
||||
});
|
||||
|
||||
textarea._easymde = easymde;
|
||||
});
|
||||
}
|
||||
|
||||
// 클립보드에 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고
|
||||
// 그 자리에 마크다운 이미지 문법()을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
|
||||
// CodeMirror 기본 텍스트 붙여넣기가 그대로 동작하게 둔다.
|
||||
function handleImagePaste(cm, event) {
|
||||
var items = event.clipboardData && event.clipboardData.items;
|
||||
if (!items) return;
|
||||
|
||||
var imageItem = null;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf("image/") === 0) {
|
||||
imageItem = items[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!imageItem) return;
|
||||
|
||||
event.preventDefault();
|
||||
var file = imageItem.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
var doc = cm.getDoc();
|
||||
var from = doc.getCursor();
|
||||
var placeholder = "![업로드 중...]()";
|
||||
doc.replaceRange(placeholder, from);
|
||||
var to = { line: from.line, ch: from.ch + placeholder.length };
|
||||
// 업로드가 끝나기 전에 사용자가 다른 곳에서 계속 타이핑해도(줄 추가 등) 자리를 잃지
|
||||
// 않도록 고정 좌표 대신 CodeMirror 북마크로 추적한다.
|
||||
var startMark = doc.setBookmark(from);
|
||||
var endMark = doc.setBookmark(to);
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append("file", file, file.name || "pasted-image.png");
|
||||
|
||||
fetch("/api/journal/paste-image", { method: "POST", body: formData })
|
||||
.then(function (res) {
|
||||
if (!res.ok) return res.json().then(function (body) { throw new Error(body.detail || "업로드 실패"); });
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
var start = startMark.find();
|
||||
var end = endMark.find();
|
||||
if (start && end) { doc.replaceRange("", start, end); }
|
||||
})
|
||||
.catch(function (err) {
|
||||
var start = startMark.find();
|
||||
var end = endMark.find();
|
||||
if (start && end) { doc.replaceRange("(이미지 붙여넣기 실패: " + err.message + ")", start, end); }
|
||||
})
|
||||
.finally(function () {
|
||||
startMark.clear();
|
||||
endMark.clear();
|
||||
cm.save();
|
||||
cm.getTextArea().dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initEditors(document);
|
||||
});
|
||||
document.body.addEventListener("htmx:afterSwap", function (evt) {
|
||||
initEditors(evt.target);
|
||||
});
|
||||
})();
|
||||
Vendored
+7
File diff suppressed because one or more lines are too long
@@ -1,13 +1,16 @@
|
||||
const CACHE_NAME = "habit-tracker-v5";
|
||||
const CACHE_NAME = "habit-tracker-v6";
|
||||
const APP_SHELL = [
|
||||
"/static/css/style.css",
|
||||
"/static/css/vendor/easymde.min.css",
|
||||
"/static/js/app.js",
|
||||
"/static/js/push-register.js",
|
||||
"/static/js/habit-reorder.js",
|
||||
"/static/js/journal-category-reorder.js",
|
||||
"/static/js/journal-editor.js",
|
||||
"/static/js/vendor/htmx.min.js",
|
||||
"/static/js/vendor/alpine.min.js",
|
||||
"/static/js/vendor/sortable.min.js",
|
||||
"/static/js/vendor/easymde.min.js",
|
||||
"/static/icons/icon-192.png",
|
||||
"/static/icons/icon-512.png",
|
||||
"/static/icons/icon-apple-180.png",
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
<link rel="icon" href="/static/icons/icon-192.png" />
|
||||
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
<link rel="stylesheet" href="/static/css/vendor/easymde.min.css" />
|
||||
<script src="/static/js/vendor/htmx.min.js" defer></script>
|
||||
<script src="/static/js/push-register.js" defer></script>
|
||||
<script src="/static/js/vendor/sortable.min.js" defer></script>
|
||||
<script src="/static/js/habit-reorder.js" defer></script>
|
||||
<script src="/static/js/journal-category-reorder.js" defer></script>
|
||||
<script src="/static/js/vendor/easymde.min.js" defer></script>
|
||||
<script src="/static/js/journal-editor.js" defer></script>
|
||||
<script src="/static/js/vendor/alpine.min.js" defer></script>
|
||||
<script src="/static/js/app.js" defer></script>
|
||||
</head>
|
||||
|
||||
@@ -124,11 +124,20 @@
|
||||
categoryTemplates: {{ category_templates|tojson|forceescape }},
|
||||
content: '',
|
||||
lastAutoFilled: '',
|
||||
previewMode: false,
|
||||
togglePreview() {
|
||||
this.previewMode = !this.previewMode;
|
||||
if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); }
|
||||
},
|
||||
onCategoryChange(categoryId) {
|
||||
const tmpl = this.categoryTemplates[categoryId] || '';
|
||||
if (this.content === this.lastAutoFilled) {
|
||||
this.content = tmpl;
|
||||
this.lastAutoFilled = tmpl;
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.contentField._easymde) { this.$refs.contentField._easymde.value(tmpl); }
|
||||
this.$refs.contentField.dispatchEvent(new Event('input'));
|
||||
});
|
||||
}
|
||||
},
|
||||
init() { this.onCategoryChange(this.$refs.categorySelect.value); },
|
||||
@@ -173,7 +182,25 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="new-entry-content">내용</label>
|
||||
<textarea id="new-entry-content" name="content" rows="8" required placeholder="오늘 하루는 어땠나요?" x-model="content"></textarea>
|
||||
{% include "partials/journal_editor_toolbar.html" %}
|
||||
<div x-show="!previewMode">
|
||||
<textarea
|
||||
id="new-entry-content"
|
||||
name="content"
|
||||
rows="8"
|
||||
required
|
||||
placeholder="오늘 하루는 어땠나요?"
|
||||
class="markdown-editor"
|
||||
x-model="content"
|
||||
x-ref="contentField"
|
||||
hx-post="/journal/preview"
|
||||
hx-trigger="input changed delay:400ms, load"
|
||||
hx-target="#new-entry-preview"
|
||||
hx-swap="innerHTML"
|
||||
hx-params="content"
|
||||
></textarea>
|
||||
</div>
|
||||
<div id="new-entry-preview" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
editing: {{ 'true' if editing_entry_id == item.id else 'false' }},
|
||||
moods: [{% for m in item.moods %}'{{ m.value }}'{{ ',' if not loop.last }}{% endfor %}],
|
||||
toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) },
|
||||
previewMode: false,
|
||||
togglePreview() {
|
||||
this.previewMode = !this.previewMode;
|
||||
if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); }
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div x-show="!editing">
|
||||
@@ -24,7 +29,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.title %}<div class="journal-entry-title">{{ item.title }}</div>{% endif %}
|
||||
<div class="journal-entry-content">{{ item.content }}</div>
|
||||
<div class="journal-entry-content">{{ item.content|markdown }}</div>
|
||||
{% if item.tags %}
|
||||
<div class="journal-entry-tags">
|
||||
{% for tag in item.tags %}<span class="tag-chip">#{{ tag }}</span>{% endfor %}
|
||||
@@ -94,7 +99,22 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>내용</label>
|
||||
<textarea name="content" rows="4" required>{{ item.content }}</textarea>
|
||||
{% include "partials/journal_editor_toolbar.html" %}
|
||||
<div x-show="!previewMode">
|
||||
<textarea
|
||||
name="content"
|
||||
rows="6"
|
||||
required
|
||||
class="markdown-editor"
|
||||
x-ref="contentField"
|
||||
hx-post="/journal/preview"
|
||||
hx-trigger="input changed delay:400ms, load"
|
||||
hx-target="#journal-edit-preview-{{ item.id }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-params="content"
|
||||
>{{ item.content }}</textarea>
|
||||
</div>
|
||||
<div id="journal-edit-preview-{{ item.id }}" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>기분 (여러 개 선택 가능)</label>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<div class="markdown-toolbar">
|
||||
<span x-show="!previewMode" style="display:flex; gap:4px; flex-wrap:wrap;">
|
||||
<button type="button" class="md-tool-btn" title="굵게" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleBold()"><strong>B</strong></button>
|
||||
<button type="button" class="md-tool-btn" title="기울임" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleItalic()"><em>I</em></button>
|
||||
<button type="button" class="md-tool-btn" title="취소선" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleStrikethrough()"><s>S</s></button>
|
||||
<button type="button" class="md-tool-btn" title="제목" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleHeadingSmaller()">H</button>
|
||||
<button type="button" class="md-tool-btn" title="인용" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleBlockquote()">❝</button>
|
||||
<button type="button" class="md-tool-btn" title="글머리 목록" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleUnorderedList()">•</button>
|
||||
<button type="button" class="md-tool-btn" title="번호 목록" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleOrderedList()">1.</button>
|
||||
<button type="button" class="md-tool-btn" title="코드" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleCodeBlock()"></></button>
|
||||
<button type="button" class="md-tool-btn" title="링크" @click="$el.closest('.field').querySelector('textarea')._easymde?.drawLink()">🔗</button>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="md-tool-btn md-preview-toggle"
|
||||
style="margin-left:auto;"
|
||||
@click="togglePreview()"
|
||||
x-text="previewMode ? '✏️ 편집' : '👁 미리보기'"
|
||||
></button>
|
||||
</div>
|
||||
@@ -9,3 +9,5 @@ services:
|
||||
- TZ=Asia/Seoul
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./media:/app/app/media
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""fix default 일상 template: bare "-" bullets don't render as list items
|
||||
|
||||
Revision ID: 0014_fix_template_bullets
|
||||
Revises: 0013_journal_category_template
|
||||
Create Date: 2026-08-05
|
||||
|
||||
0013에서 넣은 기본 틀의 "-" 줄들이 뒤에 공백이 없어서, markdown 렌더러가 목록으로 인식하지 못하고
|
||||
그냥 문단 텍스트("-")로 렌더링됐다(제목 줄과 "-" 사이의 빈 줄 자체는 맞게 넣어서 제목이 밑줄로
|
||||
오인되는 문제는 없었음). "- "(대시+공백)로 통일해야 실제 빈 목록 항목(<li></li>)이 된다.
|
||||
아직 이 기본값을 커스터마이징하지 않은(즉 0013이 넣어준 원래 문구 그대로인) "일상" 카테고리만
|
||||
갱신한다 — 이미 사용자가 직접 수정한 틀은 덮어쓰지 않는다.
|
||||
|
||||
(참고: revision id를 "0014_fix_category_template_bullets"로 처음 만들었다가 alembic_version.
|
||||
version_num이 VARCHAR(32)라 34자짜리 id가 안 들어가서 DataError가 났다 — 25자로 줄여 다시 만든
|
||||
파일이다. 실제 데이터 UPDATE 자체는 그때 이미 반영됐고 버전 기록만 실패한 상태였다.)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0014_fix_template_bullets"
|
||||
down_revision: Union[str, None] = "0013_journal_category_template"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_BULLET = "- "
|
||||
|
||||
_OLD_TEMPLATE = "\n\n".join(
|
||||
[
|
||||
"**1. Story : 오늘 무슨 일이 있었나요?**",
|
||||
"-",
|
||||
"**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
|
||||
"-",
|
||||
"**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
|
||||
"-",
|
||||
"**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
|
||||
"-",
|
||||
"→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
|
||||
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
|
||||
_BULLET,
|
||||
]
|
||||
)
|
||||
|
||||
_NEW_TEMPLATE = "\n\n".join(
|
||||
[
|
||||
"**1. Story : 오늘 무슨 일이 있었나요?**",
|
||||
_BULLET,
|
||||
"**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
|
||||
_BULLET,
|
||||
"**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
|
||||
_BULLET,
|
||||
"**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
|
||||
_BULLET,
|
||||
"→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
|
||||
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
|
||||
_BULLET,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
journal_category = sa.table(
|
||||
"journal_category", sa.column("name", sa.String), sa.column("content_template", sa.Text)
|
||||
)
|
||||
op.execute(
|
||||
journal_category.update()
|
||||
.where(journal_category.c.name == "일상", journal_category.c.content_template == _OLD_TEMPLATE)
|
||||
.values(content_template=_NEW_TEMPLATE)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
journal_category = sa.table(
|
||||
"journal_category", sa.column("name", sa.String), sa.column("content_template", sa.Text)
|
||||
)
|
||||
op.execute(
|
||||
journal_category.update()
|
||||
.where(journal_category.c.name == "일상", journal_category.c.content_template == _NEW_TEMPLATE)
|
||||
.values(content_template=_OLD_TEMPLATE)
|
||||
)
|
||||
@@ -20,6 +20,8 @@ dependencies = [
|
||||
"authlib>=1.3",
|
||||
"httpx>=0.27",
|
||||
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성 + 저널 첨부 이미지 썸네일 생성(런타임)
|
||||
"markdown>=3.7", # 저널 기록 내용을 마크다운으로 렌더링
|
||||
"bleach>=6.0", # 렌더링된 마크다운 HTML을 허용 태그만 남기고 sanitize (XSS 방지)
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -23,7 +23,9 @@ DEPLOY_ENV_PATH = PROJECT_ROOT / "deploy.env"
|
||||
|
||||
# Dockerfile이 COPY하는 것과 동일한 목록 + 컨테이너 정의 파일
|
||||
SYNC_TARGETS = ["pyproject.toml", "alembic.ini", "Dockerfile", "docker-compose.yml", "app", "migrations", "scripts"]
|
||||
SKIP_NAMES = {"__pycache__"}
|
||||
# app/media는 소스 코드가 아니라 런타임에 생성되는 유저 업로드 데이터라 절대 동기화하면 안 된다 —
|
||||
# 로컬에서 테스트하며 쌓인 실제 유저 사진이 원격 빌드 컨텍스트로 그대로 올라가버리는 사고가 있었다.
|
||||
SKIP_NAMES = {"__pycache__", "media"}
|
||||
SKIP_SUFFIXES = {".pyc"}
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,78 @@ def test_get_media_returns_404_for_other_users_attachment(auth_client, db_sessio
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def _png_bytes():
|
||||
buf = BytesIO()
|
||||
Image.new("RGB", (400, 300), "red").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_paste_image_requires_login(client):
|
||||
response = client.post("/api/journal/paste-image", files={"file": ("a.png", _png_bytes(), "image/png")})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_paste_image_uploads_and_serves(auth_client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
|
||||
|
||||
response = auth_client.post("/api/journal/paste-image", files={"file": ("a.png", _png_bytes(), "image/png")})
|
||||
assert response.status_code == 200
|
||||
url = response.json()["url"]
|
||||
assert url.startswith("/api/journal/pasted-media/")
|
||||
|
||||
fetched = auth_client.get(url)
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.content[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def test_paste_image_rejects_unsupported_type(auth_client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
|
||||
|
||||
response = auth_client.post(
|
||||
"/api/journal/paste-image", files={"file": ("a.pdf", b"%PDF-1.4", "application/pdf")}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_paste_image_rejects_oversized_file(auth_client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
|
||||
monkeypatch.setattr(journal_service.settings, "journal_max_upload_mb", 0)
|
||||
|
||||
response = auth_client.post("/api/journal/paste-image", files={"file": ("a.png", _png_bytes(), "image/png")})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_pasted_media_requires_login(client):
|
||||
response = client.get("/api/journal/pasted-media/anything.png")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_pasted_media_returns_404_for_missing_file(auth_client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
|
||||
response = auth_client.get("/api/journal/pasted-media/does-not-exist.png")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_pasted_media_is_scoped_per_user(auth_client, db_session, other_user, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
|
||||
filename = journal_service.save_pasted_image(
|
||||
other_user.id,
|
||||
UploadFile(file=BytesIO(_png_bytes()), filename="a.png", headers=Headers({"content-type": "image/png"})),
|
||||
)
|
||||
|
||||
# 파일명을 정확히 알아도 다른 유저 소유 폴더 안에 있으면 접근할 수 없어야 한다(디렉터리로 스코핑됨).
|
||||
response = auth_client.get(f"/api/journal/pasted-media/{filename}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_pasted_media_blocks_path_traversal(auth_client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
|
||||
# 요청 자체가 상대경로 컴포넌트를 포함하면 라우팅에서 걸러지지만, 인코딩된 경로 구분자로
|
||||
# 시도해도 Path(...).name이 디렉터리 구분자를 다 제거해서 상위 폴더로 못 나간다.
|
||||
response = auth_client.get("/api/journal/pasted-media/..%2f..%2f..%2fetc%2fpasswd")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_reorder_categories_requires_login(client):
|
||||
response = client.post("/api/journal/categories/reorder", json={"category_ids": [1, 2]})
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -41,13 +41,30 @@ def _upload(filename: str, content_type: str, data: bytes) -> UploadFile:
|
||||
def test_ensure_default_category_creates_once(db_session, test_user):
|
||||
first = journal_service.ensure_default_category(db_session, test_user.id)
|
||||
assert first.name == journal_service.DEFAULT_CATEGORY_NAME
|
||||
assert first.content_template == journal_service.DEFAULT_CATEGORY_TEMPLATE.strip()
|
||||
assert first.content_template == journal_service.DEFAULT_CATEGORY_TEMPLATE
|
||||
|
||||
second = journal_service.ensure_default_category(db_session, test_user.id)
|
||||
assert second.id == first.id
|
||||
assert len(journal_service.list_categories(db_session, test_user.id)) == 1
|
||||
|
||||
|
||||
def test_default_category_template_renders_as_headers_and_lists_not_bare_dashes(db_session, test_user):
|
||||
from app.markdown_utils import render_markdown
|
||||
|
||||
category = journal_service.ensure_default_category(db_session, test_user.id)
|
||||
html = render_markdown(category.content_template)
|
||||
|
||||
# "-"에 뒤 공백이 없으면 markdown이 목록으로 안 잡고 그냥 "<p>-</p>"로 렌더링해버리는
|
||||
# 회귀가 있었다 — 다섯 항목 전부 실제 <ul><li> 목록이어야 한다.
|
||||
assert html.count("<ul>") == 5
|
||||
assert html.count("<li>") == 5
|
||||
# 제목 줄 바로 다음에 "-"가 오면(빈 줄 없이) markdown이 그걸 제목 밑줄로 오인해서
|
||||
# 굵은 글씨가 아니라 <h1>/<h2> 제목으로 바뀌어버리는 회귀도 있었다.
|
||||
assert "<h1" not in html
|
||||
assert "<h2" not in html
|
||||
assert html.count("<strong>") == 6
|
||||
|
||||
|
||||
def test_category_content_template_persists_and_updates(db_session, test_user):
|
||||
category = journal_service.create_category(
|
||||
db_session, test_user.id, JournalCategoryCreate(name="회고", content_template="질문 1\n\n질문 2")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from app.markdown_utils import render_markdown
|
||||
|
||||
|
||||
def test_bold_and_heading_render_as_html():
|
||||
html = render_markdown("# 제목\n\n**굵게** 쓴 문장입니다")
|
||||
assert "<h1>제목</h1>" in html
|
||||
assert "<strong>굵게</strong>" in html
|
||||
|
||||
|
||||
def test_single_newline_becomes_line_break():
|
||||
html = render_markdown("첫째 줄\n둘째 줄")
|
||||
assert "<br" in html
|
||||
|
||||
|
||||
def test_list_renders_as_html_list():
|
||||
html = render_markdown("- 하나\n- 둘")
|
||||
assert "<ul>" in html
|
||||
assert "<li>하나</li>" in html
|
||||
|
||||
|
||||
def test_dash_and_star_bullets_render_identically():
|
||||
# 마크다운 글머리 기호는 -/*/+ 전부 동일하게 처리돼야 한다 — 에디터 쪽 기본 기호(unorderedListStyle)만
|
||||
# "-"로 바뀌었을 뿐, 렌더링 결과는 어떤 기호를 써도 같아야 한다.
|
||||
assert render_markdown("- 하나\n- 둘") == render_markdown("* 하나\n* 둘") == render_markdown("+ 하나\n+ 둘")
|
||||
|
||||
|
||||
def test_script_tag_is_stripped_not_executed():
|
||||
html = render_markdown('<script>alert("xss")</script>본문')
|
||||
assert "<script" not in html
|
||||
assert "alert" not in html or "<script" not in html # 태그는 지워지고 텍스트만 남아야 함
|
||||
|
||||
|
||||
def test_javascript_href_is_neutralized():
|
||||
html = render_markdown('[click me](javascript:alert(1))')
|
||||
assert "javascript:" not in html
|
||||
|
||||
|
||||
def test_onerror_attribute_is_stripped_even_though_img_is_allowed():
|
||||
html = render_markdown('<img src="x.png" onerror="alert(1)">본문')
|
||||
assert "onerror" not in html
|
||||
assert '<img src="x.png">' in html # img 자체는 허용되지만 onerror 같은 이벤트 속성은 지워져야 함
|
||||
|
||||
|
||||
def test_allowed_link_href_is_preserved():
|
||||
html = render_markdown("[내 블로그](https://example.com)")
|
||||
assert 'href="https://example.com"' in html
|
||||
|
||||
|
||||
def test_pasted_image_markdown_renders_with_relative_src():
|
||||
# 붙여넣은 이미지는 절대 URL이 아니라 /api/journal/pasted-media/... 같은 상대 경로로 참조된다 —
|
||||
# bleach가 스킴 없는 상대 경로도 그대로 통과시키는지 확인.
|
||||
html = render_markdown("")
|
||||
assert 'src="/api/journal/pasted-media/abc123.png"' in html
|
||||
@@ -101,6 +101,22 @@ def test_create_entry_rejects_blank_content(auth_client, db_session, test_user):
|
||||
assert journal_service.list_entries(db_session, test_user.id) == []
|
||||
|
||||
|
||||
def test_create_entry_rejects_other_users_category(auth_client, db_session, test_user, other_user):
|
||||
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
|
||||
|
||||
response = auth_client.post(
|
||||
"/journal/new",
|
||||
data={
|
||||
"category_id": str(others_category.id),
|
||||
"entry_date": date.today().isoformat(),
|
||||
"content": "가로채기 시도",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "카테고리를 찾을 수 없어요" in response.text
|
||||
assert journal_service.list_entries(db_session, test_user.id) == []
|
||||
|
||||
|
||||
def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
today = date.today()
|
||||
@@ -111,12 +127,58 @@ def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
|
||||
assert "오늘의 기록" in response.text
|
||||
|
||||
|
||||
def test_journal_day_detail_renders_content_as_markdown(auth_client, db_session, test_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
today = date.today()
|
||||
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="**굵은 글씨** 테스트")
|
||||
|
||||
response = auth_client.get(f"/journal/day/{today.isoformat()}")
|
||||
assert response.status_code == 200
|
||||
assert "<strong>굵은 글씨</strong>" in response.text
|
||||
|
||||
|
||||
def test_journal_day_detail_strips_script_tags_from_content(auth_client, db_session, test_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
today = date.today()
|
||||
_make_entry(
|
||||
db_session, test_user.id, category.id, entry_date=today, content='<script>alert(1)</script>본문'
|
||||
)
|
||||
|
||||
response = auth_client.get(f"/journal/day/{today.isoformat()}")
|
||||
assert response.status_code == 200
|
||||
assert "<script" not in response.text
|
||||
|
||||
|
||||
def test_journal_day_detail_requires_login(client):
|
||||
response = client.get(f"/journal/day/{date.today().isoformat()}", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_preview_renders_markdown(auth_client):
|
||||
response = auth_client.post("/journal/preview", data={"content": "**굵게** 그리고 - 목록"})
|
||||
assert response.status_code == 200
|
||||
assert "<strong>굵게</strong>" in response.text
|
||||
|
||||
|
||||
def test_preview_strips_script_tags(auth_client):
|
||||
response = auth_client.post("/journal/preview", data={"content": '<script>alert(1)</script>본문'})
|
||||
assert response.status_code == 200
|
||||
assert "<script" not in response.text
|
||||
|
||||
|
||||
def test_preview_shows_placeholder_for_blank_content(auth_client):
|
||||
response = auth_client.post("/journal/preview", data={"content": " "})
|
||||
assert response.status_code == 200
|
||||
assert "미리보기가 여기에 표시돼요" in response.text
|
||||
|
||||
|
||||
def test_preview_requires_login(client):
|
||||
response = client.post("/journal/preview", data={"content": "test"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_edit_entry_updates_content(auth_client, db_session, test_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
|
||||
@@ -163,6 +225,27 @@ def test_edit_other_users_entry_returns_404(auth_client, db_session, other_user)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_edit_entry_rejects_moving_to_other_users_category(auth_client, db_session, test_user, other_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
|
||||
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
|
||||
|
||||
response = auth_client.post(
|
||||
f"/journal/{entry.id}/edit",
|
||||
data={
|
||||
"category_id": str(others_category.id),
|
||||
"entry_date": entry.entry_date.isoformat(),
|
||||
"content": "가로채기 시도",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "카테고리를 찾을 수 없어요" in response.text
|
||||
|
||||
db_session.refresh(entry)
|
||||
assert entry.category_id == category.id
|
||||
assert entry.content == "원래 내용"
|
||||
|
||||
|
||||
def test_delete_entry_removes_it(auth_client, db_session, test_user):
|
||||
category = _make_category(db_session, test_user.id)
|
||||
entry = _make_entry(db_session, test_user.id, category.id, content="지울 기록")
|
||||
|
||||
Reference in New Issue
Block a user