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  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:
@@ -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"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
|
||||
|
||||
@@ -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:
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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 / 회상 ----
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -9,3 +9,5 @@ services:
|
||||
- TZ=Asia/Seoul
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./media:/app/app/media
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,12 +35,19 @@ def test_javascript_href_is_neutralized():
|
||||
assert "javascript:" not in html
|
||||
|
||||
|
||||
def test_onerror_attribute_is_stripped():
|
||||
html = render_markdown('<img src=x onerror="alert(1)">본문')
|
||||
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" not in html # img는 허용 태그 목록에 없음
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user