Skip to content

Commit 2aa2a42

Browse files
authored
Merge pull request #14 from Dynamite2003/feature/document
feature: add document management
2 parents 2e4df34 + a269b4f commit 2aa2a42

File tree

30 files changed

+3191
-375
lines changed

30 files changed

+3191
-375
lines changed

backend/app/api/v1/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Versioned API router."""
22
from fastapi import APIRouter
33
from .arxiv import router as arxiv_router
4-
from app.api.v1.endpoints import health, papers, users, auth, academic, conversations
4+
from app.api.v1.endpoints import health, papers, users, auth, academic, conversations, library
55

66
api_router = APIRouter()
77
api_router.include_router(health.router, prefix="/health", tags=["health"])
@@ -11,3 +11,4 @@
1111
api_router.include_router(academic.router, prefix="/academic", tags=["academic"])
1212
api_router.include_router(arxiv_router,prefix="/arxiv",tags=["arxiv"])
1313
api_router.include_router(conversations.router, prefix="/conversations", tags=["conversations"])
14+
api_router.include_router(library.router, prefix="/library", tags=["library"])

backend/app/api/v1/api.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""API v1 router aggregation."""
22
from fastapi import APIRouter
33

4-
from app.api.v1.endpoints import academic, auth, conversations, papers, users
4+
from app.api.v1.endpoints import academic, auth, conversations, library, papers, users
55

66
# 创建主路由器
77
api_router = APIRouter()
@@ -25,6 +25,12 @@
2525
tags=["论文检索"]
2626
)
2727

28+
api_router.include_router(
29+
library.router,
30+
prefix="/library",
31+
tags=["我的文库"]
32+
)
33+
2834
api_router.include_router(
2935
academic.router,
3036
prefix="/academic",
@@ -35,4 +41,4 @@
3541
conversations.router,
3642
prefix="/conversations",
3743
tags=["对话历史"]
38-
)
44+
)

backend/app/api/v1/endpoints/auth.py

Lines changed: 206 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,33 @@
11
"""认证授权相关API接口"""
2-
from fastapi import APIRouter, Depends, HTTPException, status
2+
from __future__ import annotations
3+
4+
import json
5+
import secrets
6+
from typing import cast
7+
from urllib.parse import urlencode
8+
9+
import httpx
10+
from fastapi import APIRouter, Depends, HTTPException, Request, status
11+
from fastapi.responses import RedirectResponse
312
from fastapi.security import OAuth2PasswordRequestForm
413
from sqlalchemy.ext.asyncio import AsyncSession
5-
from typing import cast
614

715
from app.core.auth import create_access_token
8-
from app.core.security import verify_password
16+
from app.core.config import get_settings
17+
from app.core.security import hash_password, verify_password
918
from app.db import UserRepository
1019
from app.db.session import get_db
1120
from app.schemas.auth import Token # type: ignore[import-not-found]
1221

1322
router = APIRouter()
23+
settings = get_settings()
24+
25+
GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
26+
GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
27+
GITHUB_USER_API = "https://api.github.com/user"
28+
GITHUB_EMAILS_API = "https://api.github.com/user/emails"
29+
GITHUB_STATE_COOKIE = "github_oauth_state"
30+
GITHUB_STATE_TTL = 600
1431

1532

