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 @@ - 오프라인 - 습관 트래커 + 오프라인 - 해빗랩 diff --git a/app/static/service-worker.js b/app/static/service-worker.js index 7f4c777..661f74b 100644 --- a/app/static/service-worker.js +++ b/app/static/service-worker.js @@ -78,7 +78,7 @@ self.addEventListener("push", (event) => { if (!event.data) return; const data = event.data.json(); event.waitUntil( - self.registration.showNotification(data.title || "습관 트래커", { + self.registration.showNotification(data.title || "해빗랩", { body: data.body || "", icon: "/static/icons/icon-192.png", badge: "/static/icons/icon-192.png", diff --git a/app/templates/404.html b/app/templates/404.html index a667623..b413a83 100644 --- a/app/templates/404.html +++ b/app/templates/404.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}페이지를 찾을 수 없어요 · 습관 트래커{% endblock %} +{% block title %}페이지를 찾을 수 없어요 · 해빗랩{% endblock %} {% block content %}
diff --git a/app/templates/500.html b/app/templates/500.html index 0801f48..34fba86 100644 --- a/app/templates/500.html +++ b/app/templates/500.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}오류가 발생했어요 · 습관 트래커{% endblock %} +{% block title %}오류가 발생했어요 · 해빗랩{% endblock %} {% block content %}
diff --git a/app/templates/account.html b/app/templates/account.html new file mode 100644 index 0000000..b820816 --- /dev/null +++ b/app/templates/account.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}계정 · 해빗랩{% endblock %} +{% block content %} +

계정

+ +
+

이메일
{{ current_user.email }}

+ {% if current_user.name %}

이름
{{ current_user.name }}

{% endif %} +

가입일
{{ current_user.created_at.strftime('%Y-%m-%d') }}

+
+ +
+

계정 삭제

+

+ 계정을 삭제하면 등록한 모든 습관, 체크 기록, 알림 구독 정보가 즉시 영구적으로 삭제되며 되돌릴 수 없습니다. +

+ {% if delete_error %}

{{ delete_error }}

{% endif %} +
+ + + +
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index d3019d1..57630f8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,14 +3,14 @@ - {% block title %}습관 트래커{% endblock %} + {% block title %}해빗랩{% endblock %} - + @@ -31,7 +31,7 @@
{% endif %} {% block content %}{% endblock %} +
diff --git a/app/templates/habits.html b/app/templates/habits.html index 56c438d..4caafa7 100644 --- a/app/templates/habits.html +++ b/app/templates/habits.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}습관 관리 · 습관 트래커{% endblock %} +{% block title %}습관 관리 · 해빗랩{% endblock %} {% block nav_habits %}active{% endblock %} {% block content %}

습관 관리

diff --git a/app/templates/history.html b/app/templates/history.html index 48e19a5..e02e340 100644 --- a/app/templates/history.html +++ b/app/templates/history.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}기록 · 습관 트래커{% endblock %} +{% block title %}기록 · 해빗랩{% endblock %} {% block nav_history %}active{% endblock %} {% block shell_class %} wide{% endblock %} {% block content %} diff --git a/app/templates/home.html b/app/templates/home.html new file mode 100644 index 0000000..046f3c2 --- /dev/null +++ b/app/templates/home.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}해빗랩 · 습관이 만들어지는 진짜 시간{% endblock %} +{% block content %} +
+
+
해빗랩
+

습관이 만들어지는
진짜 시간

+

21일의 법칙 대신, 습관마다 다른 진짜 목표 기간을 알려드려요

+ + +
+ +
+

+ "습관은 21일이면 만들어진다"는 말, 많이 들어보셨을 거예요. 사실 근거가 약한 통설이에요. + 실증 연구(Lally et al., 2010, UCL)에 따르면 습관이 몸에 붙기까지 걸리는 시간은 + 사람마다, 습관마다 달라서 짧게는 3주, 길게는 8개월 넘게 걸리기도 해요. +

+

+ 해빗랩은 여러분이 만들고 싶은 습관도, 멈추고 싶은 습관도 함께 만들어가는 동반자예요. + 검증된 연구를 시스템에 그대로 담아 목표 기간과 진행 상황을 짚어드리고, 그 여정을 끝까지 응원할게요. +

+
+ +
+
+ +

연구 기반 목표 기간속설이 아니라 실제 연구값(21·66·254일)으로 목표를 잡아요

+
+
+ +

만들기와 끊기, 동시에새 습관을 만드는 것과 나쁜 습관을 끊는 것을 똑같은 무게로 다뤄요

+
+
+ +

광고·구독 없음습관관리에만 집중해요, 그 외엔 아무것도 없어요

+
+
+ + Google로 시작하기 +
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index 09c41cf..f36d56b 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -1,9 +1,10 @@ {% extends "base.html" %} -{% block title %}로그인 · 습관 트래커{% endblock %} +{% block title %}로그인 · 해빗랩{% endblock %} {% block content %}
-

습관 트래커

+

해빗랩

+ {% if deleted %}

계정이 삭제되었습니다.

{% endif %}

구글 계정으로 로그인하세요

Google로 로그인
diff --git a/app/templates/privacy.html b/app/templates/privacy.html new file mode 100644 index 0000000..eff9371 --- /dev/null +++ b/app/templates/privacy.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}개인정보처리방침 · 해빗랩{% endblock %} +{% block content %} +
+
+

