diff --git a/CLAUDE.md b/CLAUDE.md
index 68af95f..0a82a69 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
diff --git a/app/markdown_utils.py b/app/markdown_utils.py
index 27e88d7..f2c7ead 100644
--- a/app/markdown_utils.py
+++ b/app/markdown_utils.py
@@ -10,9 +10,9 @@ _ALLOWED_TAGS = [
"p", "br", "strong", "em", "del",
"h1", "h2", "h3", "h4",
"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:
diff --git a/app/routers/journal.py b/app/routers/journal.py
index 5a29f77..3fb476a 100644
--- a/app/routers/journal.py
+++ b/app/routers/journal.py
@@ -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,
diff --git a/app/services/journal_service.py b/app/services/journal_service.py
index 4944288..a8e36fd 100644
--- a/app/services/journal_service.py
+++ b/app/services/journal_service.py
@@ -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 / 회상 ----
diff --git a/app/static/css/style.css b/app/static/css/style.css
index 4d0f632..d98d601 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -950,6 +950,7 @@ label {
font-family: inherit;
font-size: 15px;
padding: 6px 8px;
+ overflow: hidden; /* 안에서 뭐가 카드 폭보다 커지려 해도 밖으로 안 새어나가게 */
}
.markdown-editor + .EasyMDEContainer .CodeMirror-cursor {
@@ -960,6 +961,14 @@ label {
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;
}
@@ -1027,6 +1036,14 @@ label {
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 {
display: flex;
flex-wrap: wrap;
diff --git a/app/static/js/journal-editor.js b/app/static/js/journal-editor.js
index efd0ebc..5c19d11 100644
--- a/app/static/js/journal-editor.js
+++ b/app/static/js/journal-editor.js
@@ -32,10 +32,70 @@
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);
});
diff --git a/docker-compose.yml b/docker-compose.yml
index 1c843c7..3519cd0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -9,3 +9,5 @@ services:
- TZ=Asia/Seoul
env_file:
- .env
+ volumes:
+ - ./media:/app/app/media
diff --git a/scripts/deploy_sftp.py b/scripts/deploy_sftp.py
index 8d74b5f..cd90f35 100644
--- a/scripts/deploy_sftp.py
+++ b/scripts/deploy_sftp.py
@@ -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"}
diff --git a/tests/test_api_journal.py b/tests/test_api_journal.py
index ab7791a..7be6d1e 100644
--- a/tests/test_api_journal.py
+++ b/tests/test_api_journal.py
@@ -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
diff --git a/tests/test_markdown_utils.py b/tests/test_markdown_utils.py
index 3866e37..bf18618 100644
--- a/tests/test_markdown_utils.py
+++ b/tests/test_markdown_utils.py
@@ -35,12 +35,19 @@ def test_javascript_href_is_neutralized():
assert "javascript:" not in html
-def test_onerror_attribute_is_stripped():
- html = render_markdown('
본문')
+def test_onerror_attribute_is_stripped_even_though_img_is_allowed():
+ html = render_markdown('
본문')
assert "onerror" not in html
- assert "
' 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