refactor: convert f-string logging to lazy %-style format (G004)
Sweep all 1676 G004 violations across 112 files, converting
`logger.<level>(f"…{x}…")` to `logger.<level>("…%s…", x)`.
Why: ruff rule G004 was added to pyproject.toml to enforce lazy
%-style logging — defers formatting until the log level is enabled
and lets structured log tooling match the unformatted template.
Conversion preserves rendered output byte-for-byte:
- `{x}` → `%s` + `x`
- `{x!r}` / `{x!s}` / `{x!a}` → `%r` / `%s` / `%a`
- Format specs (`{x:.2f}`, `{x:>10}`) → `%s` + `format(x, 'spec')`
(printf-style specs aren't 1:1 with Python format specs, so we
delegate to `format()` to keep identical output)
- Literal `%` → `%%`
- Concatenated f-strings (`f"a {x} " "b"`) flattened
- Trailing kwargs (`exc_info=True`) preserved
Verified:
- `uv run ruff check --select G004` → 0 violations
- `uv run ty check -- nextcloud_mcp_server` → passes
- `uv run pytest tests/unit/` → 1010 passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a4e6125d28
commit
665cb9b1eb
@@ -61,12 +61,12 @@ class AstrolabeClient:
|
||||
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
|
||||
|
||||
async with nextcloud_httpx_client() as client:
|
||||
logger.debug(f"Discovering token endpoint from {discovery_url}")
|
||||
logger.debug("Discovering token endpoint from %s", discovery_url)
|
||||
discovery_resp = await client.get(discovery_url)
|
||||
discovery_resp.raise_for_status()
|
||||
token_endpoint = discovery_resp.json()["token_endpoint"]
|
||||
|
||||
logger.debug(f"Requesting client credentials token from {token_endpoint}")
|
||||
logger.debug("Requesting client credentials token from %s", token_endpoint)
|
||||
|
||||
# Request token using client credentials grant
|
||||
token_resp = await client.post(
|
||||
@@ -88,7 +88,7 @@ class AstrolabeClient:
|
||||
"expires_at": time.time() + expires_in - 60,
|
||||
}
|
||||
|
||||
logger.info(f"Obtained Astrolabe API token (expires in {expires_in}s)")
|
||||
logger.info("Obtained Astrolabe API token (expires in %ss)", expires_in)
|
||||
return data["access_token"]
|
||||
|
||||
async def get_user_app_password(self, user_id: str) -> Optional[str]:
|
||||
@@ -108,7 +108,7 @@ class AstrolabeClient:
|
||||
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
||||
|
||||
async with nextcloud_httpx_client() as client:
|
||||
logger.debug(f"Retrieving app password for user: {user_id}")
|
||||
logger.debug("Retrieving app password for user: %s", user_id)
|
||||
|
||||
response = await client.get(
|
||||
url,
|
||||
@@ -117,14 +117,16 @@ class AstrolabeClient:
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
logger.debug(f"No app password configured for user: {user_id}")
|
||||
logger.debug("No app password configured for user: %s", user_id)
|
||||
return None
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
logger.info(
|
||||
f"Retrieved app password for user: {user_id} (type: {data.get('credential_type')})"
|
||||
"Retrieved app password for user: %s (type: %s)",
|
||||
user_id,
|
||||
data.get("credential_type"),
|
||||
)
|
||||
return data.get("app_password")
|
||||
|
||||
|
||||
@@ -133,8 +133,8 @@ async def register_client(
|
||||
if resource_url:
|
||||
client_metadata["resource_url"] = resource_url
|
||||
|
||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||
logger.info("Registering OAuth client with Nextcloud: %s", client_name)
|
||||
logger.debug("Registration endpoint: %s", registration_endpoint)
|
||||
|
||||
async with nextcloud_httpx_client(timeout=30.0) as client:
|
||||
for attempt in range(max_retries):
|
||||
@@ -151,14 +151,17 @@ async def register_client(
|
||||
retry_after = int(response.headers.get("Retry-After", 2))
|
||||
wait_time = min(retry_after, 2**attempt)
|
||||
logger.warning(
|
||||
f"Rate limited (429) registering client, "
|
||||
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
|
||||
"Rate limited (429) registering client, retrying in %ss (attempt %s/%s)",
|
||||
wait_time,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
)
|
||||
await anyio.sleep(wait_time)
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to register client after {max_retries} attempts: Rate limited (429)"
|
||||
"Failed to register client after %s attempts: Rate limited (429)",
|
||||
max_retries,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -166,14 +169,15 @@ async def register_client(
|
||||
|
||||
client_info = response.json()
|
||||
logger.info(
|
||||
f"Successfully registered client: {client_info.get('client_id')}"
|
||||
"Successfully registered client: %s", client_info.get("client_id")
|
||||
)
|
||||
expires_at = dt.datetime.fromtimestamp(
|
||||
client_info.get("client_secret_expires_at")
|
||||
)
|
||||
logger.info(
|
||||
f"Client expires at: {expires_at} "
|
||||
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
|
||||
"Client expires at: %s (in %s seconds)",
|
||||
expires_at,
|
||||
client_info.get("client_secret_expires_at", 0) - int(time.time()),
|
||||
)
|
||||
|
||||
# Log if RFC 7592 fields are present
|
||||
@@ -206,13 +210,13 @@ async def register_client(
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"Failed to register client: HTTP {e.response.status_code}"
|
||||
"Failed to register client: HTTP %s", e.response.status_code
|
||||
)
|
||||
logger.error(f"Response: {e.response.text}")
|
||||
logger.error("Response: %s", e.response.text)
|
||||
raise
|
||||
except KeyError as e:
|
||||
logger.error(
|
||||
f"Invalid response from registration endpoint: missing {e}"
|
||||
"Invalid response from registration endpoint: missing %s", e
|
||||
)
|
||||
raise ValueError(f"Invalid registration response: missing {e}")
|
||||
|
||||
@@ -264,8 +268,8 @@ async def delete_client(
|
||||
else:
|
||||
deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}"
|
||||
|
||||
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
||||
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
||||
logger.info("Deleting OAuth client: %s...", client_id[:16])
|
||||
logger.debug("Deletion endpoint: %s", deletion_endpoint)
|
||||
|
||||
async with nextcloud_httpx_client(timeout=30.0) as http_client:
|
||||
for attempt in range(max_retries):
|
||||
@@ -296,7 +300,7 @@ async def delete_client(
|
||||
# RFC 7592: Successful deletion returns 204 No Content
|
||||
if response.status_code == 204:
|
||||
logger.info(
|
||||
f"Successfully deleted OAuth client: {client_id[:16]}..."
|
||||
"Successfully deleted OAuth client: %s...", client_id[:16]
|
||||
)
|
||||
return True
|
||||
elif response.status_code == 429:
|
||||
@@ -307,42 +311,53 @@ async def delete_client(
|
||||
retry_after, 2**attempt
|
||||
) # Exponential backoff, max from header
|
||||
logger.warning(
|
||||
f"Rate limited (429) deleting client {client_id[:16]}..., "
|
||||
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
|
||||
"Rate limited (429) deleting client %s..., retrying in %ss (attempt %s/%s)",
|
||||
client_id[:16],
|
||||
wait_time,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
)
|
||||
await anyio.sleep(wait_time)
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}... after {max_retries} attempts: Rate limited (429)"
|
||||
"Failed to delete client %s... after %s attempts: Rate limited (429)",
|
||||
client_id[:16],
|
||||
max_retries,
|
||||
)
|
||||
return False
|
||||
elif response.status_code == 401:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}...: Authentication failed (invalid credentials)"
|
||||
"Failed to delete client %s...: Authentication failed (invalid credentials)",
|
||||
client_id[:16],
|
||||
)
|
||||
return False
|
||||
elif response.status_code == 403:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}...: Not authorized (not a DCR client or wrong client)"
|
||||
"Failed to delete client %s...: Not authorized (not a DCR client or wrong client)",
|
||||
client_id[:16],
|
||||
)
|
||||
return False
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}...: HTTP {response.status_code}"
|
||||
"Failed to delete client %s...: HTTP %s",
|
||||
client_id[:16],
|
||||
response.status_code,
|
||||
)
|
||||
logger.debug(f"Response: {response.text}")
|
||||
logger.debug("Response: %s", response.text)
|
||||
return False
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error deleting client {client_id[:16]}...: {e.response.status_code}"
|
||||
"HTTP error deleting client %s...: %s",
|
||||
client_id[:16],
|
||||
e.response.status_code,
|
||||
)
|
||||
logger.debug(f"Response: {e.response.text}")
|
||||
logger.debug("Response: %s", e.response.text)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting client {client_id[:16]}...: {e}"
|
||||
"Unexpected error deleting client %s...: %s", client_id[:16], e
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -390,14 +405,14 @@ async def ensure_oauth_client(
|
||||
client_data = await storage.get_oauth_client()
|
||||
if client_data:
|
||||
logger.info(
|
||||
f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..."
|
||||
"Loaded OAuth client from SQLite: %s...", client_data["client_id"][:16]
|
||||
)
|
||||
return ClientInfo.from_dict(client_data)
|
||||
|
||||
# Register new client
|
||||
logger.info("Registering new OAuth client...")
|
||||
if resource_url:
|
||||
logger.info(f" with resource_url: {resource_url}")
|
||||
logger.info(" with resource_url: %s", resource_url)
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_url,
|
||||
registration_endpoint=registration_endpoint,
|
||||
|
||||
@@ -89,7 +89,7 @@ class ClientRegistry:
|
||||
|
||||
if not cid or not redirect:
|
||||
logger.warning(
|
||||
f"Skipping malformed ALLOWED_MCP_CLIENTS entry: {entry!r}"
|
||||
"Skipping malformed ALLOWED_MCP_CLIENTS entry: %r", entry
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -97,8 +97,9 @@ class ClientRegistry:
|
||||
hostname = parsed.hostname
|
||||
if hostname is None:
|
||||
logger.warning(
|
||||
f"Skipping client {cid!r}: cannot parse hostname "
|
||||
f"from {redirect!r}"
|
||||
"Skipping client %r: cannot parse hostname from %r",
|
||||
cid,
|
||||
redirect,
|
||||
)
|
||||
continue
|
||||
is_loopback = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
@@ -108,8 +109,9 @@ class ClientRegistry:
|
||||
or (parsed.scheme == "http" and is_loopback)
|
||||
):
|
||||
logger.warning(
|
||||
f"Rejecting client {cid!r}: HTTP redirect URIs are only "
|
||||
f"allowed for localhost, got {redirect!r}"
|
||||
"Rejecting client %r: HTTP redirect URIs are only allowed for localhost, got %r",
|
||||
cid,
|
||||
redirect,
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -120,7 +122,7 @@ class ClientRegistry:
|
||||
allowed_scopes=["*"],
|
||||
is_public=True,
|
||||
)
|
||||
logger.info(f"Registered static client: {cid}")
|
||||
logger.info("Registered static client: %s", cid)
|
||||
else:
|
||||
self._clients[entry] = MCPClientInfo(
|
||||
client_id=entry,
|
||||
@@ -129,7 +131,7 @@ class ClientRegistry:
|
||||
allowed_scopes=["*"],
|
||||
is_public=True,
|
||||
)
|
||||
logger.info(f"Registered static client: {entry}")
|
||||
logger.info("Registered static client: %s", entry)
|
||||
|
||||
if not self._clients:
|
||||
logger.warning(
|
||||
@@ -170,7 +172,7 @@ class ClientRegistry:
|
||||
if not client:
|
||||
if self.allow_dynamic_registration:
|
||||
# In production, would attempt DCR here
|
||||
logger.info(f"Unknown client {client_id}, would attempt DCR")
|
||||
logger.info("Unknown client %s, would attempt DCR", client_id)
|
||||
return True, None
|
||||
else:
|
||||
return False, f"Unknown client_id: {client_id}"
|
||||
@@ -229,15 +231,15 @@ class ClientRegistry:
|
||||
True if registered successfully
|
||||
"""
|
||||
if not self.allow_dynamic_registration:
|
||||
logger.warning(f"DCR disabled, cannot register {client_info.client_id}")
|
||||
logger.warning("DCR disabled, cannot register %s", client_info.client_id)
|
||||
return False
|
||||
|
||||
if client_info.client_id in self._clients:
|
||||
logger.warning(f"Client {client_info.client_id} already registered")
|
||||
logger.warning("Client %s already registered", client_info.client_id)
|
||||
return False
|
||||
|
||||
self._clients[client_info.client_id] = client_info
|
||||
logger.info(f"Dynamically registered client: {client_info.client_id}")
|
||||
logger.info("Dynamically registered client: %s", client_info.client_id)
|
||||
|
||||
# In production, would persist to database
|
||||
return True
|
||||
@@ -263,7 +265,7 @@ class ClientRegistry:
|
||||
allowed_scopes=["*"], # Nextcloud enforces actual scopes
|
||||
is_public=True,
|
||||
)
|
||||
logger.info(f"Registered proxy client: {client_id}")
|
||||
logger.info("Registered proxy client: %s", client_id)
|
||||
|
||||
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
|
||||
"""
|
||||
|
||||
@@ -54,8 +54,8 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
||||
raise ValueError("Username not available in OAuth token context")
|
||||
|
||||
logger.debug(
|
||||
f"Creating NextcloudClient for user {username} with multi-audience token "
|
||||
f"(no exchange needed)"
|
||||
"Creating NextcloudClient for user %s with multi-audience token (no exchange needed)",
|
||||
username,
|
||||
)
|
||||
|
||||
# Token was validated to have MCP audience
|
||||
@@ -65,6 +65,6 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
||||
)
|
||||
|
||||
except AttributeError as e:
|
||||
logger.error(f"Failed to extract OAuth context: {e}")
|
||||
logger.error("Failed to extract OAuth context: %s", e)
|
||||
logger.error("This may indicate the server is not running in OAuth mode")
|
||||
raise
|
||||
|
||||
@@ -129,7 +129,7 @@ class LoginFlowV2Client:
|
||||
f"Malformed Login Flow v2 initiate response from Nextcloud (missing key: {e})"
|
||||
) from e
|
||||
|
||||
logger.info(f"Login Flow v2 initiated: login_url={result.login_url[:60]}...")
|
||||
logger.info("Login Flow v2 initiated: login_url=%s...", result.login_url[:60])
|
||||
return result
|
||||
|
||||
def _rewrite_to_nextcloud_host(self, url: str) -> str:
|
||||
@@ -142,7 +142,7 @@ class LoginFlowV2Client:
|
||||
"""
|
||||
result = rewrite_url_origin(url, self.nextcloud_host)
|
||||
if result != url:
|
||||
logger.debug(f"Rewrote Login Flow v2 URL: {url} → {result}")
|
||||
logger.debug("Rewrote Login Flow v2 URL: %s → %s", url, result)
|
||||
return result
|
||||
|
||||
async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult:
|
||||
@@ -172,8 +172,9 @@ class LoginFlowV2Client:
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
logger.info(
|
||||
f"Login Flow v2 completed: server={data.get('server')}, "
|
||||
f"loginName={data.get('loginName')}"
|
||||
"Login Flow v2 completed: server=%s, loginName=%s",
|
||||
data.get("server"),
|
||||
data.get("loginName"),
|
||||
)
|
||||
try:
|
||||
return LoginFlowPollResult(
|
||||
@@ -193,6 +194,6 @@ class LoginFlowV2Client:
|
||||
|
||||
# Any other status indicates the flow has expired or is invalid
|
||||
logger.warning(
|
||||
f"Login Flow v2 poll returned unexpected status: {response.status_code}"
|
||||
"Login Flow v2 poll returned unexpected status: %s", response.status_code
|
||||
)
|
||||
return LoginFlowPollResult(status="expired")
|
||||
|
||||
@@ -359,7 +359,8 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
if auth_parsed.query:
|
||||
authorization_endpoint += f"?{auth_parsed.query}"
|
||||
logger.info(
|
||||
f"Rewrote authorization endpoint for browser access: {authorization_endpoint}"
|
||||
"Rewrote authorization endpoint for browser access: %s",
|
||||
authorization_endpoint,
|
||||
)
|
||||
|
||||
# Prefix resource scopes with the resource server identifier if configured.
|
||||
@@ -952,7 +953,7 @@ async def _oauth_callback_as_proxy(
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"AS proxy token exchange failed: {response.status_code} {response.text}"
|
||||
"AS proxy token exchange failed: %s %s", response.status_code, response.text
|
||||
)
|
||||
params = urlencode(
|
||||
{
|
||||
@@ -968,8 +969,8 @@ async def _oauth_callback_as_proxy(
|
||||
nc_token_response = response.json()
|
||||
|
||||
logger.info(
|
||||
"AS proxy: Successfully exchanged code for Nextcloud token "
|
||||
f"(token_type={nc_token_response.get('token_type')})"
|
||||
"AS proxy: Successfully exchanged code for Nextcloud token (token_type=%s)",
|
||||
nc_token_response.get("token_type"),
|
||||
)
|
||||
|
||||
# Verify the ID token signature + claims before caching the response
|
||||
@@ -1017,7 +1018,8 @@ async def _oauth_callback_as_proxy(
|
||||
redirect_url = f"{session.client_redirect_uri}?{redirect_params}"
|
||||
|
||||
logger.info(
|
||||
f"AS proxy: Redirecting to client with proxy_code (client_id={session.client_id})"
|
||||
"AS proxy: Redirecting to client with proxy_code (client_id=%s)",
|
||||
session.client_id,
|
||||
)
|
||||
return RedirectResponse(redirect_url, status_code=302)
|
||||
|
||||
@@ -1294,7 +1296,7 @@ async def _token_refresh(request: Request, form) -> JSONResponse:
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"AS proxy token refresh failed: {response.status_code} {response.text}"
|
||||
"AS proxy token refresh failed: %s %s", response.status_code, response.text
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -1396,7 +1398,9 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
logger.error(
|
||||
f"DCR proxy: Upstream registration failed: {response.status_code} {response.text}"
|
||||
"DCR proxy: Upstream registration failed: %s %s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
return JSONResponse(
|
||||
response.json()
|
||||
|
||||
@@ -44,11 +44,14 @@ async def is_nextcloud_admin(request: Request, http_client: AsyncClient) -> bool
|
||||
# Check if user is in the admin group
|
||||
is_admin = "admin" in user_groups
|
||||
logger.debug(
|
||||
f"Admin check for user '{username}': {is_admin} (groups: {user_groups})"
|
||||
"Admin check for user '%s': %s (groups: %s)",
|
||||
username,
|
||||
is_admin,
|
||||
user_groups,
|
||||
)
|
||||
|
||||
return is_admin
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking admin permissions: {e}", exc_info=True)
|
||||
logger.error("Error checking admin permissions: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
@@ -90,7 +90,7 @@ async def _poll_and_store(provision_id: str) -> None:
|
||||
result = await flow_client.poll(poll_endpoint, poll_token)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Login Flow v2 poll error for provision {provision_id}: {e}"
|
||||
"Login Flow v2 poll error for provision %s: %s", provision_id, e
|
||||
)
|
||||
await anyio.sleep(2)
|
||||
continue
|
||||
@@ -106,7 +106,8 @@ async def _poll_and_store(provision_id: str) -> None:
|
||||
if session:
|
||||
session["status"] = "error"
|
||||
logger.error(
|
||||
f"Login Flow v2 completed but no app_password (provision_id={provision_id})"
|
||||
"Login Flow v2 completed but no app_password (provision_id=%s)",
|
||||
provision_id,
|
||||
)
|
||||
return
|
||||
await storage.store_app_password_with_scopes(
|
||||
@@ -121,8 +122,9 @@ async def _poll_and_store(provision_id: str) -> None:
|
||||
session["status"] = "completed"
|
||||
session["username"] = result.login_name
|
||||
logger.info(
|
||||
f"Login Flow v2 web provision completed for user {effective_user_id} "
|
||||
f"(provision_id={provision_id})"
|
||||
"Login Flow v2 web provision completed for user %s (provision_id=%s)",
|
||||
effective_user_id,
|
||||
provision_id,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -131,7 +133,7 @@ async def _poll_and_store(provision_id: str) -> None:
|
||||
if session:
|
||||
session["status"] = "expired"
|
||||
logger.warning(
|
||||
f"Login Flow v2 web provision expired (provision_id={provision_id})"
|
||||
"Login Flow v2 web provision expired (provision_id=%s)", provision_id
|
||||
)
|
||||
return
|
||||
|
||||
@@ -142,7 +144,7 @@ async def _poll_and_store(provision_id: str) -> None:
|
||||
if session:
|
||||
session["status"] = "expired"
|
||||
logger.warning(
|
||||
f"Login Flow v2 web provision timed out (provision_id={provision_id})"
|
||||
"Login Flow v2 web provision timed out (provision_id=%s)", provision_id
|
||||
)
|
||||
|
||||
|
||||
@@ -166,7 +168,7 @@ async def provision_page(
|
||||
try:
|
||||
user_id, _token_data = await validate_token_and_get_user(request)
|
||||
except (ValueError, KeyError, AttributeError) as e:
|
||||
logger.warning(f"Provision request rejected: {e}")
|
||||
logger.warning("Provision request rejected: %s", e)
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
||||
|
||||
_cleanup_expired_sessions()
|
||||
@@ -180,14 +182,14 @@ async def provision_page(
|
||||
)
|
||||
|
||||
if urlparse(redirect_uri).scheme == "http":
|
||||
logger.warning(f"Provision redirect_uri uses insecure HTTP: {redirect_uri}")
|
||||
logger.warning("Provision redirect_uri uses insecure HTTP: %s", redirect_uri)
|
||||
|
||||
# Check if user already has an app password — skip straight to redirect
|
||||
if user_id:
|
||||
storage = await get_shared_storage()
|
||||
existing = await storage.get_app_password_with_scopes(user_id)
|
||||
if existing:
|
||||
logger.info(f"User {user_id} already has app password, skipping provision")
|
||||
logger.info("User %s already has app password, skipping provision", user_id)
|
||||
return RedirectResponse(redirect_uri)
|
||||
|
||||
# Initiate Login Flow v2
|
||||
@@ -206,7 +208,7 @@ async def provision_page(
|
||||
)
|
||||
init_response = await flow_client.initiate()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initiate Login Flow v2 for web provision: {e}")
|
||||
logger.error("Failed to initiate Login Flow v2 for web provision: %s", e)
|
||||
return HTMLResponse(
|
||||
content=_render_error(
|
||||
"Failed to start login flow. Please try again later."
|
||||
@@ -241,8 +243,9 @@ async def provision_page(
|
||||
poll_tg.start_soon(_poll_and_store, provision_id)
|
||||
|
||||
logger.info(
|
||||
f"Login Flow v2 web provision initiated (provision_id={provision_id}, "
|
||||
f"user_id={user_id or 'unknown'}), redirecting to NC login"
|
||||
"Login Flow v2 web provision initiated (provision_id=%s, user_id=%s), redirecting to NC login",
|
||||
provision_id,
|
||||
user_id or "unknown",
|
||||
)
|
||||
|
||||
# Redirect to Nextcloud's Login Flow v2 login page.
|
||||
@@ -277,7 +280,7 @@ async def provision_status(request: Request) -> JSONResponse:
|
||||
try:
|
||||
_user_id, _token_data = await validate_token_and_get_user(request)
|
||||
except (ValueError, KeyError, AttributeError) as e:
|
||||
logger.warning(f"Provision status request rejected: {e}")
|
||||
logger.warning("Provision status request rejected: %s", e)
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
||||
|
||||
provision_id = request.query_params.get("id", "")
|
||||
|
||||
@@ -89,7 +89,7 @@ def require_provisioning(func: Callable) -> Callable:
|
||||
if not refresh_data:
|
||||
# User has not completed Flow 2 - provide helpful error
|
||||
logger.info(
|
||||
f"User {user_id} attempted to use Nextcloud tool without provisioning"
|
||||
"User %s attempted to use Nextcloud tool without provisioning", user_id
|
||||
)
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
@@ -104,7 +104,7 @@ def require_provisioning(func: Callable) -> Callable:
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"User {user_id} has provisioned access - proceeding with tool execution"
|
||||
"User %s has provisioned access - proceeding with tool execution", user_id
|
||||
)
|
||||
|
||||
# User has provisioned - allow access
|
||||
@@ -156,15 +156,14 @@ def require_provisioning_or_suggest(func: Callable) -> Callable:
|
||||
|
||||
if not refresh_data:
|
||||
logger.info(
|
||||
f"User {user_id} has not provisioned Nextcloud access. "
|
||||
"Some features may not work. Consider running "
|
||||
"'provision_nextcloud_access' tool."
|
||||
"User %s has not provisioned Nextcloud access. Some features may not work. Consider running 'provision_nextcloud_access' tool.",
|
||||
user_id,
|
||||
)
|
||||
else:
|
||||
logger.debug(f"User {user_id} has provisioned access")
|
||||
logger.debug("User %s has provisioned access", user_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check provisioning status: {e}")
|
||||
logger.debug("Could not check provisioning status: %s", e)
|
||||
|
||||
# Always proceed with the function
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
@@ -119,7 +119,7 @@ def require_scopes(*required_scopes: str):
|
||||
# No context parameter found - likely BasicAuth mode
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
logger.debug(
|
||||
f"No context parameter for {func_name} - allowing (BasicAuth mode)"
|
||||
"No context parameter for %s - allowing (BasicAuth mode)", func_name
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
@@ -131,7 +131,7 @@ def require_scopes(*required_scopes: str):
|
||||
if access_token is None:
|
||||
# No OAuth token — BasicAuth mode bypasses scope checks
|
||||
logger.debug(
|
||||
f"No access token for {func_name} - allowing (BasicAuth mode)"
|
||||
"No access token for %s - allowing (BasicAuth mode)", func_name
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
@@ -210,7 +210,8 @@ def require_scopes(*required_scopes: str):
|
||||
if stored_scopes == "all":
|
||||
# NULL scopes in DB = legacy app password = all allowed
|
||||
logger.debug(
|
||||
f"Stored app password scope check passed for {func_name}: all scopes"
|
||||
"Stored app password scope check passed for %s: all scopes",
|
||||
func_name,
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
@@ -227,7 +228,7 @@ def require_scopes(*required_scopes: str):
|
||||
raise InsufficientScopeError(list(missing), error_msg)
|
||||
|
||||
logger.debug(
|
||||
f"Stored app password scope check passed for {func_name}"
|
||||
"Stored app password scope check passed for %s", func_name
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
@@ -303,7 +304,7 @@ def require_scopes(*required_scopes: str):
|
||||
|
||||
# All required scopes present - allow execution
|
||||
logger.debug(
|
||||
f"Scope authorization passed for {func_name}: {required_scopes}"
|
||||
"Scope authorization passed for %s: %s", func_name, required_scopes
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
@@ -366,7 +367,7 @@ def get_access_token_scopes(ctx: Context | None = None) -> set[str]:
|
||||
|
||||
scopes = set(access_token.scopes or [])
|
||||
scopes = _strip_resource_prefix(scopes)
|
||||
logger.info(f"✅ Extracted scopes from access token: {scopes}")
|
||||
logger.info("✅ Extracted scopes from access token: %s", scopes)
|
||||
return scopes
|
||||
|
||||
|
||||
@@ -446,7 +447,7 @@ def is_jwt_token() -> bool:
|
||||
token_string = access_token.token
|
||||
is_jwt = "." in token_string and token_string.count(".") == 2
|
||||
|
||||
logger.debug(f"Token format check: is_jwt={is_jwt}")
|
||||
logger.debug("Token format check: is_jwt=%s", is_jwt)
|
||||
return is_jwt
|
||||
|
||||
|
||||
|
||||
@@ -184,12 +184,12 @@ class RefreshTokenStorage:
|
||||
|
||||
if has_schema:
|
||||
logger.info(
|
||||
f"Detected pre-Alembic database at {self.db_path}, "
|
||||
"stamping with initial revision"
|
||||
"Detected pre-Alembic database at %s, stamping with initial revision",
|
||||
self.db_path,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Initializing new database at {self.db_path} with migrations"
|
||||
"Initializing new database at %s with migrations", self.db_path
|
||||
)
|
||||
|
||||
# Run migrations in a worker thread using anyio.to_thread
|
||||
@@ -215,7 +215,7 @@ class RefreshTokenStorage:
|
||||
os.chmod(self.db_path, 0o600)
|
||||
|
||||
self._initialized = True
|
||||
logger.info(f"Initialized refresh token storage at {self.db_path}")
|
||||
logger.info("Initialized refresh token storage at %s", self.db_path)
|
||||
|
||||
async def store_refresh_token(
|
||||
self,
|
||||
@@ -333,7 +333,7 @@ class RefreshTokenStorage:
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.debug(f"Cached user profile for {user_id}")
|
||||
logger.debug("Cached user profile for %s", user_id)
|
||||
|
||||
async def get_user_profile(self, user_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
@@ -419,7 +419,7 @@ class RefreshTokenStorage:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.debug(f"No refresh token found for user {user_id}")
|
||||
logger.debug("No refresh token found for user %s", user_id)
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
return None
|
||||
@@ -437,7 +437,9 @@ class RefreshTokenStorage:
|
||||
# Check expiration
|
||||
if expires_at is not None and expires_at < time.time():
|
||||
logger.warning(
|
||||
f"Refresh token for user {user_id} has expired (expired at {expires_at})"
|
||||
"Refresh token for user %s has expired (expired at %s)",
|
||||
user_id,
|
||||
expires_at,
|
||||
)
|
||||
await self.delete_refresh_token(user_id)
|
||||
duration = time.time() - start_time
|
||||
@@ -448,7 +450,9 @@ class RefreshTokenStorage:
|
||||
scopes = json.loads(scopes_json) if scopes_json else None
|
||||
|
||||
logger.debug(
|
||||
f"Retrieved refresh token for user {user_id} (flow_type: {flow_type})"
|
||||
"Retrieved refresh token for user %s (flow_type: %s)",
|
||||
user_id,
|
||||
flow_type,
|
||||
)
|
||||
|
||||
duration = time.time() - start_time
|
||||
@@ -467,7 +471,7 @@ class RefreshTokenStorage:
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "error")
|
||||
logger.error(f"Failed to decrypt refresh token for user {user_id}: {e}")
|
||||
logger.error("Failed to decrypt refresh token for user %s: %s", user_id, e)
|
||||
return None
|
||||
|
||||
async def get_refresh_token_by_provisioning_client_id(
|
||||
@@ -511,7 +515,8 @@ class RefreshTokenStorage:
|
||||
|
||||
if not row:
|
||||
logger.debug(
|
||||
f"No refresh token found for provisioning_client_id {provisioning_client_id[:16]}..."
|
||||
"No refresh token found for provisioning_client_id %s...",
|
||||
provisioning_client_id[:16],
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -529,7 +534,8 @@ class RefreshTokenStorage:
|
||||
# Check expiration
|
||||
if expires_at is not None and expires_at < time.time():
|
||||
logger.warning(
|
||||
f"Refresh token for provisioning_client_id {provisioning_client_id[:16]}... has expired"
|
||||
"Refresh token for provisioning_client_id %s... has expired",
|
||||
provisioning_client_id[:16],
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -538,7 +544,9 @@ class RefreshTokenStorage:
|
||||
scopes = json.loads(scopes_json) if scopes_json else None
|
||||
|
||||
logger.debug(
|
||||
f"Retrieved refresh token for provisioning_client_id {provisioning_client_id[:16]}... (user_id: {user_id})"
|
||||
"Retrieved refresh token for provisioning_client_id %s... (user_id: %s)",
|
||||
provisioning_client_id[:16],
|
||||
user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -553,7 +561,9 @@ class RefreshTokenStorage:
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to decrypt refresh token for provisioning_client_id {provisioning_client_id[:16]}...: {e}"
|
||||
"Failed to decrypt refresh token for provisioning_client_id %s...: %s",
|
||||
provisioning_client_id[:16],
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -584,14 +594,14 @@ class RefreshTokenStorage:
|
||||
record_db_operation("sqlite", "delete", duration, "success")
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Deleted refresh token for user {user_id}")
|
||||
logger.info("Deleted refresh token for user %s", user_id)
|
||||
await self._audit_log(
|
||||
event="delete_refresh_token",
|
||||
user_id=user_id,
|
||||
auth_method="offline_access",
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No refresh token to delete for user {user_id}")
|
||||
logger.debug("No refresh token to delete for user %s", user_id)
|
||||
|
||||
return deleted
|
||||
except Exception:
|
||||
@@ -616,7 +626,7 @@ class RefreshTokenStorage:
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
user_ids = [row[0] for row in rows]
|
||||
logger.debug(f"Found {len(user_ids)} users with refresh tokens")
|
||||
logger.debug("Found %s users with refresh tokens", len(user_ids))
|
||||
return user_ids
|
||||
|
||||
async def cleanup_expired_tokens(self) -> int:
|
||||
@@ -640,7 +650,7 @@ class RefreshTokenStorage:
|
||||
deleted = cursor.rowcount
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"Cleaned up {deleted} expired refresh token(s)")
|
||||
logger.info("Cleaned up %s expired refresh token(s)", deleted)
|
||||
|
||||
return deleted
|
||||
|
||||
@@ -718,8 +728,9 @@ class RefreshTokenStorage:
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Stored OAuth client credentials (client_id: {client_id[:16]}..., "
|
||||
f"expires at {client_secret_expires_at})"
|
||||
"Stored OAuth client credentials (client_id: %s..., expires at %s)",
|
||||
client_id[:16],
|
||||
client_secret_expires_at,
|
||||
)
|
||||
|
||||
# Audit log
|
||||
@@ -785,7 +796,7 @@ class RefreshTokenStorage:
|
||||
# Check expiration
|
||||
if expires_at < time.time():
|
||||
logger.warning(
|
||||
f"OAuth client has expired (expired at {expires_at}), deleting"
|
||||
"OAuth client has expired (expired at %s), deleting", expires_at
|
||||
)
|
||||
await self.delete_oauth_client()
|
||||
return None
|
||||
@@ -803,7 +814,7 @@ class RefreshTokenStorage:
|
||||
redirect_uris = json.loads(redirect_uris_json)
|
||||
|
||||
logger.debug(
|
||||
f"Retrieved OAuth client credentials (client_id: {client_id[:16]}...)"
|
||||
"Retrieved OAuth client credentials (client_id: %s...)", client_id[:16]
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -817,7 +828,7 @@ class RefreshTokenStorage:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt OAuth client credentials: {e}")
|
||||
logger.error("Failed to decrypt OAuth client credentials: %s", e)
|
||||
return None
|
||||
|
||||
async def delete_oauth_client(self) -> bool:
|
||||
@@ -1017,7 +1028,9 @@ class RefreshTokenStorage:
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.debug(f"Stored OAuth session {session_id} (expires in {ttl_seconds}s)")
|
||||
logger.debug(
|
||||
"Stored OAuth session %s (expires in %ss)", session_id, ttl_seconds
|
||||
)
|
||||
|
||||
async def get_oauth_session(self, session_id: str) -> dict | None:
|
||||
"""
|
||||
@@ -1043,7 +1056,7 @@ class RefreshTokenStorage:
|
||||
|
||||
# Check expiration
|
||||
if session["expires_at"] < time.time():
|
||||
logger.debug(f"OAuth session {session_id} has expired")
|
||||
logger.debug("OAuth session %s has expired", session_id)
|
||||
await self.delete_oauth_session(session_id)
|
||||
return None
|
||||
|
||||
@@ -1077,7 +1090,8 @@ class RefreshTokenStorage:
|
||||
# Check expiration
|
||||
if session["expires_at"] < time.time():
|
||||
logger.debug(
|
||||
f"OAuth session with MCP code {mcp_authorization_code[:16]}... has expired"
|
||||
"OAuth session with MCP code %s... has expired",
|
||||
mcp_authorization_code[:16],
|
||||
)
|
||||
await self.delete_oauth_session(session["session_id"])
|
||||
return None
|
||||
@@ -1133,7 +1147,7 @@ class RefreshTokenStorage:
|
||||
updated = cursor.rowcount > 0
|
||||
|
||||
if updated:
|
||||
logger.debug(f"Updated OAuth session {session_id}")
|
||||
logger.debug("Updated OAuth session %s", session_id)
|
||||
|
||||
return updated
|
||||
|
||||
@@ -1155,7 +1169,7 @@ class RefreshTokenStorage:
|
||||
deleted = cursor.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
logger.debug(f"Deleted OAuth session {session_id}")
|
||||
logger.debug("Deleted OAuth session %s", session_id)
|
||||
|
||||
return deleted
|
||||
|
||||
@@ -1179,7 +1193,7 @@ class RefreshTokenStorage:
|
||||
deleted = cursor.rowcount
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"Cleaned up {deleted} expired OAuth session(s)")
|
||||
logger.info("Cleaned up %s expired OAuth session(s)", deleted)
|
||||
|
||||
return deleted
|
||||
|
||||
@@ -1339,7 +1353,7 @@ class RefreshTokenStorage:
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.debug(f"Stored webhook {webhook_id} for preset '{preset_id}'")
|
||||
logger.debug("Stored webhook %s for preset '%s'", webhook_id, preset_id)
|
||||
|
||||
async def get_webhooks_by_preset(self, preset_id: str) -> list[int]:
|
||||
"""
|
||||
@@ -1384,7 +1398,7 @@ class RefreshTokenStorage:
|
||||
deleted = cursor.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
logger.debug(f"Deleted webhook {webhook_id} from tracking")
|
||||
logger.debug("Deleted webhook %s from tracking", webhook_id)
|
||||
|
||||
return deleted
|
||||
|
||||
@@ -1430,7 +1444,7 @@ class RefreshTokenStorage:
|
||||
deleted = cursor.rowcount
|
||||
|
||||
if deleted > 0:
|
||||
logger.debug(f"Cleared {deleted} webhook(s) for preset '{preset_id}'")
|
||||
logger.debug("Cleared %s webhook(s) for preset '%s'", deleted, preset_id)
|
||||
|
||||
return deleted
|
||||
|
||||
@@ -1482,7 +1496,7 @@ class RefreshTokenStorage:
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "success")
|
||||
logger.info(f"Stored app password for user {user_id}")
|
||||
logger.info("Stored app password for user %s", user_id)
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
@@ -1525,7 +1539,7 @@ class RefreshTokenStorage:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.debug(f"No app password found for user {user_id}")
|
||||
logger.debug("No app password found for user %s", user_id)
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
return None
|
||||
@@ -1535,14 +1549,14 @@ class RefreshTokenStorage:
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
logger.debug(f"Retrieved app password for user {user_id}")
|
||||
logger.debug("Retrieved app password for user %s", user_id)
|
||||
|
||||
return decrypted_password
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "error")
|
||||
logger.error(f"Failed to decrypt app password for user {user_id}: {e}")
|
||||
logger.error("Failed to decrypt app password for user %s: %s", user_id, e)
|
||||
return None
|
||||
|
||||
async def delete_app_password(self, user_id: str) -> bool:
|
||||
@@ -1572,14 +1586,14 @@ class RefreshTokenStorage:
|
||||
record_db_operation("sqlite", "delete", duration, "success")
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Deleted app password for user {user_id}")
|
||||
logger.info("Deleted app password for user %s", user_id)
|
||||
await self._audit_log(
|
||||
event="delete_app_password",
|
||||
user_id=user_id,
|
||||
auth_method="app_password",
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No app password to delete for user {user_id}")
|
||||
logger.debug("No app password to delete for user %s", user_id)
|
||||
|
||||
return deleted
|
||||
|
||||
@@ -1605,7 +1619,7 @@ class RefreshTokenStorage:
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
user_ids = [row[0] for row in rows]
|
||||
logger.debug(f"Found {len(user_ids)} users with app passwords")
|
||||
logger.debug("Found %s users with app passwords", len(user_ids))
|
||||
return user_ids
|
||||
|
||||
async def cleanup_invalid_app_passwords(self, nextcloud_host: str) -> list[str]:
|
||||
@@ -1651,19 +1665,21 @@ class RefreshTokenStorage:
|
||||
|
||||
if response.status_code in (401, 403):
|
||||
logger.info(
|
||||
f"App password for {user_id} is invalid "
|
||||
f"(HTTP {response.status_code}), removing"
|
||||
"App password for %s is invalid (HTTP %s), removing",
|
||||
user_id,
|
||||
response.status_code,
|
||||
)
|
||||
await self.delete_app_password(user_id)
|
||||
removed.append(user_id)
|
||||
else:
|
||||
logger.debug(
|
||||
f"App password for {user_id} validated "
|
||||
f"(HTTP {response.status_code})"
|
||||
"App password for %s validated (HTTP %s)",
|
||||
user_id,
|
||||
response.status_code,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not validate app password for {user_id}: {e}")
|
||||
logger.warning("Could not validate app password for %s: %s", user_id, e)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
for user_id in user_ids:
|
||||
@@ -1745,9 +1761,10 @@ class RefreshTokenStorage:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "success")
|
||||
logger.info(
|
||||
f"Stored scoped app password for user {user_id} "
|
||||
f"(scopes={'all' if scopes is None else len(scopes)}, "
|
||||
f"username={username or 'N/A'})"
|
||||
"Stored scoped app password for user %s (scopes=%s, username=%s)",
|
||||
user_id,
|
||||
"all" if scopes is None else len(scopes),
|
||||
username or "N/A",
|
||||
)
|
||||
|
||||
except Exception:
|
||||
@@ -1793,7 +1810,7 @@ class RefreshTokenStorage:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.debug(f"No app password found for user {user_id}")
|
||||
logger.debug("No app password found for user %s", user_id)
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
return None
|
||||
@@ -1917,7 +1934,7 @@ class RefreshTokenStorage:
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "success")
|
||||
logger.info(f"Stored login flow session for user {user_id}")
|
||||
logger.info("Stored login flow session for user %s", user_id)
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
@@ -1984,7 +2001,7 @@ class RefreshTokenStorage:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "error")
|
||||
logger.error(
|
||||
f"Failed to retrieve login flow session for user {user_id}: {e}"
|
||||
"Failed to retrieve login flow session for user %s: %s", user_id, e
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -2014,7 +2031,7 @@ class RefreshTokenStorage:
|
||||
record_db_operation("sqlite", "delete", duration, "success")
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Deleted login flow session for user {user_id}")
|
||||
logger.info("Deleted login flow session for user %s", user_id)
|
||||
await self._audit_log(
|
||||
event="delete_login_flow_session",
|
||||
user_id=user_id,
|
||||
@@ -2052,7 +2069,7 @@ class RefreshTokenStorage:
|
||||
record_db_operation("sqlite", "delete", duration, "success")
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"Cleaned up {count} expired login flow sessions")
|
||||
logger.info("Cleaned up %s expired login flow sessions", count)
|
||||
await self._audit_log(
|
||||
event="delete_expired_login_flow_sessions",
|
||||
user_id="system",
|
||||
|
||||
@@ -56,15 +56,15 @@ class TokenCache:
|
||||
# Check if token has expired
|
||||
if now >= expiry:
|
||||
del self._cache[user_id]
|
||||
logger.debug(f"Cached token expired for user {user_id}")
|
||||
logger.debug("Cached token expired for user %s", user_id)
|
||||
return None
|
||||
|
||||
# Check if token will expire soon (refresh early)
|
||||
if now >= expiry - self._early_refresh:
|
||||
logger.debug(f"Cached token expiring soon for user {user_id}")
|
||||
logger.debug("Cached token expiring soon for user %s", user_id)
|
||||
return None
|
||||
|
||||
logger.debug(f"Using cached token for user {user_id}")
|
||||
logger.debug("Using cached token for user %s", user_id)
|
||||
return token
|
||||
|
||||
async def set(self, user_id: str, token: str, expires_in: int | None = None):
|
||||
@@ -77,14 +77,14 @@ class TokenCache:
|
||||
expiry = datetime.now(timezone.utc) + self._ttl
|
||||
|
||||
self._cache[user_id] = (token, expiry)
|
||||
logger.debug(f"Cached token for user {user_id} until {expiry}")
|
||||
logger.debug("Cached token for user %s until %s", user_id, expiry)
|
||||
|
||||
async def invalidate(self, user_id: str):
|
||||
"""Remove token from cache."""
|
||||
async with self._lock:
|
||||
if user_id in self._cache:
|
||||
del self._cache[user_id]
|
||||
logger.debug(f"Invalidated cached token for user {user_id}")
|
||||
logger.debug("Invalidated cached token for user %s", user_id)
|
||||
|
||||
|
||||
class TokenBrokerService:
|
||||
@@ -214,7 +214,7 @@ class TokenBrokerService:
|
||||
# Get stored refresh token
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if not refresh_data:
|
||||
logger.info(f"No refresh token found for user {user_id}")
|
||||
logger.info("No refresh token found for user %s", user_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -230,7 +230,7 @@ class TokenBrokerService:
|
||||
return access_token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Nextcloud token for user {user_id}: {e}")
|
||||
logger.error("Failed to get Nextcloud token for user %s: %s", user_id, e)
|
||||
# Invalidate cache on error
|
||||
await self.cache.invalidate(user_id)
|
||||
return None
|
||||
@@ -273,19 +273,21 @@ class TokenBrokerService:
|
||||
cached_token = await self.cache.get(cache_key)
|
||||
if cached_token:
|
||||
logger.debug(
|
||||
f"Token found in cache after lock acquisition for user {user_id}"
|
||||
"Token found in cache after lock acquisition for user %s", user_id
|
||||
)
|
||||
return cached_token
|
||||
|
||||
# Check if another thread is currently refreshing
|
||||
if await self.cache.get(refresh_in_progress_key):
|
||||
logger.debug(f"Refresh in progress for user {user_id}, waiting briefly")
|
||||
logger.debug(
|
||||
"Refresh in progress for user %s, waiting briefly", user_id
|
||||
)
|
||||
await anyio.sleep(0.1) # Brief wait for in-progress refresh
|
||||
# Check cache one more time after wait
|
||||
cached_token = await self.cache.get(cache_key)
|
||||
if cached_token:
|
||||
logger.debug(
|
||||
f"Token refreshed by another thread for user {user_id}"
|
||||
"Token refreshed by another thread for user %s", user_id
|
||||
)
|
||||
return cached_token
|
||||
|
||||
@@ -296,7 +298,7 @@ class TokenBrokerService:
|
||||
# Get stored refresh token
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if not refresh_data:
|
||||
logger.info(f"No refresh token found for user {user_id}")
|
||||
logger.info("No refresh token found for user %s", user_id)
|
||||
return None
|
||||
|
||||
# storage.get_refresh_token() returns already-decrypted token
|
||||
@@ -312,14 +314,18 @@ class TokenBrokerService:
|
||||
await self.cache.set(cache_key, access_token, expires_in)
|
||||
|
||||
logger.info(
|
||||
f"Generated background token for user {user_id} with scopes: {required_scopes}"
|
||||
"Generated background token for user %s with scopes: %s",
|
||||
user_id,
|
||||
required_scopes,
|
||||
)
|
||||
|
||||
return access_token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get background token for user {user_id}: {e}",
|
||||
"Failed to get background token for user %s: %s",
|
||||
user_id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
await self.cache.invalidate(cache_key)
|
||||
@@ -375,7 +381,7 @@ class TokenBrokerService:
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"Token refresh failed: {response.status_code} - {response.text}"
|
||||
"Token refresh failed: %s - %s", response.status_code, response.text
|
||||
)
|
||||
raise Exception(f"Token refresh failed: {response.status_code}")
|
||||
|
||||
@@ -395,11 +401,11 @@ class TokenBrokerService:
|
||||
refresh_token=new_refresh_token,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
logger.info(f"Stored rotated refresh token for user {user_id}")
|
||||
logger.info("Stored rotated refresh token for user %s", user_id)
|
||||
|
||||
# Note: Nextcloud validates token audience on API calls - no need to pre-validate here
|
||||
|
||||
logger.info(f"Refreshed access token (expires in {expires_in}s)")
|
||||
logger.info("Refreshed access token (expires in %ss)", expires_in)
|
||||
return access_token, expires_in
|
||||
|
||||
async def _refresh_access_token_with_scopes(
|
||||
@@ -446,7 +452,9 @@ class TokenBrokerService:
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Token refresh request to {token_endpoint} with client_id={self.client_id[:16]}..."
|
||||
"Token refresh request to %s with client_id=%s...",
|
||||
token_endpoint,
|
||||
self.client_id[:16],
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
@@ -457,9 +465,11 @@ class TokenBrokerService:
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"Token refresh with scopes failed: {response.status_code} - {response.text}"
|
||||
"Token refresh with scopes failed: %s - %s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
logger.error(f" client_id used: {self.client_id[:16]}...")
|
||||
logger.error(" client_id used: %s...", self.client_id[:16])
|
||||
raise Exception(f"Token refresh failed: {response.status_code}")
|
||||
|
||||
token_data = response.json()
|
||||
@@ -479,12 +489,12 @@ class TokenBrokerService:
|
||||
refresh_token=new_refresh_token,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
logger.info(f"Stored rotated refresh token for user {user_id}")
|
||||
logger.info("Stored rotated refresh token for user %s", user_id)
|
||||
|
||||
# Note: Nextcloud validates token audience on API calls - no need to pre-validate here
|
||||
|
||||
logger.info(
|
||||
f"Refreshed access token with scopes {scopes} (expires in {expires_in}s)"
|
||||
"Refreshed access token with scopes %s (expires in %ss)", scopes, expires_in
|
||||
)
|
||||
return access_token, expires_in
|
||||
|
||||
@@ -503,7 +513,7 @@ class TokenBrokerService:
|
||||
"""
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if not refresh_data:
|
||||
logger.warning(f"No refresh token to rotate for user {user_id}")
|
||||
logger.warning("No refresh token to rotate for user %s", user_id)
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -538,7 +548,7 @@ class TokenBrokerService:
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Master token refresh failed: {response.status_code}")
|
||||
logger.error("Master token refresh failed: %s", response.status_code)
|
||||
return False
|
||||
|
||||
token_data = response.json()
|
||||
@@ -555,7 +565,7 @@ class TokenBrokerService:
|
||||
refresh_token=new_refresh_token,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
logger.info(f"Rotated master refresh token for user {user_id}")
|
||||
logger.info("Rotated master refresh token for user %s", user_id)
|
||||
|
||||
# Invalidate cached access token
|
||||
await self.cache.invalidate(user_id)
|
||||
@@ -564,7 +574,7 @@ class TokenBrokerService:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh master token for user {user_id}: {e}")
|
||||
logger.error("Failed to refresh master token for user %s: %s", user_id, e)
|
||||
return False
|
||||
|
||||
async def has_nextcloud_provisioning(self, user_id: str) -> bool:
|
||||
@@ -601,7 +611,7 @@ class TokenBrokerService:
|
||||
refresh_token = refresh_data["refresh_token"]
|
||||
await self._revoke_token_at_idp(refresh_token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to revoke at IdP: {e}")
|
||||
logger.warning("Failed to revoke at IdP: %s", e)
|
||||
|
||||
# Remove from storage
|
||||
await self.storage.delete_refresh_token(user_id)
|
||||
@@ -609,11 +619,11 @@ class TokenBrokerService:
|
||||
# Clear cache
|
||||
await self.cache.invalidate(user_id)
|
||||
|
||||
logger.info(f"Revoked Nextcloud access for user {user_id}")
|
||||
logger.info("Revoked Nextcloud access for user %s", user_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke access for user {user_id}: {e}")
|
||||
logger.error("Failed to revoke access for user %s: %s", user_id, e)
|
||||
return False
|
||||
|
||||
async def _revoke_token_at_idp(self, token: str):
|
||||
@@ -638,7 +648,7 @@ class TokenBrokerService:
|
||||
if response.status_code == 200:
|
||||
logger.info("Token revoked at IdP")
|
||||
else:
|
||||
logger.warning(f"Token revocation returned {response.status_code}")
|
||||
logger.warning("Token revocation returned %s", response.status_code)
|
||||
|
||||
async def close(self):
|
||||
"""Clean up resources."""
|
||||
|
||||
@@ -69,7 +69,7 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
# JWT verification support
|
||||
self.jwks_client: PyJWKClient | None = None
|
||||
if hasattr(settings, "jwks_uri") and settings.jwks_uri:
|
||||
logger.info(f"JWT verification enabled with JWKS URI: {settings.jwks_uri}")
|
||||
logger.info("JWT verification enabled with JWKS URI: %s", settings.jwks_uri)
|
||||
self.jwks_client = PyJWKClient(settings.jwks_uri, cache_keys=True)
|
||||
|
||||
# Introspection support (for opaque tokens)
|
||||
@@ -81,7 +81,7 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
and settings.oidc_client_secret
|
||||
):
|
||||
self.introspection_uri = settings.introspection_uri
|
||||
logger.info(f"Token introspection enabled: {self.introspection_uri}")
|
||||
logger.info("Token introspection enabled: %s", self.introspection_uri)
|
||||
|
||||
# Build list of valid issuers (internal + public may differ in Docker)
|
||||
# AS proxy obtains tokens via internal URL (e.g. http://app:80), while
|
||||
@@ -114,14 +114,16 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Management API allowlist: {sorted(self._allowed_mgmt_clients)}"
|
||||
"Management API allowlist: %s", sorted(self._allowed_mgmt_clients)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"UnifiedTokenVerifier initialized in {self.mode} mode. "
|
||||
f"MCP audience: {settings.oidc_client_id} or {settings.nextcloud_mcp_server_url}, "
|
||||
f"Nextcloud resource URI: {settings.nextcloud_resource_uri}, "
|
||||
f"Valid issuers: {self.valid_issuers}"
|
||||
"UnifiedTokenVerifier initialized in %s mode. MCP audience: %s or %s, Nextcloud resource URI: %s, Valid issuers: %s",
|
||||
self.mode,
|
||||
settings.oidc_client_id,
|
||||
settings.nextcloud_mcp_server_url,
|
||||
settings.nextcloud_resource_uri,
|
||||
self.valid_issuers,
|
||||
)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
@@ -281,9 +283,10 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
if not self._has_mcp_audience(payload):
|
||||
audiences = payload.get("aud", [])
|
||||
logger.error(
|
||||
f"Token rejected: Missing MCP audience. "
|
||||
f"Got {audiences}, need MCP ({self.settings.oidc_client_id} or "
|
||||
f"{self.settings.nextcloud_mcp_server_url})"
|
||||
"Token rejected: Missing MCP audience. Got %s, need MCP (%s or %s)",
|
||||
audiences,
|
||||
self.settings.oidc_client_id,
|
||||
self.settings.nextcloud_mcp_server_url,
|
||||
)
|
||||
# Record as invalid due to audience mismatch
|
||||
record_oauth_token_validation(validation_method, "invalid")
|
||||
@@ -303,7 +306,7 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
return self._create_access_token(token, payload)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification failed: {e}")
|
||||
logger.error("Token verification failed: %s", e)
|
||||
record_oauth_token_validation(validation_method, "error")
|
||||
return None
|
||||
|
||||
@@ -368,14 +371,15 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
|
||||
# Skip audience validation - any valid Nextcloud token is accepted
|
||||
logger.debug(
|
||||
f"Management API token validated (no audience check) for user: {payload.get('sub')}"
|
||||
"Management API token validated (no audience check) for user: %s",
|
||||
payload.get("sub"),
|
||||
)
|
||||
|
||||
# Cache and return the token
|
||||
return self._create_access_token_with_cache_key(token, payload, cache_key)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Management API token verification failed: {e}")
|
||||
logger.error("Management API token verification failed: %s", e)
|
||||
record_oauth_token_validation(validation_method, "error")
|
||||
return None
|
||||
|
||||
@@ -478,20 +482,20 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
f"expected one of: {self.valid_issuers}"
|
||||
)
|
||||
|
||||
logger.debug(f"JWT signature verified for user: {payload.get('sub')}")
|
||||
logger.debug("JWT signature verified for user: %s", payload.get("sub"))
|
||||
return payload
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.info("JWT token has expired")
|
||||
return None
|
||||
except jwt.InvalidIssuerError as e:
|
||||
logger.warning(f"JWT issuer validation failed: {e}")
|
||||
logger.warning("JWT issuer validation failed: %s", e)
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"JWT validation failed: {e}")
|
||||
logger.warning("JWT validation failed: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during JWT verification: {e}")
|
||||
logger.error("Unexpected error during JWT verification: %s", e)
|
||||
return None
|
||||
|
||||
async def _introspect_token(self, token: str) -> dict[str, Any] | None:
|
||||
@@ -528,20 +532,23 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"Token introspected successfully for user: {introspection_data.get('sub')}"
|
||||
"Token introspected successfully for user: %s",
|
||||
introspection_data.get("sub"),
|
||||
)
|
||||
return introspection_data
|
||||
|
||||
elif response.status_code in (400, 401, 403):
|
||||
logger.warning(
|
||||
f"Token introspection failed: HTTP {response.status_code}. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
"Token introspection failed: HTTP %s. Response: %s",
|
||||
response.status_code,
|
||||
response.text[:200] if response.text else "empty",
|
||||
)
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unexpected response from introspection: {response.status_code}. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
"Unexpected response from introspection: %s. Response: %s",
|
||||
response.status_code,
|
||||
response.text[:200] if response.text else "empty",
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -549,10 +556,10 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
logger.error("Timeout while introspecting token")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Network error while introspecting token: {e}")
|
||||
logger.error("Network error while introspecting token: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token introspection: {e}")
|
||||
logger.error("Unexpected error during token introspection: %s", e)
|
||||
return None
|
||||
|
||||
def _create_access_token(
|
||||
@@ -598,7 +605,9 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
scope_string = payload.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(
|
||||
f"Extracted scopes from token - scope claim: '{scope_string}' -> scopes list: {scopes}"
|
||||
"Extracted scopes from token - scope claim: '%s' -> scopes list: %s",
|
||||
scope_string,
|
||||
scopes,
|
||||
)
|
||||
|
||||
# Extract expiration
|
||||
|
||||
@@ -143,7 +143,7 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
indexed_count = count_result.count
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to query Qdrant for indexed count: {e}")
|
||||
logger.warning("Failed to query Qdrant for indexed count: %s", e)
|
||||
# Continue with indexed_count = 0
|
||||
|
||||
# Determine status
|
||||
@@ -156,7 +156,7 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting processing status: {e}")
|
||||
logger.error("Error getting processing status: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -243,11 +243,11 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
||||
try:
|
||||
await oauth_client.discover()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to discover IdP endpoints: {e}")
|
||||
logger.error("Failed to discover IdP endpoints: %s", e)
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"Using external IdP userinfo endpoint: {oauth_client.userinfo_endpoint}"
|
||||
"Using external IdP userinfo endpoint: %s", oauth_client.userinfo_endpoint
|
||||
)
|
||||
return oauth_client.userinfo_endpoint
|
||||
|
||||
@@ -269,7 +269,8 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
||||
|
||||
if userinfo_endpoint:
|
||||
logger.debug(
|
||||
f"Using Nextcloud userinfo endpoint from discovery: {userinfo_endpoint}"
|
||||
"Using Nextcloud userinfo endpoint from discovery: %s",
|
||||
userinfo_endpoint,
|
||||
)
|
||||
return userinfo_endpoint
|
||||
|
||||
@@ -277,7 +278,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to query discovery document for userinfo endpoint: {e}")
|
||||
logger.error("Failed to query discovery document for userinfo endpoint: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -302,7 +303,7 @@ async def _query_idp_userinfo(
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to query IdP userinfo endpoint: {e}")
|
||||
logger.warning("Failed to query IdP userinfo endpoint: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -379,9 +380,9 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
|
||||
# Include cached profile if available
|
||||
if profile_data:
|
||||
user_context["idp_profile"] = profile_data
|
||||
logger.debug(f"Loaded cached profile for {session_id[:16]}...")
|
||||
logger.debug("Loaded cached profile for %s...", session_id[:16])
|
||||
else:
|
||||
logger.warning(f"No cached profile found for {session_id[:16]}...")
|
||||
logger.warning("No cached profile found for %s...", session_id[:16])
|
||||
user_context["idp_profile_error"] = (
|
||||
"Profile not cached. Try logging out and back in."
|
||||
)
|
||||
@@ -389,8 +390,8 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
|
||||
return user_context
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving user info: {e}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
logger.error("Error retrieving user info: %s", e)
|
||||
logger.error("Traceback: %s", traceback.format_exc())
|
||||
return {
|
||||
"error": f"Failed to retrieve user info: {e}",
|
||||
"username": username,
|
||||
@@ -439,7 +440,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
is_admin = await is_nextcloud_admin(request, nc_client._client)
|
||||
await nc_client.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check admin status: {e}")
|
||||
logger.warning("Failed to check admin status: %s", e)
|
||||
# Default to not admin if check fails
|
||||
|
||||
# Check for error
|
||||
@@ -697,9 +698,9 @@ async def revoke_session(request: Request) -> HTMLResponse:
|
||||
|
||||
try:
|
||||
# Delete the refresh token
|
||||
logger.info(f"Revoking background access for session {session_id[:16]}...")
|
||||
logger.info("Revoking background access for session %s...", session_id[:16])
|
||||
await storage.delete_refresh_token(session_id)
|
||||
logger.info(f"✓ Background access revoked for session {session_id[:16]}...")
|
||||
logger.info("✓ Background access revoked for session %s...", session_id[:16])
|
||||
|
||||
# Redirect back to user page
|
||||
user_page_url = str(request.url_for("user_info_html"))
|
||||
@@ -718,7 +719,7 @@ async def revoke_session(request: Request) -> HTMLResponse:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke background access: {e}")
|
||||
logger.error("Failed to revoke background access: %s", e)
|
||||
template = _jinja_env.get_template("error.html")
|
||||
return HTMLResponse(
|
||||
content=template.render(
|
||||
|
||||
@@ -140,8 +140,13 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
doc_types = doc_types_param.split(",") if doc_types_param else None
|
||||
|
||||
logger.info(
|
||||
f"Viz search: user={username}, query='{query}', "
|
||||
f"algorithm={algorithm}, fusion={fusion}, limit={limit}, doc_types={doc_types}"
|
||||
"Viz search: user=%s, query='%s', algorithm=%s, fusion=%s, limit=%s, doc_types=%s",
|
||||
username,
|
||||
query,
|
||||
algorithm,
|
||||
fusion,
|
||||
limit,
|
||||
doc_types,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -230,8 +235,9 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
score_range = max_score - min_score if max_score > min_score else 1.0
|
||||
|
||||
logger.info(
|
||||
f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] "
|
||||
f"→ [0.0, 1.0]"
|
||||
"Normalizing scores for viz: original range [%s, %s] → [0.0, 1.0]",
|
||||
format(min_score, ".3f"),
|
||||
format(max_score, ".3f"),
|
||||
)
|
||||
|
||||
# Store original score and rescale to 0-1 for visualization
|
||||
@@ -338,7 +344,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
logger.info(f"Detected embedding dimension: {embedding_dim}")
|
||||
logger.info("Detected embedding dimension: %s", embedding_dim)
|
||||
|
||||
# Build chunk vectors array in search_results order (1:1 mapping)
|
||||
chunk_vectors = []
|
||||
@@ -349,7 +355,8 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
else:
|
||||
# Chunk not found in vectors (shouldn't happen)
|
||||
logger.warning(
|
||||
f"Chunk {chunk_key} not found in fetched vectors, using zero vector"
|
||||
"Chunk %s not found in fetched vectors, using zero vector",
|
||||
chunk_key,
|
||||
)
|
||||
# Use zero vector as fallback
|
||||
chunk_vectors.append(np.zeros(embedding_dim))
|
||||
@@ -361,14 +368,16 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
logger.info(
|
||||
f"Reusing query embedding from search algorithm "
|
||||
f"(dimension={len(query_embedding)})"
|
||||
"Reusing query embedding from search algorithm (dimension=%s)",
|
||||
len(query_embedding),
|
||||
)
|
||||
else:
|
||||
# Fallback: generate embedding if not available from search
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
||||
logger.info(
|
||||
"Generated query embedding (dimension=%s)", len(query_embedding)
|
||||
)
|
||||
query_embed_duration = time.perf_counter() - query_embed_start
|
||||
|
||||
# Combine query vector with chunk vectors for PCA
|
||||
@@ -387,16 +396,19 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
if zero_norm_mask.any():
|
||||
zero_indices = np.where(zero_norm_mask)[0]
|
||||
logger.warning(
|
||||
f"Found {zero_norm_mask.sum()} zero-norm vectors at indices {zero_indices.tolist()}. "
|
||||
"Replacing with small epsilon to avoid division by zero."
|
||||
"Found %s zero-norm vectors at indices %s. Replacing with small epsilon to avoid division by zero.",
|
||||
zero_norm_mask.sum(),
|
||||
zero_indices.tolist(),
|
||||
)
|
||||
# Replace zero norms with small epsilon to avoid NaN
|
||||
norms[zero_norm_mask] = 1e-10
|
||||
|
||||
all_vectors_normalized = all_vectors / norms
|
||||
logger.info(
|
||||
f"Normalized vectors: query_norm={norms[-1][0]:.3f}, "
|
||||
f"doc_norm_range=[{norms[:-1].min():.3f}, {norms[:-1].max():.3f}]"
|
||||
"Normalized vectors: query_norm=%s, doc_norm_range=[%s, %s]",
|
||||
format(norms[-1][0], ".3f"),
|
||||
format(norms[:-1].min(), ".3f"),
|
||||
format(norms[:-1].max(), ".3f"),
|
||||
)
|
||||
|
||||
# Apply PCA dimensionality reduction (768-dim → 3D) on normalized vectors
|
||||
@@ -428,8 +440,9 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
if nan_mask.any():
|
||||
nan_rows = np.where(nan_mask.any(axis=1))[0]
|
||||
logger.error(
|
||||
f"Found NaN values in PCA output at {len(nan_rows)} points: {nan_rows.tolist()[:10]}. "
|
||||
"Replacing NaN with 0.0 to prevent JSON serialization error."
|
||||
"Found NaN values in PCA output at %s points: %s. Replacing NaN with 0.0 to prevent JSON serialization error.",
|
||||
len(nan_rows),
|
||||
nan_rows.tolist()[:10],
|
||||
)
|
||||
# Replace NaN with 0 to allow JSON serialization
|
||||
coords_3d = np.nan_to_num(coords_3d, nan=0.0)
|
||||
@@ -442,13 +455,16 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
chunk_coords_3d = coords_3d[:-1] # All but last are chunks
|
||||
|
||||
logger.info(
|
||||
f"PCA explained variance: PC1={pca.explained_variance_ratio_[0]:.3f}, "
|
||||
f"PC2={pca.explained_variance_ratio_[1]:.3f}, "
|
||||
f"PC3={pca.explained_variance_ratio_[2]:.3f}"
|
||||
"PCA explained variance: PC1=%s, PC2=%s, PC3=%s",
|
||||
format(pca.explained_variance_ratio_[0], ".3f"),
|
||||
format(pca.explained_variance_ratio_[1], ".3f"),
|
||||
format(pca.explained_variance_ratio_[2], ".3f"),
|
||||
)
|
||||
logger.info(
|
||||
f"Embedding stats: chunks={len(chunk_vectors)}, "
|
||||
f"query_dim={len(query_embedding)}, chunk_vector_dim={chunk_vectors.shape[1] if chunk_vectors.size > 0 else 0}"
|
||||
"Embedding stats: chunks=%s, query_dim=%s, chunk_vector_dim=%s",
|
||||
len(chunk_vectors),
|
||||
len(query_embedding),
|
||||
chunk_vectors.shape[1] if chunk_vectors.size > 0 else 0,
|
||||
)
|
||||
|
||||
# Coordinates already match search_results order (1:1 mapping)
|
||||
@@ -479,12 +495,18 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
|
||||
# Log comprehensive timing metrics
|
||||
logger.info(
|
||||
f"Viz search timing: total={total_duration * 1000:.1f}ms, "
|
||||
f"search={search_duration * 1000:.1f}ms ({search_duration / total_duration * 100:.1f}%), "
|
||||
f"vector_fetch={vector_fetch_duration * 1000:.1f}ms ({vector_fetch_duration / total_duration * 100:.1f}%), "
|
||||
f"query_embed={query_embed_duration * 1000:.1f}ms ({query_embed_duration / total_duration * 100:.1f}%), "
|
||||
f"pca={pca_duration * 1000:.1f}ms ({pca_duration / total_duration * 100:.1f}%), "
|
||||
f"results={len(search_results)}, chunk_vectors={len(chunk_vectors)}"
|
||||
"Viz search timing: total=%sms, search=%sms (%s%%), vector_fetch=%sms (%s%%), query_embed=%sms (%s%%), pca=%sms (%s%%), results=%s, chunk_vectors=%s",
|
||||
format(total_duration * 1000, ".1f"),
|
||||
format(search_duration * 1000, ".1f"),
|
||||
format(search_duration / total_duration * 100, ".1f"),
|
||||
format(vector_fetch_duration * 1000, ".1f"),
|
||||
format(vector_fetch_duration / total_duration * 100, ".1f"),
|
||||
format(query_embed_duration * 1000, ".1f"),
|
||||
format(query_embed_duration / total_duration * 100, ".1f"),
|
||||
format(pca_duration * 1000, ".1f"),
|
||||
format(pca_duration / total_duration * 100, ".1f"),
|
||||
len(search_results),
|
||||
len(chunk_vectors),
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -511,7 +533,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Viz search error: {e}", exc_info=True)
|
||||
logger.error("Viz search error: %s", e, exc_info=True)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": str(e)},
|
||||
status_code=500,
|
||||
@@ -637,10 +659,12 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Fetched chunk context for {doc_type}_{doc_id}: "
|
||||
f"chunk_len={len(chunk_context.chunk_text)}, "
|
||||
f"before_len={len(chunk_context.before_context)}, "
|
||||
f"after_len={len(chunk_context.after_context)}"
|
||||
"Fetched chunk context for %s_%s: chunk_len=%s, before_len=%s, after_len=%s",
|
||||
doc_type,
|
||||
doc_id,
|
||||
len(chunk_context.chunk_text),
|
||||
len(chunk_context.before_context),
|
||||
len(chunk_context.after_context),
|
||||
)
|
||||
|
||||
# For PDF files, also fetch the chunk bbox from Qdrant so the client
|
||||
@@ -688,7 +712,7 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
status_code=400,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk context error: {e}", exc_info=True)
|
||||
logger.error("Chunk context error: %s", e, exc_info=True)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": str(e)},
|
||||
status_code=500,
|
||||
|
||||
Reference in New Issue
Block a user