prepare app for public app store distribution

- rebrand from 습관 트래커 to 해빗랩 across templates, manifest, service worker
- add HTTPS redirect middleware for reverse-proxied deployments
- add public landing page (/) and privacy policy page with real data
  handling disclosures
- add in-app account deletion (Apple review requirement)
- add Android TWA Digital Asset Links support (/.well-known/assetlinks.json)
- add SFTP deployment script for the Synology-hosted server

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:41:50 +09:00
co-authored by Claude Sonnet 5
parent 34a128a79b
commit ef2a3ea082
26 changed files with 534 additions and 25 deletions
+1
View File
@@ -2,6 +2,7 @@ __pycache__/
*.pyc
.env
.env.dev
deploy.env
.venv/
venv/
*.egg-info/
+1 -1
View File
@@ -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"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
+1 -1
View File
@@ -1,4 +1,4 @@
# 습관 트래커
# 해빗랩
개인용 습관 관리 PWA. 아이폰과 PC에서 같은 서버(MariaDB)에 접속해 습관을 관리합니다.
+18 -2
View File
@@ -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")
+48 -4
View File
@@ -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:
+1 -1
View File
@@ -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}
+14
View File
@@ -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()
+8
View File
@@ -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"]
}
}]
+161
View File
@@ -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;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "습관 트래커",
"short_name": "습관 트래커",
"name": "해빗랩",
"short_name": "해빗랩",
"description": "형성하고 싶은 습관과 끊고 싶은 습관을 요일별로 관리하고 매일 체크하는 개인용 습관 관리 앱",
"start_url": "/today",
"scope": "/",
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>오프라인 - 습관 트래커</title>
<title>오프라인 - 해빗랩</title>
<link rel="icon" href="/static/icons/icon-192.png" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}페이지를 찾을 수 없어요 · 습관 트래커{% endblock %}
{% block title %}페이지를 찾을 수 없어요 · 해빗랩{% endblock %}
{% block content %}
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
<div>
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}오류가 발생했어요 · 습관 트래커{% endblock %}
{% block title %}오류가 발생했어요 · 해빗랩{% endblock %}
{% block content %}
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
<div>
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}계정 · 해빗랩{% endblock %}
{% block content %}
<h1>계정</h1>
<div class="card">
<p><strong>이메일</strong><br />{{ current_user.email }}</p>
{% if current_user.name %}<p><strong>이름</strong><br />{{ current_user.name }}</p>{% endif %}
<p><strong>가입일</strong><br />{{ current_user.created_at.strftime('%Y-%m-%d') }}</p>
</div>
<div class="card">
<h2>계정 삭제</h2>
<p>
계정을 삭제하면 등록한 모든 습관, 체크 기록, 알림 구독 정보가 즉시 영구적으로 삭제되며 되돌릴 수 없습니다.
</p>
{% if delete_error %}<p style="color: var(--color-danger);">{{ delete_error }}</p>{% endif %}
<form method="post" action="/account/delete">
<label for="confirm_email">확인을 위해 본인 이메일({{ current_user.email }})을 입력하세요</label>
<input type="text" id="confirm_email" name="confirm_email" autocomplete="off" />
<button type="submit" class="btn btn-danger-ghost btn-block" style="margin-top: var(--space-2);">계정 영구 삭제</button>
</form>
</div>
{% endblock %}
+7 -4
View File
@@ -3,14 +3,14 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{% block title %}습관 트래커{% endblock %}</title>
<title>{% block title %}해빗랩{% endblock %}</title>
<link rel="manifest" href="/static/manifest.json" />
<meta name="theme-color" content="#d97757" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#e08962" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="습관 트래커" />
<meta name="apple-mobile-web-app-title" content="해빗랩" />
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-apple-180.png" />
<link rel="apple-touch-icon" href="/static/icons/icon-192.png" />
<link rel="icon" href="/static/icons/icon-192.png" />
@@ -31,7 +31,7 @@
<button type="button" onclick="dismissIosInstallBanner()" aria-label="닫기">&times;</button>
</div>
<nav class="top-nav">
<span class="brand">습관 트래커</span>
<span class="brand">해빗랩</span>
<div class="nav-links">
<a href="/today" class="{% block nav_today %}{% endblock %}">오늘</a>
<a href="/habits" class="{% block nav_habits %}{% endblock %}">습관 관리</a>
@@ -40,7 +40,7 @@
{% if current_user %}
<div class="nav-user">
{% if account_level %}<span class="nav-level-pill">Lv.{{ account_level.level }}</span>{% endif %}
<span>{{ current_user.name or current_user.email }}</span>
<a href="/account">{{ current_user.name or current_user.email }}</a>
<form method="post" action="/auth/logout">
<button type="submit" class="btn-link">로그아웃</button>
</form>
@@ -63,6 +63,9 @@
</nav>
{% endif %}
{% block content %}{% endblock %}
<footer class="app-footer">
<a href="/privacy">개인정보처리방침</a>
</footer>
</div>
</body>
</html>
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}습관 관리 · 습관 트래커{% endblock %}
{% block title %}습관 관리 · 해빗랩{% endblock %}
{% block nav_habits %}active{% endblock %}
{% block content %}
<h1>습관 관리</h1>
+1 -1
View File
@@ -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 %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}해빗랩 · 습관이 만들어지는 진짜 시간{% endblock %}
{% block content %}
<div class="landing">
<div class="landing-hero">
<div class="landing-brand"><img src="/static/icons/icon-192.png" alt="" /> 해빗랩</div>
<h1>습관이 만들어지는<br />진짜 시간</h1>
<p class="landing-tagline">21일의 법칙 대신, 습관마다 다른 진짜 목표 기간을 알려드려요</p>
<div class="landing-streak" aria-hidden="true">
<div class="landing-day done">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
</div>
<div class="landing-day done">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
</div>
<div class="landing-day done">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
</div>
<div class="landing-day done">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
</div>
<div class="landing-day done">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
</div>
<div class="landing-day"></div>
<div class="landing-day"></div>
</div>
</div>
<div class="landing-copy">
<p>
"습관은 21일이면 만들어진다"는 말, 많이 들어보셨을 거예요. 사실 근거가 약한 통설이에요.
실증 연구(<strong>Lally et al., 2010, UCL</strong>)에 따르면 습관이 몸에 붙기까지 걸리는 시간은
사람마다, 습관마다 달라서 짧게는 3주, 길게는 8개월 넘게 걸리기도 해요.
</p>
<p>
<strong>해빗랩</strong>은 여러분이 만들고 싶은 습관도, 멈추고 싶은 습관도 함께 만들어가는 동반자예요.
검증된 연구를 시스템에 그대로 담아 목표 기간과 진행 상황을 짚어드리고, 그 여정을 끝까지 응원할게요.
</p>
</div>
<div class="landing-features">
<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>
<p><strong>연구 기반 목표 기간</strong>속설이 아니라 실제 연구값(21·66·254일)으로 목표를 잡아요</p>
</div>
<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="8" cy="12" r="6" /><line x1="8" y1="9" x2="8" y2="15" /><line x1="5" y1="12" x2="11" y2="12" /><circle cx="17" cy="12" r="6" /><line x1="14" y1="12" x2="20" y2="12" /></svg>
<p><strong>만들기와 끊기, 동시에</strong>새 습관을 만드는 것과 나쁜 습관을 끊는 것을 똑같은 무게로 다뤄요</p>
</div>
<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" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
<p><strong>광고·구독 없음</strong>습관관리에만 집중해요, 그 외엔 아무것도 없어요</p>
</div>
</div>
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 시작하기</a>
</div>
{% endblock %}
+3 -2
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %}
{% block title %}로그인 · 습관 트래커{% endblock %}
{% block title %}로그인 · 해빗랩{% endblock %}
{% block content %}
<div style="min-height: 70vh; display: flex; align-items: center; justify-content: center;">
<div class="card" style="width: 100%; max-width: 320px;">
<h1 style="text-align:center;">습관 트래커</h1>
<h1 style="text-align:center;">해빗랩</h1>
{% if deleted %}<p style="text-align:center; color: var(--color-success);">계정이 삭제되었습니다.</p>{% endif %}
<p style="text-align:center;">구글 계정으로 로그인하세요</p>
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 로그인</a>
</div>
+45
View File
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}개인정보처리방침 · 해빗랩{% endblock %}
{% block content %}
<div style="max-width: 640px; margin: 0 auto; padding: 24px 16px 48px;">
<div class="card">
<h1>개인정보처리방침</h1>
<p>시행일자: 2026년 7월 22일</p>
<p>해빗랩(이하 "서비스")는 이용자의 개인정보를 소중히 다루며, 아래와 같이 개인정보를 수집·이용·보관합니다.</p>
<h2>1. 수집하는 개인정보 항목</h2>
<p><strong>가. 구글 로그인 시 (필수)</strong><br />
이메일 주소, 이름, 프로필 사진 URL, 구글 계정 고유 식별자(sub). 서비스는 구글 계정 정보를 이용해 자동으로 계정을 생성하며, 비밀번호는 별도로 수집·저장하지 않습니다.</p>
<p><strong>나. 서비스 이용 중 직접 입력하는 정보</strong><br />
등록한 습관의 이름, 유형(만들기/끊기), 요일 스케줄, 목표 난이도, 달성 조건, 알림 시각, 날짜별 체크·실패 기록.</p>
<p><strong>다. 알림(Web Push)을 켠 경우에만</strong><br />
브라우저가 발급하는 푸시 구독 정보(endpoint, 암호화 키)와 브라우저 종류(User-Agent). 알림 중복 발송을 막기 위한 발송 이력도 함께 기록됩니다.</p>
<p><strong>라. 자동 수집 정보</strong><br />
로그인 유지를 위한 서명된 세션 쿠키. 별도의 광고·분석·트래킹 쿠키는 사용하지 않습니다.</p>
<h2>2. 개인정보의 수집 및 이용 목적</h2>
<p>구글 계정 인증 및 로그인 유지, 습관 등록·체크·통계(완료율, 연속 달성일 등) 제공, 설정한 시각에 맞춘 습관 알림(Web Push) 발송을 위해서만 이용합니다. 광고, 마케팅, 프로필링 목적으로는 이용하지 않습니다.</p>
<h2>3. 개인정보의 보유 및 이용 기간</h2>
<p>회원 탈퇴 시까지 보관하며, 탈퇴 시 계정 정보와 습관·체크 기록, 푸시 구독 정보를 지체 없이 삭제합니다. 관계 법령에 따라 보존이 필요한 경우가 아니면 별도 보관하지 않습니다.</p>
<h2>4. 개인정보의 제3자 제공</h2>
<p>서비스는 이용자의 개인정보를 원칙적으로 외부에 제공하지 않습니다. 다만 구글 로그인 인증 과정에서 구글(Google LLC)과 통신이 발생하며, 이는 이용자 인증을 위한 목적에 한정됩니다.</p>
<h2>5. 이용자의 권리와 행사 방법</h2>
<p>이용자는 언제든지 자신의 개인정보 열람·정정·삭제를 요청할 수 있습니다. 로그인 후 <a href="/account">계정</a> 페이지에서 본인 이메일 확인 후 즉시 계정과 모든 데이터를 직접 삭제할 수 있으며, 앱을 이용하기 어려운 경우 아래 문의처로 요청하셔도 확인 후 지체 없이 처리해드립니다.</p>
<h2>6. 보안을 위한 조치</h2>
<p>비밀번호를 직접 저장하지 않는 구글 OAuth 인증 방식을 사용하며, 로그인 세션은 서명된 쿠키로 관리합니다. 모든 통신은 HTTPS로 암호화되며, 각 이용자의 데이터는 계정 단위로 분리되어 다른 이용자가 접근할 수 없습니다.</p>
<h2>7. 문의처</h2>
<p>개인정보 관련 문의, 열람·정정·삭제 요청은 아래로 연락해주세요.</p>
<p>해빗랩 운영자<br />
이메일: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a></p>
<h2>8. 고지의 의무</h2>
<p>이 개인정보처리방침은 법령이나 서비스 변경사항을 반영하기 위해 수정될 수 있으며, 변경 시 이 페이지를 통해 고지합니다.</p>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}오늘 · 습관 트래커{% endblock %}
{% block title %}오늘 · 해빗랩{% endblock %}
{% block nav_today %}active{% endblock %}
{% block content %}
<div id="today-content">
+9
View File
@@ -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
+1
View File
@@ -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]
+122
View File
@@ -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()
+1 -1
View File
@@ -1,4 +1,4 @@
# 습관 트래커 서버를 상시 실행하기 위한 스크립트.
# 해빗랩 서버를 상시 실행하기 위한 스크립트.
# Windows 작업 스케줄러 등록 시 이 스크립트를 대상으로 지정한다 (--reload 없이, conda activate 없이
# 대상 conda 환경의 python.exe를 직접 호출해 셸 활성화 없는 예약 작업에서도 안정적으로 동작하게 한다).