diff --git a/.gitignore b/.gitignore index 36cd880..1575719 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.pyc .env .env.dev +deploy.env .venv/ venv/ *.egg-info/ diff --git a/CLAUDE.md b/CLAUDE.md index 19b7177..7b24bd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,4 +97,4 @@ pytest tests/test_habits.py::test_name # 단일 테스트 - `.env`는 이미지에 COPY하지 않고(`.dockerignore`) `docker-compose.yml`의 `env_file`로 런타임에 주입한다 — 이미지 레이어에 비밀번호가 남지 않게 하기 위함. - **컨테이너는 반드시 1개만 실행**해야 한다 — `scheduler_service`가 프로세스 안에서 APScheduler를 직접 돌리므로, replica를 늘리면 각자 스케줄러를 따로 띄워 같은 알림을 중복 처리하려 든다(`_claim_notification_slot`의 유니크 제약 경합 방지 덕에 죽지는 않지만 애초에 여러 개 띄울 이유가 없다). - **타임존**: `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 프록시하면 된다. 이 앱 자체는 어느 쪽이든 코드 변경 없이 평문 HTTP로만 응답하면 되므로(`app/main.py`에 HTTPS 강제/리다이렉트 로직 없음), 배포 방식은 순전히 인프라 레이어에서 결정된다. 프록시가 컨테이너와 같은 호스트에서 돈다면 `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"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장. diff --git a/README.md b/README.md index 6bbefe4..5e42873 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 습관 트래커 +# 해빗랩 개인용 습관 관리 PWA. 아이폰과 PC에서 같은 서버(MariaDB)에 접속해 습관을 관리합니다. diff --git a/app/main.py b/app/main.py index b21cdac..cabff52 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,7 @@ import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Request -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.sessions import SessionMiddleware @@ -24,12 +24,22 @@ async def lifespan(app: FastAPI): scheduler_service.shutdown_scheduler() -app = FastAPI(title="습관 트래커", lifespan=lifespan) +app = FastAPI(title="해빗랩", lifespan=lifespan) # 구글 OAuth 핸드셰이크 중 state/nonce를 임시로 저장하는 데만 쓰는 세션이다. # 로그인 유지용 쿠키(habit_session)와는 별개. app.add_middleware(SessionMiddleware, secret_key=settings.secret_key, session_cookie="oauth_session", max_age=600) + +@app.middleware("http") +async def redirect_http_to_https(request: Request, call_next): + # 리버스 프록시가 X-Forwarded-Proto로 원래 스킴을 알려줄 때만 동작한다 + # (로컬 uvicorn 직접 실행 시에는 이 헤더가 없어 그냥 통과). + if request.headers.get("x-forwarded-proto") == "http": + return RedirectResponse(str(request.url.replace(scheme="https")), status_code=301) + return await call_next(request) + + app.mount("/static", StaticFiles(directory="app/static"), name="static") app.include_router(auth.router) @@ -75,3 +85,9 @@ async def unhandled_exception_handler(request: Request, exc: Exception): def service_worker(): # 서비스워커 scope가 앱 전체를 커버하려면 /static/ 하위가 아닌 루트 경로로 서빙해야 한다. return FileResponse("app/static/service-worker.js", media_type="application/javascript") + + +@app.get("/.well-known/assetlinks.json") +def assetlinks(): + # Android TWA(kr.co.alphalok.habit.twa)의 도메인 소유권 증명용 — 반드시 /.well-known/ 루트 경로여야 한다. + return FileResponse("app/static/.well-known/assetlinks.json", media_type="application/json") diff --git a/app/routers/pages.py b/app/routers/pages.py index b8d45f3..4d53f29 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -8,12 +8,13 @@ from fastapi.templating import Jinja2Templates from pydantic import ValidationError from sqlalchemy.orm import Session +from app.config import settings from app.database import get_db from app.models.habit import HabitDifficulty, HabitStatus, HabitType from app.models.user import User from app.schemas.habit import HabitCreate, HabitUpdate from app.security import get_current_user_optional -from app.services import habit_service, log_service +from app.services import habit_service, log_service, user_service from app.template_utils import ( difficulty_label, goal_progress, @@ -46,14 +47,57 @@ def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectR def index(request: Request, db: Session = Depends(get_db)): if get_current_user_optional(request, db) is not None: return RedirectResponse(url="/today", status_code=303) - return RedirectResponse(url="/login", status_code=303) + # 로그인 없이도 앱 목적을 설명하는 페이지가 있어야 한다(구글 OAuth 동의 화면 "홈페이지" 요건). + return templates.TemplateResponse(request, "home.html", {"logged_in": False, "current_user": None}) @router.get("/login") -def login_page(request: Request, db: Session = Depends(get_db)): +def login_page(request: Request, deleted: bool = False, db: Session = Depends(get_db)): if get_current_user_optional(request, db) is not None: return RedirectResponse(url="/today", status_code=303) - return templates.TemplateResponse(request, "login.html", {"logged_in": False, "current_user": None}) + return templates.TemplateResponse( + request, "login.html", {"logged_in": False, "current_user": None, "deleted": deleted} + ) + + +@router.get("/privacy") +def privacy_page(request: Request, db: Session = Depends(get_db)): + user = get_current_user_optional(request, db) + return templates.TemplateResponse( + request, "privacy.html", {"logged_in": user is not None, "current_user": user} + ) + + +@router.get("/account") +def account_page(request: Request, db: Session = Depends(get_db)): + current = _current_user_or_redirect(request, db) + if isinstance(current, RedirectResponse): + return current + return templates.TemplateResponse(request, "account.html", {"logged_in": True, "current_user": current}) + + +@router.post("/account/delete") +def delete_account_page(request: Request, confirm_email: str = Form(""), db: Session = Depends(get_db)): + current = _current_user_or_redirect(request, db) + if isinstance(current, RedirectResponse): + return current + + if confirm_email.strip().lower() != current.email.lower(): + return templates.TemplateResponse( + request, + "account.html", + { + "logged_in": True, + "current_user": current, + "delete_error": "입력한 이메일이 계정 이메일과 일치하지 않습니다.", + }, + status_code=400, + ) + + user_service.delete_account(db, current) + response = RedirectResponse(url="/login?deleted=1", status_code=303) + response.delete_cookie(settings.session_cookie_name) + return response def _today_context(db: Session, user_id: int, **extra) -> dict: diff --git a/app/routers/push.py b/app/routers/push.py index b35aff7..8149731 100644 --- a/app/routers/push.py +++ b/app/routers/push.py @@ -42,5 +42,5 @@ def unsubscribe( @router.post("/test") def send_test(db: Session = Depends(get_db), current_user: User = Depends(require_login)): - sent = push_service.send_to_user(db, current_user.id, title="습관 트래커", body="테스트 알림입니다.") + sent = push_service.send_to_user(db, current_user.id, title="해빗랩", body="테스트 알림입니다.") return {"sent": sent} diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..8cef811 --- /dev/null +++ b/app/services/user_service.py @@ -0,0 +1,14 @@ +from sqlalchemy.orm import Session + +from app.models.user import User + + +def delete_account(db: Session, user: User) -> None: + """계정과 그에 딸린 모든 데이터를 삭제한다. + + Habit/PushSubscription/HabitLog/HabitNotificationLog/SummaryNotificationLog는 전부 + user.id 기준 DB 레벨 ON DELETE CASCADE로 연결돼 있어(마이그레이션 0004/0005 참고), + User 행만 지우면 나머지는 MariaDB가 알아서 정리한다. + """ + db.delete(user) + db.commit() diff --git a/app/static/.well-known/assetlinks.json b/app/static/.well-known/assetlinks.json new file mode 100644 index 0000000..1ce4b46 --- /dev/null +++ b/app/static/.well-known/assetlinks.json @@ -0,0 +1,8 @@ +[{ + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "kr.co.alphalok.habit.twa", + "sha256_cert_fingerprints": ["2A:BC:19:7D:91:65:3A:88:68:02:8C:3B:1D:58:42:C7:17:02:FA:B9:C9:4F:01:02:3D:60:B7:C9:CE:6F:05:20"] + } + }] diff --git a/app/static/css/style.css b/app/static/css/style.css index 4cf30f7..4385a80 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -111,6 +111,16 @@ p { color: var(--color-text-muted); } +.app-footer { + text-align: center; + padding: var(--space-4) 0 var(--space-2); + font-size: 13px; +} + +.app-footer a { + color: var(--color-text-muted); +} + /* iOS 홈 화면 추가 안내 배너 */ .ios-install-banner { display: none; @@ -799,3 +809,154 @@ label { .wm-dash { color: var(--color-border); } + +/* 랜딩 페이지 (/) */ +.landing { + max-width: 480px; + margin: 0 auto; + padding: var(--space-4) var(--space-2) var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.landing-hero { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + text-align: center; +} + +.landing-brand { + display: flex; + 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-weight: 700; + letter-spacing: -0.02em; + text-wrap: balance; + margin: 0; +} + +.landing-tagline { + font-size: 16px; + color: var(--color-text-muted); + margin: 0; +} + +/* 요일 스트릭 시연: 실제 습관 체크 인터랙션을 그대로 축소해 보여준다 */ +.landing-streak { + display: flex; + gap: 8px; +} + +.landing-day { + width: 34px; + height: 34px; + border-radius: 999px; + border: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--color-text-muted); + background: var(--color-surface); +} + +.landing-day.done { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; + opacity: 0; + animation: landing-day-in 0.35s ease-out forwards; +} + +.landing-day.done svg { + width: 16px; + height: 16px; +} + +.landing-day:nth-of-type(1).done { animation-delay: 0.1s; } +.landing-day:nth-of-type(2).done { animation-delay: 0.25s; } +.landing-day:nth-of-type(3).done { animation-delay: 0.4s; } +.landing-day:nth-of-type(4).done { animation-delay: 0.55s; } +.landing-day:nth-of-type(5).done { animation-delay: 0.7s; } + +@keyframes landing-day-in { + from { + opacity: 0; + transform: scale(0.6); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .landing-day.done { + opacity: 1; + animation: none; + } +} + +.landing-copy { + font-size: 15.5px; + line-height: 1.75; + color: var(--color-text); + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.landing-copy strong { + color: var(--color-accent); +} + +.landing-features { + display: grid; + gap: var(--space-2); +} + +.landing-feature { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); +} + +.landing-feature svg { + flex-shrink: 0; + width: 20px; + height: 20px; + color: var(--color-accent); +} + +.landing-feature p { + margin: 0; + font-size: 14px; + color: var(--color-text); +} + +.landing-feature p strong { + display: block; + font-size: 14.5px; + margin-bottom: 2px; +} diff --git a/app/static/manifest.json b/app/static/manifest.json index 4804ef8..ee1047c 100644 --- a/app/static/manifest.json +++ b/app/static/manifest.json @@ -1,6 +1,6 @@ { - "name": "습관 트래커", - "short_name": "습관 트래커", + "name": "해빗랩", + "short_name": "해빗랩", "description": "형성하고 싶은 습관과 끊고 싶은 습관을 요일별로 관리하고 매일 체크하는 개인용 습관 관리 앱", "start_url": "/today", "scope": "/", diff --git a/app/static/offline.html b/app/static/offline.html index ea8c3ac..86c1a93 100644 --- a/app/static/offline.html +++ b/app/static/offline.html @@ -3,7 +3,7 @@
-이메일
{{ current_user.email }}
이름
{{ current_user.name }}
가입일
{{ current_user.created_at.strftime('%Y-%m-%d') }}
+ 계정을 삭제하면 등록한 모든 습관, 체크 기록, 알림 구독 정보가 즉시 영구적으로 삭제되며 되돌릴 수 없습니다. +
+ {% if delete_error %}{{ delete_error }}
{% endif %} + +