from __future__ import annotations
import hmac
import html
from urllib.parse import parse_qs, quote
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
from fastapi.security import HTTPBasicCredentials
from ..auth import (
configured_password,
is_admin_authenticated,
is_records_authenticated,
security,
session_token,
verify_any_auth,
)
from ..config import (
ACCOUNTS_SESSION_COOKIE,
ADMIN_AUTH_PASSWORD,
ADMIN_SESSION_COOKIE,
BASIC_AUTH_PASSWORD,
RECORDS_SESSION_COOKIE,
SESSION_MAX_AGE,
STATIC_DIR,
)
router = APIRouter()
def safe_next_path(value: str | None) -> str:
if not value or not value.startswith("/") or value.startswith("//"):
return "/"
return value
def render_login_page(title: str, action: str, next_path: str = "/", has_error: bool = False) -> str:
escaped_title = html.escape(title)
escaped_action = html.escape(action, quote=True)
escaped_next = html.escape(next_path, quote=True)
error_html = '
密码不正确,请重新输入。
' if has_error else '请输入访问密码。
'
return f"""
{escaped_title}
{escaped_title}
{error_html}
"""
@router.get("/")
def index(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
if not is_records_authenticated(request, credentials):
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return FileResponse(STATIC_DIR / "index.html")
@router.head("/")
def index_head(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
if not is_records_authenticated(request, credentials):
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return Response(status_code=status.HTTP_200_OK)
@router.get("/login")
def login_page(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
next_path = safe_next_path(request.query_params.get("next"))
has_error = request.query_params.get("error") == "1"
if is_records_authenticated(request, credentials):
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
return HTMLResponse(
render_login_page(
title="新时空教务管理系统",
action="/login",
next_path=next_path,
has_error=has_error,
)
)
@router.post("/login")
async def login_submit(request: Request):
body = (await request.body()).decode("utf-8")
form = parse_qs(body, keep_blank_values=True)
password = form.get("password", [""])[0]
next_path = safe_next_path(form.get("next", ["/"])[0])
records_password = configured_password("课程记录", BASIC_AUTH_PASSWORD)
if hmac.compare_digest(password, records_password):
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
RECORDS_SESSION_COOKIE,
session_token(records_password, b"xsk-education-management-records-session-v1"),
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
)
return response
error_url = f"/login?error=1&next={quote(next_path)}"
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/logout")
def logout():
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(RECORDS_SESSION_COOKIE)
return response
@router.get("/accounts")
def accounts_index():
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin")
def admin_index(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
if not is_admin_authenticated(request, credentials):
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
return FileResponse(STATIC_DIR / "admin.html")
@router.get("/admin/login")
def admin_login_page(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
next_path = safe_next_path(request.query_params.get("next") or "/admin")
has_error = request.query_params.get("error") == "1"
if is_admin_authenticated(request, credentials):
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
return HTMLResponse(render_login_page("管理后台", "/admin/login", next_path, has_error))
@router.post("/admin/login")
async def admin_login_submit(request: Request):
body = (await request.body()).decode("utf-8")
form = parse_qs(body, keep_blank_values=True)
password = form.get("password", [""])[0]
next_path = safe_next_path(form.get("next", ["/admin"])[0])
admin_password = configured_password("管理后台", ADMIN_AUTH_PASSWORD)
if hmac.compare_digest(password, admin_password):
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
ADMIN_SESSION_COOKIE,
session_token(admin_password, b"xsk-admin-web-session-v1"),
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
)
return response
error_url = f"/admin/login?error=1&next={quote(next_path)}"
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin/logout")
def admin_logout():
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(ADMIN_SESSION_COOKIE)
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
return response
@router.get("/accounts/login")
def accounts_login_page():
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/accounts/login")
async def accounts_login_submit(request: Request):
return await admin_login_submit(request)
@router.get("/accounts/logout")
def accounts_logout():
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
response.delete_cookie(ADMIN_SESSION_COOKIE)
return response
@router.get("/static/{asset_path:path}")
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
target = (STATIC_DIR / asset_path).resolve()
static_root = STATIC_DIR.resolve()
if not target.is_file() or static_root not in target.parents:
raise HTTPException(status_code=404, detail="静态资源不存在")
return FileResponse(target)