This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
@@ -0,0 +1,9 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.apps import AppConfig
class Middleware(AppConfig):
name = "plane.middleware"
@@ -0,0 +1,164 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Database routing middleware for read replica selection.
This middleware determines whether database queries should be routed to
read replicas or the primary database based on HTTP method and view configuration.
"""
import logging
from typing import Callable, Optional
from django.http import HttpRequest, HttpResponse
from plane.utils.core import (
set_use_read_replica,
clear_read_replica_context,
)
logger = logging.getLogger("plane.api")
class ReadReplicaRoutingMiddleware:
"""
Middleware for intelligent database routing to read replicas.
Routing Logic:
• Non-GET requests (POST, PUT, DELETE, PATCH) ➜ Primary database
• GET requests:
- View has use_read_replica=False ➜ Primary database
- View has use_read_replica=True ➜ Read replica
- View has no use_read_replica attribute ➜ Primary database (safe default)
The middleware supports both Django CBVs and DRF APIViews/ViewSets.
Context is properly isolated per request to prevent data leakage.
"""
# HTTP methods that are considered read-only by default
READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
def __init__(self, get_response):
"""
Initialize the middleware with the next middleware/view in the chain.
Args:
get_response: The next middleware or view function
"""
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
"""
Process the request and determine database routing.
Args:
request: The HTTP request object
Returns:
HttpResponse: The HTTP response from the view
"""
# For non-read operations, set primary database immediately
if request.method not in self.READ_ONLY_METHODS:
set_use_read_replica(False)
logger.debug(f"Routing {request.method} {request.path} to primary database")
try:
# Process the request through the middleware chain
response = self.get_response(request)
return response
finally:
# Always clean up context, even if an exception occurs
# This prevents context leakage between requests
clear_read_replica_context()
def process_view(
self,
request: HttpRequest,
view_func: Callable,
view_args: tuple,
view_kwargs: dict,
) -> None:
"""
Hook called just before Django calls the view.
This is more efficient than resolving URLs in __call__ since Django
provides the view function directly.
Args:
request: The HTTP request object
view_func: The view function to be called
view_args: Positional arguments for the view
view_kwargs: Keyword arguments for the view
"""
# Only process read operations (write operations already handled in __call__)
if request.method in self.READ_ONLY_METHODS:
use_replica = self._should_use_read_replica(view_func)
set_use_read_replica(use_replica)
db_type = "read replica" if use_replica else "primary database"
logger.debug(f"Routing {request.method} {request.path} to {db_type}")
# Return None to continue normal request processing
return None
def _should_use_read_replica(self, view_func: Callable) -> bool:
"""
Determine if the view should use read replica based on its configuration.
Args:
view_func: The view function to inspect
Returns:
bool: True if should use read replica, False for primary database
"""
use_replica_attr = self._get_use_replica_attribute(view_func)
# Default to primary database for GET requests if no explicit setting
# This ensures only views that explicitly opt-in use read replicas
if use_replica_attr is None:
return False
return bool(use_replica_attr)
def _get_use_replica_attribute(self, view_func: Callable) -> Optional[bool]:
"""
Extract the use_read_replica attribute from various view types.
Args:
view_func: The view function to inspect
Returns:
Optional[bool]: The use_read_replica setting, or None if not found
"""
# Return None if view_func is None to prevent AttributeError
if view_func is None:
return None
# Check function-based view attribute
use_replica = getattr(view_func, "use_read_replica", None)
if use_replica is not None:
return use_replica
# Check Django CBV wrapper
if hasattr(view_func, "view_class"):
use_replica = getattr(view_func.view_class, "use_read_replica", None)
if use_replica is not None:
return use_replica
# Check DRF wrapper (APIView / ViewSet)
if hasattr(view_func, "cls"):
use_replica = getattr(view_func.cls, "use_read_replica", None)
if use_replica is not None:
return use_replica
return None
def process_exception(self, request: HttpRequest, exception: Exception) -> None:
"""
Handle exceptions that occur during view processing.
This provides an additional safety net for context cleanup when views
raise exceptions, complementing the try/finally in __call__.
Args:
request: The HTTP request object
exception: The exception that was raised
Returns:
None: Don't handle the exception, just clean up context
"""
# Clean up context on exception as a safety measure
# The try/finally in __call__ should handle most cases, but this
# provides extra protection specifically for view exceptions
clear_read_replica_context()
logger.debug(f"Cleaned up read replica context due to exception: {type(exception).__name__}")
# Return None to let the exception continue propagating
return None
@@ -0,0 +1,154 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import logging
import time
# Django imports
from django.http import HttpRequest
from django.utils import timezone
# Third party imports
from rest_framework.request import Request
# Module imports
from plane.utils.ip_address import get_client_ip
from plane.utils.exception_logger import log_exception
from plane.bgtasks.logger_task import process_logs
api_logger = logging.getLogger("plane.api.request")
class RequestLoggerMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def _should_log_route(self, request: Request | HttpRequest) -> bool:
"""
Determines whether a route should be logged based on the request and status code.
"""
# Don't log health checks
if request.path == "/" and request.method == "GET":
return False
return True
def __call__(self, request):
# get the start time
start_time = time.time()
# Get the response
response = self.get_response(request)
# calculate the duration
duration = time.time() - start_time
# Check if logging is required
log_true = self._should_log_route(request=request)
# If logging is not required, return the response
if not log_true:
return response
user_id = (
request.user.id if getattr(request, "user") and getattr(request.user, "is_authenticated", False) else None
)
user_agent = request.META.get("HTTP_USER_AGENT", "")
# Log the request information
api_logger.info(
f"{request.method} {request.get_full_path()} {response.status_code}",
extra={
"path": request.path,
"method": request.method,
"status_code": response.status_code,
"duration_ms": int(duration * 1000),
"remote_addr": get_client_ip(request),
"user_agent": user_agent,
"user_id": user_id,
},
)
# return the response
return response
class APITokenLogMiddleware:
"""
Middleware to log External API requests to MongoDB or PostgreSQL.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request_body = request.body
response = self.get_response(request)
self.process_request(request, response, request_body)
return response
def _safe_decode_body(self, content):
"""
Safely decodes request/response body content, handling binary data.
Returns None if content is None, or a string representation of the content.
"""
# If the content is None, return None
if content is None:
return None
# If the content is an empty bytes object, return None
if content == b"":
return None
# Check if content is binary by looking for common binary file signatures
if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"):
return "[Binary Content]"
try:
return content.decode("utf-8")
except UnicodeDecodeError:
return "[Could not decode content]"
def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
# If the API key is not present, return
if not api_key:
return
try:
log_data = {
"token_identifier": api_key,
"path": request.path,
"method": request.method,
"query_params": request.META.get("QUERY_STRING", ""),
"headers": str(request.headers),
"body": self._safe_decode_body(request_body) if request_body else None,
"response_body": self._safe_decode_body(response.content) if response.content else None,
"response_code": response.status_code,
"ip_address": get_client_ip(request=request),
"user_agent": request.META.get("HTTP_USER_AGENT", None),
}
user_id = (
str(request.user.id)
if getattr(request, "user") and getattr(request.user, "is_authenticated", False)
else None
)
# Additional fields for MongoDB
mongo_log = {
**log_data,
"created_at": timezone.now(),
"updated_at": timezone.now(),
"created_by": user_id,
"updated_by": user_id,
}
process_logs.delay(log_data=log_data, mongo_log=mongo_log)
except Exception as e:
log_exception(e)
return None
@@ -0,0 +1,31 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.core.exceptions import RequestDataTooBig
from django.http import JsonResponse
class RequestBodySizeLimitMiddleware:
"""
Middleware to catch RequestDataTooBig exceptions and return
413 Request Entity Too Large instead of 400 Bad Request.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
try:
_ = request.body
except RequestDataTooBig:
return JsonResponse(
{
"error": "REQUEST_BODY_TOO_LARGE",
"detail": "The size of the request body exceeds the maximum allowed size.",
},
status=413,
)
# If body size is OK, continue with the request
return self.get_response(request)