1633
@router.post(
@@ -99,4 +116,189 @@ async def login_for_access_token(
99116
)
100117

101118
token = create_access_token(subject=cast(str, getattr(user, "email", "")))
102-
return Token(access_token=token)
119+
return Token(access_token=token)
120+
121+
122+
@router.get("/github/login")
123+
async def github_login(request: Request, next: str | None = None):
124+
client_id = settings.github_client_id
125+
client_secret = settings.github_client_secret
126+
if not client_id or not client_secret:
127+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="GitHub OAuth 未配置")
128+
129+
nonce = secrets.token_urlsafe(32)
130+
cookie_payload = json.dumps(
131+
{
132+
"nonce": nonce,
133+
"next": _sanitize_next_path(next),
134+
}
135+
)
136+
137+
callback_url = str(request.url_for("github_callback"))
138+
params = {
139+
"client_id": client_id,
140+
"redirect_uri": callback_url,
141+
"scope": "read:user user:email",
142+
"state": nonce,
143+
"allow_signup": "true",
144+
}
145+
authorize_url = f"{GITHUB_AUTHORIZE_URL}?{urlencode(params)}"
146+
response = RedirectResponse(authorize_url, status_code=status.HTTP_302_FOUND)
147+
response.set_cookie(
148+
GITHUB_STATE_COOKIE,
149+
cookie_payload,
150+
max_age=GITHUB_STATE_TTL,
151+
httponly=True,
152+
secure=_is_cookie_secure(),
153+
samesite="lax",
154+
)
155+
return response
156+
157+
158+
@router.get("/github/callback", name="github_callback")
159+
async def github_callback(
160+
request: Request,
161+
code: str | None = None,
162+
state: str | None = None,
163+
db: AsyncSession = Depends(get_db),
164+
):
165+
cookie_payload = request.cookies.get(GITHUB_STATE_COOKIE)
166+
if not cookie_payload:
167+
return _oauth_error_redirect("Missing OAuth state cookie.")
168+
169+
try:
170+
payload = json.loads(cookie_payload)
171+
except json.JSONDecodeError:
172+
return _oauth_error_redirect("Invalid OAuth state payload.")
173+
174+
expected_state = payload.get("nonce")
175+
next_path = _sanitize_next_path(payload.get("next"))
176+
if not code or not state or expected_state != state:
177+
return _oauth_error_redirect("OAuth state mismatch.", next_path=next_path)
178+
179+
try:
180+
callback_url = str(request.url_for("github_callback"))
181+
token_data = await _exchange_github_code_for_token(code, callback_url)
182+
access_token = token_data.get("access_token")
183+
if not access_token:
184+
raise RuntimeError("GitHub did not return an access token.")
185+
186+
github_user, primary_email = await _fetch_github_profile(access_token)
187+
if not primary_email:
188+
raise RuntimeError("未能获取 GitHub 邮箱,请在 GitHub 账户开启公开邮箱或授权 email scope。")
189+
190+
github_user_id = str(github_user.get("id"))
191+
repo = UserRepository(db)
192+
user = await repo.get_by_oauth_account("github", github_user_id)
193+
194+
if user is None:
195+
user = await repo.get_by_email(primary_email)
196+
if user:
197+
user = await repo.update(
198+
user,
199+
{
200+
"oauth_provider": "github",
201+
"oauth_account_id": github_user_id,
202+
"avatar_url": user.avatar_url or github_user.get("avatar_url"),
203+
},
204+
)
205+
else:
206+
hashed_password = hash_password(secrets.token_urlsafe(32))
207+
user = await repo.create(
208+
email=primary_email,
209+
hashed_password=hashed_password,
210+
full_name=github_user.get("name") or github_user.get("login"),
211+
avatar_url=github_user.get("avatar_url"),
212+
oauth_provider="github",
213+
oauth_account_id=github_user_id,
214+
)
215+
else:
216+
updates: dict[str, str | None] = {}
217+
if not user.avatar_url and github_user.get("avatar_url"):
218+
updates["avatar_url"] = github_user.get("avatar_url")
219+
updates["oauth_provider"] = "github"
220+
updates["oauth_account_id"] = github_user_id
221+
user = await repo.update(user, updates)
222+
223+
await db.commit()
224+
await db.refresh(user)
225+
226+
token = create_access_token(subject=cast(str, getattr(user, "email", "")))
227+
redirect_target = _build_frontend_redirect(token=token, next_path=next_path)
228+
response = RedirectResponse(redirect_target, status_code=status.HTTP_302_FOUND)
229+
response.delete_cookie(GITHUB_STATE_COOKIE)
230+
return response
231+
except Exception as exc: # pragma: no cover - defensive
232+
return _oauth_error_redirect(str(exc), next_path=next_path)
233+
234+
235+
def _sanitize_next_path(value: str | None) -> str:
236+
if not value or not value.startswith("/"):
237+
return "/"
238+
return value
239+
240+
241+
def _is_cookie_secure() -> bool:
242+
return settings.environment not in {"local", "development"}
243+
244+
245+
async def _exchange_github_code_for_token(code: str, redirect_uri: str) -> dict[str, str]:
246+
client_id = settings.github_client_id
247+
client_secret = settings.github_client_secret
248+
if not client_id or not client_secret:
249+
raise RuntimeError("GitHub OAuth 未配置。")
250+
251+
data = {
252+
"client_id": client_id,
253+
"client_secret": client_secret,
254+
"code": code,
255+
"redirect_uri": redirect_uri,
256+
}
257+
258+
async with httpx.AsyncClient(timeout=15.0, headers={"Accept": "application/json"}) as client:
259+
response = await client.post(GITHUB_TOKEN_URL, data=data)
260+
response.raise_for_status()
261+
return response.json()
262+
263+
264+
async def _fetch_github_profile(access_token: str) -> tuple[dict[str, str], str | None]:
265+
headers = {
266+
"Accept": "application/json",
267+
"Authorization": f"Bearer {access_token}",
268+
}
269+
async with httpx.AsyncClient(timeout=15.0) as client:
270+
user_resp = await client.get(GITHUB_USER_API, headers=headers)
271+
user_resp.raise_for_status()
272+
user_data = user_resp.json()
273+
email = user_data.get("email")
274+
if not email:
275+
emails_resp = await client.get(GITHUB_EMAILS_API, headers=headers)
276+
emails_resp.raise_for_status()
277+
emails = emails_resp.json()
278+
email = next(
279+
(
280+
item.get("email")
281+
for item in emails
282+
if item.get("primary") and item.get("verified")
283+
),
284+
None,
285+
) or next((item.get("email") for item in emails if item.get("verified")), None)
286+
return user_data, email
287+
288+
289+
def _build_frontend_redirect(*, token: str | None, next_path: str) -> str:
290+
params = {"next": _sanitize_next_path(next_path)}
291+
if token:
292+
params["token"] = token
293+
params["provider"] = "github"
294+
return f"{settings.frontend_oauth_redirect_url}?{urlencode(params)}"
295+
296+
297+
def _oauth_error_redirect(message: str, *, next_path: str = "/") -> RedirectResponse:
298+
params = {
299+
"error": message,
300+
"next": _sanitize_next_path(next_path),
301+
}
302+
response = RedirectResponse(f"{settings.frontend_oauth_redirect_url}?{urlencode(params)}", status_code=status.HTTP_302_FOUND)
303+
response.delete_cookie(GITHUB_STATE_COOKIE)
304+
return response

0 commit comments

Comments
 (0)