개인정보처리방침

+

시행일자: 2026년 7월 22일

+ +

해빗랩(이하 "서비스")는 이용자의 개인정보를 소중히 다루며, 아래와 같이 개인정보를 수집·이용·보관합니다.

+ +

1. 수집하는 개인정보 항목

+

가. 구글 로그인 시 (필수)
+ 이메일 주소, 이름, 프로필 사진 URL, 구글 계정 고유 식별자(sub). 서비스는 구글 계정 정보를 이용해 자동으로 계정을 생성하며, 비밀번호는 별도로 수집·저장하지 않습니다.

+

나. 서비스 이용 중 직접 입력하는 정보
+ 등록한 습관의 이름, 유형(만들기/끊기), 요일 스케줄, 목표 난이도, 달성 조건, 알림 시각, 날짜별 체크·실패 기록.

+

다. 알림(Web Push)을 켠 경우에만
+ 브라우저가 발급하는 푸시 구독 정보(endpoint, 암호화 키)와 브라우저 종류(User-Agent). 알림 중복 발송을 막기 위한 발송 이력도 함께 기록됩니다.

+

라. 자동 수집 정보
+ 로그인 유지를 위한 서명된 세션 쿠키. 별도의 광고·분석·트래킹 쿠키는 사용하지 않습니다.

+ +

2. 개인정보의 수집 및 이용 목적

+

구글 계정 인증 및 로그인 유지, 습관 등록·체크·통계(완료율, 연속 달성일 등) 제공, 설정한 시각에 맞춘 습관 알림(Web Push) 발송을 위해서만 이용합니다. 광고, 마케팅, 프로필링 목적으로는 이용하지 않습니다.

+ +

3. 개인정보의 보유 및 이용 기간

+

회원 탈퇴 시까지 보관하며, 탈퇴 시 계정 정보와 습관·체크 기록, 푸시 구독 정보를 지체 없이 삭제합니다. 관계 법령에 따라 보존이 필요한 경우가 아니면 별도 보관하지 않습니다.

+ +

4. 개인정보의 제3자 제공

+

서비스는 이용자의 개인정보를 원칙적으로 외부에 제공하지 않습니다. 다만 구글 로그인 인증 과정에서 구글(Google LLC)과 통신이 발생하며, 이는 이용자 인증을 위한 목적에 한정됩니다.

+ +

5. 이용자의 권리와 행사 방법

