Add logging functionality and request handling classes
This commit is contained in:
parent
e9ce416bb7
commit
6f2c93d954
8 changed files with 680 additions and 0 deletions
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace FederationServer\Classes\Configuration;
|
||||||
|
|
||||||
|
class LoggingConfiguration
|
||||||
|
{
|
||||||
|
private bool $logUnauthorizedRequests;
|
||||||
|
|
||||||
|
public function __construct(array $config)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
65
src/FederationServer/Classes/Enums/Method.php
Normal file
65
src/FederationServer/Classes/Enums/Method.php
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace FederationServer\Classes\Enums;
|
||||||
|
|
||||||
|
use FederationServer\Exceptions\RequestException;
|
||||||
|
use FederationServer\Methods\CreateOperator;
|
||||||
|
use FederationServer\Methods\DeleteOperator;
|
||||||
|
use FederationServer\Methods\DownloadAttachment;
|
||||||
|
use FederationServer\Methods\UploadAttachment;
|
||||||
|
|
||||||
|
enum Method
|
||||||
|
{
|
||||||
|
case CREATE_OPERATOR;
|
||||||
|
case DELETE_OPERATOR;
|
||||||
|
|
||||||
|
case UPLOAD_ATTACHMENT;
|
||||||
|
case DOWNLOAD_ATTACHMENT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the request of the method
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws RequestException Thrown if there was an error while executing the request method
|
||||||
|
*/
|
||||||
|
public function handleRequest(): void
|
||||||
|
{
|
||||||
|
switch($this)
|
||||||
|
{
|
||||||
|
case self::CREATE_OPERATOR:
|
||||||
|
CreateOperator::handleRequest();
|
||||||
|
break;
|
||||||
|
case self::DELETE_OPERATOR:
|
||||||
|
DeleteOperator::handleRequest();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case self::UPLOAD_ATTACHMENT:
|
||||||
|
UploadAttachment::handleRequest();
|
||||||
|
break;
|
||||||
|
case self::DOWNLOAD_ATTACHMENT:
|
||||||
|
DownloadAttachment::handleRequest();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the given input with a matching available method, returns null if no available match was found
|
||||||
|
*
|
||||||
|
* @param string $requestMethod The request method that was used to make the request
|
||||||
|
* @param string $path The request path (Excluding the URI)
|
||||||
|
* @return Method|null The matching method or null if no match was found
|
||||||
|
*/
|
||||||
|
public static function matchHandle(string $requestMethod, string $path): ?Method
|
||||||
|
{
|
||||||
|
return match (true)
|
||||||
|
{
|
||||||
|
$requestMethod === 'POST' && $path === '/' => 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
29
src/FederationServer/Classes/Logger.php
Normal file
29
src/FederationServer/Classes/Logger.php
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace FederationServer\Classes;
|
||||||
|
|
||||||
|
class Logger
|
||||||
|
{
|
||||||
|
private static ?\LogLib2\Logger $logger = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the logger instance. If it does not exist, create it using the configuration.
|
||||||
|
*
|
||||||
|
* @return \LogLib2\Logger
|
||||||
|
*/
|
||||||
|
public static function log(): \LogLib2\Logger
|
||||||
|
{
|
||||||
|
if (self::$logger === null)
|
||||||
|
{
|
||||||
|
self::$logger = new \LogLib2\Logger('federation_server');
|
||||||
|
|
||||||
|
// Don't register handlers if we are testing. This conflicts with PHPUnit.
|
||||||
|
if(!defined('FS_TEST'))
|
||||||
|
{
|
||||||
|
\LogLib2\Logger::registerHandlers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$logger;
|
||||||
|
}
|
||||||
|
}
|
239
src/FederationServer/Classes/RequestHandler.php
Normal file
239
src/FederationServer/Classes/RequestHandler.php
Normal file
|
@ -0,0 +1,239 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace FederationServer\Classes;
|
||||||
|
|
||||||
|
use FederationServer\Classes\Managers\OperatorManager;
|
||||||
|
use FederationServer\Exceptions\DatabaseOperationException;
|
||||||
|
use FederationServer\Exceptions\RequestException;
|
||||||
|
use FederationServer\Interfaces\RequestHandlerInterface;
|
||||||
|
use FederationServer\Objects\OperatorRecord;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
abstract class RequestHandler implements RequestHandlerInterface
|
||||||
|
{
|
||||||
|
private static ?array $decodedContent = null;
|
||||||
|
private static ?string $inputContent = null;
|
||||||
|
private static ?string $requestMethod = null;
|
||||||
|
private static ?string $path = null;
|
||||||
|
private static ?string $uri = null;
|
||||||
|
private static ?array $parameters = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the incoming request.
|
||||||
|
*
|
||||||
|
* This method should be implemented by subclasses to handle specific requests.
|
||||||
|
* It is responsible for processing the request and returning a response.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws RequestException
|
||||||
|
*/
|
||||||
|
public static function handleRequest(): void
|
||||||
|
{
|
||||||
|
self::$requestMethod = $_SERVER['REQUEST_METHOD'] ?? '';
|
||||||
|
self::$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
self::$path = parse_url(self::$uri, PHP_URL_PATH) ?? '/';
|
||||||
|
self::$inputContent = file_get_contents('php://input') ?: '';
|
||||||
|
|
||||||
|
// Decode the input content if it's JSON
|
||||||
|
if (self::$inputContent && str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'application/json'))
|
||||||
|
{
|
||||||
|
self::$decodedContent = json_decode(self::$inputContent, true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE)
|
||||||
|
{
|
||||||
|
throw new RequestException('Invalid JSON input: ' . json_last_error_msg(), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse parameters from the URI, POST data, or query string
|
||||||
|
self::$parameters = [];
|
||||||
|
|
||||||
|
if (self::$uri)
|
||||||
|
{
|
||||||
|
$query = parse_url(self::$uri, PHP_URL_QUERY);
|
||||||
|
parse_str($query, self::$parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::$inputContent && str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'application/x-www-form-urlencoded'))
|
||||||
|
{
|
||||||
|
parse_str(self::$inputContent, $postParams);
|
||||||
|
self::$parameters = array_merge(self::$parameters, $postParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::$requestMethod === 'GET' && !empty($_GET))
|
||||||
|
{
|
||||||
|
self::$parameters = array_merge(self::$parameters, $_GET);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::$requestMethod === 'POST' && !empty($_POST))
|
||||||
|
{
|
||||||
|
self::$parameters = array_merge(self::$parameters, $_POST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the decoded JSON content from the request body, if available.
|
||||||
|
*
|
||||||
|
* @return array|null Decoded JSON content or null if not available.
|
||||||
|
*/
|
||||||
|
protected static function getDecodedContent(): ?array
|
||||||
|
{
|
||||||
|
return self::$decodedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the raw input content from the request body.
|
||||||
|
*
|
||||||
|
* @return string|null Raw input content or null if not available.
|
||||||
|
*/
|
||||||
|
protected static function getInputContent(): ?string
|
||||||
|
{
|
||||||
|
return self::$inputContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the HTTP request method (GET, POST, etc.).
|
||||||
|
*
|
||||||
|
* @return string|null HTTP request method or null if not available.
|
||||||
|
*/
|
||||||
|
protected static function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return self::$requestMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path component of the request URI.
|
||||||
|
*
|
||||||
|
* @return string|null Path or null if not available.
|
||||||
|
*/
|
||||||
|
protected static function getPath(): ?string
|
||||||
|
{
|
||||||
|
return self::$path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full request URI.
|
||||||
|
*
|
||||||
|
* @return string|null Request URI or null if not available.
|
||||||
|
*/
|
||||||
|
protected static function getUri(): ?string
|
||||||
|
{
|
||||||
|
return self::$uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respond with a success message and data.
|
||||||
|
*
|
||||||
|
* @param mixed $data Data to include in the response.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected static function successResponse(mixed $data=null): void
|
||||||
|
{
|
||||||
|
http_response_code(200);
|
||||||
|
self::returnHeaders();
|
||||||
|
print(json_encode([
|
||||||
|
'success' => 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
12
src/FederationServer/Classes/Validate.php
Normal file
12
src/FederationServer/Classes/Validate.php
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace FederationServer\Classes;
|
||||||
|
|
||||||
|
class Validate
|
||||||
|
{
|
||||||
|
public static function uuid(string $uuid): bool
|
||||||
|
{
|
||||||
|
// Validate UUID format using regex
|
||||||
|
return preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $uuid) === 1;
|
||||||
|
}
|
||||||
|
}
|
8
src/FederationServer/Exceptions/RequestException.php
Normal file
8
src/FederationServer/Exceptions/RequestException.php
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace FederationServer\Exceptions;
|
||||||
|
|
||||||
|
class RequestException extends \Exception
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
14
src/FederationServer/Interfaces/RequestHandlerInterface.php
Normal file
14
src/FederationServer/Interfaces/RequestHandlerInterface.php
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace FederationServer\Interfaces;
|
||||||
|
|
||||||
|
interface RequestHandlerInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the incoming request.
|
||||||
|
* This method should be implemented to process the request and return a response.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function handleRequest(): void;
|
||||||
|
}
|
290
src/FederationServer/Methods/UploadAttachment.php
Normal file
290
src/FederationServer/Methods/UploadAttachment.php
Normal file
|
@ -0,0 +1,290 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace FederationServer\Methods;
|
||||||
|
|
||||||
|
use FederationServer\Classes\Configuration;
|
||||||
|
use FederationServer\Classes\Enums\AuditLogType;
|
||||||
|
use FederationServer\Classes\Logger;
|
||||||
|
use FederationServer\Classes\Managers\AuditLogManager;
|
||||||
|
use FederationServer\Classes\Managers\EvidenceManager;
|
||||||
|
use FederationServer\Classes\Managers\FileAttachmentManager;
|
||||||
|
use FederationServer\Classes\RequestHandler;
|
||||||
|
use FederationServer\Classes\Validate;
|
||||||
|
use FederationServer\Exceptions\DatabaseOperationException;
|
||||||
|
use FederationServer\Exceptions\RequestException;
|
||||||
|
use FederationServer\FederationServer;
|
||||||
|
use FilesystemIterator;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class UploadAttachment extends RequestHandler
|
||||||
|
{
|
||||||
|
// Maximum number of files allowed in the storage directory
|
||||||
|
private const MAX_FILES = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
* @throws RequestException
|
||||||
|
*/
|
||||||
|
public static function handleRequest(): void
|
||||||
|
{
|
||||||
|
$evidenceUuid = FederationServer::getParameter('evidence');
|
||||||
|
if($evidenceUuid === null)
|
||||||
|
{
|
||||||
|
throw new RequestException('Evidence UUID is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate evidence UUID exists
|
||||||
|
if(!Validate::uuid($evidenceUuid) || !EvidenceManager::evidenceExists($evidenceUuid))
|
||||||
|
{
|
||||||
|
throw new RequestException('Invalid Evidence UUID', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$evidence = EvidenceManager::getEvidence($evidenceUuid);
|
||||||
|
if($evidence === null)
|
||||||
|
{
|
||||||
|
throw new RequestException('Evidence not found', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (DatabaseOperationException $e)
|
||||||
|
{
|
||||||
|
throw new RequestException('Evidence not found or database error', 404, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
$operator = FederationServer::getAuthenticatedOperator();
|
||||||
|
if(!$operator->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue