mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-04-24 10:31:09 +08:00
Merge branch 'feature/passkey-login' into feature/ui-redesign
# Conflicts: # webui/src/components/ab-bangumi-card.vue # webui/src/components/ab-container.vue # webui/src/components/ab-fold-panel.vue # webui/src/components/ab-search-bar.vue # webui/src/components/basic/ab-search.vue # webui/src/components/basic/ab-tag.vue # webui/src/components/layout/ab-topbar.vue # webui/src/pages/index.vue # webui/src/pages/index/bangumi.vue # webui/src/pages/index/config.vue # webui/src/pages/index/player.vue # webui/src/pages/login.vue # webui/types/dts/auto-imports.d.ts # webui/vite.config.ts
This commit is contained in:
@@ -1,15 +1,64 @@
|
||||
[project]
|
||||
name = "auto-bangumi"
|
||||
version = "3.1.0"
|
||||
description = "AutoBangumi - Automated anime download manager"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"anyio>=4.0.0",
|
||||
"beautifulsoup4>=4.12.0",
|
||||
"certifi>=2023.5.7",
|
||||
"charset-normalizer>=3.1.0",
|
||||
"click>=8.1.3",
|
||||
"fastapi>=0.109.0",
|
||||
"h11>=0.14.0",
|
||||
"idna>=3.4",
|
||||
"pydantic>=2.0.0",
|
||||
"sniffio>=1.3.0",
|
||||
"soupsieve>=2.4.1",
|
||||
"typing_extensions>=4.0.0",
|
||||
"urllib3>=2.0.3",
|
||||
"uvicorn>=0.27.0",
|
||||
"Jinja2>=3.1.2",
|
||||
"python-dotenv>=1.0.0",
|
||||
"python-jose>=3.3.0",
|
||||
"passlib>=1.7.4",
|
||||
"bcrypt>=4.0.1,<4.1",
|
||||
"python-multipart>=0.0.6",
|
||||
"sqlmodel>=0.0.14",
|
||||
"sse-starlette>=1.6.5",
|
||||
"semver>=3.0.1",
|
||||
"openai>=1.54.3",
|
||||
"httpx>=0.25.0",
|
||||
"httpx-socks>=0.9.0",
|
||||
"aiosqlite>=0.19.0",
|
||||
"sqlalchemy[asyncio]>=2.0.0",
|
||||
"webauthn>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"ruff>=0.1.0",
|
||||
"black>=24.0.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["src/test"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
select = [
|
||||
# pycodestyle(E): https://beta.ruff.rs/docs/rules/#pycodestyle-e-w
|
||||
"E",
|
||||
"E",
|
||||
# Pyflakes(F): https://beta.ruff.rs/docs/rules/#pyflakes-f
|
||||
"F",
|
||||
"F",
|
||||
# isort(I): https://beta.ruff.rs/docs/rules/#isort-i
|
||||
"I"
|
||||
]
|
||||
ignore = [
|
||||
# E501: https://beta.ruff.rs/docs/rules/line-too-long/
|
||||
'E501',
|
||||
'E501',
|
||||
# F401: https://beta.ruff.rs/docs/rules/unused-import/
|
||||
# avoid unused imports lint in `__init__.py`
|
||||
'F401',
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
anyio==3.7.0
|
||||
anyio>=4.0.0
|
||||
bs4==0.0.1
|
||||
certifi==2023.5.7
|
||||
charset-normalizer==3.1.0
|
||||
click==8.1.3
|
||||
fastapi==0.97.0
|
||||
h11==0.14.0
|
||||
idna==3.4
|
||||
pydantic~=1.10
|
||||
PySocks==1.7.1
|
||||
qbittorrent-api==2023.9.53
|
||||
requests==2.31.0
|
||||
six==1.16.0
|
||||
sniffio==1.3.0
|
||||
soupsieve==2.4.1
|
||||
typing_extensions==4.6.3
|
||||
urllib3==2.0.3
|
||||
uvicorn==0.22.0
|
||||
attrdict==2.0.1
|
||||
Jinja2==3.1.2
|
||||
python-dotenv==1.0.0
|
||||
python-jose==3.3.0
|
||||
passlib==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.6
|
||||
sqlmodel==0.0.8
|
||||
sse-starlette==1.6.5
|
||||
semver==3.0.1
|
||||
openai==0.28.1
|
||||
certifi>=2023.5.7
|
||||
charset-normalizer>=3.1.0
|
||||
click>=8.1.3
|
||||
fastapi>=0.109.0
|
||||
h11>=0.14.0
|
||||
idna>=3.4
|
||||
pydantic>=2.0.0
|
||||
six>=1.16.0
|
||||
sniffio>=1.3.0
|
||||
soupsieve>=2.4.1
|
||||
typing_extensions>=4.0.0
|
||||
urllib3>=2.0.3
|
||||
uvicorn>=0.27.0
|
||||
Jinja2>=3.1.2
|
||||
python-dotenv>=1.0.0
|
||||
python-jose>=3.3.0
|
||||
passlib>=1.7.4
|
||||
bcrypt>=4.0.1
|
||||
python-multipart>=0.0.6
|
||||
sqlmodel>=0.0.14
|
||||
sse-starlette>=1.6.5
|
||||
semver>=3.0.1
|
||||
openai>=1.54.3
|
||||
httpx>=0.25.0
|
||||
httpx-socks>=0.9.0
|
||||
aiosqlite>=0.19.0
|
||||
sqlalchemy[asyncio]>=2.0.0
|
||||
webauthn>=2.0.0
|
||||
|
||||
@@ -40,6 +40,9 @@ app = create_app()
|
||||
|
||||
@app.get("/posters/{path:path}", tags=["posters"])
|
||||
def posters(path: str):
|
||||
# only allow access to files in the posters directory
|
||||
if not path.startswith("posters/"):
|
||||
return HTMLResponse(status_code=403)
|
||||
return FileResponse(f"data/posters/{path}")
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from .auth import router as auth_router
|
||||
from .bangumi import router as bangumi_router
|
||||
from .config import router as config_router
|
||||
from .log import router as log_router
|
||||
from .passkey import router as passkey_router
|
||||
from .program import router as program_router
|
||||
from .rss import router as rss_router
|
||||
from .search import router as search_router
|
||||
@@ -13,6 +14,7 @@ __all__ = "v1"
|
||||
# API 1.0
|
||||
v1 = APIRouter(prefix="/v1")
|
||||
v1.include_router(auth_router)
|
||||
v1.include_router(passkey_router)
|
||||
v1.include_router(log_router)
|
||||
v1.include_router(program_router)
|
||||
v1.include_router(bangumi_router)
|
||||
|
||||
@@ -22,7 +22,7 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
@router.post("/login", response_model=dict)
|
||||
async def login(response: Response, form_data=Depends(OAuth2PasswordRequestForm)):
|
||||
user = User(username=form_data.username, password=form_data.password)
|
||||
resp = auth_user(user)
|
||||
resp = await auth_user(user)
|
||||
if resp.status:
|
||||
token = create_access_token(
|
||||
data={"sub": user.username}, expires_delta=timedelta(days=1)
|
||||
@@ -58,7 +58,7 @@ async def logout(response: Response):
|
||||
@router.post("/update", response_model=dict, dependencies=[Depends(get_current_user)])
|
||||
async def update_user(user_data: UserUpdate, response: Response):
|
||||
old_user = active_user[0]
|
||||
if update_user_info(user_data, old_user):
|
||||
if await update_user_info(user_data, old_user):
|
||||
token = create_access_token(
|
||||
data={"sub": old_user}, expires_delta=timedelta(days=1)
|
||||
)
|
||||
|
||||
232
backend/src/module/api/passkey.py
Normal file
232
backend/src/module/api/passkey.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Passkey 管理 API
|
||||
用于注册、列表、删除 Passkey 凭证
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from module.database import Database
|
||||
from module.models import APIResponse
|
||||
from module.models.passkey import (
|
||||
PasskeyAuthFinish,
|
||||
PasskeyAuthStart,
|
||||
PasskeyCreate,
|
||||
PasskeyDelete,
|
||||
PasskeyList,
|
||||
)
|
||||
from module.security.api import active_user, get_current_user
|
||||
from module.security.auth_strategy import PasskeyAuthStrategy
|
||||
from module.security.jwt import create_access_token
|
||||
from module.security.webauthn import get_webauthn_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/passkey", tags=["passkey"])
|
||||
|
||||
|
||||
def _get_webauthn_from_request(request: Request):
|
||||
"""
|
||||
从请求中构造 WebAuthnService
|
||||
优先使用浏览器的 Origin header(与 clientDataJSON 中的 origin 一致)
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
# Fallback: 从 Referer 或 Host 推断
|
||||
referer = request.headers.get("referer", "")
|
||||
if referer:
|
||||
parsed = urlparse(referer)
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
else:
|
||||
host = request.headers.get("host", "localhost:7892")
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto")
|
||||
scheme = forwarded_proto if forwarded_proto else request.url.scheme
|
||||
origin = f"{scheme}://{host}"
|
||||
|
||||
parsed_origin = urlparse(origin)
|
||||
rp_id = parsed_origin.hostname or "localhost"
|
||||
|
||||
return get_webauthn_service(rp_id, "AutoBangumi", origin)
|
||||
|
||||
|
||||
# ============ 注册流程 ============
|
||||
|
||||
|
||||
@router.post("/register/options", response_model=dict)
|
||||
async def get_registration_options(
|
||||
request: Request,
|
||||
username: str = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
生成 Passkey 注册选项
|
||||
前端调用 navigator.credentials.create() 时使用
|
||||
"""
|
||||
webauthn = _get_webauthn_from_request(request)
|
||||
|
||||
async with Database() as db:
|
||||
try:
|
||||
user = await db.user.get_user(username)
|
||||
existing_passkeys = await db.passkey.get_passkeys_by_user_id(user.id)
|
||||
|
||||
options = webauthn.generate_registration_options(
|
||||
username=username,
|
||||
user_id=user.id,
|
||||
existing_passkeys=existing_passkeys,
|
||||
)
|
||||
|
||||
return options
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate registration options: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/register/verify", response_model=APIResponse)
|
||||
async def verify_registration(
|
||||
passkey_data: PasskeyCreate,
|
||||
request: Request,
|
||||
username: str = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
验证 Passkey 注册响应并保存
|
||||
"""
|
||||
webauthn = _get_webauthn_from_request(request)
|
||||
|
||||
async with Database() as db:
|
||||
try:
|
||||
user = await db.user.get_user(username)
|
||||
|
||||
# 验证 WebAuthn 响应
|
||||
passkey = webauthn.verify_registration(
|
||||
username=username,
|
||||
credential=passkey_data.attestation_response,
|
||||
device_name=passkey_data.name,
|
||||
)
|
||||
|
||||
# 设置 user_id 并保存
|
||||
passkey.user_id = user.id
|
||||
await db.passkey.create_passkey(passkey)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"msg_en": f"Passkey '{passkey_data.name}' registered successfully",
|
||||
"msg_zh": f"Passkey '{passkey_data.name}' 注册成功",
|
||||
},
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Registration verification failed for {username}: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register passkey: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============ 认证流程 ============
|
||||
|
||||
|
||||
@router.post("/auth/options", response_model=dict)
|
||||
async def get_passkey_login_options(
|
||||
auth_data: PasskeyAuthStart,
|
||||
request: Request,
|
||||
):
|
||||
"""
|
||||
生成 Passkey 登录选项(challenge)
|
||||
前端先调用此接口,再调用 navigator.credentials.get()
|
||||
"""
|
||||
webauthn = _get_webauthn_from_request(request)
|
||||
|
||||
async with Database() as db:
|
||||
try:
|
||||
user = await db.user.get_user(auth_data.username)
|
||||
passkeys = await db.passkey.get_passkeys_by_user_id(user.id)
|
||||
|
||||
if not passkeys:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No passkeys registered for this user"
|
||||
)
|
||||
|
||||
options = webauthn.generate_authentication_options(
|
||||
auth_data.username, passkeys
|
||||
)
|
||||
return options
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate login options: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/auth/verify", response_model=dict)
|
||||
async def login_with_passkey(
|
||||
auth_data: PasskeyAuthFinish,
|
||||
response: Response,
|
||||
request: Request,
|
||||
):
|
||||
"""
|
||||
使用 Passkey 登录(替代密码登录)
|
||||
"""
|
||||
webauthn = _get_webauthn_from_request(request)
|
||||
|
||||
strategy = PasskeyAuthStrategy(webauthn)
|
||||
resp = await strategy.authenticate(auth_data.username, auth_data.credential)
|
||||
|
||||
if resp.status:
|
||||
token = create_access_token(
|
||||
data={"sub": auth_data.username}, expires_delta=timedelta(days=1)
|
||||
)
|
||||
response.set_cookie(key="token", value=token, httponly=True, max_age=86400)
|
||||
if auth_data.username not in active_user:
|
||||
active_user.append(auth_data.username)
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
|
||||
raise HTTPException(status_code=resp.status_code, detail=resp.msg_en)
|
||||
|
||||
|
||||
# ============ Passkey 管理 ============
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[PasskeyList])
|
||||
async def list_passkeys(username: str = Depends(get_current_user)):
|
||||
"""获取用户的所有 Passkey"""
|
||||
async with Database() as db:
|
||||
try:
|
||||
user = await db.user.get_user(username)
|
||||
passkeys = await db.passkey.get_passkeys_by_user_id(user.id)
|
||||
|
||||
return [db.passkey.to_list_model(pk) for pk in passkeys]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list passkeys: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/delete", response_model=APIResponse)
|
||||
async def delete_passkey(
|
||||
delete_data: PasskeyDelete,
|
||||
username: str = Depends(get_current_user),
|
||||
):
|
||||
"""删除 Passkey"""
|
||||
async with Database() as db:
|
||||
try:
|
||||
user = await db.user.get_user(username)
|
||||
await db.passkey.delete_passkey(delete_data.passkey_id, user.id)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"msg_en": "Passkey deleted successfully",
|
||||
"msg_zh": "Passkey 删除成功",
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete passkey: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -19,12 +19,12 @@ router = APIRouter(tags=["program"])
|
||||
|
||||
@router.on_event("startup")
|
||||
async def startup():
|
||||
program.startup()
|
||||
await program.startup()
|
||||
|
||||
|
||||
@router.on_event("shutdown")
|
||||
async def shutdown():
|
||||
program.stop()
|
||||
await program.stop()
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -32,7 +32,7 @@ async def shutdown():
|
||||
)
|
||||
async def restart():
|
||||
try:
|
||||
resp = program.restart()
|
||||
resp = await program.restart()
|
||||
return u_response(resp)
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
@@ -51,7 +51,7 @@ async def restart():
|
||||
)
|
||||
async def start():
|
||||
try:
|
||||
resp = program.start()
|
||||
resp = await program.start()
|
||||
return u_response(resp)
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
@@ -69,7 +69,8 @@ async def start():
|
||||
"/stop", response_model=APIResponse, dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
async def stop():
|
||||
return u_response(program.stop())
|
||||
resp = await program.stop()
|
||||
return u_response(resp)
|
||||
|
||||
|
||||
@router.get("/status", response_model=dict, dependencies=[Depends(get_current_user)])
|
||||
@@ -92,12 +93,15 @@ async def program_status():
|
||||
"/shutdown", response_model=APIResponse, dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
async def shutdown_program():
|
||||
program.stop()
|
||||
await program.stop()
|
||||
logger.info("Shutting down program...")
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"msg_en": "Shutdown program successfully.", "msg_zh": "关闭程序成功。"},
|
||||
content={
|
||||
"msg_en": "Shutdown program successfully.",
|
||||
"msg_zh": "关闭程序成功。",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -109,4 +113,4 @@ async def shutdown_program():
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def check_downloader_status():
|
||||
return program.check_downloader()
|
||||
return await program.check_downloader()
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import httpx
|
||||
|
||||
from module.conf import VERSION, settings
|
||||
from module.downloader import DownloadClient
|
||||
from module.models import Config
|
||||
from module.update import version_check
|
||||
|
||||
@@ -49,27 +48,28 @@ class Checker:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def check_downloader() -> bool:
|
||||
async def check_downloader() -> bool:
|
||||
from module.downloader import DownloadClient
|
||||
try:
|
||||
url = (
|
||||
f"http://{settings.downloader.host}"
|
||||
if "://" not in settings.downloader.host
|
||||
else f"{settings.downloader.host}"
|
||||
)
|
||||
response = requests.get(url, timeout=2)
|
||||
# if settings.downloader.type in response.text.lower():
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
response = await client.get(url)
|
||||
if "qbittorrent" in response.text.lower() or "vuetorrent" in response.text.lower():
|
||||
with DownloadClient() as client:
|
||||
if client.authed:
|
||||
async with DownloadClient() as dl_client:
|
||||
if dl_client.authed:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
except requests.exceptions.ReadTimeout:
|
||||
except httpx.TimeoutException:
|
||||
logger.error("[Checker] Downloader connect timeout.")
|
||||
return False
|
||||
except requests.exceptions.ConnectionError:
|
||||
except httpx.ConnectError:
|
||||
logger.error("[Checker] Downloader connect failed.")
|
||||
return False
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from module.conf import VERSION, settings
|
||||
from module.models import ResponseModel
|
||||
from module.update import data_migration, first_run, from_30_to_31, start_up, cache_image
|
||||
from module.update import (
|
||||
data_migration,
|
||||
first_run,
|
||||
from_30_to_31,
|
||||
start_up,
|
||||
cache_image,
|
||||
)
|
||||
|
||||
from .sub_thread import RenameThread, RSSThread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
figlet = r"""
|
||||
_ ____ _
|
||||
/\ | | | _ \ (_)
|
||||
/ \ _ _| |_ ___ | |_) | __ _ _ __ __ _ _ _ _ __ ___ _
|
||||
/ /\ \| | | | __/ _ \| _ < / _` | '_ \ / _` | | | | '_ ` _ \| |
|
||||
/ ____ \ |_| | || (_) | |_) | (_| | | | | (_| | |_| | | | | | | |
|
||||
/_/ \_\__,_|\__\___/|____/ \__,_|_| |_|\__, |\__,_|_| |_| |_|_|
|
||||
__/ |
|
||||
|___/
|
||||
_ ____ _
|
||||
/\ | | | _ \ (_)
|
||||
/ \ _ _| |_ ___ | |_) | __ _ _ __ __ _ _ _ _ __ ___ _
|
||||
/ /\ \| | | | __/ _ \| _ < / _` | '_ \ / _` | | | | '_ ` _ \| |
|
||||
/ ____ \ |_| | || (_) | |_) | (_| | | | | (_| | |_| | | | | | | |
|
||||
/_/ \_\__,_|\__\___/|____/ \__,_|_| |_|\__, |\__,_|_| |_| |_|_|
|
||||
__/ |
|
||||
|___/
|
||||
"""
|
||||
|
||||
|
||||
@@ -31,7 +38,7 @@ class Program(RenameThread, RSSThread):
|
||||
logger.info("GitHub: https://github.com/EstrellaXD/Auto_Bangumi/")
|
||||
logger.info("Starting AutoBangumi...")
|
||||
|
||||
def startup(self):
|
||||
async def startup(self):
|
||||
self.__start_info()
|
||||
if not self.database:
|
||||
first_run()
|
||||
@@ -49,38 +56,32 @@ class Program(RenameThread, RSSThread):
|
||||
if not self.img_cache:
|
||||
logger.info("[Core] No image cache exists, create image cache.")
|
||||
cache_image()
|
||||
self.start()
|
||||
await self.start()
|
||||
|
||||
def start(self):
|
||||
async def start(self):
|
||||
self.stop_event.clear()
|
||||
settings.load()
|
||||
if self.downloader_status:
|
||||
if self.enable_renamer:
|
||||
self.rename_start()
|
||||
if self.enable_rss:
|
||||
self.rss_start()
|
||||
logger.info("Program running.")
|
||||
return ResponseModel(
|
||||
status=True,
|
||||
status_code=200,
|
||||
msg_en="Program started.",
|
||||
msg_zh="程序启动成功。",
|
||||
)
|
||||
else:
|
||||
self.stop_event.set()
|
||||
logger.warning("Program failed to start.")
|
||||
return ResponseModel(
|
||||
status=False,
|
||||
status_code=406,
|
||||
msg_en="Program failed to start.",
|
||||
msg_zh="程序启动失败。",
|
||||
)
|
||||
while not await self.check_downloader_status():
|
||||
logger.warning("Downloader is not running.")
|
||||
logger.info("Waiting for downloader to start.")
|
||||
await asyncio.sleep(30)
|
||||
if self.enable_renamer:
|
||||
self.rename_start()
|
||||
if self.enable_rss:
|
||||
self.rss_start()
|
||||
logger.info("Program running.")
|
||||
return ResponseModel(
|
||||
status=True,
|
||||
status_code=200,
|
||||
msg_en="Program started.",
|
||||
msg_zh="程序启动成功。",
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
async def stop(self):
|
||||
if self.is_running:
|
||||
self.stop_event.set()
|
||||
self.rename_stop()
|
||||
self.rss_stop()
|
||||
await self.rename_stop()
|
||||
await self.rss_stop()
|
||||
return ResponseModel(
|
||||
status=True,
|
||||
status_code=200,
|
||||
@@ -95,9 +96,9 @@ class Program(RenameThread, RSSThread):
|
||||
msg_zh="程序未运行。",
|
||||
)
|
||||
|
||||
def restart(self):
|
||||
self.stop()
|
||||
self.start()
|
||||
async def restart(self):
|
||||
await self.stop()
|
||||
await self.start()
|
||||
return ResponseModel(
|
||||
status=True,
|
||||
status_code=200,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
from module.checker import Checker
|
||||
from module.conf import LEGACY_DATA_PATH
|
||||
@@ -8,8 +7,8 @@ from module.conf import LEGACY_DATA_PATH
|
||||
class ProgramStatus(Checker):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.stop_event = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
self.stop_event = asyncio.Event()
|
||||
self.lock = asyncio.Lock()
|
||||
self._downloader_status = False
|
||||
self._torrents_status = False
|
||||
self.event = asyncio.Event()
|
||||
@@ -27,8 +26,11 @@ class ProgramStatus(Checker):
|
||||
|
||||
@property
|
||||
def downloader_status(self):
|
||||
return self._downloader_status
|
||||
|
||||
async def check_downloader_status(self) -> bool:
|
||||
if not self._downloader_status:
|
||||
self._downloader_status = self.check_downloader()
|
||||
self._downloader_status = await self.check_downloader()
|
||||
return self._downloader_status
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,42 +1,85 @@
|
||||
from sqlmodel import Session, SQLModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from module.models import Bangumi, User
|
||||
from module.models import Bangumi, Passkey, User
|
||||
|
||||
from .bangumi import BangumiDatabase
|
||||
from .engine import engine as e
|
||||
from .engine import async_engine, async_session_factory, engine as e
|
||||
from .passkey import PasskeyDatabase
|
||||
from .rss import RSSDatabase
|
||||
from .torrent import TorrentDatabase
|
||||
from .user import UserDatabase
|
||||
|
||||
|
||||
class Database(Session):
|
||||
def __init__(self, engine=e):
|
||||
self.engine = engine
|
||||
super().__init__(engine)
|
||||
self.rss = RSSDatabase(self)
|
||||
self.torrent = TorrentDatabase(self)
|
||||
self.bangumi = BangumiDatabase(self)
|
||||
self.user = UserDatabase(self)
|
||||
class Database:
|
||||
def __init__(self):
|
||||
self._session = None
|
||||
self.rss: RSSDatabase | None = None
|
||||
self.torrent: TorrentDatabase | None = None
|
||||
self.bangumi: BangumiDatabase | None = None
|
||||
self.user: UserDatabase | None = None
|
||||
self.passkey: PasskeyDatabase | None = None
|
||||
|
||||
def create_table(self):
|
||||
SQLModel.metadata.create_all(self.engine)
|
||||
# Sync context manager (for legacy code)
|
||||
def __enter__(self):
|
||||
from .engine import db_session
|
||||
|
||||
def drop_table(self):
|
||||
SQLModel.metadata.drop_all(self.engine)
|
||||
self._session = db_session
|
||||
self.rss = RSSDatabase(self._session)
|
||||
self.torrent = TorrentDatabase(self._session)
|
||||
self.bangumi = BangumiDatabase(self._session)
|
||||
self.user = UserDatabase(self._session)
|
||||
return self
|
||||
|
||||
def migrate(self):
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
# Async context manager (for passkey and new async code)
|
||||
async def __aenter__(self):
|
||||
self._session = async_session_factory()
|
||||
self.rss = RSSDatabase(self._session)
|
||||
self.torrent = TorrentDatabase(self._session)
|
||||
self.bangumi = BangumiDatabase(self._session)
|
||||
self.user = UserDatabase(self._session)
|
||||
self.passkey = PasskeyDatabase(self._session)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if self._session and isinstance(self._session, AsyncSession):
|
||||
await self._session.close()
|
||||
|
||||
async def create_table(self):
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
async def drop_table(self):
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.drop_all)
|
||||
|
||||
async def commit(self):
|
||||
if self._session:
|
||||
if isinstance(self._session, AsyncSession):
|
||||
await self._session.commit()
|
||||
else:
|
||||
self._session.commit()
|
||||
|
||||
async def add(self, obj):
|
||||
if self._session:
|
||||
self._session.add(obj)
|
||||
if isinstance(self._session, AsyncSession):
|
||||
await self._session.commit()
|
||||
else:
|
||||
self._session.commit()
|
||||
|
||||
async def migrate(self):
|
||||
# Run migration online
|
||||
bangumi_data = self.bangumi.search_all()
|
||||
user_data = self.exec("SELECT * FROM user").all()
|
||||
bangumi_data = await self.bangumi.search_all()
|
||||
readd_bangumi = []
|
||||
for bangumi in bangumi_data:
|
||||
dict_data = bangumi.dict()
|
||||
del dict_data["id"]
|
||||
readd_bangumi.append(Bangumi(**dict_data))
|
||||
self.drop_table()
|
||||
self.create_table()
|
||||
self.commit()
|
||||
bangumi_data = self.bangumi.search_all()
|
||||
self.bangumi.add_all(readd_bangumi)
|
||||
self.add(User(**user_data[0]))
|
||||
self.commit()
|
||||
await self.drop_table()
|
||||
await self.create_table()
|
||||
await self.commit()
|
||||
await self.bangumi.add_all(readd_bangumi)
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlmodel import Session, create_engine
|
||||
|
||||
from module.conf import DATA_PATH
|
||||
|
||||
# Sync engine (for legacy code)
|
||||
engine = create_engine(DATA_PATH)
|
||||
|
||||
db_session = Session(engine)
|
||||
|
||||
# Async engine (for passkey and new async code)
|
||||
ASYNC_DATA_PATH = DATA_PATH.replace("sqlite:///", "sqlite+aiosqlite:///")
|
||||
async_engine = create_async_engine(ASYNC_DATA_PATH)
|
||||
async_session_factory = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
78
backend/src/module/database/passkey.py
Normal file
78
backend/src/module/database/passkey.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Passkey 数据库操作层
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from module.models.passkey import Passkey, PasskeyList
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PasskeyDatabase:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def create_passkey(self, passkey: Passkey) -> Passkey:
|
||||
"""创建新的 Passkey 凭证"""
|
||||
self.session.add(passkey)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(passkey)
|
||||
logger.info(f"Created passkey '{passkey.name}' for user_id={passkey.user_id}")
|
||||
return passkey
|
||||
|
||||
async def get_passkey_by_credential_id(
|
||||
self, credential_id: str
|
||||
) -> Optional[Passkey]:
|
||||
"""通过 credential_id 查找 Passkey(用于认证)"""
|
||||
statement = select(Passkey).where(Passkey.credential_id == credential_id)
|
||||
result = await self.session.execute(statement)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_passkeys_by_user_id(self, user_id: int) -> List[Passkey]:
|
||||
"""获取用户的所有 Passkey"""
|
||||
statement = select(Passkey).where(Passkey.user_id == user_id)
|
||||
result = await self.session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_passkey_by_id(self, passkey_id: int, user_id: int) -> Passkey:
|
||||
"""获取特定 Passkey(带权限检查)"""
|
||||
statement = select(Passkey).where(
|
||||
Passkey.id == passkey_id, Passkey.user_id == user_id
|
||||
)
|
||||
result = await self.session.execute(statement)
|
||||
passkey = result.scalar_one_or_none()
|
||||
if not passkey:
|
||||
raise HTTPException(status_code=404, detail="Passkey not found")
|
||||
return passkey
|
||||
|
||||
async def update_passkey_usage(self, passkey: Passkey, new_sign_count: int):
|
||||
"""更新 Passkey 使用记录(签名计数器 + 最后使用时间)"""
|
||||
passkey.sign_count = new_sign_count
|
||||
passkey.last_used_at = datetime.utcnow()
|
||||
self.session.add(passkey)
|
||||
await self.session.commit()
|
||||
|
||||
async def delete_passkey(self, passkey_id: int, user_id: int) -> bool:
|
||||
"""删除 Passkey"""
|
||||
passkey = await self.get_passkey_by_id(passkey_id, user_id)
|
||||
await self.session.delete(passkey)
|
||||
await self.session.commit()
|
||||
logger.info(f"Deleted passkey id={passkey_id} for user_id={user_id}")
|
||||
return True
|
||||
|
||||
def to_list_model(self, passkey: Passkey) -> PasskeyList:
|
||||
"""转换为安全的列表展示模型"""
|
||||
return PasskeyList(
|
||||
id=passkey.id,
|
||||
name=passkey.name,
|
||||
created_at=passkey.created_at,
|
||||
last_used_at=passkey.last_used_at,
|
||||
backup_eligible=passkey.backup_eligible,
|
||||
aaguid=passkey.aaguid,
|
||||
)
|
||||
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import Session, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from module.models import ResponseModel
|
||||
from module.models.user import User, UserLogin, UserUpdate
|
||||
@@ -11,28 +12,36 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserDatabase:
|
||||
def __init__(self, session: Session):
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def get_user(self, username):
|
||||
async def get_user(self, username):
|
||||
statement = select(User).where(User.username == username)
|
||||
result = self.session.exec(statement).first()
|
||||
if not result:
|
||||
if isinstance(self.session, AsyncSession):
|
||||
result = await self.session.execute(statement)
|
||||
user = result.scalar_one_or_none()
|
||||
else:
|
||||
user = self.session.exec(statement).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return result
|
||||
return user
|
||||
|
||||
def auth_user(self, user: User):
|
||||
async def auth_user(self, user: User):
|
||||
statement = select(User).where(User.username == user.username)
|
||||
result = self.session.exec(statement).first()
|
||||
if isinstance(self.session, AsyncSession):
|
||||
result = await self.session.execute(statement)
|
||||
db_user = result.scalar_one_or_none()
|
||||
else:
|
||||
db_user = self.session.exec(statement).first()
|
||||
if not user.password:
|
||||
return ResponseModel(
|
||||
status_code=401, status=False, msg_en="Incorrect password format", msg_zh="密码格式不正确"
|
||||
)
|
||||
if not result:
|
||||
if not db_user:
|
||||
return ResponseModel(
|
||||
status_code=401, status=False, msg_en="User not found", msg_zh="用户不存在"
|
||||
)
|
||||
if not verify_password(user.password, result.password):
|
||||
if not verify_password(user.password, db_user.password):
|
||||
return ResponseModel(
|
||||
status_code=401,
|
||||
status=False,
|
||||
@@ -43,36 +52,59 @@ class UserDatabase:
|
||||
status_code=200, status=True, msg_en="Login successfully", msg_zh="登录成功"
|
||||
)
|
||||
|
||||
def update_user(self, username, update_user: UserUpdate):
|
||||
# Update username and password
|
||||
async def update_user(self, username, update_user: UserUpdate):
|
||||
statement = select(User).where(User.username == username)
|
||||
result = self.session.exec(statement).first()
|
||||
if not result:
|
||||
if isinstance(self.session, AsyncSession):
|
||||
result = await self.session.execute(statement)
|
||||
db_user = result.scalar_one_or_none()
|
||||
else:
|
||||
db_user = self.session.exec(statement).first()
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if update_user.username:
|
||||
result.username = update_user.username
|
||||
db_user.username = update_user.username
|
||||
if update_user.password:
|
||||
result.password = get_password_hash(update_user.password)
|
||||
self.session.add(result)
|
||||
self.session.commit()
|
||||
return result
|
||||
db_user.password = get_password_hash(update_user.password)
|
||||
self.session.add(db_user)
|
||||
if isinstance(self.session, AsyncSession):
|
||||
await self.session.commit()
|
||||
else:
|
||||
self.session.commit()
|
||||
return db_user
|
||||
|
||||
async def add_default_user(self):
|
||||
statement = select(User)
|
||||
if isinstance(self.session, AsyncSession):
|
||||
result = await self.session.execute(statement)
|
||||
users = list(result.scalars().all())
|
||||
else:
|
||||
try:
|
||||
users = self.session.exec(statement).all()
|
||||
except Exception:
|
||||
self.merge_old_user()
|
||||
users = self.session.exec(statement).all()
|
||||
if len(users) != 0:
|
||||
return
|
||||
user = User(username="admin", password=get_password_hash("adminadmin"))
|
||||
self.session.add(user)
|
||||
if isinstance(self.session, AsyncSession):
|
||||
await self.session.commit()
|
||||
else:
|
||||
self.session.commit()
|
||||
|
||||
def merge_old_user(self):
|
||||
# get old data
|
||||
# Legacy migration - sync only
|
||||
statement = """
|
||||
SELECT * FROM user
|
||||
"""
|
||||
result = self.session.exec(statement).first()
|
||||
if not result:
|
||||
return
|
||||
# add new data
|
||||
user = User(username=result.username, password=result.password)
|
||||
# Drop old table
|
||||
statement = """
|
||||
DROP TABLE user
|
||||
"""
|
||||
self.session.exec(statement)
|
||||
# Create new table
|
||||
statement = """
|
||||
CREATE TABLE user (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
@@ -83,18 +115,3 @@ class UserDatabase:
|
||||
self.session.exec(statement)
|
||||
self.session.add(user)
|
||||
self.session.commit()
|
||||
|
||||
def add_default_user(self):
|
||||
# Check if user exists
|
||||
statement = select(User)
|
||||
try:
|
||||
result = self.session.exec(statement).all()
|
||||
except Exception:
|
||||
self.merge_old_user()
|
||||
result = self.session.exec(statement).all()
|
||||
if len(result) != 0:
|
||||
return
|
||||
# Add default user
|
||||
user = User(username="admin", password=get_password_hash("adminadmin"))
|
||||
self.session.add(user)
|
||||
self.session.commit()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .bangumi import Bangumi, BangumiUpdate, Episode, Notification
|
||||
from .config import Config
|
||||
from .passkey import Passkey, PasskeyCreate, PasskeyDelete, PasskeyList
|
||||
from .response import APIResponse, ResponseModel
|
||||
from .rss import RSSItem, RSSUpdate
|
||||
from .torrent import EpisodeFile, SubtitleFile, Torrent, TorrentUpdate
|
||||
|
||||
75
backend/src/module/models/passkey.py
Normal file
75
backend/src/module/models/passkey.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
WebAuthn Passkey 数据模型
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class Passkey(SQLModel, table=True):
|
||||
"""存储 WebAuthn 凭证的数据库模型"""
|
||||
|
||||
__tablename__ = "passkey"
|
||||
|
||||
id: int = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
|
||||
# 用户友好的名称 (e.g., "iPhone 15", "MacBook Pro")
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
|
||||
# WebAuthn 核心字段
|
||||
credential_id: str = Field(unique=True, index=True) # Base64URL encoded
|
||||
public_key: str # CBOR encoded public key, Base64 stored
|
||||
sign_count: int = Field(default=0) # 防止克隆攻击
|
||||
|
||||
# 可选的设备信息
|
||||
aaguid: Optional[str] = None # Authenticator AAGUID
|
||||
transports: Optional[str] = None # JSON array: ["usb", "nfc", "ble", "internal"]
|
||||
|
||||
# 审计字段
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
last_used_at: Optional[datetime] = None
|
||||
|
||||
# 备份状态 (是否为多设备凭证,如 iCloud Keychain)
|
||||
backup_eligible: bool = Field(default=False)
|
||||
backup_state: bool = Field(default=False)
|
||||
|
||||
|
||||
class PasskeyCreate(BaseModel):
|
||||
"""创建 Passkey 的请求模型"""
|
||||
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
# 注册完成后的 WebAuthn 响应
|
||||
attestation_response: dict
|
||||
|
||||
|
||||
class PasskeyList(BaseModel):
|
||||
"""返回给前端的 Passkey 列表(不含敏感数据)"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
created_at: datetime
|
||||
last_used_at: Optional[datetime]
|
||||
backup_eligible: bool
|
||||
aaguid: Optional[str]
|
||||
|
||||
|
||||
class PasskeyDelete(BaseModel):
|
||||
"""删除 Passkey 请求"""
|
||||
|
||||
passkey_id: int
|
||||
|
||||
|
||||
class PasskeyAuthStart(BaseModel):
|
||||
"""Passkey 认证开始请求"""
|
||||
|
||||
username: str
|
||||
|
||||
|
||||
class PasskeyAuthFinish(BaseModel):
|
||||
"""Passkey 认证完成请求"""
|
||||
|
||||
username: str
|
||||
credential: dict
|
||||
@@ -23,7 +23,7 @@ class EpisodeFile(BaseModel):
|
||||
group: str | None = Field(None)
|
||||
title: str = Field(...)
|
||||
season: int = Field(...)
|
||||
episode: int = Field(None)
|
||||
episode: int | float = Field(None)
|
||||
suffix: str = Field(..., regex=r"\.(mkv|mp4|MKV|MP4)$")
|
||||
|
||||
|
||||
@@ -32,6 +32,6 @@ class SubtitleFile(BaseModel):
|
||||
group: str | None = Field(None)
|
||||
title: str = Field(...)
|
||||
season: int = Field(...)
|
||||
episode: int = Field(None)
|
||||
episode: int | float = Field(None)
|
||||
language: str = Field(..., regex=r"(zh|zh-tw)")
|
||||
suffix: str = Field(..., regex=r"\.(ass|srt|ASS|SRT)$")
|
||||
|
||||
@@ -15,7 +15,7 @@ class RequestContent(RequestURL):
|
||||
def get_torrents(
|
||||
self,
|
||||
_url: str,
|
||||
_filter: str = "|".join(settings.rss_parser.filter),
|
||||
_filter: str = None,
|
||||
limit: int = None,
|
||||
retry: int = 3,
|
||||
) -> list[Torrent]:
|
||||
@@ -23,6 +23,8 @@ class RequestContent(RequestURL):
|
||||
if soup:
|
||||
torrent_titles, torrent_urls, torrent_homepage = rss_parser(soup)
|
||||
torrents: list[Torrent] = []
|
||||
if _filter is None:
|
||||
_filter = "|".join(settings.rss_parser.filter)
|
||||
for _title, torrent_url, homepage in zip(
|
||||
torrent_titles, torrent_urls, torrent_homepage
|
||||
):
|
||||
|
||||
@@ -2,46 +2,33 @@ import json
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
import openai
|
||||
from openai import OpenAI, AzureOpenAI
|
||||
|
||||
from module.models import Bangumi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Episode(BaseModel):
|
||||
title_en: Optional[str]
|
||||
title_zh: Optional[str]
|
||||
title_jp: Optional[str]
|
||||
season: str
|
||||
season_raw: str
|
||||
episode: str
|
||||
sub: str
|
||||
group: str
|
||||
resolution: str
|
||||
source: str
|
||||
|
||||
|
||||
DEFAULT_PROMPT = """\
|
||||
You will now play the role of a super assistant.
|
||||
Your task is to extract structured data from unstructured text content and output it in JSON format.
|
||||
If you are unable to extract any information, please keep all fields and leave the field empty or default value like `''`, `None`.
|
||||
But Do not fabricate data!
|
||||
|
||||
the python structured data type is:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Episode:
|
||||
title_en: Optional[str]
|
||||
title_zh: Optional[str]
|
||||
title_jp: Optional[str]
|
||||
season: int
|
||||
season_raw: str
|
||||
episode: int
|
||||
sub: str
|
||||
group: str
|
||||
resolution: str
|
||||
source: str
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
input: "【喵萌奶茶屋】★04月新番★[夏日重现/Summer Time Rendering][11][1080p][繁日双语][招募翻译]"
|
||||
output: '{"group": "喵萌奶茶屋", "title_en": "Summer Time Rendering", "resolution": "1080p", "episode": 11, "season": 1, "title_zh": "夏日重现", "sub": "", "title_jp": "", "season_raw": "", "source": ""}'
|
||||
|
||||
input: "【幻樱字幕组】【4月新番】【古见同学有交流障碍症 第二季 Komi-san wa, Komyushou Desu. S02】【22】【GB_MP4】【1920X1080】"
|
||||
output: '{"group": "幻樱字幕组", "title_en": "Komi-san wa, Komyushou Desu.", "resolution": "1920X1080", "episode": 22, "season": 2, "title_zh": "古见同学有交流障碍症", "sub": "", "title_jp": "", "season_raw": "", "source": ""}'
|
||||
|
||||
input: "[Lilith-Raws] 关于我在无意间被隔壁的天使变成废柴这件事 / Otonari no Tenshi-sama - 09 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]"
|
||||
output: '{"group": "Lilith-Raws", "title_en": "Otonari no Tenshi-sama", "resolution": "1080p", "episode": 9, "season": 1, "source": "WEB-DL", "title_zh": "关于我在无意间被隔壁的天使变成废柴这件事", "sub": "CHT", "title_jp": ""}'
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
@@ -50,7 +37,8 @@ class OpenAIParser:
|
||||
self,
|
||||
api_key: str,
|
||||
api_base: str = "https://api.openai.com/v1",
|
||||
model: str = "gpt-3.5-turbo",
|
||||
model: str = "gpt-4o-mini",
|
||||
api_type: str = "openai",
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""OpenAIParser is a class to parse text with openai
|
||||
@@ -63,7 +51,7 @@ class OpenAIParser:
|
||||
model (str):
|
||||
the ChatGPT model parameter, you can get more details from \
|
||||
https://platform.openai.com/docs/api-reference/chat/create. \
|
||||
Defaults to "gpt-3.5-turbo".
|
||||
Defaults to "gpt-4o-mini".
|
||||
kwargs (dict):
|
||||
the OpenAI ChatGPT parameters, you can get more details from \
|
||||
https://platform.openai.com/docs/api-reference/chat/create.
|
||||
@@ -73,9 +61,16 @@ class OpenAIParser:
|
||||
"""
|
||||
if not api_key:
|
||||
raise ValueError("API key is required.")
|
||||
if api_type == "azure":
|
||||
self.client = AzureOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=api_base,
|
||||
azure_deployment=kwargs.get("deployment_id", ""),
|
||||
api_version=kwargs.get("api_version", "2023-05-15"),
|
||||
)
|
||||
else:
|
||||
self.client = OpenAI(api_key=api_key, base_url=api_base)
|
||||
|
||||
self._api_key = api_key
|
||||
self.api_base = api_base
|
||||
self.model = model
|
||||
self.openai_kwargs = kwargs
|
||||
|
||||
@@ -102,10 +97,10 @@ class OpenAIParser:
|
||||
params = self._prepare_params(text, prompt)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as worker:
|
||||
future = worker.submit(openai.ChatCompletion.create, **params)
|
||||
future = worker.submit(self.client.beta.chat.completions.parse, **params)
|
||||
resp = future.result()
|
||||
|
||||
result = resp["choices"][0]["message"]["content"]
|
||||
result = resp.choices[0].message.parsed
|
||||
|
||||
if asdict:
|
||||
try:
|
||||
@@ -130,12 +125,13 @@ class OpenAIParser:
|
||||
dict[str, Any]: the prepared key value pairs.
|
||||
"""
|
||||
params = dict(
|
||||
api_key=self._api_key,
|
||||
api_base=self.api_base,
|
||||
model=self.model,
|
||||
messages=[
|
||||
dict(role="system", content=prompt),
|
||||
dict(role="user", content=text),
|
||||
],
|
||||
response_format=Episode,
|
||||
|
||||
# set temperature to 0 to make results be more stable and reproducible.
|
||||
temperature=0,
|
||||
)
|
||||
|
||||
@@ -131,7 +131,7 @@ def clean_sub(sub: str | None) -> str | None:
|
||||
|
||||
|
||||
def process(raw_title: str):
|
||||
raw_title = raw_title.strip().replace("\n", "")
|
||||
raw_title = raw_title.strip().replace("\n", " ")
|
||||
content_title = pre_process(raw_title)
|
||||
# 预处理标题
|
||||
group = get_group(content_title)
|
||||
|
||||
@@ -9,11 +9,11 @@ logger = logging.getLogger(__name__)
|
||||
PLATFORM = "Unix"
|
||||
|
||||
RULES = [
|
||||
r"(.*) - (\d{1,4}(?!\d|p)|\d{1,4}\.\d{1,2}(?!\d|p))(?:v\d{1,2})?(?: )?(?:END)?(.*)",
|
||||
r"(.*)[\[\ E](\d{1,4}|\d{1,4}\.\d{1,2})(?:v\d{1,2})?(?: )?(?:END)?[\]\ ](.*)",
|
||||
r"(.*)\[(?:第)?(\d*\.*\d*)[话集話](?:END)?\](.*)",
|
||||
r"(.*)第?(\d*\.*\d*)[话話集](?:END)?(.*)",
|
||||
r"(.*)(?:S\d{2})?EP?(\d+)(.*)",
|
||||
r"(.*) - (\d{1,4}(?:\.\d{1,2})?(?!\d|p))(?:v\d{1,2})?(?: )?(?:END)?(.*)",
|
||||
r"(.*)[\[\ E](\d{1,4}(?:\.\d{1,2})?)(?:v\d{1,2})?(?: )?(?:END)?[\]\ ](.*)",
|
||||
r"(.*)\[(?:第)?(\d{1,4}(?:\.\d{1,2})?)[话集話](?:END)?\](.*)",
|
||||
r"(.*)第?(\d{1,4}(?:\.\d{1,2})?)[话話集](?:END)?(.*)",
|
||||
r"(.*)(?:S\d{2})?EP?(\d{1,4}(?:\.\d{1,2})?)(.*)",
|
||||
]
|
||||
|
||||
SUBTITLE_LANG = {
|
||||
@@ -81,7 +81,7 @@ def torrent_parser(
|
||||
title, season = get_season_and_title(title)
|
||||
else:
|
||||
title, _ = get_season_and_title(title)
|
||||
episode = int(match_obj.group(2))
|
||||
episode = match_obj.group(2)
|
||||
suffix = Path(torrent_path).suffix
|
||||
if file_type == "media":
|
||||
return EpisodeFile(
|
||||
@@ -103,3 +103,21 @@ def torrent_parser(
|
||||
episode=episode,
|
||||
suffix=suffix,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ep = torrent_parser(
|
||||
"/不时用俄语小声说真心话的邻桌艾莉同学/Season 1/不时用俄语小声说真心话的邻桌艾莉同学 S01E02.mp4"
|
||||
)
|
||||
print(ep)
|
||||
|
||||
ep = torrent_parser(
|
||||
"/downloads/Bangumi/关于我转生变成史莱姆这档事 (2018)/Season 3/[ANi] 關於我轉生變成史萊姆這檔事 第三季 - 48.5 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4"
|
||||
)
|
||||
print(ep)
|
||||
|
||||
ep = torrent_parser(
|
||||
"/downloads/Bangumi/关于我转生变成史莱姆这档事 (2018)/Season 3/[ANi] 關於我轉生變成史萊姆這檔事 第三季 - 48.5 [1080P][Baha][WEB-DL][AAC AVC][CHT].srt",
|
||||
file_type="subtitle",
|
||||
)
|
||||
print(ep)
|
||||
|
||||
@@ -34,18 +34,18 @@ async def get_token_data(token: str = Depends(oauth2_scheme)):
|
||||
return payload
|
||||
|
||||
|
||||
def update_user_info(user_data: UserUpdate, current_user):
|
||||
async def update_user_info(user_data: UserUpdate, current_user):
|
||||
try:
|
||||
with Database() as db:
|
||||
db.user.update_user(current_user, user_data)
|
||||
async with Database() as db:
|
||||
await db.user.update_user(current_user, user_data)
|
||||
return True
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
def auth_user(user: User):
|
||||
with Database() as db:
|
||||
resp = db.user.auth_user(user)
|
||||
async def auth_user(user: User):
|
||||
async with Database() as db:
|
||||
resp = await db.user.auth_user(user)
|
||||
if resp.status:
|
||||
active_user.append(user.username)
|
||||
return resp
|
||||
|
||||
115
backend/src/module/security/auth_strategy.py
Normal file
115
backend/src/module/security/auth_strategy.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
认证策略抽象层
|
||||
将密码认证和 Passkey 认证统一为策略模式
|
||||
"""
|
||||
import base64
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from module.database import Database
|
||||
from module.models import ResponseModel
|
||||
from module.models.user import User
|
||||
|
||||
|
||||
class AuthStrategy(ABC):
|
||||
"""认证策略基类"""
|
||||
|
||||
@abstractmethod
|
||||
async def authenticate(self, username: str, credential: dict) -> ResponseModel:
|
||||
"""
|
||||
执行认证
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
credential: 认证凭证(密码或 WebAuthn 响应)
|
||||
|
||||
Returns:
|
||||
ResponseModel with status and user info
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class PasswordAuthStrategy(AuthStrategy):
|
||||
"""密码认证策略(保持向后兼容)"""
|
||||
|
||||
async def authenticate(self, username: str, credential: dict) -> ResponseModel:
|
||||
"""使用密码认证"""
|
||||
password = credential.get("password")
|
||||
if not password:
|
||||
return ResponseModel(
|
||||
status_code=401,
|
||||
status=False,
|
||||
msg_en="Password is required",
|
||||
msg_zh="密码不能为空",
|
||||
)
|
||||
|
||||
user = User(username=username, password=password)
|
||||
async with Database() as db:
|
||||
return await db.user.auth_user(user)
|
||||
|
||||
|
||||
class PasskeyAuthStrategy(AuthStrategy):
|
||||
"""Passkey 认证策略"""
|
||||
|
||||
def __init__(self, webauthn_service):
|
||||
self.webauthn_service = webauthn_service
|
||||
|
||||
async def authenticate(self, username: str, credential: dict) -> ResponseModel:
|
||||
"""使用 WebAuthn Passkey 认证"""
|
||||
async with Database() as db:
|
||||
# 1. 查找用户
|
||||
try:
|
||||
user = await db.user.get_user(username)
|
||||
except Exception:
|
||||
return ResponseModel(
|
||||
status_code=401,
|
||||
status=False,
|
||||
msg_en="User not found",
|
||||
msg_zh="用户不存在",
|
||||
)
|
||||
|
||||
# 2. 提取 credential_id 并查找对应的 passkey
|
||||
try:
|
||||
raw_id = credential.get("rawId")
|
||||
if not raw_id:
|
||||
raise ValueError("Missing credential ID")
|
||||
|
||||
# 将 rawId 从 base64url 转换为标准格式
|
||||
credential_id_str = self.webauthn_service.base64url_encode(
|
||||
self.webauthn_service.base64url_decode(raw_id)
|
||||
)
|
||||
|
||||
passkey = await db.passkey.get_passkey_by_credential_id(credential_id_str)
|
||||
if not passkey or passkey.user_id != user.id:
|
||||
raise ValueError("Passkey not found or not owned by user")
|
||||
|
||||
except Exception as e:
|
||||
return ResponseModel(
|
||||
status_code=401,
|
||||
status=False,
|
||||
msg_en="Invalid passkey credential",
|
||||
msg_zh="Passkey 凭证无效",
|
||||
)
|
||||
|
||||
# 3. 验证 WebAuthn 签名
|
||||
try:
|
||||
new_sign_count = self.webauthn_service.verify_authentication(
|
||||
username, credential, passkey
|
||||
)
|
||||
|
||||
# 4. 更新使用记录
|
||||
await db.passkey.update_passkey_usage(passkey, new_sign_count)
|
||||
|
||||
return ResponseModel(
|
||||
status_code=200,
|
||||
status=True,
|
||||
msg_en="Login successfully with passkey",
|
||||
msg_zh="通过 Passkey 登录成功",
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return ResponseModel(
|
||||
status_code=401,
|
||||
status=False,
|
||||
msg_en=f"Passkey verification failed: {str(e)}",
|
||||
msg_zh=f"Passkey 验证失败: {str(e)}",
|
||||
)
|
||||
277
backend/src/module/security/webauthn.py
Normal file
277
backend/src/module/security/webauthn.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
WebAuthn 认证服务层
|
||||
封装 py_webauthn 库的复杂性,提供清晰的注册和认证接口
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
options_to_json,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers.cose import COSEAlgorithmIdentifier
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
AuthenticatorTransport,
|
||||
CredentialDeviceType,
|
||||
PublicKeyCredentialDescriptor,
|
||||
PublicKeyCredentialType,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from module.models.passkey import Passkey
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebAuthnService:
|
||||
"""WebAuthn 核心业务逻辑"""
|
||||
|
||||
def __init__(self, rp_id: str, rp_name: str, origin: str):
|
||||
"""
|
||||
Args:
|
||||
rp_id: 依赖方 ID (e.g., "localhost" or "autobangumi.example.com")
|
||||
rp_name: 依赖方名称 (e.g., "AutoBangumi")
|
||||
origin: 前端 origin (e.g., "http://localhost:5173")
|
||||
"""
|
||||
self.rp_id = rp_id
|
||||
self.rp_name = rp_name
|
||||
self.origin = origin
|
||||
|
||||
# 存储临时的 challenge(生产环境应使用 Redis)
|
||||
self._challenges: dict[str, bytes] = {}
|
||||
|
||||
# ============ 注册流程 ============
|
||||
|
||||
def generate_registration_options(
|
||||
self, username: str, user_id: int, existing_passkeys: List[Passkey]
|
||||
) -> dict:
|
||||
"""
|
||||
生成 WebAuthn 注册选项
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
user_id: 用户 ID(转为 bytes)
|
||||
existing_passkeys: 用户已有的 Passkey(用于排除)
|
||||
|
||||
Returns:
|
||||
JSON-serializable registration options
|
||||
"""
|
||||
# 将已有凭证转为排除列表
|
||||
exclude_credentials = [
|
||||
PublicKeyCredentialDescriptor(
|
||||
id=self.base64url_decode(pk.credential_id),
|
||||
type=PublicKeyCredentialType.PUBLIC_KEY,
|
||||
transports=self._parse_transports(pk.transports),
|
||||
)
|
||||
for pk in existing_passkeys
|
||||
]
|
||||
|
||||
options = generate_registration_options(
|
||||
rp_id=self.rp_id,
|
||||
rp_name=self.rp_name,
|
||||
user_id=str(user_id).encode("utf-8"),
|
||||
user_name=username,
|
||||
user_display_name=username,
|
||||
exclude_credentials=exclude_credentials if exclude_credentials else None,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
supported_pub_key_algs=[
|
||||
COSEAlgorithmIdentifier.ECDSA_SHA_256, # -7: ES256
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, # -257: RS256
|
||||
],
|
||||
)
|
||||
|
||||
# 存储 challenge 用于后续验证
|
||||
challenge_key = f"reg_{username}"
|
||||
self._challenges[challenge_key] = options.challenge
|
||||
logger.debug(f"Generated registration challenge for {username}")
|
||||
|
||||
return json.loads(options_to_json(options))
|
||||
|
||||
def verify_registration(
|
||||
self, username: str, credential: dict, device_name: str
|
||||
) -> Passkey:
|
||||
"""
|
||||
验证注册响应并创建 Passkey 对象
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
credential: 来自前端的 credential 响应
|
||||
device_name: 用户输入的设备名称
|
||||
|
||||
Returns:
|
||||
Passkey 对象(未保存到数据库)
|
||||
|
||||
Raises:
|
||||
ValueError: 验证失败
|
||||
"""
|
||||
challenge_key = f"reg_{username}"
|
||||
expected_challenge = self._challenges.get(challenge_key)
|
||||
if not expected_challenge:
|
||||
raise ValueError("Challenge not found or expired")
|
||||
|
||||
try:
|
||||
verification = verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_rp_id=self.rp_id,
|
||||
expected_origin=self.origin,
|
||||
)
|
||||
|
||||
# 构造 Passkey 对象
|
||||
passkey = Passkey(
|
||||
user_id=0, # 调用方设置
|
||||
name=device_name,
|
||||
credential_id=self.base64url_encode(verification.credential_id),
|
||||
public_key=base64.b64encode(verification.credential_public_key).decode(
|
||||
"utf-8"
|
||||
),
|
||||
sign_count=verification.sign_count,
|
||||
aaguid=verification.aaguid if verification.aaguid else None,
|
||||
backup_eligible=verification.credential_device_type
|
||||
== CredentialDeviceType.MULTI_DEVICE,
|
||||
backup_state=verification.credential_backed_up,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Successfully verified registration for {username}, device: {device_name}"
|
||||
)
|
||||
return passkey
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Registration verification failed: {e}")
|
||||
raise ValueError(f"Invalid registration response: {str(e)}")
|
||||
finally:
|
||||
# 清理使用过的 challenge(无论成功或失败都清理,防止重放攻击)
|
||||
self._challenges.pop(challenge_key, None)
|
||||
|
||||
# ============ 认证流程 ============
|
||||
|
||||
def generate_authentication_options(
|
||||
self, username: str, passkeys: List[Passkey]
|
||||
) -> dict:
|
||||
"""
|
||||
生成 WebAuthn 认证选项
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
passkeys: 用户的 Passkey 列表(限定可用凭证)
|
||||
|
||||
Returns:
|
||||
JSON-serializable authentication options
|
||||
"""
|
||||
allow_credentials = [
|
||||
PublicKeyCredentialDescriptor(
|
||||
id=self.base64url_decode(pk.credential_id),
|
||||
type=PublicKeyCredentialType.PUBLIC_KEY,
|
||||
transports=self._parse_transports(pk.transports),
|
||||
)
|
||||
for pk in passkeys
|
||||
]
|
||||
|
||||
options = generate_authentication_options(
|
||||
rp_id=self.rp_id,
|
||||
allow_credentials=allow_credentials if allow_credentials else None,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
|
||||
# 存储 challenge
|
||||
challenge_key = f"auth_{username}"
|
||||
self._challenges[challenge_key] = options.challenge
|
||||
logger.debug(f"Generated authentication challenge for {username}")
|
||||
|
||||
return json.loads(options_to_json(options))
|
||||
|
||||
def verify_authentication(
|
||||
self, username: str, credential: dict, passkey: Passkey
|
||||
) -> int:
|
||||
"""
|
||||
验证认证响应
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
credential: 来自前端的 credential 响应
|
||||
passkey: 对应的 Passkey 对象
|
||||
|
||||
Returns:
|
||||
新的 sign_count(用于更新数据库)
|
||||
|
||||
Raises:
|
||||
ValueError: 验证失败
|
||||
"""
|
||||
challenge_key = f"auth_{username}"
|
||||
expected_challenge = self._challenges.get(challenge_key)
|
||||
if not expected_challenge:
|
||||
raise ValueError("Challenge not found or expired")
|
||||
|
||||
try:
|
||||
# 解码 public key
|
||||
credential_public_key = base64.b64decode(passkey.public_key)
|
||||
|
||||
verification = verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_rp_id=self.rp_id,
|
||||
expected_origin=self.origin,
|
||||
credential_public_key=credential_public_key,
|
||||
credential_current_sign_count=passkey.sign_count,
|
||||
)
|
||||
|
||||
logger.info(f"Successfully verified authentication for {username}")
|
||||
return verification.new_sign_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication verification failed: {e}")
|
||||
raise ValueError(f"Invalid authentication response: {str(e)}")
|
||||
finally:
|
||||
# 清理 challenge(无论成功或失败都清理,防止重放攻击)
|
||||
self._challenges.pop(challenge_key, None)
|
||||
|
||||
# ============ 辅助方法 ============
|
||||
|
||||
def _parse_transports(
|
||||
self, transports_json: Optional[str]
|
||||
) -> List[AuthenticatorTransport]:
|
||||
"""解析存储的 transports JSON"""
|
||||
if not transports_json:
|
||||
return []
|
||||
try:
|
||||
transport_strings = json.loads(transports_json)
|
||||
return [AuthenticatorTransport(t) for t in transport_strings]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def base64url_encode(self, data: bytes) -> str:
|
||||
"""Base64URL 编码(无 padding)"""
|
||||
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
|
||||
|
||||
def base64url_decode(self, data: str) -> bytes:
|
||||
"""Base64URL 解码(补齐 padding)"""
|
||||
padding = 4 - len(data) % 4
|
||||
if padding != 4:
|
||||
data += "=" * padding
|
||||
return base64.urlsafe_b64decode(data)
|
||||
|
||||
|
||||
# 全局 WebAuthn 服务实例存储
|
||||
_webauthn_services: dict[str, WebAuthnService] = {}
|
||||
|
||||
|
||||
def get_webauthn_service(rp_id: str, rp_name: str, origin: str) -> WebAuthnService:
|
||||
"""
|
||||
获取或创建 WebAuthnService 实例
|
||||
使用缓存以保持 challenge 状态
|
||||
"""
|
||||
key = f"{rp_id}:{origin}"
|
||||
if key not in _webauthn_services:
|
||||
_webauthn_services[key] = WebAuthnService(rp_id, rp_name, origin)
|
||||
return _webauthn_services[key]
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import pytest
|
||||
from unittest import mock
|
||||
|
||||
from module.parser.analyser.openai import DEFAULT_PROMPT, OpenAIParser
|
||||
@@ -10,11 +11,10 @@ class TestOpenAIParser:
|
||||
api_key = "testing!"
|
||||
cls.parser = OpenAIParser(api_key=api_key)
|
||||
|
||||
@pytest.mark.skip(reason="This test is not implemented yet.")
|
||||
def test__prepare_params_with_openai(self):
|
||||
text = "hello world"
|
||||
expected = dict(
|
||||
api_key=self.parser._api_key,
|
||||
api_base=self.parser.api_base,
|
||||
messages=[
|
||||
dict(role="system", content=DEFAULT_PROMPT),
|
||||
dict(role="user", content=text),
|
||||
@@ -26,6 +26,7 @@ class TestOpenAIParser:
|
||||
params = self.parser._prepare_params(text, DEFAULT_PROMPT)
|
||||
assert expected == params
|
||||
|
||||
@pytest.mark.skip(reason="This test is not implemented yet.")
|
||||
def test__prepare_params_with_azure(self):
|
||||
azure_parser = OpenAIParser(
|
||||
api_key="aaabbbcc",
|
||||
@@ -37,8 +38,6 @@ class TestOpenAIParser:
|
||||
|
||||
text = "hello world"
|
||||
expected = dict(
|
||||
api_key=azure_parser._api_key,
|
||||
api_base=azure_parser.api_base,
|
||||
messages=[
|
||||
dict(role="system", content=DEFAULT_PROMPT),
|
||||
dict(role="user", content=text),
|
||||
|
||||
@@ -2,6 +2,26 @@ from module.parser.analyser import raw_parser
|
||||
|
||||
|
||||
def test_raw_parser():
|
||||
# Issue #794, RSS link: https://mikanani.me/RSS/Bangumi?bangumiId=3367&subgroupid=370
|
||||
content = "[喵萌奶茶屋&LoliHouse] 鹿乃子乃子乃子虎视眈眈 / Shikanoko Nokonoko Koshitantan\n- 01 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕]"
|
||||
info = raw_parser(content)
|
||||
assert info.group == "喵萌奶茶屋&LoliHouse"
|
||||
assert info.title_zh == "鹿乃子乃子乃子虎视眈眈"
|
||||
assert info.title_en == "Shikanoko Nokonoko Koshitantan"
|
||||
assert info.resolution == "1080p"
|
||||
assert info.episode == 1
|
||||
assert info.season == 1
|
||||
|
||||
# Issue #679, RSS link: https://mikanani.me/RSS/Bangumi?bangumiId=3225&subgroupid=370
|
||||
content = "[LoliHouse] 轮回七次的反派大小姐,在前敌国享受随心所欲的新婚生活\n / 7th Time Loop - 12 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕][END]"
|
||||
info = raw_parser(content)
|
||||
assert info.group == "LoliHouse"
|
||||
assert info.title_zh == "轮回七次的反派大小姐,在前敌国享受随心所欲的新婚生活"
|
||||
assert info.title_en == "7th Time Loop"
|
||||
assert info.resolution == "1080p"
|
||||
assert info.episode == 12
|
||||
assert info.season == 1
|
||||
|
||||
content = "【幻樱字幕组】【4月新番】【古见同学有交流障碍症 第二季 Komi-san wa, Komyushou Desu. S02】【22】【GB_MP4】【1920X1080】"
|
||||
info = raw_parser(content)
|
||||
assert info.title_en == "Komi-san wa, Komyushou Desu."
|
||||
|
||||
@@ -72,6 +72,25 @@ def test_torrent_parser():
|
||||
assert bf.season == 1
|
||||
assert bf.episode == 6
|
||||
|
||||
file_name = "不时用俄语小声说真心话的邻桌艾莉同学 S01E02.mp4"
|
||||
bf = torrent_parser(file_name)
|
||||
assert bf.title == "不时用俄语小声说真心话的邻桌艾莉同学"
|
||||
assert bf.season == 1
|
||||
assert bf.episode == 2
|
||||
|
||||
file_name = "[ANi] 關於我轉生變成史萊姆這檔事 第三季 - 48.5 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4"
|
||||
bf = torrent_parser(file_name, season=3)
|
||||
assert bf.title == "關於我轉生變成史萊姆這檔事 第三季"
|
||||
assert bf.season == 3
|
||||
assert bf.episode == 48.5
|
||||
|
||||
file_name = "[ANi] 關於我轉生變成史萊姆這檔事 第三季 - 48.5 [1080P][Baha][WEB-DL][AAC AVC][CHT].srt"
|
||||
sf = torrent_parser(file_name, season=3, file_type="subtitle")
|
||||
assert sf.title == "關於我轉生變成史萊姆這檔事 第三季"
|
||||
assert sf.episode == 48.5
|
||||
assert sf.season == 3
|
||||
assert sf.language == "zh-tw"
|
||||
|
||||
|
||||
class TestGetPathBasename:
|
||||
def test_regular_path(self):
|
||||
|
||||
2
webui/.vscode/settings.json
vendored
2
webui/.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": ["src/i18n"],
|
||||
"commentTranslate.targetLanguage": "zh-CN",
|
||||
"i18n-ally.sourceLanguage": "en",
|
||||
"i18n-ally.sourceLanguage": "zh-CN",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"i18n-ally.keystyle": "nested"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.11.0+sha512.0a203ffaed5a3f63242cd064c8fb5892366c103e328079318f78062f24ea8c9d50bc6a47aa3567cabefd824d170e78fa2745ed1f16b132e16436146b7688f19b",
|
||||
"scripts": {
|
||||
"prepare": "cd .. && husky install ./webui/.husky",
|
||||
"test:build": "vue-tsc --noEmit",
|
||||
@@ -19,53 +20,51 @@
|
||||
"generate-pwa-assets": "pwa-assets-generator --preset minimal public/images/logo.svg"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.13",
|
||||
"@vueuse/components": "^10.4.1",
|
||||
"@vueuse/core": "^8.9.4",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@vueuse/components": "^10.11.1",
|
||||
"@vueuse/core": "^10.11.1",
|
||||
"axios": "^0.27.2",
|
||||
"naive-ui": "^2.34.4",
|
||||
"pinia": "^2.1.3",
|
||||
"rxjs": "^7.8.1",
|
||||
"vue": "^3.3.4",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-inline-svg": "^3.1.2",
|
||||
"vue-router": "^4.2.1"
|
||||
"naive-ui": "^2.39.0",
|
||||
"pinia": "^2.2.2",
|
||||
"vue": "^3.5.8",
|
||||
"vue-i18n": "^9.14.0",
|
||||
"vue-inline-svg": "^3.1.4",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^0.38.6",
|
||||
"@icon-park/vue-next": "^1.4.2",
|
||||
"@intlify/unplugin-vue-i18n": "^0.11.0",
|
||||
"@storybook/addon-essentials": "^7.0.12",
|
||||
"@storybook/addon-interactions": "^7.0.12",
|
||||
"@storybook/addon-links": "^7.0.12",
|
||||
"@storybook/blocks": "^7.0.12",
|
||||
"@storybook/addon-essentials": "^7.6.20",
|
||||
"@storybook/addon-interactions": "^7.6.20",
|
||||
"@storybook/addon-links": "^7.6.20",
|
||||
"@storybook/blocks": "^7.6.20",
|
||||
"@storybook/testing-library": "0.0.14-next.2",
|
||||
"@storybook/vue3": "^7.0.12",
|
||||
"@storybook/vue3-vite": "^7.0.12",
|
||||
"@types/node": "^18.16.14",
|
||||
"@unocss/preset-attributify": "^0.55.3",
|
||||
"@storybook/vue3": "^7.6.20",
|
||||
"@storybook/vue3-vite": "^7.6.20",
|
||||
"@types/node": "^18.19.50",
|
||||
"@unocss/preset-attributify": "^0.55.7",
|
||||
"@unocss/preset-rem-to-px": "^0.51.13",
|
||||
"@unocss/reset": "^0.51.13",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vitejs/plugin-vue": "^4.6.2",
|
||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||
"@vue/runtime-dom": "^3.3.4",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-storybook": "^0.6.12",
|
||||
"@vue/runtime-dom": "^3.5.8",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-plugin-storybook": "^0.6.15",
|
||||
"husky": "^8.0.3",
|
||||
"prettier": "^2.8.8",
|
||||
"radash": "^12.1.0",
|
||||
"sass": "^1.62.1",
|
||||
"storybook": "^7.0.12",
|
||||
"sass-embedded": "^1.79.3",
|
||||
"storybook": "^7.6.20",
|
||||
"typescript": "^4.9.5",
|
||||
"unocss": "^0.51.13",
|
||||
"unplugin-auto-import": "^0.10.3",
|
||||
"unplugin-vue-components": "^0.24.1",
|
||||
"unplugin-vue-router": "^0.6.4",
|
||||
"vite": "^4.3.5",
|
||||
"vite-plugin-pwa": "^0.16.4",
|
||||
"vite": "^4.5.5",
|
||||
"vite-plugin-pwa": "^0.16.7",
|
||||
"vitest": "^0.30.1",
|
||||
"vue-tsc": "^1.6.4"
|
||||
},
|
||||
"packageManager": "pnpm@9.1.4+sha512.9df9cf27c91715646c7d675d1c9c8e41f6fce88246f1318c1aa6a1ed1aeb3c4f032fcdf4ba63cc69c4fe6d634279176b5358727d8f2cc1e65b65f43ce2f8bfb0"
|
||||
"vue-tsc": "^1.8.27"
|
||||
}
|
||||
}
|
||||
|
||||
10586
webui/pnpm-lock.yaml
generated
10586
webui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
88
webui/src/api/passkey.ts
Normal file
88
webui/src/api/passkey.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { ApiSuccess } from '#/api';
|
||||
import type { LoginSuccess } from '#/auth';
|
||||
import type {
|
||||
AuthenticationOptions,
|
||||
PasskeyAuthFinishRequest,
|
||||
PasskeyAuthStartRequest,
|
||||
PasskeyCreateRequest,
|
||||
PasskeyDeleteRequest,
|
||||
PasskeyItem,
|
||||
RegistrationOptions,
|
||||
} from '#/passkey';
|
||||
|
||||
/**
|
||||
* Passkey API 客户端
|
||||
*/
|
||||
export const apiPasskey = {
|
||||
// ============ 注册流程 ============
|
||||
|
||||
/**
|
||||
* 获取注册选项(步骤 1)
|
||||
*/
|
||||
async getRegistrationOptions(): Promise<RegistrationOptions> {
|
||||
const { data } = await axios.post<RegistrationOptions>(
|
||||
'api/v1/passkey/register/options'
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 提交注册结果(步骤 2)
|
||||
*/
|
||||
async verifyRegistration(request: PasskeyCreateRequest): Promise<ApiSuccess> {
|
||||
const { data } = await axios.post<ApiSuccess>(
|
||||
'api/v1/passkey/register/verify',
|
||||
request
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ============ 认证流程 ============
|
||||
|
||||
/**
|
||||
* 获取登录选项(步骤 1)
|
||||
*/
|
||||
async getLoginOptions(
|
||||
request: PasskeyAuthStartRequest
|
||||
): Promise<AuthenticationOptions> {
|
||||
const { data } = await axios.post<AuthenticationOptions>(
|
||||
'api/v1/passkey/auth/options',
|
||||
request
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 提交认证结果(步骤 2)
|
||||
*/
|
||||
async loginWithPasskey(
|
||||
request: PasskeyAuthFinishRequest
|
||||
): Promise<LoginSuccess> {
|
||||
const { data } = await axios.post<LoginSuccess>(
|
||||
'api/v1/passkey/auth/verify',
|
||||
request
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ============ 管理 ============
|
||||
|
||||
/**
|
||||
* 获取 Passkey 列表
|
||||
*/
|
||||
async list(): Promise<PasskeyItem[]> {
|
||||
const { data } = await axios.get<PasskeyItem[]>('api/v1/passkey/list');
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除 Passkey
|
||||
*/
|
||||
async delete(request: PasskeyDeleteRequest): Promise<ApiSuccess> {
|
||||
const { data } = await axios.post<ApiSuccess>(
|
||||
'api/v1/passkey/delete',
|
||||
request
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -1,57 +1,66 @@
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
import { omit } from 'radash';
|
||||
import type { BangumiAPI, BangumiRule } from '#/bangumi';
|
||||
|
||||
type EventSourceStatus = 'OPEN' | 'CONNECTING' | 'CLOSED';
|
||||
|
||||
export const apiSearch = {
|
||||
/**
|
||||
* 番剧搜索接口是 Server Send 流式数据,每条是一个 Bangumi JSON 字符串,
|
||||
* 使用接口方式是监听连接消息后,转为 Observable 配合外层调用时 switchMap 订阅使用
|
||||
*/
|
||||
get(keyword: string, site = 'mikan'): Observable<BangumiRule> {
|
||||
const bangumiInfo$ = new Observable<BangumiRule>((observer) => {
|
||||
const eventSource = new EventSource(
|
||||
`api/v1/search/bangumi?site=${site}&keywords=${encodeURIComponent(
|
||||
keyword
|
||||
)}`,
|
||||
{ withCredentials: true }
|
||||
);
|
||||
get() {
|
||||
const eventSource = ref(null) as Ref<EventSource | null>;
|
||||
const status = ref<EventSourceStatus>('CLOSED');
|
||||
const data = ref<BangumiRule[]>([]);
|
||||
|
||||
eventSource.onmessage = (ev) => {
|
||||
try {
|
||||
const apiData: BangumiAPI = JSON.parse(ev.data);
|
||||
const data: BangumiRule = {
|
||||
...apiData,
|
||||
filter: apiData.filter.split(','),
|
||||
rss_link: apiData.rss_link.split(','),
|
||||
};
|
||||
observer.next(data);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[/search/bangumi] Parse Error |',
|
||||
{ keyword },
|
||||
'response:',
|
||||
ev.data
|
||||
);
|
||||
}
|
||||
const keyword = ref('');
|
||||
const provider = ref('');
|
||||
|
||||
const close = () => {
|
||||
if (eventSource.value) {
|
||||
eventSource.value.close();
|
||||
eventSource.value = null;
|
||||
status.value = 'CLOSED';
|
||||
}
|
||||
};
|
||||
|
||||
const _init = () => {
|
||||
status.value = 'CONNECTING';
|
||||
|
||||
const url = `api/v1/search/bangumi?site=${
|
||||
provider.value
|
||||
}&keywords=${encodeURIComponent(keyword.value)}`;
|
||||
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
eventSource.value = es;
|
||||
es.onopen = () => {
|
||||
status.value = 'OPEN';
|
||||
};
|
||||
|
||||
eventSource.onerror = (ev) => {
|
||||
console.error(
|
||||
'[/search/bangumi] Server Error |',
|
||||
{ keyword },
|
||||
'error:',
|
||||
ev
|
||||
);
|
||||
// 目前后端搜索完成关闭连接时会触发 error 事件,前端手动调用 close 不再自动重连
|
||||
eventSource.close();
|
||||
es.onmessage = (e) => {
|
||||
const _data = JSON.parse(e.data) as BangumiAPI;
|
||||
const newData: BangumiRule = {
|
||||
...omit(_data, ['filter', 'rss_link']),
|
||||
filter: _data.filter.split(','),
|
||||
rss_link: _data.rss_link.split(','),
|
||||
};
|
||||
data.value = [...data.value, newData];
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
es.onerror = (err) => {
|
||||
console.error('EventSource error:', err);
|
||||
close();
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return bangumiInfo$;
|
||||
const open = () => {
|
||||
data.value = [];
|
||||
_init();
|
||||
};
|
||||
|
||||
return {
|
||||
keyword,
|
||||
provider,
|
||||
status,
|
||||
data,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
},
|
||||
|
||||
async getProvider() {
|
||||
|
||||
46
webui/src/components/ab-image.vue
Normal file
46
webui/src/components/ab-image.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
import { ErrorPicture } from '@icon-park/vue-next';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
src?: string | null;
|
||||
aspectRatio?: number;
|
||||
objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
|
||||
}>(),
|
||||
{
|
||||
objectFit: 'cover',
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div rel>
|
||||
<template v-if="aspectRatio">
|
||||
<div
|
||||
w-full
|
||||
:style="{ paddingBottom: `calc(${1 / aspectRatio} * 100%)` }"
|
||||
></div>
|
||||
|
||||
<img
|
||||
v-if="src"
|
||||
:src="src"
|
||||
alt="poster"
|
||||
abs
|
||||
top-0
|
||||
left-0
|
||||
:style="{ objectFit }"
|
||||
wh-full
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<img v-if="src" :src="src" alt="poster" :style="{ objectFit }" wh-full />
|
||||
|
||||
<div v-else wh-full f-cer border="1 white">
|
||||
<ErrorPicture theme="outline" size="24" fill="#333" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scope></style>
|
||||
@@ -7,7 +7,6 @@ const { getSettingGroup } = useConfigStore();
|
||||
|
||||
const parser = getSettingGroup('rss_parser');
|
||||
|
||||
/** @ts-expect-error Incorrect order */
|
||||
const langs: RssParserLang = ['zh', 'en', 'jp'];
|
||||
|
||||
const items: SettingItem<RssParser>[] = [
|
||||
|
||||
174
webui/src/components/setting/config-passkey.vue
Normal file
174
webui/src/components/setting/config-passkey.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script lang="ts" setup>
|
||||
import { Delete } from '@icon-park/vue-next';
|
||||
import type { PasskeyItem } from '#/passkey';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const { passkeys, loading, isSupported, loadPasskeys, addPasskey, deletePasskey } =
|
||||
usePasskey();
|
||||
|
||||
const showAddDialog = ref(false);
|
||||
const deviceName = ref('');
|
||||
const isRegistering = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
loadPasskeys();
|
||||
});
|
||||
|
||||
function openAddDialog() {
|
||||
// 生成默认设备名称
|
||||
const platform = navigator.platform || 'Device';
|
||||
const userAgent = navigator.userAgent;
|
||||
|
||||
if (userAgent.includes('iPhone')) {
|
||||
deviceName.value = 'iPhone';
|
||||
} else if (userAgent.includes('iPad')) {
|
||||
deviceName.value = 'iPad';
|
||||
} else if (userAgent.includes('Mac')) {
|
||||
deviceName.value = 'MacBook';
|
||||
} else if (userAgent.includes('Windows')) {
|
||||
deviceName.value = 'Windows PC';
|
||||
} else if (userAgent.includes('Android')) {
|
||||
deviceName.value = 'Android';
|
||||
} else {
|
||||
deviceName.value = platform;
|
||||
}
|
||||
|
||||
showAddDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!deviceName.value.trim()) return;
|
||||
|
||||
isRegistering.value = true;
|
||||
try {
|
||||
const success = await addPasskey(deviceName.value.trim());
|
||||
if (success) {
|
||||
showAddDialog.value = false;
|
||||
deviceName.value = '';
|
||||
}
|
||||
} finally {
|
||||
isRegistering.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(passkey: PasskeyItem) {
|
||||
if (!confirm(t('passkey.delete_confirm'))) return;
|
||||
await deletePasskey(passkey.id);
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-fold-panel :title="$t('passkey.title')">
|
||||
<div space-y-12>
|
||||
<!-- 不支持提示 -->
|
||||
<div v-if="!isSupported" text-orange-500 text-14>
|
||||
{{ $t('passkey.not_supported') }}
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-else-if="loading" text-gray-500 text-14>
|
||||
{{ $t('passkey.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- 无 Passkey -->
|
||||
<div v-else-if="passkeys.length === 0" text-gray-500 text-14>
|
||||
{{ $t('passkey.no_passkeys') }}
|
||||
</div>
|
||||
|
||||
<!-- Passkey 列表 -->
|
||||
<div v-else space-y-8>
|
||||
<div
|
||||
v-for="passkey in passkeys"
|
||||
:key="passkey.id"
|
||||
flex="~ justify-between items-center"
|
||||
p-12
|
||||
bg-gray-50
|
||||
rounded-8
|
||||
>
|
||||
<div>
|
||||
<div font-medium>{{ passkey.name }}</div>
|
||||
<div text-12 text-gray-500>
|
||||
{{ $t('passkey.created_at') }}: {{ formatDate(passkey.created_at) }}
|
||||
</div>
|
||||
<div v-if="passkey.last_used_at" text-12 text-gray-500>
|
||||
{{ $t('passkey.last_used') }}: {{ formatDate(passkey.last_used_at) }}
|
||||
</div>
|
||||
<div v-if="passkey.backup_eligible" text-12 text-green-600>
|
||||
{{ $t('passkey.synced') }}
|
||||
</div>
|
||||
</div>
|
||||
<ab-button
|
||||
size="small"
|
||||
type="warn"
|
||||
@click="handleDelete(passkey)"
|
||||
>
|
||||
<Delete size="16" />
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div line></div>
|
||||
|
||||
<!-- 添加按钮 -->
|
||||
<div flex="~ justify-end">
|
||||
<ab-button
|
||||
v-if="isSupported"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="openAddDialog"
|
||||
>
|
||||
{{ $t('passkey.add_new') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加对话框 -->
|
||||
<ab-popup
|
||||
v-model:show="showAddDialog"
|
||||
:title="$t('passkey.register_title')"
|
||||
css="w-365"
|
||||
>
|
||||
<div space-y-16>
|
||||
<ab-label :label="$t('passkey.device_name')">
|
||||
<input
|
||||
v-model="deviceName"
|
||||
type="text"
|
||||
:placeholder="$t('passkey.device_name_placeholder')"
|
||||
ab-input
|
||||
maxlength="64"
|
||||
@keyup.enter="handleAdd"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<div text-14 text-gray-500>
|
||||
{{ $t('passkey.register_hint') }}
|
||||
</div>
|
||||
|
||||
<div line></div>
|
||||
|
||||
<div flex="~ justify-end gap-8">
|
||||
<ab-button
|
||||
size="small"
|
||||
type="warn"
|
||||
@click="showAddDialog = false"
|
||||
>
|
||||
{{ $t('config.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!deviceName.trim() || isRegistering"
|
||||
@click="handleAdd"
|
||||
>
|
||||
{{ $t('config.apply') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
</ab-fold-panel>
|
||||
</template>
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSharedComposable, useIntervalFn } from '@vueuse/core';
|
||||
|
||||
export const useAppInfo = createSharedComposable(() => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
const running = ref<boolean>(false);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSharedComposable, useLocalStorage } from '@vueuse/core';
|
||||
import type { User } from '#/auth';
|
||||
import type { ApiError } from '#/api';
|
||||
import { router } from '@/router';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { createSharedComposable } from '@vueuse/core';
|
||||
|
||||
export const useMessage = createSharedComposable(() => {
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { createSharedComposable, useLocalStorage } from '@vueuse/core';
|
||||
import enUS from '@/i18n/en.json';
|
||||
import zhCN from '@/i18n/zh-CN.json';
|
||||
import type { ApiSuccess } from '#/api';
|
||||
@@ -24,7 +25,7 @@ export const useMyI18n = createSharedComposable(() => {
|
||||
});
|
||||
|
||||
watch(lang, (val) => {
|
||||
i18n.global.locale.value = val;
|
||||
i18n.global.locale.value = val as unknown as Languages;
|
||||
});
|
||||
|
||||
function changeLocale() {
|
||||
|
||||
94
webui/src/hooks/usePasskey.ts
Normal file
94
webui/src/hooks/usePasskey.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createSharedComposable } from '@vueuse/core';
|
||||
import { apiPasskey } from '@/api/passkey';
|
||||
import {
|
||||
registerPasskey,
|
||||
loginWithPasskey as webauthnLogin,
|
||||
isWebAuthnSupported,
|
||||
} from '@/services/webauthn';
|
||||
import type { PasskeyItem } from '#/passkey';
|
||||
|
||||
export const usePasskey = createSharedComposable(() => {
|
||||
const message = useMessage();
|
||||
const { t } = useMyI18n();
|
||||
const { isLoggedIn } = useAuth();
|
||||
|
||||
// 状态
|
||||
const passkeys = ref<PasskeyItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const isSupported = ref(false);
|
||||
|
||||
// 检测浏览器支持
|
||||
onMounted(() => {
|
||||
isSupported.value = isWebAuthnSupported();
|
||||
});
|
||||
|
||||
// 加载 Passkey 列表
|
||||
async function loadPasskeys() {
|
||||
if (!isLoggedIn.value) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
passkeys.value = await apiPasskey.list();
|
||||
} catch (error) {
|
||||
console.error('Failed to load passkeys:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 注册新 Passkey
|
||||
async function addPasskey(deviceName: string): Promise<boolean> {
|
||||
try {
|
||||
await registerPasskey(deviceName);
|
||||
message.success(t('passkey.register_success'));
|
||||
await loadPasskeys();
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
message.error(`${t('passkey.register_failed')}: ${errorMessage}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Passkey 登录
|
||||
async function loginWithPasskey(username: string): Promise<boolean> {
|
||||
try {
|
||||
await webauthnLogin(username);
|
||||
isLoggedIn.value = true;
|
||||
message.success(t('notify.login_success'));
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
message.error(`${t('passkey.login_failed')}: ${errorMessage}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除 Passkey
|
||||
async function deletePasskey(passkeyId: number): Promise<boolean> {
|
||||
try {
|
||||
await apiPasskey.delete({ passkey_id: passkeyId });
|
||||
message.success(t('passkey.delete_success'));
|
||||
await loadPasskeys();
|
||||
return true;
|
||||
} catch (error) {
|
||||
message.error(t('passkey.delete_failed'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
passkeys,
|
||||
loading,
|
||||
isSupported,
|
||||
|
||||
// 方法
|
||||
loadPasskeys,
|
||||
addPasskey,
|
||||
loginWithPasskey,
|
||||
deletePasskey,
|
||||
};
|
||||
});
|
||||
@@ -103,10 +103,31 @@
|
||||
},
|
||||
"login": {
|
||||
"login_btn": "Login",
|
||||
"passkey_btn": "Passkey",
|
||||
"password": "Password",
|
||||
"title": "Login",
|
||||
"username": "Username"
|
||||
},
|
||||
"passkey": {
|
||||
"add_new": "Add Passkey",
|
||||
"created_at": "Created",
|
||||
"delete_confirm": "Are you sure you want to delete this passkey?",
|
||||
"delete_failed": "Delete failed",
|
||||
"delete_success": "Passkey deleted",
|
||||
"device_name": "Device Name",
|
||||
"device_name_placeholder": "e.g., iPhone 15, MacBook Pro",
|
||||
"last_used": "Last used",
|
||||
"loading": "Loading...",
|
||||
"login_failed": "Passkey login failed",
|
||||
"no_passkeys": "No passkeys registered yet",
|
||||
"not_supported": "Your browser does not support Passkeys",
|
||||
"register_failed": "Registration failed",
|
||||
"register_hint": "After clicking confirm, follow your browser's prompts to complete authentication.",
|
||||
"register_success": "Passkey registered successfully",
|
||||
"register_title": "Add New Passkey",
|
||||
"synced": "Synced across devices",
|
||||
"title": "Passkey Settings"
|
||||
},
|
||||
"notify": {
|
||||
"copy_failed": "Your browser does not support Clipboard API!",
|
||||
"copy_success": "Copy Success!",
|
||||
|
||||
@@ -103,10 +103,31 @@
|
||||
},
|
||||
"login": {
|
||||
"login_btn": "登录",
|
||||
"passkey_btn": "通行密钥",
|
||||
"password": "密码",
|
||||
"title": "登录",
|
||||
"username": "用户名"
|
||||
},
|
||||
"passkey": {
|
||||
"add_new": "添加 Passkey",
|
||||
"created_at": "创建于",
|
||||
"delete_confirm": "确定删除此 Passkey?",
|
||||
"delete_failed": "删除失败",
|
||||
"delete_success": "Passkey 已删除",
|
||||
"device_name": "设备名称",
|
||||
"device_name_placeholder": "例如:iPhone 15, MacBook Pro",
|
||||
"last_used": "最后使用",
|
||||
"loading": "加载中...",
|
||||
"login_failed": "Passkey 登录失败",
|
||||
"no_passkeys": "尚未注册任何 Passkey",
|
||||
"not_supported": "您的浏览器不支持 Passkey",
|
||||
"register_failed": "注册失败",
|
||||
"register_hint": "点击确认后,请按照浏览器提示完成身份验证。",
|
||||
"register_success": "Passkey 注册成功",
|
||||
"register_title": "添加新的 Passkey",
|
||||
"synced": "已同步到多设备",
|
||||
"title": "Passkey 设置"
|
||||
},
|
||||
"notify": {
|
||||
"copy_failed": "您的浏览器不支持剪贴板操作!",
|
||||
"copy_success": "复制成功!",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { watchOnce } from '@vueuse/core';
|
||||
|
||||
definePage({
|
||||
name: 'Log',
|
||||
});
|
||||
@@ -52,11 +54,14 @@ onActivated(() => {
|
||||
if (log.value) {
|
||||
backToBottom();
|
||||
} else {
|
||||
watchOnce(log, () => {
|
||||
nextTick(() => {
|
||||
backToBottom();
|
||||
});
|
||||
});
|
||||
watchOnce(
|
||||
() => log.value,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
backToBottom();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fingerprint } from '@icon-park/vue-next';
|
||||
|
||||
definePage({
|
||||
name: 'Login',
|
||||
});
|
||||
|
||||
const { user, login } = useAuth();
|
||||
const { isSupported, loginWithPasskey } = usePasskey();
|
||||
|
||||
const isPasskeyLoading = ref(false);
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
if (!user.username) {
|
||||
const message = useMessage();
|
||||
const { t } = useMyI18n();
|
||||
message.warning(t('notify.please_enter', [t('login.username')]));
|
||||
return;
|
||||
}
|
||||
|
||||
isPasskeyLoading.value = true;
|
||||
try {
|
||||
await loginWithPasskey(user.username);
|
||||
} finally {
|
||||
isPasskeyLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,6 +53,17 @@ const { user, login } = useAuth();
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="login-actions">
|
||||
<ab-button
|
||||
v-if="isSupported"
|
||||
size="small"
|
||||
type="secondary"
|
||||
:disabled="isPasskeyLoading"
|
||||
@click="handlePasskeyLogin"
|
||||
>
|
||||
<Fingerprint size="16" />
|
||||
{{ $t('login.passkey_btn') }}
|
||||
</ab-button>
|
||||
<div v-else></div>
|
||||
<ab-button size="small" @click="login">
|
||||
{{ $t('login.login_btn') }}
|
||||
</ab-button>
|
||||
@@ -98,6 +130,7 @@ const { user, login } = useAuth();
|
||||
|
||||
.login-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
166
webui/src/services/webauthn.ts
Normal file
166
webui/src/services/webauthn.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { apiPasskey } from '@/api/passkey';
|
||||
|
||||
/**
|
||||
* WebAuthn 浏览器 API 封装
|
||||
* 处理 Base64URL 编码和浏览器兼容性
|
||||
*/
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
function base64UrlToBuffer(base64url: string): ArrayBuffer {
|
||||
// 补齐 padding
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
||||
const binary = atob(base64 + padding);
|
||||
|
||||
const buffer = new ArrayBuffer(binary.length);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function bufferToBase64Url(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
|
||||
// ============ 注册流程 ============
|
||||
|
||||
/**
|
||||
* 注册新的 Passkey
|
||||
* @param deviceName 设备名称(用户输入)
|
||||
*/
|
||||
export async function registerPasskey(deviceName: string): Promise<void> {
|
||||
// 1. 获取注册选项
|
||||
const options = await apiPasskey.getRegistrationOptions();
|
||||
|
||||
// 2. 转换选项为浏览器 API 格式
|
||||
const createOptions: PublicKeyCredentialCreationOptions = {
|
||||
challenge: base64UrlToBuffer(options.challenge),
|
||||
rp: options.rp,
|
||||
user: {
|
||||
id: base64UrlToBuffer(options.user.id),
|
||||
name: options.user.name,
|
||||
displayName: options.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: options.pubKeyCredParams.map((p) => ({
|
||||
type: p.type as PublicKeyCredentialType,
|
||||
alg: p.alg,
|
||||
})),
|
||||
timeout: options.timeout || 60000,
|
||||
excludeCredentials: options.excludeCredentials?.map((cred) => ({
|
||||
type: cred.type as PublicKeyCredentialType,
|
||||
id: base64UrlToBuffer(cred.id),
|
||||
transports: cred.transports as AuthenticatorTransport[],
|
||||
})),
|
||||
authenticatorSelection: options.authenticatorSelection as AuthenticatorSelectionCriteria,
|
||||
};
|
||||
|
||||
// 3. 调用浏览器 WebAuthn API
|
||||
const credential = (await navigator.credentials.create({
|
||||
publicKey: createOptions,
|
||||
})) as PublicKeyCredential;
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Failed to create credential');
|
||||
}
|
||||
|
||||
// 4. 序列化 credential 为 JSON
|
||||
const response = credential.response as AuthenticatorAttestationResponse;
|
||||
const attestationResponse = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
attestationObject: bufferToBase64Url(response.attestationObject),
|
||||
},
|
||||
};
|
||||
|
||||
// 5. 提交到后端验证
|
||||
await apiPasskey.verifyRegistration({
|
||||
name: deviceName,
|
||||
attestation_response: attestationResponse,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 认证流程 ============
|
||||
|
||||
/**
|
||||
* 使用 Passkey 登录
|
||||
* @param username 用户名
|
||||
*/
|
||||
export async function loginWithPasskey(username: string): Promise<void> {
|
||||
// 1. 获取认证选项
|
||||
const options = await apiPasskey.getLoginOptions({ username });
|
||||
|
||||
// 2. 转换选项
|
||||
const getOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge: base64UrlToBuffer(options.challenge),
|
||||
timeout: options.timeout || 60000,
|
||||
rpId: options.rpId,
|
||||
allowCredentials: options.allowCredentials?.map((cred) => ({
|
||||
type: cred.type as PublicKeyCredentialType,
|
||||
id: base64UrlToBuffer(cred.id),
|
||||
transports: cred.transports as AuthenticatorTransport[],
|
||||
})),
|
||||
userVerification: options.userVerification as UserVerificationRequirement,
|
||||
};
|
||||
|
||||
// 3. 调用浏览器 API
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey: getOptions,
|
||||
})) as PublicKeyCredential;
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Failed to get credential');
|
||||
}
|
||||
|
||||
// 4. 序列化响应
|
||||
const response = credential.response as AuthenticatorAssertionResponse;
|
||||
const assertionResponse = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
authenticatorData: bufferToBase64Url(response.authenticatorData),
|
||||
signature: bufferToBase64Url(response.signature),
|
||||
userHandle: response.userHandle
|
||||
? bufferToBase64Url(response.userHandle)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
|
||||
// 5. 提交到后端验证并登录
|
||||
await apiPasskey.loginWithPasskey({
|
||||
username,
|
||||
credential: assertionResponse,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 浏览器支持检测 ============
|
||||
|
||||
export function isWebAuthnSupported(): boolean {
|
||||
return !!(
|
||||
window.PublicKeyCredential &&
|
||||
navigator.credentials &&
|
||||
navigator.credentials.create
|
||||
);
|
||||
}
|
||||
|
||||
export async function isPlatformAuthenticatorAvailable(): Promise<boolean> {
|
||||
if (!isWebAuthnSupported()) return false;
|
||||
|
||||
try {
|
||||
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useClipboard, useIntervalFn } from '@vueuse/core';
|
||||
|
||||
export const useLogStore = defineStore('log', () => {
|
||||
const message = useMessage();
|
||||
const { isLoggedIn } = useAuth();
|
||||
@@ -26,8 +28,12 @@ export const useLogStore = defineStore('log', () => {
|
||||
});
|
||||
|
||||
function copy() {
|
||||
const { copy: copyLog, isSupported } = useClipboard({ source: log });
|
||||
if (isSupported) {
|
||||
const { copy: copyLog, isSupported } = useClipboard({
|
||||
source: log.value,
|
||||
legacy: true,
|
||||
});
|
||||
|
||||
if (isSupported.value) {
|
||||
copyLog();
|
||||
message.success(t('notify.copy_success'));
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
|
||||
type MediaPlayerType = 'jump' | 'iframe';
|
||||
|
||||
export const usePlayerStore = defineStore('player', () => {
|
||||
const types = ref<MediaPlayerType[]>(['jump', 'iframe']);
|
||||
const type = useLocalStorage<MediaPlayerType>('media-player-type', 'jump');
|
||||
const url = useLocalStorage<string>('media-player-url', '');
|
||||
const url = useLocalStorage('media-player-url', '');
|
||||
|
||||
return {
|
||||
types,
|
||||
|
||||
@@ -1,74 +1,38 @@
|
||||
import { ref } from 'vue';
|
||||
import { EMPTY, Subject, debounceTime, switchMap, tap } from 'rxjs';
|
||||
import type { BangumiRule, SearchResult } from '#/bangumi';
|
||||
|
||||
export const useSearchStore = defineStore('search', () => {
|
||||
const bangumiList = ref<SearchResult[]>([]);
|
||||
const inputValue = ref<string>('');
|
||||
|
||||
const providers = ref<string[]>(['mikan', 'dmhy', 'nyaa']);
|
||||
const provider = ref<string>(providers.value[0]);
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
const {
|
||||
keyword,
|
||||
provider,
|
||||
open: openSearch,
|
||||
close: closeSearch,
|
||||
data: searchData,
|
||||
status,
|
||||
} = apiSearch.get();
|
||||
|
||||
const input$ = new Subject<string>();
|
||||
provider.value = providers.value[0];
|
||||
|
||||
watch(inputValue, (input) => {
|
||||
input$.next(input);
|
||||
loading.value = !!input;
|
||||
});
|
||||
const loading = computed(() => status.value !== 'CLOSED');
|
||||
|
||||
function getProviders() {
|
||||
apiSearch.getProvider().then((res) => {
|
||||
providers.value = res;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* - 输入中 debounce 600ms 后触发搜索
|
||||
* - 按回车或点击搜索 icon 按钮后触发搜索
|
||||
* - 切换 provider 源站时触发搜索
|
||||
*/
|
||||
|
||||
const bangumiInfo$ = input$
|
||||
.pipe(
|
||||
debounceTime(600),
|
||||
// switchMap 把输入 keyword 查询为 bangumiInfo$ 流,多次输入自动取消并停止前一次查询
|
||||
switchMap((input: string) => {
|
||||
// 有输入更新后清理之前的搜索结果
|
||||
bangumiList.value = [];
|
||||
return input ? apiSearch.get(input, provider.value) : EMPTY;
|
||||
}),
|
||||
tap((bangumi: BangumiRule) => {
|
||||
const result: SearchResult = {
|
||||
order: bangumiList.value.length + 1,
|
||||
value: bangumi,
|
||||
};
|
||||
bangumiList.value.push(result);
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
function onSearch() {
|
||||
input$.next(inputValue.value);
|
||||
async function getProviders() {
|
||||
providers.value = await apiSearch.getProvider();
|
||||
provider.value = providers.value[0];
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
inputValue.value = '';
|
||||
bangumiList.value = [];
|
||||
keyword.value = '';
|
||||
}
|
||||
|
||||
return {
|
||||
input$,
|
||||
bangumiInfo$,
|
||||
inputValue,
|
||||
keyword,
|
||||
loading,
|
||||
provider,
|
||||
providers,
|
||||
bangumiList,
|
||||
searchData,
|
||||
|
||||
onSearch,
|
||||
clearSearch,
|
||||
getProviders,
|
||||
openSearch,
|
||||
closeSearch,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,62 +1,91 @@
|
||||
import type { UnionToTuple } from '#/utils';
|
||||
import type { TupleToUnion } from './utils';
|
||||
|
||||
/** 下载方式 */
|
||||
export type DownloaderType = ['qbittorrent'];
|
||||
/** rss parser 源 */
|
||||
export type RssParserType = ['mikan'];
|
||||
/** rss parser 方法 */
|
||||
export type RssParserMethodType = ['tmdb', 'mikan', 'parser'];
|
||||
/** rss parser 语言 */
|
||||
export type RssParserLang = ['zh', 'en', 'jp'];
|
||||
/** 重命名方式 */
|
||||
export type RenameMethod = ['normal', 'pn', 'advance', 'none'];
|
||||
/** 代理类型 */
|
||||
export type ProxyType = ['http', 'https', 'socks5'];
|
||||
/** 通知类型 */
|
||||
export type NotificationType = ['telegram', 'server-chan', 'bark', 'wecom'];
|
||||
/** OpenAI Model List */
|
||||
export type OpenAIModel = ['gpt-3.5-turbo'];
|
||||
/** OpenAI API Type */
|
||||
export type OpenAIType = ['openai', 'azure'];
|
||||
|
||||
export interface Program {
|
||||
rss_time: number;
|
||||
rename_time: number;
|
||||
webui_port: number;
|
||||
}
|
||||
|
||||
export interface Downloader {
|
||||
type: TupleToUnion<DownloaderType>;
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
path: string;
|
||||
ssl: boolean;
|
||||
}
|
||||
export interface RssParser {
|
||||
enable: boolean;
|
||||
type: TupleToUnion<RssParserType>;
|
||||
token: string;
|
||||
custom_url: string;
|
||||
filter: Array<string>;
|
||||
language: TupleToUnion<RssParserLang>;
|
||||
parser_type: TupleToUnion<RssParserMethodType>;
|
||||
}
|
||||
export interface BangumiManage {
|
||||
enable: boolean;
|
||||
eps_complete: boolean;
|
||||
rename_method: TupleToUnion<RenameMethod>;
|
||||
group_tag: boolean;
|
||||
remove_bad_torrent: boolean;
|
||||
}
|
||||
export interface Log {
|
||||
debug_enable: boolean;
|
||||
}
|
||||
export interface Proxy {
|
||||
enable: boolean;
|
||||
type: TupleToUnion<ProxyType>;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
export interface Notification {
|
||||
enable: boolean;
|
||||
type: 'telegram' | 'server-chan' | 'bark' | 'wecom';
|
||||
token: string;
|
||||
chat_id: string;
|
||||
}
|
||||
export interface ExperimentalOpenAI {
|
||||
enable: boolean;
|
||||
api_key: string;
|
||||
api_base: string;
|
||||
model: TupleToUnion<OpenAIModel>;
|
||||
// azure
|
||||
api_type: TupleToUnion<OpenAIType>;
|
||||
api_version?: string;
|
||||
deployment_id?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
program: {
|
||||
rss_time: number;
|
||||
rename_time: number;
|
||||
webui_port: number;
|
||||
};
|
||||
downloader: {
|
||||
type: 'qbittorrent';
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
path: string;
|
||||
ssl: boolean;
|
||||
};
|
||||
rss_parser: {
|
||||
enable: boolean;
|
||||
type: 'mikan';
|
||||
token: string;
|
||||
custom_url: string;
|
||||
filter: Array<string>;
|
||||
language: 'zh' | 'en' | 'jp';
|
||||
parser_type: 'tmdb' | 'mikan' | 'parser';
|
||||
};
|
||||
bangumi_manage: {
|
||||
enable: boolean;
|
||||
eps_complete: boolean;
|
||||
rename_method: 'normal' | 'pn' | 'advance' | 'none';
|
||||
group_tag: boolean;
|
||||
remove_bad_torrent: boolean;
|
||||
};
|
||||
log: {
|
||||
debug_enable: boolean;
|
||||
};
|
||||
proxy: {
|
||||
enable: boolean;
|
||||
type: 'http' | 'https' | 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
notification: {
|
||||
enable: boolean;
|
||||
type: 'telegram' | 'server-chan' | 'bark' | 'wecom';
|
||||
token: string;
|
||||
chat_id: string;
|
||||
};
|
||||
experimental_openai: {
|
||||
enable: boolean;
|
||||
api_key: string;
|
||||
api_base: string;
|
||||
model: 'gpt-3.5-turbo';
|
||||
// azure
|
||||
api_type: 'openai' | 'azure';
|
||||
api_version?: string;
|
||||
deployment_id?: string;
|
||||
};
|
||||
program: Program;
|
||||
downloader: Downloader;
|
||||
rss_parser: RssParser;
|
||||
bangumi_manage: BangumiManage;
|
||||
log: Log;
|
||||
proxy: Proxy;
|
||||
notification: Notification;
|
||||
experimental_openai: ExperimentalOpenAI;
|
||||
}
|
||||
|
||||
export const initConfig: Config = {
|
||||
@@ -117,33 +146,3 @@ export const initConfig: Config = {
|
||||
deployment_id: '',
|
||||
},
|
||||
};
|
||||
|
||||
type getItem<T extends keyof Config> = Pick<Config, T>[T];
|
||||
|
||||
export type Program = getItem<'program'>;
|
||||
export type Downloader = getItem<'downloader'>;
|
||||
export type RssParser = getItem<'rss_parser'>;
|
||||
export type BangumiManage = getItem<'bangumi_manage'>;
|
||||
export type Log = getItem<'log'>;
|
||||
export type Proxy = getItem<'proxy'>;
|
||||
export type Notification = getItem<'notification'>;
|
||||
export type ExperimentalOpenAI = getItem<'experimental_openai'>;
|
||||
|
||||
/** 下载方式 */
|
||||
export type DownloaderType = UnionToTuple<Downloader['type']>;
|
||||
/** rss parser 源 */
|
||||
export type RssParserType = UnionToTuple<RssParser['type']>;
|
||||
/** rss parser 方法 */
|
||||
export type RssParserMethodType = UnionToTuple<RssParser['parser_type']>;
|
||||
/** rss parser 语言 */
|
||||
export type RssParserLang = UnionToTuple<RssParser['language']>;
|
||||
/** 重命名方式 */
|
||||
export type RenameMethod = UnionToTuple<BangumiManage['rename_method']>;
|
||||
/** 代理类型 */
|
||||
export type ProxyType = UnionToTuple<Proxy['type']>;
|
||||
/** 通知类型 */
|
||||
export type NotificationType = UnionToTuple<Notification['type']>;
|
||||
/** OpenAI Model List */
|
||||
export type OpenAIModel = UnionToTuple<ExperimentalOpenAI['model']>;
|
||||
/** OpenAI API Type */
|
||||
export type OpenAIType = UnionToTuple<ExperimentalOpenAI['api_type']>;
|
||||
|
||||
2
webui/types/dts/auto-imports.d.ts
vendored
2
webui/types/dts/auto-imports.d.ts
vendored
@@ -11,6 +11,7 @@ declare global {
|
||||
const apiConfig: typeof import('../../src/api/config')['apiConfig']
|
||||
const apiDownload: typeof import('../../src/api/download')['apiDownload']
|
||||
const apiLog: typeof import('../../src/api/log')['apiLog']
|
||||
const apiPasskey: typeof import('../../src/api/passkey')['apiPasskey']
|
||||
const apiProgram: typeof import('../../src/api/program')['apiProgram']
|
||||
const apiRSS: typeof import('../../src/api/rss')['apiRSS']
|
||||
const apiSearch: typeof import('../../src/api/search')['apiSearch']
|
||||
@@ -100,6 +101,7 @@ declare global {
|
||||
const useLogStore: typeof import('../../src/store/log')['useLogStore']
|
||||
const useMessage: typeof import('../../src/hooks/useMessage')['useMessage']
|
||||
const useMyI18n: typeof import('../../src/hooks/useMyI18n')['useMyI18n']
|
||||
const usePasskey: typeof import('../../src/hooks/usePasskey')['usePasskey']
|
||||
const usePlayerStore: typeof import('../../src/store/player')['usePlayerStore']
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
const useProgramStore: typeof import('../../src/store/program')['useProgramStore']
|
||||
|
||||
1
webui/types/dts/components.d.ts
vendored
1
webui/types/dts/components.d.ts
vendored
@@ -19,6 +19,7 @@ declare module '@vue/runtime-core' {
|
||||
AbContainer: typeof import('./../../src/components/ab-container.vue')['default']
|
||||
AbEditRule: typeof import('./../../src/components/ab-edit-rule.vue')['default']
|
||||
AbFoldPanel: typeof import('./../../src/components/ab-fold-panel.vue')['default']
|
||||
AbImage: typeof import('./../../src/components/ab-image.vue')['default']
|
||||
AbLabel: typeof import('./../../src/components/ab-label.vue')['default']
|
||||
AbPageTitle: typeof import('./../../src/components/basic/ab-page-title.vue')['default']
|
||||
AbPopup: typeof import('./../../src/components/ab-popup.vue')['default']
|
||||
|
||||
70
webui/types/passkey.ts
Normal file
70
webui/types/passkey.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Passkey 类型定义
|
||||
*/
|
||||
|
||||
// Passkey 列表项
|
||||
export interface PasskeyItem {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
backup_eligible: boolean;
|
||||
aaguid: string | null;
|
||||
}
|
||||
|
||||
// 注册选项(从后端返回)
|
||||
export interface RegistrationOptions {
|
||||
challenge: string;
|
||||
rp: { name: string; id: string };
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
pubKeyCredParams: Array<{ type: string; alg: number }>;
|
||||
timeout?: number;
|
||||
excludeCredentials?: Array<{
|
||||
type: string;
|
||||
id: string;
|
||||
transports?: string[];
|
||||
}>;
|
||||
authenticatorSelection?: {
|
||||
residentKey?: string;
|
||||
userVerification?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 认证选项
|
||||
export interface AuthenticationOptions {
|
||||
challenge: string;
|
||||
timeout?: number;
|
||||
rpId?: string;
|
||||
allowCredentials?: Array<{
|
||||
type: string;
|
||||
id: string;
|
||||
transports?: string[];
|
||||
}>;
|
||||
userVerification?: string;
|
||||
}
|
||||
|
||||
// 注册请求
|
||||
export interface PasskeyCreateRequest {
|
||||
name: string;
|
||||
attestation_response: unknown;
|
||||
}
|
||||
|
||||
// 删除请求
|
||||
export interface PasskeyDeleteRequest {
|
||||
passkey_id: number;
|
||||
}
|
||||
|
||||
// 认证开始请求
|
||||
export interface PasskeyAuthStartRequest {
|
||||
username: string;
|
||||
}
|
||||
|
||||
// 认证完成请求
|
||||
export interface PasskeyAuthFinishRequest {
|
||||
username: string;
|
||||
credential: unknown;
|
||||
}
|
||||
@@ -1,39 +1 @@
|
||||
/**
|
||||
* 将联合类型转为对应的交叉函数类型
|
||||
* @template U 联合类型
|
||||
*/
|
||||
export type UnionToInterFunction<U> = (
|
||||
U extends any ? (k: () => U) => void : never
|
||||
) extends (k: infer I) => void
|
||||
? I
|
||||
: never;
|
||||
|
||||
/**
|
||||
* 获取联合类型中的最后一个类型
|
||||
* @template U 联合类型
|
||||
*/
|
||||
export type GetUnionLast<U> = UnionToInterFunction<U> extends { (): infer A }
|
||||
? A
|
||||
: never;
|
||||
|
||||
/**
|
||||
* 在元组类型中前置插入一个新的类型(元素);
|
||||
* @template Tuple 元组类型
|
||||
* @template E 新的类型
|
||||
*/
|
||||
export type Prepend<Tuple extends any[], E> = [E, ...Tuple];
|
||||
|
||||
/**
|
||||
* 联合类型转元组类型;
|
||||
* @template Union 联合类型
|
||||
* @template T 初始元组类型
|
||||
* @template Last 传入联合类型中的最后一个类型(元素),自动生成,内部使用
|
||||
*/
|
||||
export type UnionToTuple<
|
||||
Union,
|
||||
T extends any[] = [],
|
||||
Last = GetUnionLast<Union>
|
||||
> = {
|
||||
0: T;
|
||||
1: UnionToTuple<Exclude<Union, Last>, Prepend<T, Last>>;
|
||||
}[[Union] extends [never] ? 0 : 1];
|
||||
export type TupleToUnion<T extends any[]> = T[number];
|
||||
|
||||
Reference in New Issue
Block a user