+

이용자는 언제든지 자신의 개인정보 열람·정정·삭제를 요청할 수 있습니다. 로그인 후 계정 페이지에서 본인 이메일 확인 후 즉시 계정과 모든 데이터를 직접 삭제할 수 있으며, 앱을 이용하기 어려운 경우 아래 문의처로 요청하셔도 확인 후 지체 없이 처리해드립니다.

+ +

6. 보안을 위한 조치

+

비밀번호를 직접 저장하지 않는 구글 OAuth 인증 방식을 사용하며, 로그인 세션은 서명된 쿠키로 관리합니다. 모든 통신은 HTTPS로 암호화되며, 각 이용자의 데이터는 계정 단위로 분리되어 다른 이용자가 접근할 수 없습니다.

+ +

7. 문의처

+

개인정보 관련 문의, 열람·정정·삭제 요청은 아래로 연락해주세요.

+

해빗랩 운영자
+ 이메일: shinalok357@gmail.com

+ +

8. 고지의 의무

+

이 개인정보처리방침은 법령이나 서비스 변경사항을 반영하기 위해 수정될 수 있으며, 변경 시 이 페이지를 통해 고지합니다.

+
+
+{% endblock %} diff --git a/app/templates/today.html b/app/templates/today.html index 656a38c..cae5041 100644 --- a/app/templates/today.html +++ b/app/templates/today.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}오늘 · 습관 트래커{% endblock %} +{% block title %}오늘 · 해빗랩{% endblock %} {% block nav_today %}active{% endblock %} {% block content %}
diff --git a/deploy.env.example b/deploy.env.example new file mode 100644 index 0000000..8f6b6ec --- /dev/null +++ b/deploy.env.example @@ -0,0 +1,9 @@ +# 이 파일을 복사해 deploy.env로 저장한 뒤 값을 채워넣으세요. deploy.env는 git에 커밋되지 않습니다. +# scripts/deploy_sftp.py가 이 값들로 SFTP 접속해 시놀로지 NAS에 소스를 동기화합니다. + +SFTP_HOST=your-domain-or-ip +SFTP_PORT=22 +SFTP_USERNAME= +SFTP_PASSWORD= +# 시놀로지 SFTP는 보통 /volume1을 세션 루트("/")로 보여주므로 /volume1 접두어 없이 적는다 +SFTP_REMOTE_PATH=/docker/habit-tracker diff --git a/pyproject.toml b/pyproject.toml index 06629d6..f2a5bb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ dev = [ "pytest>=8.0", "pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성용 (런타임 미사용) + "paramiko>=3.4", # scripts/deploy_sftp.py SFTP 배포용 (런타임 미사용) ] [tool.setuptools.packages.find] diff --git a/scripts/deploy_sftp.py b/scripts/deploy_sftp.py new file mode 100644 index 0000000..8d74b5f --- /dev/null +++ b/scripts/deploy_sftp.py @@ -0,0 +1,122 @@ +"""SFTP로 시놀로지 NAS에 소스를 동기화하는 배포 스크립트. + +deploy.env(git 미포함, deploy.env.example 참고)의 접속 정보를 읽어, Dockerfile이 COPY하는 +파일/디렉터리(pyproject.toml, alembic.ini, app/, migrations/, scripts/)와 Dockerfile, +docker-compose.yml을 원격 경로로 동기화한다. 로컬 mtime이 원격보다 최신인 파일만 올리고, +원격에만 있는 파일은 건드리지 않는다(단방향 추가/갱신, 삭제 없음). .env는 절대 동기화하지 않는다 +(원격 프로덕션 .env를 덮어쓰면 안 되므로). + +컨테이너 재시작/재빌드는 이 스크립트가 하지 않는다 — 파일을 올린 뒤 직접 재시작할 것. + +사용법: python scripts/deploy_sftp.py [--dry-run] +""" + +import sys +from pathlib import Path + +import paramiko + +sys.stdout.reconfigure(encoding="utf-8") + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DEPLOY_ENV_PATH = PROJECT_ROOT / "deploy.env" + +# Dockerfile이 COPY하는 것과 동일한 목록 + 컨테이너 정의 파일 +SYNC_TARGETS = ["pyproject.toml", "alembic.ini", "Dockerfile", "docker-compose.yml", "app", "migrations", "scripts"] +SKIP_NAMES = {"__pycache__"} +SKIP_SUFFIXES = {".pyc"} + + +def load_deploy_env(path: Path) -> dict[str, str]: + if not path.exists(): + print(f"{path}가 없습니다. deploy.env.example을 복사해 값을 채워주세요.") + sys.exit(1) + values = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + values[key.strip()] = value.strip() + return values + + +def iter_local_files(target: Path): + if target.is_file(): + yield target + return + for path in target.rglob("*"): + if path.is_dir(): + continue + if any(part in SKIP_NAMES for part in path.parts): + continue + if path.suffix in SKIP_SUFFIXES: + continue + yield path + + +def ensure_remote_dir(sftp: paramiko.SFTPClient, remote_dir: str) -> None: + parts = remote_dir.strip("/").split("/") + current = "" + for part in parts: + current += "/" + part + try: + sftp.stat(current) + except FileNotFoundError: + sftp.mkdir(current) + + +def remote_mtime(sftp: paramiko.SFTPClient, remote_path: str) -> float | None: + try: + return sftp.stat(remote_path).st_mtime + except FileNotFoundError: + return None + + +def main() -> None: + dry_run = "--dry-run" in sys.argv + env = load_deploy_env(DEPLOY_ENV_PATH) + + host = env["SFTP_HOST"] + port = int(env.get("SFTP_PORT", "22")) + username = env["SFTP_USERNAME"] + password = env["SFTP_PASSWORD"] + remote_root = env["SFTP_REMOTE_PATH"].rstrip("/") + + print(f"{host}:{port} ({username}) -> {remote_root} 로 동기화{'(dry-run)' if dry_run else ''}") + + transport = paramiko.Transport((host, port)) + transport.connect(username=username, password=password) + sftp = paramiko.SFTPClient.from_transport(transport) + assert sftp is not None + + uploaded, skipped = 0, 0 + try: + for target_name in SYNC_TARGETS: + local_target = PROJECT_ROOT / target_name + if not local_target.exists(): + continue + for local_file in iter_local_files(local_target): + rel_path = local_file.relative_to(PROJECT_ROOT).as_posix() + remote_path = f"{remote_root}/{rel_path}" + local_mtime = local_file.stat().st_mtime + existing_mtime = remote_mtime(sftp, remote_path) + + if existing_mtime is not None and existing_mtime >= local_mtime: + skipped += 1 + continue + + print(f" 업로드: {rel_path}") + if not dry_run: + ensure_remote_dir(sftp, str(Path(remote_path).parent.as_posix())) + sftp.put(str(local_file), remote_path) + uploaded += 1 + finally: + sftp.close() + transport.close() + + print(f"완료: {uploaded}개 업로드, {skipped}개 변경 없음. 컨테이너 재시작/재빌드는 직접 해주세요.") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_server.ps1 b/scripts/run_server.ps1 index 2d1d031..c3d826f 100644 --- a/scripts/run_server.ps1 +++ b/scripts/run_server.ps1 @@ -1,4 +1,4 @@ -# 습관 트래커 서버를 상시 실행하기 위한 스크립트. +# 해빗랩 서버를 상시 실행하기 위한 스크립트. # Windows 작업 스케줄러 등록 시 이 스크립트를 대상으로 지정한다 (--reload 없이, conda activate 없이 # 대상 conda 환경의 python.exe를 직접 호출해 셸 활성화 없는 예약 작업에서도 안정적으로 동작하게 한다).