From ae66c2baad711e8f502e70df69d2d35843beaf94 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Wed, 20 May 2026 02:16:17 +0200 Subject: [PATCH] Integrate Monolog: replace four logging systems with single PSR-3 factory - Add monolog/monolog dependency (^3.10) - Create app/Logger.php central factory with channels: app, admin, error, audit - Each channel gets RotatingFileHandler (30-day retention) with pass-through LineFormatter preserving existing JSON format contracts - Rewrite AppLogger as thin facade delegating to Logger::get('app') - Rewrite ErrorHandler::log() to delegate to Logger::get('error') - Rewrite AdminLogger file output to delegate to Logger::get('admin'), keep DB writes - Add Monolog file shadow to Audit via Logger::get('audit') (Option A per monolog-plan) - Log level controlled by LOG_LEVEL env var (defaults: DEBUG in cli-server, WARNING otherwise) - Graceful NullHandler fallback when log directory is not writable - Update SystemController LOG_FILES: remove php_error, add app/admin/error/audit - JSON app logs parsed to readable one-liners in the log viewer - Remove nginx config tab (parametres + fragment + template + css) - Friendly empty-state message when app log files don't exist yet (notYet) - PHP tail fallback when exec() unavailable - All 228 PHPUnit tests pass, no call sites changed --- .phpunit.result.cache | 2 +- TODO.md | 4 + app/public/admin/parametres.php | 35 +-- app/public/admin/system-fragment.php | 32 +-- app/public/assets/css/system.css | 27 +- app/src/AdminLogger.php | 19 +- app/src/AppLogger.php | 17 +- app/src/Audit.php | 29 ++- app/src/Controllers/SystemController.php | 231 +++++++++++++----- app/src/ErrorHandler.php | 32 +-- app/src/Logger.php | 105 ++++++++ app/templates/admin/parametres.php | 15 +- .../admin/partials/system-log-panel.php | 10 +- .../partials/system-nginx-config-panel.php | 40 --- app/templates/admin/system.php | 114 --------- composer.json | 5 +- composer.lock | 209 ++++++++++++---- docs/monolog-plan.md | 141 +++++++++++ tests/phpunit/SystemControllerHelpersTest.php | 28 +-- 19 files changed, 662 insertions(+), 433 deletions(-) create mode 100644 app/src/Logger.php delete mode 100644 app/templates/admin/partials/system-nginx-config-panel.php delete mode 100644 app/templates/admin/system.php create mode 100644 docs/monolog-plan.md diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 390efcf..01fbdde 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"CryptoTest::testEncryptDecryptEmptyString":7,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":7,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":7,"SystemControllerHelpersTest::testHumanBytesOneMB":7,"SystemControllerHelpersTest::testHumanBytesOneGB":7,"CryptoTest::testEncryptEmptyStringProducesCiphertext":7,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":7,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":7,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":7,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":7,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":7,"RateLimitExtendedTest::testGetRemainingDecrements":7,"RateLimitExtendedTest::testGetRemainingAtLimit":7,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":7,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":7,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":8},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0.026,"CryptoTest::testEncryptDecryptWithUnicode":0.002,"CryptoTest::testEncryptDecryptMultiline":0.002,"CryptoTest::testDifferentPlaintextsProduceDifferentCiphertexts":0.001,"CryptoTest::testSamePlaintextProducesDifferentCiphertexts":0.002,"CryptoTest::testIsEncryptedRecognizesEncryptedValue":0.002,"CryptoTest::testIsEncryptedRejectsPlaintext":0.002,"CryptoTest::testIsEncryptedReturnsFalseForEmptyString":0.001,"CryptoTest::testIsEncryptedRejectsInvalidBase64":0.001,"CryptoTest::testEncryptDecryptEmptyString":0.008,"CryptoTest::testDecryptEmptyStringReturnsEmpty":0.001,"CryptoTest::testDecryptInvalidBase64ReturnsInputGracefully":0.002,"CryptoTest::testDecryptTooShortBlobReturnsInputGracefully":0.001,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":0.002,"CryptoTest::testDecryptValidBlobTamperedTagReturnsEmpty":0.002,"EmailObfuscatorTest::testEncodeContainsNoAtSign":0.014,"EmailObfuscatorTest::testEncodeOutputIsNumericEntities":0.002,"EmailObfuscatorTest::testEmailReturnsObfuscatedAddress":0.002,"EmailObfuscatorTest::testMailtoBuildsCorrectHrefStructure":0.002,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":0.002,"EmailObfuscatorTest::testEmailTextReplacesMultipleEmails":0.002,"EmailObfuscatorTest::testMailtoInTextReplacesMailtoLinks":0.002,"EmailObfuscatorTest::testObfuscateHtmlReplacesAnchorTag":0.004,"EmailObfuscatorTest::testObfuscateHtmlKeepsNonMailtoLinksUnchanged":0.002,"EmailObfuscatorTest::testObfuscateHtmlPreservesCustomLinkText":0.002,"EmailObfuscatorTest::testEmptyStringReturnsEmpty":0.002,"EmailObfuscatorTest::testStringWithNoEmailsIsUnchanged":0.002,"EmailObfuscatorTest::testAlreadyObfuscatedContentIsNotDoubleEncoded":0.002,"EmailObfuscatorTest::testMultipleEmailsInOneString":0.002,"EmailObfuscatorTest::testEmailWithPlusSign":0.002,"StudentEmailTest::testBuildHtmlReturnsNonEmptyString":0.023,"StudentEmailTest::testBuildHtmlContainsKeyFields":0.003,"StudentEmailTest::testBuildHtmlEscapesSpecialCharacters":0.002,"StudentEmailTest::testBuildHtmlHandlesMissingOptionalFields":0.002,"StudentEmailTest::testBuildHtmlHandlesNullFieldsGracefully":0.002,"StudentEmailTest::testBuildHtmlHandlesEmptyArray":0.002,"StudentEmailTest::testBuildHtmlContainsLabelFields":0.002,"SystemControllerHelpersTest::testHumanBytesZero":0.096,"SystemControllerHelpersTest::testHumanBytesBelowOneKB":0.003,"SystemControllerHelpersTest::testHumanBytesOneKB":0.002,"SystemControllerHelpersTest::testHumanBytesOneMB":0.002,"SystemControllerHelpersTest::testHumanBytesOneGB":0.002,"SystemControllerHelpersTest::testHumanBytes1523MB":0.002,"SystemControllerHelpersTest::testHumanBytes2500GB":0.003,"SystemControllerHelpersTest::testDiskColorBelowWarning":0.002,"SystemControllerHelpersTest::testDiskColorWarning":0.002,"SystemControllerHelpersTest::testDiskColorCritical":0.002,"SystemControllerHelpersTest::testLogLineClassCrit":0.002,"SystemControllerHelpersTest::testLogLineClassError":0.002,"SystemControllerHelpersTest::testLogLineClassWarn":0.003,"SystemControllerHelpersTest::testLogLineClassNotice":0.002,"SystemControllerHelpersTest::testLogLineClassHttp500":0.002,"SystemControllerHelpersTest::testLogLineClassHttp300":0.002,"SystemControllerHelpersTest::testLogLineClassDefault":0.002,"SystemControllerHelpersTest::testNginxLineClassComment":0.002,"SystemControllerHelpersTest::testNginxLineClassBlock":0.003,"SystemControllerHelpersTest::testNginxLineClassDirective":0.002,"SystemControllerHelpersTest::testStatusLabelActive":0.002,"SystemControllerHelpersTest::testStatusLabelInactive":0.002,"SystemControllerHelpersTest::testStatusLabelFailed":0.002,"SystemControllerHelpersTest::testStatusLabelWarn":0.002,"SystemControllerHelpersTest::testStatusLabelUnknown":0.002,"SystemControllerHelpersTest::testStatusClassOk":0.002,"SystemControllerHelpersTest::testStatusClassWarn":0.002,"SystemControllerHelpersTest::testStatusClassError":0.002,"SystemControllerHelpersTest::testStatusClassUnknown":0.002,"TfeControllerOgTest::testBuildOgTagsReturnsAllRequiredKeys":0.003,"TfeControllerOgTest::testBuildOgTagsTitleIncludesAuthors":0.003,"TfeControllerOgTest::testBuildOgTagsImageEmptyWhenNoFiles":0.002,"TfeControllerOgTest::testBuildOgTagsImageFromCover":0.002,"TfeControllerOgTest::testBuildOgTagsImageFallbackToFirstImage":0.002,"TfeControllerOgTest::testBuildOgTagsUrlIncludesThesisId":0.002,"TfeControllerOgTest::testBuildOgTagsPublishedTimeFormatted":0.002,"TfeControllerOgTest::testBuildOgTagsPublishedTimeEmptyWhenNoYear":0.002,"TfeControllerOgTest::testBuildMetaDescriptionTruncatesLongSynopsis":0.002,"TfeControllerOgTest::testBuildMetaDescriptionKeepsShortSynopsis":0.002,"TfeControllerOgTest::testBuildMetaDescriptionEmptySynopsisReturnsDefault":0.002,"TfeControllerOgTest::testBuildMetaDescriptionStripsHtmlTags":0.003,"CryptoTest::testEncryptEmptyStringProducesCiphertext":0.002,"DatabaseExtendedTest::testEscapeLikeStringViaSearchConditions":0.002,"DatabaseExtendedTest::testBuildSearchConditionsEmptyParams":0.003,"DatabaseExtendedTest::testBuildSearchConditionsWithQuery":0.004,"DatabaseExtendedTest::testBuildSearchConditionsWithYear":0.004,"DatabaseExtendedTest::testBuildSearchConditionsWithAllFilters":0.003,"DatabaseExtendedTest::testFindDuplicateThesisExactMatch":0.004,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentTitle":0.002,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentYear":0.002,"DatabaseExtendedTest::testFindDuplicateThesisEmptyAuthorNamesReturnsNull":0.002,"DatabaseExtendedTest::testFindDuplicateThesisEmptyTable":0.002,"DatabaseExtendedTest::testFindDuplicateThesisNearDuplicateByLevenshtein":0.002,"DatabaseExtendedTest::testGenerateThesisIdentifierFirstInYear":0.002,"DatabaseExtendedTest::testGenerateThesisIdentifierIncrementsCorrectly":0.002,"DatabaseExtendedTest::testGenerateThesisIdentifierUsesMaxNotCount":0.002,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsPaths":0.002,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsEmptyForUnknownIds":0.002,"DatabaseExtendedTest::testGetCoverPathsForThesesEmptyInputReturnsEmpty":0.002,"DatabaseExtendedTest::testGetCoverPathsForThesesMultipleTheses":0.002,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":0.002,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":0.002,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":0.002,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":0.002,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":0.003,"DatabaseExtendedTest::testRenameLanguageUpdatesName":0.015,"DatabaseExtendedTest::testMergeLanguageReassignsTheses":0.003,"DatabaseExtendedTest::testRenameTagUpdatesName":0.002,"DatabaseExtendedTest::testMergeTagReassignsTheses":0.002,"RateLimitExtendedTest::testCheckKeyCountsPerKey":0.033,"RateLimitExtendedTest::testCheckKeyDoesNotAffectDefaultCheck":0.002,"RateLimitExtendedTest::testGetRemainingDecrements":0,"RateLimitExtendedTest::testGetRemainingAtLimit":0,"RateLimitExtendedTest::testGetRemainingUsesClientIdentifier":0.001,"RateLimitExtendedTest::testCheckUsesConsistentIdentifier":0,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":0.002,"RateLimitExtendedTest::testGetResetTimeReturnsZeroWhenNoData":0.002,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":0.002,"RateLimitExtendedTest::testCleanupRemovesOldFiles":0.002,"ShareLinkExtendedTest::testListActiveReturnsOnlyActiveLinks":0.34,"ShareLinkExtendedTest::testListArchivedReturnsOnlyArchivedLinks":0.291,"ShareLinkExtendedTest::testFindBySlugHit":0.293,"ShareLinkExtendedTest::testFindBySlugMiss":0.002,"ShareLinkExtendedTest::testSetPasswordAndDecryptRoundTrip":0.286,"ShareLinkExtendedTest::testGetDecryptedPasswordOnNonexistentId":0.002,"ShareLinkExtendedTest::testUpdateChangesNameAndExpiration":0.287,"ShareLinkExtendedTest::testUpdateOnlyNameLeavesExpirationUnchanged":0.312,"ShareLinkExtendedTest::testUpdateClearsExpiration":0.306,"ShareLinkExtendedTest::testCreateWithLockedYear":0.292,"ShareLinkExtendedTest::testCreateWithInvalidLockedYearRejected":0.292,"ShareLinkExtendedTest::testUpdateLockedYear":0.293,"ShareLinkExtendedTest::testUpdateClearLockedYear":0.324,"ShareLinkExtendedTest::testIncrementUsage":0.292,"ShareLinkExtendedTest::testCreateDefaultsToTfeWhenInvalidObjet":0.286,"ShareLinkExtendedTest::testCreateAcceptsValidObjet":0.29,"RateLimitExtendedTest::testGetRemainingStartsAtMax":0.002,"RateLimitExtendedTest::testCheckDecrementsRemainingForSameIp":0.002,"RateLimitExtendedTest::testCheckAndCheckKeyAreIndependent":0.002,"RateLimitExtendedTest::testMultipleChecksFromSameClient":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusTitle":1.01,"AutofocusFieldForErrorTest::testCreateAutofocusAuthors":0.003,"AutofocusFieldForErrorTest::testCreateAutofocusSynopsis":0.003,"AutofocusFieldForErrorTest::testCreateAutofocusYear":0.003,"AutofocusFieldForErrorTest::testCreateAutofocusOrientation":0.003,"AutofocusFieldForErrorTest::testCreateAutofocusAP":0.003,"AutofocusFieldForErrorTest::testCreateAutofocusFinality":0.003,"AutofocusFieldForErrorTest::testCreateAutofocusLanguages":0.003,"AutofocusFieldForErrorTest::testCreateAutofocusPromoteur":0.004,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurInterne":0.004,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurExterne":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusFormats":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusLicense":0.003,"AutofocusFieldForErrorTest::testCreateAutofocusUrl":0.003,"AutofocusFieldForErrorTest::testCreateAutofocusTags":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusUnknownErrorReturnsNull":0.003,"AutofocusFieldForErrorTest::testEditAutofocusTitle":0.139,"AutofocusFieldForErrorTest::testEditAutofocusYear":0.002,"AutofocusFieldForErrorTest::testEditAutofocusSynopsis":0.002,"AutofocusFieldForErrorTest::testEditAutofocusAuthors":0.002,"AutofocusFieldForErrorTest::testEditAutofocusUnknownErrorReturnsNull":0.002,"AutofocusFieldForErrorTest::testCreateDoesNotLeakEditFieldNames":0.003,"ThesisCreateValidationTest::testValidSubmissionReturnsCleanedData":0.002,"ThesisCreateValidationTest::testMissingTitleThrowsException":0.003,"ThesisCreateValidationTest::testMissingAuthorsThrowsException":0.002,"ThesisCreateValidationTest::testMissingSynopsisThrowsException":0.004,"ThesisCreateValidationTest::testMissingOrientationInNonAdminModeThrowsException":0.002,"ThesisCreateValidationTest::testMissingAPProgramInNonAdminModeThrowsException":0.002,"ThesisCreateValidationTest::testMissingFinalityInNonAdminModeThrowsException":0.003,"ThesisCreateValidationTest::testInvalidYearFormatRejected":0.003,"ThesisCreateValidationTest::testYearZeroRejected":0.002,"ThesisCreateValidationTest::testYearBefore2000Rejected":0.002,"ThesisCreateValidationTest::testFarFutureYearRejected":0.002,"ThesisCreateValidationTest::testCurrentYearAccepted":0.003,"ThesisCreateValidationTest::testMalformedUrlRejected":0.003,"ThesisCreateValidationTest::testValidUrlAccepted":0.002,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":0.002,"ThesisCreateValidationTest::testMaxTenKeywordsEnforced":0.004,"ThesisCreateValidationTest::testXssPayloadStrippedFromTitle":0.002,"ThesisCreateValidationTest::testHtmlInSynopsisStripped":0.002,"ThesisCreateValidationTest::testMultipleAuthorsAreSorted":0.002,"ThesisCreateValidationTest::testMissingPromoteurInNonAdminModeThrowsException":0.003,"ThesisCreateValidationTest::testMissingLecteurInterneInNonAdminModeThrowsException":0.002,"ThesisCreateValidationTest::testMissingLanguagesInNonAdminModeThrowsException":0.002,"ThesisCreateValidationTest::testMissingFormatsInNonAdminModeThrowsException":0.002,"ThesisCreateValidationTest::testMissingLicenseWithLibreAccessThrowsException":0.003,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":0.004,"ThesisEditValidationTest::testLoadThrowsOnUnknownId":0.003,"ThesisEditValidationTest::testLoadThrowsOnInvalidId":0.004,"ThesisEditValidationTest::testLoadThrowsOnNegativeId":0.003,"ThesisEditValidationTest::testCollectJuryMembersEmptyInput":0.002,"ThesisEditValidationTest::testCollectJuryMembersSinglePromoteur":0.002,"ThesisEditValidationTest::testCollectJuryMembersPromoteurUlb":0.002,"ThesisEditValidationTest::testCollectJuryMembersLecteurs":0.002,"ThesisEditValidationTest::testCollectJuryMembersDeduplicatesEmptyStrings":0.002,"ThesisEditValidationTest::testCollectJuryMembersScalarPromoteurAccepted":0.002,"ThesisEditValidationTest::testHandleWebsiteUrlStoresValidUrl":0.003,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsInvalidUrl":0.003,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsEmptyUrl":0.002,"ThesisEditValidationTest::testHandleWebsiteUrlNormalisesHttp":0.002,"ErrorHandlerTest::testFkThesesTableMentionsAllPossibleFields":0.027,"ErrorHandlerTest::testFkApPrograms":0.002,"ErrorHandlerTest::testFkFinalityTypes":0.002,"ErrorHandlerTest::testFkThesisLanguages":0.002,"ErrorHandlerTest::testFkThesisFormats":0.002,"ErrorHandlerTest::testFkThesisTags":0.002,"ErrorHandlerTest::testFkThesisSupervisors":0.002,"ErrorHandlerTest::testFkAccessTypes":0.002,"ErrorHandlerTest::testFkLicenseTypes":0.001,"ErrorHandlerTest::testFkAuthors":0.002,"ErrorHandlerTest::testFkQuotedTableName":0.002,"ErrorHandlerTest::testFkQuotedLanguages":0.002,"ErrorHandlerTest::testFkQuotedFormatTypes":0.002,"ErrorHandlerTest::testFkReferencesTags":0.002,"ErrorHandlerTest::testFkReferencesOrientations":0.002,"ErrorHandlerTest::testFkUnknownTableGenericFallback":0.002,"ErrorHandlerTest::testFkEmptyMessageGenericFallback":0.002,"ErrorHandlerTest::testUniqueConstraint":0.002,"ErrorHandlerTest::testNotNullConstraint":0.002,"ErrorHandlerTest::testGenericPdoError":0.002,"ErrorHandlerTest::testDuplicateThesisExceptionPassesThrough":0.005,"ErrorHandlerTest::testValidationExceptionPassesThrough":0.002,"ErrorHandlerTest::testGenericExceptionPassesThrough":0.002,"ErrorHandlerTest::testTypeErrorReturnsGeneric":0.002,"ErrorHandlerTest::testLogWithContext":0.002,"ErrorHandlerTest::testLogWithNullValues":0.003,"ErrorHandlerTest::testLogWithEmptyExtra":0.002,"ErrorHandlerTest::testFkQuotedColumnNames":0.002,"ErrorHandlerTest::testFkUpdateStatement":0.001,"ErrorHandlerTest::testFkWithReferencesAndInsert":0.002,"PureLogicTest::testSplitJuryByRoleAllRoles":0.043,"PureLogicTest::testSplitJuryByRoleEmptyNameSkipped":0.002,"PureLogicTest::testSplitJuryByRoleEmptyJury":0.002,"PureLogicTest::testCollectCaptionPathsVttByMime":0.002,"PureLogicTest::testCollectCaptionPathsVttByExtension":0.002,"PureLogicTest::testCollectCaptionPathsNoVttReturnsEmpty":0.002,"PureLogicTest::testDetectFileTypeByMime":0.003,"PureLogicTest::testDetectFileTypeByExtensionFallback":0.003,"SearchControllerTest::testHandleSearchReturnsCoverMapKey":0.055,"SearchControllerTest::testCoverMapContainsKnownThesis":0.004}} \ No newline at end of file +{"version":2,"defects":{"CryptoTest::testEncryptDecryptEmptyString":7,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":7,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":7,"SystemControllerHelpersTest::testHumanBytesOneMB":7,"SystemControllerHelpersTest::testHumanBytesOneGB":7,"CryptoTest::testEncryptEmptyStringProducesCiphertext":7,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":7,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":7,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":7,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":7,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":7,"RateLimitExtendedTest::testGetRemainingDecrements":7,"RateLimitExtendedTest::testGetRemainingAtLimit":7,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":7,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":7,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":8},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0.035,"CryptoTest::testEncryptDecryptWithUnicode":0.002,"CryptoTest::testEncryptDecryptMultiline":0.002,"CryptoTest::testDifferentPlaintextsProduceDifferentCiphertexts":0.002,"CryptoTest::testSamePlaintextProducesDifferentCiphertexts":0.002,"CryptoTest::testIsEncryptedRecognizesEncryptedValue":0.01,"CryptoTest::testIsEncryptedRejectsPlaintext":0.003,"CryptoTest::testIsEncryptedReturnsFalseForEmptyString":0.003,"CryptoTest::testIsEncryptedRejectsInvalidBase64":0.002,"CryptoTest::testEncryptDecryptEmptyString":0.008,"CryptoTest::testDecryptEmptyStringReturnsEmpty":0.002,"CryptoTest::testDecryptInvalidBase64ReturnsInputGracefully":0.002,"CryptoTest::testDecryptTooShortBlobReturnsInputGracefully":0.002,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":0.002,"CryptoTest::testDecryptValidBlobTamperedTagReturnsEmpty":0.005,"EmailObfuscatorTest::testEncodeContainsNoAtSign":0.015,"EmailObfuscatorTest::testEncodeOutputIsNumericEntities":0.002,"EmailObfuscatorTest::testEmailReturnsObfuscatedAddress":0.002,"EmailObfuscatorTest::testMailtoBuildsCorrectHrefStructure":0.007,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":0.003,"EmailObfuscatorTest::testEmailTextReplacesMultipleEmails":0.004,"EmailObfuscatorTest::testMailtoInTextReplacesMailtoLinks":0.002,"EmailObfuscatorTest::testObfuscateHtmlReplacesAnchorTag":0.003,"EmailObfuscatorTest::testObfuscateHtmlKeepsNonMailtoLinksUnchanged":0.002,"EmailObfuscatorTest::testObfuscateHtmlPreservesCustomLinkText":0.002,"EmailObfuscatorTest::testEmptyStringReturnsEmpty":0.001,"EmailObfuscatorTest::testStringWithNoEmailsIsUnchanged":0.001,"EmailObfuscatorTest::testAlreadyObfuscatedContentIsNotDoubleEncoded":0.002,"EmailObfuscatorTest::testMultipleEmailsInOneString":0.002,"EmailObfuscatorTest::testEmailWithPlusSign":0.011,"StudentEmailTest::testBuildHtmlReturnsNonEmptyString":0.053,"StudentEmailTest::testBuildHtmlContainsKeyFields":0.006,"StudentEmailTest::testBuildHtmlEscapesSpecialCharacters":0.006,"StudentEmailTest::testBuildHtmlHandlesMissingOptionalFields":0.008,"StudentEmailTest::testBuildHtmlHandlesNullFieldsGracefully":0.003,"StudentEmailTest::testBuildHtmlHandlesEmptyArray":0.004,"StudentEmailTest::testBuildHtmlContainsLabelFields":0.005,"SystemControllerHelpersTest::testHumanBytesZero":0.219,"SystemControllerHelpersTest::testHumanBytesBelowOneKB":0.004,"SystemControllerHelpersTest::testHumanBytesOneKB":0.003,"SystemControllerHelpersTest::testHumanBytesOneMB":0.003,"SystemControllerHelpersTest::testHumanBytesOneGB":0.004,"SystemControllerHelpersTest::testHumanBytes1523MB":0.003,"SystemControllerHelpersTest::testHumanBytes2500GB":0.003,"SystemControllerHelpersTest::testDiskColorBelowWarning":0.005,"SystemControllerHelpersTest::testDiskColorWarning":0.005,"SystemControllerHelpersTest::testDiskColorCritical":0.005,"SystemControllerHelpersTest::testLogLineClassCrit":0.003,"SystemControllerHelpersTest::testLogLineClassError":0.003,"SystemControllerHelpersTest::testLogLineClassWarn":0.003,"SystemControllerHelpersTest::testLogLineClassNotice":0.009,"SystemControllerHelpersTest::testLogLineClassHttp500":0.003,"SystemControllerHelpersTest::testLogLineClassHttp300":0.002,"SystemControllerHelpersTest::testLogLineClassDefault":0.003,"SystemControllerHelpersTest::testNginxLineClassComment":0.003,"SystemControllerHelpersTest::testNginxLineClassBlock":0.004,"SystemControllerHelpersTest::testNginxLineClassDirective":0.003,"SystemControllerHelpersTest::testStatusLabelActive":0.004,"SystemControllerHelpersTest::testStatusLabelInactive":0.003,"SystemControllerHelpersTest::testStatusLabelFailed":0.003,"SystemControllerHelpersTest::testStatusLabelWarn":0.004,"SystemControllerHelpersTest::testStatusLabelUnknown":0.003,"SystemControllerHelpersTest::testStatusClassOk":0.005,"SystemControllerHelpersTest::testStatusClassWarn":0.003,"SystemControllerHelpersTest::testStatusClassError":0.009,"SystemControllerHelpersTest::testStatusClassUnknown":0.004,"TfeControllerOgTest::testBuildOgTagsReturnsAllRequiredKeys":0.003,"TfeControllerOgTest::testBuildOgTagsTitleIncludesAuthors":0.006,"TfeControllerOgTest::testBuildOgTagsImageEmptyWhenNoFiles":0.003,"TfeControllerOgTest::testBuildOgTagsImageFromCover":0.003,"TfeControllerOgTest::testBuildOgTagsImageFallbackToFirstImage":0.007,"TfeControllerOgTest::testBuildOgTagsUrlIncludesThesisId":0.004,"TfeControllerOgTest::testBuildOgTagsPublishedTimeFormatted":0.003,"TfeControllerOgTest::testBuildOgTagsPublishedTimeEmptyWhenNoYear":0.007,"TfeControllerOgTest::testBuildMetaDescriptionTruncatesLongSynopsis":0.007,"TfeControllerOgTest::testBuildMetaDescriptionKeepsShortSynopsis":0.011,"TfeControllerOgTest::testBuildMetaDescriptionEmptySynopsisReturnsDefault":0.004,"TfeControllerOgTest::testBuildMetaDescriptionStripsHtmlTags":0.004,"CryptoTest::testEncryptEmptyStringProducesCiphertext":0.003,"DatabaseExtendedTest::testEscapeLikeStringViaSearchConditions":0.003,"DatabaseExtendedTest::testBuildSearchConditionsEmptyParams":0.003,"DatabaseExtendedTest::testBuildSearchConditionsWithQuery":0.002,"DatabaseExtendedTest::testBuildSearchConditionsWithYear":0.002,"DatabaseExtendedTest::testBuildSearchConditionsWithAllFilters":0.002,"DatabaseExtendedTest::testFindDuplicateThesisExactMatch":0.003,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentTitle":0.003,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentYear":0.008,"DatabaseExtendedTest::testFindDuplicateThesisEmptyAuthorNamesReturnsNull":0.004,"DatabaseExtendedTest::testFindDuplicateThesisEmptyTable":0.004,"DatabaseExtendedTest::testFindDuplicateThesisNearDuplicateByLevenshtein":0.003,"DatabaseExtendedTest::testGenerateThesisIdentifierFirstInYear":0.002,"DatabaseExtendedTest::testGenerateThesisIdentifierIncrementsCorrectly":0.002,"DatabaseExtendedTest::testGenerateThesisIdentifierUsesMaxNotCount":0.002,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsPaths":0.002,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsEmptyForUnknownIds":0.009,"DatabaseExtendedTest::testGetCoverPathsForThesesEmptyInputReturnsEmpty":0.005,"DatabaseExtendedTest::testGetCoverPathsForThesesMultipleTheses":0.01,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":0.002,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":0.002,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":0.007,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":0.003,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":0.003,"DatabaseExtendedTest::testRenameLanguageUpdatesName":0.053,"DatabaseExtendedTest::testMergeLanguageReassignsTheses":0.003,"DatabaseExtendedTest::testRenameTagUpdatesName":0.008,"DatabaseExtendedTest::testMergeTagReassignsTheses":0.003,"RateLimitExtendedTest::testCheckKeyCountsPerKey":0.061,"RateLimitExtendedTest::testCheckKeyDoesNotAffectDefaultCheck":0.004,"RateLimitExtendedTest::testGetRemainingDecrements":0,"RateLimitExtendedTest::testGetRemainingAtLimit":0,"RateLimitExtendedTest::testGetRemainingUsesClientIdentifier":0.001,"RateLimitExtendedTest::testCheckUsesConsistentIdentifier":0,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":0.003,"RateLimitExtendedTest::testGetResetTimeReturnsZeroWhenNoData":0.007,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":0.003,"RateLimitExtendedTest::testCleanupRemovesOldFiles":0.003,"ShareLinkExtendedTest::testListActiveReturnsOnlyActiveLinks":0.372,"ShareLinkExtendedTest::testListArchivedReturnsOnlyArchivedLinks":0.295,"ShareLinkExtendedTest::testFindBySlugHit":0.302,"ShareLinkExtendedTest::testFindBySlugMiss":0.002,"ShareLinkExtendedTest::testSetPasswordAndDecryptRoundTrip":0.3,"ShareLinkExtendedTest::testGetDecryptedPasswordOnNonexistentId":0.002,"ShareLinkExtendedTest::testUpdateChangesNameAndExpiration":0.323,"ShareLinkExtendedTest::testUpdateOnlyNameLeavesExpirationUnchanged":0.334,"ShareLinkExtendedTest::testUpdateClearsExpiration":0.326,"ShareLinkExtendedTest::testCreateWithLockedYear":0.312,"ShareLinkExtendedTest::testCreateWithInvalidLockedYearRejected":0.309,"ShareLinkExtendedTest::testUpdateLockedYear":0.33,"ShareLinkExtendedTest::testUpdateClearLockedYear":0.31,"ShareLinkExtendedTest::testIncrementUsage":0.316,"ShareLinkExtendedTest::testCreateDefaultsToTfeWhenInvalidObjet":0.332,"ShareLinkExtendedTest::testCreateAcceptsValidObjet":0.322,"RateLimitExtendedTest::testGetRemainingStartsAtMax":0.002,"RateLimitExtendedTest::testCheckDecrementsRemainingForSameIp":0.003,"RateLimitExtendedTest::testCheckAndCheckKeyAreIndependent":0.002,"RateLimitExtendedTest::testMultipleChecksFromSameClient":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusTitle":1.349,"AutofocusFieldForErrorTest::testCreateAutofocusAuthors":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusSynopsis":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusYear":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusOrientation":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusAP":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusFinality":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusLanguages":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusPromoteur":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurInterne":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurExterne":0.001,"AutofocusFieldForErrorTest::testCreateAutofocusFormats":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusLicense":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusUrl":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusTags":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusUnknownErrorReturnsNull":0.007,"AutofocusFieldForErrorTest::testEditAutofocusTitle":0.215,"AutofocusFieldForErrorTest::testEditAutofocusYear":0.003,"AutofocusFieldForErrorTest::testEditAutofocusSynopsis":0.006,"AutofocusFieldForErrorTest::testEditAutofocusAuthors":0.003,"AutofocusFieldForErrorTest::testEditAutofocusUnknownErrorReturnsNull":0.003,"AutofocusFieldForErrorTest::testCreateDoesNotLeakEditFieldNames":0.004,"ThesisCreateValidationTest::testValidSubmissionReturnsCleanedData":0.011,"ThesisCreateValidationTest::testMissingTitleThrowsException":0.006,"ThesisCreateValidationTest::testMissingAuthorsThrowsException":0.006,"ThesisCreateValidationTest::testMissingSynopsisThrowsException":0.004,"ThesisCreateValidationTest::testMissingOrientationInNonAdminModeThrowsException":0.014,"ThesisCreateValidationTest::testMissingAPProgramInNonAdminModeThrowsException":0.013,"ThesisCreateValidationTest::testMissingFinalityInNonAdminModeThrowsException":0.006,"ThesisCreateValidationTest::testInvalidYearFormatRejected":0.006,"ThesisCreateValidationTest::testYearZeroRejected":0.005,"ThesisCreateValidationTest::testYearBefore2000Rejected":0.005,"ThesisCreateValidationTest::testFarFutureYearRejected":0.006,"ThesisCreateValidationTest::testCurrentYearAccepted":0.005,"ThesisCreateValidationTest::testMalformedUrlRejected":0.003,"ThesisCreateValidationTest::testValidUrlAccepted":0.002,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":0.005,"ThesisCreateValidationTest::testMaxTenKeywordsEnforced":0.004,"ThesisCreateValidationTest::testXssPayloadStrippedFromTitle":0.007,"ThesisCreateValidationTest::testHtmlInSynopsisStripped":0.005,"ThesisCreateValidationTest::testMultipleAuthorsAreSorted":0.007,"ThesisCreateValidationTest::testMissingPromoteurInNonAdminModeThrowsException":0.003,"ThesisCreateValidationTest::testMissingLecteurInterneInNonAdminModeThrowsException":0.003,"ThesisCreateValidationTest::testMissingLanguagesInNonAdminModeThrowsException":0.004,"ThesisCreateValidationTest::testMissingFormatsInNonAdminModeThrowsException":0.003,"ThesisCreateValidationTest::testMissingLicenseWithLibreAccessThrowsException":0.009,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":0.007,"ThesisEditValidationTest::testLoadThrowsOnUnknownId":0.005,"ThesisEditValidationTest::testLoadThrowsOnInvalidId":0.002,"ThesisEditValidationTest::testLoadThrowsOnNegativeId":0.002,"ThesisEditValidationTest::testCollectJuryMembersEmptyInput":0.002,"ThesisEditValidationTest::testCollectJuryMembersSinglePromoteur":0.002,"ThesisEditValidationTest::testCollectJuryMembersPromoteurUlb":0.002,"ThesisEditValidationTest::testCollectJuryMembersLecteurs":0.002,"ThesisEditValidationTest::testCollectJuryMembersDeduplicatesEmptyStrings":0.003,"ThesisEditValidationTest::testCollectJuryMembersScalarPromoteurAccepted":0.008,"ThesisEditValidationTest::testHandleWebsiteUrlStoresValidUrl":0.005,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsInvalidUrl":0.003,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsEmptyUrl":0.003,"ThesisEditValidationTest::testHandleWebsiteUrlNormalisesHttp":0.003,"ErrorHandlerTest::testFkThesesTableMentionsAllPossibleFields":0.794,"ErrorHandlerTest::testFkApPrograms":0.002,"ErrorHandlerTest::testFkFinalityTypes":0.001,"ErrorHandlerTest::testFkThesisLanguages":0.001,"ErrorHandlerTest::testFkThesisFormats":0.003,"ErrorHandlerTest::testFkThesisTags":0.005,"ErrorHandlerTest::testFkThesisSupervisors":0.002,"ErrorHandlerTest::testFkAccessTypes":0.002,"ErrorHandlerTest::testFkLicenseTypes":0.002,"ErrorHandlerTest::testFkAuthors":0.002,"ErrorHandlerTest::testFkQuotedTableName":0.002,"ErrorHandlerTest::testFkQuotedLanguages":0.002,"ErrorHandlerTest::testFkQuotedFormatTypes":0.001,"ErrorHandlerTest::testFkReferencesTags":0.001,"ErrorHandlerTest::testFkReferencesOrientations":0.001,"ErrorHandlerTest::testFkUnknownTableGenericFallback":0.001,"ErrorHandlerTest::testFkEmptyMessageGenericFallback":0.002,"ErrorHandlerTest::testUniqueConstraint":0.002,"ErrorHandlerTest::testNotNullConstraint":0.003,"ErrorHandlerTest::testGenericPdoError":0.004,"ErrorHandlerTest::testDuplicateThesisExceptionPassesThrough":0.009,"ErrorHandlerTest::testValidationExceptionPassesThrough":0.003,"ErrorHandlerTest::testGenericExceptionPassesThrough":0.002,"ErrorHandlerTest::testTypeErrorReturnsGeneric":0.002,"ErrorHandlerTest::testLogWithContext":0.024,"ErrorHandlerTest::testLogWithNullValues":0.003,"ErrorHandlerTest::testLogWithEmptyExtra":0.002,"ErrorHandlerTest::testFkQuotedColumnNames":0.002,"ErrorHandlerTest::testFkUpdateStatement":0.002,"ErrorHandlerTest::testFkWithReferencesAndInsert":0.002,"PureLogicTest::testSplitJuryByRoleAllRoles":0.064,"PureLogicTest::testSplitJuryByRoleEmptyNameSkipped":0.004,"PureLogicTest::testSplitJuryByRoleEmptyJury":0.002,"PureLogicTest::testCollectCaptionPathsVttByMime":0.002,"PureLogicTest::testCollectCaptionPathsVttByExtension":0.002,"PureLogicTest::testCollectCaptionPathsNoVttReturnsEmpty":0.002,"PureLogicTest::testDetectFileTypeByMime":0.002,"PureLogicTest::testDetectFileTypeByExtensionFallback":0.002,"SearchControllerTest::testHandleSearchReturnsCoverMapKey":0.058,"SearchControllerTest::testCoverMapContainsKnownThesis":0.007}} \ No newline at end of file diff --git a/TODO.md b/TODO.md index 97ee59b..edb6198 100644 --- a/TODO.md +++ b/TODO.md @@ -225,8 +225,12 @@ - [x] Step 4 — Update templates (data-queue-type on all inputs, data-existing-files in edit) - [x] Step 5 — Update upload-progress.js (new collectFileNames, pending-uploads guard) - [ ] Step 6 — QA / integration testing +- [x] Logs accessible via Paramètres: app, admin, error, audit tabs (JSON parsed to readable lines), nginx tabs kept +- [x] Remove nginx config tab and PHP-FPM error log tab from UI - [ ] Step 7 — Cleanup: remove transition flags, remove INPUT_ID_TO_TYPE + + # CSP & Deploy Fixes (May 2026) - [x] Track vendor JS files in jj (they were moved to vendor/ but never `jj file track`ed) diff --git a/app/public/admin/parametres.php b/app/public/admin/parametres.php index dda265b..586e264 100644 --- a/app/public/admin/parametres.php +++ b/app/public/admin/parametres.php @@ -48,11 +48,9 @@ $diskPct = $diskInfo['pct']; $diskColor = SystemController::diskColor($diskPct); // ── Logs section ────────────────────────────────────────────────────────────── -$activeTab = $_GET['tab'] ?? 'nginx_access'; -if ($activeTab === 'status') { - $activeTab = 'nginx_access'; -} elseif ($activeTab !== 'nginx_config' && !array_key_exists($activeTab, SystemController::LOG_FILES)) { - $activeTab = 'nginx_access'; +$activeTab = $_GET['tab'] ?? 'app'; +if ($activeTab === 'status' || !array_key_exists($activeTab, SystemController::LOG_FILES)) { + $activeTab = 'app'; } $selectedN = isset($_GET['n']) ? (int) $_GET['n'] : 100; @@ -60,27 +58,12 @@ if (!in_array($selectedN, SystemController::ALLOWED_LINES, true)) { $selectedN = 100; } -$logLines = null; -$logError = null; -$logFileMeta = null; - -$nginxConfigLines = null; -$nginxConfigSource = null; -$nginxConfigError = null; -$nginxConfigMeta = null; - -if ($activeTab === 'nginx_config') { - $nginxData = $_controller->getNginxConfigData(); - $nginxConfigLines = $nginxData['lines']; - $nginxConfigSource = $nginxData['source']; - $nginxConfigMeta = $nginxData['meta']; - $nginxConfigError = $nginxData['error']; -} else { - $logData = $_controller->getLogData($activeTab, $selectedN); - $logLines = $logData['lines']; - $logError = $logData['error']; - $logFileMeta = $logData['meta']; -} +$logData = $_controller->getLogData($activeTab, $selectedN); +$logLines = $logData['lines']; +$logError = $logData['error']; +$logFileMeta = $logData['meta']; +$logIsJson = $logData['isJson'] ?? false; +$notYet = $logData['notYet'] ?? false; $collapsed = $_COOKIE['sys_collapsed'] ?? null; $statusInitiallyCollapsed = $collapsed === '1'; diff --git a/app/public/admin/system-fragment.php b/app/public/admin/system-fragment.php index 7001fd7..682a180 100644 --- a/app/public/admin/system-fragment.php +++ b/app/public/admin/system-fragment.php @@ -2,9 +2,9 @@ /** * system-fragment.php — returns only the tab-panel HTML for the admin system page. * - * Called by fetch() from system.php JS when switching tabs or changing line count. + * Called by fetch() from parametres.php JS when switching tabs or changing line count. * With JS disabled the user never hits this URL directly; the tab hrefs still - * point at system.php?tab=… so navigation degrades gracefully. + * point at parametres.php?tab=… so navigation degrades gracefully. * * Response: text/html fragment (no // wrapper). * On any auth failure or bad request: 403 / 400 with a plain-text body. @@ -23,9 +23,9 @@ if (!AdminAuth::isAuthenticated()) { } // ── Validate inputs ──────────────────────────────────────────────────────── -$activeTab = $_GET['tab'] ?? 'nginx_access'; -if ($activeTab !== 'nginx_config' && !array_key_exists($activeTab, SystemController::LOG_FILES)) { - $activeTab = 'nginx_access'; +$activeTab = $_GET['tab'] ?? 'app'; +if (!array_key_exists($activeTab, SystemController::LOG_FILES)) { + $activeTab = 'app'; } $selectedN = isset($_GET['n']) ? (int) $_GET['n'] : 100; @@ -42,19 +42,11 @@ $_cache = new SystemCache($_db->getPDO()); $_controller = new SystemController($_db, $_cache); // ── Render ───────────────────────────────────────────────────────────────── -if ($activeTab === 'nginx_config') { - $nginxData = $_controller->getNginxConfigData(); - $nginxConfigLines = $nginxData['lines']; - $nginxConfigSource = $nginxData['source']; - $nginxConfigMeta = $nginxData['meta']; - $nginxConfigError = $nginxData['error']; +$logData = $_controller->getLogData($activeTab, $selectedN); +$logLines = $logData['lines']; +$logError = $logData['error']; +$logFileMeta = $logData['meta']; +$logIsJson = $logData['isJson'] ?? false; +$notYet = $logData['notYet'] ?? false; - include APP_ROOT . '/templates/admin/partials/system-nginx-config-panel.php'; -} else { - $logData = $_controller->getLogData($activeTab, $selectedN); - $logLines = $logData['lines']; - $logError = $logData['error']; - $logFileMeta = $logData['meta']; - - include APP_ROOT . '/templates/admin/partials/system-log-panel.php'; -} +include APP_ROOT . '/templates/admin/partials/system-log-panel.php'; diff --git a/app/public/assets/css/system.css b/app/public/assets/css/system.css index bdc7d51..6dff4ff 100644 --- a/app/public/assets/css/system.css +++ b/app/public/assets/css/system.css @@ -352,29 +352,4 @@ border: 1px solid var(--success-muted-border); } -/* ── Nginx config viewer ───────────────────────────────────────────────── */ -.nginx-source-badge { - display: inline-block; - font-size: var(--step--2); - font-family: ui-monospace, monospace; - padding: var(--space-3xs) var(--space-2xs); - border-radius: var(--radius); - margin-left: var(--space-2xs); - vertical-align: middle; -} -.nginx-source-badge--live { - background: var(--success-muted-bg); - color: var(--success); - border: 1px solid var(--success-muted-border); -} -.nginx-source-badge--local { - background: var(--warning-muted-bg); - color: var(--warning); - border: 1px solid var(--warning-muted-border); -} -/* Nginx syntax highlight layers inside .log-output */ -.nginx-comment { color: var(--sys-syntax-comment); font-style: italic; } -.nginx-directive { color: var(--sys-syntax-directive); } -.nginx-block { color: var(--sys-syntax-block); font-weight: 600; } -.nginx-value { color: var(--sys-syntax-value); } -.nginx-location { color: var(--sys-syntax-location); } + diff --git a/app/src/AdminLogger.php b/app/src/AdminLogger.php index 7abd489..e63cfc2 100644 --- a/app/src/AdminLogger.php +++ b/app/src/AdminLogger.php @@ -14,21 +14,10 @@ */ class AdminLogger { - private string $logFile; private ?Database $db; public function __construct(?Database $db = null) { - if (php_sapi_name() === 'cli-server') { - $dir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : __DIR__ . '/../storage/logs'; - if (!is_dir($dir)) { - mkdir($dir, 0755, true); - } - $this->logFile = $dir . '/admin.log'; - } else { - $this->logFile = '/var/log/xamxam.log'; - } - $this->db = $db; } @@ -256,10 +245,10 @@ class AdminLogger $entry['context'] = $context; } - $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if (is_writable($this->logFile) || (!file_exists($this->logFile) && is_writable(dirname($this->logFile)))) { - error_log($line, 3, $this->logFile); - } + $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + // File output — delegates to Monolog 'admin' channel + Logger::get('admin')->info($line); if ($this->db !== null) { $this->insertDb($resource, $action, $status, $context); diff --git a/app/src/AppLogger.php b/app/src/AppLogger.php index f7e7072..73453fd 100644 --- a/app/src/AppLogger.php +++ b/app/src/AppLogger.php @@ -3,9 +3,8 @@ /** * Structured application logger for form submissions. * - * Writes JSON-lines to a log file in storage/logs/. - * Each entry contains: timestamp, source (admin|partage), action, - * status (success|error), context (IP, UA, thesis ID, error message, etc.). + * Thin facade over Monolog channel 'app'. + * Delegates all file I/O — keeps existing public API unchanged. */ class AppLogger { @@ -16,10 +15,7 @@ class AppLogger { $this->logDir = $logDir ?? (defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : __DIR__ . '/../storage/logs'); - if (!is_dir($this->logDir)) { - mkdir($this->logDir, 0755, true); - } - + // Keep for backward compat — actual file I/O is now handled by Monolog via Logger::get('app') $this->logFile = $this->logDir . '/form-submissions.log'; } @@ -88,7 +84,7 @@ class AppLogger } /** - * Write a structured log line. + * Write a structured log line — delegates to Monolog 'app' channel. */ private function write(array $entry): void { @@ -96,7 +92,8 @@ class AppLogger $entry['ip'] = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $entry['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? ''; - $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - error_log($line, 3, $this->logFile); + $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + Logger::get('app')->info($line); } } diff --git a/app/src/Audit.php b/app/src/Audit.php index 6e77cd6..52d1564 100644 --- a/app/src/Audit.php +++ b/app/src/Audit.php @@ -34,6 +34,7 @@ class Audit ?array $oldData = null, ?array $newData = null ): void { + // DB write is the primary path — best-effort, never crash. try { $stmt = $db->getConnection()->prepare( 'INSERT INTO audit_log (actor, action, table_name, record_id, old_data, new_data) @@ -49,7 +50,33 @@ class Audit ]); } catch (\Throwable $e) { // Audit logging is best-effort — never crash the app over it. - error_log('[Audit] write failed: ' . $e->getMessage()); + error_log('[Audit] DB write failed: ' . $e->getMessage()); + } + + // File shadow — structured JSON-line log for debuggability + // (Option A from monolog-plan: keep Audit DB logic as-is, add file trace) + try { + $entry = [ + 'timestamp' => date('c'), + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli', + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', + 'actor' => $actor, + 'action' => $action, + 'table' => $tableName, + 'record_id' => $recordId, + ]; + if ($oldData !== null) { + $entry['old_data'] = $oldData; + } + if ($newData !== null) { + $entry['new_data'] = $newData; + } + + $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + Logger::get('audit')->info($line); + } catch (\Throwable $e) { + // File shadow is also best-effort + error_log('[Audit] file shadow write failed: ' . $e->getMessage()); } } diff --git a/app/src/Controllers/SystemController.php b/app/src/Controllers/SystemController.php index a0a8128..59e835b 100644 --- a/app/src/Controllers/SystemController.php +++ b/app/src/Controllers/SystemController.php @@ -11,9 +11,8 @@ * maintenance mode) with SystemCache TTL caching * - PHP environment info (1-hour TTL) * - Disk usage info (5-minute TTL) - * - Log file reading (tail, meta) - * - Nginx config file reading - * - Log/nginx line classifiers used by both system.php and system-fragment.php + * - Log file reading (tail, meta, JSON-line parsing) + * - Log line classifiers used by both system.php and system-fragment.php * * Both system.php (full page) and system-fragment.php (AJAX panel) delegate * here so helpers are never duplicated. @@ -23,17 +22,39 @@ class SystemController // ── Constants ───────────────────────────────────────────────────────────── public const LOG_FILES = [ - 'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/xamxam_access.log'], - 'nginx_error' => ['label' => 'nginx — erreurs', 'path' => '/var/log/nginx/xamxam_error.log'], - 'php_error' => ['label' => 'PHP-FPM — erreurs', 'path' => '/var/log/php8.4-fpm.log'], + 'app' => ['label' => 'App — soumissions', 'path' => null, 'json' => true], + 'admin' => ['label' => 'Admin — actions', 'path' => null, 'json' => true], + 'error' => ['label' => 'Erreurs — application', 'path' => null, 'json' => true], + 'audit' => ['label' => 'Audit — données', 'path' => null, 'json' => true], + 'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/xamxam_access.log', 'json' => false], + 'nginx_error' => ['label' => 'nginx — erreurs','path' => '/var/log/nginx/xamxam_error.log', 'json' => false], ]; - public const ALLOWED_LINES = [50, 100, 200, 500]; + /** + * Resolve a log file path — app logs live under STORAGE_ROOT, system logs + * have hard-coded paths (only valid in production). + */ + private static function resolveLogPath(string $tab): string + { + $def = self::LOG_FILES[$tab]; + if ($def['path'] !== null) { + return $def['path']; + } + // App logs: storage/logs/{channel}.log (Monolog RotatingFileHandler uses + // this as base name; the current log is always at {channel}-YYYY-MM-DD.log) + $dir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : APP_ROOT . '/storage/logs'; + // Find the most recent dated log file for this channel + $base = $dir . '/' . $tab; + $dated = glob($base . '-20[0-9][0-9]-[0-9][0-9]-[0-9][0-9].log'); + if (!empty($dated)) { + rsort($dated); // newest first + return $dated[0]; + } + // Fall back to the bare name (pre-existing or non-rotated) + return $base . '.log'; + } - /** Live deployed nginx config path. */ - public const NGINX_CONFIG_LIVE = '/etc/nginx/sites-available/xamxam'; - /** Local reference copy used as fallback in dev. */ - public const NGINX_CONFIG_LOCAL = APP_ROOT . '/nginx/xamxam.conf'; + public const ALLOWED_LINES = [50, 100, 200, 500]; // ── TTLs ────────────────────────────────────────────────────────────────── private const TTL_STATUS = 120; // 2 minutes @@ -140,11 +161,37 @@ class SystemController */ public function getLogData(string $tab, int $n): array { - $logPath = self::LOG_FILES[$tab]['path']; + $logPath = self::resolveLogPath($tab); + $isJson = self::LOG_FILES[$tab]['json'] ?? false; $error = null; - $lines = $this->readLogTail($logPath, $n, $error); - $meta = null; + $rawLines = null; + if (!file_exists($logPath)) { + // App logs are rotated by Monolog; a missing file just means no + // events have been logged yet. Show a friendly empty-state message + // instead of a scary "fichier introuvable" error. + if ($isJson) { + return [ + 'lines' => [], + 'error' => null, + 'meta' => null, + 'isJson' => true, + 'notYet' => true, + ]; + } + // System logs genuinely missing — show an error + $error = 'Fichier introuvable : ' . htmlspecialchars($logPath); + } else { + $rawLines = $this->readLogTail($logPath, $n, $error); + } + + // Parse JSON lines into display strings + $lines = null; + if ($rawLines !== null) { + $lines = $isJson ? self::formatJsonLines($rawLines) : $rawLines; + } + + $meta = null; if (file_exists($logPath)) { $sz = filesize($logPath); $meta = [ @@ -156,43 +203,102 @@ class SystemController ]; } - return ['lines' => $lines, 'error' => $error, 'meta' => $meta]; + return ['lines' => $lines, 'error' => $error, 'meta' => $meta, 'isJson' => $isJson, 'notYet' => false]; } - // ── Nginx config tab ────────────────────────────────────────────────────── - /** - * Read and return data for the nginx config tab. + * Format JSON log lines into human-readable display strings. * - * @return array{lines: ?array, source: ?string, meta: ?array, error: ?string} + * Each line is a flat JSON object. We extract key fields and build a + * compact one-line representation with emoji status indicators. + * + * @param string[] $jsonLines Raw JSON strings (newest first). + * @return string[] Formatted display lines. */ - public function getNginxConfigData(): array + private static function formatJsonLines(array $jsonLines): array { - $livePath = self::NGINX_CONFIG_LIVE; - $localPath = self::NGINX_CONFIG_LOCAL; - - foreach ([[$livePath, 'live'], [$localPath, 'local']] as [$path, $src]) { - if (file_exists($path) && is_readable($path)) { - $raw = file($path, FILE_IGNORE_NEW_LINES); - if ($raw !== false) { - $sz = filesize($path); - $meta = [ - 'path' => $path, - 'size' => $sz > 1048576 - ? number_format($sz / 1048576, 2) . ' MB' - : number_format($sz / 1024, 1) . ' KB', - 'mtime' => date('d/m/Y H:i:s', filemtime($path)), - ]; - return ['lines' => $raw, 'source' => $src, 'meta' => $meta, 'error' => null]; - } + $formatted = []; + foreach ($jsonLines as $raw) { + $entry = json_decode($raw, true); + if (!is_array($entry)) { + $formatted[] = $raw; // not valid JSON — show raw + continue; } + + $parts = []; + + // Timestamp + $ts = $entry['timestamp'] ?? ''; + if ($ts !== '') { + $parts[] = substr($ts, 0, 19); // YYYY-MM-DDTHH:MM:SS + } + + // Status emoji + $status = $entry['status'] ?? ''; + if ($status === 'success' || $status === 'active') { + $parts[] = '✓'; + } elseif ($status === 'error' || $status === 'duplicate') { + $parts[] = '✗'; + } + + // Key identifying fields (vary by channel) + $id = $entry['resource'] + ?? $entry['source'] + ?? $entry['context'] + ?? ''; + if ($id !== '') { + $parts[] = $id; + } + + // Action + $action = $entry['action'] ?? ''; + if ($action !== '') { + $parts[] = $action; + } + + // Status text + if ($status !== '' && $status !== 'success') { + $parts[] = $status; + } + + // Actor / IP + $actor = $entry['actor'] ?? ''; + if ($actor !== '') { + $parts[] = $actor; + } elseif (isset($entry['ip'])) { + $parts[] = $entry['ip']; + } + + // User agent (compact) + $ua = $entry['user_agent'] ?? ''; + if ($ua !== '' && $ua !== 'unknown' && $ua !== 'cli') { + // Truncate UA: first meaningful segment + $uaShort = preg_match('#^([^(]+)#', $ua, $m) ? trim($m[1]) : $ua; + if (mb_strlen($uaShort) > 60) { + $uaShort = mb_substr($uaShort, 0, 57) . '…'; + } + $parts[] = $uaShort; + } + + // For error log: exception + message + if (isset($entry['exception']) && isset($entry['message'])) { + $msg = $entry['exception'] . ': ' . $entry['message']; + if (mb_strlen($msg) > 120) { + $msg = mb_substr($msg, 0, 117) . '…'; + } + $parts[] = $msg; + } + + // For audit log: table + record_id + $table = $entry['table'] ?? ''; + if ($table !== '') { + $parts[] = $table . (isset($entry['record_id']) ? '#' . $entry['record_id'] : ''); + } + + $formatted[] = implode(' ', $parts); } - $error = file_exists($livePath) - ? 'Fichier non lisible (permissions insuffisantes) : ' . htmlspecialchars($livePath) - : 'Config live introuvable (' . htmlspecialchars($livePath) . ') et config locale introuvable (' . htmlspecialchars($localPath) . ').'; - - return ['lines' => null, 'source' => null, 'meta' => null, 'error' => $error]; + return $formatted; } // ── Line classifiers (used by both system.php and system-fragment.php) ──── @@ -223,21 +329,6 @@ class SystemController return ''; } - /** - * Return the CSS class for a nginx config line. - */ - public static function nginxLineClass(string $line): string - { - $trimmed = ltrim($line); - if ($trimmed === '' || str_starts_with($trimmed, '#')) { - return 'nginx-comment'; - } - if (preg_match('/^\s*(location|server|upstream|events|http|geo|map|types)\b/', $line)) { - return 'nginx-block'; - } - return 'nginx-directive'; - } - // ── View helpers ────────────────────────────────────────────────────────── /** @@ -399,15 +490,14 @@ class SystemController /** * Read the tail of a log file, newest-first. Returns null on error. + * + * Prefers tail(1) for efficiency on large files; falls back to a pure-PHP + * read for app log files when exec() is unavailable or tail fails. */ private function readLogTail(string $logPath, int $n, ?string &$errorMsg): ?array { $errorMsg = null; - if (!function_exists('exec')) { - $errorMsg = 'exec() est désactivé sur ce serveur.'; - return null; - } if (!file_exists($logPath)) { $errorMsg = 'Fichier introuvable : ' . htmlspecialchars($logPath); return null; @@ -417,16 +507,25 @@ class SystemController return null; } - $output = []; - $rc = 0; - exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc); + // Try tail(1) first — fast on large log files + if (function_exists('exec')) { + $output = []; + $rc = 0; + @exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc); + if ($rc === 0) { + return array_reverse($output); // newest first + } + } - if ($rc !== 0) { + // PHP fallback — reads the whole file, returns last N lines + $raw = @file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($raw === false) { $errorMsg = 'Erreur lors de la lecture du fichier journal.'; return null; } - return array_reverse($output); // newest first + $slice = array_slice($raw, -min($n, count($raw))); + return array_reverse($slice); } /** diff --git a/app/src/ErrorHandler.php b/app/src/ErrorHandler.php index 2f97f25..0eefdcd 100644 --- a/app/src/ErrorHandler.php +++ b/app/src/ErrorHandler.php @@ -154,7 +154,7 @@ class ErrorHandler } /** - * Write a structured error log entry. + * Write a structured error log entry — delegates to Monolog 'error' channel. * * @param string $context e.g. 'thesis_edit', 'partage_submit', 'csv_import' * @param \Throwable $e @@ -162,22 +162,22 @@ class ErrorHandler */ public static function log(string $context, \Throwable $e, array $extra = []): void { - $parts = [ - 'context=' . $context, - 'exception=' . get_class($e), - 'message=' . $e->getMessage(), + $entry = [ + 'timestamp' => date('c'), + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', + 'context' => $context, + 'exception' => get_class($e), + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), ]; - foreach ($extra as $k => $v) { - if (is_scalar($v) || $v === null) { - $parts[] = $k . '=' . json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } elseif (is_array($v)) { - $parts[] = $k . '=' . json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } else { - $parts[] = $k . '=' . gettype($v); - } - } - $parts[] = 'trace=' . $e->getTraceAsString(); - error_log(implode(' | ', $parts)); + if (!empty($extra)) { + $entry['extra'] = $extra; + } + + $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + Logger::get('error')->error($line); } } diff --git a/app/src/Logger.php b/app/src/Logger.php new file mode 100644 index 0000000..b441389 --- /dev/null +++ b/app/src/Logger.php @@ -0,0 +1,105 @@ +info('message', [...]); + * Logger::get('admin')->warning('message', [...]); + */ +class Logger +{ + /** @var array */ + private static array $channels = []; + + /** + * Get (or lazily create) a named Monolog channel. + */ + public static function get(string $channel): LoggerInterface + { + if (!isset(self::$channels[$channel])) { + self::$channels[$channel] = self::create($channel); + } + return self::$channels[$channel]; + } + + /** + * Create a Monolog channel with rotating JSON file handler. + * + * Falls back to NullHandler if the log directory is not writable + * (e.g. CLI scripts on a machine that doesn't have the production path). + */ + private static function create(string $channel): \Monolog\Logger + { + $logDir = self::logDir(); + + if (!is_dir($logDir) && !@mkdir($logDir, 0755, true) && !is_dir($logDir)) { + // Directory can't be created — use null handler (graceful degradation) + $logger = new \Monolog\Logger($channel); + $logger->pushHandler(new NullHandler()); + return $logger; + } + + try { + $handler = new RotatingFileHandler( + $logDir . '/' . $channel . '.log', + 30, // keep 30 days of logs + self::level() + ); + } catch (\Throwable $e) { + // Can't open log file — use null handler + $logger = new \Monolog\Logger($channel); + $logger->pushHandler(new NullHandler()); + return $logger; + } + + // Pass-through formatter: the facades (AppLogger, AdminLogger, etc.) + // construct their own JSON lines and pass them as the log message. + // %message% preserves the existing JSON format contract exactly. + $handler->setFormatter(new LineFormatter("%message%\n", null, true)); + + $logger = new \Monolog\Logger($channel); + $logger->pushHandler($handler); + + return $logger; + } + + /** + * Read the LOG_LEVEL env var with sensible defaults. + */ + private static function level(): Level + { + $level = strtoupper(getenv('LOG_LEVEL') ?: ''); + + // Default: WARNING in production (always set in .env), DEBUG otherwise + if ($level === '') { + return php_sapi_name() === 'cli-server' ? Level::Debug : Level::Warning; + } + + return Level::fromName($level); + } + + /** + * Resolve the log directory. + */ + private static function logDir(): string + { + if (defined('STORAGE_ROOT')) { + return STORAGE_ROOT . '/logs'; + } + return __DIR__ . '/../storage/logs'; + } +} diff --git a/app/templates/admin/parametres.php b/app/templates/admin/parametres.php index e41f369..1245804 100644 --- a/app/templates/admin/parametres.php +++ b/app/templates/admin/parametres.php @@ -472,23 +472,10 @@ - >nginx — config
- - - - - +
diff --git a/app/templates/admin/partials/system-log-panel.php b/app/templates/admin/partials/system-log-panel.php index a1179da..d79717d 100644 --- a/app/templates/admin/partials/system-log-panel.php +++ b/app/templates/admin/partials/system-log-panel.php @@ -19,7 +19,7 @@
- +
@@ -29,7 +29,7 @@
Journaux non disponibles
- +
En environnement de développement, les logs nginx ne sont pas disponibles. Cette page est pleinement fonctionnelle sur le serveur de production. @@ -37,6 +37,12 @@
+ +
+ Aucune entrée pour le moment.
+ Le journal sera créé automatiquement au premier événement. +
+
Le fichier journal est vide.
diff --git a/app/templates/admin/partials/system-nginx-config-panel.php b/app/templates/admin/partials/system-nginx-config-panel.php deleted file mode 100644 index 2b60863..0000000 --- a/app/templates/admin/partials/system-nginx-config-panel.php +++ /dev/null @@ -1,40 +0,0 @@ - -
- - - - - ● Config déployée - - ⚠ Référence locale (config live inaccessible) - -
- - - -
- Configuration nginx non disponible -
- -
- En développement, /etc/nginx/sites-available/xamxam n'existe pas. - La config de référence se trouve dans nginx/xamxam.conf. -
- -
- - -
Le fichier de configuration est vide.
- - -
- - $line): ?> - - -
- diff --git a/app/templates/admin/system.php b/app/templates/admin/system.php deleted file mode 100644 index c54df8f..0000000 --- a/app/templates/admin/system.php +++ /dev/null @@ -1,114 +0,0 @@ -
-

