journal: paste image from clipboard, cap embedded image size, fix media persistence

- Paste-to-embed: pasting an image into the markdown editor uploads it and
  inserts ![](url) at the cursor. Unlike gallery attachments these aren't
  tied to a journal_entry (the entry may not exist yet while composing), so
  they're stored per-user under app/media/journal/{user_id}/pasted/ with no
  DB row, served through an ownership-scoped route, and never cleaned up
  automatically when an entry is deleted -- an accepted tradeoff at this
  app's personal scale.
- The markdown sanitizer was stripping all <img> tags (not on the bleach
  allowlist), which would have silently deleted every pasted image on save;
  added img/src/alt/title while keeping event-handler attributes blocked.
- Cap embedded image width in both the editor pane and the rendered preview
  so a large pasted photo can't overflow its card.
- Fix real data loss risk found while testing this: docker-compose.yml had
  no volume for app/media, so every container recreate during a deploy wiped
  uploaded photos, and deploy_sftp.py was syncing app/media/ (runtime user
  data, not source) into the remote build context. Added the volume mount
  and excluded media/ from the sync script. Recovered and relocated the
  real attachments that had already landed in the wrong place on the NAS
  during earlier deploys this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:27:30 +09:00
co-authored by Claude Sonnet 5
parent bdf9d0bae7
commit 00c66f9df8
10 changed files with 224 additions and 8 deletions
+2 -1
View File
@@ -99,11 +99,12 @@ pytest tests/test_habits.py::test_name # 단일 테스트
## Docker 배포 ## 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` 구조를 바꾸면 이 상대경로도 깨진다. - `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)하다. - `scripts/docker-entrypoint.sh`가 컨테이너 시작마다 `alembic upgrade head`를 먼저 실행한 뒤 `uvicorn``exec`한다 — 이미 적용된 리비전은 건너뛰므로 재시작마다 실행돼도 안전(idempotent)하다.
- `.env`는 이미지에 COPY하지 않고(`.dockerignore`) `docker-compose.yml``env_file`로 런타임에 주입한다 — 이미지 레이어에 비밀번호가 남지 않게 하기 위함. - `.env`는 이미지에 COPY하지 않고(`.dockerignore`) `docker-compose.yml``env_file`로 런타임에 주입한다 — 이미지 레이어에 비밀번호가 남지 않게 하기 위함.
- **컨테이너는 반드시 1개만 실행**해야 한다 — `scheduler_service`가 프로세스 안에서 APScheduler를 직접 돌리므로, replica를 늘리면 각자 스케줄러를 따로 띄워 같은 알림을 중복 처리하려 든다(`_claim_notification_slot`의 유니크 제약 경합 방지 덕에 죽지는 않지만 애초에 여러 개 띄울 이유가 없다). - **컨테이너는 반드시 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()`) 자체는 문제가 아니라 컨테이너 타임존 설정 누락이 원인이었으니, 비슷한 날짜 관련 이상 증상이 배포 환경에서만 재현되면 먼저 컨테이너 타임존을 의심할 것. - **타임존**: `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"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장. - **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"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
+2 -2
View File
@@ -10,9 +10,9 @@ _ALLOWED_TAGS = [
"p", "br", "strong", "em", "del", "p", "br", "strong", "em", "del",
"h1", "h2", "h3", "h4", "h1", "h2", "h3", "h4",
"ul", "ol", "li", "ul", "ol", "li",
"blockquote", "code", "pre", "hr", "a", "blockquote", "code", "pre", "hr", "a", "img",
] ]
_ALLOWED_ATTRS = {"a": ["href", "title"]} _ALLOWED_ATTRS = {"a": ["href", "title"], "img": ["src", "alt", "title"]}
def render_markdown(text: str) -> Markup: def render_markdown(text: str) -> Markup:
+21 -1
View File
@@ -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 fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -26,6 +26,26 @@ def get_media(
return FileResponse(path, filename=attachment.original_filename) 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") @router.post("/categories/reorder")
def reorder_categories( def reorder_categories(
data: JournalCategoryReorderRequest, data: JournalCategoryReorderRequest,
+35
View File
@@ -294,6 +294,41 @@ def delete_attachment(db: Session, attachment: JournalAttachment) -> None:
db.commit() db.commit()
# ---- 에디터에 붙여넣은 이미지 ----
# 글을 쓰는 중(아직 엔트리가 저장되기 전)에 클립보드로 붙여넣은 이미지라 JournalAttachment처럼
# entry_id에 묶을 수가 없다 — DB 행 없이 유저별 폴더에만 저장하고, 마크다운 본문에
# ![](url) 형태로 직접 참조한다. 그래서 첨부파일 갤러리(삭제 버튼 등)에는 안 뜨고, 엔트리를
# 지워도 자동으로 같이 지워지지 않는다(개인 규모 사용량이라 감수할 만한 트레이드오프).
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 / 회상 ---- # ---- 캘린더 / day-detail / 회상 ----
+17
View File
@@ -950,6 +950,7 @@ label {
font-family: inherit; font-family: inherit;
font-size: 15px; font-size: 15px;
padding: 6px 8px; padding: 6px 8px;
overflow: hidden; /* 안에서 뭐가 카드 폭보다 커지려 해도 밖으로 안 새어나가게 */
} }
.markdown-editor + .EasyMDEContainer .CodeMirror-cursor { .markdown-editor + .EasyMDEContainer .CodeMirror-cursor {
@@ -960,6 +961,14 @@ label {
color: var(--color-text-muted); 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 { .journal-entry-content > *:first-child {
margin-top: 0; margin-top: 0;
} }
@@ -1027,6 +1036,14 @@ label {
color: var(--color-accent); 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 { .journal-entry-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+60
View File
@@ -32,10 +32,70 @@
textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.dispatchEvent(new Event("input", { bubbles: true }));
}); });
easymde.codemirror.on("paste", function (cm, event) {
handleImagePaste(cm, event);
});
textarea._easymde = easymde; textarea._easymde = easymde;
}); });
} }
// 클립보드에 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고
// 그 자리에 마크다운 이미지 문법(![](url))을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
// 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("![](" + data.url + ")", 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 () { document.addEventListener("DOMContentLoaded", function () {
initEditors(document); initEditors(document);
}); });
+2
View File
@@ -9,3 +9,5 @@ services:
- TZ=Asia/Seoul - TZ=Asia/Seoul
env_file: env_file:
- .env - .env
volumes:
- ./media:/app/app/media
+3 -1
View File
@@ -23,7 +23,9 @@ DEPLOY_ENV_PATH = PROJECT_ROOT / "deploy.env"
# Dockerfile이 COPY하는 것과 동일한 목록 + 컨테이너 정의 파일 # Dockerfile이 COPY하는 것과 동일한 목록 + 컨테이너 정의 파일
SYNC_TARGETS = ["pyproject.toml", "alembic.ini", "Dockerfile", "docker-compose.yml", "app", "migrations", "scripts"] 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"} SKIP_SUFFIXES = {".pyc"}
+72
View File
@@ -55,6 +55,78 @@ def test_get_media_returns_404_for_other_users_attachment(auth_client, db_sessio
assert response.status_code == 404 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): def test_reorder_categories_requires_login(client):
response = client.post("/api/journal/categories/reorder", json={"category_ids": [1, 2]}) response = client.post("/api/journal/categories/reorder", json={"category_ids": [1, 2]})
assert response.status_code == 401 assert response.status_code == 401
+10 -3
View File
@@ -35,12 +35,19 @@ def test_javascript_href_is_neutralized():
assert "javascript:" not in html assert "javascript:" not in html
def test_onerror_attribute_is_stripped(): def test_onerror_attribute_is_stripped_even_though_img_is_allowed():
html = render_markdown('<img src=x onerror="alert(1)">본문') html = render_markdown('<img src="x.png" onerror="alert(1)">본문')
assert "onerror" not in html assert "onerror" not in html
assert "<img" not in html # img는 허용 태그 목록에 없음 assert '<img src="x.png">' in html # img 자체는 허용되지만 onerror 같은 이벤트 속성은 지워져야 함
def test_allowed_link_href_is_preserved(): def test_allowed_link_href_is_preserved():
html = render_markdown("[내 블로그](https://example.com)") html = render_markdown("[내 블로그](https://example.com)")
assert 'href="https://example.com"' in html 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("![](/api/journal/pasted-media/abc123.png)")
assert 'src="/api/journal/pasted-media/abc123.png"' in html