diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index 745ba75..b14b173 100644 --- a/.php-cs-fixer.cache +++ b/.php-cs-fixer.cache @@ -1 +1 @@ -{"php":"8.5.6","version":"3.95.1","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":false,"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"clean_namespace":true,"no_unset_cast":true,"assign_null_coalescing_to_coalesce_equal":true,"normalize_index_brace":true,"heredoc_indentation":true,"no_whitespace_before_comma_in_array":{"after_heredoc":true},"trailing_comma_in_multiline":true,"list_syntax":true,"ternary_to_null_coalescing":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"single_quote":true},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"app\/src\/SystemCache.php":"4ead28637fa3a9281bdad42cdb9e00c2","app\/src\/AdminAuth.php":"14677d94cd04b7d455c43e74f7ec5d14","app\/tests\/Security\/SecurityTest.php":"ff9996affd0ca42095d34e0ac0650050","app\/tests\/Integration\/SearchTest.php":"29aba0f9da3c115fb5d2722576815361","app\/tests\/Unit\/DatabaseTest.php":"2dd249e28ac632ba222a8cead3ae16a1","app\/tests\/Unit\/RateLimitTest.php":"4fc2ffe64d2c889835ef4fcd2e89d835","app\/src\/Controllers\/FileAccessController.php":"9665edaa0ab1fd7c94b9a3d9fad95c5f","app\/src\/Controllers\/LiveReloadController.php":"e2ff21e7155e769b2684a51accf1699d","app\/src\/Parsedown.php":"d98c00dfbbb11933a86407ee9cf9215d","app\/src\/AppLogger.php":"139735566a1cc21d64eacc5b63de1d3c","app\/src\/DuplicateThesisException.php":"52abe5f40ef48cfbfd44c119d91309e9","app\/src\/RateLimit.php":"2e1df734570cb3eb584682bed33a2636","app\/src\/Audit.php":"6f795cbdf5d81b0ae337dd2df5084f75","app\/src\/Crypto.php":"e72e65eeaf5b6fa8e41ba4501643440b","app\/src\/FragmentRenderer.php":"52083e1f2ff98f01e074a93bf6765366","app\/src\/ErrorHandler.php":"56d3dc0af9ce6b5ef07291419073a6b1","app\/src\/EmailObfuscator.php":"ff946c10add222870223b9626990e75c","app\/src\/FilepondHandler.php":"7ba1547b7cb8daeecd69f32566d6a755","app\/src\/Controllers\/validate-file-fragment-shared.php":"d572b9564076e22b6a3f9baddf7bdb46","app\/tests\/run-tests.php":"860d3aa6d8f15ee90c80fef5e5d50c7c","app\/src\/Controllers\/HomeController.php":"8e9a29c622c8c37191ea2bdb40c5f3bf","app\/src\/Controllers\/ThesisCreateController.php":"02958bdd91e11e23e1597070a382012c","app\/src\/Dispatcher.php":"5d7ca0543b942857e5ef909aface040b","app\/src\/App.php":"cc568bc6c2453d35638ae64836c443da","app\/src\/AdminLogger.php":"b3e80ea6375d19f40e5da126ee8397b7","app\/src\/SmtpRelay.php":"c694b9561bb477ab4728a386ffc96955","app\/src\/ShareLink.php":"7fc43ea0264f7e2a8562abd0ef8c2ecd","app\/src\/StudentEmail.php":"06b51a435ff86462f7311f58dfe03c34","app\/src\/Controllers\/ExportController.php":"f33ac38ed4014c0236534c8529b92f1c","app\/src\/Controllers\/TfeController.php":"d080380d1612d8365d7987ca536c31c7","app\/src\/Controllers\/SystemController.php":"4e25f8cd33ff44d5a9a6c615b000d6cb","app\/src\/Controllers\/SearchController.php":"2daaca037c8df9353888f8093120e36e","app\/src\/Controllers\/MediaController.php":"4b1412723d5a77bb8f75ad825b4ef333","app\/src\/Controllers\/ThesisEditController.php":"ee75f8510c40add9365966187262e251","app\/src\/PeerTubeService.php":"02ba65d9ddc4ba2bda2d4a5d1fb6f459","app\/src\/Controllers\/ThesisFileHandler.php":"01af0cfcc89e048868351805b760c13b","app\/src\/Database.php":"37a008372ca85a45fa78651cd88975ec","app\/tests\/Unit\/ErrorHandlerTest.php":"a8f89fea425a0c0d358a865aeca8dd11","app\/tests\/Unit\/ShareLinkTest.php":"1c4eccb8d46f15a3b4f3938b52a70e60","app\/tests\/Unit\/FormSaveTest.php":"72fe083f6df01d588e4a097e2cb36cd6","app\/tests\/Unit\/PureLogicTest.php":"de6e0c5535c6ef83c4e566bd07320c9e"}} \ No newline at end of file +{"php":"8.5.6","version":"3.95.1","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":false,"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"clean_namespace":true,"no_unset_cast":true,"assign_null_coalescing_to_coalesce_equal":true,"normalize_index_brace":true,"heredoc_indentation":true,"no_whitespace_before_comma_in_array":{"after_heredoc":true},"trailing_comma_in_multiline":true,"list_syntax":true,"ternary_to_null_coalescing":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"single_quote":true},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"app\/src\/SystemCache.php":"4ead28637fa3a9281bdad42cdb9e00c2","app\/src\/AdminAuth.php":"14677d94cd04b7d455c43e74f7ec5d14","app\/tests\/Security\/SecurityTest.php":"ff9996affd0ca42095d34e0ac0650050","app\/tests\/Integration\/SearchTest.php":"29aba0f9da3c115fb5d2722576815361","app\/tests\/Unit\/DatabaseTest.php":"2dd249e28ac632ba222a8cead3ae16a1","app\/tests\/Unit\/RateLimitTest.php":"4fc2ffe64d2c889835ef4fcd2e89d835","app\/src\/Controllers\/FileAccessController.php":"9665edaa0ab1fd7c94b9a3d9fad95c5f","app\/src\/Controllers\/LiveReloadController.php":"e2ff21e7155e769b2684a51accf1699d","app\/src\/Parsedown.php":"d98c00dfbbb11933a86407ee9cf9215d","app\/src\/AppLogger.php":"139735566a1cc21d64eacc5b63de1d3c","app\/src\/DuplicateThesisException.php":"52abe5f40ef48cfbfd44c119d91309e9","app\/src\/RateLimit.php":"2e1df734570cb3eb584682bed33a2636","app\/src\/Audit.php":"6f795cbdf5d81b0ae337dd2df5084f75","app\/src\/Crypto.php":"e72e65eeaf5b6fa8e41ba4501643440b","app\/src\/FragmentRenderer.php":"52083e1f2ff98f01e074a93bf6765366","app\/src\/ErrorHandler.php":"56d3dc0af9ce6b5ef07291419073a6b1","app\/src\/EmailObfuscator.php":"ff946c10add222870223b9626990e75c","app\/src\/FilepondHandler.php":"7ba1547b7cb8daeecd69f32566d6a755","app\/src\/Controllers\/validate-file-fragment-shared.php":"d572b9564076e22b6a3f9baddf7bdb46","app\/tests\/run-tests.php":"860d3aa6d8f15ee90c80fef5e5d50c7c","app\/src\/Controllers\/HomeController.php":"8e9a29c622c8c37191ea2bdb40c5f3bf","app\/src\/Controllers\/ThesisCreateController.php":"02958bdd91e11e23e1597070a382012c","app\/src\/Dispatcher.php":"5d7ca0543b942857e5ef909aface040b","app\/src\/App.php":"cc568bc6c2453d35638ae64836c443da","app\/src\/AdminLogger.php":"b3e80ea6375d19f40e5da126ee8397b7","app\/src\/SmtpRelay.php":"c694b9561bb477ab4728a386ffc96955","app\/src\/ShareLink.php":"7fc43ea0264f7e2a8562abd0ef8c2ecd","app\/src\/StudentEmail.php":"06b51a435ff86462f7311f58dfe03c34","app\/src\/Controllers\/ExportController.php":"f33ac38ed4014c0236534c8529b92f1c","app\/src\/Controllers\/TfeController.php":"d080380d1612d8365d7987ca536c31c7","app\/src\/Controllers\/SystemController.php":"4e25f8cd33ff44d5a9a6c615b000d6cb","app\/src\/Controllers\/SearchController.php":"2daaca037c8df9353888f8093120e36e","app\/src\/Controllers\/MediaController.php":"4b1412723d5a77bb8f75ad825b4ef333","app\/src\/Controllers\/ThesisEditController.php":"ee75f8510c40add9365966187262e251","app\/src\/Controllers\/ThesisFileHandler.php":"01af0cfcc89e048868351805b760c13b","app\/src\/Database.php":"37a008372ca85a45fa78651cd88975ec","app\/tests\/Unit\/ErrorHandlerTest.php":"a8f89fea425a0c0d358a865aeca8dd11","app\/tests\/Unit\/ShareLinkTest.php":"1c4eccb8d46f15a3b4f3938b52a70e60","app\/tests\/Unit\/FormSaveTest.php":"72fe083f6df01d588e4a097e2cb36cd6","app\/tests\/Unit\/PureLogicTest.php":"de6e0c5535c6ef83c4e566bd07320c9e","app\/src\/Controllers\/LicenceController.php":"b0947402e3cdfeef49a4cca6f0e703c8","app\/src\/Controllers\/AboutController.php":"4c67bf2c4aa6d7d69b0f5168d13029de"}} \ No newline at end of file diff --git a/TODO.md b/TODO.md index 73c5261..47cbe99 100644 --- a/TODO.md +++ b/TODO.md @@ -8,7 +8,7 @@ - [x] Update phpstan.neon (scanDirectories replaces manual Parsedown scan) - [x] Write docs/system-setup.md (PHP extension requirements) - [x] Phase 1: Replace Parsedown with league/commonmark (4 call sites) -- [ ] Phase 2: Replace PeerTubeService HTTP client with Guzzle +- [x] Phase 2: Replace PeerTubeService HTTP client with Guzzle - [ ] Phase 3: Replace SmtpRelay SMTP socket with PHPMailer - [ ] Phase 4 (optional): Replace Crypto with defuse/php-encryption diff --git a/app/src/PeerTubeService.php b/app/src/PeerTubeService.php index 8a77a26..bb3024b 100644 --- a/app/src/PeerTubeService.php +++ b/app/src/PeerTubeService.php @@ -1,5 +1,8 @@ In-memory channel name → ID cache. */ private static array $channelCache = []; + /** @var Client|null Shared Guzzle client (lazy-init). */ + private static ?Client $httpClient = null; + // ------------------------------------------------------------------------- // DB CRUD // ------------------------------------------------------------------------- @@ -179,26 +185,27 @@ class PeerTubeService $token = self::obtainToken($s); $baseUrl = $s['instance_url']; - $mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($filePath); // ── Simple multipart upload (non-resumable) ── $uploadUrl = $baseUrl . '/api/v1/videos/upload'; - $postFields = [ - 'channelId' => $channelId, - 'name' => $title, - 'privacy' => (int)$s['privacy'], - 'commentsEnabled' => true, - 'category' => 15, - 'videofile' => new \CURLFile($filePath, $mimeType, $originalName), + $multipart = [ + ['name' => 'channelId', 'contents' => $channelId], + ['name' => 'name', 'contents' => $title], + ['name' => 'privacy', 'contents' => (int)$s['privacy']], + ['name' => 'commentsEnabled', 'contents' => 'true'], + ['name' => 'category', 'contents' => '15'], + ['name' => 'videofile', 'contents' => fopen($filePath, 'r'), 'filename' => $originalName], ]; if ($description !== '') { - $postFields['description'] = $description; + $multipart[] = ['name' => 'description', 'contents' => $description]; } - $resp = self::httpRequest($uploadUrl, 'POST', $postFields, [ - 'Authorization: Bearer ' . $token, - ], 600); + $resp = self::httpRequest($uploadUrl, 'POST', [ + 'headers' => ['Authorization' => 'Bearer ' . $token], + 'multipart' => $multipart, + 'timeout' => 600, + ]); if ($resp['status'] < 200 || $resp['status'] >= 300) { $errJson = json_decode($resp['body'], true); @@ -235,9 +242,10 @@ class PeerTubeService try { $token = self::obtainToken($s); $url = $s['instance_url'] . '/api/v1/videos/' . urlencode($uuid); - $resp = self::httpRequest($url, 'GET', '', [ - 'Authorization: Bearer ' . $token, - ], 10); + $resp = self::httpRequest($url, 'GET', [ + 'headers' => ['Authorization' => 'Bearer ' . $token], + 'timeout' => 10, + ]); if ($resp['status'] !== 200) { return null; } @@ -283,9 +291,10 @@ class PeerTubeService try { $token = self::obtainToken($s); $url = rtrim($s['instance_url'], '/') . '/api/v1/video-channels/' . urlencode($name); - $resp = self::httpRequest($url, 'GET', '', [ - 'Authorization: Bearer ' . $token, - ], 10); + $resp = self::httpRequest($url, 'GET', [ + 'headers' => ['Authorization' => 'Bearer ' . $token], + 'timeout' => 10, + ]); if ($resp['status'] !== 200) { return null; @@ -319,7 +328,7 @@ class PeerTubeService } $url = rtrim($instanceUrl, '/') . '/api/v1/oauth-clients/local'; - $response = self::httpRequest($url, 'GET', '', [], 10); + $response = self::httpRequest($url, 'GET', ['timeout' => 10]); $json = json_decode($response['body'], true); if ($response['status'] !== 200 || empty($json['client_id'])) { @@ -352,9 +361,9 @@ class PeerTubeService 'password' => $s['password'], ]); - $response = self::httpRequest($tokenUrl, 'POST', $body, [ - 'Content-Type: application/x-www-form-urlencoded', - 'Content-Length: ' . strlen($body), + $response = self::httpRequest($tokenUrl, 'POST', [ + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], + 'body' => $body, ]); $json = json_decode($response['body'], true); @@ -370,71 +379,44 @@ class PeerTubeService // HTTP helper // ------------------------------------------------------------------------- - // ------------------------------------------------------------------------- - // HTTP helper - // ------------------------------------------------------------------------- + /** + * Shared Guzzle HTTP client (lazy-init with SSL verification, HTTP/2, 15s connect timeout). + */ + private static function client(): Client + { + if (self::$httpClient === null) { + self::$httpClient = new Client([ + 'http_errors' => false, + 'allow_redirects' => false, + 'connect_timeout' => 15, + 'version' => 2.0, // HTTP/2 + ]); + } + return self::$httpClient; + } /** - * Minimal cURL HTTP helper. + * Perform an HTTP request via Guzzle. * + * @param array $options Guzzle request options (headers, body, multipart, timeout, etc.) * @return array{status:int, body:string, headers:array} */ - public static function httpRequest( - string $url, - string $method, - string|array $body, - array $headers, - int $timeout = 300 - ): array { - if (!function_exists('curl_init')) { - throw new \RuntimeException('L\'extension PHP cURL est requise pour l\'intégration PeerTube.'); + public static function httpRequest(string $url, string $method, array $options = []): array + { + try { + $response = self::client()->request($method, $url, $options); + $headers = []; + foreach ($response->getHeaders() as $name => $values) { + $headers[strtolower($name)] = end($values); + } + return [ + 'status' => $response->getStatusCode(), + 'body' => (string)$response->getBody(), + 'headers' => $headers, + ]; + } catch (GuzzleException $e) { + throw new \RuntimeException('Erreur réseau PeerTube : ' . $e->getMessage(), 0, $e); } - - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => $timeout, - CURLOPT_CONNECTTIMEOUT => 15, - CURLOPT_FOLLOWLOCATION => false, // Must be false to capture Location header - CURLOPT_MAXREDIRS => 3, - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_SSL_VERIFYHOST => 2, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0, - CURLOPT_HEADERFUNCTION => function ($ch, $headerLine) use (&$responseHeaders) { - $len = strlen($headerLine); - $parts = explode(':', $headerLine, 2); - if (count($parts) === 2) { - $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); - } - return $len; - }, - ]); - - $responseHeaders = []; - - if ($method === 'POST') { - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } elseif ($method === 'PUT' || $method === 'PATCH') { - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } elseif ($method === 'DELETE') { - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - } elseif ($method === 'HEAD') { - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'HEAD'); - curl_setopt($ch, CURLOPT_NOBODY, true); - } - - $responseBody = curl_exec($ch); - $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - - if ($responseBody === false && $method !== 'HEAD') { - throw new \RuntimeException('Erreur réseau PeerTube : ' . $error); - } - - return ['status' => $status, 'body' => (string)$responseBody, 'headers' => $responseHeaders]; } // ------------------------------------------------------------------------- diff --git a/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json index fa361ff..6302053 100644 --- a/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json +++ b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json @@ -1 +1 @@ -[1779231711] \ No newline at end of file +[1779232047] \ No newline at end of file