feat: support upstream token refresh
This commit is contained in:
@@ -12,7 +12,7 @@ class Upstream(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
base_url: Mapped[str] = mapped_column(String(512), nullable=False)
|
base_url: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
api_prefix: Mapped[str] = mapped_column(String(128), default="/api/v1")
|
api_prefix: Mapped[str] = mapped_column(String(128), default="/api/v1")
|
||||||
# none | bearer | api_key | login_password
|
# none | bearer | new_api_token | nox_token | api_key | cookie | login_password
|
||||||
auth_type: Mapped[str] = mapped_column(String(32), default="login_password")
|
auth_type: Mapped[str] = mapped_column(String(32), default="login_password")
|
||||||
# JSON: {"email":"..","password":".."} or {"token":".."} etc.
|
# JSON: {"email":"..","password":".."} or {"token":".."} etc.
|
||||||
auth_config_json: Mapped[str] = mapped_column(Text, default="{}")
|
auth_config_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from app.utils.auth import get_current_user
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"])
|
router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"])
|
||||||
|
|
||||||
SENSITIVE_CANDIDATE_FIELDS = frozenset({"value", "cookie_value"})
|
SENSITIVE_CANDIDATE_FIELDS = frozenset({"value", "cookie_value", "refresh_token"})
|
||||||
|
|
||||||
|
|
||||||
class CaptureExtractResponse(BaseModel):
|
class CaptureExtractResponse(BaseModel):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import SessionLocal, get_db
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
from app.models.upstream import Upstream
|
from app.models.upstream import Upstream
|
||||||
from app.models.upstream_key import UpstreamGeneratedKey
|
from app.models.upstream_key import UpstreamGeneratedKey
|
||||||
@@ -25,6 +25,7 @@ from app.schemas.upstream import (
|
|||||||
UpstreamBatchActionItem, UpstreamBatchActionSummary, UpstreamBatchActionResponse,
|
UpstreamBatchActionItem, UpstreamBatchActionSummary, UpstreamBatchActionResponse,
|
||||||
)
|
)
|
||||||
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot, mask_secret, _extract_key_value
|
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot, mask_secret, _extract_key_value
|
||||||
|
from app.services.auth_config import MASK, mask_auth_config, normalize_auth_config
|
||||||
from app.services.snapshot_service import diff_snapshots
|
from app.services.snapshot_service import diff_snapshots
|
||||||
from app.services import scheduler as sched_svc
|
from app.services import scheduler as sched_svc
|
||||||
from app.services import webhook_service
|
from app.services import webhook_service
|
||||||
@@ -33,20 +34,6 @@ from app.utils.auth import get_current_user
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/upstreams", tags=["upstreams"])
|
router = APIRouter(prefix="/api/upstreams", tags=["upstreams"])
|
||||||
|
|
||||||
MASK = "***"
|
|
||||||
SECRET_KEYS = {"password", "token", "key", "secret"}
|
|
||||||
|
|
||||||
|
|
||||||
AUTH_CONFIG_ALLOWED_KEYS = {
|
|
||||||
"none": set(),
|
|
||||||
"bearer": {"token"},
|
|
||||||
"nox_token": {"token", "user_id", "new_api_user", "provider"},
|
|
||||||
"api_key": {"key", "header"},
|
|
||||||
"cookie": {"cookie_string", "new_api_user", "user_id", "provider"},
|
|
||||||
"login_password": {"email", "password", "login_path", "username_field", "new_api_user", "user_id"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _group_id(group: dict) -> str:
|
def _group_id(group: dict) -> str:
|
||||||
for key in ("id", "group_id", "groupId"):
|
for key in ("id", "group_id", "groupId"):
|
||||||
value = group.get(key)
|
value = group.get(key)
|
||||||
@@ -80,21 +67,23 @@ def _key_response(row: UpstreamGeneratedKey, include_value: bool = False) -> Gen
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mask_auth_config(auth_type: str, cfg: dict) -> dict:
|
def _persist_auth_config_update(db: Session, upstream: Upstream, updated_config: dict[str, Any]) -> None:
|
||||||
masked = {}
|
config_json = json.dumps(
|
||||||
for k, v in cfg.items():
|
normalize_auth_config(upstream.auth_type, updated_config),
|
||||||
if k.lower() in SECRET_KEYS and v:
|
ensure_ascii=False,
|
||||||
masked[k] = MASK
|
)
|
||||||
else:
|
write_db = SessionLocal()
|
||||||
masked[k] = v
|
updated_at = datetime.now(timezone.utc)
|
||||||
return masked
|
try:
|
||||||
|
row = write_db.query(Upstream).filter(Upstream.id == upstream.id).first()
|
||||||
|
if row:
|
||||||
def _normalize_auth_config(auth_type: str, cfg: dict) -> dict:
|
row.auth_config_json = config_json
|
||||||
allowed = AUTH_CONFIG_ALLOWED_KEYS.get(auth_type)
|
row.updated_at = updated_at
|
||||||
if allowed is None:
|
write_db.commit()
|
||||||
return cfg
|
finally:
|
||||||
return {k: v for k, v in cfg.items() if k in allowed}
|
write_db.close()
|
||||||
|
upstream.auth_config_json = config_json
|
||||||
|
upstream.updated_at = updated_at
|
||||||
|
|
||||||
|
|
||||||
def _extract_plaintext_key(payload: dict[str, Any] | None) -> str:
|
def _extract_plaintext_key(payload: dict[str, Any] | None) -> str:
|
||||||
@@ -134,7 +123,7 @@ def _to_response(u: Upstream) -> UpstreamResponse:
|
|||||||
base_url=u.base_url,
|
base_url=u.base_url,
|
||||||
api_prefix=u.api_prefix,
|
api_prefix=u.api_prefix,
|
||||||
auth_type=u.auth_type,
|
auth_type=u.auth_type,
|
||||||
auth_config_masked=_mask_auth_config(u.auth_type, cfg),
|
auth_config_masked=mask_auth_config(u.auth_type, cfg),
|
||||||
rate_endpoint=u.rate_endpoint,
|
rate_endpoint=u.rate_endpoint,
|
||||||
groups_endpoint=u.groups_endpoint,
|
groups_endpoint=u.groups_endpoint,
|
||||||
enabled=u.enabled,
|
enabled=u.enabled,
|
||||||
@@ -205,6 +194,7 @@ def list_generated_keys(uid: int, db: Session = Depends(get_db), _=Depends(get_c
|
|||||||
auth_type=upstream.auth_type,
|
auth_type=upstream.auth_type,
|
||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(upstream.timeout_seconds),
|
timeout=float(upstream.timeout_seconds),
|
||||||
|
on_auth_config_update=lambda updated: _persist_auth_config_update(db, upstream, updated),
|
||||||
) as client:
|
) as client:
|
||||||
client.login()
|
client.login()
|
||||||
for prefix in website_sync._fetch_remote_managed_prefixes(db, uid):
|
for prefix in website_sync._fetch_remote_managed_prefixes(db, uid):
|
||||||
@@ -343,8 +333,11 @@ def _is_nox_api_upstream(upstream: Upstream) -> bool:
|
|||||||
and upstream.groups_endpoint == "/api/user/self/groups"
|
and upstream.groups_endpoint == "/api/user/self/groups"
|
||||||
and (
|
and (
|
||||||
upstream.auth_type == "nox_token"
|
upstream.auth_type == "nox_token"
|
||||||
or auth_config.get("login_path") == "/api/user/login"
|
or auth_config.get("provider") == "nox-api"
|
||||||
or bool(auth_config.get("user_id"))
|
or (
|
||||||
|
auth_config.get("provider") not in {"new-api", "new-api-user"}
|
||||||
|
and auth_config.get("login_path") == "/api/user/login"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -354,7 +347,9 @@ def _is_new_api_user_upstream(upstream: Upstream) -> bool:
|
|||||||
return (
|
return (
|
||||||
upstream.api_prefix.strip("/") == ""
|
upstream.api_prefix.strip("/") == ""
|
||||||
and (
|
and (
|
||||||
bool(auth_config.get("new_api_user"))
|
upstream.auth_type == "new_api_token"
|
||||||
|
or auth_config.get("provider") in {"new-api", "new-api-user"}
|
||||||
|
or bool(auth_config.get("new_api_user"))
|
||||||
or (
|
or (
|
||||||
upstream.groups_endpoint == "/api/user/self/groups"
|
upstream.groups_endpoint == "/api/user/self/groups"
|
||||||
and auth_config.get("login_path") != "/api/user/login"
|
and auth_config.get("login_path") != "/api/user/login"
|
||||||
@@ -540,6 +535,7 @@ def generate_keys_by_groups(
|
|||||||
auth_type=u.auth_type,
|
auth_type=u.auth_type,
|
||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(u.timeout_seconds),
|
timeout=float(u.timeout_seconds),
|
||||||
|
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
||||||
) as client:
|
) as client:
|
||||||
try:
|
try:
|
||||||
client.login()
|
client.login()
|
||||||
@@ -581,6 +577,7 @@ def _test_upstream_core(db: Session, u: Upstream) -> UpstreamBatchActionItem:
|
|||||||
auth_type=u.auth_type,
|
auth_type=u.auth_type,
|
||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(u.timeout_seconds),
|
timeout=float(u.timeout_seconds),
|
||||||
|
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
||||||
) as client:
|
) as client:
|
||||||
client.login()
|
client.login()
|
||||||
groups = client.get_available_groups(u.groups_endpoint)
|
groups = client.get_available_groups(u.groups_endpoint)
|
||||||
@@ -620,6 +617,7 @@ def _check_now_core(db: Session, u: Upstream) -> tuple[str, bool]:
|
|||||||
auth_type=u.auth_type,
|
auth_type=u.auth_type,
|
||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(u.timeout_seconds),
|
timeout=float(u.timeout_seconds),
|
||||||
|
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
||||||
) as client:
|
) as client:
|
||||||
client.login()
|
client.login()
|
||||||
groups = client.get_available_groups(u.groups_endpoint)
|
groups = client.get_available_groups(u.groups_endpoint)
|
||||||
@@ -802,7 +800,7 @@ def create_upstream(
|
|||||||
base_url=body.base_url.rstrip("/"),
|
base_url=body.base_url.rstrip("/"),
|
||||||
api_prefix=body.api_prefix,
|
api_prefix=body.api_prefix,
|
||||||
auth_type=body.auth_type,
|
auth_type=body.auth_type,
|
||||||
auth_config_json=json.dumps(_normalize_auth_config(body.auth_type, body.auth_config), ensure_ascii=False),
|
auth_config_json=json.dumps(normalize_auth_config(body.auth_type, body.auth_config), ensure_ascii=False),
|
||||||
rate_endpoint=body.rate_endpoint,
|
rate_endpoint=body.rate_endpoint,
|
||||||
groups_endpoint=body.groups_endpoint,
|
groups_endpoint=body.groups_endpoint,
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
@@ -847,7 +845,7 @@ def update_upstream(
|
|||||||
if v != MASK: # don't overwrite with mask placeholder
|
if v != MASK: # don't overwrite with mask placeholder
|
||||||
existing[k] = v
|
existing[k] = v
|
||||||
next_auth_type = data.get("auth_type", u.auth_type)
|
next_auth_type = data.get("auth_type", u.auth_type)
|
||||||
u.auth_config_json = json.dumps(_normalize_auth_config(next_auth_type, existing), ensure_ascii=False)
|
u.auth_config_json = json.dumps(normalize_auth_config(next_auth_type, existing), ensure_ascii=False)
|
||||||
if "base_url" in data:
|
if "base_url" in data:
|
||||||
data["base_url"] = data["base_url"].rstrip("/")
|
data["base_url"] = data["base_url"].rstrip("/")
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
class AuthConfigBearer(BaseModel):
|
class AuthConfigBearer(BaseModel):
|
||||||
token: str = ""
|
token: str = ""
|
||||||
|
refresh_token: str = ""
|
||||||
|
expires_in: Optional[int] = None
|
||||||
|
token_expires_at: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class AuthConfigApiKey(BaseModel):
|
class AuthConfigApiKey(BaseModel):
|
||||||
@@ -16,13 +19,14 @@ class AuthConfigLoginPassword(BaseModel):
|
|||||||
email: str = ""
|
email: str = ""
|
||||||
password: str = ""
|
password: str = ""
|
||||||
login_path: str = "/auth/login"
|
login_path: str = "/auth/login"
|
||||||
|
username_field: str = "email"
|
||||||
|
|
||||||
|
|
||||||
class UpstreamCreate(BaseModel):
|
class UpstreamCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
base_url: str
|
base_url: str
|
||||||
api_prefix: str = "/api/v1"
|
api_prefix: str = "/api/v1"
|
||||||
auth_type: str = "login_password" # none | bearer | api_key | login_password
|
auth_type: str = "login_password" # none | bearer | new_api_token | nox_token | api_key | cookie | login_password
|
||||||
auth_config: dict[str, Any] = {}
|
auth_config: dict[str, Any] = {}
|
||||||
rate_endpoint: str = "/groups/rates"
|
rate_endpoint: str = "/groups/rates"
|
||||||
groups_endpoint: str = "/groups/available"
|
groups_endpoint: str = "/groups/available"
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ from urllib.parse import urlparse
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Keys likely to contain auth tokens in storage
|
# Keys likely to contain auth tokens in storage
|
||||||
TOKEN_KEYS = frozenset({
|
ACCESS_TOKEN_KEYS = frozenset({
|
||||||
"token", "access_token", "accessToken", "jwt", "auth_token", "authToken",
|
"token", "access_token", "accesstoken", "jwt", "auth_token", "authtoken",
|
||||||
"refresh_token", "refreshToken", "id_token", "session_token",
|
"id_token", "idtoken", "session_token", "sessiontoken",
|
||||||
})
|
})
|
||||||
|
REFRESH_TOKEN_KEYS = frozenset({"refresh_token", "refreshtoken"})
|
||||||
|
TOKEN_KEYS = ACCESS_TOKEN_KEYS | REFRESH_TOKEN_KEYS
|
||||||
SECRET_KEYS = frozenset({
|
SECRET_KEYS = frozenset({
|
||||||
"secret", "api_key", "apiKey", "apikey",
|
"secret", "api_key", "apiKey", "apikey",
|
||||||
})
|
})
|
||||||
@@ -116,6 +118,27 @@ def _curate_candidates(
|
|||||||
extra=bundle_extra,
|
extra=bundle_extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 0.5. Sub2API browser sessions keep access token and refresh token separately.
|
||||||
|
storage_sources = [("localStorage", local_storage), ("sessionStorage", session_storage)]
|
||||||
|
access_entry = _find_storage_token_entry(storage_sources, ACCESS_TOKEN_KEYS)
|
||||||
|
refresh_entry = _find_storage_token_entry(storage_sources, REFRESH_TOKEN_KEYS)
|
||||||
|
if access_entry and refresh_entry:
|
||||||
|
access_source, access_value = access_entry
|
||||||
|
refresh_source, refresh_value = refresh_entry
|
||||||
|
_add(
|
||||||
|
candidates,
|
||||||
|
"bearer_token",
|
||||||
|
f"{access_source}+{refresh_source}",
|
||||||
|
access_value,
|
||||||
|
_preview(access_value),
|
||||||
|
"Sub2API Bearer + Refresh Token",
|
||||||
|
96,
|
||||||
|
extra={
|
||||||
|
"refresh_token": refresh_value,
|
||||||
|
"refresh_token_preview": _preview(refresh_value),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# 1. CDP-captured network headers (high confidence)
|
# 1. CDP-captured network headers (high confidence)
|
||||||
seen = set()
|
seen = set()
|
||||||
for h in auth_headers:
|
for h in auth_headers:
|
||||||
@@ -141,9 +164,10 @@ def _curate_candidates(
|
|||||||
if not isinstance(val, str) or not val:
|
if not isinstance(val, str) or not val:
|
||||||
continue
|
continue
|
||||||
key_lower = key.lower()
|
key_lower = key.lower()
|
||||||
|
is_refresh_key = any(k in key_lower for k in REFRESH_TOKEN_KEYS)
|
||||||
|
|
||||||
# Explicit auth-named keys
|
# Explicit auth-named keys
|
||||||
if any(k in key_lower for k in TOKEN_KEYS):
|
if any(k in key_lower for k in TOKEN_KEYS) and not is_refresh_key:
|
||||||
preview = _preview(val)
|
preview = _preview(val)
|
||||||
score = 85 if "token" in key_lower and val.count(".") >= 2 else 75
|
score = 85 if "token" in key_lower and val.count(".") >= 2 else 75
|
||||||
_add(candidates, "bearer_token", f"{store_name}.{key}", val, preview,
|
_add(candidates, "bearer_token", f"{store_name}.{key}", val, preview,
|
||||||
@@ -153,14 +177,14 @@ def _curate_candidates(
|
|||||||
f"{store_name}.{key}", 70)
|
f"{store_name}.{key}", 70)
|
||||||
|
|
||||||
# Looks like a JWT (xx.yy.zz format)
|
# Looks like a JWT (xx.yy.zz format)
|
||||||
if val.count(".") >= 2 and 20 < len(val) < 5000:
|
if not is_refresh_key and val.count(".") >= 2 and 20 < len(val) < 5000:
|
||||||
if val not in seen:
|
if val not in seen:
|
||||||
seen.add(val)
|
seen.add(val)
|
||||||
_add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val),
|
_add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val),
|
||||||
f"{store_name}.{key} (JWT)", 80)
|
f"{store_name}.{key} (JWT)", 80)
|
||||||
|
|
||||||
# sk-xxx API key pattern
|
# sk-xxx API key pattern
|
||||||
if val.startswith("sk-") and len(val) > 10:
|
if not is_refresh_key and val.startswith("sk-") and len(val) > 10:
|
||||||
_add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val),
|
_add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val),
|
||||||
f"{store_name}.{key} (API Key)", 90)
|
f"{store_name}.{key} (API Key)", 90)
|
||||||
|
|
||||||
@@ -198,6 +222,19 @@ def _find_user_header(headers: list[dict[str, str]]) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_storage_token_entry(
|
||||||
|
stores: list[tuple[str, dict[str, str]]],
|
||||||
|
key_names: frozenset[str],
|
||||||
|
) -> tuple[str, str] | None:
|
||||||
|
for store_name, store in stores:
|
||||||
|
for key, value in store.items():
|
||||||
|
if key.lower() not in key_names:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return f"{store_name}.{key}", value.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _find_storage_value(*stores: dict[str, str], key: str) -> str:
|
def _find_storage_value(*stores: dict[str, str], key: str) -> str:
|
||||||
for store in stores:
|
for store in stores:
|
||||||
value = store.get(key)
|
value = store.get(key)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
MASK = "***"
|
||||||
|
SECRET_KEYS = {"password", "token", "refresh_token", "access_token", "auth_token", "key", "secret"}
|
||||||
|
|
||||||
|
AUTH_CONFIG_ALLOWED_KEYS = {
|
||||||
|
"none": set(),
|
||||||
|
"bearer": {"token", "refresh_token", "expires_in", "token_expires_at"},
|
||||||
|
"new_api_token": {"token", "user_id", "new_api_user", "provider"},
|
||||||
|
"nox_token": {"token", "user_id", "new_api_user", "provider"},
|
||||||
|
"api_key": {"key", "header"},
|
||||||
|
"cookie": {"cookie_string", "new_api_user", "user_id", "provider"},
|
||||||
|
"login_password": {
|
||||||
|
"email", "password", "login_path", "username_field", "new_api_user", "user_id", "provider",
|
||||||
|
"token", "refresh_token", "expires_in", "token_expires_at",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mask_auth_config(auth_type: str, cfg: dict) -> dict:
|
||||||
|
masked = {}
|
||||||
|
for k, v in cfg.items():
|
||||||
|
if k.lower() in SECRET_KEYS and v:
|
||||||
|
masked[k] = MASK
|
||||||
|
else:
|
||||||
|
masked[k] = v
|
||||||
|
return masked
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_auth_config(auth_type: str, cfg: dict) -> dict:
|
||||||
|
allowed = AUTH_CONFIG_ALLOWED_KEYS.get(auth_type)
|
||||||
|
if allowed is None:
|
||||||
|
return cfg
|
||||||
|
return {k: v for k, v in cfg.items() if k in allowed}
|
||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
@@ -12,6 +13,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models.upstream import Upstream
|
from app.models.upstream import Upstream
|
||||||
from app.models.snapshot import UpstreamRateSnapshot
|
from app.models.snapshot import UpstreamRateSnapshot
|
||||||
|
from app.services.auth_config import normalize_auth_config
|
||||||
from app.services.upstream_client import UpstreamClient, build_snapshot
|
from app.services.upstream_client import UpstreamClient, build_snapshot
|
||||||
from app.services.snapshot_service import diff_snapshots, prune_snapshots
|
from app.services.snapshot_service import diff_snapshots, prune_snapshots
|
||||||
from app.services import webhook_service
|
from app.services import webhook_service
|
||||||
@@ -27,6 +29,25 @@ def get_scheduler() -> BackgroundScheduler:
|
|||||||
return _scheduler
|
return _scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_auth_config_update(upstream: Upstream, updated_config: dict[str, Any]) -> None:
|
||||||
|
config_json = json.dumps(
|
||||||
|
normalize_auth_config(upstream.auth_type, updated_config),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
updated_at = datetime.now(timezone.utc)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.query(Upstream).filter(Upstream.id == upstream.id).first()
|
||||||
|
if row:
|
||||||
|
row.auth_config_json = config_json
|
||||||
|
row.updated_at = updated_at
|
||||||
|
db.commit()
|
||||||
|
upstream.auth_config_json = row.auth_config_json
|
||||||
|
upstream.updated_at = updated_at
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _check_upstream(upstream_id: int) -> None:
|
def _check_upstream(upstream_id: int) -> None:
|
||||||
"""Full upstream check executed by scheduler (runs in thread).
|
"""Full upstream check executed by scheduler (runs in thread).
|
||||||
|
|
||||||
@@ -55,6 +76,7 @@ def _check_upstream(upstream_id: int) -> None:
|
|||||||
auth_type=upstream.auth_type,
|
auth_type=upstream.auth_type,
|
||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(upstream.timeout_seconds),
|
timeout=float(upstream.timeout_seconds),
|
||||||
|
on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated),
|
||||||
) as client:
|
) as client:
|
||||||
try:
|
try:
|
||||||
client.login()
|
client.login()
|
||||||
@@ -235,6 +257,7 @@ def _sync_upstream_keys(upstream_id: int, snapshot: dict[str, Any], captured_at:
|
|||||||
auth_type=upstream.auth_type,
|
auth_type=upstream.auth_type,
|
||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(upstream.timeout_seconds),
|
timeout=float(upstream.timeout_seconds),
|
||||||
|
on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated),
|
||||||
) as client:
|
) as client:
|
||||||
client.login()
|
client.login()
|
||||||
remote_key_ids = website_sync._fetch_remote_managed_key_ids(db, client, upstream_id)
|
remote_key_ids = website_sync._fetch_remote_managed_key_ids(db, client, upstream_id)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import Any, Optional
|
from typing import Any, Callable, Optional
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -32,6 +32,38 @@ def _find_token(value: Any) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_refresh_token(value: Any) -> str:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key in ("refresh_token", "refreshToken"):
|
||||||
|
candidate = value.get(key)
|
||||||
|
if isinstance(candidate, str) and candidate:
|
||||||
|
return candidate
|
||||||
|
for key in ("data", "result", "user", "session"):
|
||||||
|
tok = _find_refresh_token(value.get(key))
|
||||||
|
if tok:
|
||||||
|
return tok
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_expires_in(value: Any) -> int | None:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key in ("expires_in", "expiresIn"):
|
||||||
|
candidate = value.get(key)
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
expires_in = int(float(candidate))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if expires_in > 0:
|
||||||
|
return expires_in
|
||||||
|
for key in ("data", "result", "user", "session"):
|
||||||
|
expires_in = _find_expires_in(value.get(key))
|
||||||
|
if expires_in is not None:
|
||||||
|
return expires_in
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _clean_auth_header_value(value: Any, field_name: str) -> str:
|
def _clean_auth_header_value(value: Any, field_name: str) -> str:
|
||||||
text = str(value or "").strip()
|
text = str(value or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
@@ -289,12 +321,14 @@ class UpstreamClient:
|
|||||||
auth_type: str,
|
auth_type: str,
|
||||||
auth_config: dict[str, Any],
|
auth_config: dict[str, Any],
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
|
on_auth_config_update: Callable[[dict[str, Any]], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.api_prefix = api_prefix.strip("/")
|
self.api_prefix = api_prefix.strip("/")
|
||||||
self.auth_type = auth_type
|
self.auth_type = auth_type
|
||||||
self.auth_config = auth_config
|
self.auth_config = auth_config
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.on_auth_config_update = on_auth_config_update
|
||||||
self._token: str = ""
|
self._token: str = ""
|
||||||
self._cookies: dict[str, str] = {}
|
self._cookies: dict[str, str] = {}
|
||||||
self._new_api_user: str = ""
|
self._new_api_user: str = ""
|
||||||
@@ -321,6 +355,7 @@ class UpstreamClient:
|
|||||||
bool(self.auth_config.get("new_api_user"))
|
bool(self.auth_config.get("new_api_user"))
|
||||||
or login_path == "/api/user/login"
|
or login_path == "/api/user/login"
|
||||||
or self.auth_type == "cookie"
|
or self.auth_type == "cookie"
|
||||||
|
or self.auth_type == "new_api_token"
|
||||||
or self.auth_type == "nox_token"
|
or self.auth_type == "nox_token"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -349,6 +384,13 @@ class UpstreamClient:
|
|||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
if user_id:
|
if user_id:
|
||||||
headers["Nox-Api-User"] = user_id
|
headers["Nox-Api-User"] = user_id
|
||||||
|
elif self.auth_type == "new_api_token":
|
||||||
|
token = _clean_auth_header_value(self.auth_config.get("token", ""), "New-API access token")
|
||||||
|
user_id = self._user_header_value()
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
if user_id:
|
||||||
|
headers["New-Api-User"] = user_id
|
||||||
elif self.auth_type == "api_key":
|
elif self.auth_type == "api_key":
|
||||||
key = _clean_auth_header_value(self.auth_config.get("key", ""), "API key")
|
key = _clean_auth_header_value(self.auth_config.get("key", ""), "API key")
|
||||||
header = self.auth_config.get("header", "Authorization")
|
header = self.auth_config.get("header", "Authorization")
|
||||||
@@ -371,28 +413,117 @@ class UpstreamClient:
|
|||||||
headers["Nox-Api-User"] = self._new_api_user
|
headers["Nox-Api-User"] = self._new_api_user
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
def _is_sub2api_bearer(self) -> bool:
|
||||||
|
return self.auth_type == "bearer" and self.api_prefix == "api/v1"
|
||||||
|
|
||||||
|
def _remember_auth_tokens(
|
||||||
|
self,
|
||||||
|
token: str = "",
|
||||||
|
refresh_token: str = "",
|
||||||
|
expires_in: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
changed = False
|
||||||
|
if token and self.auth_config.get("token") != token:
|
||||||
|
self.auth_config["token"] = token
|
||||||
|
changed = True
|
||||||
|
if refresh_token and self.auth_config.get("refresh_token") != refresh_token:
|
||||||
|
self.auth_config["refresh_token"] = refresh_token
|
||||||
|
changed = True
|
||||||
|
if expires_in is not None:
|
||||||
|
if self.auth_config.get("expires_in") != expires_in:
|
||||||
|
self.auth_config["expires_in"] = expires_in
|
||||||
|
changed = True
|
||||||
|
token_expires_at = int(time.time()) + expires_in
|
||||||
|
if self.auth_config.get("token_expires_at") != token_expires_at:
|
||||||
|
self.auth_config["token_expires_at"] = token_expires_at
|
||||||
|
changed = True
|
||||||
|
if changed and self.on_auth_config_update:
|
||||||
|
self.on_auth_config_update(dict(self.auth_config))
|
||||||
|
|
||||||
|
def _refresh_sub2api_bearer_token(self) -> bool:
|
||||||
|
if not self._is_sub2api_bearer():
|
||||||
|
return False
|
||||||
|
refresh_token = str(self.auth_config.get("refresh_token") or "").strip()
|
||||||
|
if not refresh_token:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
resp = self._client.request(
|
||||||
|
"POST",
|
||||||
|
self._url("/auth/refresh"),
|
||||||
|
json={"refresh_token": refresh_token},
|
||||||
|
headers=self._headers(auth=False),
|
||||||
|
cookies=self._cookies,
|
||||||
|
)
|
||||||
|
self._cookies.update(dict(resp.cookies))
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json() if resp.content else {}
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
token = _find_token(payload)
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
self._remember_auth_tokens(
|
||||||
|
token=token,
|
||||||
|
refresh_token=_find_refresh_token(payload) or refresh_token,
|
||||||
|
expires_in=_find_expires_in(payload),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _send_request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
auth: bool = True,
|
||||||
|
allow_refresh: bool = True,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> httpx.Response:
|
||||||
|
resp = self._client.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers=self._headers(auth),
|
||||||
|
cookies=self._cookies,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
self._cookies.update(dict(resp.cookies))
|
||||||
|
if (
|
||||||
|
getattr(resp, "status_code", None) == 401
|
||||||
|
and auth
|
||||||
|
and allow_refresh
|
||||||
|
and self._is_sub2api_bearer()
|
||||||
|
and self._refresh_sub2api_bearer_token()
|
||||||
|
):
|
||||||
|
resp = self._client.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers=self._headers(auth),
|
||||||
|
cookies=self._cookies,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
self._cookies.update(dict(resp.cookies))
|
||||||
|
return resp
|
||||||
|
|
||||||
def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any:
|
def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any:
|
||||||
if auth and self.auth_type == "cookie" and "user/self" in path and not self.auth_config.get("new_api_user"):
|
if auth and self.auth_type == "cookie" and "user/self" in path and not self._user_header_value():
|
||||||
raise UpstreamError("New-API user endpoint requires New-Api-User; re-extract the session cookie after login and save the upstream")
|
raise UpstreamError("New-API user endpoint requires New-Api-User; re-extract the session cookie after login and save the upstream")
|
||||||
|
if auth and self.auth_type == "new_api_token" and "user/self" in path and not self._user_header_value():
|
||||||
|
raise UpstreamError("New-API endpoint requires New-Api-User; please fill user id and retry")
|
||||||
if auth and self.auth_type == "nox_token" and "user/self" in path and not self._user_header_value():
|
if auth and self.auth_type == "nox_token" and "user/self" in path and not self._user_header_value():
|
||||||
raise UpstreamError("Nox-API endpoint requires Nox-Api-User; please fill user id and retry")
|
raise UpstreamError("Nox-API endpoint requires Nox-Api-User; please fill user id and retry")
|
||||||
url = self._url(path)
|
url = self._url(path)
|
||||||
if body is not None:
|
if body is not None:
|
||||||
resp = self._client.request(
|
resp = self._send_request(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
json=body,
|
json=body,
|
||||||
headers=self._headers(auth),
|
auth=auth,
|
||||||
cookies=self._cookies,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
resp = self._client.request(
|
resp = self._send_request(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
headers=self._headers(auth),
|
auth=auth,
|
||||||
cookies=self._cookies,
|
|
||||||
)
|
)
|
||||||
self._cookies.update(dict(resp.cookies))
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
ct = resp.headers.get("content-type", "")
|
ct = resp.headers.get("content-type", "")
|
||||||
if not resp.content:
|
if not resp.content:
|
||||||
@@ -449,14 +580,11 @@ class UpstreamClient:
|
|||||||
return items, meta
|
return items, meta
|
||||||
|
|
||||||
def _request_new_api_token_list(self, path: str, params: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
def _request_new_api_token_list(self, path: str, params: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||||
resp = self._client.request(
|
resp = self._send_request(
|
||||||
"GET",
|
"GET",
|
||||||
self._url(path),
|
self._url(path),
|
||||||
params=params,
|
params=params,
|
||||||
headers=self._headers(),
|
|
||||||
cookies=self._cookies,
|
|
||||||
)
|
)
|
||||||
self._cookies.update(dict(resp.cookies))
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
self._ensure_api_success(data, "list New-API tokens")
|
self._ensure_api_success(data, "list New-API tokens")
|
||||||
@@ -585,14 +713,28 @@ class UpstreamClient:
|
|||||||
return
|
return
|
||||||
email = self.auth_config.get("email", "")
|
email = self.auth_config.get("email", "")
|
||||||
password = self.auth_config.get("password", "")
|
password = self.auth_config.get("password", "")
|
||||||
login_path = self.auth_config.get("login_path", "/auth/login")
|
default_login_path = "/api/user/login" if self.api_prefix == "" else "/auth/login"
|
||||||
username_field = self.auth_config.get("username_field", "email")
|
login_path = self.auth_config.get("login_path") or default_login_path
|
||||||
|
default_username_field = "username" if login_path == "/api/user/login" else "email"
|
||||||
|
username_field = self.auth_config.get("username_field") or default_username_field
|
||||||
if not email or not password:
|
if not email or not password:
|
||||||
raise UpstreamError("login_password auth requires email and password in auth_config")
|
raise UpstreamError("login_password auth requires email and password in auth_config")
|
||||||
resp = self._request("POST", login_path, {username_field: email, "password": password}, auth=False)
|
resp = self._request("POST", login_path, {username_field: email, "password": password}, auth=False)
|
||||||
token = _find_token(resp)
|
token = _find_token(resp)
|
||||||
if token:
|
if token:
|
||||||
self._token = token
|
self._token = token
|
||||||
|
self._new_api_user = (
|
||||||
|
self.auth_config.get("new_api_user", "")
|
||||||
|
or self.auth_config.get("user_id", "")
|
||||||
|
or _find_user_id(resp)
|
||||||
|
)
|
||||||
|
refresh_token = _find_refresh_token(resp)
|
||||||
|
if self.api_prefix == "api/v1" or refresh_token:
|
||||||
|
self._remember_auth_tokens(
|
||||||
|
token=token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
expires_in=_find_expires_in(resp),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
if self._cookies:
|
if self._cookies:
|
||||||
self._new_api_user = self.auth_config.get("new_api_user", "") or _find_user_id(resp)
|
self._new_api_user = self.auth_config.get("new_api_user", "") or _find_user_id(resp)
|
||||||
@@ -655,12 +797,10 @@ class UpstreamClient:
|
|||||||
if status:
|
if status:
|
||||||
params["status"] = status
|
params["status"] = status
|
||||||
url = self._url(endpoint)
|
url = self._url(endpoint)
|
||||||
resp = self._client.request(
|
resp = self._send_request(
|
||||||
"GET",
|
"GET",
|
||||||
url,
|
url,
|
||||||
params=params if params else None,
|
params=params if params else None,
|
||||||
headers=self._headers(),
|
|
||||||
cookies=self._cookies,
|
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
from app.models.snapshot import UpstreamRateSnapshot
|
from app.models.snapshot import UpstreamRateSnapshot
|
||||||
from app.models.upstream import Upstream
|
from app.models.upstream import Upstream
|
||||||
from app.models.upstream_key import UpstreamGeneratedKey
|
from app.models.upstream_key import UpstreamGeneratedKey
|
||||||
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog
|
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog
|
||||||
|
from app.services.auth_config import normalize_auth_config
|
||||||
from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate
|
from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate
|
||||||
from app.services.upstream_client import UpstreamClient
|
from app.services.upstream_client import UpstreamClient
|
||||||
from app.services import webhook_service
|
from app.services import webhook_service
|
||||||
@@ -23,6 +26,25 @@ PRIORITY_BASE = 1
|
|||||||
PRIORITY_STEP = 10
|
PRIORITY_STEP = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_upstream_auth_config(upstream: Upstream, updated_config: dict[str, Any]) -> None:
|
||||||
|
config_json = json.dumps(
|
||||||
|
normalize_auth_config(upstream.auth_type, updated_config),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
updated_at = datetime.now(timezone.utc)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.query(Upstream).filter(Upstream.id == upstream.id).first()
|
||||||
|
if row:
|
||||||
|
row.auth_config_json = config_json
|
||||||
|
row.updated_at = updated_at
|
||||||
|
db.commit()
|
||||||
|
upstream.auth_config_json = row.auth_config_json
|
||||||
|
upstream.updated_at = updated_at
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def priority_for_rate_rank(rank: int) -> int:
|
def priority_for_rate_rank(rank: int) -> int:
|
||||||
"""Convert a zero-based sorted rate rank to an account priority."""
|
"""Convert a zero-based sorted rate rank to an account priority."""
|
||||||
return PRIORITY_BASE + rank * PRIORITY_STEP
|
return PRIORITY_BASE + rank * PRIORITY_STEP
|
||||||
@@ -672,6 +694,7 @@ def reconcile_upstream_keys_full(db: Session, upstream_id: int) -> bool:
|
|||||||
auth_type=upstream.auth_type,
|
auth_type=upstream.auth_type,
|
||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(upstream.timeout_seconds),
|
timeout=float(upstream.timeout_seconds),
|
||||||
|
on_auth_config_update=lambda updated: _persist_upstream_auth_config(upstream, updated),
|
||||||
) as client:
|
) as client:
|
||||||
client.login()
|
client.login()
|
||||||
# 获取远端 Key 列表(支持自定义 managed_prefix)
|
# 获取远端 Key 列表(支持自定义 managed_prefix)
|
||||||
|
|||||||
@@ -218,6 +218,39 @@ def test_new_api_user_propagated_to_bundle():
|
|||||||
assert bundle.get("new_api_user") == "42"
|
assert bundle.get("new_api_user") == "42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_access_and_refresh_token_build_sub2api_bearer_candidate():
|
||||||
|
candidates = _curate_candidates(
|
||||||
|
cookies=[],
|
||||||
|
local_storage={"auth_token": "access-token", "refresh_token": "refresh-token"},
|
||||||
|
session_storage={},
|
||||||
|
auth_headers=[],
|
||||||
|
page_url="https://sub2api.example.com/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert candidates[0]["type"] == "bearer_token"
|
||||||
|
assert candidates[0]["value"] == "access-token"
|
||||||
|
assert candidates[0]["refresh_token"] == "refresh-token"
|
||||||
|
assert candidates[0]["label"] == "Sub2API Bearer + Refresh Token"
|
||||||
|
assert not any(
|
||||||
|
c["type"] == "bearer_token" and c.get("value") == "refresh-token"
|
||||||
|
for c in candidates
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_access_token_without_refresh_keeps_bearer_candidate():
|
||||||
|
candidates = _curate_candidates(
|
||||||
|
cookies=[],
|
||||||
|
local_storage={"auth_token": "access-token"},
|
||||||
|
session_storage={},
|
||||||
|
auth_headers=[],
|
||||||
|
page_url="https://sub2api.example.com/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert candidates[0]["type"] == "bearer_token"
|
||||||
|
assert candidates[0]["value"] == "access-token"
|
||||||
|
assert "refresh_token" not in candidates[0]
|
||||||
|
|
||||||
|
|
||||||
def test_browser_import_payload_builds_cookie_bundle_with_new_api_user():
|
def test_browser_import_payload_builds_cookie_bundle_with_new_api_user():
|
||||||
from app.services.browser_import_service import build_import_result
|
from app.services.browser_import_service import build_import_result
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from app.services.upstream_client import UpstreamClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_api_token_headers_include_authorization_and_new_api_user():
|
||||||
|
client = UpstreamClient(
|
||||||
|
base_url="http://newapi.local",
|
||||||
|
api_prefix="",
|
||||||
|
auth_type="new_api_token",
|
||||||
|
auth_config={"token": "access-token", "user_id": "7"},
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = client._headers()
|
||||||
|
|
||||||
|
assert headers["Authorization"] == "Bearer access-token"
|
||||||
|
assert headers["New-Api-User"] == "7"
|
||||||
|
assert "Nox-Api-User" not in headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_nox_token_headers_include_authorization_and_nox_api_user():
|
||||||
|
client = UpstreamClient(
|
||||||
|
base_url="http://nox.local",
|
||||||
|
api_prefix="",
|
||||||
|
auth_type="nox_token",
|
||||||
|
auth_config={"token": "nox-access-token", "user_id": "9"},
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = client._headers()
|
||||||
|
|
||||||
|
assert headers["Authorization"] == "Bearer nox-access-token"
|
||||||
|
assert headers["Nox-Api-User"] == "9"
|
||||||
|
assert "New-Api-User" not in headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_headers_do_not_include_new_or_nox_user_headers():
|
||||||
|
client = UpstreamClient(
|
||||||
|
base_url="http://sub2api.local",
|
||||||
|
api_prefix="/api/v1",
|
||||||
|
auth_type="bearer",
|
||||||
|
auth_config={"token": "sk-sub2api", "user_id": "11", "new_api_user": "11"},
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = client._headers()
|
||||||
|
|
||||||
|
assert headers["Authorization"] == "Bearer sk-sub2api"
|
||||||
|
assert "New-Api-User" not in headers
|
||||||
|
assert "Nox-Api-User" not in headers
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import upstream_client
|
||||||
|
from app.services.upstream_client import UpstreamClient
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHttpClient:
|
||||||
|
def __init__(self, handler):
|
||||||
|
self.handler = handler
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def request(self, method: str, url: str, **kwargs):
|
||||||
|
request = httpx.Request(method, url)
|
||||||
|
self.calls.append({
|
||||||
|
"method": method,
|
||||||
|
"path": request.url.path,
|
||||||
|
"headers": kwargs.get("headers") or {},
|
||||||
|
"json": kwargs.get("json"),
|
||||||
|
})
|
||||||
|
return self.handler(request, kwargs)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_client(monkeypatch, handler) -> FakeHttpClient:
|
||||||
|
fake = FakeHttpClient(handler)
|
||||||
|
monkeypatch.setattr(upstream_client.httpx, "Client", lambda timeout=None: fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _response(request: httpx.Request, status_code: int, payload=None) -> httpx.Response:
|
||||||
|
return httpx.Response(status_code, json=payload, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sub2api_bearer_refreshes_on_401_and_retries(monkeypatch):
|
||||||
|
updates: list[dict] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request, kwargs):
|
||||||
|
if request.url.path == "/api/v1/groups/available":
|
||||||
|
auth = kwargs["headers"].get("Authorization")
|
||||||
|
if auth == "Bearer old-access":
|
||||||
|
return _response(request, 401, {"detail": "expired"})
|
||||||
|
assert auth == "Bearer new-access"
|
||||||
|
return _response(request, 200, [{"id": "g1", "name": "G1"}])
|
||||||
|
if request.url.path == "/api/v1/auth/refresh":
|
||||||
|
assert kwargs["json"] == {"refresh_token": "old-refresh"}
|
||||||
|
return _response(request, 200, {
|
||||||
|
"access_token": "new-access",
|
||||||
|
"refresh_token": "new-refresh",
|
||||||
|
"expires_in": 3600,
|
||||||
|
})
|
||||||
|
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||||
|
|
||||||
|
fake = _install_fake_client(monkeypatch, handler)
|
||||||
|
client = UpstreamClient(
|
||||||
|
base_url="http://sub2api.local",
|
||||||
|
api_prefix="/api/v1",
|
||||||
|
auth_type="bearer",
|
||||||
|
auth_config={"token": "old-access", "refresh_token": "old-refresh"},
|
||||||
|
on_auth_config_update=updates.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
groups = client.get_available_groups("/groups/available")
|
||||||
|
|
||||||
|
assert groups == [{"id": "g1", "name": "G1"}]
|
||||||
|
assert [(c["method"], c["path"]) for c in fake.calls] == [
|
||||||
|
("GET", "/api/v1/groups/available"),
|
||||||
|
("POST", "/api/v1/auth/refresh"),
|
||||||
|
("GET", "/api/v1/groups/available"),
|
||||||
|
]
|
||||||
|
assert updates[-1]["token"] == "new-access"
|
||||||
|
assert updates[-1]["refresh_token"] == "new-refresh"
|
||||||
|
assert updates[-1]["expires_in"] == 3600
|
||||||
|
|
||||||
|
|
||||||
|
def test_sub2api_bearer_without_refresh_token_does_not_refresh(monkeypatch):
|
||||||
|
def handler(request: httpx.Request, kwargs):
|
||||||
|
assert request.url.path == "/api/v1/groups/available"
|
||||||
|
return _response(request, 401, {"detail": "expired"})
|
||||||
|
|
||||||
|
fake = _install_fake_client(monkeypatch, handler)
|
||||||
|
client = UpstreamClient(
|
||||||
|
base_url="http://sub2api.local",
|
||||||
|
api_prefix="/api/v1",
|
||||||
|
auth_type="bearer",
|
||||||
|
auth_config={"token": "old-access"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
client.get_available_groups("/groups/available")
|
||||||
|
|
||||||
|
assert [(c["method"], c["path"]) for c in fake.calls] == [
|
||||||
|
("GET", "/api/v1/groups/available"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_sub2api_bearer_does_not_refresh(monkeypatch):
|
||||||
|
def handler(request: httpx.Request, kwargs):
|
||||||
|
assert request.url.path == "/groups/available"
|
||||||
|
return _response(request, 401, {"detail": "expired"})
|
||||||
|
|
||||||
|
fake = _install_fake_client(monkeypatch, handler)
|
||||||
|
client = UpstreamClient(
|
||||||
|
base_url="http://plain-bearer.local",
|
||||||
|
api_prefix="",
|
||||||
|
auth_type="bearer",
|
||||||
|
auth_config={"token": "old-access", "refresh_token": "old-refresh"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
client.get_available_groups("/groups/available")
|
||||||
|
|
||||||
|
assert [(c["method"], c["path"]) for c in fake.calls] == [
|
||||||
|
("GET", "/groups/available"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_password_saves_sub2api_refresh_token(monkeypatch):
|
||||||
|
updates: list[dict] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request, kwargs):
|
||||||
|
assert request.url.path == "/api/v1/auth/login"
|
||||||
|
assert kwargs["json"] == {"email": "user@example.com", "password": "secret"}
|
||||||
|
return _response(request, 200, {
|
||||||
|
"access_token": "login-access",
|
||||||
|
"refresh_token": "login-refresh",
|
||||||
|
"expires_in": 1800,
|
||||||
|
})
|
||||||
|
|
||||||
|
_install_fake_client(monkeypatch, handler)
|
||||||
|
client = UpstreamClient(
|
||||||
|
base_url="http://sub2api.local",
|
||||||
|
api_prefix="/api/v1",
|
||||||
|
auth_type="login_password",
|
||||||
|
auth_config={"email": "user@example.com", "password": "secret"},
|
||||||
|
on_auth_config_update=updates.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
client.login()
|
||||||
|
|
||||||
|
assert client._headers()["Authorization"] == "Bearer login-access"
|
||||||
|
assert updates[-1]["token"] == "login-access"
|
||||||
|
assert updates[-1]["refresh_token"] == "login-refresh"
|
||||||
|
assert updates[-1]["expires_in"] == 1800
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_password_empty_prefix_defaults_to_user_api_login(monkeypatch):
|
||||||
|
def handler(request: httpx.Request, kwargs):
|
||||||
|
assert request.url.path == "/api/user/login"
|
||||||
|
assert kwargs["json"] == {"username": "admin", "password": "secret"}
|
||||||
|
return _response(request, 200, {
|
||||||
|
"token": "nox-access",
|
||||||
|
"user": {"id": 9},
|
||||||
|
})
|
||||||
|
|
||||||
|
_install_fake_client(monkeypatch, handler)
|
||||||
|
client = UpstreamClient(
|
||||||
|
base_url="http://nox.local",
|
||||||
|
api_prefix="",
|
||||||
|
auth_type="login_password",
|
||||||
|
auth_config={"email": "admin", "password": "secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
client.login()
|
||||||
|
|
||||||
|
headers = client._headers()
|
||||||
|
assert headers["Authorization"] == "Bearer nox-access"
|
||||||
|
assert headers["Nox-Api-User"] == "9"
|
||||||
|
assert headers["New-Api-User"] == "9"
|
||||||
@@ -461,6 +461,8 @@ export interface AuthCaptureCandidate {
|
|||||||
cookie_names?: string[]
|
cookie_names?: string[]
|
||||||
new_api_user?: string
|
new_api_user?: string
|
||||||
user_id?: string
|
user_id?: string
|
||||||
|
refresh_token?: string
|
||||||
|
refresh_token_preview?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthCaptureResult {
|
export interface AuthCaptureResult {
|
||||||
|
|||||||
@@ -112,7 +112,18 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', v: boolean): void
|
(e: 'update:modelValue', v: boolean): void
|
||||||
(e: 'select', candidate: { type: string; value: string; source: string; cookie_name?: string; cookie_value?: string; cookie_count?: number; cookie_names?: string[]; new_api_user?: string; user_id?: string }): void
|
(e: 'select', candidate: {
|
||||||
|
type: string
|
||||||
|
value: string
|
||||||
|
source: string
|
||||||
|
cookie_name?: string
|
||||||
|
cookie_value?: string
|
||||||
|
cookie_count?: number
|
||||||
|
cookie_names?: string[]
|
||||||
|
new_api_user?: string
|
||||||
|
user_id?: string
|
||||||
|
refresh_token?: string
|
||||||
|
}): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const visible = ref(props.modelValue)
|
const visible = ref(props.modelValue)
|
||||||
@@ -330,6 +341,7 @@ async function confirmImportSelection() {
|
|||||||
cookie_names: fullCandidate.cookie_names,
|
cookie_names: fullCandidate.cookie_names,
|
||||||
user_id: resolveNewApiUser(rawResult.data.result, fullCandidate),
|
user_id: resolveNewApiUser(rawResult.data.result, fullCandidate),
|
||||||
new_api_user: resolveNewApiUser(rawResult.data.result, fullCandidate),
|
new_api_user: resolveNewApiUser(rawResult.data.result, fullCandidate),
|
||||||
|
refresh_token: fullCandidate.refresh_token,
|
||||||
})
|
})
|
||||||
closeDialog()
|
closeDialog()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -129,12 +129,14 @@
|
|||||||
<el-input v-model="form.base_url" placeholder="https://example.com" />
|
<el-input v-model="form.base_url" placeholder="https://example.com" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="API Prefix">
|
<el-form-item label="API Prefix">
|
||||||
<el-input v-model="form.api_prefix" placeholder="/api/v1" />
|
<el-input v-model="form.api_prefix" :placeholder="apiPrefixPlaceholder" />
|
||||||
|
<div v-if="apiPrefixHint" class="form-hint">{{ apiPrefixHint }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="认证方式">
|
<el-form-item label="认证方式">
|
||||||
<el-select v-model="form.auth_type" style="width: 100%">
|
<el-select v-model="form.auth_type" style="width: 100%" @change="handleAuthTypeChange">
|
||||||
<el-option label="无认证" value="none" />
|
<el-option label="无认证" value="none" />
|
||||||
<el-option label="Bearer Token" value="bearer" />
|
<el-option label="Bearer Token" value="bearer" />
|
||||||
|
<el-option label="New-API Access Token" value="new_api_token" />
|
||||||
<el-option label="Nox Access Token" value="nox_token" />
|
<el-option label="Nox Access Token" value="nox_token" />
|
||||||
<el-option label="Cookie" value="cookie" />
|
<el-option label="Cookie" value="cookie" />
|
||||||
<el-option label="API Key" value="api_key" />
|
<el-option label="API Key" value="api_key" />
|
||||||
@@ -151,6 +153,25 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="form.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1'" label="Refresh Token">
|
||||||
|
<el-input v-model="form.auth_config.refresh_token" type="password" show-password placeholder="可选,真实浏览器提取后自动填入" />
|
||||||
|
<div class="form-hint">没有 refresh token 时,access token 过期后需要重新提取或改用邮箱密码登录。</div>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="form.auth_type === 'new_api_token'">
|
||||||
|
<el-form-item label="New-API Access Token">
|
||||||
|
<div class="auth-field-row">
|
||||||
|
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="上游 /api/user/token 生成的 access token" />
|
||||||
|
<el-button size="small" @click="openAuthCapture">
|
||||||
|
<el-icon><Pointer /></el-icon>
|
||||||
|
提取
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="New-API User ID">
|
||||||
|
<el-input v-model="form.auth_config.user_id" placeholder="例如 1" />
|
||||||
|
<div class="form-hint">请求 <code>/api/user/self/groups</code> 时需要同时发送 <code>New-Api-User</code>。不要反复点上游「重新生成」,<code>/api/user/token</code> 会覆盖旧 access token。</div>
|
||||||
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="form.auth_type === 'nox_token'">
|
<template v-else-if="form.auth_type === 'nox_token'">
|
||||||
<el-form-item label="Nox Access Token">
|
<el-form-item label="Nox Access Token">
|
||||||
@@ -177,6 +198,7 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<div class="form-hint">Cookie 仅作为 fallback;它受 session、Cloudflare 校验和上游重启影响,通常不如 access token 稳定。</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="form.auth_type === 'api_key'">
|
<template v-else-if="form.auth_type === 'api_key'">
|
||||||
<el-form-item label="API Key">
|
<el-form-item label="API Key">
|
||||||
@@ -187,6 +209,12 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="form.auth_type === 'login_password'">
|
<template v-else-if="form.auth_type === 'login_password'">
|
||||||
|
<el-form-item label="账号字段">
|
||||||
|
<el-select v-model="form.auth_config.username_field" style="width: 100%">
|
||||||
|
<el-option label="邮箱 email" value="email" />
|
||||||
|
<el-option label="用户名 username" value="username" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item :label="form.auth_config.username_field === 'username' ? '登录账号' : '登录邮箱'">
|
<el-form-item :label="form.auth_config.username_field === 'username' ? '登录账号' : '登录邮箱'">
|
||||||
<el-input v-model="form.auth_config.email" :placeholder="form.auth_config.username_field === 'username' ? 'admin' : 'admin@example.com'" />
|
<el-input v-model="form.auth_config.email" :placeholder="form.auth_config.username_field === 'username' ? 'admin' : 'admin@example.com'" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -194,20 +222,20 @@
|
|||||||
<el-input v-model="form.auth_config.password" type="password" show-password placeholder="***" />
|
<el-input v-model="form.auth_config.password" type="password" show-password placeholder="***" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="登录接口路径">
|
<el-form-item label="登录接口路径">
|
||||||
<el-input v-model="form.auth_config.login_path" placeholder="/auth/login" />
|
<el-input v-model="form.auth_config.login_path" :placeholder="loginPathPlaceholder" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
<el-form-item label="分组接口">
|
<el-form-item label="分组接口">
|
||||||
<el-input v-model="form.groups_endpoint" placeholder="/groups/available" />
|
<el-input v-model="form.groups_endpoint" :placeholder="groupsEndpointPlaceholder" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="倍率接口">
|
<el-form-item label="倍率接口">
|
||||||
<el-input v-model="form.rate_endpoint" placeholder="/groups/rates" />
|
<el-input v-model="form.rate_endpoint" :placeholder="rateEndpointPlaceholder" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="余额接口">
|
<el-form-item label="余额接口">
|
||||||
<el-input v-model="form.balance_endpoint" placeholder="留空则不获取余额,如 /auth/me" />
|
<el-input v-model="form.balance_endpoint" :placeholder="balanceEndpointPlaceholder" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="余额字段路径">
|
<el-form-item label="余额字段路径">
|
||||||
<el-input v-model="form.balance_response_path" placeholder="如 balance、data.quota" />
|
<el-input v-model="form.balance_response_path" :placeholder="balanceResponsePathPlaceholder" />
|
||||||
<div class="form-hint">JSON 点分路径,例如 <code>balance</code> 或 <code>data.quota</code></div>
|
<div class="form-hint">JSON 点分路径,例如 <code>balance</code> 或 <code>data.quota</code></div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="余额除数">
|
<el-form-item label="余额除数">
|
||||||
@@ -446,20 +474,80 @@ const saving = ref(false)
|
|||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
const defaultForm = () => ({
|
type QuickPlatform = 'sub2api' | 'new-api-user' | 'nox-api' | 'custom'
|
||||||
name: '',
|
type KnownQuickPlatform = Exclude<QuickPlatform, 'custom'>
|
||||||
base_url: '',
|
type AuthType = 'none' | 'bearer' | 'new_api_token' | 'nox_token' | 'cookie' | 'api_key' | 'login_password'
|
||||||
|
|
||||||
|
const USER_API_GROUPS_ENDPOINT = '/api/user/self/groups'
|
||||||
|
const USER_API_BALANCE_ENDPOINT = '/api/user/self'
|
||||||
|
const USER_API_LOGIN_PATH = '/api/user/login'
|
||||||
|
const SUB2API_LOGIN_PATH = '/auth/login'
|
||||||
|
|
||||||
|
const platformDefaults: Record<KnownQuickPlatform, {
|
||||||
|
api_prefix: string
|
||||||
|
auth_type: AuthType
|
||||||
|
auth_config: Record<string, any>
|
||||||
|
rate_endpoint: string
|
||||||
|
groups_endpoint: string
|
||||||
|
balance_endpoint: string
|
||||||
|
balance_response_path: string
|
||||||
|
balance_divisor: number
|
||||||
|
}> = {
|
||||||
|
sub2api: {
|
||||||
api_prefix: '/api/v1',
|
api_prefix: '/api/v1',
|
||||||
auth_type: 'login_password',
|
auth_type: 'login_password',
|
||||||
auth_config: { email: '', password: '', login_path: '/auth/login' } as Record<string, any>,
|
auth_config: { email: '', password: '', login_path: SUB2API_LOGIN_PATH, username_field: 'email' },
|
||||||
rate_endpoint: '/groups/rates',
|
rate_endpoint: '/groups/rates',
|
||||||
groups_endpoint: '/groups/available',
|
groups_endpoint: '/groups/available',
|
||||||
enabled: true,
|
|
||||||
check_interval_seconds: 600,
|
|
||||||
timeout_seconds: 30,
|
|
||||||
balance_endpoint: '/auth/me',
|
balance_endpoint: '/auth/me',
|
||||||
balance_response_path: 'data.balance',
|
balance_response_path: 'data.balance',
|
||||||
balance_divisor: 1.0,
|
balance_divisor: 1.0,
|
||||||
|
},
|
||||||
|
'new-api-user': {
|
||||||
|
api_prefix: '',
|
||||||
|
auth_type: 'login_password',
|
||||||
|
auth_config: { email: '', password: '', login_path: USER_API_LOGIN_PATH, username_field: 'username', provider: 'new-api' },
|
||||||
|
rate_endpoint: USER_API_GROUPS_ENDPOINT,
|
||||||
|
groups_endpoint: USER_API_GROUPS_ENDPOINT,
|
||||||
|
balance_endpoint: USER_API_BALANCE_ENDPOINT,
|
||||||
|
balance_response_path: 'data.quota',
|
||||||
|
balance_divisor: 500000,
|
||||||
|
},
|
||||||
|
'nox-api': {
|
||||||
|
api_prefix: '',
|
||||||
|
auth_type: 'login_password',
|
||||||
|
auth_config: { email: '', password: '', login_path: USER_API_LOGIN_PATH, username_field: 'username', provider: 'nox-api' },
|
||||||
|
rate_endpoint: USER_API_GROUPS_ENDPOINT,
|
||||||
|
groups_endpoint: USER_API_GROUPS_ENDPOINT,
|
||||||
|
balance_endpoint: USER_API_BALANCE_ENDPOINT,
|
||||||
|
balance_response_path: 'data.quota',
|
||||||
|
balance_divisor: 500000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformLabels: Record<QuickPlatform, string> = {
|
||||||
|
sub2api: 'Sub2API',
|
||||||
|
'new-api-user': 'New-API',
|
||||||
|
'nox-api': 'Nox-API',
|
||||||
|
custom: '自定义',
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickPlatform = ref<QuickPlatform>('sub2api')
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
name: '',
|
||||||
|
base_url: '',
|
||||||
|
api_prefix: platformDefaults.sub2api.api_prefix,
|
||||||
|
auth_type: platformDefaults.sub2api.auth_type,
|
||||||
|
auth_config: { ...platformDefaults.sub2api.auth_config } as Record<string, any>,
|
||||||
|
rate_endpoint: platformDefaults.sub2api.rate_endpoint,
|
||||||
|
groups_endpoint: platformDefaults.sub2api.groups_endpoint,
|
||||||
|
enabled: true,
|
||||||
|
check_interval_seconds: 600,
|
||||||
|
timeout_seconds: 30,
|
||||||
|
balance_endpoint: platformDefaults.sub2api.balance_endpoint,
|
||||||
|
balance_response_path: platformDefaults.sub2api.balance_response_path,
|
||||||
|
balance_divisor: platformDefaults.sub2api.balance_divisor,
|
||||||
balance_alert_threshold: null as number | null,
|
balance_alert_threshold: null as number | null,
|
||||||
})
|
})
|
||||||
const form = ref(defaultForm())
|
const form = ref(defaultForm())
|
||||||
@@ -470,6 +558,42 @@ const rules = {
|
|||||||
|
|
||||||
const authCaptureVisible = ref(false)
|
const authCaptureVisible = ref(false)
|
||||||
|
|
||||||
|
const selectedPlatformDefaults = computed(() =>
|
||||||
|
quickPlatform.value === 'custom' ? null : platformDefaults[quickPlatform.value],
|
||||||
|
)
|
||||||
|
|
||||||
|
const apiPrefixPlaceholder = computed(() => {
|
||||||
|
const defaults = selectedPlatformDefaults.value
|
||||||
|
if (!defaults) return '/api/v1 或留空'
|
||||||
|
return defaults.api_prefix || `留空(${platformLabels[quickPlatform.value]} 默认)`
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiPrefixHint = computed(() => {
|
||||||
|
if (quickPlatform.value !== 'new-api-user' && quickPlatform.value !== 'nox-api') return ''
|
||||||
|
return `${platformLabels[quickPlatform.value]} 用户接口默认不加 API Prefix,留空即可。`
|
||||||
|
})
|
||||||
|
|
||||||
|
const loginPathPlaceholder = computed(() => {
|
||||||
|
const defaults = selectedPlatformDefaults.value
|
||||||
|
return defaults?.auth_config.login_path || SUB2API_LOGIN_PATH
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupsEndpointPlaceholder = computed(() =>
|
||||||
|
selectedPlatformDefaults.value?.groups_endpoint || '/groups/available',
|
||||||
|
)
|
||||||
|
|
||||||
|
const rateEndpointPlaceholder = computed(() =>
|
||||||
|
selectedPlatformDefaults.value?.rate_endpoint || '/groups/rates',
|
||||||
|
)
|
||||||
|
|
||||||
|
const balanceEndpointPlaceholder = computed(() =>
|
||||||
|
selectedPlatformDefaults.value?.balance_endpoint || '留空则不获取余额,如 /auth/me',
|
||||||
|
)
|
||||||
|
|
||||||
|
const balanceResponsePathPlaceholder = computed(() =>
|
||||||
|
selectedPlatformDefaults.value?.balance_response_path || '如 balance、data.quota',
|
||||||
|
)
|
||||||
|
|
||||||
const authCaptureInitialUrl = computed(() => {
|
const authCaptureInitialUrl = computed(() => {
|
||||||
const base = (form.value.base_url || '').replace(/\/+$/, '')
|
const base = (form.value.base_url || '').replace(/\/+$/, '')
|
||||||
if (!base) return ''
|
if (!base) return ''
|
||||||
@@ -478,7 +602,7 @@ const authCaptureInitialUrl = computed(() => {
|
|||||||
|
|
||||||
const authCapturePreferredTypes = computed<AuthCaptureCandidate['type'][]>(() => {
|
const authCapturePreferredTypes = computed<AuthCaptureCandidate['type'][]>(() => {
|
||||||
if (quickPlatform.value === 'sub2api') return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
|
if (quickPlatform.value === 'sub2api') return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
|
||||||
if (quickPlatform.value === 'new-api-user') return ['cookie_bundle', 'cookie', 'bearer_token', 'api_key']
|
if (quickPlatform.value === 'new-api-user') return ['bearer_token', 'cookie_bundle', 'cookie', 'api_key']
|
||||||
if (quickPlatform.value === 'nox-api') return ['cookie_bundle', 'cookie', 'bearer_token', 'api_key']
|
if (quickPlatform.value === 'nox-api') return ['cookie_bundle', 'cookie', 'bearer_token', 'api_key']
|
||||||
return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
|
return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
|
||||||
})
|
})
|
||||||
@@ -496,27 +620,47 @@ function handleAuthCaptureSelect(candidate: {
|
|||||||
cookie_names?: string[]
|
cookie_names?: string[]
|
||||||
new_api_user?: string
|
new_api_user?: string
|
||||||
user_id?: string
|
user_id?: string
|
||||||
|
refresh_token?: string
|
||||||
}) {
|
}) {
|
||||||
if (candidate.type === 'bearer_token') {
|
if (candidate.type === 'bearer_token') {
|
||||||
if (quickPlatform.value === 'nox-api') {
|
if (quickPlatform.value === 'nox-api') {
|
||||||
form.value.auth_type = 'nox_token'
|
form.value.auth_type = 'nox_token'
|
||||||
form.value.auth_config.token = candidate.value
|
form.value.auth_config.token = candidate.value
|
||||||
form.value.auth_config.provider = 'nox-api'
|
form.value.auth_config.provider = 'nox-api'
|
||||||
|
applyUserApiEndpoints()
|
||||||
if (candidate.user_id || candidate.new_api_user) {
|
if (candidate.user_id || candidate.new_api_user) {
|
||||||
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||||||
}
|
}
|
||||||
ElMessage.success('已填入 Nox Access Token')
|
ElMessage.success('已填入 Nox Access Token')
|
||||||
|
} else if (quickPlatform.value === 'new-api-user') {
|
||||||
|
form.value.auth_type = 'new_api_token'
|
||||||
|
form.value.auth_config.token = candidate.value
|
||||||
|
form.value.auth_config.provider = 'new-api'
|
||||||
|
if (candidate.user_id || candidate.new_api_user) {
|
||||||
|
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||||||
|
form.value.auth_config.new_api_user = candidate.new_api_user || candidate.user_id
|
||||||
|
}
|
||||||
|
applyUserApiEndpoints()
|
||||||
|
ElMessage.success('已填入 New-API Access Token')
|
||||||
} else {
|
} else {
|
||||||
form.value.auth_type = 'bearer'
|
form.value.auth_type = 'bearer'
|
||||||
form.value.auth_config.token = candidate.value
|
form.value.auth_config.token = candidate.value
|
||||||
|
if (candidate.refresh_token) {
|
||||||
|
form.value.auth_config.refresh_token = candidate.refresh_token
|
||||||
|
ElMessage.success('已填入 Bearer Token 和 Refresh Token')
|
||||||
|
} else {
|
||||||
ElMessage.success('已填入 Bearer Token')
|
ElMessage.success('已填入 Bearer Token')
|
||||||
|
if (quickPlatform.value === 'sub2api' || form.value.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1') {
|
||||||
|
ElMessage.warning('未提取到 Refresh Token,过期后需要重新提取或改用邮箱密码登录')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (candidate.type === 'cookie_bundle') {
|
} else if (candidate.type === 'cookie_bundle') {
|
||||||
// 完整 cookie 组:value 已是完整 "name1=v1; name2=v2" 字符串
|
// 完整 cookie 组:value 已是完整 "name1=v1; name2=v2" 字符串
|
||||||
form.value.auth_type = 'cookie'
|
form.value.auth_type = 'cookie'
|
||||||
form.value.auth_config.cookie_string = candidate.value
|
form.value.auth_config.cookie_string = candidate.value
|
||||||
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') {
|
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') {
|
||||||
form.value.auth_config.provider = quickPlatform.value
|
form.value.auth_config.provider = quickPlatform.value === 'nox-api' ? 'nox-api' : 'new-api'
|
||||||
}
|
}
|
||||||
if (quickPlatform.value === 'sub2api') {
|
if (quickPlatform.value === 'sub2api') {
|
||||||
ElMessage.warning('Sub2API 通常需要 Bearer Token;Cookie 只能在确认上游支持 Cookie 鉴权时使用')
|
ElMessage.warning('Sub2API 通常需要 Bearer Token;Cookie 只能在确认上游支持 Cookie 鉴权时使用')
|
||||||
@@ -524,13 +668,9 @@ function handleAuthCaptureSelect(candidate: {
|
|||||||
if (candidate.new_api_user) {
|
if (candidate.new_api_user) {
|
||||||
form.value.auth_config.new_api_user = candidate.new_api_user
|
form.value.auth_config.new_api_user = candidate.new_api_user
|
||||||
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||||||
form.value.api_prefix = ''
|
applyUserApiEndpoints()
|
||||||
form.value.groups_endpoint = '/api/user/self/groups'
|
|
||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
|
||||||
} else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
} else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||||||
form.value.api_prefix = ''
|
applyUserApiEndpoints()
|
||||||
form.value.groups_endpoint = '/api/user/self/groups'
|
|
||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
|
||||||
if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||||||
ElMessage.warning(`已填入完整 Cookie 组,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`)
|
ElMessage.warning(`已填入完整 Cookie 组,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`)
|
||||||
}
|
}
|
||||||
@@ -543,7 +683,7 @@ function handleAuthCaptureSelect(candidate: {
|
|||||||
? `${candidate.cookie_name}=${candidate.cookie_value}`
|
? `${candidate.cookie_name}=${candidate.cookie_value}`
|
||||||
: candidate.value
|
: candidate.value
|
||||||
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') {
|
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') {
|
||||||
form.value.auth_config.provider = quickPlatform.value
|
form.value.auth_config.provider = quickPlatform.value === 'nox-api' ? 'nox-api' : 'new-api'
|
||||||
}
|
}
|
||||||
if (quickPlatform.value === 'sub2api') {
|
if (quickPlatform.value === 'sub2api') {
|
||||||
ElMessage.warning('Sub2API 通常需要 Bearer Token;Cookie 只能在确认上游支持 Cookie 鉴权时使用')
|
ElMessage.warning('Sub2API 通常需要 Bearer Token;Cookie 只能在确认上游支持 Cookie 鉴权时使用')
|
||||||
@@ -551,13 +691,9 @@ function handleAuthCaptureSelect(candidate: {
|
|||||||
if (candidate.new_api_user) {
|
if (candidate.new_api_user) {
|
||||||
form.value.auth_config.new_api_user = candidate.new_api_user
|
form.value.auth_config.new_api_user = candidate.new_api_user
|
||||||
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||||||
form.value.api_prefix = ''
|
applyUserApiEndpoints()
|
||||||
form.value.groups_endpoint = '/api/user/self/groups'
|
|
||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
|
||||||
} else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
} else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||||||
form.value.api_prefix = ''
|
applyUserApiEndpoints()
|
||||||
form.value.groups_endpoint = '/api/user/self/groups'
|
|
||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
|
||||||
if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||||||
ElMessage.warning(`已填入 Cookie,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`)
|
ElMessage.warning(`已填入 Cookie,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`)
|
||||||
}
|
}
|
||||||
@@ -571,71 +707,152 @@ function handleAuthCaptureSelect(candidate: {
|
|||||||
} else if (candidate.type === 'credential') {
|
} else if (candidate.type === 'credential') {
|
||||||
// Try to guess — if value starts with 'sk-', treat as bearer
|
// Try to guess — if value starts with 'sk-', treat as bearer
|
||||||
if (candidate.value.startsWith('sk-')) {
|
if (candidate.value.startsWith('sk-')) {
|
||||||
form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_token' : 'bearer'
|
form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_token' : quickPlatform.value === 'new-api-user' ? 'new_api_token' : 'bearer'
|
||||||
form.value.auth_config.token = candidate.value
|
form.value.auth_config.token = candidate.value
|
||||||
if (quickPlatform.value === 'nox-api') form.value.auth_config.provider = 'nox-api'
|
if (quickPlatform.value === 'nox-api') form.value.auth_config.provider = 'nox-api'
|
||||||
if (quickPlatform.value === 'nox-api' && (candidate.user_id || candidate.new_api_user)) {
|
if (quickPlatform.value === 'new-api-user') form.value.auth_config.provider = 'new-api'
|
||||||
|
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') applyUserApiEndpoints()
|
||||||
|
if ((quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') && (candidate.user_id || candidate.new_api_user)) {
|
||||||
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||||||
|
if (quickPlatform.value === 'new-api-user') form.value.auth_config.new_api_user = candidate.new_api_user || candidate.user_id
|
||||||
}
|
}
|
||||||
ElMessage.success('已填入 Bearer Token (sk-key)')
|
ElMessage.success(quickPlatform.value === 'new-api-user' ? '已填入 New-API Access Token' : '已填入 Bearer Token (sk-key)')
|
||||||
} else {
|
} else {
|
||||||
form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_token' : 'bearer'
|
form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_token' : quickPlatform.value === 'new-api-user' ? 'new_api_token' : 'bearer'
|
||||||
form.value.auth_config.token = candidate.value
|
form.value.auth_config.token = candidate.value
|
||||||
if (quickPlatform.value === 'nox-api') form.value.auth_config.provider = 'nox-api'
|
if (quickPlatform.value === 'nox-api') form.value.auth_config.provider = 'nox-api'
|
||||||
if (quickPlatform.value === 'nox-api' && (candidate.user_id || candidate.new_api_user)) {
|
if (quickPlatform.value === 'new-api-user') form.value.auth_config.provider = 'new-api'
|
||||||
|
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') applyUserApiEndpoints()
|
||||||
|
if ((quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') && (candidate.user_id || candidate.new_api_user)) {
|
||||||
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||||||
|
if (quickPlatform.value === 'new-api-user') form.value.auth_config.new_api_user = candidate.new_api_user || candidate.user_id
|
||||||
}
|
}
|
||||||
ElMessage.success('已填入认证信息')
|
ElMessage.success('已填入认证信息')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const quickPlatform = ref('sub2api')
|
function isKnownQuickPlatform(value: string): value is KnownQuickPlatform {
|
||||||
|
return value === 'sub2api' || value === 'new-api-user' || value === 'nox-api'
|
||||||
|
}
|
||||||
|
|
||||||
function inferPlatform(row: Pick<UpstreamData, 'api_prefix' | 'groups_endpoint' | 'auth_type' | 'auth_config_masked'>): string {
|
function normalizeQuickPlatform(value: string): QuickPlatform {
|
||||||
|
return isKnownQuickPlatform(value) ? value : 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformProvider(platform: QuickPlatform): string {
|
||||||
|
if (platform === 'nox-api') return 'nox-api'
|
||||||
|
if (platform === 'new-api-user') return 'new-api'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAuthType(value: string): AuthType {
|
||||||
|
if (value === 'none'
|
||||||
|
|| value === 'bearer'
|
||||||
|
|| value === 'new_api_token'
|
||||||
|
|| value === 'nox_token'
|
||||||
|
|| value === 'cookie'
|
||||||
|
|| value === 'api_key'
|
||||||
|
|| value === 'login_password') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUserApiEndpoints() {
|
||||||
|
form.value.api_prefix = ''
|
||||||
|
form.value.groups_endpoint = USER_API_GROUPS_ENDPOINT
|
||||||
|
form.value.rate_endpoint = USER_API_GROUPS_ENDPOINT
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferPlatform(row: Pick<UpstreamData, 'api_prefix' | 'groups_endpoint' | 'auth_type' | 'auth_config_masked'>): QuickPlatform {
|
||||||
if (row.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1') return 'sub2api'
|
if (row.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1') return 'sub2api'
|
||||||
if (row.api_prefix === '' && row.groups_endpoint === '/api/user/self/groups') {
|
if (row.api_prefix === '' && row.groups_endpoint === USER_API_GROUPS_ENDPOINT) {
|
||||||
if (row.auth_type === 'nox_token' || row.auth_config_masked?.provider === 'nox-api') return 'nox-api'
|
if (row.auth_type === 'nox_token' || row.auth_config_masked?.provider === 'nox-api') return 'nox-api'
|
||||||
|
if (row.auth_type === 'new_api_token' || row.auth_config_masked?.provider === 'new-api') return 'new-api-user'
|
||||||
return 'new-api-user'
|
return 'new-api-user'
|
||||||
}
|
}
|
||||||
return 'custom'
|
return 'custom'
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlatformChange(val: string) {
|
function handlePlatformChange(val: string) {
|
||||||
if (val === 'sub2api') {
|
const platform = normalizeQuickPlatform(val)
|
||||||
form.value.api_prefix = '/api/v1'
|
if (platform === 'custom') {
|
||||||
form.value.groups_endpoint = '/groups/available'
|
|
||||||
form.value.rate_endpoint = '/groups/rates'
|
|
||||||
form.value.auth_type = 'login_password'
|
|
||||||
form.value.auth_config.login_path = '/auth/login'
|
|
||||||
form.value.auth_config.username_field = 'email'
|
|
||||||
form.value.balance_endpoint = '/auth/me'
|
|
||||||
form.value.balance_response_path = 'data.balance'
|
|
||||||
form.value.balance_divisor = 1.0
|
|
||||||
} else if (val === 'new-api-user') {
|
|
||||||
form.value.api_prefix = ''
|
|
||||||
form.value.groups_endpoint = '/api/user/self/groups'
|
|
||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
|
||||||
form.value.auth_type = 'login_password'
|
|
||||||
form.value.auth_config.provider = 'new-api-user'
|
|
||||||
form.value.auth_config.login_path = '/api/user/login'
|
|
||||||
form.value.auth_config.username_field = 'username'
|
|
||||||
form.value.balance_endpoint = '/api/user/self'
|
|
||||||
form.value.balance_response_path = 'data.quota'
|
|
||||||
form.value.balance_divisor = 500000
|
|
||||||
} else if (val === 'nox-api') {
|
|
||||||
form.value.api_prefix = ''
|
|
||||||
form.value.groups_endpoint = '/api/user/self/groups'
|
|
||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
|
||||||
form.value.auth_type = 'nox_token'
|
|
||||||
form.value.auth_config = { token: '', user_id: '', provider: 'nox-api' }
|
|
||||||
form.value.balance_endpoint = '/api/user/self'
|
|
||||||
form.value.balance_response_path = 'data.quota'
|
|
||||||
form.value.balance_divisor = 500000
|
|
||||||
} else {
|
|
||||||
form.value.balance_endpoint = ''
|
form.value.balance_endpoint = ''
|
||||||
form.value.balance_response_path = ''
|
form.value.balance_response_path = ''
|
||||||
form.value.balance_divisor = 1.0
|
form.value.balance_divisor = 1.0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = platformDefaults[platform]
|
||||||
|
form.value.api_prefix = defaults.api_prefix
|
||||||
|
form.value.groups_endpoint = defaults.groups_endpoint
|
||||||
|
form.value.rate_endpoint = defaults.rate_endpoint
|
||||||
|
form.value.auth_type = defaults.auth_type
|
||||||
|
form.value.auth_config = { ...defaults.auth_config }
|
||||||
|
form.value.balance_endpoint = defaults.balance_endpoint
|
||||||
|
form.value.balance_response_path = defaults.balance_response_path
|
||||||
|
form.value.balance_divisor = defaults.balance_divisor
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUserApiPasswordLoginPlatform(): boolean {
|
||||||
|
return quickPlatform.value === 'new-api-user'
|
||||||
|
|| quickPlatform.value === 'nox-api'
|
||||||
|
|| (form.value.api_prefix === '' && form.value.groups_endpoint === USER_API_GROUPS_ENDPOINT)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthTypeChange(val: string) {
|
||||||
|
const provider = platformProvider(quickPlatform.value)
|
||||||
|
if (val === 'none') {
|
||||||
|
form.value.auth_config = {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (val === 'bearer') {
|
||||||
|
form.value.auth_config = {
|
||||||
|
token: form.value.auth_config.token || '',
|
||||||
|
refresh_token: form.value.auth_config.refresh_token || '',
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (val === 'new_api_token') {
|
||||||
|
form.value.auth_config = {
|
||||||
|
token: form.value.auth_config.token || '',
|
||||||
|
user_id: form.value.auth_config.user_id || form.value.auth_config.new_api_user || '',
|
||||||
|
provider: 'new-api',
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (val === 'nox_token') {
|
||||||
|
form.value.auth_config = {
|
||||||
|
token: form.value.auth_config.token || '',
|
||||||
|
user_id: form.value.auth_config.user_id || form.value.auth_config.new_api_user || '',
|
||||||
|
provider: 'nox-api',
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (val === 'cookie') {
|
||||||
|
form.value.auth_config = {
|
||||||
|
cookie_string: form.value.auth_config.cookie_string || '',
|
||||||
|
...(provider ? { provider } : {}),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (val === 'api_key') {
|
||||||
|
form.value.auth_config = {
|
||||||
|
key: form.value.auth_config.key || '',
|
||||||
|
header: form.value.auth_config.header || 'Authorization',
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (val !== 'login_password') return
|
||||||
|
|
||||||
|
const userApiLogin = isUserApiPasswordLoginPlatform()
|
||||||
|
form.value.auth_config = {
|
||||||
|
email: form.value.auth_config.email || '',
|
||||||
|
password: form.value.auth_config.password || '',
|
||||||
|
login_path: userApiLogin ? USER_API_LOGIN_PATH : (form.value.auth_config.login_path || SUB2API_LOGIN_PATH),
|
||||||
|
username_field: userApiLogin ? 'username' : (form.value.auth_config.username_field || 'email'),
|
||||||
|
...(provider ? { provider } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,9 +902,10 @@ function usesTokenEndpointUpstream(row: UpstreamData | null) {
|
|||||||
if (!row) return false
|
if (!row) return false
|
||||||
return row.api_prefix === ''
|
return row.api_prefix === ''
|
||||||
&& (
|
&& (
|
||||||
row.groups_endpoint === '/api/user/self/groups'
|
row.groups_endpoint === USER_API_GROUPS_ENDPOINT
|
||||||
|| row.auth_config_masked?.login_path === '/api/user/login'
|
|| row.auth_config_masked?.login_path === USER_API_LOGIN_PATH
|
||||||
|| Boolean(row.auth_config_masked?.new_api_user)
|
|| Boolean(row.auth_config_masked?.new_api_user)
|
||||||
|
|| row.auth_type === 'new_api_token'
|
||||||
|| row.auth_type === 'nox_token'
|
|| row.auth_type === 'nox_token'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -714,7 +932,7 @@ const recentChecks = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
||||||
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', nox_token: 'Nox Token', cookie: 'Cookie', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
|
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', new_api_token: 'New-API Token', nox_token: 'Nox Token', cookie: 'Cookie', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
|
||||||
|
|
||||||
function formatBalance(value: number | null | undefined): string {
|
function formatBalance(value: number | null | undefined): string {
|
||||||
if (value === null || value === undefined) return '—'
|
if (value === null || value === undefined) return '—'
|
||||||
@@ -765,7 +983,7 @@ function openEdit(row: UpstreamData) {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
base_url: row.base_url,
|
base_url: row.base_url,
|
||||||
api_prefix: row.api_prefix,
|
api_prefix: row.api_prefix,
|
||||||
auth_type: row.auth_type,
|
auth_type: normalizeAuthType(row.auth_type),
|
||||||
auth_config: { ...(row.auth_config_masked as Record<string, any>) },
|
auth_config: { ...(row.auth_config_masked as Record<string, any>) },
|
||||||
rate_endpoint: row.rate_endpoint,
|
rate_endpoint: row.rate_endpoint,
|
||||||
groups_endpoint: row.groups_endpoint,
|
groups_endpoint: row.groups_endpoint,
|
||||||
@@ -785,11 +1003,23 @@ async function handleSave() {
|
|||||||
if (!valid) return
|
if (!valid) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form.value,
|
||||||
|
auth_config: { ...(form.value.auth_config as Record<string, any>) },
|
||||||
|
}
|
||||||
|
if (payload.auth_type === 'new_api_token') {
|
||||||
|
payload.auth_config.provider = 'new-api'
|
||||||
|
if (payload.auth_config.user_id && !payload.auth_config.new_api_user) {
|
||||||
|
payload.auth_config.new_api_user = payload.auth_config.user_id
|
||||||
|
}
|
||||||
|
} else if (payload.auth_type === 'nox_token') {
|
||||||
|
payload.auth_config.provider = 'nox-api'
|
||||||
|
}
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await upstreamsApi.update(editingId.value, form.value)
|
await upstreamsApi.update(editingId.value, payload)
|
||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
} else {
|
} else {
|
||||||
await upstreamsApi.create(form.value as any)
|
await upstreamsApi.create(payload as any)
|
||||||
ElMessage.success('创建成功')
|
ElMessage.success('创建成功')
|
||||||
}
|
}
|
||||||
drawerVisible.value = false
|
drawerVisible.value = false
|
||||||
|
|||||||
Reference in New Issue
Block a user