Système

- -

- Affiché le — - Rafraîchir — - Forcer actualisation -

- - -
-
-

Statut - - - ⚡ Cache — il y a s - - - - ⟳ Actualisé - - -

- -
- -
> -
- - -
-
- - -
- -
- -
- -
- -
-
-

Environnement PHP

-
- $val): ?> -
-
-
-
- -
-
-
-

Espace disque

-
-
-
-
- utilisé (%) - libre / -
-
-
-
-
- - - - - -
- - - - - - - -
- -
- diff --git a/composer.json b/composer.json index ffa1d40..fea6e86 100644 --- a/composer.json +++ b/composer.json @@ -6,10 +6,11 @@ "require": { "php": ">=8.4", "ext-json": "*", - "ext-pdo": "*", "ext-openssl": "*", - "league/commonmark": "^2.4", + "ext-pdo": "*", "guzzlehttp/guzzle": "^7.9", + "league/commonmark": "^2.4", + "monolog/monolog": "^3.10", "phpmailer/phpmailer": "^6.9" }, "require-dev": { diff --git a/composer.lock b/composer.lock index cc2a866..951a324 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "14e1f81f7d65a5f6cf56c604bb12847d", + "content-hash": "b03509a0cf5bec5df187ca91f13c2f6a", "packages": [ { "name": "dflydev/dot-access-data", @@ -597,6 +597,109 @@ ], "time": "2022-12-11T20:36:23+00:00" }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, { "name": "nette/schema", "version": "v1.3.5", @@ -1046,6 +1149,56 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -2614,56 +2767,6 @@ }, "time": "2021-11-05T16:47:00+00:00" }, - { - "name": "psr/log", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" - }, - "time": "2024-09-11T13:17:53+00:00" - }, { "name": "react/cache", "version": "v1.2.0", @@ -5638,8 +5741,8 @@ "platform": { "php": ">=8.4", "ext-json": "*", - "ext-pdo": "*", - "ext-openssl": "*" + "ext-openssl": "*", + "ext-pdo": "*" }, "platform-dev": {}, "platform-overrides": { diff --git a/docs/monolog-plan.md b/docs/monolog-plan.md new file mode 100644 index 0000000..72a97cd --- /dev/null +++ b/docs/monolog-plan.md @@ -0,0 +1,141 @@ +# XAMXAM — Monolog Integration Plan + +## Goal + +Replace the three separate logging systems (`AppLogger`, `AdminLogger`, `ErrorHandler`, `Audit`) with a single +Monolog-based logger, PSR-3 compliant, without changing any call sites in the first pass. + +--- + +## Prerequisites + +```bash +composer require monolog/monolog +``` + +--- + +## Step 1 — Understand the current landscape + +Four logging systems exist. Map them before touching anything: + +| Class | What it logs | Output | Call sites | +|---|---|---|---| +| `AppLogger` | App-level errors, warnings | File (JSON lines) | Scattered across controllers | +| `AdminLogger` | Admin actions, audit trail | File + DB | Admin controllers | +| `ErrorHandler` | PHP errors, exceptions | File (JSON lines) | Registered globally in boot | +| `Audit` | Data mutations (create/edit/delete) | DB table | DB layer, controllers | + +Before writing any code, grep the codebase for every call site of each class and note the method signatures. +The goal is to know exactly what the new unified interface must support before designing it. + +--- + +## Step 2 — Create a central `Logger` factory + +Create `app/Logger.php` — a single factory/registry that holds named Monolog channel instances. +Do not replace any existing class yet. Just build the foundation. + +```php +// Channels to create: +// - 'app' → replaces AppLogger +// - 'admin' → replaces AdminLogger +// - 'error' → replaces ErrorHandler logging +// - 'audit' → replaces Audit (DB writes stay, but structured through Monolog) +``` + +Each channel gets: +- A `RotatingFileHandler` writing to `storage/logs/{channel}.log`, keeping 30 days +- A `JsonFormatter` so log lines stay JSON (preserving the existing format contract) +- Log level set from an environment variable (`LOG_LEVEL`, defaulting to `WARNING` in production, `DEBUG` in dev) + +The factory must be a simple static registry (`Logger::get('app')`) so existing call sites can be migrated +one file at a time without passing instances around. + +--- + +## Step 3 — Replace `AppLogger` + +- Rewrite `AppLogger` as a thin wrapper that delegates to `Logger::get('app')` +- Keep the existing public method signatures identical — no call sites change in this step +- Run the app, verify log output appears in `storage/logs/app.log` +- Delete the old file-writing implementation inside `AppLogger`, keep the class as a facade for now + +--- + +## Step 4 — Replace `ErrorHandler` logging + +- In `ErrorHandler`, replace the internal `log()` method to delegate to `Logger::get('error')` +- Monolog's `ErrorHandler` integration can optionally replace the manual `set_error_handler` / + `set_exception_handler` registration — evaluate whether to adopt that or keep the custom handler + and just swap the write path +- Verify that fatal errors and uncaught exceptions still produce log entries + +--- + +## Step 5 — Replace `AdminLogger` + +This is the most complex because `AdminLogger` writes to both a file and the DB. + +- File path → delegate to `Logger::get('admin')` with a `RotatingFileHandler` +- DB writes → keep as-is for now inside `AdminLogger`, or add a custom Monolog `Handler` that + writes to the DB table. A custom handler is cleaner but optional in this pass. +- Keep public method signatures identical + +--- + +## Step 6 — Replace `Audit` + +`Audit` is DB-only (no file output). Two options: + +- **Option A (simple):** Keep `Audit` as-is, add a Monolog `Logger::get('audit')` that shadows + writes to a file for debuggability, call both from `Audit` methods +- **Option B (clean):** Write a custom Monolog `AuditHandler` that writes to the DB table, + replace `Audit` entirely + +Option A is lower risk for this pass. Option B is the right long-term shape. +Recommend Option A now, Option B as a follow-up. + +--- + +## Step 7 — Collapse the facades + +Once all four classes delegate to Monolog internally, the facades (`AppLogger`, `AdminLogger`, etc.) +are just indirection. This step is optional in this pass but sets up the cleanup: + +- Identify call sites that use `AppLogger::warning(...)` style static calls +- Decide whether to keep the facades permanently (low churn, acceptable) or migrate call sites + to `Logger::get('app')->warning(...)` directly (cleaner, more churn) +- A middle path: have the facades implement `Psr\Log\LoggerInterface` explicitly, which makes + them swappable in tests + +--- + +## Step 8 — Add context standardisation + +One of the main wins of Monolog over the current setup is structured context. Once the plumbing works, +add processors to inject consistent fields into every log entry: + +- `WebProcessor` — adds URL, IP, HTTP method to every request log automatically +- A custom processor for `request_id` — generate a UUID per request in `App::boot()` and attach + it to all channels so log entries from one request can be correlated across channels + +--- + +## What NOT to do in this pass + +- Do not change any call site outside the four logger classes +- Do not change log file paths or formats yet (other tooling may depend on them) +- Do not add Slack/email handlers yet — get the foundation right first +- Do not touch `Audit`'s DB schema + +--- + +## Definition of done + +- `composer require monolog/monolog` is the only `composer.json` change +- All four logging systems write through Monolog internally +- Existing log file locations and JSON format are preserved +- No call site outside the four logger classes has changed +- `AppLogger`, `AdminLogger`, `ErrorHandler`, `Audit` still exist and work as before from the outside +- A single `LOG_LEVEL` environment variable controls verbosity across all channels diff --git a/tests/phpunit/SystemControllerHelpersTest.php b/tests/phpunit/SystemControllerHelpersTest.php index 878ed7f..8755d46 100644 --- a/tests/phpunit/SystemControllerHelpersTest.php +++ b/tests/phpunit/SystemControllerHelpersTest.php @@ -4,7 +4,7 @@ use PHPUnit\Framework\TestCase; /** * SystemControllerHelpersTest — Pure logic tests for the static helper methods - * in SystemController (humanBytes, diskColor, logLineClass, nginxLineClass, + * in SystemController (humanBytes, diskColor, logLineClass, * statusLabel, statusClass). * * These are stateless, no-IO functions. @@ -116,32 +116,6 @@ class SystemControllerHelpersTest extends TestCase $this->assertSame('', SystemController::logLineClass('GET / " 200 123 "')); } - // ── nginxLineClass() ────────────────────────────────────────────────────── - - public function testNginxLineClassComment(): void - { - $this->assertSame('nginx-comment', SystemController::nginxLineClass('# comment')); - $this->assertSame('nginx-comment', SystemController::nginxLineClass(' # indented comment')); - $this->assertSame('nginx-comment', SystemController::nginxLineClass('')); - $this->assertSame('nginx-comment', SystemController::nginxLineClass(' ')); - } - - public function testNginxLineClassBlock(): void - { - $this->assertSame('nginx-block', SystemController::nginxLineClass('server {')); - $this->assertSame('nginx-block', SystemController::nginxLineClass('location / {')); - $this->assertSame('nginx-block', SystemController::nginxLineClass(' upstream backend {')); - $this->assertSame('nginx-block', SystemController::nginxLineClass('events {')); - $this->assertSame('nginx-block', SystemController::nginxLineClass('http {')); - } - - public function testNginxLineClassDirective(): void - { - $this->assertSame('nginx-directive', SystemController::nginxLineClass('listen 80;')); - $this->assertSame('nginx-directive', SystemController::nginxLineClass('root /var/www;')); - $this->assertSame('nginx-directive', SystemController::nginxLineClass(' server_name example.com;')); - } - // ── statusLabel() ───────────────────────────────────────────────────────── public function testStatusLabelActive(): void