Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions backend/app/routers/github_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ def _get_or_create_cache(db: Session, user_id: str) -> GitHubLinkCache:
return cache


def require_github_user(user: User = Depends(get_current_user)) -> User:
"""GitHub 連携には GitHub ログイン(``github_id`` 保持)が必須。未連携なら 403。

``start`` / ``retry`` の両エンドポイントで共通の認可ガード。
"""
if user.github_id is None:
raise_app_error(
status_code=403,
code=ErrorCode.AUTH_REQUIRED,
message=get_error("github_link.github_login_required"),
action="GitHub アカウントでログインし直してください",
)
return user


@router.get("/cache", response_model=CachedGitHubLinkResponse)
def get_cache(
user: User = Depends(get_current_user),
Expand Down Expand Up @@ -104,18 +119,10 @@ async def start_github_link(
request: Request,
payload: GitHubLinkRequest,
background_tasks: BackgroundTasks,
user: User = Depends(get_current_user),
user: User = Depends(require_github_user),
db: Session = Depends(get_db),
):
"""GitHub 連携パイプラインをバックグラウンドで開始する。"""
if user.github_id is None:
raise_app_error(
status_code=403,
code=ErrorCode.AUTH_REQUIRED,
message=get_error("github_link.github_login_required"),
action="GitHub アカウントでログインし直してください",
)

github_username = user.username

# 進行中のタスクがあればそのステータスを返す
Expand Down Expand Up @@ -151,22 +158,14 @@ async def retry_github_link(
request: Request,
background_tasks: BackgroundTasks,
payload: GitHubLinkRequest | None = None,
user: User = Depends(get_current_user),
user: User = Depends(require_github_user),
db: Session = Depends(get_db),
):
"""失敗した GitHub 連携タスクを手動で再実行する。

``dead_letter`` 状態のキャッシュのみ再実行可能。
``retry_count`` を 0 にリセットし、ステータスを ``pending`` に戻して再ディスパッチする。
"""
if user.github_id is None:
raise_app_error(
status_code=403,
code=ErrorCode.AUTH_REQUIRED,
message=get_error("github_link.github_login_required"),
action="GitHub アカウントでログインし直してください",
)

cache = db.query(GitHubLinkCache).filter_by(user_id=user.id).first()
if not cache:
raise_app_error(
Expand Down
138 changes: 83 additions & 55 deletions backend/app/services/blog/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,51 @@ class BlogPlatformRequestError(RuntimeError):
"qiita": {"qiita.com", "www.qiita.com"},
}

# verify_user_exists 用のユーザー存在確認エンドポイント。
# (URL テンプレート, クエリパラメータ) を platform ごとに引く。
_VERIFY_ENDPOINTS: dict[str, tuple[str, dict | None]] = {
"zenn": ("https://zenn.dev/api/users/{username}", None),
"note": (
"https://note.com/api/v2/creators/{username}/contents",
{"kind": "note", "page": 1},
),
"qiita": ("https://qiita.com/api/v2/users/{username}", None),
}


def _truncate_date(value: str | None) -> str | None:
"""ISO 日付文字列の先頭 10 文字(``YYYY-MM-DD``)を返す。空なら ``None``。"""
return value[:10] if value else None


def _build_article(
platform: str,
*,
external_id: str,
title: str,
url: str,
published_at: str | None,
likes_count: int,
tags: list[str],
summary: str = "",
) -> dict:
"""各プラットフォーム共通の記事 dict を組み立てる。

この dict の形が記事取り込みの暗黙の契約であり、
``repositories.blog.BlogArticleRepository._apply_article_payload`` が同じキーを期待する。
キーを増減する場合は両者を併せて更新すること。
"""
return {
"platform": platform,
"external_id": external_id,
"title": title,
"url": url,
"published_at": published_at,
"likes_count": likes_count,
"summary": summary,
"tags": tags,
}


def _parse_platform_url(raw_username: str, allowed_hosts: set[str]):
if raw_username.startswith(("http://", "https://")):
Expand Down Expand Up @@ -121,18 +166,15 @@ async def fetch_zenn_articles(username: str) -> list[dict]:
if isinstance(t, dict)
]
articles.append(
{
"platform": "zenn",
"external_id": slug,
"title": item.get("title", ""),
"url": f"https://zenn.dev/{username}/articles/{slug}",
"published_at": (
item.get("published_at", "")[:10] if item.get("published_at") else None
),
"likes_count": item.get("liked_count", 0),
"summary": "",
"tags": tag_names,
}
_build_article(
"zenn",
external_id=slug,
title=item.get("title", ""),
url=f"https://zenn.dev/{username}/articles/{slug}",
published_at=_truncate_date(item.get("published_at")),
likes_count=item.get("liked_count", 0),
tags=tag_names,
)
)

