From 6f2c93d95441cfbb945f3624fe13a052ba21266e Mon Sep 17 00:00:00 2001 From: netkas Date: Mon, 2 Jun 2025 18:29:41 -0400 Subject: [PATCH] Add logging functionality and request handling classes --- .../Configuration/LoggingConfiguration.php | 23 ++ src/FederationServer/Classes/Enums/Method.php | 65 ++++ src/FederationServer/Classes/Logger.php | 29 ++ .../Classes/RequestHandler.php | 239 +++++++++++++++ src/FederationServer/Classes/Validate.php | 12 + .../Exceptions/RequestException.php | 8 + .../Interfaces/RequestHandlerInterface.php | 14 + .../Methods/UploadAttachment.php | 290 ++++++++++++++++++ 8 files changed, 680 insertions(+) create mode 100644 src/FederationServer/Classes/Configuration/LoggingConfiguration.php create mode 100644 src/FederationServer/Classes/Enums/Method.php create mode 100644 src/FederationServer/Classes/Logger.php create mode 100644 src/FederationServer/Classes/RequestHandler.php create mode 100644 src/FederationServer/Classes/Validate.php create mode 100644 src/FederationServer/Exceptions/RequestException.php create mode 100644 src/FederationServer/Interfaces/RequestHandlerInterface.php create mode 100644 src/FederationServer/Methods/UploadAttachment.php diff --git a/src/FederationServer/Classes/Configuration/LoggingConfiguration.php b/src/FederationServer/Classes/Configuration/LoggingConfiguration.php new file mode 100644 index 0000000..608c86b --- /dev/null +++ b/src/FederationServer/Classes/Configuration/LoggingConfiguration.php @@ -0,0 +1,23 @@ +logUnauthorizedRequests = $config['log_unauthorized_requests'] ?? false; + } + + /** + * Check if unauthorized requests should be logged. + * + * @return bool True if unauthorized requests should be logged, false otherwise. + */ + public function shouldLogUnauthorizedRequests(): bool + { + return $this->logUnauthorizedRequests; + } + } \ No newline at end of file diff --git a/src/FederationServer/Classes/Enums/Method.php b/src/FederationServer/Classes/Enums/Method.php new file mode 100644 index 0000000..e98acdc --- /dev/null +++ b/src/FederationServer/Classes/Enums/Method.php @@ -0,0 +1,65 @@ + null, + preg_match('#^/attachment/([a-fA-F0-9\-]{36,})$#', $path) => Method::DOWNLOAD_ATTACHMENT, + ($requestMethod === 'POST' | $requestMethod === 'PUT') && $path === '/uploadAttachment' => Method::UPLOAD_ATTACHMENT, + $requestMethod === 'POST' && $path === '/createOperator' => Method::CREATE_OPERATOR, + $requestMethod === 'DELETE' && $path === '/deleteOperator' => Method::DELETE_OPERATOR, + default => null, + }; + + } + } diff --git a/src/FederationServer/Classes/Logger.php b/src/FederationServer/Classes/Logger.php new file mode 100644 index 0000000..6964ee1 --- /dev/null +++ b/src/FederationServer/Classes/Logger.php @@ -0,0 +1,29 @@ + true, + 'results' => $data, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + } + + /** + * Respond with an error message. + * + * @param string $message Error message to include in the response. + * @param int $code HTTP status code (default is 500). + * @return void + */ + protected static function errorResponse(string $message, int $code=500): void + { + http_response_code($code); + self::returnHeaders(); + print(json_encode([ + 'success' => false, + 'code' => $code, + 'message' => $message, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + } + + /** + * Handle a Throwable exception and return a JSON response. + * + * This method captures the exception details and returns a JSON response + * with the error code and message. + * + * @param Throwable $e The exception to handle. + * @return void + */ + protected static function throwableResponse(Throwable $e): void + { + http_response_code($e->getCode() ?: 500); + self::returnHeaders(); + print(json_encode([ + 'success' => false, + 'code' => $e->getCode() ?: 500, + 'message' => $e->getMessage(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + } + + /** + * Set the response headers for JSON output. + * + * This method sets the necessary headers for a JSON response, + * including content type and CORS headers. + * + * @return void + */ + protected static function returnHeaders(): void + { + header('Content-Type: application/json; charset=utf-8'); + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: POST, PUT, GET'); + header('Access-Control-Allow-Headers: Content-Type, Authorization'); + } + + /** + * Get the authenticated operator based on the API key provided in the request. + * + * This method retrieves the API key from the request headers or query parameters, + * validates it, and returns the corresponding OperatorRecord object if found and enabled. + * + * @return OperatorRecord Returns the authenticated OperatorRecord object or null if not found or disabled. + * @throws RequestException If the API key is missing, invalid, or the operator is disabled. + */ + protected static function getAuthenticatedOperator(): OperatorRecord + { + // First obtain the API key from the request headers or query parameters. + $apiKey = $_SERVER['HTTP_API_KEY'] ?? $_GET['api_key'] ?? null; + if (empty($apiKey)) + { + throw new RequestException('API key is required', 401); + } + + if(strlen($apiKey) > 32) + { + throw new RequestException('API key is too long', 400); + } + + try + { + $operator = OperatorManager::getOperatorByApiKey($apiKey); + + if($operator === null) + { + throw new RequestException('Invalid API key', 401); + } + } + catch (DatabaseOperationException $e) + { + throw new RequestException('Internal Database Error', 500, $e); + } + + if ($operator->isDisabled()) + { + throw new RequestException('Operator is disabled', 403); + } + + // If the operator is found and enabled, return the OperatorRecord object. + return $operator; + } + } + diff --git a/src/FederationServer/Classes/Validate.php b/src/FederationServer/Classes/Validate.php new file mode 100644 index 0000000..bc11a1d --- /dev/null +++ b/src/FederationServer/Classes/Validate.php @@ -0,0 +1,12 @@ +canManageBlacklist()) + { + throw new RequestException('Insufficient Permissions to upload attachments', 403); + } + + // Verify the file upload field exists + if(!isset($_FILES['file'])) + { + throw new RequestException('File upload is required', 400); + } + + // Ensure only a single file is uploaded + $file = $_FILES['file']; + if (!is_array($file) || !isset($file['tmp_name']) || empty($file['tmp_name']) || is_array($file['tmp_name'])) + { + throw new RequestException('Invalid file upload or multiple files detected', 400); + } + + // Validate the file size + if (!isset($file['size']) || $file['size'] <= 0) + { + throw new RequestException('Invalid file size'); + } + + $maxUploadSize = Configuration::getServerConfiguration()->getMaxUploadSize(); + if ($file['size'] > $maxUploadSize) + { + throw new RequestException("File exceeds maximum allowed size ({$maxUploadSize} bytes)", 401); + } + + // Validate file upload status + if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) + { + $errorMessage = self::getUploadErrorMessage($file['error'] ?? -1); + throw new RequestException($errorMessage); + } + + // Validate file exists and is readable + if (!is_file($file['tmp_name']) || !is_readable($file['tmp_name'])) + { + throw new RequestException('Uploaded file is not accessible'); + } + + $detectedMimeType = self::detectMimeType($file['tmp_name']); + $originalName = self::getSafeFileName($file['name'] ?? 'unnamed'); + + // Check for symlinks/hardlinks in tmp_name + if (is_link($file['tmp_name'])) + { + throw new RequestException('Invalid file upload (symbolic link detected)'); + } + + // Additional check for path traversal attempts + $realpath = realpath($file['tmp_name']); + if ($realpath === false || strpos($realpath, sys_get_temp_dir()) !== 0) + { + throw new RequestException('Path traversal attempt detected'); + } + + // Get file storage path and ensure the directory exists + $storagePath = rtrim(Configuration::getServerConfiguration()->getStoragePath(), DIRECTORY_SEPARATOR); + if (!is_dir($storagePath)) + { + if (!mkdir($storagePath, 0750, true)) + { + throw new RequestException('Storage directory could not be created'); + } + } + + // Verify storage directory permissions + if (!is_writable($storagePath)) + { + throw new RequestException('Storage directory is not writable'); + } + + // Limit number of files in storage directory (prevent DoS) + $fileCount = iterator_count(new FilesystemIterator($storagePath, FilesystemIterator::SKIP_DOTS)); + if ($fileCount >= self::MAX_FILES) + { + throw new RequestException('Storage limit reached'); + } + + // Generate a strong random UUID for the file + $uuid = Uuid::v4()->toRfc4122(); + + // Prepare destination path (UUID only, no extension as per requirements) + $destinationPath = $storagePath . DIRECTORY_SEPARATOR . $uuid; + + // Use atomic operations where possible + $tempDestination = $storagePath . DIRECTORY_SEPARATOR . uniqid('tmp_', true); + + if (!move_uploaded_file($file['tmp_name'], $tempDestination)) + { + throw new RequestException('Failed to move uploaded file'); + } + + try + { + // Set restrictive permissions before moving to final destination + chmod($tempDestination, 0640); + + // Move to final destination + if (!rename($tempDestination, $destinationPath)) + { + throw new RequestException('Failed to finalize file upload'); + } + + // Create a record in the database + FileAttachmentManager::createRecord($uuid, $evidenceUuid, $detectedMimeType, $originalName, $file['size']); + + // Log upload success + AuditLogManager::createEntry(AuditLogType::ATTACHMENT_UPLOADED, sprintf('Operator %s uploaded file %s (%s %s) Type %s | For Evidence %s', + $operator->getName(), $uuid, $originalName, $file['size'], $detectedMimeType, $evidenceUuid + ), $operator->getUuid(), $evidence->getEntity()); + + self::successResponse([ + 'uuid' => $uuid, + 'url' => Configuration::getServerConfiguration()->getBaseUrl() . '/attachment/' . $uuid + ]); + } + catch (DatabaseOperationException $e) + { + // If database insertion fails, remove the file to maintain consistency + @unlink($destinationPath); + + Logger::log()->error(sprintf('Failed to record file upload for evidence %s: %s', $evidenceUuid, $e->getMessage()), $e); + throw new RequestException('Failed to record file upload: ' . $e->getMessage()); + } + catch (Throwable $e) { + // Handle any other unexpected errors + @unlink($destinationPath); + Logger::log()->error(sprintf('Unexpected error during file upload for evidence %s: %s', $evidenceUuid, $e->getMessage())); + throw new RequestException('Unexpected error during file upload: ' . $e->getMessage()); + } + finally + { + // Clean up temporary files + if (file_exists($tempDestination)) + { + @unlink($tempDestination); + } + + if (file_exists($file['tmp_name'])) + { + @unlink($file['tmp_name']); + } + } + } + + /** + * Get human-readable error message for PHP upload error codes + * + * @param int $errorCode PHP upload error code + * @return string Human-readable error message + */ + private static function getUploadErrorMessage(int $errorCode): string + { + return match ($errorCode) + { + UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the maximum allowed size', + UPLOAD_ERR_PARTIAL => 'The file was only partially uploaded', + UPLOAD_ERR_NO_FILE => 'No file was uploaded', + UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder', + UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', + UPLOAD_ERR_EXTENSION => 'File upload stopped by extension', + default => 'Unknown upload error', + }; + } + + /** + * Safely detect the MIME type of a file + * + * @param string $filePath Path to the file + * @return string The detected MIME type + */ + private static function detectMimeType(string $filePath): string + { + // Using multiple methods for better reliability + + // First try with Fileinfo extension (most reliable) + if (function_exists('finfo_open')) + { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $filePath); + finfo_close($finfo); + if ($mimeType) + { + return $mimeType; + } + } + + // Then try with mime_content_type + if (function_exists('mime_content_type')) + { + $mimeType = mime_content_type($filePath); + if ($mimeType) + { + return $mimeType; + } + } + + // Fall back to a simple extension-based check as last resort + return 'application/octet-stream'; + } + + /** + * Get a safe filename by removing potentially unsafe characters + * + * @param string $filename Original filename + * @return string Sanitized filename + */ + private static function getSafeFileName(string $filename): string + { + // Remove any path information to avoid directory traversal + $filename = basename($filename); + + // Remove null bytes and other control characters + $filename = preg_replace('/[\x00-\x1F\x7F]/u', '', $filename); + + // Remove potentially dangerous characters + $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename); + + // Limit length to avoid extremely long filenames + if (strlen($filename) > 255) + { + $extension = pathinfo($filename, PATHINFO_EXTENSION); + $baseFilename = pathinfo($filename, PATHINFO_FILENAME); + $filename = substr($baseFilename, 0, 245) . '.' . $extension; + } + + return $filename; + } + } +