diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 0c1ff08..eda39aa 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,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":8},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0,"CryptoTest::testEncryptDecryptWithUnicode":0,"CryptoTest::testEncryptDecryptMultiline":0,"CryptoTest::testDifferentPlaintextsProduceDifferentCiphertexts":0,"CryptoTest::testSamePlaintextProducesDifferentCiphertexts":0,"CryptoTest::testIsEncryptedRecognizesEncryptedValue":0,"CryptoTest::testIsEncryptedRejectsPlaintext":0,"CryptoTest::testIsEncryptedReturnsFalseForEmptyString":0,"CryptoTest::testIsEncryptedRejectsInvalidBase64":0,"CryptoTest::testEncryptDecryptEmptyString":0.008,"CryptoTest::testDecryptEmptyStringReturnsEmpty":0,"CryptoTest::testDecryptInvalidBase64ReturnsInputGracefully":0,"CryptoTest::testDecryptTooShortBlobReturnsInputGracefully":0,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":0,"CryptoTest::testDecryptValidBlobTamperedTagReturnsEmpty":0,"EmailObfuscatorTest::testEncodeContainsNoAtSign":0,"EmailObfuscatorTest::testEncodeOutputIsNumericEntities":0,"EmailObfuscatorTest::testEmailReturnsObfuscatedAddress":0,"EmailObfuscatorTest::testMailtoBuildsCorrectHrefStructure":0,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":0,"EmailObfuscatorTest::testEmailTextReplacesMultipleEmails":0,"EmailObfuscatorTest::testMailtoInTextReplacesMailtoLinks":0,"EmailObfuscatorTest::testObfuscateHtmlReplacesAnchorTag":0,"EmailObfuscatorTest::testObfuscateHtmlKeepsNonMailtoLinksUnchanged":0,"EmailObfuscatorTest::testObfuscateHtmlPreservesCustomLinkText":0,"EmailObfuscatorTest::testEmptyStringReturnsEmpty":0,"EmailObfuscatorTest::testStringWithNoEmailsIsUnchanged":0,"EmailObfuscatorTest::testAlreadyObfuscatedContentIsNotDoubleEncoded":0,"EmailObfuscatorTest::testMultipleEmailsInOneString":0,"EmailObfuscatorTest::testEmailWithPlusSign":0,"StudentEmailTest::testBuildHtmlReturnsNonEmptyString":0,"StudentEmailTest::testBuildHtmlContainsKeyFields":0,"StudentEmailTest::testBuildHtmlEscapesSpecialCharacters":0,"StudentEmailTest::testBuildHtmlHandlesMissingOptionalFields":0,"StudentEmailTest::testBuildHtmlHandlesNullFieldsGracefully":0,"StudentEmailTest::testBuildHtmlHandlesEmptyArray":0,"StudentEmailTest::testBuildHtmlContainsLabelFields":0,"SystemControllerHelpersTest::testHumanBytesZero":0,"SystemControllerHelpersTest::testHumanBytesBelowOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneMB":0,"SystemControllerHelpersTest::testHumanBytesOneGB":0,"SystemControllerHelpersTest::testHumanBytes1523MB":0,"SystemControllerHelpersTest::testHumanBytes2500GB":0,"SystemControllerHelpersTest::testDiskColorBelowWarning":0,"SystemControllerHelpersTest::testDiskColorWarning":0,"SystemControllerHelpersTest::testDiskColorCritical":0,"SystemControllerHelpersTest::testLogLineClassCrit":0,"SystemControllerHelpersTest::testLogLineClassError":0,"SystemControllerHelpersTest::testLogLineClassWarn":0,"SystemControllerHelpersTest::testLogLineClassNotice":0,"SystemControllerHelpersTest::testLogLineClassHttp500":0,"SystemControllerHelpersTest::testLogLineClassHttp300":0,"SystemControllerHelpersTest::testLogLineClassDefault":0,"SystemControllerHelpersTest::testNginxLineClassComment":0.003,"SystemControllerHelpersTest::testNginxLineClassBlock":0.004,"SystemControllerHelpersTest::testNginxLineClassDirective":0.003,"SystemControllerHelpersTest::testStatusLabelActive":0,"SystemControllerHelpersTest::testStatusLabelInactive":0,"SystemControllerHelpersTest::testStatusLabelFailed":0,"SystemControllerHelpersTest::testStatusLabelWarn":0,"SystemControllerHelpersTest::testStatusLabelUnknown":0,"SystemControllerHelpersTest::testStatusClassOk":0,"SystemControllerHelpersTest::testStatusClassWarn":0,"SystemControllerHelpersTest::testStatusClassError":0,"SystemControllerHelpersTest::testStatusClassUnknown":0,"TfeControllerOgTest::testBuildOgTagsReturnsAllRequiredKeys":0,"TfeControllerOgTest::testBuildOgTagsTitleIncludesAuthors":0,"TfeControllerOgTest::testBuildOgTagsImageEmptyWhenNoFiles":0,"TfeControllerOgTest::testBuildOgTagsImageFromCover":0,"TfeControllerOgTest::testBuildOgTagsImageFallbackToFirstImage":0,"TfeControllerOgTest::testBuildOgTagsUrlIncludesThesisId":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeFormatted":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeEmptyWhenNoYear":0,"TfeControllerOgTest::testBuildMetaDescriptionTruncatesLongSynopsis":0,"TfeControllerOgTest::testBuildMetaDescriptionKeepsShortSynopsis":0,"TfeControllerOgTest::testBuildMetaDescriptionEmptySynopsisReturnsDefault":0,"TfeControllerOgTest::testBuildMetaDescriptionStripsHtmlTags":0,"CryptoTest::testEncryptEmptyStringProducesCiphertext":0,"DatabaseExtendedTest::testEscapeLikeStringViaSearchConditions":0,"DatabaseExtendedTest::testBuildSearchConditionsEmptyParams":0,"DatabaseExtendedTest::testBuildSearchConditionsWithQuery":0,"DatabaseExtendedTest::testBuildSearchConditionsWithYear":0,"DatabaseExtendedTest::testBuildSearchConditionsWithAllFilters":0,"DatabaseExtendedTest::testFindDuplicateThesisExactMatch":0,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentTitle":0,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentYear":0,"DatabaseExtendedTest::testFindDuplicateThesisEmptyAuthorNamesReturnsNull":0,"DatabaseExtendedTest::testFindDuplicateThesisEmptyTable":0,"DatabaseExtendedTest::testFindDuplicateThesisNearDuplicateByLevenshtein":0,"DatabaseExtendedTest::testGenerateThesisIdentifierFirstInYear":0,"DatabaseExtendedTest::testGenerateThesisIdentifierIncrementsCorrectly":0,"DatabaseExtendedTest::testGenerateThesisIdentifierUsesMaxNotCount":0,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsPaths":0,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsEmptyForUnknownIds":0,"DatabaseExtendedTest::testGetCoverPathsForThesesEmptyInputReturnsEmpty":0,"DatabaseExtendedTest::testGetCoverPathsForThesesMultipleTheses":0,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":0,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":0,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":0,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":0,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":0,"DatabaseExtendedTest::testRenameLanguageUpdatesName":0.003,"DatabaseExtendedTest::testMergeLanguageReassignsTheses":0,"DatabaseExtendedTest::testRenameTagUpdatesName":0,"DatabaseExtendedTest::testMergeTagReassignsTheses":0,"RateLimitExtendedTest::testCheckKeyCountsPerKey":0,"RateLimitExtendedTest::testCheckKeyDoesNotAffectDefaultCheck":0,"RateLimitExtendedTest::testGetRemainingDecrements":0,"RateLimitExtendedTest::testGetRemainingAtLimit":0,"RateLimitExtendedTest::testGetRemainingUsesClientIdentifier":0.001,"RateLimitExtendedTest::testCheckUsesConsistentIdentifier":0,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":0,"RateLimitExtendedTest::testGetResetTimeReturnsZeroWhenNoData":0,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":0,"RateLimitExtendedTest::testCleanupRemovesOldFiles":0,"ShareLinkExtendedTest::testListActiveReturnsOnlyActiveLinks":0.173,"ShareLinkExtendedTest::testListArchivedReturnsOnlyArchivedLinks":0.173,"ShareLinkExtendedTest::testFindBySlugHit":0.172,"ShareLinkExtendedTest::testFindBySlugMiss":0,"ShareLinkExtendedTest::testSetPasswordAndDecryptRoundTrip":0.174,"ShareLinkExtendedTest::testGetDecryptedPasswordOnNonexistentId":0,"ShareLinkExtendedTest::testUpdateChangesNameAndExpiration":0.173,"ShareLinkExtendedTest::testUpdateOnlyNameLeavesExpirationUnchanged":0.173,"ShareLinkExtendedTest::testUpdateClearsExpiration":0.173,"ShareLinkExtendedTest::testCreateWithLockedYear":0.173,"ShareLinkExtendedTest::testCreateWithInvalidLockedYearRejected":0.173,"ShareLinkExtendedTest::testUpdateLockedYear":0.174,"ShareLinkExtendedTest::testUpdateClearLockedYear":0.175,"ShareLinkExtendedTest::testIncrementUsage":0.173,"ShareLinkExtendedTest::testCreateDefaultsToTfeWhenInvalidObjet":0.173,"ShareLinkExtendedTest::testCreateAcceptsValidObjet":0.173,"RateLimitExtendedTest::testGetRemainingStartsAtMax":0,"RateLimitExtendedTest::testCheckDecrementsRemainingForSameIp":0,"RateLimitExtendedTest::testCheckAndCheckKeyAreIndependent":0,"RateLimitExtendedTest::testMultipleChecksFromSameClient":0,"AutofocusFieldForErrorTest::testCreateAutofocusTitle":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusAuthors":0,"AutofocusFieldForErrorTest::testCreateAutofocusSynopsis":0,"AutofocusFieldForErrorTest::testCreateAutofocusYear":0,"AutofocusFieldForErrorTest::testCreateAutofocusOrientation":0,"AutofocusFieldForErrorTest::testCreateAutofocusAP":0,"AutofocusFieldForErrorTest::testCreateAutofocusFinality":0,"AutofocusFieldForErrorTest::testCreateAutofocusLanguages":0,"AutofocusFieldForErrorTest::testCreateAutofocusPromoteur":0,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurInterne":0,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurExterne":0,"AutofocusFieldForErrorTest::testCreateAutofocusFormats":0,"AutofocusFieldForErrorTest::testCreateAutofocusLicense":0,"AutofocusFieldForErrorTest::testCreateAutofocusUrl":0,"AutofocusFieldForErrorTest::testCreateAutofocusTags":0,"AutofocusFieldForErrorTest::testCreateAutofocusUnknownErrorReturnsNull":0,"AutofocusFieldForErrorTest::testEditAutofocusTitle":0,"AutofocusFieldForErrorTest::testEditAutofocusYear":0,"AutofocusFieldForErrorTest::testEditAutofocusSynopsis":0,"AutofocusFieldForErrorTest::testEditAutofocusAuthors":0,"AutofocusFieldForErrorTest::testEditAutofocusUnknownErrorReturnsNull":0,"AutofocusFieldForErrorTest::testCreateDoesNotLeakEditFieldNames":0,"ThesisCreateValidationTest::testValidSubmissionReturnsCleanedData":0,"ThesisCreateValidationTest::testMissingTitleThrowsException":0,"ThesisCreateValidationTest::testMissingAuthorsThrowsException":0,"ThesisCreateValidationTest::testMissingSynopsisThrowsException":0,"ThesisCreateValidationTest::testMissingOrientationInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingAPProgramInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingFinalityInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testInvalidYearFormatRejected":0,"ThesisCreateValidationTest::testYearZeroRejected":0,"ThesisCreateValidationTest::testYearBefore2000Rejected":0,"ThesisCreateValidationTest::testFarFutureYearRejected":0,"ThesisCreateValidationTest::testCurrentYearAccepted":0,"ThesisCreateValidationTest::testMalformedUrlRejected":0,"ThesisCreateValidationTest::testValidUrlAccepted":0,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":0,"ThesisCreateValidationTest::testMaxTenKeywordsEnforced":0,"ThesisCreateValidationTest::testXssPayloadStrippedFromTitle":0,"ThesisCreateValidationTest::testHtmlInSynopsisStripped":0,"ThesisCreateValidationTest::testMultipleAuthorsAreSorted":0,"ThesisCreateValidationTest::testMissingPromoteurInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLecteurInterneInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLanguagesInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingFormatsInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLicenseWithLibreAccessThrowsException":0,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":0.001,"ThesisEditValidationTest::testLoadThrowsOnUnknownId":0.001,"ThesisEditValidationTest::testLoadThrowsOnInvalidId":0,"ThesisEditValidationTest::testLoadThrowsOnNegativeId":0,"ThesisEditValidationTest::testCollectJuryMembersEmptyInput":0,"ThesisEditValidationTest::testCollectJuryMembersSinglePromoteur":0,"ThesisEditValidationTest::testCollectJuryMembersPromoteurUlb":0,"ThesisEditValidationTest::testCollectJuryMembersLecteurs":0,"ThesisEditValidationTest::testCollectJuryMembersDeduplicatesEmptyStrings":0,"ThesisEditValidationTest::testCollectJuryMembersScalarPromoteurAccepted":0,"ThesisEditValidationTest::testHandleWebsiteUrlStoresValidUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsInvalidUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsEmptyUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlNormalisesHttp":0,"ErrorHandlerTest::testFkThesesTableMentionsAllPossibleFields":0,"ErrorHandlerTest::testFkApPrograms":0,"ErrorHandlerTest::testFkFinalityTypes":0,"ErrorHandlerTest::testFkThesisLanguages":0,"ErrorHandlerTest::testFkThesisFormats":0,"ErrorHandlerTest::testFkThesisTags":0,"ErrorHandlerTest::testFkThesisSupervisors":0,"ErrorHandlerTest::testFkAccessTypes":0,"ErrorHandlerTest::testFkLicenseTypes":0,"ErrorHandlerTest::testFkAuthors":0,"ErrorHandlerTest::testFkQuotedTableName":0,"ErrorHandlerTest::testFkQuotedLanguages":0,"ErrorHandlerTest::testFkQuotedFormatTypes":0,"ErrorHandlerTest::testFkReferencesTags":0,"ErrorHandlerTest::testFkReferencesOrientations":0,"ErrorHandlerTest::testFkUnknownTableGenericFallback":0,"ErrorHandlerTest::testFkEmptyMessageGenericFallback":0,"ErrorHandlerTest::testUniqueConstraint":0,"ErrorHandlerTest::testNotNullConstraint":0,"ErrorHandlerTest::testGenericPdoError":0,"ErrorHandlerTest::testDuplicateThesisExceptionPassesThrough":0,"ErrorHandlerTest::testValidationExceptionPassesThrough":0,"ErrorHandlerTest::testGenericExceptionPassesThrough":0,"ErrorHandlerTest::testTypeErrorReturnsGeneric":0,"ErrorHandlerTest::testLogWithContext":0,"ErrorHandlerTest::testLogWithNullValues":0,"ErrorHandlerTest::testLogWithEmptyExtra":0,"ErrorHandlerTest::testFkQuotedColumnNames":0,"ErrorHandlerTest::testFkUpdateStatement":0,"ErrorHandlerTest::testFkWithReferencesAndInsert":0,"PureLogicTest::testSplitJuryByRoleAllRoles":0,"PureLogicTest::testSplitJuryByRoleEmptyNameSkipped":0,"PureLogicTest::testSplitJuryByRoleEmptyJury":0,"PureLogicTest::testCollectCaptionPathsVttByMime":0,"PureLogicTest::testCollectCaptionPathsVttByExtension":0,"PureLogicTest::testCollectCaptionPathsNoVttReturnsEmpty":0,"PureLogicTest::testDetectFileTypeByMime":0,"PureLogicTest::testDetectFileTypeByExtensionFallback":0,"SearchControllerTest::testHandleSearchReturnsCoverMapKey":0.002,"SearchControllerTest::testCoverMapContainsKnownThesis":0.001,"DatabaseExtendedTest::testCreateThesisMinimalFields":0.004,"DatabaseExtendedTest::testCreateThesisAllOptionalFields":0,"DatabaseExtendedTest::testCreateThesisGeneratesUniqueIdentifiers":0}} \ 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,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForHexId":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeVideo":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeAudio":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForMixedIds":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForHexInCoverQueue":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeInCoverQueue":8,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForNumericIdsOnly":8,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForEmptyInput":8,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForEmptyStrings":8,"PureLogicTest::testHasFilePondQueueDataHandlesScalarNotArray":8},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0,"CryptoTest::testEncryptDecryptWithUnicode":0,"CryptoTest::testEncryptDecryptMultiline":0,"CryptoTest::testDifferentPlaintextsProduceDifferentCiphertexts":0,"CryptoTest::testSamePlaintextProducesDifferentCiphertexts":0,"CryptoTest::testIsEncryptedRecognizesEncryptedValue":0,"CryptoTest::testIsEncryptedRejectsPlaintext":0,"CryptoTest::testIsEncryptedReturnsFalseForEmptyString":0,"CryptoTest::testIsEncryptedRejectsInvalidBase64":0,"CryptoTest::testEncryptDecryptEmptyString":0.008,"CryptoTest::testDecryptEmptyStringReturnsEmpty":0,"CryptoTest::testDecryptInvalidBase64ReturnsInputGracefully":0,"CryptoTest::testDecryptTooShortBlobReturnsInputGracefully":0,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":0,"CryptoTest::testDecryptValidBlobTamperedTagReturnsEmpty":0,"EmailObfuscatorTest::testEncodeContainsNoAtSign":0,"EmailObfuscatorTest::testEncodeOutputIsNumericEntities":0,"EmailObfuscatorTest::testEmailReturnsObfuscatedAddress":0,"EmailObfuscatorTest::testMailtoBuildsCorrectHrefStructure":0,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":0,"EmailObfuscatorTest::testEmailTextReplacesMultipleEmails":0,"EmailObfuscatorTest::testMailtoInTextReplacesMailtoLinks":0,"EmailObfuscatorTest::testObfuscateHtmlReplacesAnchorTag":0,"EmailObfuscatorTest::testObfuscateHtmlKeepsNonMailtoLinksUnchanged":0,"EmailObfuscatorTest::testObfuscateHtmlPreservesCustomLinkText":0,"EmailObfuscatorTest::testEmptyStringReturnsEmpty":0,"EmailObfuscatorTest::testStringWithNoEmailsIsUnchanged":0,"EmailObfuscatorTest::testAlreadyObfuscatedContentIsNotDoubleEncoded":0,"EmailObfuscatorTest::testMultipleEmailsInOneString":0,"EmailObfuscatorTest::testEmailWithPlusSign":0,"StudentEmailTest::testBuildHtmlReturnsNonEmptyString":0,"StudentEmailTest::testBuildHtmlContainsKeyFields":0,"StudentEmailTest::testBuildHtmlEscapesSpecialCharacters":0,"StudentEmailTest::testBuildHtmlHandlesMissingOptionalFields":0,"StudentEmailTest::testBuildHtmlHandlesNullFieldsGracefully":0,"StudentEmailTest::testBuildHtmlHandlesEmptyArray":0,"StudentEmailTest::testBuildHtmlContainsLabelFields":0,"SystemControllerHelpersTest::testHumanBytesZero":0,"SystemControllerHelpersTest::testHumanBytesBelowOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneMB":0,"SystemControllerHelpersTest::testHumanBytesOneGB":0,"SystemControllerHelpersTest::testHumanBytes1523MB":0,"SystemControllerHelpersTest::testHumanBytes2500GB":0,"SystemControllerHelpersTest::testDiskColorBelowWarning":0,"SystemControllerHelpersTest::testDiskColorWarning":0,"SystemControllerHelpersTest::testDiskColorCritical":0,"SystemControllerHelpersTest::testLogLineClassCrit":0,"SystemControllerHelpersTest::testLogLineClassError":0,"SystemControllerHelpersTest::testLogLineClassWarn":0,"SystemControllerHelpersTest::testLogLineClassNotice":0,"SystemControllerHelpersTest::testLogLineClassHttp500":0,"SystemControllerHelpersTest::testLogLineClassHttp300":0,"SystemControllerHelpersTest::testLogLineClassDefault":0,"SystemControllerHelpersTest::testNginxLineClassComment":0.003,"SystemControllerHelpersTest::testNginxLineClassBlock":0.004,"SystemControllerHelpersTest::testNginxLineClassDirective":0.003,"SystemControllerHelpersTest::testStatusLabelActive":0,"SystemControllerHelpersTest::testStatusLabelInactive":0,"SystemControllerHelpersTest::testStatusLabelFailed":0,"SystemControllerHelpersTest::testStatusLabelWarn":0,"SystemControllerHelpersTest::testStatusLabelUnknown":0,"SystemControllerHelpersTest::testStatusClassOk":0,"SystemControllerHelpersTest::testStatusClassWarn":0,"SystemControllerHelpersTest::testStatusClassError":0,"SystemControllerHelpersTest::testStatusClassUnknown":0,"TfeControllerOgTest::testBuildOgTagsReturnsAllRequiredKeys":0,"TfeControllerOgTest::testBuildOgTagsTitleIncludesAuthors":0,"TfeControllerOgTest::testBuildOgTagsImageEmptyWhenNoFiles":0,"TfeControllerOgTest::testBuildOgTagsImageFromCover":0,"TfeControllerOgTest::testBuildOgTagsImageFallbackToFirstImage":0,"TfeControllerOgTest::testBuildOgTagsUrlIncludesThesisId":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeFormatted":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeEmptyWhenNoYear":0,"TfeControllerOgTest::testBuildMetaDescriptionTruncatesLongSynopsis":0,"TfeControllerOgTest::testBuildMetaDescriptionKeepsShortSynopsis":0,"TfeControllerOgTest::testBuildMetaDescriptionEmptySynopsisReturnsDefault":0,"TfeControllerOgTest::testBuildMetaDescriptionStripsHtmlTags":0,"CryptoTest::testEncryptEmptyStringProducesCiphertext":0,"DatabaseExtendedTest::testEscapeLikeStringViaSearchConditions":0,"DatabaseExtendedTest::testBuildSearchConditionsEmptyParams":0,"DatabaseExtendedTest::testBuildSearchConditionsWithQuery":0,"DatabaseExtendedTest::testBuildSearchConditionsWithYear":0,"DatabaseExtendedTest::testBuildSearchConditionsWithAllFilters":0,"DatabaseExtendedTest::testFindDuplicateThesisExactMatch":0,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentTitle":0,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentYear":0,"DatabaseExtendedTest::testFindDuplicateThesisEmptyAuthorNamesReturnsNull":0,"DatabaseExtendedTest::testFindDuplicateThesisEmptyTable":0,"DatabaseExtendedTest::testFindDuplicateThesisNearDuplicateByLevenshtein":0,"DatabaseExtendedTest::testGenerateThesisIdentifierFirstInYear":0,"DatabaseExtendedTest::testGenerateThesisIdentifierIncrementsCorrectly":0,"DatabaseExtendedTest::testGenerateThesisIdentifierUsesMaxNotCount":0,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsPaths":0,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsEmptyForUnknownIds":0,"DatabaseExtendedTest::testGetCoverPathsForThesesEmptyInputReturnsEmpty":0,"DatabaseExtendedTest::testGetCoverPathsForThesesMultipleTheses":0,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":0,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":0,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":0,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":0,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":0,"DatabaseExtendedTest::testRenameLanguageUpdatesName":0.003,"DatabaseExtendedTest::testMergeLanguageReassignsTheses":0,"DatabaseExtendedTest::testRenameTagUpdatesName":0,"DatabaseExtendedTest::testMergeTagReassignsTheses":0,"RateLimitExtendedTest::testCheckKeyCountsPerKey":0,"RateLimitExtendedTest::testCheckKeyDoesNotAffectDefaultCheck":0,"RateLimitExtendedTest::testGetRemainingDecrements":0,"RateLimitExtendedTest::testGetRemainingAtLimit":0,"RateLimitExtendedTest::testGetRemainingUsesClientIdentifier":0.001,"RateLimitExtendedTest::testCheckUsesConsistentIdentifier":0,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":0,"RateLimitExtendedTest::testGetResetTimeReturnsZeroWhenNoData":0,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":0,"RateLimitExtendedTest::testCleanupRemovesOldFiles":0,"ShareLinkExtendedTest::testListActiveReturnsOnlyActiveLinks":0.173,"ShareLinkExtendedTest::testListArchivedReturnsOnlyArchivedLinks":0.173,"ShareLinkExtendedTest::testFindBySlugHit":0.172,"ShareLinkExtendedTest::testFindBySlugMiss":0,"ShareLinkExtendedTest::testSetPasswordAndDecryptRoundTrip":0.174,"ShareLinkExtendedTest::testGetDecryptedPasswordOnNonexistentId":0,"ShareLinkExtendedTest::testUpdateChangesNameAndExpiration":0.173,"ShareLinkExtendedTest::testUpdateOnlyNameLeavesExpirationUnchanged":0.173,"ShareLinkExtendedTest::testUpdateClearsExpiration":0.173,"ShareLinkExtendedTest::testCreateWithLockedYear":0.173,"ShareLinkExtendedTest::testCreateWithInvalidLockedYearRejected":0.173,"ShareLinkExtendedTest::testUpdateLockedYear":0.174,"ShareLinkExtendedTest::testUpdateClearLockedYear":0.175,"ShareLinkExtendedTest::testIncrementUsage":0.173,"ShareLinkExtendedTest::testCreateDefaultsToTfeWhenInvalidObjet":0.173,"ShareLinkExtendedTest::testCreateAcceptsValidObjet":0.173,"RateLimitExtendedTest::testGetRemainingStartsAtMax":0,"RateLimitExtendedTest::testCheckDecrementsRemainingForSameIp":0,"RateLimitExtendedTest::testCheckAndCheckKeyAreIndependent":0,"RateLimitExtendedTest::testMultipleChecksFromSameClient":0,"AutofocusFieldForErrorTest::testCreateAutofocusTitle":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusAuthors":0,"AutofocusFieldForErrorTest::testCreateAutofocusSynopsis":0,"AutofocusFieldForErrorTest::testCreateAutofocusYear":0,"AutofocusFieldForErrorTest::testCreateAutofocusOrientation":0,"AutofocusFieldForErrorTest::testCreateAutofocusAP":0,"AutofocusFieldForErrorTest::testCreateAutofocusFinality":0,"AutofocusFieldForErrorTest::testCreateAutofocusLanguages":0,"AutofocusFieldForErrorTest::testCreateAutofocusPromoteur":0,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurInterne":0,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurExterne":0,"AutofocusFieldForErrorTest::testCreateAutofocusFormats":0,"AutofocusFieldForErrorTest::testCreateAutofocusLicense":0,"AutofocusFieldForErrorTest::testCreateAutofocusUrl":0,"AutofocusFieldForErrorTest::testCreateAutofocusTags":0,"AutofocusFieldForErrorTest::testCreateAutofocusUnknownErrorReturnsNull":0,"AutofocusFieldForErrorTest::testEditAutofocusTitle":0,"AutofocusFieldForErrorTest::testEditAutofocusYear":0,"AutofocusFieldForErrorTest::testEditAutofocusSynopsis":0,"AutofocusFieldForErrorTest::testEditAutofocusAuthors":0,"AutofocusFieldForErrorTest::testEditAutofocusUnknownErrorReturnsNull":0,"AutofocusFieldForErrorTest::testCreateDoesNotLeakEditFieldNames":0,"ThesisCreateValidationTest::testValidSubmissionReturnsCleanedData":0,"ThesisCreateValidationTest::testMissingTitleThrowsException":0,"ThesisCreateValidationTest::testMissingAuthorsThrowsException":0,"ThesisCreateValidationTest::testMissingSynopsisThrowsException":0,"ThesisCreateValidationTest::testMissingOrientationInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingAPProgramInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingFinalityInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testInvalidYearFormatRejected":0,"ThesisCreateValidationTest::testYearZeroRejected":0,"ThesisCreateValidationTest::testYearBefore2000Rejected":0,"ThesisCreateValidationTest::testFarFutureYearRejected":0,"ThesisCreateValidationTest::testCurrentYearAccepted":0,"ThesisCreateValidationTest::testMalformedUrlRejected":0,"ThesisCreateValidationTest::testValidUrlAccepted":0,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":0,"ThesisCreateValidationTest::testMaxTenKeywordsEnforced":0,"ThesisCreateValidationTest::testXssPayloadStrippedFromTitle":0,"ThesisCreateValidationTest::testHtmlInSynopsisStripped":0,"ThesisCreateValidationTest::testMultipleAuthorsAreSorted":0,"ThesisCreateValidationTest::testMissingPromoteurInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLecteurInterneInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLanguagesInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingFormatsInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLicenseWithLibreAccessThrowsException":0,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":0.002,"ThesisEditValidationTest::testLoadThrowsOnUnknownId":0,"ThesisEditValidationTest::testLoadThrowsOnInvalidId":0,"ThesisEditValidationTest::testLoadThrowsOnNegativeId":0,"ThesisEditValidationTest::testCollectJuryMembersEmptyInput":0,"ThesisEditValidationTest::testCollectJuryMembersSinglePromoteur":0,"ThesisEditValidationTest::testCollectJuryMembersPromoteurUlb":0,"ThesisEditValidationTest::testCollectJuryMembersLecteurs":0,"ThesisEditValidationTest::testCollectJuryMembersDeduplicatesEmptyStrings":0,"ThesisEditValidationTest::testCollectJuryMembersScalarPromoteurAccepted":0,"ThesisEditValidationTest::testHandleWebsiteUrlStoresValidUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsInvalidUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsEmptyUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlNormalisesHttp":0,"ErrorHandlerTest::testFkThesesTableMentionsAllPossibleFields":0,"ErrorHandlerTest::testFkApPrograms":0,"ErrorHandlerTest::testFkFinalityTypes":0,"ErrorHandlerTest::testFkThesisLanguages":0,"ErrorHandlerTest::testFkThesisFormats":0,"ErrorHandlerTest::testFkThesisTags":0,"ErrorHandlerTest::testFkThesisSupervisors":0,"ErrorHandlerTest::testFkAccessTypes":0,"ErrorHandlerTest::testFkLicenseTypes":0,"ErrorHandlerTest::testFkAuthors":0,"ErrorHandlerTest::testFkQuotedTableName":0,"ErrorHandlerTest::testFkQuotedLanguages":0,"ErrorHandlerTest::testFkQuotedFormatTypes":0,"ErrorHandlerTest::testFkReferencesTags":0,"ErrorHandlerTest::testFkReferencesOrientations":0,"ErrorHandlerTest::testFkUnknownTableGenericFallback":0,"ErrorHandlerTest::testFkEmptyMessageGenericFallback":0,"ErrorHandlerTest::testUniqueConstraint":0,"ErrorHandlerTest::testNotNullConstraint":0,"ErrorHandlerTest::testGenericPdoError":0,"ErrorHandlerTest::testDuplicateThesisExceptionPassesThrough":0,"ErrorHandlerTest::testValidationExceptionPassesThrough":0,"ErrorHandlerTest::testGenericExceptionPassesThrough":0,"ErrorHandlerTest::testTypeErrorReturnsGeneric":0,"ErrorHandlerTest::testLogWithContext":0,"ErrorHandlerTest::testLogWithNullValues":0,"ErrorHandlerTest::testLogWithEmptyExtra":0,"ErrorHandlerTest::testFkQuotedColumnNames":0,"ErrorHandlerTest::testFkUpdateStatement":0,"ErrorHandlerTest::testFkWithReferencesAndInsert":0,"PureLogicTest::testSplitJuryByRoleAllRoles":0.004,"PureLogicTest::testSplitJuryByRoleEmptyNameSkipped":0,"PureLogicTest::testSplitJuryByRoleEmptyJury":0,"PureLogicTest::testCollectCaptionPathsVttByMime":0,"PureLogicTest::testCollectCaptionPathsVttByExtension":0,"PureLogicTest::testCollectCaptionPathsNoVttReturnsEmpty":0,"PureLogicTest::testDetectFileTypeByMime":0.001,"PureLogicTest::testDetectFileTypeByExtensionFallback":0,"SearchControllerTest::testHandleSearchReturnsCoverMapKey":0.002,"SearchControllerTest::testCoverMapContainsKnownThesis":0.001,"DatabaseExtendedTest::testCreateThesisMinimalFields":0.004,"DatabaseExtendedTest::testCreateThesisAllOptionalFields":0,"DatabaseExtendedTest::testCreateThesisGeneratesUniqueIdentifiers":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForHexId":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeVideo":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeAudio":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForMixedIds":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForHexInCoverQueue":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeInCoverQueue":0,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForNumericIdsOnly":0,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForEmptyInput":0,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForEmptyStrings":0,"PureLogicTest::testHasFilePondQueueDataHandlesScalarNotArray":0,"ThesisEditValidationTest::testHandleWebsiteUrlPreservesExistingRowWhenUrlUnchanged":0,"ThesisEditValidationTest::testHandleWebsiteUrlPreservesLabelWhenNotProvided":0,"ThesisEditValidationTest::testHandleWebsiteUrlUpdatesLabelWhenProvided":0,"ThesisEditValidationTest::testHandleWebsiteUrlDeletesRowWhenUrlExplicitlyCleared":0.002,"ThesisEditValidationTest::testHandleWebsiteUrlUpdatesUrlWhenChanged":0,"ThesisEditValidationTest::testHandleWebsiteUrlNoExistingRowEmptyUrlDoesNothing":0}} \ No newline at end of file diff --git a/TODO.md b/TODO.md index eb6ba24..c050c09 100644 --- a/TODO.md +++ b/TODO.md @@ -7,3 +7,9 @@ - [x] Fix PeerTube upload failure: PHP /tmp tmpfs too small for large video files - [x] Fix keyword word-breaking in repertoire.php: widen kw grid column, add white-space: nowrap - [x] Fix licence.php TOC: restore `open` attribute on `
` (lost in vrxsstns, never restored by uuomvtvm fix) +- [x] Fix hasFilePondQueueData() missing peertube: prefix check → new PeerTube uploads silently lost on edit +- [x] Fix handleWebsiteUrl() in ThesisEditController unconditionally deleting website rows on every edit +- [x] Fix FilePond server.remove potentially triggering during HTMX fragment teardown +- [x] Add restore-from-corbeille functionality in cleanup page +- [x] Fix cleanup stats: use sidecar JSON for restorability classification (not DB row existence) +- [x] Remove destructive HTMX fragment refresh from relink flow (close modal only, pond.addFile in-place) diff --git a/app/public/admin/actions/_cleanup-stats-data.php b/app/public/admin/actions/_cleanup-stats-data.php index 288eb3c..ebd00ab 100644 --- a/app/public/admin/actions/_cleanup-stats-data.php +++ b/app/public/admin/actions/_cleanup-stats-data.php @@ -120,17 +120,12 @@ function getCleanupStats(): array $db = new Database(); $pdo = $db->getPDO(); - $existingFileIds = []; - $stmt = $pdo->query('SELECT id FROM thesis_files'); - while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { - $existingFileIds[(int)$row['id']] = true; - } - $trStaleCount = 0; $trStaleSize = 0; $trStaleFiles = []; $trActiveCount = 0; $trActiveSize = 0; + $trActiveFiles = []; if (is_dir($trashDir)) { $items = @scandir($trashDir); @@ -139,6 +134,10 @@ function getCleanupStats(): array if ($item === '.' || $item === '..') { continue; } + // Skip JSON sidecar files — they're listed alongside their parent. + if (str_ends_with($item, '.json')) { + continue; + } $filePath = $trashDir . '/' . $item; if (!is_file($filePath)) { continue; @@ -148,20 +147,37 @@ function getCleanupStats(): array $mtime = filemtime($filePath); $ageDays = (int)(($now - $mtime) / 86400); - $stale = false; - - if (preg_match('/^(\d+)_/', $item, $m)) { - $dbId = (int)$m[1]; - if (!isset($existingFileIds[$dbId])) { - $stale = true; + // Check for sidecar metadata — files with sidecars are restorable + // regardless of whether the thesis_files DB row still exists. + $sidecarPath = $filePath . '.json'; + $hasSidecar = file_exists($sidecarPath); + $sidecarData = null; + if ($hasSidecar) { + $sidecarData = json_decode(file_get_contents($sidecarPath), true); + if (!is_array($sidecarData)) { + $hasSidecar = false; + $sidecarData = null; } } - if (!$stale && $ageDays > ($maxAgeTrash / 86400)) { - $stale = true; - } + // Restorable = has a valid sidecar AND is younger than max age + $restorable = $hasSidecar && $ageDays <= ($maxAgeTrash / 86400); - if ($stale) { + if ($restorable) { + $trActiveCount++; + $trActiveSize += $size; + $trActiveFiles[] = [ + 'name' => $item, + 'size' => $size, + 'human' => humanBytes($size), + 'age_days' => $ageDays, + 'has_sidecar' => true, + 'thesis_id' => $sidecarData['thesis_id'] ?? null, + 'file_type' => $sidecarData['file_type'] ?? '?', + 'original_name' => $sidecarData['file_name'] ?? '', + 'original_path' => $sidecarData['file_path'] ?? '', + ]; + } else { $trStaleCount++; $trStaleSize += $size; $trStaleFiles[] = [ @@ -170,9 +186,6 @@ function getCleanupStats(): array 'human' => humanBytes($size), 'age_days' => $ageDays, ]; - } else { - $trActiveCount++; - $trActiveSize += $size; } } } @@ -193,5 +206,6 @@ function getCleanupStats(): array 'trash_active_count' => $trActiveCount, 'trash_active_size' => $trActiveSize, 'trash_active_human' => humanBytes($trActiveSize), + 'trash_active_files' => $trActiveFiles, ]; } diff --git a/app/public/admin/actions/cleanup-stats-fragment.php b/app/public/admin/actions/cleanup-stats-fragment.php index 597d07f..d8462b5 100644 --- a/app/public/admin/actions/cleanup-stats-fragment.php +++ b/app/public/admin/actions/cleanup-stats-fragment.php @@ -92,7 +92,7 @@ if ($trStale > 0) { 0): ?> -

Corbeille

+

Corbeille (à nettoyer)

@@ -120,9 +120,62 @@ if ($trStale > 0) {
NomTailleÂge
- 0 || $trActive > 0): ?> + 1 ? 's' : ''); + if ($trActive > 0) { + $trActiveMeta .= ' · ' . ($d['trash_active_human'] ?? ''); + } +?> +

Corbeille (restaurable)

+

+ Fichiers récemment supprimés pour lesquels le TFE associé existe encore. Vous pouvez les restaurer ou les supprimer définitivement. +

+ + + + + + + + + + + + + +
NomTailleÂgeOrigine
~ j + + + + + + +
+ + + 0): ?>

Conservés : - - +

diff --git a/app/public/admin/actions/restore-trash.php b/app/public/admin/actions/restore-trash.php new file mode 100644 index 0000000..2cb0777 --- /dev/null +++ b/app/public/admin/actions/restore-trash.php @@ -0,0 +1,157 @@ + false, 'error' => 'Méthode non autorisée.']); + exit; +} + +if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) + || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { + http_response_code(403); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'CSRF invalide.']); + exit; +} + +$trashFile = trim($_POST['trash_file'] ?? ''); +if ($trashFile === '') { + http_response_code(400); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'Nom de fichier invalide.']); + exit; +} + +$storageRoot = STORAGE_ROOT; +$trashDir = $storageRoot . '/tmp/_trash'; +$trashPath = $trashDir . '/' . basename($trashFile); +$sidecarPath = $trashPath . '.json'; + +// Validate the trash file is inside the trash directory +if (!is_file($trashPath) || !str_starts_with(realpath($trashPath), realpath($trashDir))) { + http_response_code(404); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'Fichier introuvable dans la corbeille.']); + exit; +} + +// Read sidecar metadata +if (!file_exists($sidecarPath)) { + http_response_code(400); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => false, + 'error' => 'Métadonnées de restauration absentes (fichier trop ancien ?). Utilisez "Relier un fichier existant".', + ]); + exit; +} + +$sidecar = json_decode(file_get_contents($sidecarPath), true); +if (!is_array($sidecar) || empty($sidecar['file_path']) || empty($sidecar['thesis_id'])) { + http_response_code(400); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'Métadonnées corrompues.']); + exit; +} + +$originalPath = $sidecar['file_path']; +$thesisId = (int)$sidecar['thesis_id']; +$fileType = $sidecar['file_type'] ?? 'other'; +$fileName = $sidecar['file_name'] ?? basename($originalPath); +$mimeType = $sidecar['mime_type'] ?? 'application/octet-stream'; +$fileSize = (int)($sidecar['file_size'] ?? 0); +$displayLabel = $sidecar['display_label'] ?? null; + +// Verify the thesis still exists +require_once __DIR__ . '/../../../src/Database.php'; +$db = new Database(); +$thesis = $db->getThesis($thesisId); +if (!$thesis) { + http_response_code(404); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'Le TFE associé n\'existe plus.']); + exit; +} + +// Check no file already exists at the original path +$absOriginal = $storageRoot . '/' . $originalPath; +if (file_exists($absOriginal)) { + // File already restored or a new file replaced it — just clean up the trash + @unlink($trashPath); + @unlink($sidecarPath); + http_response_code(409); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => false, + 'error' => 'Un fichier existe déjà à l\'emplacement d\'origine. Fichier corbeille nettoyé.', + ]); + exit; +} + +// Ensure parent directory exists +$parentDir = dirname($absOriginal); +if (!is_dir($parentDir)) { + mkdir($parentDir, 0755, true); +} + +// Move file back from trash to original location +if (!rename($trashPath, $absOriginal)) { + http_response_code(500); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'Échec du déplacement du fichier.']); + exit; +} +chmod($absOriginal, 0644); + +// Delete the sidecar file +@unlink($sidecarPath); + +// Re-insert the thesis_files DB row +$db->insertThesisFile( + $thesisId, + $fileType, + $originalPath, + $fileName, + $fileSize, + $mimeType, + $displayLabel, + null +); + +$newId = $db->getConnection()->lastInsertId(); + +error_log("[restore-trash] thesis_id=$thesisId file_id=$newId restored from trash: $trashFile → $originalPath"); + +$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + +// HTMX request: re-render the fragment +if (isset($_SERVER['HTTP_HX_REQUEST'])) { + header('HX-Trigger: refreshStats'); + require __DIR__ . '/cleanup-stats-fragment.php'; + exit; +} + +header('Content-Type: application/json; charset=utf-8'); +echo json_encode([ + 'ok' => true, + 'id' => $newId, + 'thesis_id' => $thesisId, + 'message' => 'Fichier restauré avec succès.', +]); +exit; diff --git a/app/public/assets/js/app/file-upload-filepond.js b/app/public/assets/js/app/file-upload-filepond.js index c07ec6d..6f93087 100644 --- a/app/public/assets/js/app/file-upload-filepond.js +++ b/app/public/assets/js/app/file-upload-filepond.js @@ -321,6 +321,15 @@ remove: (source, load, error) => { console.log(`[filepond] remove called | id=${source}`); + // During teardown (HTMX swap), skip server round-trips. + // FilePond 4.x destroy() does not normally fire remove callbacks, + // but this guard prevents accidental deletion of DB files as a + // defence-in-depth measure. + if (_xamxamTeardown) { + console.log(`[filepond] remove skipped (teardown) | id=${source}`); + load(); + return; + } // Hex IDs (32 chars) → temp files → use revert endpoint if (/^[a-f0-9]{32}$/.test(source)) { fetch(`${base}/revert.php`, { @@ -519,6 +528,13 @@ }); }; + /** + * Guard flag set during destroyFilePondsIn — the server.remove callback + * checks this and skips the server round-trip during teardown to prevent + * accidental deletion of existing DB files from FilePond.destroy(). + */ + let _xamxamTeardown = false; + /** * Destroy FilePond instances inside a given container element. * Generic: handles ANY HTMX swap target, not just known IDs. @@ -563,8 +579,12 @@ } catch (_abort) {} } } + _xamxamTeardown = true; pond.destroy(); - } catch (_) {} + _xamxamTeardown = false; + } catch (_) { + _xamxamTeardown = false; + } } }); } @@ -885,24 +905,9 @@ " | found=" + !!input, ); - var closeAndRefresh = () => { + var closeModal = () => { var modal = document.getElementById("relink-modal"); if (modal) modal.close(); - // Re-fetch the fichiers fragment from the server so the - // newly-linked file appears in the FilePond pools. - var block = document.getElementById("format-fichiers-block"); - if (block && window.htmx) { - let url = "/admin/fragments/fichiers.php"; - if (window.__xamxamRelinkCtx?.thesisId) { - url += - "?_thesis_id=" + - encodeURIComponent(window.__xamxamRelinkCtx.thesisId); - } - htmx.ajax("GET", url, { - target: "#format-fichiers-block", - swap: "outerHTML", - }); - } }; if (input) { const pond = FilePond.find(input); @@ -924,25 +929,25 @@ " | queueType=" + queueType, ); - closeAndRefresh(); + closeModal(); }) .catch((err) => { console.error("[relink] addFile rejected", err); - closeAndRefresh(); + closeModal(); }); } else { console.error( "[relink] FilePond.find returned null for input", input, ); - closeAndRefresh(); + closeModal(); } } else { console.warn( "[relink] input not found, page may have reloaded | queueType=" + queueType, ); - closeAndRefresh(); + closeModal(); } // Mark form dirty @@ -1008,18 +1013,9 @@ var input = document.querySelector( '.tfe-file-picker[data-queue-type="tfe"]', ); - var closeAndRefresh = () => { + var closeModal = () => { var modal = document.getElementById("peertube-relink-modal"); if (modal) modal.close(); - var block = document.getElementById("format-fichiers-block"); - if (block && window.htmx) { - var url = "/admin/fragments/fichiers.php"; - if (thesisId) url += `?_thesis_id=${encodeURIComponent(thesisId)}`; - htmx.ajax("GET", url, { - target: "#format-fichiers-block", - swap: "outerHTML", - }); - } }; if (input) { var pond = FilePond.find(input); @@ -1035,19 +1031,19 @@ }) .then(() => { console.log("[pt-relink] addFile resolved"); - closeAndRefresh(); + closeModal(); }) .catch((err) => { console.error("[pt-relink] addFile rejected", err); - closeAndRefresh(); + closeModal(); }); } else { console.error("[pt-relink] FilePond.find returned null"); - closeAndRefresh(); + closeModal(); } } else { console.warn("[pt-relink] input not found"); - closeAndRefresh(); + closeModal(); } window.__xamxamDirty = true; diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 8ee814b..3175b31 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -627,15 +627,25 @@ class ThesisEditController { $websiteUrl = trim($post['website_url'] ?? ''); - // Remove existing website rows (website URLs have no disk file) + // Find existing website row (if any) $existingFiles = $this->db->getThesisFiles($thesisId); + $existingWebsiteRow = null; foreach ($existingFiles as $f) { if ($f['file_type'] === 'website') { - $this->db->deleteThesisFile((int)$f['id'], $thesisId); + $existingWebsiteRow = $f; + break; } } + // No URL provided and no existing row → nothing to do. + if ($websiteUrl === '' && $existingWebsiteRow === null) { + return; + } + + // URL explicitly cleared → delete existing website row. if ($websiteUrl === '') { + $this->db->deleteThesisFile((int)$existingWebsiteRow['id'], $thesisId); + error_log('ThesisEditController: website removed (explicitly cleared)'); return; } @@ -650,16 +660,32 @@ class ThesisEditController $sortOrder = isset($post['website_order']) ? (int)$post['website_order'] : null; $fileName = rtrim(preg_replace('#^https?://#i', '', $websiteUrl), '/'); - $this->db->insertThesisFile( - $thesisId, - 'website', - $websiteUrl, - $fileName, - 0, - 'text/html', - $label !== '' ? $label : null, - $sortOrder - ); - error_log("ThesisEditController: website stored → $websiteUrl"); + if ($existingWebsiteRow !== null) { + // Preserve existing label if no new label is provided. + if ($label === '' && !empty($existingWebsiteRow['display_label'])) { + $label = $existingWebsiteRow['display_label']; + } + // Update existing row in-place instead of delete + re-insert. + $this->db->updateThesisWebsiteUrl( + (int)$existingWebsiteRow['id'], + $thesisId, + $websiteUrl, + $fileName, + $label !== '' ? $label : null + ); + error_log("ThesisEditController: website updated → $websiteUrl"); + } else { + $this->db->insertThesisFile( + $thesisId, + 'website', + $websiteUrl, + $fileName, + 0, + 'text/html', + $label !== '' ? $label : null, + $sortOrder + ); + error_log("ThesisEditController: website stored → $websiteUrl"); + } } } diff --git a/app/src/Controllers/ThesisFileHandler.php b/app/src/Controllers/ThesisFileHandler.php index 3ce6bd3..349377a 100644 --- a/app/src/Controllers/ThesisFileHandler.php +++ b/app/src/Controllers/ThesisFileHandler.php @@ -872,7 +872,9 @@ trait ThesisFileHandler $ids = is_array($raw) ? $raw : [$raw]; foreach ($ids as $id) { $id = is_string($id) ? trim($id) : ''; - if ($id !== '' && preg_match('/^[a-f0-9]{32}$/', $id)) { + // 32-char hex IDs = regular FilePond async uploads. + // peertube: prefix = video/audio uploaded to PeerTube via process.php. + if ($id !== '' && (preg_match('/^[a-f0-9]{32}$/', $id) || str_starts_with($id, 'peertube:'))) { return true; } } @@ -1299,6 +1301,21 @@ trait ThesisFileHandler @copy($abs, $trashPath); @unlink($abs); } + // Save metadata sidecar for potential restore + $sidecar = [ + 'thesis_id' => $thesisId, + 'file_id' => $fileId, + 'file_type' => $fileRow['file_type'] ?? 'other', + 'file_path' => $filePath, + 'file_name' => $fileRow['file_name'] ?? basename($filePath), + 'mime_type' => $fileRow['mime_type'] ?? 'application/octet-stream', + 'file_size' => $fileRow['file_size'] ?? 0, + 'deleted_at' => date('c'), + ]; + @file_put_contents( + $trashPath . '.json', + json_encode($sidecar, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); error_log("ThesisFileHandler: file {$fileId} moved to trash → {$trashName}"); } } diff --git a/app/src/Database.php b/app/src/Database.php index ebdc1fa..593f5c6 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -2565,6 +2565,25 @@ class Database )->execute([$label ?: null, $fileId, $thesisId]); } + /** + * Update an existing website-type thesis_files row in-place. + * + * Avoids the delete-then-insert pattern that loses the row ID and + * any associated metadata. Only updates the URL, derived filename, + * and optional display label. + */ + public function updateThesisWebsiteUrl( + int $fileId, + int $thesisId, + string $url, + string $fileName, + ?string $label + ): void { + $this->pdo->prepare( + 'UPDATE thesis_files SET file_path = ?, file_name = ?, display_label = ? WHERE id = ? AND thesis_id = ?' + )->execute([$url, $fileName, $label ?: null, $fileId, $thesisId]); + } + /** * Delete a single thesis file record by its ID and optionally remove the * file from disk. Returns the file_path that was deleted (or null if not diff --git a/app/src/FilepondHandler.php b/app/src/FilepondHandler.php index 2f11d2b..e795928 100644 --- a/app/src/FilepondHandler.php +++ b/app/src/FilepondHandler.php @@ -332,6 +332,12 @@ class FilepondHandler } $filePath = $fileRow['file_path'] ?? ''; + $thesisId = $fileRow['thesis_id'] ?? 0; + $fileType = $fileRow['file_type'] ?? 'other'; + $fileName = $fileRow['file_name'] ?? basename($filePath); + $mimeType = $fileRow['mime_type'] ?? 'application/octet-stream'; + $fileSize = $fileRow['file_size'] ?? 0; + if ($filePath !== '' && !str_starts_with($filePath, 'peertube_ids:') && !str_starts_with($filePath, 'http://') @@ -345,6 +351,21 @@ class FilepondHandler } $trashPath = $trashDir . '/' . $dbId . '_' . basename($filePath); rename($absPath, $trashPath); + // Save metadata sidecar for potential restore + $sidecar = [ + 'thesis_id' => (int)$thesisId, + 'file_id' => $dbId, + 'file_type' => $fileType, + 'file_path' => $filePath, + 'file_name' => $fileName, + 'mime_type' => $mimeType, + 'file_size' => (int)$fileSize, + 'deleted_at' => date('c'), + ]; + @file_put_contents( + $trashPath . '.json', + json_encode($sidecar, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); } } diff --git a/tests/phpunit/PureLogicTest.php b/tests/phpunit/PureLogicTest.php index 7916f9f..b98616e 100644 --- a/tests/phpunit/PureLogicTest.php +++ b/tests/phpunit/PureLogicTest.php @@ -38,6 +38,17 @@ class PureLogicTest extends TestCase }; } + /** + * Invoke the private hasFilePondQueueData() method via reflection. + * The method lives in the ThesisFileHandler trait, used by ThesisCreateController. + */ + private function invokeHasFilePondQueueData(array $post): bool + { + $ctrl = $this->getThesisCreateController(); + $ref = new ReflectionMethod(ThesisCreateController::class, 'hasFilePondQueueData'); + return $ref->invoke($ctrl, $post); + } + // ── splitJuryByRole ────────────────────────────────────────────────────── public function testSplitJuryByRoleAllRoles(): void @@ -150,4 +161,82 @@ class PureLogicTest extends TestCase $this->assertSame('image', $ctrl->exposedDetectFileType('application/octet-stream', 'webp')); $this->assertSame('caption', $ctrl->exposedDetectFileType('application/octet-stream', 'vtt')); } + + // ── hasFilePondQueueData ──────────────────────────────────────────────── + + public function testHasFilePondQueueDataReturnsTrueForHexId(): void + { + $post = ['queue_file' => ['tfe' => ['abc123def456abc123def456abc123de']]]; + $this->assertTrue($this->invokeHasFilePondQueueData($post)); + } + + public function testHasFilePondQueueDataReturnsTrueForPeertubeVideo(): void + { + $post = ['queue_file' => ['tfe' => ['peertube:video:bmpQZTUPv4ou8ufiwajV63']]]; + $this->assertTrue( + $this->invokeHasFilePondQueueData($post), + 'peertube:video:UUID should be detected as FilePond data' + ); + } + + public function testHasFilePondQueueDataReturnsTrueForPeertubeAudio(): void + { + $post = ['queue_file' => ['tfe' => ['peertube:audio:xyz123']]]; + $this->assertTrue( + $this->invokeHasFilePondQueueData($post), + 'peertube:audio:UUID should be detected as FilePond data' + ); + } + + public function testHasFilePondQueueDataReturnsTrueForMixedIds(): void + { + $post = ['queue_file' => ['tfe' => ['123', 'peertube:video:abc123', '456']]]; + $this->assertTrue( + $this->invokeHasFilePondQueueData($post), + 'Mixed array containing a peertube: ID should be detected' + ); + } + + public function testHasFilePondQueueDataReturnsTrueForHexInCoverQueue(): void + { + $post = ['queue_file' => ['cover' => ['abcdef1234567890abcdef1234567890']]]; + $this->assertTrue($this->invokeHasFilePondQueueData($post)); + } + + public function testHasFilePondQueueDataReturnsTrueForPeertubeInCoverQueue(): void + { + $post = ['queue_file' => ['cover' => ['peertube:video:uuid1']]]; + $this->assertTrue($this->invokeHasFilePondQueueData($post)); + } + + public function testHasFilePondQueueDataReturnsFalseForNumericIdsOnly(): void + { + $post = ['queue_file' => ['tfe' => ['123', '456']]]; + $this->assertFalse($this->invokeHasFilePondQueueData($post)); + } + + public function testHasFilePondQueueDataReturnsFalseForEmptyInput(): void + { + $this->assertFalse($this->invokeHasFilePondQueueData([])); + $this->assertFalse($this->invokeHasFilePondQueueData(['queue_file' => []])); + $this->assertFalse($this->invokeHasFilePondQueueData(['queue_file' => ['tfe' => []]])); + } + + public function testHasFilePondQueueDataReturnsFalseForEmptyStrings(): void + { + $post = ['queue_file' => ['tfe' => ['', ' ']]]; + $this->assertFalse($this->invokeHasFilePondQueueData($post)); + } + + public function testHasFilePondQueueDataHandlesScalarNotArray(): void + { + $post = ['queue_file' => ['tfe' => 'peertube:video:singleUuid']]; + $this->assertTrue($this->invokeHasFilePondQueueData($post)); + + $post2 = ['queue_file' => ['tfe' => 'abc123def456abc123def456abc123de']]; + $this->assertTrue($this->invokeHasFilePondQueueData($post2)); + + $post3 = ['queue_file' => ['tfe' => '123']]; + $this->assertFalse($this->invokeHasFilePondQueueData($post3)); + } } diff --git a/tests/phpunit/ThesisEditValidationTest.php b/tests/phpunit/ThesisEditValidationTest.php index 844bc9c..e28abb2 100644 --- a/tests/phpunit/ThesisEditValidationTest.php +++ b/tests/phpunit/ThesisEditValidationTest.php @@ -193,4 +193,120 @@ class ThesisEditValidationTest extends TestCase $file = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetch(); $this->assertStringContainsString('example.com/path', $file['file_name']); } + + // ── handleWebsiteUrl regression: existing rows preserved (not deleted-then-recreated) ─ + + public function testHandleWebsiteUrlPreservesExistingRowWhenUrlUnchanged(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Keep Site', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + // Seed an existing website row + $pdo->prepare( + "INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label) + VALUES (?, 'website', 'https://old.example.com', 'old.example.com', 0, 'text/html', 'Old Label')" + )->execute([$thesisId]); + $oldId = (int)$pdo->lastInsertId(); + + // Submit the SAME URL (no change intended) + $post = ['website_url' => 'https://old.example.com']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + // The row should still exist with the same ID and (crucially) preserved label + $row = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetch(); + $this->assertNotFalse($row, 'Website row should still exist'); + $this->assertSame($oldId, (int)$row['id'], 'Row ID should be preserved (not delete+reinsert)'); + $this->assertSame('Old Label', $row['display_label'], 'Label should be preserved when no new label is given'); + $this->assertSame('https://old.example.com', $row['file_path']); + } + + public function testHandleWebsiteUrlPreservesLabelWhenNotProvided(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Label Preserve', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + $pdo->prepare( + "INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label) + VALUES (?, 'website', 'https://example.com', 'example.com', 0, 'text/html', 'My Custom Label')" + )->execute([$thesisId]); + + // Submit URL without a label + $post = ['website_url' => 'https://example.com', 'website_label' => '']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + $row = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetch(); + $this->assertSame('My Custom Label', $row['display_label'], 'Existing label should survive when no new label is provided'); + } + + public function testHandleWebsiteUrlUpdatesLabelWhenProvided(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Label Update', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + $pdo->prepare( + "INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label) + VALUES (?, 'website', 'https://example.com', 'example.com', 0, 'text/html', 'Old Label')" + )->execute([$thesisId]); + + $post = ['website_url' => 'https://example.com', 'website_label' => 'New Label']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + $row = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetch(); + $this->assertSame('New Label', $row['display_label']); + } + + public function testHandleWebsiteUrlDeletesRowWhenUrlExplicitlyCleared(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Clear Site', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + $pdo->prepare( + "INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label) + VALUES (?, 'website', 'https://example.com', 'example.com', 0, 'text/html', 'Label')" + )->execute([$thesisId]); + + // Explicitly clear the URL + $post = ['website_url' => '']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + $count = $pdo->query("SELECT COUNT(*) FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetchColumn(); + $this->assertSame(0, (int)$count, 'Website row should be deleted when URL is explicitly cleared'); + } + + public function testHandleWebsiteUrlUpdatesUrlWhenChanged(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Change URL', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + $pdo->prepare( + "INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label) + VALUES (?, 'website', 'https://old.example.com', 'old.example.com', 0, 'text/html', 'Label')" + )->execute([$thesisId]); + $oldId = (int)$pdo->lastInsertId(); + + $post = ['website_url' => 'https://new.example.com']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + $row = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetch(); + $this->assertSame($oldId, (int)$row['id'], 'Row ID should be preserved on URL update'); + $this->assertSame('https://new.example.com', $row['file_path']); + $this->assertSame('Label', $row['display_label'], 'Label should be preserved on URL-only change'); + } + + public function testHandleWebsiteUrlNoExistingRowEmptyUrlDoesNothing(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Noop', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + // Should not error even with no existing row + $post = ['website_url' => '']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + $count = $pdo->query("SELECT COUNT(*) FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetchColumn(); + $this->assertSame(0, (int)$count); + + // All other files should still be intact + $totalFiles = $pdo->query("SELECT COUNT(*) FROM thesis_files WHERE thesis_id = $thesisId")->fetchColumn(); + $this->assertGreaterThan(0, (int)$totalFiles, 'Cover file from seeding should still exist'); + } }