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"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
|
||||
|
||||
Reference in New Issue
Block a user