next_page = data.get("next_page")
Expand Down Expand Up @@ -163,11 +205,6 @@ async def fetch_note_articles(username: str) -> list[dict]:
break

for item in notes:
note_url = item.get("noteUrl", "")
external_id = str(item.get("id", ""))
published_at = (
item.get("publishAt", "")[:10] if item.get("publishAt") else None
)
hashtags = item.get("hashtags", [])
tag_names = [
h.get("hashtag", {}).get("name", "")
Expand All @@ -177,16 +214,16 @@ async def fetch_note_articles(username: str) -> list[dict]:
body = item.get("body") or ""

articles.append(
{
"platform": "note",
"external_id": external_id,
"title": item.get("name", ""),
"url": note_url,
"published_at": published_at,
"likes_count": item.get("likeCount", 0),
"summary": body[:500],
"tags": [t for t in tag_names if t],
}
_build_article(
"note",
external_id=str(item.get("id", "")),
title=item.get("name", ""),
url=item.get("noteUrl", ""),
published_at=_truncate_date(item.get("publishAt")),
likes_count=item.get("likeCount", 0),
summary=body[:500],
tags=[t for t in tag_names if t],
)
)

is_last_page = data.get("data", {}).get("isLastPage", True)
Expand Down Expand Up @@ -219,18 +256,15 @@ async def fetch_qiita_articles(username: str) -> list[dict]:
for item in data:
tag_names = [t.get("name", "") for t in item.get("tags", [])]
articles.append(
{
"platform": "qiita",
"external_id": item.get("id", ""),
"title": item.get("title", ""),
"url": item.get("url", ""),
"published_at": (
item.get("created_at", "")[:10] if item.get("created_at") else None
),
"likes_count": item.get("likes_count", 0),
"summary": "",
"tags": tag_names,
}
_build_article(
"qiita",
external_id=item.get("id", ""),
title=item.get("title", ""),
url=item.get("url", ""),
published_at=_truncate_date(item.get("created_at")),
likes_count=item.get("likes_count", 0),
tags=tag_names,
)
)

if len(data) < per_page:
Expand All @@ -250,23 +284,17 @@ async def verify_user_exists(platform: str, username: str) -> bool:
except ValueError:
return False

try:
if platform == "zenn":
async with httpx.AsyncClient(timeout=_VERIFY_TIMEOUT_SECONDS) as client:
resp = await client.get(f"https://zenn.dev/api/users/{normalized_username}")
return resp.status_code == 200
if platform == "note":
async with httpx.AsyncClient(timeout=_VERIFY_TIMEOUT_SECONDS) as client:
resp = await client.get(
f"https://note.com/api/v2/creators/{normalized_username}/contents",
params={"kind": "note", "page": 1},
)
return resp.status_code == 200
if platform == "qiita":
async with httpx.AsyncClient(timeout=_VERIFY_TIMEOUT_SECONDS) as client:
resp = await client.get(f"https://qiita.com/api/v2/users/{normalized_username}")
return resp.status_code == 200
endpoint = _VERIFY_ENDPOINTS.get(platform)
if endpoint is None:
raise UnsupportedBlogPlatformError(platform)
url_template, params = endpoint

try:
async with httpx.AsyncClient(timeout=_VERIFY_TIMEOUT_SECONDS) as client:
resp = await client.get(
url_template.format(username=normalized_username), params=params
)
return resp.status_code == 200
except (httpx.ConnectError, httpx.TimeoutException):
logger.warning("プラットフォーム %s への接続に失敗しました", platform)
raise BlogPlatformRequestError(platform) from None
Expand Down
17 changes: 9 additions & 8 deletions backend/app/services/tasks/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,18 @@ async def execute_task(
},
)

