forked from retoor/devplacepy
feat: replace inline IP resolution with centralized client_ip utility across routers and audit
Consolidate scattered `X-Real-IP` / `request.client.host` fallback logic into the single `client_ip()` helper from `utils`, reducing duplication in the rate limiter middleware, project file zip routes, tools shared module, and audit record builder. Also refactor three admin endpoints (`/ai-usage/data`, `/analytics`, `/users/{uid}/ai-usage`) to use the existing `require_admin()` guard instead of repeating manual auth checks, and remove now-unused `get_current_user`/`is_admin` imports from those modules.
This commit is contained in:
+2
-4
@@ -43,7 +43,7 @@ from devplacepy.cache import TTLCache
|
||||
from devplacepy.responses import respond, wants_json, json_error
|
||||
from devplacepy.schemas import LandingOut, ValidationErrorOut
|
||||
from fastapi.responses import JSONResponse
|
||||
from devplacepy.utils import get_current_user, time_ago, safe_next
|
||||
from devplacepy.utils import get_current_user, time_ago, safe_next, client_ip
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.routers import (
|
||||
auth,
|
||||
@@ -486,9 +486,7 @@ async def rate_limit_middleware(request: Request, call_next):
|
||||
settings = _hot_settings()
|
||||
limit = _worker_rate_limit(settings["rate_limit_per_minute"])
|
||||
window = settings["rate_limit_window_seconds"]
|
||||
ip = request.headers.get("X-Real-IP") or (
|
||||
request.client.host if request.client else "unknown"
|
||||
)
|
||||
ip = client_ip(request, default="unknown")
|
||||
now = time.time()
|
||||
window_start = now - window
|
||||
global _last_rate_sweep
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.utils import require_admin, get_current_user, is_admin
|
||||
from devplacepy.utils import require_admin
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import GatewayUsageOut
|
||||
@@ -46,11 +46,7 @@ async def admin_ai_usage(request: Request):
|
||||
|
||||
@router.get("/ai-usage/data")
|
||||
async def admin_ai_usage_json(request: Request, hours: int = 48, top_n: int = 10):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
||||
if not is_admin(user):
|
||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
||||
require_admin(request)
|
||||
service = service_manager.get_service("openai")
|
||||
pricing = pricing_from_cfg(service.get_config()) if service is not None else None
|
||||
return JSONResponse(build_analytics(hours, top_n=top_n, pricing=pricing))
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.database import get_platform_analytics
|
||||
from devplacepy.utils import require_admin, get_current_user, is_admin
|
||||
from devplacepy.utils import require_admin
|
||||
from devplacepy.responses import action_result
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,9 +19,5 @@ async def admin_index(request: Request):
|
||||
|
||||
@router.get("/analytics")
|
||||
async def admin_analytics(request: Request, top_n: int = 10):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
||||
if not is_admin(user):
|
||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
||||
require_admin(request)
|
||||
return JSONResponse(get_platform_analytics(top_n))
|
||||
|
||||
@@ -15,8 +15,6 @@ from devplacepy.utils import (
|
||||
require_admin,
|
||||
hash_password_async,
|
||||
clear_user_cache,
|
||||
get_current_user,
|
||||
is_admin,
|
||||
)
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.responses import respond, action_result
|
||||
@@ -56,11 +54,7 @@ def _deny_senior(request: Request, admin: dict, uid: str, target: dict, event_ke
|
||||
|
||||
@router.get("/users/{uid}/ai-usage")
|
||||
async def admin_user_ai_usage(request: Request, uid: str, hours: int = 24):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
||||
if not is_admin(user):
|
||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
||||
require_admin(request)
|
||||
service = service_manager.get_service("openai")
|
||||
pricing = pricing_from_cfg(service.get_config()) if service is not None else None
|
||||
usage = build_user_usage(uid, hours=hours, pricing=pricing)
|
||||
|
||||
@@ -22,7 +22,7 @@ from devplacepy.responses import respond, action_result, wants_json, json_error
|
||||
from devplacepy.schemas import ProjectFilesOut
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.utils import get_current_user, require_user, not_found, is_admin, track_action
|
||||
from devplacepy.utils import get_current_user, require_user, not_found, is_admin, track_action, client_ip
|
||||
from devplacepy import project_files
|
||||
from devplacepy.project_files import ProjectFileError
|
||||
from devplacepy.services.audit import record as audit
|
||||
@@ -125,7 +125,7 @@ async def project_files_zip(request: Request, project_slug: str, path: str = "")
|
||||
return json_error(404, "Path not found")
|
||||
name = subpath.rsplit("/", 1)[-1]
|
||||
owner_kind, owner_id = (
|
||||
("user", user["uid"]) if user else ("guest", request.client.host or "anon")
|
||||
("user", user["uid"]) if user else ("guest", client_ip(request, default="anon"))
|
||||
)
|
||||
uid = queue.enqueue(
|
||||
"zip",
|
||||
|
||||
@@ -41,6 +41,7 @@ from devplacepy.utils import (
|
||||
not_found,
|
||||
is_admin,
|
||||
track_action,
|
||||
client_ip,
|
||||
XP_PROJECT,
|
||||
)
|
||||
from devplacepy.seo import (
|
||||
@@ -269,7 +270,7 @@ async def zip_project(request: Request, project_slug: str):
|
||||
if not can_view_project(project, user):
|
||||
raise not_found("Project not found")
|
||||
owner_kind, owner_id = (
|
||||
("user", user["uid"]) if user else ("guest", request.client.host or "anon")
|
||||
("user", user["uid"]) if user else ("guest", client_ip(request, default="anon"))
|
||||
)
|
||||
busy = _active_job("zip", (owner_kind, owner_id))
|
||||
if busy:
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from devplacepy.utils import get_current_user
|
||||
from devplacepy.utils import get_current_user, client_ip
|
||||
|
||||
|
||||
def owner_for(request: Request) -> tuple[str, str]:
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
return "user", user["uid"]
|
||||
ip = request.headers.get("X-Real-IP") or (
|
||||
request.client.host if request.client else "anon"
|
||||
)
|
||||
return "guest", ip
|
||||
return "guest", client_ip(request, default="anon")
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.utils import strip_html, generate_uid
|
||||
from devplacepy.utils import strip_html, generate_uid, client_ip
|
||||
from devplacepy.services.audit import store
|
||||
from devplacepy.services.audit.categories import category_for
|
||||
from devplacepy.services.background import background
|
||||
@@ -131,8 +131,7 @@ def _request_fields(request) -> dict:
|
||||
fields["request_method"] = getattr(request, "method", None)
|
||||
url = getattr(request, "url", None)
|
||||
fields["request_path"] = url.path if url is not None else None
|
||||
client = getattr(request, "client", None)
|
||||
fields["actor_ip"] = client.host if client is not None else None
|
||||
fields["actor_ip"] = client_ip(request, default=None)
|
||||
headers = getattr(request, "headers", None)
|
||||
if headers is not None:
|
||||
fields["user_agent"] = headers.get("user-agent")
|
||||
|
||||
@@ -347,16 +347,19 @@ class Dispatcher:
|
||||
logger.info("Dispatch %s args=%s", name, list(arguments))
|
||||
try:
|
||||
if action.requires_auth and not self._client.authenticated:
|
||||
self._audit_denied(name, "authentication required", arguments)
|
||||
raise AuthRequiredError(
|
||||
"Not authenticated. Ask the user for credentials and call the login tool first.",
|
||||
tool=name,
|
||||
)
|
||||
if action.requires_admin and not self._is_admin:
|
||||
self._audit_denied(name, "administrator access required", arguments)
|
||||
raise AuthRequiredError(
|
||||
"This information is restricted to administrators.",
|
||||
tool=name,
|
||||
)
|
||||
if action.requires_primary_admin and not self._is_primary_admin:
|
||||
self._audit_denied(name, "primary administrator access required", arguments)
|
||||
raise AuthRequiredError(
|
||||
"This tool is restricted to the primary administrator.",
|
||||
tool=name,
|
||||
@@ -384,6 +387,22 @@ class Dispatcher:
|
||||
logger.exception("Dispatch %s crashed", name)
|
||||
return unexpected_result(exc)
|
||||
|
||||
def _audit_denied(self, name: str, reason: str, arguments: dict[str, Any]) -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
actor_kind = "user" if self._owner_kind == "user" else self._owner_kind
|
||||
audit.record_system(
|
||||
"security.authz.denied",
|
||||
actor_kind=actor_kind,
|
||||
actor_uid=self._owner_id if self._owner_kind == "user" else None,
|
||||
actor_role="admin" if self._is_admin else (actor_kind if actor_kind != "user" else "member"),
|
||||
origin="devii",
|
||||
via_agent=1,
|
||||
result="denied",
|
||||
summary=f"Devii denied {name}: {reason}",
|
||||
metadata={"tool": name, "reason": reason, "args": _safe_args(arguments)},
|
||||
)
|
||||
|
||||
def _audit_mechanic(self, action: Action, arguments: dict[str, Any]) -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from urllib.parse import urlsplit
|
||||
from passlib.hash import pbkdf2_sha256
|
||||
from fastapi import Request, HTTPException, status
|
||||
@@ -279,6 +280,23 @@ def cookie_secure(request: Request) -> bool:
|
||||
return request.url.scheme == "https"
|
||||
|
||||
|
||||
def client_ip(request, default: Optional[str] = "unknown") -> Optional[str]:
|
||||
headers = getattr(request, "headers", None)
|
||||
if headers is not None:
|
||||
real = headers.get("x-real-ip")
|
||||
if real and real.strip():
|
||||
return real.strip()
|
||||
forwarded = headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
first = forwarded.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
client = getattr(request, "client", None)
|
||||
if client is not None:
|
||||
return client.host
|
||||
return default
|
||||
|
||||
|
||||
def require_user(request: Request):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
|
||||
Reference in New Issue
Block a user