- 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>
156 lines
6.6 KiB
Python
156 lines
6.6 KiB
Python
from datetime import date
|
|
from io import BytesIO
|
|
|
|
from PIL import Image
|
|
from starlette.datastructures import Headers, UploadFile
|
|
|
|
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate
|
|
from app.services import journal_service
|
|
|
|
|
|
def _make_entry_with_attachment(db_session, user_id, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
|
|
category = journal_service.create_category(db_session, user_id, JournalCategoryCreate(name="일상"))
|
|
entry = journal_service.create_entry(
|
|
db_session,
|
|
user_id,
|
|
JournalEntryCreate(category_id=category.id, entry_date=date.today(), content="기록"),
|
|
)
|
|
buf = BytesIO()
|
|
Image.new("RGB", (800, 600), "blue").save(buf, format="PNG")
|
|
upload = UploadFile(file=BytesIO(buf.getvalue()), filename="photo.png", headers=Headers({"content-type": "image/png"}))
|
|
attachment = journal_service.save_attachment(db_session, entry, upload)
|
|
return attachment
|
|
|
|
|
|
def test_get_media_requires_login(client):
|
|
response = client.get("/api/journal/media/1")
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_get_media_returns_file_for_owner(auth_client, db_session, test_user, tmp_path, monkeypatch):
|
|
attachment = _make_entry_with_attachment(db_session, test_user.id, tmp_path, monkeypatch)
|
|
|
|
response = auth_client.get(f"/api/journal/media/{attachment.id}")
|
|
assert response.status_code == 200
|
|
assert response.content[:8] == b"\x89PNG\r\n\x1a\n"
|
|
|
|
|
|
def test_get_media_thumbnail_variant(auth_client, db_session, test_user, tmp_path, monkeypatch):
|
|
attachment = _make_entry_with_attachment(db_session, test_user.id, tmp_path, monkeypatch)
|
|
|
|
response = auth_client.get(f"/api/journal/media/{attachment.id}?thumbnail=true")
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_get_media_returns_404_for_missing_attachment(auth_client):
|
|
response = auth_client.get("/api/journal/media/9999")
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_get_media_returns_404_for_other_users_attachment(auth_client, db_session, other_user, tmp_path, monkeypatch):
|
|
attachment = _make_entry_with_attachment(db_session, other_user.id, tmp_path, monkeypatch)
|
|
|
|
response = auth_client.get(f"/api/journal/media/{attachment.id}")
|
|
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
|
|
|
|
|
|
def test_reorder_categories_updates_sort_order(auth_client, db_session, test_user):
|
|
a = journal_service.create_category(db_session, test_user.id, JournalCategoryCreate(name="A"))
|
|
b = journal_service.create_category(db_session, test_user.id, JournalCategoryCreate(name="B"))
|
|
|
|
response = auth_client.post("/api/journal/categories/reorder", json={"category_ids": [b.id, a.id]})
|
|
assert response.status_code == 200
|
|
|
|
db_session.refresh(a)
|
|
db_session.refresh(b)
|
|
assert b.sort_order == 0
|
|
assert a.sort_order == 1
|
|
|
|
|
|
def test_reorder_categories_ignores_other_users_ids(auth_client, db_session, other_user):
|
|
other_category = journal_service.create_category(db_session, other_user.id, JournalCategoryCreate(name="남의 것"))
|
|
|
|
response = auth_client.post("/api/journal/categories/reorder", json={"category_ids": [other_category.id]})
|
|
assert response.status_code == 200
|
|
|
|
db_session.refresh(other_category)
|
|
assert other_category.sort_order is None
|