if get_handler(task_type) is None:
handler = get_handler(task_type)
if handler is None:
logger.error("不明なタスク種別: %s", task_type)
return

try:
# 各 ``_run_*`` シムはテストからの patch ポイントとして残している。
# 実体は ``services/tasks/handlers/`` 配下のハンドラ。
# ハンドラレジストリ経由でディスパッチする(種別ごとの if 分岐は持たない)。
# 種別を増やしてもここは変更不要で、ハンドラ未登録なら上で早期 return するため
# 「分岐の書き忘れで黙って completed になる」事故は構造的に起きない。
# ハンドラには SessionLocal (ファクトリ) を渡し、ハンドラ内で長時間処理の
# 前後にセッションを開閉させる。
if task_type == TaskType.GITHUB_LINK:
await _run_github_link(SessionLocal, payload)
await handler.run(SessionLocal, payload)

duration_ms = _monotonic_ms_since(start)
logger.info(
Expand Down Expand Up @@ -231,9 +232,9 @@ def _finalize_retrying(
)


# ---------- ハンドラ薄ラッパー(後方互換)----------
# テストや既存呼び出しから ``_run_github_link`` を直接呼べるよう
# ハンドラ実装への薄いシムを残す。引数は ``session_factory``。
# ---------- ハンドラ薄ラッパー(直接呼び出し用)----------
# ``execute_task`` はレジストリ経由で汎用ディスパッチするためこのシムを介さないが
# GitHub 連携ハンドラ単体をテスト等から直接呼ぶための入口として残す。引数は ``session_factory``。


async def _run_github_link(session_factory: SessionFactory, payload: dict) -> None:
Expand Down
10 changes: 3 additions & 7 deletions backend/tests/security/test_rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,16 @@
"""

from app.main import limiter
from app.repositories import UserRepository
from fastapi.testclient import TestClient

from conftest import auth_header


def test_github_link_run_rate_limited(client: TestClient) -> None:
"""POST /api/github-link/run が 5/分の上限超で 429 を返す。"""
headers = auth_header(client, "rl-gh-user")
# /run は連携済み GitHub アカウント(github_login)を要求するため事前にセットする
db = client._db_session
user = UserRepository(db).get_by_username("rl-gh-user")
user.github_login = "octocat"
db.commit()
# /run は GitHub 連携済みユーザー(github_id)を require_github_user で要求するため、
# 認可で弾かれず rate limit まで到達するよう github_id 付きで作成する。
headers = auth_header(client, "rl-gh-user", github_id=424242)

limiter.reset()
statuses: list[int] = []
Expand Down
4 changes: 3 additions & 1 deletion backend/tests/test_blog_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ def test_verify_user_exists_normalizes_before_request() -> None:
result = _run(verify_user_exists("zenn", "https://zenn.dev/testuser/articles/test"))

assert result is True
mock_client.get.assert_awaited_once_with("https://zenn.dev/api/users/testuser")
mock_client.get.assert_awaited_once_with(
"https://zenn.dev/api/users/testuser", params=None
)


def test_verify_user_exists_zenn_user_not_found() -> None:
Expand Down
Loading
Loading