feat: add Nox-API upstream support
This commit is contained in:
@@ -37,6 +37,16 @@ MASK = "***"
|
|||||||
SECRET_KEYS = {"password", "token", "key", "secret"}
|
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,6 +90,13 @@ def _mask_auth_config(auth_type: str, cfg: dict) -> dict:
|
|||||||
return masked
|
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}
|
||||||
|
|
||||||
|
|
||||||
def _extract_plaintext_key(payload: dict[str, Any] | None) -> str:
|
def _extract_plaintext_key(payload: dict[str, Any] | None) -> str:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return ""
|
return ""
|
||||||
@@ -319,20 +336,39 @@ def _is_sub2api_upstream(upstream: Upstream) -> bool:
|
|||||||
return upstream.api_prefix.strip("/") == "api/v1"
|
return upstream.api_prefix.strip("/") == "api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_nox_api_upstream(upstream: Upstream) -> bool:
|
||||||
|
auth_config = json.loads(upstream.auth_config_json or "{}")
|
||||||
|
return (
|
||||||
|
upstream.api_prefix.strip("/") == ""
|
||||||
|
and upstream.groups_endpoint == "/api/user/self/groups"
|
||||||
|
and (
|
||||||
|
upstream.auth_type == "nox_token"
|
||||||
|
or auth_config.get("login_path") == "/api/user/login"
|
||||||
|
or bool(auth_config.get("user_id"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_new_api_user_upstream(upstream: Upstream) -> bool:
|
def _is_new_api_user_upstream(upstream: Upstream) -> bool:
|
||||||
auth_config = json.loads(upstream.auth_config_json or "{}")
|
auth_config = json.loads(upstream.auth_config_json or "{}")
|
||||||
return (
|
return (
|
||||||
upstream.api_prefix.strip("/") == ""
|
upstream.api_prefix.strip("/") == ""
|
||||||
and (
|
and (
|
||||||
upstream.groups_endpoint == "/api/user/self/groups"
|
bool(auth_config.get("new_api_user"))
|
||||||
or auth_config.get("login_path") == "/api/user/login"
|
or (
|
||||||
or bool(auth_config.get("new_api_user"))
|
upstream.groups_endpoint == "/api/user/self/groups"
|
||||||
|
and auth_config.get("login_path") != "/api/user/login"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _supports_key_generation(upstream: Upstream) -> bool:
|
def _supports_key_generation(upstream: Upstream) -> bool:
|
||||||
return _is_sub2api_upstream(upstream) or _is_new_api_user_upstream(upstream)
|
return (
|
||||||
|
_is_sub2api_upstream(upstream)
|
||||||
|
or _is_new_api_user_upstream(upstream)
|
||||||
|
or _is_nox_api_upstream(upstream)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_group_key(
|
def _ensure_group_key(
|
||||||
@@ -486,7 +522,7 @@ def generate_keys_by_groups(
|
|||||||
if not u:
|
if not u:
|
||||||
raise HTTPException(404, "upstream not found")
|
raise HTTPException(404, "upstream not found")
|
||||||
if not _supports_key_generation(u):
|
if not _supports_key_generation(u):
|
||||||
raise HTTPException(400, "仅支持 Sub2API 或 New-API 普通账号上游生成 Key")
|
raise HTTPException(400, "仅支持 Sub2API、New-API 普通账号或 Nox-API 上游生成 Key")
|
||||||
|
|
||||||
# 生成前先对账,清理远端已删除的旧 Key
|
# 生成前先对账,清理远端已删除的旧 Key
|
||||||
try:
|
try:
|
||||||
@@ -766,7 +802,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(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,
|
||||||
@@ -810,7 +846,8 @@ def update_upstream(
|
|||||||
for k, v in incoming.items():
|
for k, v in incoming.items():
|
||||||
if v != MASK: # don't overwrite with mask placeholder
|
if v != MASK: # don't overwrite with mask placeholder
|
||||||
existing[k] = v
|
existing[k] = v
|
||||||
u.auth_config_json = json.dumps(existing, ensure_ascii=False)
|
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)
|
||||||
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():
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ def _curate_candidates(
|
|||||||
"""Scan extracted data for likely credentials with confidence scoring."""
|
"""Scan extracted data for likely credentials with confidence scoring."""
|
||||||
candidates: list[dict[str, Any]] = []
|
candidates: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
if not new_api_user:
|
||||||
|
new_api_user = _find_user_header(auth_headers)
|
||||||
|
if not new_api_user:
|
||||||
|
new_api_user = _find_new_api_user(local_storage, session_storage)
|
||||||
|
|
||||||
# 0. 完整 Cookie Bundle(最高优先级)
|
# 0. 完整 Cookie Bundle(最高优先级)
|
||||||
# 按页面 origin 收集所有相关 cookie,包含 cf_clearance 等 Cloudflare cookie
|
# 按页面 origin 收集所有相关 cookie,包含 cf_clearance 等 Cloudflare cookie
|
||||||
cookie_string, cookie_names = _build_cookie_bundle(cookies, page_url)
|
cookie_string, cookie_names = _build_cookie_bundle(cookies, page_url)
|
||||||
@@ -100,6 +105,7 @@ def _curate_candidates(
|
|||||||
}
|
}
|
||||||
if new_api_user:
|
if new_api_user:
|
||||||
bundle_extra["new_api_user"] = new_api_user
|
bundle_extra["new_api_user"] = new_api_user
|
||||||
|
bundle_extra["user_id"] = new_api_user
|
||||||
_add(
|
_add(
|
||||||
candidates, "cookie_bundle",
|
candidates, "cookie_bundle",
|
||||||
f"bundle:{page_url[:60]}",
|
f"bundle:{page_url[:60]}",
|
||||||
@@ -113,18 +119,21 @@ def _curate_candidates(
|
|||||||
# 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:
|
||||||
|
htype = h.get("type", "authorization")
|
||||||
|
if htype == "user_id":
|
||||||
|
continue
|
||||||
dedup_key = h["value"]
|
dedup_key = h["value"]
|
||||||
if dedup_key in seen:
|
if dedup_key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(dedup_key)
|
seen.add(dedup_key)
|
||||||
htype = h.get("type", "authorization")
|
|
||||||
preview = _preview(h["value"])
|
preview = _preview(h["value"])
|
||||||
if htype == "api_key":
|
if htype == "api_key":
|
||||||
_add(candidates, "api_key", f"network:{h['url'][:60]}", h["value"], preview,
|
_add(candidates, "api_key", f"network:{h['url'][:60]}", h["value"], preview,
|
||||||
f"X-API-Key — {h['url'][:40]}", 95)
|
f"X-API-Key — {h['url'][:40]}", 95)
|
||||||
else:
|
else:
|
||||||
_add(candidates, "bearer_token", f"network:{h['url'][:60]}", h["value"], preview,
|
_add(candidates, "bearer_token", f"network:{h['url'][:60]}", h["value"], preview,
|
||||||
f"Authorization — {h['url'][:40]}", 95)
|
f"Authorization — {h['url'][:40]}", 95,
|
||||||
|
extra={"new_api_user": new_api_user, "user_id": new_api_user} if new_api_user else None)
|
||||||
|
|
||||||
# 2. localStorage/sessionStorage items
|
# 2. localStorage/sessionStorage items
|
||||||
for store_name, store in [("localStorage", local_storage), ("sessionStorage", session_storage)]:
|
for store_name, store in [("localStorage", local_storage), ("sessionStorage", session_storage)]:
|
||||||
@@ -155,9 +164,6 @@ def _curate_candidates(
|
|||||||
_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)
|
||||||
|
|
||||||
if not new_api_user:
|
|
||||||
new_api_user = _find_new_api_user(local_storage, session_storage)
|
|
||||||
|
|
||||||
# 3. 单个 Session cookie(保留,供独立 fallback / bearer 降级使用)
|
# 3. 单个 Session cookie(保留,供独立 fallback / bearer 降级使用)
|
||||||
for c in cookies:
|
for c in cookies:
|
||||||
cname = c["name"].lower()
|
cname = c["name"].lower()
|
||||||
@@ -168,6 +174,7 @@ def _curate_candidates(
|
|||||||
extra = {"cookie_name": c["name"], "cookie_value": c["value"]}
|
extra = {"cookie_name": c["name"], "cookie_value": c["value"]}
|
||||||
if cname == "session" and new_api_user:
|
if cname == "session" and new_api_user:
|
||||||
extra["new_api_user"] = new_api_user
|
extra["new_api_user"] = new_api_user
|
||||||
|
extra["user_id"] = new_api_user
|
||||||
_add(candidates, "cookie", f"cookie:{c['name']}", cookie_val, preview,
|
_add(candidates, "cookie", f"cookie:{c['name']}", cookie_val, preview,
|
||||||
f"Cookie {c['name']} ({c['domain']})", confidence,
|
f"Cookie {c['name']} ({c['domain']})", confidence,
|
||||||
extra=extra)
|
extra=extra)
|
||||||
@@ -182,6 +189,15 @@ def _curate_candidates(
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _find_user_header(headers: list[dict[str, str]]) -> str:
|
||||||
|
for header in headers:
|
||||||
|
if header.get("type") == "user_id":
|
||||||
|
value = str(header.get("value") or "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ def _normalize_headers(value: Any) -> list[dict[str, str]]:
|
|||||||
"type": str(item.get("type") or "authorization"),
|
"type": str(item.get("type") or "authorization"),
|
||||||
"value": header_value,
|
"value": header_value,
|
||||||
"url": str(item.get("url") or ""),
|
"url": str(item.get("url") or ""),
|
||||||
|
"header": str(item.get("header") or ""),
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
|
|||||||
|
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return _normalize(value)
|
return _normalize(value)
|
||||||
|
if isinstance(value, dict) and not _is_success_response(value):
|
||||||
|
raise UpstreamError(_response_message(value, "upstream API returned success=false"))
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for key in ("data", "items", "groups", "available_groups", "availableGroups"):
|
for key in ("data", "items", "groups", "available_groups", "availableGroups"):
|
||||||
nested = value.get(key)
|
nested = value.get(key)
|
||||||
@@ -145,8 +147,11 @@ def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
|
|||||||
elif isinstance(nested, dict):
|
elif isinstance(nested, dict):
|
||||||
# Handle /api/user/self/groups where data is a dict of group_name -> { desc, ratio }
|
# Handle /api/user/self/groups where data is a dict of group_name -> { desc, ratio }
|
||||||
out = []
|
out = []
|
||||||
for k in nested.keys():
|
for k, item in nested.items():
|
||||||
out.append({"id": k, "name": k})
|
row = {"id": k, "name": k}
|
||||||
|
if isinstance(item, dict):
|
||||||
|
row.update(item)
|
||||||
|
out.append(row)
|
||||||
return out
|
return out
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -316,9 +321,16 @@ 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 == "nox_token"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _user_header_value(self) -> str:
|
||||||
|
return _clean_auth_header_value(
|
||||||
|
self.auth_config.get("user_id", "") or self.auth_config.get("new_api_user", ""),
|
||||||
|
"User header",
|
||||||
|
)
|
||||||
|
|
||||||
def _headers(self, auth: bool = True) -> dict[str, str]:
|
def _headers(self, auth: bool = True) -> dict[str, str]:
|
||||||
headers: dict[str, str] = {
|
headers: dict[str, str] = {
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
@@ -330,6 +342,13 @@ class UpstreamClient:
|
|||||||
token = _clean_auth_header_value(self.auth_config.get("token", ""), "Bearer token")
|
token = _clean_auth_header_value(self.auth_config.get("token", ""), "Bearer token")
|
||||||
if token:
|
if token:
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
elif self.auth_type == "nox_token":
|
||||||
|
token = _clean_auth_header_value(self.auth_config.get("token", ""), "Nox access token")
|
||||||
|
user_id = self._user_header_value()
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
if user_id:
|
||||||
|
headers["Nox-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")
|
||||||
@@ -339,20 +358,24 @@ class UpstreamClient:
|
|||||||
cookie_str = _clean_auth_header_value(self.auth_config.get("cookie_string", ""), "Cookie")
|
cookie_str = _clean_auth_header_value(self.auth_config.get("cookie_string", ""), "Cookie")
|
||||||
if cookie_str:
|
if cookie_str:
|
||||||
headers["Cookie"] = cookie_str
|
headers["Cookie"] = cookie_str
|
||||||
new_api_user = _clean_auth_header_value(self.auth_config.get("new_api_user", ""), "New-Api-User")
|
user_id = self._user_header_value()
|
||||||
if new_api_user:
|
if user_id:
|
||||||
headers["New-Api-User"] = new_api_user
|
headers["New-Api-User"] = user_id
|
||||||
|
headers["Nox-Api-User"] = user_id
|
||||||
elif self.auth_type == "login_password" and self._token:
|
elif self.auth_type == "login_password" and self._token:
|
||||||
token = _clean_auth_header_value(self._token, "Login token")
|
token = _clean_auth_header_value(self._token, "Login token")
|
||||||
if token:
|
if token:
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
if self.auth_type == "login_password" and self._new_api_user:
|
if self.auth_type == "login_password" and self._new_api_user:
|
||||||
headers["New-Api-User"] = self._new_api_user
|
headers["New-Api-User"] = self._new_api_user
|
||||||
|
headers["Nox-Api-User"] = self._new_api_user
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
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.auth_config.get("new_api_user"):
|
||||||
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 == "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")
|
||||||
url = self._url(path)
|
url = self._url(path)
|
||||||
if body is not None:
|
if body is not None:
|
||||||
resp = self._client.request(
|
resp = self._client.request(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const authHeadersByTab = new Map()
|
const authHeadersByTab = new Map()
|
||||||
const AUTH_HEADER_NAMES = new Set(['authorization', 'x-api-key', 'api-key'])
|
const AUTH_HEADER_NAMES = new Set(['authorization', 'x-api-key', 'api-key'])
|
||||||
|
const USER_HEADER_NAMES = new Set(['new-api-user', 'nox-api-user'])
|
||||||
|
|
||||||
function rememberHeader(tabId, entry) {
|
function rememberHeader(tabId, entry) {
|
||||||
if (tabId < 0) return
|
if (tabId < 0) return
|
||||||
@@ -17,11 +18,16 @@ chrome.webRequest.onBeforeSendHeaders.addListener(
|
|||||||
for (const header of headers) {
|
for (const header of headers) {
|
||||||
const name = String(header.name || '').toLowerCase()
|
const name = String(header.name || '').toLowerCase()
|
||||||
const value = String(header.value || '').trim()
|
const value = String(header.value || '').trim()
|
||||||
if (!AUTH_HEADER_NAMES.has(name) || !value) continue
|
if ((!AUTH_HEADER_NAMES.has(name) && !USER_HEADER_NAMES.has(name)) || !value) continue
|
||||||
rememberHeader(details.tabId, {
|
rememberHeader(details.tabId, {
|
||||||
type: name === 'authorization' ? 'authorization' : 'api_key',
|
type: name === 'authorization'
|
||||||
|
? 'authorization'
|
||||||
|
: USER_HEADER_NAMES.has(name)
|
||||||
|
? 'user_id'
|
||||||
|
: 'api_key',
|
||||||
value,
|
value,
|
||||||
url: details.url || '',
|
url: details.url || '',
|
||||||
|
header: header.name || '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -460,6 +460,7 @@ export interface AuthCaptureCandidate {
|
|||||||
cookie_count?: number
|
cookie_count?: number
|
||||||
cookie_names?: string[]
|
cookie_names?: string[]
|
||||||
new_api_user?: string
|
new_api_user?: string
|
||||||
|
user_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthCaptureResult {
|
export interface AuthCaptureResult {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ 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 }): 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
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const visible = ref(props.modelValue)
|
const visible = ref(props.modelValue)
|
||||||
@@ -170,6 +170,7 @@ function resolveCandidateValue(candidate: AuthCaptureCandidate): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveNewApiUser(rawResult: AuthCaptureResult, candidate: AuthCaptureCandidate): string | undefined {
|
function resolveNewApiUser(rawResult: AuthCaptureResult, candidate: AuthCaptureCandidate): string | undefined {
|
||||||
|
if (candidate.user_id) return candidate.user_id
|
||||||
if (candidate.new_api_user) return candidate.new_api_user
|
if (candidate.new_api_user) return candidate.new_api_user
|
||||||
const stores = [rawResult.storage, rawResult.session_storage]
|
const stores = [rawResult.storage, rawResult.session_storage]
|
||||||
for (const store of stores) {
|
for (const store of stores) {
|
||||||
@@ -327,6 +328,7 @@ async function confirmImportSelection() {
|
|||||||
cookie_value: fullCandidate.cookie_value,
|
cookie_value: fullCandidate.cookie_value,
|
||||||
cookie_count: fullCandidate.cookie_count,
|
cookie_count: fullCandidate.cookie_count,
|
||||||
cookie_names: fullCandidate.cookie_names,
|
cookie_names: fullCandidate.cookie_names,
|
||||||
|
user_id: resolveNewApiUser(rawResult.data.result, fullCandidate),
|
||||||
new_api_user: resolveNewApiUser(rawResult.data.result, fullCandidate),
|
new_api_user: resolveNewApiUser(rawResult.data.result, fullCandidate),
|
||||||
})
|
})
|
||||||
closeDialog()
|
closeDialog()
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
<el-select v-model="quickPlatform" @change="handlePlatformChange" style="width: 100%">
|
<el-select v-model="quickPlatform" @change="handlePlatformChange" style="width: 100%">
|
||||||
<el-option label="Sub2API" value="sub2api" />
|
<el-option label="Sub2API" value="sub2api" />
|
||||||
<el-option label="New-API" value="new-api-user" />
|
<el-option label="New-API" value="new-api-user" />
|
||||||
|
<el-option label="Nox-API" value="nox-api" />
|
||||||
<el-option label="自定义" value="custom" />
|
<el-option label="自定义" value="custom" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -134,6 +135,7 @@
|
|||||||
<el-select v-model="form.auth_type" style="width: 100%">
|
<el-select v-model="form.auth_type" style="width: 100%">
|
||||||
<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="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" />
|
||||||
<el-option label="邮箱密码登录" value="login_password" />
|
<el-option label="邮箱密码登录" value="login_password" />
|
||||||
@@ -150,6 +152,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="form.auth_type === 'nox_token'">
|
||||||
|
<el-form-item label="Nox Access Token">
|
||||||
|
<div class="auth-field-row">
|
||||||
|
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="***" />
|
||||||
|
<el-button size="small" @click="openAuthCapture">
|
||||||
|
<el-icon><Pointer /></el-icon>
|
||||||
|
提取
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Nox User ID">
|
||||||
|
<el-input v-model="form.auth_config.user_id" placeholder="例如 1" />
|
||||||
|
<div class="form-hint">请求 <code>/api/user/self/groups</code> 时需要同时发送 <code>Nox-Api-User</code></div>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
<template v-else-if="form.auth_type === 'cookie'">
|
<template v-else-if="form.auth_type === 'cookie'">
|
||||||
<el-form-item label="Cookie">
|
<el-form-item label="Cookie">
|
||||||
<div class="auth-field-row">
|
<div class="auth-field-row">
|
||||||
@@ -195,7 +212,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="余额除数">
|
<el-form-item label="余额除数">
|
||||||
<el-input-number v-model="form.balance_divisor" :min="1" :max="999999999" style="width: 100%" />
|
<el-input-number v-model="form.balance_divisor" :min="1" :max="999999999" style="width: 100%" />
|
||||||
<div class="form-hint">原始值除以该数得到实际余额。New-API 填 <code>500000</code>,Sub2API 填 <code>1</code></div>
|
<div class="form-hint">原始值除以该数得到实际余额。New-API / Nox-API 填 <code>500000</code>,Sub2API 填 <code>1</code></div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="余额告警阈值">
|
<el-form-item label="余额告警阈值">
|
||||||
<el-input-number v-model="form.balance_alert_threshold" :min="0" :precision="2" :value-on-clear="null" style="width: 100%" />
|
<el-input-number v-model="form.balance_alert_threshold" :min="0" :precision="2" :value-on-clear="null" style="width: 100%" />
|
||||||
@@ -462,6 +479,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 ['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']
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -477,28 +495,45 @@ function handleAuthCaptureSelect(candidate: {
|
|||||||
cookie_count?: number
|
cookie_count?: number
|
||||||
cookie_names?: string[]
|
cookie_names?: string[]
|
||||||
new_api_user?: string
|
new_api_user?: string
|
||||||
|
user_id?: string
|
||||||
}) {
|
}) {
|
||||||
if (candidate.type === 'bearer_token') {
|
if (candidate.type === 'bearer_token') {
|
||||||
form.value.auth_type = 'bearer'
|
if (quickPlatform.value === 'nox-api') {
|
||||||
form.value.auth_config.token = candidate.value
|
form.value.auth_type = 'nox_token'
|
||||||
ElMessage.success('已填入 Bearer Token')
|
form.value.auth_config.token = candidate.value
|
||||||
|
form.value.auth_config.provider = 'nox-api'
|
||||||
|
if (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')
|
||||||
|
} else {
|
||||||
|
form.value.auth_type = 'bearer'
|
||||||
|
form.value.auth_config.token = candidate.value
|
||||||
|
ElMessage.success('已填入 Bearer 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') {
|
||||||
|
form.value.auth_config.provider = quickPlatform.value
|
||||||
|
}
|
||||||
if (quickPlatform.value === 'sub2api') {
|
if (quickPlatform.value === 'sub2api') {
|
||||||
ElMessage.warning('Sub2API 通常需要 Bearer Token;Cookie 只能在确认上游支持 Cookie 鉴权时使用')
|
ElMessage.warning('Sub2API 通常需要 Bearer Token;Cookie 只能在确认上游支持 Cookie 鉴权时使用')
|
||||||
}
|
}
|
||||||
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.api_prefix = ''
|
form.value.api_prefix = ''
|
||||||
form.value.groups_endpoint = '/api/user/self/groups'
|
form.value.groups_endpoint = '/api/user/self/groups'
|
||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
form.value.rate_endpoint = '/api/user/self/groups'
|
||||||
} else if (quickPlatform.value === 'new-api-user') {
|
} else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||||||
form.value.api_prefix = ''
|
form.value.api_prefix = ''
|
||||||
form.value.groups_endpoint = '/api/user/self/groups'
|
form.value.groups_endpoint = '/api/user/self/groups'
|
||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
form.value.rate_endpoint = '/api/user/self/groups'
|
||||||
ElMessage.warning('已填入完整 Cookie 组,但未提取到 New-Api-User,请重新登录后再提取')
|
if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||||||
|
ElMessage.warning(`已填入完整 Cookie 组,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const countLabel = candidate.cookie_count ? `(${candidate.cookie_count} 个 cookie)` : ''
|
const countLabel = candidate.cookie_count ? `(${candidate.cookie_count} 个 cookie)` : ''
|
||||||
ElMessage.success(`已填入完整 Cookie 组${countLabel}`)
|
ElMessage.success(`已填入完整 Cookie 组${countLabel}`)
|
||||||
@@ -507,19 +542,25 @@ function handleAuthCaptureSelect(candidate: {
|
|||||||
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
|
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
|
||||||
? `${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') {
|
||||||
|
form.value.auth_config.provider = quickPlatform.value
|
||||||
|
}
|
||||||
if (quickPlatform.value === 'sub2api') {
|
if (quickPlatform.value === 'sub2api') {
|
||||||
ElMessage.warning('Sub2API 通常需要 Bearer Token;Cookie 只能在确认上游支持 Cookie 鉴权时使用')
|
ElMessage.warning('Sub2API 通常需要 Bearer Token;Cookie 只能在确认上游支持 Cookie 鉴权时使用')
|
||||||
}
|
}
|
||||||
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.api_prefix = ''
|
form.value.api_prefix = ''
|
||||||
form.value.groups_endpoint = '/api/user/self/groups'
|
form.value.groups_endpoint = '/api/user/self/groups'
|
||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
form.value.rate_endpoint = '/api/user/self/groups'
|
||||||
} else if (quickPlatform.value === 'new-api-user') {
|
} else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||||||
form.value.api_prefix = ''
|
form.value.api_prefix = ''
|
||||||
form.value.groups_endpoint = '/api/user/self/groups'
|
form.value.groups_endpoint = '/api/user/self/groups'
|
||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
form.value.rate_endpoint = '/api/user/self/groups'
|
||||||
ElMessage.warning('已填入 Cookie,但未提取到 New-Api-User,请重新登录后再提取')
|
if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||||||
|
ElMessage.warning(`已填入 Cookie,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ElMessage.success('已填入 Cookie')
|
ElMessage.success('已填入 Cookie')
|
||||||
} else if (candidate.type === 'api_key') {
|
} else if (candidate.type === 'api_key') {
|
||||||
@@ -530,12 +571,20 @@ 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 = 'bearer'
|
form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_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' && (candidate.user_id || candidate.new_api_user)) {
|
||||||
|
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||||||
|
}
|
||||||
ElMessage.success('已填入 Bearer Token (sk-key)')
|
ElMessage.success('已填入 Bearer Token (sk-key)')
|
||||||
} else {
|
} else {
|
||||||
form.value.auth_type = 'bearer'
|
form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_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' && (candidate.user_id || candidate.new_api_user)) {
|
||||||
|
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||||||
|
}
|
||||||
ElMessage.success('已填入认证信息')
|
ElMessage.success('已填入认证信息')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -543,6 +592,15 @@ function handleAuthCaptureSelect(candidate: {
|
|||||||
|
|
||||||
const quickPlatform = ref('sub2api')
|
const quickPlatform = ref('sub2api')
|
||||||
|
|
||||||
|
function inferPlatform(row: Pick<UpstreamData, 'api_prefix' | 'groups_endpoint' | 'auth_type' | 'auth_config_masked'>): string {
|
||||||
|
if (row.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1') return 'sub2api'
|
||||||
|
if (row.api_prefix === '' && row.groups_endpoint === '/api/user/self/groups') {
|
||||||
|
if (row.auth_type === 'nox_token' || row.auth_config_masked?.provider === 'nox-api') return 'nox-api'
|
||||||
|
return 'new-api-user'
|
||||||
|
}
|
||||||
|
return 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
function handlePlatformChange(val: string) {
|
function handlePlatformChange(val: string) {
|
||||||
if (val === 'sub2api') {
|
if (val === 'sub2api') {
|
||||||
form.value.api_prefix = '/api/v1'
|
form.value.api_prefix = '/api/v1'
|
||||||
@@ -559,11 +617,21 @@ function handlePlatformChange(val: string) {
|
|||||||
form.value.groups_endpoint = '/api/user/self/groups'
|
form.value.groups_endpoint = '/api/user/self/groups'
|
||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
form.value.rate_endpoint = '/api/user/self/groups'
|
||||||
form.value.auth_type = 'login_password'
|
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.login_path = '/api/user/login'
|
||||||
form.value.auth_config.username_field = 'username'
|
form.value.auth_config.username_field = 'username'
|
||||||
form.value.balance_endpoint = '/api/user/self'
|
form.value.balance_endpoint = '/api/user/self'
|
||||||
form.value.balance_response_path = 'data.quota'
|
form.value.balance_response_path = 'data.quota'
|
||||||
form.value.balance_divisor = 500000
|
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 {
|
} else {
|
||||||
form.value.balance_endpoint = ''
|
form.value.balance_endpoint = ''
|
||||||
form.value.balance_response_path = ''
|
form.value.balance_response_path = ''
|
||||||
@@ -613,13 +681,14 @@ const healthyRate = computed(() => {
|
|||||||
|
|
||||||
const pendingChecks = computed(() => list.value.filter((item) => !item.last_checked_at).length)
|
const pendingChecks = computed(() => list.value.filter((item) => !item.last_checked_at).length)
|
||||||
|
|
||||||
function isNewApiUserUpstream(row: UpstreamData | null) {
|
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 === '/api/user/self/groups'
|
||||||
|| row.auth_config_masked?.login_path === '/api/user/login'
|
|| row.auth_config_masked?.login_path === '/api/user/login'
|
||||||
|| Boolean(row.auth_config_masked?.new_api_user)
|
|| Boolean(row.auth_config_masked?.new_api_user)
|
||||||
|
|| row.auth_type === 'nox_token'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,7 +714,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', cookie: 'Cookie', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
|
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', 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 '—'
|
||||||
@@ -691,6 +760,7 @@ function openCreate() {
|
|||||||
|
|
||||||
function openEdit(row: UpstreamData) {
|
function openEdit(row: UpstreamData) {
|
||||||
editingId.value = row.id
|
editingId.value = row.id
|
||||||
|
quickPlatform.value = inferPlatform(row)
|
||||||
form.value = {
|
form.value = {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
base_url: row.base_url,
|
base_url: row.base_url,
|
||||||
@@ -813,7 +883,7 @@ async function openKeyGenerate(row: UpstreamData) {
|
|||||||
rate_limit_5h: 0,
|
rate_limit_5h: 0,
|
||||||
rate_limit_1d: 0,
|
rate_limit_1d: 0,
|
||||||
rate_limit_7d: 0,
|
rate_limit_7d: 0,
|
||||||
endpoint: isNewApiUserUpstream(row) ? '/api/token' : '/keys',
|
endpoint: usesTokenEndpointUpstream(row) ? '/api/token' : '/keys',
|
||||||
}
|
}
|
||||||
useKeyExpiry.value = false
|
useKeyExpiry.value = false
|
||||||
keyExpiresDays.value = 30
|
keyExpiresDays.value = 30
|
||||||
|
|||||||
Reference in New Issue
Block a user