Compare commits
2 Commits
feature/si
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e35ce311d3 | |||
| 79962b93e2 |
@ -260,3 +260,28 @@
|
||||
- 글마다 별도 URL·목록 페이지 — 한 장 규칙을 깬다
|
||||
- 예약 요청 관리 화면 — `booking_request.py` 는 요청을 DB 에 남기지 않는다(2026-09-16
|
||||
대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다
|
||||
|
||||
## 메일 링크 — 둘 다 일회용 코드다 (2026-09-28)
|
||||
|
||||
```
|
||||
이대로 올리려면 : /v1/site/post/approve?t=<43자>
|
||||
고쳐서 올리려면 : /v1/site/post/edit?t=<43자>
|
||||
```
|
||||
|
||||
★ 예전에는 수정 링크가 `/blog?placeId=..&postId=..&auto=<JWT>` 였다. 주소가 500자였던 것은
|
||||
곁가지고, 진짜 문제는 그 `auto` 가 **빌더 액세스 토큰 통짜**(sub 에 UserInfo 전체 — role 포함)
|
||||
였다는 것이다 — **메일 전달 한 번이 그날 자정까지의 권한 양도**이고, 브라우저 히스토리 ·
|
||||
앞단 프록시 로그 · Referer 에도 그대로 남았다.
|
||||
(SNS 승인 흐름에서는 같은 이유로 "기존 액세스 토큰을 승인 링크에 얹지 않는다" 를 원칙으로
|
||||
박아 뒀는데, 이 경로에만 남아 있었다.)
|
||||
|
||||
지금은 `/edit` 이 코드를 검증한 뒤 **그 자리에서** day-pass 토큰을 만들어
|
||||
`/blog?placeId=..&postId=..#auto=<JWT>` 로 303 리다이렉트한다.
|
||||
★ **프래그먼트**로 넘기는 이유: 프래그먼트는 서버 로그와 Referer 에 남지 않는다.
|
||||
프론트(`app/provider.tsx`)는 그 값을 읽어 세션을 세운 뒤 **주소창에서 지운다.**
|
||||
|
||||
★ 쿼리(`?auto=`)도 계속 받는다 — 이미 나간 메일이 자정까지 살아 있고, 그걸 깨면 그 링크들이
|
||||
통째로 죽는다.
|
||||
|
||||
★ 두 코드는 **서로 다른 칸**(`approve_token_hash` · `edit_token_hash`)에 산다. 하나로 둘 다
|
||||
되면 일회성이 무의미해진다. 만료는 `token_expires_at` 을 같이 쓴다.
|
||||
|
||||
@ -367,6 +367,7 @@ CREATE TABLE IF NOT EXISTS public.place_posts (
|
||||
scheduled_date DATE NULL, -- 이 업장 몫 하루 한 통 배정일(KST). 생성 시 순서대로 채운다
|
||||
generation_meta JSONB NULL, -- 생성 당시 부가정보(모델명 등) — 컬럼 안 늘리고 여기 담는다
|
||||
approve_token_hash VARCHAR(64) NULL, -- sha256(평문). 평문은 메일 본문에만
|
||||
edit_token_hash VARCHAR(64) NULL, -- '고쳐서 올리려면' 링크의 일회용 코드 해시(migrations/0023)
|
||||
token_expires_at TIMESTAMPTZ NULL,
|
||||
sent_at TIMESTAMPTZ NULL,
|
||||
approved_at TIMESTAMPTZ NULL,
|
||||
@ -571,6 +572,11 @@ CREATE INDEX IF NOT EXISTS ix_place_posts_status
|
||||
CREATE INDEX IF NOT EXISTS ix_place_posts_token
|
||||
ON public.place_posts (approve_token_hash) WHERE approve_token_hash IS NOT NULL;
|
||||
|
||||
-- 메일 '고쳐서 올리려면' 링크가 한 번에 한 행을 집는다(migrations/0023).
|
||||
CREATE INDEX IF NOT EXISTS ix_place_posts_edit_token
|
||||
ON public.place_posts(edit_token_hash)
|
||||
WHERE deleted = false AND edit_token_hash IS NOT NULL;
|
||||
|
||||
-- alert_outbox
|
||||
-- 재시도 경로: PENDING(1) 이면서 next_attempt_at 이 지난 것.
|
||||
CREATE INDEX IF NOT EXISTS ix_alert_outbox_pending ON public.alert_outbox (status, next_attempt_at);
|
||||
|
||||
17
postgres-init/migrations/0023_place_posts_edit_token.sql
Normal file
17
postgres-init/migrations/0023_place_posts_edit_token.sql
Normal file
@ -0,0 +1,17 @@
|
||||
-- 0023 · place_posts.edit_token_hash — 메일의 "고쳐서 올리려면" 링크를 일회용 코드로.
|
||||
--
|
||||
-- ★ 예전에는 그 링크에 **빌더 액세스 토큰 통짜**(sub 에 UserInfo 전체 — role 포함)를
|
||||
-- 쿼리로 실어 보냈다. 주소가 500자였던 것은 그 때문이고, 길이보다 나쁜 것은 따로 있다:
|
||||
-- 메일을 한 번 전달하면 **그날 자정까지 빌더 권한이 그대로 넘어간다.** 브라우저 히스토리 ·
|
||||
-- 앞단 프록시 로그 · Referer 에도 그대로 남는다.
|
||||
-- (SNS 승인 흐름에서는 같은 이유로 "기존 액세스 토큰을 승인 링크에 얹지 않는다" 를
|
||||
-- 설계 원칙으로 박아 뒀는데, 이 경로에만 남아 있었다.)
|
||||
--
|
||||
-- ★ 승인 토큰(approve_token_hash)과 같은 규약이다 — 평문은 메일 본문에만 있고 DB 에는
|
||||
-- sha256 만 둔다. 만료는 token_expires_at 을 같이 쓴다(두 링크가 같은 시각에 죽는다).
|
||||
ALTER TABLE public.place_posts ADD COLUMN IF NOT EXISTS edit_token_hash varchar(64);
|
||||
|
||||
-- 링크 한 번에 한 행을 집는다. 승인 토큰과 같은 이유로 부분 인덱스다.
|
||||
CREATE INDEX IF NOT EXISTS ix_place_posts_edit_token
|
||||
ON public.place_posts(edit_token_hash)
|
||||
WHERE deleted = false AND edit_token_hash IS NOT NULL;
|
||||
@ -461,6 +461,9 @@ class place_posts(MainTableMixin, MAIN_BASE):
|
||||
# 파서 컬럼"). 새 필드가 늘어도 마이그레이션이 안 따라온다.
|
||||
generation_meta = Column(JSONB, nullable=True)
|
||||
approve_token_hash = Column(String(64), nullable=True)
|
||||
# ★ 메일 '고쳐서 올리려면' 링크의 일회용 코드. 예전에는 그 자리에 빌더 액세스 토큰을
|
||||
# 통짜로 실어 보냈다 — 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023).
|
||||
edit_token_hash = Column(String(64), nullable=True)
|
||||
token_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
sent_at = Column(DateTime(timezone=True), nullable=True)
|
||||
approved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@ -144,11 +144,20 @@ class PostCRUD:
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def mark_sent(self, cdb: AsyncSession, post_id, token_hash: str, expires_at) -> ErrorType:
|
||||
async def by_edit_token_hash(self, cdb: AsyncSession, token_hash: str):
|
||||
result = await cdb.execute(
|
||||
select(place_posts)
|
||||
.where(place_posts.edit_token_hash == token_hash, place_posts.deleted == False) # noqa: E712
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def mark_sent(self, cdb: AsyncSession, post_id, token_hash: str, expires_at,
|
||||
edit_token_hash: str | None = None) -> ErrorType:
|
||||
await cdb.execute(
|
||||
update(place_posts)
|
||||
.where(place_posts.post_id == post_id)
|
||||
.values(status=PostStatus.SENT.value, approve_token_hash=token_hash,
|
||||
edit_token_hash=edit_token_hash,
|
||||
token_expires_at=expires_at, sent_at=GTime.UTC(), updated_at=GTime.UTC())
|
||||
)
|
||||
return ErrorType.SUCCESS
|
||||
|
||||
@ -19,13 +19,14 @@ from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, UserInfo
|
||||
from router.v1.site.protocol import (
|
||||
Req_EditPost, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts,
|
||||
)
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from services import blog_service
|
||||
from services.post_service import PostService
|
||||
|
||||
router = APIRouter(prefix="/v1/site/post", tags=["Site"])
|
||||
@ -61,6 +62,27 @@ def _expired_page() -> HTMLResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get(path="/edit", summary="수정하기 — 일회용 코드를 세션으로 바꿔 편집 화면으로 보낸다")
|
||||
async def edit_redirect(t: str = Query(min_length=8, max_length=200), service: PostService = Depends()):
|
||||
"""메일의 '고쳐서 올리려면'.
|
||||
|
||||
★ 액세스 토큰은 **쿼리가 아니라 프래그먼트**로 넘긴다 — 프래그먼트는 서버 로그와 Referer 에
|
||||
남지 않는다. 예전처럼 쿼리에 실으면 주소가 500자가 되는 것보다, 메일 전달 한 번이
|
||||
자정까지의 권한 양도가 되는 쪽이 더 나빴다(services/post_service.open_editor)."""
|
||||
result = await service.open_editor(t)
|
||||
if not result["success"]:
|
||||
return _expired_page()
|
||||
target = (
|
||||
f"{blog_service.app_origin()}/blog"
|
||||
f"?placeId={result['place_id']}&postId={result['post_id']}"
|
||||
f"#auto={result['auto']}"
|
||||
)
|
||||
# 303 — 이 주소는 다시 쓸 수 없으니 브라우저가 되돌아오지 않게 한다.
|
||||
return RedirectResponse(target, status_code=303, headers={
|
||||
"Cache-Control": "no-store", "Referrer-Policy": "no-referrer",
|
||||
})
|
||||
|
||||
|
||||
@router.get(path="/approve", response_class=HTMLResponse, summary="승인 확정 — 누르는 즉시 게재 큐에 넣는다")
|
||||
async def approve_page(t: str = Query(min_length=8, max_length=200), service: PostService = Depends()):
|
||||
result = await service.decide(t, skip=False)
|
||||
|
||||
@ -18,9 +18,7 @@ from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import place_posts, places, sites, users
|
||||
from common.enums import DBWRType, PostStatus, SiteStatus
|
||||
from common.logger import LOG
|
||||
from common.models.gmodel import UserInfo
|
||||
from crud.post_crud import PostCRUD
|
||||
from router.v1.validator.dependencies import CreateDayPassToken
|
||||
from services import blog_service, mail_service, site_payload
|
||||
from services.snapshot import build_snapshot
|
||||
|
||||
@ -196,15 +194,15 @@ async def generate_one_for_date(place_id: str, target_date: date) -> dict | None
|
||||
) # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다
|
||||
|
||||
|
||||
def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str) -> str:
|
||||
def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str, edit_token: str) -> str:
|
||||
"""승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다
|
||||
(2026-09-17, 사장님 지시: "승인이랑 수정하기 있어야해"). 둘 다 오늘 자정(KST)에
|
||||
만료된다(2026-09-17, 사장님 지시: "승인이랑 수정모두 자정에 만료") — 그 뒤로는
|
||||
로그인해서 빌더 앱에서 처리한다. 수정 링크는 토큰 하나짜리 공개 편집 화면 대신,
|
||||
실제 로그인 세션으로 빌더 앱의 편집 모달을 그대로 연다."""
|
||||
user_info = UserInfo(user_id=str(user.user_id), id=user.id, role=user.role, token_version=user.token_version)
|
||||
auto_token = CreateDayPassToken(user_info)
|
||||
edit_link = f"{origin}/blog?placeId={post.place_id}&postId={post.post_id}&auto={auto_token}"
|
||||
# ★ 두 링크가 같은 모양이다 — 일회용 코드 하나씩. 예전에는 수정 링크만 500자였는데,
|
||||
# 길이보다 나쁜 것은 거기 실린 빌더 액세스 토큰이었다(services/post_service.open_editor).
|
||||
edit_link = f"{origin}/v1/site/post/edit?t={edit_token}"
|
||||
approve_link = f"{origin}/v1/site/post/approve?t={approve_token}"
|
||||
return (
|
||||
f"{place_name} 사이트에 올릴 글을 준비했습니다.\n\n"
|
||||
@ -223,30 +221,26 @@ def _notify_address(place, user) -> str:
|
||||
|
||||
|
||||
def _app_origin() -> str:
|
||||
"""메일의 승인·수정 링크가 향할 곳 — 빌더 앱(과 그 앞의 API)이 사는 오리진.
|
||||
|
||||
★ site_payload.publish_origin() 을 쓰면 안 된다 — 그건 발행된 고객 사이트(/s/<slug>)
|
||||
전용이다. 로컬에선 그게 solution-site 정적 서버(포트 80)라, 메일의 "수정하려면"
|
||||
링크(/blog?...)가 거기로 가서 404 났다(2026-09-21 실측). SNS 알림(notify_service.py)이
|
||||
이미 같은 목적으로 쓰는 SOCIAL_APP_ORIGIN 을 그대로 재사용한다 — 설정을 두 벌 안 둔다.
|
||||
비어 있으면(로컬에서 안 채웠으면) publish_origin() 으로 폴백해 링크가 아예 상대경로로
|
||||
깨지는 것보다는 낫게 한다."""
|
||||
return social_config.get("SOCIAL_APP_ORIGIN") or site_payload.publish_origin()
|
||||
"""blog_service.app_origin() 을 그대로 쓴다 — 라우터도 같은 값을 써야 해서 거기로 옮겼다."""
|
||||
return blog_service.app_origin()
|
||||
|
||||
|
||||
async def _send_one(place, user, post) -> bool:
|
||||
"""토큰 발급 → 메일 본문 조립 → 발송 → 성공하면 SENT 로 표시. 실패하면 DB 를 안 건드린다."""
|
||||
token, token_hash, expires = blog_service.issue_token()
|
||||
# ★ 수정 링크도 일회용 코드다. 예전에는 여기 빌더 액세스 토큰을 통짜로 실었고,
|
||||
# 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023).
|
||||
edit_token, edit_hash, _edit_expires = blog_service.issue_token()
|
||||
body = _mail_body(
|
||||
place_name=place.name, post=post, user=user,
|
||||
origin=_app_origin(), approve_token=token,
|
||||
origin=_app_origin(), approve_token=token, edit_token=edit_token,
|
||||
)
|
||||
ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body)
|
||||
if not ok:
|
||||
return False
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_posts.DBType()],
|
||||
[lambda s, pid=post.post_id, h=token_hash, e=expires: _crud.mark_sent(s, pid, h, e)],
|
||||
[lambda s, pid=post.post_id, h=token_hash, e=expires, eh=edit_hash: _crud.mark_sent(s, pid, h, e, eh)],
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import re
|
||||
import secrets
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from config import social_config
|
||||
from common.enums import LocalContentType, PlaceCategory, PostStatus, PostTopicKind
|
||||
from common.logger import LOG
|
||||
|
||||
@ -51,6 +52,19 @@ def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def app_origin() -> str:
|
||||
"""메일의 승인·수정 링크가 향할 곳 — 빌더 앱(과 그 앞의 API)이 사는 오리진.
|
||||
|
||||
★ site_payload.publish_origin() 을 쓰면 안 된다 — 그건 발행된 고객 사이트(/s/<slug>)
|
||||
전용이다. 로컬에선 그게 solution-site 정적 서버(포트 80)라, 메일의 "수정하려면"
|
||||
링크(/blog?...)가 거기로 가서 404 났다(2026-09-21 실측). SNS 알림(notify_service.py)이
|
||||
이미 같은 목적으로 쓰는 SOCIAL_APP_ORIGIN 을 그대로 재사용한다 — 설정을 두 벌 안 둔다.
|
||||
비어 있으면 publish_origin() 으로 폴백해 링크가 상대경로로 깨지는 것보다는 낫게 한다."""
|
||||
from services import site_payload
|
||||
|
||||
return social_config.get("SOCIAL_APP_ORIGIN") or site_payload.publish_origin()
|
||||
|
||||
|
||||
def issue_token() -> tuple[str, str, object]:
|
||||
"""(평문, 해시, 만료시각=오늘 자정 KST). 평문은 메일 본문에만 나가고 DB 에는 해시만 둔다.
|
||||
|
||||
|
||||
@ -63,6 +63,62 @@ class PostService:
|
||||
return None
|
||||
return post
|
||||
|
||||
async def open_editor(self, token: str) -> dict:
|
||||
"""메일의 '고쳐서 올리려면' — 일회용 코드를 그 자리에서 세션으로 바꾼다.
|
||||
|
||||
★ 예전에는 메일 링크에 **빌더 액세스 토큰을 통짜로** 실어 보냈다(sub 에 UserInfo
|
||||
전체 — role 포함). 주소가 500자였던 것은 곁가지고, 진짜 문제는 **메일 전달 한 번이
|
||||
그날 자정까지의 권한 양도**였다는 것이다. 브라우저 히스토리·프록시 로그·Referer 에도
|
||||
그대로 남았다. 이제 URL 에는 일회용 코드만 있고, 토큰은 여기서 만들어 **프래그먼트**로
|
||||
넘긴다 — 프래그먼트는 서버 로그와 Referer 에 남지 않는다.
|
||||
★ 실패 이유를 구분해 답하지 않는다(만료·없는 코드·이미 처리됨) — 사장님에게는
|
||||
'못 쓰는 링크' 하나다(find_by_token 과 같은 규약)."""
|
||||
from router.v1.validator.dependencies import CreateDayPassToken
|
||||
|
||||
token_hash = blog_service.hash_token(token)
|
||||
post = await DB_SESSION_MNG.execute_lambda(
|
||||
place_posts.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.crud.by_edit_token_hash(s, token_hash),
|
||||
)
|
||||
if not post or post.status not in (PostStatus.SENT.value, PostStatus.REVIEWED.value):
|
||||
return {"success": False}
|
||||
if post.token_expires_at and post.token_expires_at.replace(tzinfo=None) < GTime.UTC():
|
||||
return {"success": False}
|
||||
|
||||
owner = await self._owner_of(post.place_id)
|
||||
if owner is None:
|
||||
return {"success": False}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"place_id": str(post.place_id),
|
||||
"post_id": str(post.post_id),
|
||||
"auto": CreateDayPassToken(owner),
|
||||
}
|
||||
|
||||
async def _owner_of(self, place_id) -> UserInfo | None:
|
||||
"""글이 달린 사업장의 주인. ★ 토큰이 가리키는 글에서 사람을 끌어낸다 —
|
||||
URL 에 누구인지 싣지 않기 위한 조건이다."""
|
||||
from common.database.model.models import users
|
||||
|
||||
def query(session):
|
||||
return session.execute(
|
||||
select(users)
|
||||
.join(places, places.owner_user_id == users.user_id)
|
||||
.where(
|
||||
places.place_id == place_id,
|
||||
places.deleted == False, # noqa: E712
|
||||
users.deleted == False, # noqa: E712
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await DB_SESSION_MNG.execute_lambda(place_posts.DBType(), DBWRType.DB_READ.value, query)
|
||||
row = result.scalars().first() if result is not None else None
|
||||
if row is None:
|
||||
return None
|
||||
return UserInfo(user_id=str(row.user_id), id=row.id, role=row.role, token_version=row.token_version)
|
||||
|
||||
async def decide(self, token: str, *, skip: bool) -> dict:
|
||||
post = await self.find_by_token(token)
|
||||
if not post:
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
- 여기서도 금칙 게이트는 그대로 탄다 — 로그인했다고 우회되지 않는다
|
||||
"""
|
||||
import uuid
|
||||
from datetime import date, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
@ -814,9 +814,12 @@ async def test_mail_has_one_click_approve_and_autologin_edit_links(client, db_en
|
||||
assert sent == 1
|
||||
mail_text = sent_calls[0]["text"]
|
||||
assert "/v1/site/post/approve?t=" in mail_text
|
||||
assert "/blog?placeId=" in mail_text
|
||||
assert f"postId={post_id}" in mail_text
|
||||
assert "auto=" in mail_text
|
||||
# ★ 수정 링크도 일회용 코드다(2026-09-28). 예전에는 여기 빌더 액세스 토큰을 통짜로 실어
|
||||
# `/blog?placeId=..&postId=..&auto=<JWT>` 를 보냈다 — 주소가 500자였던 것은 곁가지고,
|
||||
# 진짜 문제는 **메일 전달 한 번이 그날 자정까지의 권한 양도**였다는 것이다.
|
||||
assert "/v1/site/post/edit?t=" in mail_text
|
||||
assert "auto=" not in mail_text
|
||||
assert "eyJ" not in mail_text
|
||||
|
||||
|
||||
async def test_mail_links_use_the_builder_app_origin_not_the_published_site_origin(
|
||||
@ -825,14 +828,16 @@ async def test_mail_links_use_the_builder_app_origin_not_the_published_site_orig
|
||||
"""수정·승인 링크는 빌더 앱(SOCIAL_APP_ORIGIN)으로 가야 한다 — 발행된 사이트 오리진
|
||||
(site_payload.publish_origin, 로컬에선 solution-site 정적 서버 포트 80)으로 가면
|
||||
404가 난다(2026-09-21 실측: 메일의 '수정하려면' 링크가 거기로 가서 404)."""
|
||||
from services import blog_jobs, mail_service, site_payload
|
||||
from services import blog_jobs, blog_service, mail_service, site_payload
|
||||
|
||||
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
|
||||
sent_calls = []
|
||||
monkeypatch.setattr(mail_service, "send", lambda **kwargs: sent_calls.append(kwargs) or True)
|
||||
monkeypatch.setattr(site_payload, "publish_origin", lambda: "http://published-site-origin")
|
||||
# ★ 오리진 계산은 blog_service.app_origin() 으로 옮겼다 — 라우터(수정 링크 리다이렉트)도
|
||||
# 같은 값을 써야 해서다. 그래서 패치 대상도 거기다.
|
||||
monkeypatch.setattr(
|
||||
blog_jobs.social_config, "get",
|
||||
blog_service.social_config, "get",
|
||||
lambda name, default="": "http://builder-app-origin" if name == "SOCIAL_APP_ORIGIN" else default,
|
||||
)
|
||||
|
||||
@ -850,7 +855,9 @@ async def test_mail_links_use_the_builder_app_origin_not_the_published_site_orig
|
||||
|
||||
assert sent == 1
|
||||
mail_text = sent_calls[0]["text"]
|
||||
assert "http://builder-app-origin/blog?placeId=" in mail_text
|
||||
# 두 링크 다 빌더 앱 오리진이어야 한다. 수정 링크는 일회용 코드를 거쳐 /blog 로
|
||||
# 리다이렉트되므로(router/v1/site/post.py edit_redirect) 메일에는 /v1/site/post/edit 이 실린다.
|
||||
assert "http://builder-app-origin/v1/site/post/edit?t=" in mail_text
|
||||
assert "http://builder-app-origin/v1/site/post/approve?t=" in mail_text
|
||||
assert "published-site-origin" not in mail_text
|
||||
|
||||
@ -922,3 +929,113 @@ async def test_generate_one_is_scoped_to_owner(client, db_engine, auth_headers):
|
||||
)
|
||||
|
||||
assert res.json()["result"]["success"] is False
|
||||
|
||||
|
||||
# ── 메일 '고쳐서 올리려면' — 일회용 코드 ────────────────────────────────
|
||||
|
||||
async def _seed_edit_token(db_engine, post_id, *, expired=False):
|
||||
from services import blog_service
|
||||
|
||||
token, token_hash, expires = blog_service.issue_token()
|
||||
if expired:
|
||||
expires = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
aware = expires.replace(tzinfo=timezone.utc) if expires.tzinfo is None else expires
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("UPDATE place_posts SET edit_token_hash=:h, token_expires_at=:e WHERE post_id=:id"),
|
||||
{"h": token_hash, "e": aware, "id": post_id},
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def test_수정_링크는_짧고_액세스_토큰을_싣지_않는다(client, db_engine, auth_headers):
|
||||
"""★ 예전에는 이 링크에 빌더 액세스 토큰을 통짜로 실었다 — 메일 전달 한 번이
|
||||
그날 자정까지의 권한 양도였고, 주소는 500자였다."""
|
||||
h = await auth_headers("blogedit1")
|
||||
place_id = await _place(client, h)
|
||||
post_id = await _seed_post(db_engine, place_id, status=PostStatus.SENT)
|
||||
token = await _seed_edit_token(db_engine, post_id)
|
||||
|
||||
res = await client.get(f"/v1/site/post/edit?t={token}", follow_redirects=False)
|
||||
assert res.status_code == 303
|
||||
location = res.headers["location"]
|
||||
|
||||
# 액세스 토큰은 **프래그먼트**로만 간다 — 서버 로그·Referer 에 남지 않는다.
|
||||
path_and_query = location.split("#", 1)[0]
|
||||
assert "auto=" not in path_and_query
|
||||
assert "#auto=" in location
|
||||
assert str(post_id) in path_and_query and str(place_id) in path_and_query
|
||||
assert res.headers["Referrer-Policy"] == "no-referrer"
|
||||
|
||||
|
||||
async def test_수정_링크의_토큰은_그_사장님_것이다(client, db_engine, auth_headers):
|
||||
"""URL 에 누구인지 싣지 않는다 — 토큰이 가리키는 글에서 사람을 끌어낸다."""
|
||||
from router.v1.validator.dependencies import DecodeAccessToken
|
||||
|
||||
h = await auth_headers("blogedit2")
|
||||
place_id = await _place(client, h)
|
||||
post_id = await _seed_post(db_engine, place_id, status=PostStatus.SENT)
|
||||
token = await _seed_edit_token(db_engine, post_id)
|
||||
|
||||
res = await client.get(f"/v1/site/post/edit?t={token}", follow_redirects=False)
|
||||
auto = res.headers["location"].split("#auto=", 1)[1]
|
||||
async with db_engine.begin() as conn:
|
||||
owner = (
|
||||
await conn.execute(text("SELECT owner_user_id FROM places WHERE place_id=:p"), {"p": place_id})
|
||||
).scalar_one()
|
||||
assert DecodeAccessToken(auto).user_id == str(owner)
|
||||
|
||||
|
||||
async def test_만료된_수정_링크는_안_먹는다(client, db_engine, auth_headers):
|
||||
h = await auth_headers("blogedit3")
|
||||
place_id = await _place(client, h)
|
||||
post_id = await _seed_post(db_engine, place_id, status=PostStatus.SENT)
|
||||
token = await _seed_edit_token(db_engine, post_id, expired=True)
|
||||
|
||||
res = await client.get(f"/v1/site/post/edit?t={token}", follow_redirects=False)
|
||||
assert res.status_code == 200 # 만료 안내 화면
|
||||
assert "auto=" not in res.text
|
||||
|
||||
|
||||
async def test_없는_수정_코드는_이유를_구분해_답하지_않는다(client, db_engine):
|
||||
res = await client.get("/v1/site/post/edit?t=ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", follow_redirects=False)
|
||||
assert res.status_code == 200
|
||||
assert "auto=" not in res.text
|
||||
|
||||
|
||||
async def test_승인_코드로는_수정_링크를_열_수_없다(client, db_engine, auth_headers):
|
||||
"""★ 두 코드는 서로 다른 칸에 산다. 하나로 둘 다 되면 일회성이 무의미해진다."""
|
||||
from services import blog_service
|
||||
|
||||
h = await auth_headers("blogedit4")
|
||||
place_id = await _place(client, h)
|
||||
post_id = await _seed_post(db_engine, place_id, status=PostStatus.SENT)
|
||||
approve_token, approve_hash, expires = blog_service.issue_token()
|
||||
aware = expires.replace(tzinfo=timezone.utc) if expires.tzinfo is None else expires
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("UPDATE place_posts SET approve_token_hash=:h, token_expires_at=:e WHERE post_id=:id"),
|
||||
{"h": approve_hash, "e": aware, "id": post_id},
|
||||
)
|
||||
|
||||
res = await client.get(f"/v1/site/post/edit?t={approve_token}", follow_redirects=False)
|
||||
assert res.status_code == 200
|
||||
assert "auto=" not in res.text
|
||||
|
||||
|
||||
def test_메일_본문에_액세스_토큰이_없다():
|
||||
"""★ 본문이 곧 유출 경로다 — 전달 한 번으로 권한이 넘어가면 안 된다."""
|
||||
from types import SimpleNamespace
|
||||
from services import blog_jobs
|
||||
|
||||
post = SimpleNamespace(post_id=uuid.uuid4(), place_id=uuid.uuid4(), body=BODY)
|
||||
user = SimpleNamespace(user_id=uuid.uuid4(), id="owner", role=1, token_version=1, email="a@b.c")
|
||||
body = blog_jobs._mail_body(
|
||||
place_name="테스트", post=post, user=user, origin="https://example.com",
|
||||
approve_token="APPROVE_CODE", edit_token="EDIT_CODE",
|
||||
)
|
||||
assert "APPROVE_CODE" in body and "EDIT_CODE" in body
|
||||
assert "auto=" not in body # 액세스 토큰이 실리던 자리
|
||||
assert "eyJ" not in body # JWT 의 머리글자
|
||||
longest = max(len(word) for word in body.split())
|
||||
assert longest < 120, f"링크가 아직 길다: {longest}자"
|
||||
|
||||
@ -23,11 +23,19 @@ function useRestoreSession() {
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
const autoToken = new URLSearchParams(window.location.search).get('auto');
|
||||
// ★ 프래그먼트(#auto=)를 먼저 본다. 메일의 '고쳐서 올리려면' 링크가 그쪽으로 넘긴다 —
|
||||
// 프래그먼트는 서버 로그와 Referer 에 남지 않기 때문이다. 쿼리(?auto=)도 계속 받는다:
|
||||
// 이미 나간 메일이 자정까지 살아 있고, 그걸 깨면 그 링크들이 통째로 죽는다.
|
||||
const hashToken = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('auto');
|
||||
const autoToken = hashToken ?? new URLSearchParams(window.location.search).get('auto');
|
||||
const claims = autoToken ? decodeJwtSubject(autoToken) : null;
|
||||
if (autoToken && claims) {
|
||||
setTokens(autoToken);
|
||||
setUser({userId: claims.user_id, id: claims.id, role: claims.role});
|
||||
// 주소창에 토큰을 남기지 않는다 — 히스토리·스크린샷·어깨너머로 새는 자리다.
|
||||
if (hashToken) {
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
finishRestore();
|
||||
return;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user