diff --git a/backend/app/routers/upstreams.py b/backend/app/routers/upstreams.py index 994428f..bcbaef5 100644 --- a/backend/app/routers/upstreams.py +++ b/backend/app/routers/upstreams.py @@ -37,6 +37,16 @@ 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: for key in ("id", "group_id", "groupId"): value = group.get(key) @@ -80,6 +90,13 @@ def _mask_auth_config(auth_type: str, cfg: dict) -> dict: 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: if not isinstance(payload, dict): return "" @@ -319,20 +336,39 @@ def _is_sub2api_upstream(upstream: Upstream) -> bool: 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: auth_config = json.loads(upstream.auth_config_json or "{}") return ( upstream.api_prefix.strip("/") == "" and ( - upstream.groups_endpoint == "/api/user/self/groups" - or auth_config.get("login_path") == "/api/user/login" - or bool(auth_config.get("new_api_user")) + bool(auth_config.get("new_api_user")) + or ( + upstream.groups_endpoint == "/api/user/self/groups" + and auth_config.get("login_path") != "/api/user/login" + ) ) ) 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( @@ -486,7 +522,7 @@ def generate_keys_by_groups( if not u: raise HTTPException(404, "upstream not found") if not _supports_key_generation(u): - raise HTTPException(400, "仅支持 Sub2API 或 New-API 普通账号上游生成 Key") + raise HTTPException(400, "仅支持 Sub2API、New-API 普通账号或 Nox-API 上游生成 Key") # 生成前先对账,清理远端已删除的旧 Key try: @@ -766,7 +802,7 @@ def create_upstream( base_url=body.base_url.rstrip("/"), api_prefix=body.api_prefix, 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, groups_endpoint=body.groups_endpoint, enabled=body.enabled, @@ -810,7 +846,8 @@ def update_upstream( for k, v in incoming.items(): if v != MASK: # don't overwrite with mask placeholder 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: data["base_url"] = data["base_url"].rstrip("/") for k, v in data.items(): diff --git a/backend/app/services/auth_capture_service.py b/backend/app/services/auth_capture_service.py index b930041..f398bc6 100644 --- a/backend/app/services/auth_capture_service.py +++ b/backend/app/services/auth_capture_service.py @@ -90,6 +90,11 @@ def _curate_candidates( """Scan extracted data for likely credentials with confidence scoring.""" 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(最高优先级) # 按页面 origin 收集所有相关 cookie,包含 cf_clearance 等 Cloudflare cookie cookie_string, cookie_names = _build_cookie_bundle(cookies, page_url) @@ -100,6 +105,7 @@ def _curate_candidates( } if new_api_user: bundle_extra["new_api_user"] = new_api_user + bundle_extra["user_id"] = new_api_user _add( candidates, "cookie_bundle", f"bundle:{page_url[:60]}", @@ -113,18 +119,21 @@ def _curate_candidates( # 1. CDP-captured network headers (high confidence) seen = set() for h in auth_headers: + htype = h.get("type", "authorization") + if htype == "user_id": + continue dedup_key = h["value"] if dedup_key in seen: continue seen.add(dedup_key) - htype = h.get("type", "authorization") preview = _preview(h["value"]) if htype == "api_key": _add(candidates, "api_key", f"network:{h['url'][:60]}", h["value"], preview, f"X-API-Key — {h['url'][:40]}", 95) else: _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 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), 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 降级使用) for c in cookies: cname = c["name"].lower() @@ -168,6 +174,7 @@ def _curate_candidates( extra = {"cookie_name": c["name"], "cookie_value": c["value"]} if cname == "session" and 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, f"Cookie {c['name']} ({c['domain']})", confidence, extra=extra) @@ -182,6 +189,15 @@ def _curate_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: for store in stores: value = store.get(key) diff --git a/backend/app/services/browser_import_service.py b/backend/app/services/browser_import_service.py index 89da937..a2ddd86 100644 --- a/backend/app/services/browser_import_service.py +++ b/backend/app/services/browser_import_service.py @@ -80,6 +80,7 @@ def _normalize_headers(value: Any) -> list[dict[str, str]]: "type": str(item.get("type") or "authorization"), "value": header_value, "url": str(item.get("url") or ""), + "header": str(item.get("header") or ""), }) return result diff --git a/backend/app/services/upstream_client.py b/backend/app/services/upstream_client.py index 6262845..c3a20e6 100644 --- a/backend/app/services/upstream_client.py +++ b/backend/app/services/upstream_client.py @@ -137,6 +137,8 @@ def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]: if isinstance(value, list): 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): for key in ("data", "items", "groups", "available_groups", "availableGroups"): nested = value.get(key) @@ -145,8 +147,11 @@ def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]: elif isinstance(nested, dict): # Handle /api/user/self/groups where data is a dict of group_name -> { desc, ratio } out = [] - for k in nested.keys(): - out.append({"id": k, "name": k}) + for k, item in nested.items(): + row = {"id": k, "name": k} + if isinstance(item, dict): + row.update(item) + out.append(row) return out return None @@ -316,9 +321,16 @@ class UpstreamClient: bool(self.auth_config.get("new_api_user")) or login_path == "/api/user/login" 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]: headers: dict[str, str] = { "Accept": "application/json", @@ -330,6 +342,13 @@ class UpstreamClient: token = _clean_auth_header_value(self.auth_config.get("token", ""), "Bearer token") if 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": key = _clean_auth_header_value(self.auth_config.get("key", ""), "API key") 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") if cookie_str: headers["Cookie"] = cookie_str - new_api_user = _clean_auth_header_value(self.auth_config.get("new_api_user", ""), "New-Api-User") - if new_api_user: - headers["New-Api-User"] = new_api_user + user_id = self._user_header_value() + if user_id: + headers["New-Api-User"] = user_id + headers["Nox-Api-User"] = user_id elif self.auth_type == "login_password" and self._token: token = _clean_auth_header_value(self._token, "Login token") if token: headers["Authorization"] = f"Bearer {token}" if self.auth_type == "login_password" and self._new_api_user: headers["New-Api-User"] = self._new_api_user + headers["Nox-Api-User"] = self._new_api_user return headers 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"): 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) if body is not None: resp = self._client.request( diff --git a/browser-extension/background.js b/browser-extension/background.js index 2897e26..ca0788c 100644 --- a/browser-extension/background.js +++ b/browser-extension/background.js @@ -1,5 +1,6 @@ const authHeadersByTab = new Map() 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) { if (tabId < 0) return @@ -17,11 +18,16 @@ chrome.webRequest.onBeforeSendHeaders.addListener( for (const header of headers) { const name = String(header.name || '').toLowerCase() 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, { - type: name === 'authorization' ? 'authorization' : 'api_key', + type: name === 'authorization' + ? 'authorization' + : USER_HEADER_NAMES.has(name) + ? 'user_id' + : 'api_key', value, url: details.url || '', + header: header.name || '', }) } }, diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 5638692..1d3ca3a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -460,6 +460,7 @@ export interface AuthCaptureCandidate { cookie_count?: number cookie_names?: string[] new_api_user?: string + user_id?: string } export interface AuthCaptureResult { diff --git a/frontend/src/components/AuthCaptureDialog.vue b/frontend/src/components/AuthCaptureDialog.vue index 2da3432..7a2a837 100644 --- a/frontend/src/components/AuthCaptureDialog.vue +++ b/frontend/src/components/AuthCaptureDialog.vue @@ -112,7 +112,7 @@ const props = defineProps<{ const emit = defineEmits<{ (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) @@ -170,6 +170,7 @@ function resolveCandidateValue(candidate: AuthCaptureCandidate): string { } 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 const stores = [rawResult.storage, rawResult.session_storage] for (const store of stores) { @@ -327,6 +328,7 @@ async function confirmImportSelection() { cookie_value: fullCandidate.cookie_value, cookie_count: fullCandidate.cookie_count, cookie_names: fullCandidate.cookie_names, + user_id: resolveNewApiUser(rawResult.data.result, fullCandidate), new_api_user: resolveNewApiUser(rawResult.data.result, fullCandidate), }) closeDialog() diff --git a/frontend/src/views/Upstreams.vue b/frontend/src/views/Upstreams.vue index 1e336ad..4a2e581 100644 --- a/frontend/src/views/Upstreams.vue +++ b/frontend/src/views/Upstreams.vue @@ -121,6 +121,7 @@ + @@ -134,6 +135,7 @@ + @@ -150,6 +152,21 @@ +