Compare commits

...
23 Commits
Author SHA1 Message Date
shinalok 74bfe34d3f Merge branch 'ios-only-plain-textarea-fallback' into main
CI/CD / test (push) Successful in 46s
CI/CD / deploy (push) Successful in 1m38s
2026-08-27 08:16:10 +09:00
shinalokandClaude Sonnet 5 6000de166f keep EasyMDE on desktop/Android, fall back to plain textarea only on iOS
Dropping EasyMDE entirely (previous commit) fixed the Korean IME jamo split
but also removed the live markdown syntax highlighting everywhere, even on
platforms that never had the bug. Only iOS WebKit breaks CJK IME composition
under CodeMirror, so journal-editor.js now detects iOS (including the
desktop-UA-masquerading iPad, via MacIntel + multi-touch) and only skips
EasyMDE there, falling back to a native <textarea>. Everywhere else keeps
the full EasyMDE/CodeMirror editing experience as before.

window.JournalEditor now dispatches to the right engine per textarea
(checking t._easymde) so the toolbar buttons and the category-template
autofill in journal.html work unchanged regardless of which engine backs a
given textarea. Restores the easymde vendor files, base.html includes, and
service worker precache entries removed in the previous commit (cache
bumped to v8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 08:16:06 +09:00
shinalok f524157326 Merge branch 'remove-easymde-fix-ime' into main 2026-08-26 20:23:13 +09:00
shinalokandClaude Sonnet 5 ac98cf4f99 replace EasyMDE journal editor with plain textarea to fix iOS Korean IME jamo split
Forcing inputStyle to contenteditable (previous commit) didn't fix it on
iPhone, confirming this is CodeMirror 5's own long-standing weakness with
CJK IME composition on iOS WebKit, not just the iPad desktop-UA detection
issue. There's no reliable fix short of dropping CodeMirror for the journal
content field, so EasyMDE is removed entirely and the textarea goes back to
a native, uncontrolled <textarea> — nothing intercepts/re-renders it during
composition, so iOS's IME just works.

The markdown toolbar buttons (bold/italic/heading/quote/lists/code/link)
now manipulate the textarea's selection directly via a small JournalEditor
JS API instead of calling EasyMDE/CodeMirror commands, and image paste
tracks its upload placeholder by a unique text marker instead of a
CodeMirror bookmark. Also drops the vendored easymde.min.js/css (no longer
referenced) and bumps the service worker CACHE_NAME so stale cached copies
of the old vendor files get evicted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 20:23:09 +09:00
shinalok 291813820b Merge branch 'fix-ios-ime-jamo-split' into main
CI/CD / test (push) Successful in 48s
CI/CD / deploy (push) Successful in 1m33s
2026-08-26 12:43:58 +09:00
shinalokandClaude Sonnet 5 779bbb58aa force EasyMDE contenteditable input mode to fix Korean IME jamo splitting on iPad
iPadOS 13+ sends a desktop Safari UA by default, which fools CodeMirror's mobile detection into using the more fragile textarea input mode. That, combined with CodeMirror repainting the line mid-composition, breaks Korean IME composition and leaves jamo unmerged. inputStyle can't be changed after the editor is created, so it must be set in the EasyMDE constructor options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:43:45 +09:00
shinalok 46c412f8f3 Merge pull request 'enlarge journal calendar cells and pair day/title on one line' (#1) from journal-calendar-ui-tweaks into main
CI/CD / test (push) Successful in 30s
CI/CD / deploy (push) Successful in 2m15s
Reviewed-on: #1
2026-08-19 17:18:22 +09:00
shinalokandClaude Sonnet 5 8a49757882 enlarge journal calendar cells and pair day/title on one line
CI/CD / test (pull_request) Successful in 51s
CI/CD / deploy (pull_request) Skipped
- calendar-cell-journal: bump min-height 44px -> 76px, scale up date/dot/title font sizes
- journal calendar titles were centered; left-align them
- rework JournalCalendarDay: replace separate category_colors/titles lists
  (mismatched lengths, no way to pair a dot with its title) with a single
  entries list of (color, title) pairs so the dot and its title render on
  the same non-wrapping row per entry

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:13:38 +09:00
shinalokandClaude Opus 5 b173911269 make the app name the largest text on the landing page
CI/CD / test (push) Successful in 32s
CI/CD / deploy (push) Successful in 1m29s
The branding review no longer flags the missing purpose description, but
still reports that the app name does not match the homepage — even though
the console value and the h1 text are identical strings.

The likely reason is visual hierarchy: the app name was a 15px label while
the tagline underneath it was 32px, so the tagline read as the site's name.
The name and the tagline now swap sizes, and the logo moves above the
heading instead of sitting inline next to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:34:15 +09:00
shinalokandClaude Opus 5 0e8688c25b make app name and purpose machine-readable on the landing page
CI/CD / test (push) Successful in 45s
CI/CD / deploy (push) Successful in 1m29s
Google OAuth branding review rejected the app twice with "no description of
the app purpose on the homepage" and "app name does not match the homepage",
even though both were present in Korean. Two likely causes, both addressed:

- The h1 wrapped an alt="" logo image before the text, so the app name could
  not be extracted from it. The image now sits outside the h1, leaving the
  heading as plain text.
- The page was Korean-only. The app name is now "해빗랩 (HabitLab)" (matching
  the console exactly), and the title, meta description and a new About
  section carry an English description of the app purpose and its use of
  Google account data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:29:42 +09:00
shinalok ad5a1c834c add terms page, improve landing content for branding review, and handle HTTP HEAD requests
CI/CD / test (push) Successful in 40s
CI/CD / deploy (push) Successful in 1m44s
2026-08-08 21:30:54 +09:00
shinalok be8a4e6908 add terms page, improve landing content for branding review, and handle HTTP HEAD requests
CI/CD / test (push) Successful in 46s
CI/CD / deploy (push) Successful in 1m44s
2026-08-08 20:40:47 +09:00
shinalok e752367f79 remove SSH debug connection test from CI/CD workflow for cleaner output and simplify rsync verbosity
CI/CD / test (push) Successful in 42s
CI/CD / deploy (push) Successful in 43s
2026-08-08 07:38:50 +09:00
shinalok 689d32e18b update CI/CD workflow: add unused Docker image cleanup step
CI/CD / test (push) Successful in 43s
CI/CD / deploy (push) Successful in 49s
2026-08-08 07:35:14 +09:00
shinalok bf0225d53b add SSH debug connection test to CI/CD workflow and enable batch mode for rsync
CI/CD / test (push) Successful in 40s
CI/CD / deploy (push) Successful in 1m57s
2026-08-08 07:25:51 +09:00
shinalok 5b1a9ac683 add SSH debug connection test to CI/CD workflow and enable batch mode for rsync
CI/CD / test (push) Successful in 43s
CI/CD / deploy (push) Failing after 37s
2026-08-08 07:13:27 +09:00
shinalok 97c79b8b04 add SSH debug connection test to CI/CD workflow and enable batch mode for rsync
CI/CD / test (push) Successful in 53s
CI/CD / deploy (push) Failing after 43s
2026-08-08 07:07:38 +09:00
shinalok a0a7a70c14 update CI/CD workflow: decode base64 SSH key and validate key integrity
CI/CD / test (push) Successful in 40s
CI/CD / deploy (push) Failing after 35s
2026-08-07 22:32:57 +09:00
shinalok c0d14967d0 update CI/CD workflow: install development dependencies for testing with pip
CI/CD / test (push) Successful in 51s
CI/CD / deploy (push) Failing after 1m0s
2026-08-07 19:31:07 +09:00
shinalok c09aadaeb5 update CI/CD workflow: replace containerized testing and deployment with system-installed dependencies
CI/CD / test (push) Failing after 3m54s
CI/CD / deploy (push) Has been skipped
2026-08-07 19:23:47 +09:00
shinalok 61142ed55e add CI/CD workflow for testing and deployment steps on main branch push
CI/CD / test (push) Failing after 8s
CI/CD / deploy (push) Has been skipped
2026-08-07 19:04:30 +09:00
shinalokandClaude Sonnet 5 00c66f9df8 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>
2026-08-05 14:27:30 +09:00
shinalokandClaude Sonnet 5 bdf9d0bae7 journal: real markdown editor (EasyMDE) with live preview toggle, fix default template
- Replace the plain textarea with EasyMDE (vendored locally, no CDN) for
  markdown authoring: syntax highlighting, smart list continuation, and a
  custom text-based toolbar (built-in EasyMDE toolbar icons require Font
  Awesome from a CDN, which this app doesn't use). unorderedListStyle is set
  to "-" to match the app's own template convention.
- Add a preview/edit toggle button that swaps the editor for the exact same
  server-rendered markdown (via /journal/preview) shown after saving, instead
  of always showing both.
- Fix create/edit entry routes to verify the submitted category_id actually
  belongs to the current user before inserting -- every other write path in
  this app already checked ownership; this one didn't (found while manually
  testing the new editor with a typo'd category id that happened to belong to
  someone else's category, which surfaced as an IntegrityError 500 instead of
  a clean 404-equivalent).
- Fix the default "일상" category template: bare "-" bullet lines don't parse
  as list items in the markdown renderer (they need a trailing space), and
  the content_template validator was silently stripping that trailing space
  off on every save. Backfill migration updates any category still holding
  the old, broken template text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:24:18 +09:00
28 changed files with 1305 additions and 70 deletions
+72
View File
@@ -0,0 +1,72 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: 저장소 체크아웃
uses: actions/checkout@v4
- name: Python 3.13 설치
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: 테스트용 .env 준비
run: |
cat > .env <<'EOF'
SECRET_KEY=ci-dummy-secret-key
DATABASE_URL=sqlite:///./ci.db
EOF
- name: 의존성 설치
run: pip install -e ".[dev]"
- name: pytest 실행
run: pytest
deploy:
needs: test
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: 저장소 체크아웃
uses: actions/checkout@v4
- name: ssh/rsync 설치
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends openssh-client rsync
- name: SSH 키 준비
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p ${{ vars.DEPLOY_SSH_PORT }} ${{ vars.DEPLOY_SSH_HOST }} >> ~/.ssh/known_hosts
# 키가 깨진 채로 저장됐다면 여기서 바로 에러가 나서 원인을 알 수 있음
ssh-keygen -y -f ~/.ssh/id_ed25519 > /dev/null
- name: 소스 동기화
run: |
rsync -avz \
--exclude='.env' --exclude='media' --exclude='__pycache__' \
-e "ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes -o IdentitiesOnly=yes -p ${{ vars.DEPLOY_SSH_PORT }}" \
pyproject.toml alembic.ini Dockerfile docker-compose.yml app migrations scripts \
${{ vars.DEPLOY_SSH_USER }}@${{ vars.DEPLOY_SSH_HOST }}:${{ vars.DEPLOY_REMOTE_PATH }}/
- name: 컨테이너 재빌드 & 재시작
run: |
ssh -p ${{ vars.DEPLOY_SSH_PORT }} ${{ vars.DEPLOY_SSH_USER }}@${{ vars.DEPLOY_SSH_HOST }} \
"export PATH=\$PATH:/usr/local/bin && cd ${{ vars.DEPLOY_REMOTE_PATH }} && docker compose build && docker compose up -d"
- name: 안 쓰는 이미지 정리
run: |
ssh -p ${{ vars.DEPLOY_SSH_PORT }} ${{ vars.DEPLOY_SSH_USER }}@${{ vars.DEPLOY_SSH_HOST }} \
"export PATH=\$PATH:/usr/local/bin && docker image prune -f"
+2 -1
View File
@@ -99,11 +99,12 @@ pytest tests/test_habits.py::test_name # 단일 테스트
## Docker 배포 ## 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` 구조를 바꾸면 이 상대경로도 깨진다. - `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)하다. - `scripts/docker-entrypoint.sh`가 컨테이너 시작마다 `alembic upgrade head`를 먼저 실행한 뒤 `uvicorn``exec`한다 — 이미 적용된 리비전은 건너뛰므로 재시작마다 실행돼도 안전(idempotent)하다.
- `.env`는 이미지에 COPY하지 않고(`.dockerignore`) `docker-compose.yml``env_file`로 런타임에 주입한다 — 이미지 레이어에 비밀번호가 남지 않게 하기 위함. - `.env`는 이미지에 COPY하지 않고(`.dockerignore`) `docker-compose.yml``env_file`로 런타임에 주입한다 — 이미지 레이어에 비밀번호가 남지 않게 하기 위함.
- **컨테이너는 반드시 1개만 실행**해야 한다 — `scheduler_service`가 프로세스 안에서 APScheduler를 직접 돌리므로, replica를 늘리면 각자 스케줄러를 따로 띄워 같은 알림을 중복 처리하려 든다(`_claim_notification_slot`의 유니크 제약 경합 방지 덕에 죽지는 않지만 애초에 여러 개 띄울 이유가 없다). - **컨테이너는 반드시 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()`) 자체는 문제가 아니라 컨테이너 타임존 설정 누락이 원인이었으니, 비슷한 날짜 관련 이상 증상이 배포 환경에서만 재현되면 먼저 컨테이너 타임존을 의심할 것. - **타임존**: `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"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장. - **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"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
+21
View File
@@ -42,6 +42,27 @@ async def redirect_http_to_https(request: Request, call_next):
return await call_next(request) return await call_next(request)
@app.middleware("http")
async def support_head_requests(request: Request, call_next):
# 이 Starlette 버전은 GET 라우트에 HEAD를 자동으로 열어주지 않아 크롤러의 HEAD 요청이
# 전부 405로 막힌다(구글 OAuth 브랜딩 인증 크롤러가 홈페이지를 HEAD로 먼저 확인하면서
# "콘텐츠 없음"으로 오판하는 원인이 됐음). GET과 동일하게 처리한 뒤 본문만 비워서 응답한다.
if request.method != "HEAD":
return await call_next(request)
request.scope["method"] = "GET"
response = await call_next(request)
async def _empty_body():
return
yield b"" # pragma: no cover - 제너레이터로 만들기 위한 도달 불가 코드
response.body_iterator = _empty_body()
# uvicorn은 실제 전송 바이트와 Content-Length가 다르면 예외를 던지므로 0으로 맞춘다.
response.headers["content-length"] = "0"
return response
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router) app.include_router(auth.router)
+23
View File
@@ -0,0 +1,23 @@
import bleach
import markdown
from markupsafe import Markup
# nl2br: 빈 줄 없이 그냥 엔터만 쳐도 줄바꿈되게 한다 — 지금까지 백엔드가 순수 텍스트를
# white-space: pre-wrap으로 보여주던 것과 체감이 최대한 비슷하도록.
_MARKDOWN_EXTENSIONS = ["nl2br", "sane_lists"]
_ALLOWED_TAGS = [
"p", "br", "strong", "em", "del",
"h1", "h2", "h3", "h4",
"ul", "ol", "li",
"blockquote", "code", "pre", "hr", "a", "img",
]
_ALLOWED_ATTRS = {"a": ["href", "title"], "img": ["src", "alt", "title"]}
def render_markdown(text: str) -> Markup:
"""저널 기록 내용을 마크다운 HTML로 렌더링한다. markdown 라이브러리는 기본적으로 원본 HTML을
그대로 통과시키므로(<script> 등 포함) bleach로 허용 태그만 남기고 나머지는 전부 지운다 —
이 함수가 반환하는 Markup만 템플릿에서 이스케이프 없이(그대로 안전하게) 렌더링해야 한다."""
html = markdown.markdown(text, extensions=_MARKDOWN_EXTENSIONS)
return Markup(bleach.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRS, strip=True))
+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 fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -26,6 +26,26 @@ def get_media(
return FileResponse(path, filename=attachment.original_filename) 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") @router.post("/categories/reorder")
def reorder_categories( def reorder_categories(
data: JournalCategoryReorderRequest, data: JournalCategoryReorderRequest,
+26
View File
@@ -8,6 +8,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.markdown_utils import render_markdown
from app.models.journal import JournalMood from app.models.journal import JournalMood
from app.routers.pages import _current_user_or_redirect, templates from app.routers.pages import _current_user_or_redirect, templates
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate
@@ -33,6 +34,7 @@ templates.env.globals["journal_mood_emoji"] = {m.value: emoji for m, emoji, _ in
# Jinja2 내장 |tojson 필터가 이 policy를 읽어서 json.dumps에 넘긴다 — 기본값(ensure_ascii=True)이면 # Jinja2 내장 |tojson 필터가 이 policy를 읽어서 json.dumps에 넘긴다 — 기본값(ensure_ascii=True)이면
# 한글이 \uXXXX로 이스케이프돼 응답 본문에서 읽기 힘들어진다. # 한글이 \uXXXX로 이스케이프돼 응답 본문에서 읽기 힘들어진다.
templates.env.policies["json.dumps_kwargs"] = {"ensure_ascii": False} templates.env.policies["json.dumps_kwargs"] = {"ensure_ascii": False}
templates.env.filters["markdown"] = render_markdown
def _parse_moods(raw: str) -> list[JournalMood]: def _parse_moods(raw: str) -> list[JournalMood]:
@@ -133,6 +135,17 @@ def journal_day_detail(request: Request, entry_date: date, db: Session = Depends
return _render_day_detail(request, db, current.id, entry_date) return _render_day_detail(request, db, current.id, entry_date)
@router.post("/journal/preview")
def preview_entry_content(request: Request, content: str = Form(""), db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
if not content.strip():
return HTMLResponse('<span class="empty-state">미리보기가 여기에 표시돼요</span>')
return HTMLResponse(render_markdown(content))
@router.post("/journal/new") @router.post("/journal/new")
def create_entry_page( def create_entry_page(
request: Request, request: Request,
@@ -149,6 +162,9 @@ def create_entry_page(
if isinstance(current, RedirectResponse): if isinstance(current, RedirectResponse):
return current return current
if journal_service.get_category(db, category_id, current.id) is None:
return HTMLResponse("카테고리를 찾을 수 없어요")
try: try:
parsed_date = date.fromisoformat(entry_date) parsed_date = date.fromisoformat(entry_date)
data = JournalEntryCreate( data = JournalEntryCreate(
@@ -202,6 +218,16 @@ def edit_entry_page(
original_date = entry.entry_date original_date = entry.entry_date
if journal_service.get_category(db, category_id, current.id) is None:
return _render_day_detail(
request,
db,
current.id,
original_date,
edit_error="카테고리를 찾을 수 없어요",
editing_entry_id=entry_id,
)
try: try:
parsed_date = date.fromisoformat(entry_date) parsed_date = date.fromisoformat(entry_date)
data = JournalEntryUpdate( data = JournalEntryUpdate(
+8
View File
@@ -68,6 +68,14 @@ def privacy_page(request: Request, db: Session = Depends(get_db)):
) )
@router.get("/terms")
def terms_page(request: Request, db: Session = Depends(get_db)):
user = get_current_user_optional(request, db)
return templates.TemplateResponse(
request, "terms.html", {"logged_in": user is not None, "current_user": user}
)
@router.get("/account") @router.get("/account")
def account_page(request: Request, db: Session = Depends(get_db)): def account_page(request: Request, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db) current = _current_user_or_redirect(request, db)
+12 -4
View File
@@ -29,10 +29,13 @@ class JournalCategoryBase(BaseModel):
@field_validator("content_template") @field_validator("content_template")
@classmethod @classmethod
def blank_template_to_none(cls, v: str | None) -> str | None: def blank_template_to_none(cls, v: str | None) -> str | None:
if v is None: # color/name과 달리 여기선 .strip()으로 값 자체를 바꾸지 않는다 — 템플릿 맨 끝의
# "- "(대시+공백)처럼 의미 있는 trailing whitespace가 있을 수 있고, 그걸 지우면
# markdown이 그 줄을 목록으로 인식하지 못하게 된다(빈 값인지 판단만 strip으로 하고,
# 실제로 저장하는 값은 원본을 그대로 쓴다).
if v is None or not v.strip():
return None return None
v = v.strip() return v
return v or None
class JournalCategoryCreate(JournalCategoryBase): class JournalCategoryCreate(JournalCategoryBase):
@@ -113,10 +116,15 @@ class JournalAttachmentOut(BaseModel):
has_thumbnail: bool has_thumbnail: bool
class JournalCalendarEntry(BaseModel):
color: str # 그 엔트리가 속한 카테고리 색상 (점 표시용)
title: str # 제목(없으면 내용 일부)
class JournalCalendarDay(BaseModel): class JournalCalendarDay(BaseModel):
entry_date: date entry_date: date
total_count: int total_count: int
category_colors: list[str] # 그 날 엔트리가 있는 카테고리들의 색상(점 표시용, 중복 제거) entries: list[JournalCalendarEntry] # 엔트리별 (색상, 제목) 쌍 — 점과 제목이 한 줄에 붙어 나오도록 1:1로 매칭
class JournalDayDetailItem(BaseModel): class JournalDayDetailItem(BaseModel):
+73 -29
View File
@@ -23,6 +23,7 @@ from app.models.journal import (
from app.schemas.journal import ( from app.schemas.journal import (
JournalAttachmentOut, JournalAttachmentOut,
JournalCalendarDay, JournalCalendarDay,
JournalCalendarEntry,
JournalCategoryCreate, JournalCategoryCreate,
JournalDayDetailItem, JournalDayDetailItem,
JournalEntryCreate, JournalEntryCreate,
@@ -34,27 +35,27 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"} ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
THUMBNAIL_WIDTH = 400 THUMBNAIL_WIDTH = 400
DEFAULT_CATEGORY_NAME = "일상" DEFAULT_CATEGORY_NAME = "일상"
DEFAULT_CATEGORY_TEMPLATE = """**1. Story : 오늘 무슨 일이 있었나요?** # 목록 기호("- ") 뒤에 공백이 없으면(그냥 "-"만 있으면) markdown 라이브러리가 목록으로 안 잡고
# 그냥 문단 텍스트로 렌더링한다 — 그래서 다섯 줄 다 "- "(대시+공백)로 통일해야 실제로
- # 빈 체크리스트 항목(<li></li>)이 만들어진다. 제목 줄과 "-" 사이에 빈 줄이 없으면 markdown이
# 그 "-"를 목록이 아니라 제목 밑줄(setext heading)로 오인해서 제목 자체가 사라지므로 빈 줄도 필수.
**2. Feelings : 오늘 들었던 생각과 나의 감정은?** _BULLET = "- " # 뒤 공백이 핵심 — 트리플쿼트 문자열 끝의 trailing space는 도구를 거치며 잘려나가서
# 여기서는 따옴표 "안쪽"에 명시적으로 넣어 안 잘리게 한다.
- DEFAULT_CATEGORY_TEMPLATE = "\n\n".join(
[
**3. Decisions : 오늘 내가 내린 결정이 있나요?** "**1. Story : 오늘 무슨 일이 있나요?**",
_BULLET,
- "**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
_BULLET,
**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?** "**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
_BULLET,
- "**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
_BULLET,
→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요. "→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
**5. Actions : 내가 다음에 할 행동은 무엇인가요?** _BULLET,
]
- """ )
# ---- 카테고리 ---- # ---- 카테고리 ----
@@ -294,6 +295,41 @@ def delete_attachment(db: Session, attachment: JournalAttachment) -> None:
db.commit() 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 / 회상 ---- # ---- 캘린더 / day-detail / 회상 ----
@@ -328,25 +364,33 @@ def get_monthly_journal_summary(
last_day = date(year, month, days_in_month) last_day = date(year, month, days_in_month)
stmt = ( stmt = (
select(JournalEntry.entry_date, JournalCategory.color) select(JournalEntry.entry_date, JournalCategory.color, JournalEntry.title, JournalEntry.content)
.join(JournalCategory, JournalEntry.category_id == JournalCategory.id) .join(JournalCategory, JournalEntry.category_id == JournalCategory.id)
.where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day)) .where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day))
.order_by(JournalEntry.entry_date, JournalEntry.created_at)
) )
if category_id is not None: if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id) stmt = stmt.where(JournalEntry.category_id == category_id)
rows = db.execute(stmt).all() rows = db.execute(stmt).all()
counts: dict[date, int] = {} counts: dict[date, int] = {}
colors_by_date: dict[date, list[str]] = {} entries_by_date: dict[date, list[JournalCalendarEntry]] = {}
for entry_date, color in rows: for entry_date, color, title, content in rows:
counts[entry_date] = counts.get(entry_date, 0) + 1 counts[entry_date] = counts.get(entry_date, 0) + 1
colors = colors_by_date.setdefault(entry_date, []) entries = entries_by_date.setdefault(entry_date, [])
color = color or "var(--color-accent)" entries.append(
if color not in colors: JournalCalendarEntry(
colors.append(color) color=color or "var(--color-accent)",
title=title or (content[:12] + ("" if len(content) > 12 else "")),
)
)
return { return {
d: JournalCalendarDay(entry_date=d, total_count=counts[d], category_colors=colors_by_date[d]) d: JournalCalendarDay(
entry_date=d,
total_count=counts[d],
entries=entries_by_date[d],
)
for d in counts for d in counts
} }
+239 -21
View File
@@ -121,6 +121,11 @@ p {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.app-footer a + a::before {
content: "·";
margin: 0 6px;
}
/* iOS 홈 화면 추가 안내 배너 */ /* iOS 홈 화면 추가 안내 배너 */
.ios-install-banner { .ios-install-banner {
display: none; display: none;
@@ -867,15 +872,48 @@ label {
} }
/* 저널링 */ /* 저널링 */
.journal-day-dots { .calendar-cell-journal {
aspect-ratio: auto;
min-height: 76px;
padding: 6px 5px;
overflow: hidden;
}
.calendar-cell-journal .calendar-date {
font-size: 13px;
}
.journal-day-entries {
display: flex; display: flex;
gap: 2px; flex-direction: column;
align-items: flex-start;
width: 100%;
}
.journal-day-entry {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
} }
.journal-day-dot { .journal-day-dot {
width: 5px; width: 6px;
height: 5px; height: 6px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0;
}
.journal-day-title {
min-width: 0;
font-size: 10px;
line-height: 1.3;
color: var(--color-text-muted);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.journal-prompt-card, .journal-prompt-card,
@@ -902,8 +940,149 @@ label {
} }
.journal-entry-content { .journal-entry-content {
white-space: pre-wrap;
margin-top: 4px; margin-top: 4px;
line-height: 1.6;
}
.journal-preview {
margin-top: 6px;
padding: 10px 12px;
border: 1px dashed var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
min-height: 24px;
font-size: 14px;
}
/* 마크다운 에디터 툴바 (EasyMDE는 toolbar:false로 끄고 여기서 자체 버튼으로 대체 —
EasyMDE 기본 툴바는 Font Awesome CDN을 전제로 해서 이 앱의 "CDN 금지" 원칙과 안 맞는다) */
.markdown-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.md-tool-btn {
min-width: 30px;
height: 30px;
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
font-size: 14px;
cursor: pointer;
}
.md-tool-btn:active {
background: var(--color-bg);
}
/* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다. iOS에서는
app/static/js/journal-editor.js가 EasyMDE 대신 순수 <textarea>로 폴백하므로(한글 IME
자소분리 회피) 이 규칙은 그 경우 그냥 매칭되지 않는다 — .markdown-editor 자체는 위 공통
textarea 규칙을 그대로 물려받아 별도 스타일 없이도 정상적으로 보인다. */
.markdown-editor + .EasyMDEContainer .CodeMirror {
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
color: var(--color-text);
font-family: inherit;
font-size: 15px;
padding: 6px 8px;
overflow: hidden; /* 안에서 뭐가 카드 폭보다 커지려 해도 밖으로 안 새어나가게 */
}
.markdown-editor + .EasyMDEContainer .CodeMirror-cursor {
border-left-color: var(--color-text);
}
.markdown-editor + .EasyMDEContainer .editor-statusbar {
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;
}
.journal-entry-content > *:last-child {
margin-bottom: 0;
}
.journal-entry-content p,
.journal-entry-content ul,
.journal-entry-content ol,
.journal-entry-content blockquote,
.journal-entry-content pre {
margin: 0 0 8px;
}
.journal-entry-content h1,
.journal-entry-content h2,
.journal-entry-content h3,
.journal-entry-content h4 {
margin: 12px 0 6px;
line-height: 1.3;
}
.journal-entry-content h1 { font-size: 19px; }
.journal-entry-content h2 { font-size: 17px; }
.journal-entry-content h3,
.journal-entry-content h4 { font-size: 15px; }
.journal-entry-content ul,
.journal-entry-content ol {
padding-left: 20px;
}
.journal-entry-content blockquote {
margin-left: 0;
padding-left: 10px;
border-left: 3px solid var(--color-accent);
color: var(--color-text-muted);
}
.journal-entry-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 1px 5px;
}
.journal-entry-content pre {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: 10px;
overflow-x: auto;
}
.journal-entry-content pre code {
border: none;
padding: 0;
}
.journal-entry-content a {
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 { .journal-entry-tags {
@@ -993,28 +1172,30 @@ label {
text-align: center; text-align: center;
} }
.landing-logo {
width: 56px;
height: 56px;
border-radius: 14px;
}
/* 앱 이름 h1 — 화면에서 가장 큰 글자여야 한다(위 home.html 주석 참고). */
.landing-brand { .landing-brand {
display: flex; margin: 0;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: var(--color-accent);
letter-spacing: -0.01em;
}
.landing-brand img {
width: 24px;
height: 24px;
border-radius: 6px;
}
.landing-hero h1 {
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
text-wrap: balance; text-wrap: balance;
color: var(--color-accent);
}
/* 태그라인은 앱 이름 아래 부제 */
.landing-hero-title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
text-wrap: balance;
margin: 0; margin: 0;
color: var(--color-text);
} }
.landing-tagline { .landing-tagline {
@@ -1125,3 +1306,40 @@ label {
font-size: 14.5px; font-size: 14.5px;
margin-bottom: 2px; margin-bottom: 2px;
} }
/* 기능 설명 / 계정 안내 — 구글 브랜딩 심사가 홈페이지에서 확인하는 "앱의 목적" 설명 영역. */
.landing-section h2 {
font-size: 17px;
font-weight: 700;
margin: 0 0 var(--space-2);
}
.landing-list {
margin: 0;
padding-left: 1.1em;
display: flex;
flex-direction: column;
gap: 10px;
font-size: 14.5px;
line-height: 1.65;
color: var(--color-text);
}
.landing-list strong {
color: var(--color-accent);
}
.landing-note {
margin: 0 0 var(--space-2);
font-size: 13.5px;
line-height: 1.7;
color: var(--color-text-muted);
}
.landing-note:last-child {
margin-bottom: 0;
}
.landing-note strong {
color: var(--color-text);
}
File diff suppressed because one or more lines are too long
+294
View File
@@ -0,0 +1,294 @@
(function () {
// 저널 본문 입력창(.markdown-editor)의 편집 엔진을 기기별로 나눈다.
//
// EasyMDE(CodeMirror 5 기반)는 타이핑 중 문법에 색을 입혀 보여주는 진짜 마크다운 에디터
// 경험을 주지만, iOS Safari에서 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME
// 입력 중에 CodeMirror가 화면을 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로
// 남는 문제(자소분리)가 있다. inputStyle을 contenteditable로 강제해도 아이폰에서까지
// 재현되는 걸 확인했다 — CodeMirror5 자체의 CJK IME 한계로 보고, iOS에서만 순수
// <textarea>로 폴백한다(브라우저 네이티브 입력 처리를 그대로 쓰면 IME가 깨질 이유가
// 없다). PC/안드로이드 등 iOS가 아닌 환경은 지금까지처럼 EasyMDE를 그대로 쓴다.
//
// 굵게/기울임 같은 툴바 버튼과 카테고리 템플릿 자동 채우기는 window.JournalEditor를
// 통해 호출되는데, 이 객체가 각 textarea에 EasyMDE가 붙어있는지(t._easymde) 보고
// EasyMDE 명령 또는 아래의 직접 선택 영역 조작 중 알맞은 쪽으로 위임한다 — 호출하는
// 템플릿 쪽(journal_editor_toolbar.html, journal.html)은 어느 엔진이 쓰이는지 몰라도 된다.
function isIOS() {
var ua = navigator.userAgent || "";
if (/iPad|iPhone|iPod/.test(ua)) return true;
// iPadOS 13+는 기본 설정에서 데스크톱 Safari인 척하는 UA를 보낸다("Macintosh"로 위장,
// "Request Mobile Website"를 켜지 않는 한). 이런 위장 아이패드를 잡아내는 표준적인
// 방법은 "Mac인데 멀티터치가 된다"는 조합을 보는 것이다(실제 맥은 터치스크린이 없음).
return navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
}
function fireInput(textarea) {
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
// ---- 순수 textarea 모드에서 툴바가 쓰는 선택 영역 조작 ----
function insertAtCursor(textarea, text) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
textarea.value = value.slice(0, start) + text + value.slice(end);
var pos = start + text.length;
textarea.selectionStart = textarea.selectionEnd = pos;
fireInput(textarea);
}
function replaceMarker(textarea, marker, replacement) {
var idx = textarea.value.indexOf(marker);
if (idx === -1) return;
var before = textarea.value.slice(0, idx);
var after = textarea.value.slice(idx + marker.length);
textarea.value = before + replacement + after;
var pos = before.length + replacement.length;
textarea.selectionStart = textarea.selectionEnd = pos;
fireInput(textarea);
}
function wrapSelection(textarea, prefix, suffix) {
if (suffix === undefined) suffix = prefix;
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var selected = value.slice(start, end);
textarea.value = value.slice(0, start) + prefix + selected + suffix + value.slice(end);
textarea.selectionStart = start + prefix.length;
textarea.selectionEnd = start + prefix.length + selected.length;
textarea.focus();
fireInput(textarea);
}
function currentLineRange(textarea) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var lineStart = value.lastIndexOf("\n", start - 1) + 1;
var lineEnd = value.indexOf("\n", end);
if (lineEnd === -1) lineEnd = value.length;
return { lineStart: lineStart, lineEnd: lineEnd, block: value.slice(lineStart, lineEnd) };
}
function replaceBlock(textarea, range, newBlock) {
var value = textarea.value;
textarea.value = value.slice(0, range.lineStart) + newBlock + value.slice(range.lineEnd);
textarea.selectionStart = range.lineStart;
textarea.selectionEnd = range.lineStart + newBlock.length;
textarea.focus();
fireInput(textarea);
}
function toggleLinePrefix(textarea, prefix) {
var range = currentLineRange(textarea);
var lines = range.block.split("\n");
var allPrefixed = lines.every(function (line) { return line.indexOf(prefix) === 0; });
var newLines = lines.map(function (line) {
if (allPrefixed) return line.slice(prefix.length);
return line.indexOf(prefix) === 0 ? line : prefix + line;
});
replaceBlock(textarea, range, newLines.join("\n"));
}
function toggleOrderedListPlain(textarea) {
var range = currentLineRange(textarea);
var lines = range.block.split("\n");
var re = /^\d+\.\s/;
var allNumbered = lines.every(function (line) { return re.test(line); });
var newLines = lines.map(function (line, i) {
return allNumbered ? line.replace(re, "") : (i + 1) + ". " + line.replace(re, "");
});
replaceBlock(textarea, range, newLines.join("\n"));
}
function toggleHeadingPlain(textarea) {
var range = currentLineRange(textarea);
var re = /^#{1,6}\s/;
var newBlock = re.test(range.block) ? range.block.replace(re, "") : "### " + range.block;
replaceBlock(textarea, range, newBlock);
}
function toggleCodePlain(textarea) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end);
if (selected.indexOf("\n") === -1) {
wrapSelection(textarea, "`");
return;
}
var value = textarea.value;
textarea.value = value.slice(0, start) + "```\n" + selected + "\n```" + value.slice(end);
textarea.selectionStart = start + 4;
textarea.selectionEnd = start + 4 + selected.length;
textarea.focus();
fireInput(textarea);
}
function insertLinkPlain(textarea) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end) || "링크 텍스트";
var url = window.prompt("링크 주소를 입력하세요", "https://");
if (!url) return;
var value = textarea.value;
var markdown = "[" + selected + "](" + url + ")";
textarea.value = value.slice(0, start) + markdown + value.slice(end);
textarea.selectionStart = textarea.selectionEnd = start + markdown.length;
textarea.focus();
fireInput(textarea);
}
// journal_editor_toolbar.html의 버튼들과 journal.html의 카테고리 템플릿 자동 채우기가
// 호출하는 공개 API — 어느 엔진(EasyMDE/순수 textarea)이 붙어있는지는 t._easymde 유무로
// 판단해서 알맞은 쪽으로 위임한다.
window.JournalEditor = {
bold: function (t) { if (t) (t._easymde ? t._easymde.toggleBold() : wrapSelection(t, "**")); },
italic: function (t) { if (t) (t._easymde ? t._easymde.toggleItalic() : wrapSelection(t, "*")); },
strike: function (t) { if (t) (t._easymde ? t._easymde.toggleStrikethrough() : wrapSelection(t, "~~")); },
heading: function (t) { if (t) (t._easymde ? t._easymde.toggleHeadingSmaller() : toggleHeadingPlain(t)); },
quote: function (t) { if (t) (t._easymde ? t._easymde.toggleBlockquote() : toggleLinePrefix(t, "> ")); },
ul: function (t) { if (t) (t._easymde ? t._easymde.toggleUnorderedList() : toggleLinePrefix(t, "- ")); },
ol: function (t) { if (t) (t._easymde ? t._easymde.toggleOrderedList() : toggleOrderedListPlain(t)); },
code: function (t) { if (t) (t._easymde ? t._easymde.toggleCodeBlock() : toggleCodePlain(t)); },
link: function (t) { if (t) (t._easymde ? t._easymde.drawLink() : insertLinkPlain(t)); },
setValue: function (t, value) {
if (!t) return;
t.value = value;
if (t._easymde) t._easymde.value(value);
fireInput(t);
},
};
// ---- 클립보드 이미지 붙여넣기 ----
// 스크린샷/사진을 그대로 붙여넣기 대신 서버에 업로드하고 그 자리에 마크다운 이미지
// 문법(![](url))을 끼워넣는다. 두 엔진 다 업로드 로직은 같고, "지금 커서 위치를 어떻게
// 표시해뒀다가 나중에 찾아서 바꿔치기하는지"만 다르다.
function uploadPastedImage(file) {
var formData = new FormData();
formData.append("file", file, file.name || "pasted-image.png");
return 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();
});
}
function extractImageFile(event) {
var items = event.clipboardData && event.clipboardData.items;
if (!items) return null;
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image/") === 0) return items[i].getAsFile();
}
return null;
}
// 순수 textarea 모드: 업로드 중 표식을 값에서 찾아 최종 링크로 바꾼다. 고정된 커서
// 좌표 대신 텍스트 표식으로 위치를 추적하므로, 업로드가 끝나기 전에 사용자가 다른 곳을
// 계속 타이핑해도 자리를 잃지 않는다.
function handlePlainImagePaste(textarea, event) {
var file = extractImageFile(event);
if (!file) return;
event.preventDefault();
var marker = "![업로드 중… #" + Math.random().toString(36).slice(2, 8) + "]()";
insertAtCursor(textarea, marker);
uploadPastedImage(file)
.then(function (data) { replaceMarker(textarea, marker, "![](" + data.url + ")"); })
.catch(function (err) { replaceMarker(textarea, marker, "(이미지 붙여넣기 실패: " + err.message + ")"); });
}
// EasyMDE 모드: CodeMirror 북마크로 위치를 추적한다(원본 로직 그대로).
function handleCodeMirrorImagePaste(cm, event) {
var file = extractImageFile(event);
if (!file) return;
event.preventDefault();
var doc = cm.getDoc();
var from = doc.getCursor();
var placeholder = "![업로드 중...]()";
doc.replaceRange(placeholder, from);
var to = { line: from.line, ch: from.ch + placeholder.length };
var startMark = doc.setBookmark(from);
var endMark = doc.setBookmark(to);
uploadPastedImage(file)
.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 }));
});
}
function initEasyMDE(textarea) {
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을
// 전제로 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal_editor_toolbar.html의
// 자체 버튼이 위 JournalEditor를 통해 EasyMDE 인스턴스 메서드를 호출한다.
var easymde = new EasyMDE({
element: textarea,
toolbar: false,
spellChecker: false,
autoDownloadFontAwesome: false,
status: false,
placeholder: textarea.getAttribute("placeholder") || "",
minHeight: (textarea.getAttribute("rows") || 6) * 24 + "px",
// 글머리 목록 버튼/Enter 자동 이어쓰기가 기본 "*" 대신 "-"를 쓰게 한다.
// 서버 렌더링(app/markdown_utils.py)은 -/*/+ 전부 동일하게 처리하니 렌더링과는 무관하고
// 순수하게 에디터가 새로 만들어주는 글머리 기호에 대한 취향 설정이다.
unorderedListStyle: "-",
});
// CodeMirror는 원본 textarea와 실시간으로 값이 동기화되지 않는다(.save()를 명시적으로
// 불러야 함) — 매 변경마다 저장하고, htmx 미리보기(hx-trigger="input ...")와
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
easymde.codemirror.on("change", function () {
easymde.codemirror.save();
fireInput(textarea);
});
easymde.codemirror.on("paste", function (cm, event) {
handleCodeMirrorImagePaste(cm, event);
});
textarea._easymde = easymde;
}
function initPlainTextarea(textarea) {
textarea.addEventListener("paste", function (event) { handlePlainImagePaste(textarea, event); });
}
function initEditors(root) {
var scope = root instanceof Element ? root : document;
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-journal-editor-initialized])");
textareas.forEach(function (textarea) {
textarea.setAttribute("data-journal-editor-initialized", "true");
if (typeof EasyMDE !== "undefined" && !isIOS()) {
initEasyMDE(textarea);
} else {
initPlainTextarea(textarea);
}
});
}
document.addEventListener("DOMContentLoaded", function () {
initEditors(document);
});
document.body.addEventListener("htmx:afterSwap", function (evt) {
initEditors(evt.target);
});
})();
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -1,13 +1,16 @@
const CACHE_NAME = "habit-tracker-v5"; const CACHE_NAME = "habit-tracker-v8";
const APP_SHELL = [ const APP_SHELL = [
"/static/css/style.css", "/static/css/style.css",
"/static/css/vendor/easymde.min.css",
"/static/js/app.js", "/static/js/app.js",
"/static/js/push-register.js", "/static/js/push-register.js",
"/static/js/habit-reorder.js", "/static/js/habit-reorder.js",
"/static/js/journal-category-reorder.js", "/static/js/journal-category-reorder.js",
"/static/js/journal-editor.js",
"/static/js/vendor/htmx.min.js", "/static/js/vendor/htmx.min.js",
"/static/js/vendor/alpine.min.js", "/static/js/vendor/alpine.min.js",
"/static/js/vendor/sortable.min.js", "/static/js/vendor/sortable.min.js",
"/static/js/vendor/easymde.min.js",
"/static/icons/icon-192.png", "/static/icons/icon-192.png",
"/static/icons/icon-512.png", "/static/icons/icon-512.png",
"/static/icons/icon-apple-180.png", "/static/icons/icon-apple-180.png",
+6
View File
@@ -4,6 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{% block title %}해빗랩{% endblock %}</title> <title>{% block title %}해빗랩{% endblock %}</title>
{# 구글 OAuth 브랜딩 심사는 홈페이지에서 앱 이름과 목적을 확인한다 — 설명 메타 태그가 없으면 "앱의 목적에 관한 설명이 없다"로 반려된다. #}
<meta name="description" content="{% block meta_description %}HabitLab (해빗랩) is a habit tracking web app. 해빗랩은 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 관리하는 습관 기록 앱입니다.{% endblock %}" />
<link rel="manifest" href="/static/manifest.json" /> <link rel="manifest" href="/static/manifest.json" />
<meta name="theme-color" content="#d97757" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#d97757" media="(prefers-color-scheme: light)" />
@@ -16,11 +18,14 @@
<link rel="icon" href="/static/icons/icon-192.png" /> <link rel="icon" href="/static/icons/icon-192.png" />
<link rel="stylesheet" href="/static/css/style.css" /> <link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/vendor/easymde.min.css" />
<script src="/static/js/vendor/htmx.min.js" defer></script> <script src="/static/js/vendor/htmx.min.js" defer></script>
<script src="/static/js/push-register.js" defer></script> <script src="/static/js/push-register.js" defer></script>
<script src="/static/js/vendor/sortable.min.js" defer></script> <script src="/static/js/vendor/sortable.min.js" defer></script>
<script src="/static/js/habit-reorder.js" defer></script> <script src="/static/js/habit-reorder.js" defer></script>
<script src="/static/js/journal-category-reorder.js" defer></script> <script src="/static/js/journal-category-reorder.js" defer></script>
<script src="/static/js/vendor/easymde.min.js" defer></script>
<script src="/static/js/journal-editor.js" defer></script>
<script src="/static/js/vendor/alpine.min.js" defer></script> <script src="/static/js/vendor/alpine.min.js" defer></script>
<script src="/static/js/app.js" defer></script> <script src="/static/js/app.js" defer></script>
</head> </head>
@@ -71,6 +76,7 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
<footer class="app-footer"> <footer class="app-footer">
<a href="/privacy">개인정보처리방침</a> <a href="/privacy">개인정보처리방침</a>
<a href="/terms">서비스 약관</a>
</footer> </footer>
</div> </div>
</body> </body>
+54 -3
View File
@@ -1,10 +1,15 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}해빗랩 · 습관이 만들어지는 진짜 시간{% endblock %} {% block title %}해빗랩 (HabitLab) · 습관이 만들어지는 진짜 시간{% endblock %}
{% block meta_description %}HabitLab (해빗랩) is a habit tracking web app for building good habits and quitting bad ones on a weekly schedule. 해빗랩은 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 관리하는 습관 기록 앱입니다. 요일별 습관 등록과 데일리 체크, 월별·주별 기록과 완료율·연속 달성일 통계, 설정한 시각의 습관 알림, 하루를 남기는 일기를 제공합니다.{% endblock %}
{% block content %} {% block content %}
<div class="landing"> <div class="landing">
<div class="landing-hero"> <div class="landing-hero">
<div class="landing-brand"><img src="/static/icons/icon-192.png" alt="" /> 해빗랩</div> {# 앱 이름은 h1의 순수 텍스트이면서 화면에서 가장 큰 글자여야 한다 — 구글 브랜딩 심사가 OAuth 동의 화면의
<h1>습관이 만들어지는<br />진짜 시간</h1> 앱 이름과 홈페이지의 앱 이름을 대조하는데, 태그라인이 이름보다 크면 그쪽을 사이트 이름으로 본다.
로고는 이름 추출을 방해하지 않도록 h1 바깥에 둔다. #}
<img class="landing-logo" src="/static/icons/icon-192.png" alt="" />
<h1 class="landing-brand">해빗랩 (HabitLab)</h1>
<p class="landing-hero-title">습관이 만들어지는 진짜 시간</p>
<p class="landing-tagline">21일의 법칙 대신, 습관마다 다른 진짜 목표 기간을 알려드려요</p> <p class="landing-tagline">21일의 법칙 대신, 습관마다 다른 진짜 목표 기간을 알려드려요</p>
<div class="landing-streak" aria-hidden="true"> <div class="landing-streak" aria-hidden="true">
@@ -40,6 +45,17 @@
</p> </p>
</div> </div>
<div class="landing-section">
<h2>해빗랩이 하는 일</h2>
<ul class="landing-list">
<li><strong>요일별 습관 등록</strong> — 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 등록하고, 습관마다 목표 기간과 달성 조건을 정합니다.</li>
<li><strong>데일리 체크</strong> — 오늘 예정된 습관을 한 화면에서 체크하고, 놓친 습관은 실패로 표시해 솔직하게 기록합니다.</li>
<li><strong>기록과 통계</strong> — 월별 달력과 주별 매트릭스로 지나온 기록을 돌아보고, 습관별 완료율과 연속 달성일을 확인합니다.</li>
<li><strong>습관 알림</strong> — 습관마다 설정한 시각에 브라우저 푸시 알림을 보내드립니다. 알림을 켠 경우에만 동작합니다.</li>
<li><strong>일기</strong> — 하루의 회고나 습관과 무관한 자유 일기를 카테고리·태그·사진과 함께 남길 수 있습니다.</li>
</ul>
</div>
<div class="landing-features"> <div class="landing-features">
<div class="landing-feature"> <div class="landing-feature">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="5" /><circle cx="12" cy="12" r="1" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="5" /><circle cx="12" cy="12" r="1" /></svg>
@@ -56,5 +72,40 @@
</div> </div>
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 시작하기</a> <a href="/auth/google/login" class="btn btn-primary btn-block">Google로 시작하기</a>
<div class="landing-section">
<h2>계정과 로그인</h2>
<p class="landing-note">
해빗랩은 구글 계정으로만 로그인합니다. 로그인 시 구글로부터 <strong>이름, 이메일 주소, 프로필 사진</strong>을 받아
계정을 식별하고 화면에 표시하는 용도로만 사용하며, 별도의 비밀번호는 수집하지 않습니다.
Gmail, 드라이브, 캘린더 등 다른 구글 서비스의 데이터에는 접근하지 않습니다.
직접 입력한 습관·기록·일기는 본인 계정에만 저장되고 다른 이용자에게 공개되지 않으며,
로그인 후 계정 페이지에서 언제든 계정과 모든 데이터를 직접 삭제할 수 있습니다.
</p>
<p class="landing-note">
자세한 내용은 <a href="/privacy">개인정보처리방침</a><a href="/terms">서비스 약관</a>을 확인해주세요.
문의: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a>
</p>
</div>
{# 영문 요약 — 구글 브랜딩 심사의 자동 검사가 한글만으로는 앱 목적을 인식하지 못하는 것으로 보여 병기한다. #}
<div class="landing-section" lang="en">
<h2>About HabitLab (해빗랩)</h2>
<p class="landing-note">
<strong>HabitLab (해빗랩)</strong> is a personal habit tracking web app. It helps you build the habits
you want and quit the ones you don't, on a per-weekday schedule.
</p>
<p class="landing-note">
You can register habits for specific days of the week with a target period based on published research
(Lally et al., 2010, UCL), check them off daily, review your history on monthly and weekly views with
completion rates and streaks, receive browser push reminders at a time you choose, and keep a journal.
</p>
<p class="landing-note">
HabitLab uses Google Sign-In. It receives only your name, email address, and profile picture from Google
to identify your account, and does not access Gmail, Drive, Calendar, or any other Google service data.
See our <a href="/privacy">Privacy Policy</a> and <a href="/terms">Terms of Service</a>.
Contact: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a>
</p>
</div>
</div> </div>
{% endblock %} {% endblock %}
+32 -5
View File
@@ -124,11 +124,17 @@
categoryTemplates: {{ category_templates|tojson|forceescape }}, categoryTemplates: {{ category_templates|tojson|forceescape }},
content: '', content: '',
lastAutoFilled: '', lastAutoFilled: '',
previewMode: false,
togglePreview() {
this.previewMode = !this.previewMode;
if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); }
},
onCategoryChange(categoryId) { onCategoryChange(categoryId) {
const tmpl = this.categoryTemplates[categoryId] || ''; const tmpl = this.categoryTemplates[categoryId] || '';
if (this.content === this.lastAutoFilled) { if (this.content === this.lastAutoFilled) {
this.content = tmpl; this.content = tmpl;
this.lastAutoFilled = tmpl; this.lastAutoFilled = tmpl;
this.$nextTick(() => { JournalEditor.setValue(this.$refs.contentField, tmpl); });
} }
}, },
init() { this.onCategoryChange(this.$refs.categorySelect.value); }, init() { this.onCategoryChange(this.$refs.categorySelect.value); },
@@ -173,7 +179,25 @@
<div class="field"> <div class="field">
<label for="new-entry-content">내용</label> <label for="new-entry-content">내용</label>
<textarea id="new-entry-content" name="content" rows="8" required placeholder="오늘 하루는 어땠나요?" x-model="content"></textarea> {% include "partials/journal_editor_toolbar.html" %}
<div x-show="!previewMode">
<textarea
id="new-entry-content"
name="content"
rows="8"
required
placeholder="오늘 하루는 어땠나요?"
class="markdown-editor"
x-model="content"
x-ref="contentField"
hx-post="/journal/preview"
hx-trigger="input changed delay:400ms, load"
hx-target="#new-entry-preview"
hx-swap="innerHTML"
hx-params="content"
></textarea>
</div>
<div id="new-entry-preview" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
</div> </div>
<div class="field"> <div class="field">
@@ -225,16 +249,19 @@
{% for d in week %} {% for d in week %}
{% set day_summary = summary_map.get(d) %} {% set day_summary = summary_map.get(d) %}
<div <div
class="calendar-cell clickable{{ '' if d.month == month else ' muted' }}" class="calendar-cell calendar-cell-journal clickable{{ '' if d.month == month else ' muted' }}"
hx-get="/journal/day/{{ d.isoformat() }}" hx-get="/journal/day/{{ d.isoformat() }}"
hx-target="#day-detail" hx-target="#day-detail"
hx-swap="innerHTML" hx-swap="innerHTML"
> >
<span class="calendar-date">{{ d.day }}</span> <span class="calendar-date">{{ d.day }}</span>
{% if day_summary %} {% if day_summary %}
<span class="journal-day-dots"> <span class="journal-day-entries">
{% for color in day_summary.category_colors %} {% for entry in day_summary.entries %}
<span class="journal-day-dot" style="background: {{ color }};"></span> <span class="journal-day-entry">
<span class="journal-day-dot" style="background: {{ entry.color }};"></span>
<span class="journal-day-title">{{ entry.title }}</span>
</span>
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
+22 -2
View File
@@ -14,6 +14,11 @@
editing: {{ 'true' if editing_entry_id == item.id else 'false' }}, editing: {{ 'true' if editing_entry_id == item.id else 'false' }},
moods: [{% for m in item.moods %}'{{ m.value }}'{{ ',' if not loop.last }}{% endfor %}], moods: [{% for m in item.moods %}'{{ m.value }}'{{ ',' if not loop.last }}{% endfor %}],
toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) }, toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) },
previewMode: false,
togglePreview() {
this.previewMode = !this.previewMode;
if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); }
},
}" }"
> >
<div x-show="!editing"> <div x-show="!editing">
@@ -24,7 +29,7 @@
{% endif %} {% endif %}
</div> </div>
{% if item.title %}<div class="journal-entry-title">{{ item.title }}</div>{% endif %} {% if item.title %}<div class="journal-entry-title">{{ item.title }}</div>{% endif %}
<div class="journal-entry-content">{{ item.content }}</div> <div class="journal-entry-content">{{ item.content|markdown }}</div>
{% if item.tags %} {% if item.tags %}
<div class="journal-entry-tags"> <div class="journal-entry-tags">
{% for tag in item.tags %}<span class="tag-chip">#{{ tag }}</span>{% endfor %} {% for tag in item.tags %}<span class="tag-chip">#{{ tag }}</span>{% endfor %}
@@ -94,7 +99,22 @@
</div> </div>
<div class="field"> <div class="field">
<label>내용</label> <label>내용</label>
<textarea name="content" rows="4" required>{{ item.content }}</textarea> {% include "partials/journal_editor_toolbar.html" %}
<div x-show="!previewMode">
<textarea
name="content"
rows="6"
required
class="markdown-editor"
x-ref="contentField"
hx-post="/journal/preview"
hx-trigger="input changed delay:400ms, load"
hx-target="#journal-edit-preview-{{ item.id }}"
hx-swap="innerHTML"
hx-params="content"
>{{ item.content }}</textarea>
</div>
<div id="journal-edit-preview-{{ item.id }}" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
</div> </div>
<div class="field"> <div class="field">
<label>기분 (여러 개 선택 가능)</label> <label>기분 (여러 개 선택 가능)</label>
@@ -0,0 +1,20 @@
<div class="markdown-toolbar">
<span x-show="!previewMode" style="display:flex; gap:4px; flex-wrap:wrap;">
<button type="button" class="md-tool-btn" title="굵게" @click="JournalEditor.bold($el.closest('.field').querySelector('textarea'))"><strong>B</strong></button>
<button type="button" class="md-tool-btn" title="기울임" @click="JournalEditor.italic($el.closest('.field').querySelector('textarea'))"><em>I</em></button>
<button type="button" class="md-tool-btn" title="취소선" @click="JournalEditor.strike($el.closest('.field').querySelector('textarea'))"><s>S</s></button>
<button type="button" class="md-tool-btn" title="제목" @click="JournalEditor.heading($el.closest('.field').querySelector('textarea'))">H</button>
<button type="button" class="md-tool-btn" title="인용" @click="JournalEditor.quote($el.closest('.field').querySelector('textarea'))"></button>
<button type="button" class="md-tool-btn" title="글머리 목록" @click="JournalEditor.ul($el.closest('.field').querySelector('textarea'))"></button>
<button type="button" class="md-tool-btn" title="번호 목록" @click="JournalEditor.ol($el.closest('.field').querySelector('textarea'))">1.</button>
<button type="button" class="md-tool-btn" title="코드" @click="JournalEditor.code($el.closest('.field').querySelector('textarea'))">&lt;/&gt;</button>
<button type="button" class="md-tool-btn" title="링크" @click="JournalEditor.link($el.closest('.field').querySelector('textarea'))">🔗</button>
</span>
<button
type="button"
class="md-tool-btn md-preview-toggle"
style="margin-left:auto;"
@click="togglePreview()"
x-text="previewMode ? '✏️ 편집' : '👁 미리보기'"
></button>
</div>
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}서비스 약관 · 해빗랩{% endblock %}
{% block meta_description %}해빗랩 서비스 약관 — 습관 기록 앱 해빗랩의 이용 조건, 계정, 이용자의 콘텐츠, 서비스 중단 및 책임 범위를 안내합니다.{% endblock %}
{% block content %}
<div style="max-width: 640px; margin: 0 auto; padding: 24px 16px 48px;">
<div class="card">
<h1>서비스 약관</h1>
<p>시행일자: 2026년 8월 8일</p>
<p>이 약관은 해빗랩(이하 "서비스")의 이용 조건을 정합니다. 서비스에 로그인하면 이 약관에 동의한 것으로 봅니다.</p>
<h2>1. 서비스 소개</h2>
<p>해빗랩은 이용자가 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 등록하고, 매일 체크하며, 월별·주별 기록과 완료율·연속 달성일을 확인하고, 설정한 시각에 알림을 받고, 하루의 일기를 남길 수 있는 개인용 습관 기록 서비스입니다. 서비스는 무료로 제공되며 유료 결제 항목이 없습니다.</p>
<h2>2. 계정</h2>
<p>서비스는 구글 계정을 통한 로그인만 지원합니다. 이용자는 본인의 구글 계정으로 로그인해야 하며, 계정 접근 권한의 관리 책임은 이용자 본인에게 있습니다. 이용자는 로그인 후 계정 페이지에서 언제든지 계정과 모든 데이터를 직접 삭제할 수 있으며, 삭제 시 습관·체크 기록·일기·첨부파일·알림 구독 정보가 함께 삭제되고 복구할 수 없습니다.</p>
<h2>3. 이용자의 콘텐츠</h2>
<p>이용자가 서비스에 입력한 습관, 체크 기록, 일기, 첨부한 사진·영상 등 모든 콘텐츠의 권리는 이용자에게 있습니다. 서비스는 이 콘텐츠를 이용자에게 서비스를 제공하기 위한 목적(저장, 조회, 통계 계산, 알림 발송)으로만 처리하며, 광고·마케팅에 이용하거나 이용자의 동의 없이 제3자에게 제공하지 않습니다. 각 이용자의 데이터는 계정 단위로 분리되어 다른 이용자가 접근할 수 없습니다.</p>
<h2>4. 금지 행위</h2>
<p>이용자는 다음 행위를 해서는 안 됩니다.</p>
<p>가. 타인의 계정에 무단으로 접근하거나 접근을 시도하는 행위<br />
나. 서비스의 정상적인 운영을 방해하는 행위(비정상적인 대량 요청, 취약점 악용 등)<br />
다. 법령을 위반하거나 타인의 권리를 침해하는 콘텐츠를 저장·유포하는 행위</p>
<h2>5. 알림</h2>
<p>습관 알림(Web Push)은 이용자가 브라우저에서 알림 권한을 허용하고 직접 켠 경우에만 발송됩니다. 알림은 브라우저와 운영체제, 네트워크 상황에 따라 지연되거나 전달되지 않을 수 있으며, 서비스는 알림의 정시 도달을 보장하지 않습니다. 알림은 브라우저 설정이나 서비스 내에서 언제든 끌 수 있습니다.</p>
<h2>6. 서비스의 변경과 중단</h2>
<p>서비스는 개인이 운영하는 무료 서비스로, 기능이 변경되거나 점검·장애·운영 중단이 발생할 수 있습니다. 서비스 전체를 종료하는 경우에는 이용자가 데이터를 보관할 수 있도록 서비스 내 공지 또는 이메일로 사전에 안내합니다.</p>
<h2>7. 책임의 범위</h2>
<p>서비스는 습관 기록을 돕는 도구일 뿐이며, 의료·건강·심리 상담 등 전문적인 조언을 제공하지 않습니다. 건강과 관련된 결정은 반드시 전문가와 상의하시기 바랍니다. 서비스는 데이터 백업과 보전을 위해 합리적인 노력을 기울이지만, 무료로 제공되는 서비스의 성격상 데이터 손실이나 서비스 이용으로 발생한 손해에 대해 법령이 허용하는 범위에서 책임을 지지 않습니다.</p>
<h2>8. 개인정보</h2>
<p>개인정보의 수집·이용·보관에 관한 사항은 <a href="/privacy">개인정보처리방침</a>을 따릅니다.</p>
<h2>9. 약관의 변경</h2>
<p>이 약관은 법령이나 서비스 변경사항을 반영하기 위해 수정될 수 있으며, 변경 시 이 페이지를 통해 시행일자와 함께 고지합니다.</p>
<h2>10. 문의처</h2>
<p>해빗랩 운영자<br />
이메일: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a></p>
</div>
</div>
{% endblock %}
+2
View File
@@ -9,3 +9,5 @@ services:
- TZ=Asia/Seoul - TZ=Asia/Seoul
env_file: env_file:
- .env - .env
volumes:
- ./media:/app/app/media
@@ -0,0 +1,81 @@
"""fix default 일상 template: bare "-" bullets don't render as list items
Revision ID: 0014_fix_template_bullets
Revises: 0013_journal_category_template
Create Date: 2026-08-05
0013에서 넣은 기본 틀의 "-" 줄들이 뒤에 공백이 없어서, markdown 렌더러가 목록으로 인식하지 못하고
그냥 문단 텍스트("-") 렌더링됐다(제목 줄과 "-" 사이의 자체는 맞게 넣어서 제목이 밑줄로
오인되는 문제는 없었음). "- "(대시+공백) 통일해야 실제 목록 항목(<li></li>) 된다.
아직 기본값을 커스터마이징하지 않은( 0013 넣어준 원래 문구 그대로인) "일상" 카테고리만
갱신한다 이미 사용자가 직접 수정한 틀은 덮어쓰지 않는다.
(참고: revision id를 "0014_fix_category_template_bullets" 처음 만들었다가 alembic_version.
version_num이 VARCHAR(32) 34자짜리 id가 들어가서 DataError가 났다 25자로 줄여 다시 만든
파일이다. 실제 데이터 UPDATE 자체는 그때 이미 반영됐고 버전 기록만 실패한 상태였다.)
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0014_fix_template_bullets"
down_revision: Union[str, None] = "0013_journal_category_template"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_BULLET = "- "
_OLD_TEMPLATE = "\n\n".join(
[
"**1. Story : 오늘 무슨 일이 있었나요?**",
"-",
"**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
"-",
"**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
"-",
"**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
"-",
"→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
_BULLET,
]
)
_NEW_TEMPLATE = "\n\n".join(
[
"**1. Story : 오늘 무슨 일이 있었나요?**",
_BULLET,
"**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
_BULLET,
"**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
_BULLET,
"**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
_BULLET,
"→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
_BULLET,
]
)
def upgrade() -> None:
journal_category = sa.table(
"journal_category", sa.column("name", sa.String), sa.column("content_template", sa.Text)
)
op.execute(
journal_category.update()
.where(journal_category.c.name == "일상", journal_category.c.content_template == _OLD_TEMPLATE)
.values(content_template=_NEW_TEMPLATE)
)
def downgrade() -> None:
journal_category = sa.table(
"journal_category", sa.column("name", sa.String), sa.column("content_template", sa.Text)
)
op.execute(
journal_category.update()
.where(journal_category.c.name == "일상", journal_category.c.content_template == _NEW_TEMPLATE)
.values(content_template=_OLD_TEMPLATE)
)
+2
View File
@@ -20,6 +20,8 @@ dependencies = [
"authlib>=1.3", "authlib>=1.3",
"httpx>=0.27", "httpx>=0.27",
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성 + 저널 첨부 이미지 썸네일 생성(런타임) "pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성 + 저널 첨부 이미지 썸네일 생성(런타임)
"markdown>=3.7", # 저널 기록 내용을 마크다운으로 렌더링
"bleach>=6.0", # 렌더링된 마크다운 HTML을 허용 태그만 남기고 sanitize (XSS 방지)
] ]
[project.optional-dependencies] [project.optional-dependencies]
+3 -1
View File
@@ -23,7 +23,9 @@ DEPLOY_ENV_PATH = PROJECT_ROOT / "deploy.env"
# Dockerfile이 COPY하는 것과 동일한 목록 + 컨테이너 정의 파일 # Dockerfile이 COPY하는 것과 동일한 목록 + 컨테이너 정의 파일
SYNC_TARGETS = ["pyproject.toml", "alembic.ini", "Dockerfile", "docker-compose.yml", "app", "migrations", "scripts"] 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"} SKIP_SUFFIXES = {".pyc"}
+72
View File
@@ -55,6 +55,78 @@ def test_get_media_returns_404_for_other_users_attachment(auth_client, db_sessio
assert response.status_code == 404 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): def test_reorder_categories_requires_login(client):
response = client.post("/api/journal/categories/reorder", json={"category_ids": [1, 2]}) response = client.post("/api/journal/categories/reorder", json={"category_ids": [1, 2]})
assert response.status_code == 401 assert response.status_code == 401
+19 -2
View File
@@ -41,13 +41,30 @@ def _upload(filename: str, content_type: str, data: bytes) -> UploadFile:
def test_ensure_default_category_creates_once(db_session, test_user): def test_ensure_default_category_creates_once(db_session, test_user):
first = journal_service.ensure_default_category(db_session, test_user.id) first = journal_service.ensure_default_category(db_session, test_user.id)
assert first.name == journal_service.DEFAULT_CATEGORY_NAME assert first.name == journal_service.DEFAULT_CATEGORY_NAME
assert first.content_template == journal_service.DEFAULT_CATEGORY_TEMPLATE.strip() assert first.content_template == journal_service.DEFAULT_CATEGORY_TEMPLATE
second = journal_service.ensure_default_category(db_session, test_user.id) second = journal_service.ensure_default_category(db_session, test_user.id)
assert second.id == first.id assert second.id == first.id
assert len(journal_service.list_categories(db_session, test_user.id)) == 1 assert len(journal_service.list_categories(db_session, test_user.id)) == 1
def test_default_category_template_renders_as_headers_and_lists_not_bare_dashes(db_session, test_user):
from app.markdown_utils import render_markdown
category = journal_service.ensure_default_category(db_session, test_user.id)
html = render_markdown(category.content_template)
# "-"에 뒤 공백이 없으면 markdown이 목록으로 안 잡고 그냥 "<p>-</p>"로 렌더링해버리는
# 회귀가 있었다 — 다섯 항목 전부 실제 <ul><li> 목록이어야 한다.
assert html.count("<ul>") == 5
assert html.count("<li>") == 5
# 제목 줄 바로 다음에 "-"가 오면(빈 줄 없이) markdown이 그걸 제목 밑줄로 오인해서
# 굵은 글씨가 아니라 <h1>/<h2> 제목으로 바뀌어버리는 회귀도 있었다.
assert "<h1" not in html
assert "<h2" not in html
assert html.count("<strong>") == 6
def test_category_content_template_persists_and_updates(db_session, test_user): def test_category_content_template_persists_and_updates(db_session, test_user):
category = journal_service.create_category( category = journal_service.create_category(
db_session, test_user.id, JournalCategoryCreate(name="회고", content_template="질문 1\n\n질문 2") db_session, test_user.id, JournalCategoryCreate(name="회고", content_template="질문 1\n\n질문 2")
@@ -268,7 +285,7 @@ def test_get_monthly_journal_summary_counts_entries_per_day(db_session, test_use
summary = journal_service.get_monthly_journal_summary(db_session, test_user.id, today.year, today.month) summary = journal_service.get_monthly_journal_summary(db_session, test_user.id, today.year, today.month)
assert summary[today].total_count == 2 assert summary[today].total_count == 2
assert summary[today].category_colors == ["#ff0000"] assert [e.color for e in summary[today].entries] == ["#ff0000", "#ff0000"]
def test_get_day_entries_returns_entries_for_that_date(db_session, test_user): def test_get_day_entries_returns_entries_for_that_date(db_session, test_user):
+53
View File
@@ -0,0 +1,53 @@
from app.markdown_utils import render_markdown
def test_bold_and_heading_render_as_html():
html = render_markdown("# 제목\n\n**굵게** 쓴 문장입니다")
assert "<h1>제목</h1>" in html
assert "<strong>굵게</strong>" in html
def test_single_newline_becomes_line_break():
html = render_markdown("첫째 줄\n둘째 줄")
assert "<br" in html
def test_list_renders_as_html_list():
html = render_markdown("- 하나\n- 둘")
assert "<ul>" in html
assert "<li>하나</li>" in html
def test_dash_and_star_bullets_render_identically():
# 마크다운 글머리 기호는 -/*/+ 전부 동일하게 처리돼야 한다 — 에디터 쪽 기본 기호(unorderedListStyle)만
# "-"로 바뀌었을 뿐, 렌더링 결과는 어떤 기호를 써도 같아야 한다.
assert render_markdown("- 하나\n- 둘") == render_markdown("* 하나\n* 둘") == render_markdown("+ 하나\n+ 둘")
def test_script_tag_is_stripped_not_executed():
html = render_markdown('<script>alert("xss")</script>본문')
assert "<script" not in html
assert "alert" not in html or "&lt;script" not in html # 태그는 지워지고 텍스트만 남아야 함
def test_javascript_href_is_neutralized():
html = render_markdown('[click me](javascript:alert(1))')
assert "javascript:" not in html
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 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("![](/api/journal/pasted-media/abc123.png)")
assert 'src="/api/journal/pasted-media/abc123.png"' in html
+83
View File
@@ -101,6 +101,22 @@ def test_create_entry_rejects_blank_content(auth_client, db_session, test_user):
assert journal_service.list_entries(db_session, test_user.id) == [] assert journal_service.list_entries(db_session, test_user.id) == []
def test_create_entry_rejects_other_users_category(auth_client, db_session, test_user, other_user):
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(
"/journal/new",
data={
"category_id": str(others_category.id),
"entry_date": date.today().isoformat(),
"content": "가로채기 시도",
},
)
assert response.status_code == 200
assert "카테고리를 찾을 수 없어요" in response.text
assert journal_service.list_entries(db_session, test_user.id) == []
def test_journal_day_detail_shows_entry(auth_client, db_session, test_user): def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id) category = _make_category(db_session, test_user.id)
today = date.today() today = date.today()
@@ -111,12 +127,58 @@ def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
assert "오늘의 기록" in response.text assert "오늘의 기록" in response.text
def test_journal_day_detail_renders_content_as_markdown(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="**굵은 글씨** 테스트")
response = auth_client.get(f"/journal/day/{today.isoformat()}")
assert response.status_code == 200
assert "<strong>굵은 글씨</strong>" in response.text
def test_journal_day_detail_strips_script_tags_from_content(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
_make_entry(
db_session, test_user.id, category.id, entry_date=today, content='<script>alert(1)</script>본문'
)
response = auth_client.get(f"/journal/day/{today.isoformat()}")
assert response.status_code == 200
assert "<script" not in response.text
def test_journal_day_detail_requires_login(client): def test_journal_day_detail_requires_login(client):
response = client.get(f"/journal/day/{date.today().isoformat()}", follow_redirects=False) response = client.get(f"/journal/day/{date.today().isoformat()}", follow_redirects=False)
assert response.status_code == 303 assert response.status_code == 303
assert response.headers["location"] == "/login" assert response.headers["location"] == "/login"
def test_preview_renders_markdown(auth_client):
response = auth_client.post("/journal/preview", data={"content": "**굵게** 그리고 - 목록"})
assert response.status_code == 200
assert "<strong>굵게</strong>" in response.text
def test_preview_strips_script_tags(auth_client):
response = auth_client.post("/journal/preview", data={"content": '<script>alert(1)</script>본문'})
assert response.status_code == 200
assert "<script" not in response.text
def test_preview_shows_placeholder_for_blank_content(auth_client):
response = auth_client.post("/journal/preview", data={"content": " "})
assert response.status_code == 200
assert "미리보기가 여기에 표시돼요" in response.text
def test_preview_requires_login(client):
response = client.post("/journal/preview", data={"content": "test"}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_edit_entry_updates_content(auth_client, db_session, test_user): def test_edit_entry_updates_content(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id) category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용") entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
@@ -163,6 +225,27 @@ def test_edit_other_users_entry_returns_404(auth_client, db_session, other_user)
assert response.status_code == 404 assert response.status_code == 404
def test_edit_entry_rejects_moving_to_other_users_category(auth_client, db_session, test_user, other_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={
"category_id": str(others_category.id),
"entry_date": entry.entry_date.isoformat(),
"content": "가로채기 시도",
},
)
assert response.status_code == 200
assert "카테고리를 찾을 수 없어요" in response.text
db_session.refresh(entry)
assert entry.category_id == category.id
assert entry.content == "원래 내용"
def test_delete_entry_removes_it(auth_client, db_session, test_user): def test_delete_entry_removes_it(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id) category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="지울 기록") entry = _make_entry(db_session, test_user.id, category.id, content="지울 기록")