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 -2
View File
@@ -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
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 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,
+35
View File
@@ -294,6 +294,41 @@ def delete_attachment(db: Session, attachment: JournalAttachment) -> None:
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 / 회상 ----
+17
View File
@@ -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;
+60
View File
@@ -32,10 +32,70 @@
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
easymde.codemirror.on("paste", function (cm, event) {
handleImagePaste(cm, event);
});
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 () {
initEditors(document);
});