From d588ae004d459fc73a308ace36ca1aae9d5c002c Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Thu, 11 Jun 2026 13:05:37 +0200 Subject: [PATCH] Reintroduce TFE duration metadata: DB columns, form fields, controllers, views, and migration Add 'unsafe-eval' to CSP script-src directives (htmx requires Function()) --- .phpunit.result.cache | 2 +- README.md | 10 +- TODO.md | 14 +- .../applied/040_duration_fields.sql | 80 +++++ app/public/admin/actions/acces-etudiante.php | 11 +- app/public/admin/actions/access-request.php | 1 - app/public/admin/actions/account.php | 2 - app/public/admin/actions/apropos.php | 1 - app/public/admin/actions/corbeille.php | 2 - app/public/admin/actions/delete.php | 2 - app/public/admin/actions/draft.php | 93 ++++++ app/public/admin/actions/edit.php | 6 +- app/public/admin/actions/filepond/relink.php | 1 - app/public/admin/actions/form-help.php | 1 - app/public/admin/actions/formulaire.php | 8 +- app/public/admin/actions/language.php | 1 - app/public/admin/actions/maintenance.php | 1 - app/public/admin/actions/page.php | 1 - app/public/admin/actions/publish.php | 2 - app/public/admin/actions/settings.php | 11 - app/public/admin/actions/smtp-test.php | 1 - app/public/admin/actions/tag.php | 1 - app/public/admin/actions/visibility.php | 1 - app/public/admin/contenus-edit.php | 10 +- app/public/admin/edit.php | 10 +- app/public/admin/fragments/file-browser.php | 2 - app/public/admin/index.php | 1 - app/public/admin/login.php | 9 +- app/public/admin/recapitulatif.php | 5 +- app/public/admin/toast-fragment.php | 8 +- app/public/assets/css/admin.css | 6 +- app/public/assets/css/components/details.css | 27 +- app/public/assets/css/components/forms.css | 3 +- app/public/assets/css/file-access.css | 2 +- app/public/assets/css/form-admin.css | 65 +++++ app/public/assets/css/form-base.css | 23 +- app/public/assets/css/repertoire.css | 24 +- app/public/assets/css/tfe.css | 2 +- app/public/assets/js/app/autosave-handler.js | 19 +- app/public/partage/index.php | 16 +- app/public/partage/recapitulatif.php | 3 +- app/src/AdminAuth.php | 65 +++-- app/src/Controllers/CharteController.php | 5 + app/src/Controllers/LicenceController.php | 5 + .../Controllers/ThesisCreateController.php | 22 +- app/src/Controllers/ThesisEditController.php | 6 + app/src/Database.php | 24 +- app/src/DatabaseMigrations.php | 92 ++++++ app/src/FilepondHandler.php | 2 +- app/src/Form/FormBootstrap.php | 39 ++- app/src/MarkdownHelper.php | 34 +++ app/src/ShareLink.php | 10 + app/storage/schema.sql | 11 +- app/storage/schema.sql.new | 4 + app/templates/admin/acces.php | 32 ++ app/templates/admin/contenus-edit.php | 80 +++-- app/templates/admin/index.php | 16 +- app/templates/admin/parametres.php | 6 +- app/templates/header.php | 7 +- app/templates/partage/form-page.php | 1 + app/templates/partials/form/checkbox-list.php | 4 +- .../partials/form/fichiers-fragment.php | 2 +- .../partials/form/fieldset-files.php | 2 +- app/templates/partials/form/form.php | 48 ++- app/templates/partials/form/select-field.php | 4 +- app/templates/partials/form/text-field.php | 4 +- app/templates/partials/repertoire-index.php | 6 +- app/templates/public/charte.php | 31 +- app/templates/public/licence.php | 31 +- app/templates/public/search.php | 24 +- app/templates/public/tfe.php | 21 ++ justfile | 3 +- nginx/README.md | 22 +- nginx/SETUP.md | 33 +-- nginx/docs/ADMIN_USERS.md | 275 ------------------ nginx/docs/PHP_AUTH_LAYER.md | 94 ++---- nginx/xamxam.conf | 33 ++- nginx/xamxam.conf.reference | 6 +- scripts/deploy-server.sh | 15 - scripts/fix-banner-path-view.php | 95 ++++++ scripts/manage-admin-users.sh | 199 ------------- 81 files changed, 1061 insertions(+), 840 deletions(-) create mode 100644 app/migrations/applied/040_duration_fields.sql create mode 100644 app/public/admin/actions/draft.php create mode 100644 app/src/DatabaseMigrations.php create mode 100644 app/src/MarkdownHelper.php delete mode 100644 nginx/docs/ADMIN_USERS.md create mode 100755 scripts/fix-banner-path-view.php delete mode 100755 scripts/manage-admin-users.sh diff --git a/.phpunit.result.cache b/.phpunit.result.cache index aa1a093..b5a34fb 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.022,"CryptoTest::testEncryptDecryptWithUnicode":0.002,"CryptoTest::testEncryptDecryptMultiline":0.002,"CryptoTest::testDifferentPlaintextsProduceDifferentCiphertexts":0.002,"CryptoTest::testSamePlaintextProducesDifferentCiphertexts":0.002,"CryptoTest::testIsEncryptedRecognizesEncryptedValue":0.002,"CryptoTest::testIsEncryptedRejectsPlaintext":0.002,"CryptoTest::testIsEncryptedReturnsFalseForEmptyString":0.002,"CryptoTest::testIsEncryptedRejectsInvalidBase64":0.002,"CryptoTest::testEncryptDecryptEmptyString":0.008,"CryptoTest::testDecryptEmptyStringReturnsEmpty":0.002,"CryptoTest::testDecryptInvalidBase64ReturnsInputGracefully":0.002,"CryptoTest::testDecryptTooShortBlobReturnsInputGracefully":0.001,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":0.002,"CryptoTest::testDecryptValidBlobTamperedTagReturnsEmpty":0.002,"EmailObfuscatorTest::testEncodeContainsNoAtSign":0.016,"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.002,"EmailObfuscatorTest::testObfuscateHtmlKeepsNonMailtoLinksUnchanged":0.002,"EmailObfuscatorTest::testObfuscateHtmlPreservesCustomLinkText":0.002,"EmailObfuscatorTest::testEmptyStringReturnsEmpty":0.001,"EmailObfuscatorTest::testStringWithNoEmailsIsUnchanged":0.002,"EmailObfuscatorTest::testAlreadyObfuscatedContentIsNotDoubleEncoded":0.003,"EmailObfuscatorTest::testMultipleEmailsInOneString":0.003,"EmailObfuscatorTest::testEmailWithPlusSign":0.002,"StudentEmailTest::testBuildHtmlReturnsNonEmptyString":0.027,"StudentEmailTest::testBuildHtmlContainsKeyFields":0.002,"StudentEmailTest::testBuildHtmlEscapesSpecialCharacters":0.003,"StudentEmailTest::testBuildHtmlHandlesMissingOptionalFields":0.002,"StudentEmailTest::testBuildHtmlHandlesNullFieldsGracefully":0.002,"StudentEmailTest::testBuildHtmlHandlesEmptyArray":0.002,"StudentEmailTest::testBuildHtmlContainsLabelFields":0.002,"SystemControllerHelpersTest::testHumanBytesZero":0.122,"SystemControllerHelpersTest::testHumanBytesBelowOneKB":0.002,"SystemControllerHelpersTest::testHumanBytesOneKB":0.002,"SystemControllerHelpersTest::testHumanBytesOneMB":0.002,"SystemControllerHelpersTest::testHumanBytesOneGB":0.002,"SystemControllerHelpersTest::testHumanBytes1523MB":0.003,"SystemControllerHelpersTest::testHumanBytes2500GB":0.002,"SystemControllerHelpersTest::testDiskColorBelowWarning":0.002,"SystemControllerHelpersTest::testDiskColorWarning":0.002,"SystemControllerHelpersTest::testDiskColorCritical":0.002,"SystemControllerHelpersTest::testLogLineClassCrit":0.002,"SystemControllerHelpersTest::testLogLineClassError":0.002,"SystemControllerHelpersTest::testLogLineClassWarn":0.002,"SystemControllerHelpersTest::testLogLineClassNotice":0.003,"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.002,"SystemControllerHelpersTest::testStatusLabelInactive":0.002,"SystemControllerHelpersTest::testStatusLabelFailed":0.002,"SystemControllerHelpersTest::testStatusLabelWarn":0.002,"SystemControllerHelpersTest::testStatusLabelUnknown":0.002,"SystemControllerHelpersTest::testStatusClassOk":0.004,"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.003,"TfeControllerOgTest::testBuildOgTagsPublishedTimeFormatted":0.002,"TfeControllerOgTest::testBuildOgTagsPublishedTimeEmptyWhenNoYear":0.002,"TfeControllerOgTest::testBuildMetaDescriptionTruncatesLongSynopsis":0.002,"TfeControllerOgTest::testBuildMetaDescriptionKeepsShortSynopsis":0.002,"TfeControllerOgTest::testBuildMetaDescriptionEmptySynopsisReturnsDefault":0.003,"TfeControllerOgTest::testBuildMetaDescriptionStripsHtmlTags":0.003,"CryptoTest::testEncryptEmptyStringProducesCiphertext":0.002,"DatabaseExtendedTest::testEscapeLikeStringViaSearchConditions":0.002,"DatabaseExtendedTest::testBuildSearchConditionsEmptyParams":0.002,"DatabaseExtendedTest::testBuildSearchConditionsWithQuery":0.002,"DatabaseExtendedTest::testBuildSearchConditionsWithYear":0.002,"DatabaseExtendedTest::testBuildSearchConditionsWithAllFilters":0.002,"DatabaseExtendedTest::testFindDuplicateThesisExactMatch":0.002,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentTitle":0.003,"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.003,"DatabaseExtendedTest::testGetCoverPathsForThesesMultipleTheses":0.002,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":0.002,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":0.002,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":0.002,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":0.002,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":0.002,"DatabaseExtendedTest::testRenameLanguageUpdatesName":0.034,"DatabaseExtendedTest::testMergeLanguageReassignsTheses":0.003,"DatabaseExtendedTest::testRenameTagUpdatesName":0.003,"DatabaseExtendedTest::testMergeTagReassignsTheses":0.003,"RateLimitExtendedTest::testCheckKeyCountsPerKey":0.031,"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.328,"ShareLinkExtendedTest::testListArchivedReturnsOnlyArchivedLinks":0.286,"ShareLinkExtendedTest::testFindBySlugHit":0.292,"ShareLinkExtendedTest::testFindBySlugMiss":0.003,"ShareLinkExtendedTest::testSetPasswordAndDecryptRoundTrip":0.28,"ShareLinkExtendedTest::testGetDecryptedPasswordOnNonexistentId":0.004,"ShareLinkExtendedTest::testUpdateChangesNameAndExpiration":0.279,"ShareLinkExtendedTest::testUpdateOnlyNameLeavesExpirationUnchanged":0.278,"ShareLinkExtendedTest::testUpdateClearsExpiration":0.281,"ShareLinkExtendedTest::testCreateWithLockedYear":0.278,"ShareLinkExtendedTest::testCreateWithInvalidLockedYearRejected":0.278,"ShareLinkExtendedTest::testUpdateLockedYear":0.278,"ShareLinkExtendedTest::testUpdateClearLockedYear":0.278,"ShareLinkExtendedTest::testIncrementUsage":0.285,"ShareLinkExtendedTest::testCreateDefaultsToTfeWhenInvalidObjet":0.294,"ShareLinkExtendedTest::testCreateAcceptsValidObjet":0.282,"RateLimitExtendedTest::testGetRemainingStartsAtMax":0.002,"RateLimitExtendedTest::testCheckDecrementsRemainingForSameIp":0.002,"RateLimitExtendedTest::testCheckAndCheckKeyAreIndependent":0.002,"RateLimitExtendedTest::testMultipleChecksFromSameClient":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusTitle":0.921,"AutofocusFieldForErrorTest::testCreateAutofocusAuthors":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusSynopsis":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusYear":0.003,"AutofocusFieldForErrorTest::testCreateAutofocusOrientation":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusAP":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusFinality":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusLanguages":0.001,"AutofocusFieldForErrorTest::testCreateAutofocusPromoteur":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurInterne":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurExterne":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusFormats":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusLicense":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusUrl":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusTags":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusUnknownErrorReturnsNull":0.002,"AutofocusFieldForErrorTest::testEditAutofocusTitle":0.134,"AutofocusFieldForErrorTest::testEditAutofocusYear":0.002,"AutofocusFieldForErrorTest::testEditAutofocusSynopsis":0.001,"AutofocusFieldForErrorTest::testEditAutofocusAuthors":0.001,"AutofocusFieldForErrorTest::testEditAutofocusUnknownErrorReturnsNull":0.002,"AutofocusFieldForErrorTest::testCreateDoesNotLeakEditFieldNames":0.002,"ThesisCreateValidationTest::testValidSubmissionReturnsCleanedData":0.002,"ThesisCreateValidationTest::testMissingTitleThrowsException":0.003,"ThesisCreateValidationTest::testMissingAuthorsThrowsException":0.004,"ThesisCreateValidationTest::testMissingSynopsisThrowsException":0.002,"ThesisCreateValidationTest::testMissingOrientationInNonAdminModeThrowsException":0.002,"ThesisCreateValidationTest::testMissingAPProgramInNonAdminModeThrowsException":0.004,"ThesisCreateValidationTest::testMissingFinalityInNonAdminModeThrowsException":0.004,"ThesisCreateValidationTest::testInvalidYearFormatRejected":0.002,"ThesisCreateValidationTest::testYearZeroRejected":0.003,"ThesisCreateValidationTest::testYearBefore2000Rejected":0.002,"ThesisCreateValidationTest::testFarFutureYearRejected":0.002,"ThesisCreateValidationTest::testCurrentYearAccepted":0.002,"ThesisCreateValidationTest::testMalformedUrlRejected":0.004,"ThesisCreateValidationTest::testValidUrlAccepted":0.002,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":0.002,"ThesisCreateValidationTest::testMaxTenKeywordsEnforced":0.002,"ThesisCreateValidationTest::testXssPayloadStrippedFromTitle":0.003,"ThesisCreateValidationTest::testHtmlInSynopsisStripped":0.002,"ThesisCreateValidationTest::testMultipleAuthorsAreSorted":0.002,"ThesisCreateValidationTest::testMissingPromoteurInNonAdminModeThrowsException":0.002,"ThesisCreateValidationTest::testMissingLecteurInterneInNonAdminModeThrowsException":0.002,"ThesisCreateValidationTest::testMissingLanguagesInNonAdminModeThrowsException":0.003,"ThesisCreateValidationTest::testMissingFormatsInNonAdminModeThrowsException":0.003,"ThesisCreateValidationTest::testMissingLicenseWithLibreAccessThrowsException":0.003,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":0.007,"ThesisEditValidationTest::testLoadThrowsOnUnknownId":0.003,"ThesisEditValidationTest::testLoadThrowsOnInvalidId":0.002,"ThesisEditValidationTest::testLoadThrowsOnNegativeId":0.004,"ThesisEditValidationTest::testCollectJuryMembersEmptyInput":0.002,"ThesisEditValidationTest::testCollectJuryMembersSinglePromoteur":0.002,"ThesisEditValidationTest::testCollectJuryMembersPromoteurUlb":0.002,"ThesisEditValidationTest::testCollectJuryMembersLecteurs":0.002,"ThesisEditValidationTest::testCollectJuryMembersDeduplicatesEmptyStrings":0.002,"ThesisEditValidationTest::testCollectJuryMembersScalarPromoteurAccepted":0.003,"ThesisEditValidationTest::testHandleWebsiteUrlStoresValidUrl":0.003,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsInvalidUrl":0.003,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsEmptyUrl":0.003,"ThesisEditValidationTest::testHandleWebsiteUrlNormalisesHttp":0.003,"ErrorHandlerTest::testFkThesesTableMentionsAllPossibleFields":0.025,"ErrorHandlerTest::testFkApPrograms":0.002,"ErrorHandlerTest::testFkFinalityTypes":0.001,"ErrorHandlerTest::testFkThesisLanguages":0.002,"ErrorHandlerTest::testFkThesisFormats":0.002,"ErrorHandlerTest::testFkThesisTags":0.002,"ErrorHandlerTest::testFkThesisSupervisors":0.002,"ErrorHandlerTest::testFkAccessTypes":0.001,"ErrorHandlerTest::testFkLicenseTypes":0.002,"ErrorHandlerTest::testFkAuthors":0.002,"ErrorHandlerTest::testFkQuotedTableName":0.001,"ErrorHandlerTest::testFkQuotedLanguages":0.001,"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.002,"ErrorHandlerTest::testLogWithEmptyExtra":0.002,"ErrorHandlerTest::testFkQuotedColumnNames":0.002,"ErrorHandlerTest::testFkUpdateStatement":0.001,"ErrorHandlerTest::testFkWithReferencesAndInsert":0.002,"PureLogicTest::testSplitJuryByRoleAllRoles":0.042,"PureLogicTest::testSplitJuryByRoleEmptyNameSkipped":0.002,"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.057,"SearchControllerTest::testCoverMapContainsKnownThesis":0.005}} \ 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},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0.002,"CryptoTest::testEncryptDecryptWithUnicode":0,"CryptoTest::testEncryptDecryptMultiline":0,"CryptoTest::testDifferentPlaintextsProduceDifferentCiphertexts":0,"CryptoTest::testSamePlaintextProducesDifferentCiphertexts":0.001,"CryptoTest::testIsEncryptedRecognizesEncryptedValue":0.001,"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.001,"EmailObfuscatorTest::testEncodeOutputIsNumericEntities":0.001,"EmailObfuscatorTest::testEmailReturnsObfuscatedAddress":0.001,"EmailObfuscatorTest::testMailtoBuildsCorrectHrefStructure":0.001,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":0.001,"EmailObfuscatorTest::testEmailTextReplacesMultipleEmails":0.001,"EmailObfuscatorTest::testMailtoInTextReplacesMailtoLinks":0.001,"EmailObfuscatorTest::testObfuscateHtmlReplacesAnchorTag":0.002,"EmailObfuscatorTest::testObfuscateHtmlKeepsNonMailtoLinksUnchanged":0.001,"EmailObfuscatorTest::testObfuscateHtmlPreservesCustomLinkText":0.001,"EmailObfuscatorTest::testEmptyStringReturnsEmpty":0,"EmailObfuscatorTest::testStringWithNoEmailsIsUnchanged":0.001,"EmailObfuscatorTest::testAlreadyObfuscatedContentIsNotDoubleEncoded":0.001,"EmailObfuscatorTest::testMultipleEmailsInOneString":0.001,"EmailObfuscatorTest::testEmailWithPlusSign":0.001,"StudentEmailTest::testBuildHtmlReturnsNonEmptyString":0.001,"StudentEmailTest::testBuildHtmlContainsKeyFields":0.001,"StudentEmailTest::testBuildHtmlEscapesSpecialCharacters":0.001,"StudentEmailTest::testBuildHtmlHandlesMissingOptionalFields":0.001,"StudentEmailTest::testBuildHtmlHandlesNullFieldsGracefully":0.001,"StudentEmailTest::testBuildHtmlHandlesEmptyArray":0.001,"StudentEmailTest::testBuildHtmlContainsLabelFields":0.001,"SystemControllerHelpersTest::testHumanBytesZero":0.001,"SystemControllerHelpersTest::testHumanBytesBelowOneKB":0.001,"SystemControllerHelpersTest::testHumanBytesOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneMB":0,"SystemControllerHelpersTest::testHumanBytesOneGB":0,"SystemControllerHelpersTest::testHumanBytes1523MB":0,"SystemControllerHelpersTest::testHumanBytes2500GB":0.001,"SystemControllerHelpersTest::testDiskColorBelowWarning":0.001,"SystemControllerHelpersTest::testDiskColorWarning":0.001,"SystemControllerHelpersTest::testDiskColorCritical":0.001,"SystemControllerHelpersTest::testLogLineClassCrit":0.001,"SystemControllerHelpersTest::testLogLineClassError":0.001,"SystemControllerHelpersTest::testLogLineClassWarn":0,"SystemControllerHelpersTest::testLogLineClassNotice":0.001,"SystemControllerHelpersTest::testLogLineClassHttp500":0.001,"SystemControllerHelpersTest::testLogLineClassHttp300":0.001,"SystemControllerHelpersTest::testLogLineClassDefault":0.001,"SystemControllerHelpersTest::testNginxLineClassComment":0.003,"SystemControllerHelpersTest::testNginxLineClassBlock":0.004,"SystemControllerHelpersTest::testNginxLineClassDirective":0.003,"SystemControllerHelpersTest::testStatusLabelActive":0,"SystemControllerHelpersTest::testStatusLabelInactive":0.001,"SystemControllerHelpersTest::testStatusLabelFailed":0,"SystemControllerHelpersTest::testStatusLabelWarn":0,"SystemControllerHelpersTest::testStatusLabelUnknown":0,"SystemControllerHelpersTest::testStatusClassOk":0,"SystemControllerHelpersTest::testStatusClassWarn":0.001,"SystemControllerHelpersTest::testStatusClassError":0.001,"SystemControllerHelpersTest::testStatusClassUnknown":0.001,"TfeControllerOgTest::testBuildOgTagsReturnsAllRequiredKeys":0.001,"TfeControllerOgTest::testBuildOgTagsTitleIncludesAuthors":0.001,"TfeControllerOgTest::testBuildOgTagsImageEmptyWhenNoFiles":0.001,"TfeControllerOgTest::testBuildOgTagsImageFromCover":0.001,"TfeControllerOgTest::testBuildOgTagsImageFallbackToFirstImage":0.001,"TfeControllerOgTest::testBuildOgTagsUrlIncludesThesisId":0.001,"TfeControllerOgTest::testBuildOgTagsPublishedTimeFormatted":0.001,"TfeControllerOgTest::testBuildOgTagsPublishedTimeEmptyWhenNoYear":0.001,"TfeControllerOgTest::testBuildMetaDescriptionTruncatesLongSynopsis":0.001,"TfeControllerOgTest::testBuildMetaDescriptionKeepsShortSynopsis":0.001,"TfeControllerOgTest::testBuildMetaDescriptionEmptySynopsisReturnsDefault":0.001,"TfeControllerOgTest::testBuildMetaDescriptionStripsHtmlTags":0.001,"CryptoTest::testEncryptEmptyStringProducesCiphertext":0.001,"DatabaseExtendedTest::testEscapeLikeStringViaSearchConditions":0.001,"DatabaseExtendedTest::testBuildSearchConditionsEmptyParams":0.001,"DatabaseExtendedTest::testBuildSearchConditionsWithQuery":0.001,"DatabaseExtendedTest::testBuildSearchConditionsWithYear":0.001,"DatabaseExtendedTest::testBuildSearchConditionsWithAllFilters":0,"DatabaseExtendedTest::testFindDuplicateThesisExactMatch":0.001,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentTitle":0.001,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentYear":0.002,"DatabaseExtendedTest::testFindDuplicateThesisEmptyAuthorNamesReturnsNull":0.001,"DatabaseExtendedTest::testFindDuplicateThesisEmptyTable":0.001,"DatabaseExtendedTest::testFindDuplicateThesisNearDuplicateByLevenshtein":0.001,"DatabaseExtendedTest::testGenerateThesisIdentifierFirstInYear":0,"DatabaseExtendedTest::testGenerateThesisIdentifierIncrementsCorrectly":0.001,"DatabaseExtendedTest::testGenerateThesisIdentifierUsesMaxNotCount":0.002,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsPaths":0.001,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsEmptyForUnknownIds":0.001,"DatabaseExtendedTest::testGetCoverPathsForThesesEmptyInputReturnsEmpty":0.001,"DatabaseExtendedTest::testGetCoverPathsForThesesMultipleTheses":0.001,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":0,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":0,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":0,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":0.001,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":0.002,"DatabaseExtendedTest::testRenameLanguageUpdatesName":0.008,"DatabaseExtendedTest::testMergeLanguageReassignsTheses":0.001,"DatabaseExtendedTest::testRenameTagUpdatesName":0,"DatabaseExtendedTest::testMergeTagReassignsTheses":0.001,"RateLimitExtendedTest::testCheckKeyCountsPerKey":0.002,"RateLimitExtendedTest::testCheckKeyDoesNotAffectDefaultCheck":0.002,"RateLimitExtendedTest::testGetRemainingDecrements":0,"RateLimitExtendedTest::testGetRemainingAtLimit":0,"RateLimitExtendedTest::testGetRemainingUsesClientIdentifier":0.001,"RateLimitExtendedTest::testCheckUsesConsistentIdentifier":0,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":0,"RateLimitExtendedTest::testGetResetTimeReturnsZeroWhenNoData":0.001,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":0.001,"RateLimitExtendedTest::testCleanupRemovesOldFiles":0.001,"ShareLinkExtendedTest::testListActiveReturnsOnlyActiveLinks":0.312,"ShareLinkExtendedTest::testListArchivedReturnsOnlyArchivedLinks":0.313,"ShareLinkExtendedTest::testFindBySlugHit":0.311,"ShareLinkExtendedTest::testFindBySlugMiss":0,"ShareLinkExtendedTest::testSetPasswordAndDecryptRoundTrip":0.325,"ShareLinkExtendedTest::testGetDecryptedPasswordOnNonexistentId":0.001,"ShareLinkExtendedTest::testUpdateChangesNameAndExpiration":0.318,"ShareLinkExtendedTest::testUpdateOnlyNameLeavesExpirationUnchanged":0.319,"ShareLinkExtendedTest::testUpdateClearsExpiration":0.311,"ShareLinkExtendedTest::testCreateWithLockedYear":0.311,"ShareLinkExtendedTest::testCreateWithInvalidLockedYearRejected":0.313,"ShareLinkExtendedTest::testUpdateLockedYear":0.315,"ShareLinkExtendedTest::testUpdateClearLockedYear":0.347,"ShareLinkExtendedTest::testIncrementUsage":0.336,"ShareLinkExtendedTest::testCreateDefaultsToTfeWhenInvalidObjet":0.322,"ShareLinkExtendedTest::testCreateAcceptsValidObjet":0.316,"RateLimitExtendedTest::testGetRemainingStartsAtMax":0.001,"RateLimitExtendedTest::testCheckDecrementsRemainingForSameIp":0.001,"RateLimitExtendedTest::testCheckAndCheckKeyAreIndependent":0.001,"RateLimitExtendedTest::testMultipleChecksFromSameClient":0,"AutofocusFieldForErrorTest::testCreateAutofocusTitle":0.006,"AutofocusFieldForErrorTest::testCreateAutofocusAuthors":0,"AutofocusFieldForErrorTest::testCreateAutofocusSynopsis":0.001,"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.001,"AutofocusFieldForErrorTest::testEditAutofocusTitle":0.001,"AutofocusFieldForErrorTest::testEditAutofocusYear":0,"AutofocusFieldForErrorTest::testEditAutofocusSynopsis":0,"AutofocusFieldForErrorTest::testEditAutofocusAuthors":0,"AutofocusFieldForErrorTest::testEditAutofocusUnknownErrorReturnsNull":0,"AutofocusFieldForErrorTest::testCreateDoesNotLeakEditFieldNames":0,"ThesisCreateValidationTest::testValidSubmissionReturnsCleanedData":0.001,"ThesisCreateValidationTest::testMissingTitleThrowsException":0.003,"ThesisCreateValidationTest::testMissingAuthorsThrowsException":0.001,"ThesisCreateValidationTest::testMissingSynopsisThrowsException":0.001,"ThesisCreateValidationTest::testMissingOrientationInNonAdminModeThrowsException":0.001,"ThesisCreateValidationTest::testMissingAPProgramInNonAdminModeThrowsException":0.001,"ThesisCreateValidationTest::testMissingFinalityInNonAdminModeThrowsException":0.001,"ThesisCreateValidationTest::testInvalidYearFormatRejected":0.001,"ThesisCreateValidationTest::testYearZeroRejected":0.001,"ThesisCreateValidationTest::testYearBefore2000Rejected":0.001,"ThesisCreateValidationTest::testFarFutureYearRejected":0.001,"ThesisCreateValidationTest::testCurrentYearAccepted":0.001,"ThesisCreateValidationTest::testMalformedUrlRejected":0.001,"ThesisCreateValidationTest::testValidUrlAccepted":0,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":0,"ThesisCreateValidationTest::testMaxTenKeywordsEnforced":0.001,"ThesisCreateValidationTest::testXssPayloadStrippedFromTitle":0.001,"ThesisCreateValidationTest::testHtmlInSynopsisStripped":0.001,"ThesisCreateValidationTest::testMultipleAuthorsAreSorted":0.001,"ThesisCreateValidationTest::testMissingPromoteurInNonAdminModeThrowsException":0.001,"ThesisCreateValidationTest::testMissingLecteurInterneInNonAdminModeThrowsException":0.001,"ThesisCreateValidationTest::testMissingLanguagesInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingFormatsInNonAdminModeThrowsException":0.001,"ThesisCreateValidationTest::testMissingLicenseWithLibreAccessThrowsException":0.001,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":0.006,"ThesisEditValidationTest::testLoadThrowsOnUnknownId":0.001,"ThesisEditValidationTest::testLoadThrowsOnInvalidId":0.001,"ThesisEditValidationTest::testLoadThrowsOnNegativeId":0.001,"ThesisEditValidationTest::testCollectJuryMembersEmptyInput":0.001,"ThesisEditValidationTest::testCollectJuryMembersSinglePromoteur":0.001,"ThesisEditValidationTest::testCollectJuryMembersPromoteurUlb":0.001,"ThesisEditValidationTest::testCollectJuryMembersLecteurs":0.003,"ThesisEditValidationTest::testCollectJuryMembersDeduplicatesEmptyStrings":0.001,"ThesisEditValidationTest::testCollectJuryMembersScalarPromoteurAccepted":0.002,"ThesisEditValidationTest::testHandleWebsiteUrlStoresValidUrl":0.001,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsInvalidUrl":0.001,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsEmptyUrl":0.001,"ThesisEditValidationTest::testHandleWebsiteUrlNormalisesHttp":0.001,"ErrorHandlerTest::testFkThesesTableMentionsAllPossibleFields":0.001,"ErrorHandlerTest::testFkApPrograms":0,"ErrorHandlerTest::testFkFinalityTypes":0,"ErrorHandlerTest::testFkThesisLanguages":0.001,"ErrorHandlerTest::testFkThesisFormats":0,"ErrorHandlerTest::testFkThesisTags":0.001,"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.001,"ErrorHandlerTest::testFkUnknownTableGenericFallback":0.001,"ErrorHandlerTest::testFkEmptyMessageGenericFallback":0.001,"ErrorHandlerTest::testUniqueConstraint":0.001,"ErrorHandlerTest::testNotNullConstraint":0,"ErrorHandlerTest::testGenericPdoError":0.001,"ErrorHandlerTest::testDuplicateThesisExceptionPassesThrough":0.001,"ErrorHandlerTest::testValidationExceptionPassesThrough":0,"ErrorHandlerTest::testGenericExceptionPassesThrough":0,"ErrorHandlerTest::testTypeErrorReturnsGeneric":0,"ErrorHandlerTest::testLogWithContext":0.001,"ErrorHandlerTest::testLogWithNullValues":0,"ErrorHandlerTest::testLogWithEmptyExtra":0,"ErrorHandlerTest::testFkQuotedColumnNames":0.001,"ErrorHandlerTest::testFkUpdateStatement":0.001,"ErrorHandlerTest::testFkWithReferencesAndInsert":0,"PureLogicTest::testSplitJuryByRoleAllRoles":0.001,"PureLogicTest::testSplitJuryByRoleEmptyNameSkipped":0,"PureLogicTest::testSplitJuryByRoleEmptyJury":0,"PureLogicTest::testCollectCaptionPathsVttByMime":0,"PureLogicTest::testCollectCaptionPathsVttByExtension":0,"PureLogicTest::testCollectCaptionPathsNoVttReturnsEmpty":0,"PureLogicTest::testDetectFileTypeByMime":0,"PureLogicTest::testDetectFileTypeByExtensionFallback":0,"SearchControllerTest::testHandleSearchReturnsCoverMapKey":0.006,"SearchControllerTest::testCoverMapContainsKnownThesis":0.003}} \ No newline at end of file diff --git a/README.md b/README.md index df36c35..c732820 100644 --- a/README.md +++ b/README.md @@ -65,17 +65,9 @@ just deploy just deploy-nginx ``` -### Admin users (htpasswd) - -```bash -just manage-admin-users -# Then on server: -ssh xamxam "sudo bash /tmp/manage-admin-users.sh" -``` - ## Security notes -- Admin panel protected by nginx `auth_basic` + PHP session (`AdminAuth`) +- Admin panel protected by PHP session (`AdminAuth`) — password-only, no username - Uploads stored outside webroot, served via controlled `media.php` - Rate limiting on public search (`src/RateLimit.php`) - See `nginx/docs/SECURITY_HEADERS.md` for security headers reference diff --git a/TODO.md b/TODO.md index 5910059..6e5a3af 100644 --- a/TODO.md +++ b/TODO.md @@ -1,15 +1,26 @@ # TODO > Last updated: 2026-06-11 -> Context: Form Accessibility & Resilience improvements for XAMXAM thesis submission platform +> Context: Removed overtype autosave (403 CSRF bug), replaced with explicit Save button above full-page editor ## Pending +- [ ] #apropos-toc-confirm Visually confirm charte + licence TOC layout renders correctly in browser - [ ] #aria-test-manual Test WCAG changes with VoiceOver and NVDA on full add/edit/partage form flows - [ ] #nojs-upload-test Test end-to-end: submit partage form with JS disabled, verify files arrive via `$_FILES` +- [ ] #csp-media-iframe-deploy Deploy nginx config fix to server, test PDF iframe on /tfe?id=221 ## Completed +- [x] #csp-media-iframe-fix Fix CSP `frame-ancestors 'none'` blocking PDF iframes — replaced `try_files` redirect with direct `fastcgi_pass` in `location = /media` so `add_header` CSP override survives internal nginx redirect `(nginx/xamxam.conf)` ✓ + +- [x] #duration-migration Add migration to reintroduce `duration_value` and `duration_unit` columns + update views `(migrations/applied/040_duration_fields.sql)` ✓ +- [x] #duration-database Update `createThesis`, `updateThesis`, `getThesisRawFields` in Database `(Database.php)` ✓ +- [x] #duration-controllers Handle duration in `ThesisCreateController` and `ThesisEditController` `(ThesisCreateController.php, ThesisEditController.php)` ✓ +- [x] #duration-form Add duration fieldset (value + unit dropdown) to form template `(templates/partials/form/form.php)` ✓ +- [x] #duration-display Show duration on public TFE detail page `(templates/public/tfe.php)` ✓ +- [x] #duration-view Include duration in v_theses_full and v_theses_public `(migrations/applied/040_duration_fields.sql, schema.sql, schema.sql.new)` ✓ +- [x] #duration-bootstrapWire Wire duration variables through FormBootstrap adminFormVariables `(FormBootstrap.php)` ✓ - [x] #cleanup-drafts Add periodic cleanup job for orphaned drafts (`Database.php`, `justfile`, `deploy/xamxam-cleanup.cron`, `scripts/cleanup-drafts.php`) ✓ - [x] #form-setup-helper Add `FormBootstrap` helper class to reduce bootstrap duplication across add/edit/partage `(admin/add.php)` `(admin/edit.php)` ✓ - [x] #two-phase-commit Add two-phase commit: INSERT thesis `status='draft'`, COMMIT, move files, UPDATE to `active` `(ThesisCreateController.php)` ✓ @@ -20,6 +31,7 @@ - [x] #aria-errormessage WCAG AA: field-level `aria-errormessage`, `aria-invalid`, `aria-describedby` on all form fields ✓ - [x] #nojs-upload-fix No-JS file uploads: `filepond_mode` default to `0 disabled`, server-side `$_FILES` fallback ✓ - [x] #autosave-partage Autosave text fields on partage form: session draft endpoint (`fragments/draft.php`), HTMX autosave on change/input, page-load hydration, "Brouillon enregistré" indicator, draft cleared on submit ✓ +- [x] #autosave-partage-wire Wire `formExtraAttrs`, `showAutosaveStatus`, draft hydration, `autosave-handler.js`, draft cleanup into partage form (`partage/index.php`, `partage/form-page.php`) ✓ - [x] #mobile-responsive Mobile-responsive form layout: `@media (max-width: 600px)` breakpoint, 44×44px touch targets ✓ - [x] #aria-fieldset-fix Remove invalid `required` attribute from `
`, keep `aria-required="true"`, add `role="group"` ✓ - [x] #split-form-css Split `form.css` into `form-base.css` and `form-admin.css` ✓ diff --git a/app/migrations/applied/040_duration_fields.sql b/app/migrations/applied/040_duration_fields.sql new file mode 100644 index 0000000..fd3b89a --- /dev/null +++ b/app/migrations/applied/040_duration_fields.sql @@ -0,0 +1,80 @@ +-- 040_duration_fields.sql +-- Reintroduce duration metadata (value + unit) for TFE records. +-- These columns were removed in 024_remove_duration_size_fields.sql but are now +-- re-added with a cleaner design: a numeric value and a unit selector. + +-- Add new columns to theses table +ALTER TABLE theses ADD COLUMN duration_value REAL; +ALTER TABLE theses ADD COLUMN duration_unit TEXT DEFAULT 'pages'; + +-- Drop and recreate views to include the new columns +DROP VIEW IF EXISTS v_theses_full; +DROP VIEW IF EXISTS v_theses_public; + +CREATE VIEW IF NOT EXISTS v_theses_full AS +SELECT + t.id, + t.identifier, + t.title, + t.subtitle, + t.year, + t.is_doctoral, + t.objet, + o.name as orientation, + ap.name as ap_program, + ft.name as finality_type, + t.synopsis, + t.context_note, + t.duration_value, + t.duration_unit, + at.name as access_type, + lt.name as license_type, + t.license_id, + t.license_custom, + t.access_type_id, + t.jury_points, + t.submitted_at, + t.defense_date, + t.published_at, + t.is_published, + t.baiu_link, + t.exemplaire_baiu, + t.exemplaire_erg, + t.cc2r, + t.remarks, + t.jury_note_added, + t.contact_visible, + GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors, + GROUP_CONCAT(DISTINCT s.name) as supervisors, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 0 THEN s.name END) as jury_promoteurs, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 1 THEN s.name END) as jury_promoteurs_ulb, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 0 THEN s.name END) as jury_lecteurs_internes, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 1 THEN s.name END) as jury_lecteurs_externes, + GROUP_CONCAT(DISTINCT l.name) as languages, + GROUP_CONCAT(DISTINCT fmt.name) as formats, + GROUP_CONCAT(DISTINCT tg.name) as keywords, + -- First author's email and contact-visibility flag + (SELECT a2.email FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_email, + (SELECT a2.show_contact FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_show_contact +FROM theses t +LEFT JOIN orientations o ON t.orientation_id = o.id +LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id +LEFT JOIN finality_types ft ON t.finality_id = ft.id +LEFT JOIN access_types at ON t.access_type_id = at.id +LEFT JOIN license_types lt ON t.license_id = lt.id +LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id +LEFT JOIN authors a ON ta.author_id = a.id +LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id +LEFT JOIN supervisors s ON ts.supervisor_id = s.id +LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id +LEFT JOIN languages l ON tl.language_id = l.id +LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id +LEFT JOIN format_types fmt ON tf.format_id = fmt.id +LEFT JOIN thesis_tags tt ON t.id = tt.thesis_id +LEFT JOIN tags tg ON tt.tag_id = tg.id +GROUP BY t.id; + +CREATE VIEW IF NOT EXISTS v_theses_public AS +SELECT * FROM v_theses_full +WHERE is_published = 1; diff --git a/app/public/admin/actions/acces-etudiante.php b/app/public/admin/actions/acces-etudiante.php index fcd9aa3..b0752a0 100644 --- a/app/public/admin/actions/acces-etudiante.php +++ b/app/public/admin/actions/acces-etudiante.php @@ -7,8 +7,6 @@ require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/ShareLink.php'; require_once __DIR__ . '/../../../src/AdminLogger.php'; -error_log('[acces-etudiante.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST))); - App::adminGuard(); if ($_SERVER['REQUEST_METHOD'] !== 'POST' @@ -90,6 +88,15 @@ switch ($action) { } break; + case 'delete': + if ($id > 0) { + $shareLink->delete($id); + App::redirect('/admin/acces.php', success: 'Lien supprimé définitivement.'); + } else { + App::redirect('/admin/acces.php', error: 'Lien introuvable.'); + } + break; + case 'update': if ($id > 0) { $name = isset($_POST['name']) ? trim($_POST['name']) : null; diff --git a/app/public/admin/actions/access-request.php b/app/public/admin/actions/access-request.php index 8b4d628..7a8740a 100644 --- a/app/public/admin/actions/access-request.php +++ b/app/public/admin/actions/access-request.php @@ -6,7 +6,6 @@ */ require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../src/AdminAuth.php'; -error_log('[access-request.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | request_id=' . ($_POST['request_id'] ?? 0) . ' | post_keys=' . implode(',', array_keys($_POST))); AdminAuth::requireLogin(); if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) diff --git a/app/public/admin/actions/account.php b/app/public/admin/actions/account.php index 07cd5ee..9a12649 100644 --- a/app/public/admin/actions/account.php +++ b/app/public/admin/actions/account.php @@ -11,8 +11,6 @@ require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/Database.php'; -error_log('[account.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'change_password') . ' | post_keys=' . implode(',', array_keys($_POST))); - AdminAuth::requireLogin(); // ── CSRF ────────────────────────────────────────────────────────────────────── diff --git a/app/public/admin/actions/apropos.php b/app/public/admin/actions/apropos.php index 08f4b32..a9db38d 100644 --- a/app/public/admin/actions/apropos.php +++ b/app/public/admin/actions/apropos.php @@ -8,7 +8,6 @@ */ require_once __DIR__ . "/../../../bootstrap.php"; require_once __DIR__ . '/../../../src/AdminAuth.php'; -error_log('[apropos.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['apropos_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST))); AdminAuth::requireLogin(); $isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) diff --git a/app/public/admin/actions/corbeille.php b/app/public/admin/actions/corbeille.php index 7a29dc6..fb08611 100644 --- a/app/public/admin/actions/corbeille.php +++ b/app/public/admin/actions/corbeille.php @@ -1,8 +1,6 @@ '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'); + echo json_encode(['error' => 'Token de sécurité invalide.']); + exit; +} + +// ── Determine draft key ───────────────────────────────────────────────── +$draftToken = $_POST['draft_token'] ?? ''; +$thesisId = (int)($_POST['thesis_id'] ?? 0); + +if ($draftToken !== '' && preg_match('/^[a-f0-9]{16}$/', $draftToken)) { + $draftKey = 'admin_draft_' . $draftToken; +} elseif ($thesisId > 0) { + $draftKey = 'admin_draft_edit_' . $thesisId; +} else { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Paramètres invalides.']); + exit; +} + +// ── Save all form fields ──────────────────────────────────────────────── +$excludePrefixes = [ + 'csrf_token', 'share_link_token', + 'filepond_mode', 'queue_file', 'filepond_', +]; +$excludeExact = ['draft_token', 'thesis_id', 'slug', + 'couverture', 'note_intention', 'files', 'annexes', + 'peertube_video', 'peertube_audio', 'cover_remove', + 'go', 'MAX_FILE_SIZE']; + +$draft = []; +foreach ($_POST as $key => $value) { + if (in_array($key, $excludeExact, true)) continue; + $skip = false; + foreach ($excludePrefixes as $prefix) { + if (str_starts_with($key, $prefix)) { $skip = true; break; } + } + if ($skip) continue; + + if ($value === '' || $value === null || (is_array($value) && count($value) === 0)) { + continue; + } + + $draft[$key] = $value; +} + +$_SESSION[$draftKey] = $draft; + +// Rotate CSRF after mutation +$newToken = bin2hex(random_bytes(32)); +$_SESSION['csrf_token'] = $newToken; + +header('Content-Type: application/json'); +echo json_encode([ + 'success' => true, + 'csrf_token' => $newToken, +]); +exit; diff --git a/app/public/admin/actions/edit.php b/app/public/admin/actions/edit.php index f5e9bca..3d2ece8 100644 --- a/app/public/admin/actions/edit.php +++ b/app/public/admin/actions/edit.php @@ -3,8 +3,6 @@ require_once __DIR__ . "/../../../bootstrap.php"; require_once __DIR__ . '/../../../src/AdminAuth.php'; -error_log('[edit.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | content_length=' . ($_SERVER['CONTENT_LENGTH'] ?? '0') . ' | post_keys=' . implode(',', array_keys($_POST)) . ' | files_count=' . count($_FILES) . ' | files_keys=' . implode(',', array_keys($_FILES)) . ' | queue_tfe=' . (isset($_FILES['queue_file']['name']['tfe']) ? count(array_filter($_FILES['queue_file']['name']['tfe'])) : 0)); - // PHP-level auth guard (defence-in-depth behind nginx Basic Auth) AdminAuth::requireLogin(); @@ -37,6 +35,10 @@ try { // Regenerate CSRF token after successful save $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + // Clear autosave draft + FilePond temp files + unset($_SESSION['admin_draft_edit_' . $thesisId]); + unset($_SESSION['filepond_tmp']); + AdminLogger::make()->logEdit($thesisId, $_POST['titre'] ?? $_POST['title'] ?? ''); App::flash('success', "TFE mis à jour avec succès!"); diff --git a/app/public/admin/actions/filepond/relink.php b/app/public/admin/actions/filepond/relink.php index b000943..28591c6 100644 --- a/app/public/admin/actions/filepond/relink.php +++ b/app/public/admin/actions/filepond/relink.php @@ -24,7 +24,6 @@ function relinkError(int $code, string $message): never { // CSRF via header $csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; -error_log('[relink] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | csrf=' . (isset($_SESSION['csrf_token']) ? 'set' : 'missing') . ' | header=' . (strlen($csrfHeader) > 0 ? substr($csrfHeader, 0, 8) . '...' : 'empty') . ' | body_len=' . strlen(file_get_contents('php://input'))); if (!isset($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $csrfHeader)) { relinkError(403, 'Token CSRF invalide.'); diff --git a/app/public/admin/actions/form-help.php b/app/public/admin/actions/form-help.php index 98d6e00..dd5a6ab 100644 --- a/app/public/admin/actions/form-help.php +++ b/app/public/admin/actions/form-help.php @@ -6,7 +6,6 @@ */ require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../src/AdminAuth.php'; -error_log('[form-help.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['form_help_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST))); AdminAuth::requireLogin(); $isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) diff --git a/app/public/admin/actions/formulaire.php b/app/public/admin/actions/formulaire.php index c03c62e..86e3213 100644 --- a/app/public/admin/actions/formulaire.php +++ b/app/public/admin/actions/formulaire.php @@ -3,8 +3,6 @@ require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../src/AdminAuth.php'; -error_log('[formulaire.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | content_length=' . ($_SERVER['CONTENT_LENGTH'] ?? '0') . ' | post_keys=' . implode(',', array_keys($_POST)) . ' | files_count=' . count($_FILES) . ' | files_keys=' . implode(',', array_keys($_FILES)) . ' | queue_tfe=' . (isset($_FILES['queue_file']['name']['tfe']) ? count(array_filter($_FILES['queue_file']['name']['tfe'])) : 0)); - // Only suppress display_errors in production (cli-server = dev mode). if (php_sapi_name() !== 'cli-server') { ini_set('display_errors', 0); @@ -25,8 +23,6 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) die('Erreur de sécurité : token invalide. Veuillez recharger le formulaire.'); } -error_log('[formulaire.php] full FILES array: ' . print_r($_FILES, true)); - require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php'; require_once APP_ROOT . '/src/AppLogger.php'; require_once APP_ROOT . '/src/AdminLogger.php'; @@ -45,6 +41,10 @@ try { $logger->logSubmission('admin', $thesisId, $identifier, $authorName); $adminLogger->logAdd($thesisId, $identifier, $authorName); + // Clear autosave draft + FilePond temp files + unset($_SESSION['admin_draft_' . ($_POST['draft_token'] ?? '')]); + unset($_SESSION['admin_draft_add_token']); + unset($_SESSION['filepond_tmp']); unset($_SESSION['csrf_token']); $redirect = '../recapitulatif.php?id=' . $thesisId; diff --git a/app/public/admin/actions/language.php b/app/public/admin/actions/language.php index 0989d9c..6242d1f 100644 --- a/app/public/admin/actions/language.php +++ b/app/public/admin/actions/language.php @@ -1,7 +1,6 @@ setSetting('restricted_files_enabled', $newValue); $logger->logFormSettingsUpdate(['restricted_files_enabled' => $newValue]); if ($isHxRequest) { @@ -76,9 +71,7 @@ if ($section === 'formulaire_restrictions') { ]; $newValues = []; foreach ($allowed as $key) { - $raw = $_POST[$key] ?? '(missing)'; $value = empty($_POST[$key]) ? '0' : '1'; - error_log('[settings.php] SAVE formulaire_acces | ' . $key . ' raw=' . var_export($raw, true) . ' | resolved=' . $value); $db->setSetting($key, $value); $newValues[$key] = $value; } @@ -89,14 +82,10 @@ if ($section === 'formulaire_restrictions') { App::flash('success', "Degrés d'ouverture mis à jour."); } } elseif ($section === 'objet_types') { - $rawThese = $_POST['objet_these_enabled'] ?? '(missing)'; - $rawFrart = $_POST['objet_frart_enabled'] ?? '(missing)'; $newValues = [ 'objet_these_enabled' => empty($_POST['objet_these_enabled']) ? '0' : '1', 'objet_frart_enabled' => empty($_POST['objet_frart_enabled']) ? '0' : '1', ]; - error_log('[settings.php] SAVE objet_types | objet_these_enabled raw=' . var_export($rawThese, true) . ' | resolved=' . $newValues['objet_these_enabled']); - error_log('[settings.php] SAVE objet_types | objet_frart_enabled raw=' . var_export($rawFrart, true) . ' | resolved=' . $newValues['objet_frart_enabled']); $db->setSetting('objet_these_enabled', $newValues['objet_these_enabled']); $db->setSetting('objet_frart_enabled', $newValues['objet_frart_enabled']); $logger->logObjetTypesUpdate($newValues); diff --git a/app/public/admin/actions/smtp-test.php b/app/public/admin/actions/smtp-test.php index 1cf30ca..2ff5ebd 100644 --- a/app/public/admin/actions/smtp-test.php +++ b/app/public/admin/actions/smtp-test.php @@ -1,7 +1,6 @@ ' - . '', + '', formData: $formData, siteSettings: $siteSettings, helpBlocks: $helpBlocks, options: [ + 'thesisId' => $thesisId, 'filesMode' => 'edit', 'existingWebsiteUrl' => $existingWebsiteUrl, 'existingWebsiteLabel' => $existingWebsiteLabel, @@ -144,6 +144,8 @@ extract(FormBootstrap::adminFormVariables( 'currentFiles' => $currentFiles ?? [], 'currentContextNote' => $currentContextNote ?? null, 'currentContactVisible' => $currentContactVisible ?? null, + 'currentDurationValue' => $currentDurationValue ?? null, + 'currentDurationUnit' => $currentDurationUnit ?? 'pages', 'contactInterne' => $contactInterne ?? null, 'contactPublic' => $contactPublic ?? false, 'showCoverPreview' => true, @@ -158,6 +160,10 @@ $formData['license_id'] = $currentLicenseId; $formData['license_custom'] = $currentRaw['license_custom'] ?? ''; $formData['cc2r'] = $currentRaw['cc2r'] ?? false; +// Duration variables for the form template +$durationValue = $currentDurationValue ?? null; +$durationUnit = $currentDurationUnit ?? 'pages'; + // Asset arrays and page chrome $isAdmin = true; $bodyClass = 'admin-body'; diff --git a/app/public/admin/fragments/file-browser.php b/app/public/admin/fragments/file-browser.php index 4311d7e..10e0fb4 100644 --- a/app/public/admin/fragments/file-browser.php +++ b/app/public/admin/fragments/file-browser.php @@ -13,8 +13,6 @@ AdminAuth::requireLogin(); $storageRoot = STORAGE_ROOT; -error_log('[file-browser] ENTRY | dir=' . ($_GET['dir'] ?? '(root)') . ' | storageRoot=' . $storageRoot); - // Determine which directory to browse $relDir = trim($_GET['dir'] ?? '', '/'); if ($relDir !== '' && !preg_match('#^(tfe|these|frart|documents|theses)(/|$)#', $relDir)) { diff --git a/app/public/admin/index.php b/app/public/admin/index.php index a30c71d..dff438f 100644 --- a/app/public/admin/index.php +++ b/app/public/admin/index.php @@ -17,7 +17,6 @@ $importResults = []; $importDone = false; if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) { - error_log('[admin/index.php] IMPORT ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | csv_file_error=' . ($_FILES['csv_file']['error'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST))); if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { $importErrors[] = "Erreur de sécurité : token invalide."; } else { diff --git a/app/public/admin/login.php b/app/public/admin/login.php index 046f332..c71b7a8 100644 --- a/app/public/admin/login.php +++ b/app/public/admin/login.php @@ -2,8 +2,6 @@ require_once __DIR__ . '/../../bootstrap.php'; require_once __DIR__ . '/../../src/AdminAuth.php'; -error_log('[admin/login.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | is_auth=' . (AdminAuth::isAuthenticated() ? '1' : '0') . ' | has_password=' . (AdminAuth::hasPassword() ? '1' : '0')); - if (!AdminAuth::hasPassword()) { header('Location: /admin/'); exit; @@ -24,8 +22,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } $pageTitle = 'Connexion'; -$isAdmin = true; $bodyClass = 'admin-body'; +$isAdmin = true; $isLogin = true; $bodyClass = 'admin-body'; require_once APP_ROOT . '/templates/head.php'; include APP_ROOT . '/templates/header.php'; include APP_ROOT . '/templates/admin/login.php'; -require_once APP_ROOT . '/templates/admin/footer.php'; +// Login page does not render the admin footer (no toast-region poll, no HTMX extras). +// It closes directly so there is no dangling HTMX polling the toast endpoint +// while unauthenticated. +echo "\n\n"; diff --git a/app/public/admin/recapitulatif.php b/app/public/admin/recapitulatif.php index bc1ec34..46e60ae 100644 --- a/app/public/admin/recapitulatif.php +++ b/app/public/admin/recapitulatif.php @@ -24,7 +24,10 @@ if (isset($_GET['id'])) { if ($thesisId !== false && $thesisId > 0) { try { $db = new Database(); - $thesis = $db->getThesis($thesisId); + // Student-mode preview: only show published theses. + $thesis = $studentMode + ? $db->getThesisById($thesisId) + : $db->getThesis($thesisId); if (!$thesis) { $error = "TFE non trouvé."; diff --git a/app/public/admin/toast-fragment.php b/app/public/admin/toast-fragment.php index 7276599..806b631 100644 --- a/app/public/admin/toast-fragment.php +++ b/app/public/admin/toast-fragment.php @@ -9,7 +9,13 @@ require_once __DIR__ . '/../../bootstrap.php'; require_once __DIR__ . '/../../src/AdminAuth.php'; -AdminAuth::requireLogin(); +// Don't redirect unauthenticated requests — just return empty (defense-in-depth). +// The toast-region poll fires on ; if the user is on the +// login page they are not authenticated yet. +if (!AdminAuth::isAuthenticated()) { + http_response_code(204); + exit; +} $flash = App::consumeFlash(); diff --git a/app/public/assets/css/admin.css b/app/public/assets/css/admin.css index aad0f2d..2aeb5c2 100644 --- a/app/public/assets/css/admin.css +++ b/app/public/assets/css/admin.css @@ -541,7 +541,7 @@ th.admin-ap-col { .admin-body main > section[aria-labelledby^="settings-"] fieldset legend { padding: 0; - font-weight: 600; + font-weight: 400; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-secondary); @@ -1303,7 +1303,7 @@ th.admin-ap-col { flex: 1 1 260px; } .param-smtp-test-row label { - font-weight: 600; + font-weight: 400; margin-bottom: var(--space-2xs); color: var(--text-secondary); } @@ -1909,7 +1909,7 @@ th.admin-ap-col { .fhb-edit-label { display: block; font-size: var(--step--2); - font-weight: 500; + font-weight: 400; color: var(--text-primary); margin-bottom: var(--space-3xs); } diff --git a/app/public/assets/css/components/details.css b/app/public/assets/css/components/details.css index 7ee97a1..0b27741 100644 --- a/app/public/assets/css/components/details.css +++ b/app/public/assets/css/components/details.css @@ -13,23 +13,32 @@ details > summary::-webkit-details-marker { } details { - padding: var(--space-s); + border: 1px solid var(--border-color); + border-radius: var(--radius, 6px); + background: var(--bg-secondary, var(--surface)); + overflow: hidden; +} + +details[open] { + padding-bottom: var(--space-s); +} + +details > :not(summary) { + margin-left: var(--space-s); + margin-right: var(--space-s); } summary { font-family: var(--font-display); font-weight: 600; text-decoration: none; - color: var(--accent-secondary); - transition: color 0.15s; - - svg { - fill: var(--accent-secondary); - vertical-align: text-bottom; - height: 1.4em; - } + color: var(--text-primary); + transition: color 0.15s, background 0.15s; + padding: var(--space-s); + cursor: pointer; } summary:hover { color: var(--accent-primary); + background: var(--hover-bg, rgba(0, 0, 0, 0.03)); } diff --git a/app/public/assets/css/components/forms.css b/app/public/assets/css/components/forms.css index 7f424c1..bebdb60 100644 --- a/app/public/assets/css/components/forms.css +++ b/app/public/assets/css/components/forms.css @@ -9,6 +9,7 @@ label { display: block; margin-bottom: var(--space-3xs); + font-weight: 400; } /* ── Text inputs, selects, textareas ────────────────────────────────── */ @@ -95,7 +96,7 @@ fieldset > *:not(:last-child) { margin-bottom: var(--space-xs); } legend { font-size: var(--step--1); - font-weight: 600; + font-weight: 400; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-secondary); diff --git a/app/public/assets/css/file-access.css b/app/public/assets/css/file-access.css index 72dcfb3..c605b73 100644 --- a/app/public/assets/css/file-access.css +++ b/app/public/assets/css/file-access.css @@ -242,6 +242,6 @@ .admin-dialog label { display: block; - font-weight: 600; + font-weight: 400; margin-bottom: var(--space-3xs); } diff --git a/app/public/assets/css/form-admin.css b/app/public/assets/css/form-admin.css index a583011..b8c1c84 100644 --- a/app/public/assets/css/form-admin.css +++ b/app/public/assets/css/form-admin.css @@ -232,3 +232,68 @@ .file-browser-file .file-browser-select-btn:hover { background: var(--bg-secondary); } + +/* ── Full-page editor (contenus-edit overtype) ─────────────────────── */ + +/* Make the main content area a flex column so the form fills remaining height */ +.admin-body .full-editor-page { + display: flex; + flex-direction: column; +} + +.admin-form--full-editor { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.admin-form--full-editor #editor { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.admin-form--full-editor #editor .n-container { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.admin-form--full-editor #editor .n-wrapper { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.admin-form--full-editor #editor .n-input { + height: 100% !important; + overflow-y: auto !important; +} + +.admin-form--full-editor #editor .n-preview { + height: 100% !important; + overflow-y: auto !important; +} + +.full-editor-toolbar { + display: flex; + align-items: center; + gap: var(--space-s); + padding-bottom: var(--space-s); + border-bottom: 1px solid var(--border-primary); + margin-bottom: var(--space-s); + flex-shrink: 0; +} + +.full-editor-label { + font-size: var(--step--1); + font-weight: 600; + color: var(--text-secondary); +} + +.full-editor-toolbar .btn--primary { + margin-left: auto; +} diff --git a/app/public/assets/css/form-base.css b/app/public/assets/css/form-base.css index 4cd77ff..9919682 100644 --- a/app/public/assets/css/form-base.css +++ b/app/public/assets/css/form-base.css @@ -68,7 +68,7 @@ > label, .admin-form div:has(select:required) > label, .admin-form div:has(textarea:required) > label { - font-weight: 600; + font-weight: 400; } /* Asterisk on required field labels */ @@ -161,6 +161,19 @@ gap: var(--space-3xs); } +.admin-form-group--inline { + flex-direction: row; + align-items: end; + gap: var(--space-xs); + flex-wrap: wrap; +} + +.admin-form-group--inline > div { + display: flex; + flex-direction: column; + gap: var(--space-3xs); +} + /* ── Jury fieldset ──────────────────────────────────────────────────────── */ /* ── Website-URL inline fieldset (shown/hidden via HTMX) ────────────────── */ @@ -178,7 +191,7 @@ .admin-body fieldset fieldset.admin-jury-lecteurs > legend, .student-body fieldset fieldset.admin-jury-lecteurs > legend { font-size: var(--step--2); - font-weight: 600; + font-weight: 400; letter-spacing: 0.03em; text-transform: uppercase; color: var(--text-tertiary); @@ -845,7 +858,7 @@ .retry-email-form label { font-size: var(--step--1); - font-weight: 600; + font-weight: 400; } .retry-email-form input[type="email"] { @@ -1207,7 +1220,7 @@ legend { } .licence-options-fieldset legend { - font-weight: 600; + font-weight: 400; font-size: var(--step--1); color: var(--text-secondary); padding: 0 var(--space-2xs); @@ -1227,7 +1240,7 @@ legend { .admin-form > div:not(.admin-form-footer) > label, .admin-form > div:not(.admin-form-footer) > span.admin-row-label { padding-top: 0; - font-weight: 500; + font-weight: 400; } /* Labels inside fieldsets (checkbox/radio groups) */ diff --git a/app/public/assets/css/repertoire.css b/app/public/assets/css/repertoire.css index e16e32f..5fedb51 100644 --- a/app/public/assets/css/repertoire.css +++ b/app/public/assets/css/repertoire.css @@ -11,16 +11,16 @@ } /* ---- 6-column index layout ---- */ -/* Column fractions: years=0.4 ap=1 or=1.2 fi=0.7 students=1 kw=1 */ +/* Equal-width columns except Années (years) = narrower */ .repertoire-index { display: grid; grid-template-columns: - minmax(3rem, 0.4fr) - minmax(12rem, 1.4fr) - minmax(9rem, 0.8fr) - minmax(7rem, 0.8fr) - minmax(8rem, 0.7fr) - minmax(7rem, 1fr); + minmax(3rem, 0.45fr) + minmax(12rem, 1fr) + minmax(9rem, 1fr) + minmax(7rem, 1fr) + minmax(8rem, 1fr) + minmax(min-content, 1fr); grid-template-rows: auto 1fr; gap: var(--space-s); justify-content: space-between; @@ -78,14 +78,14 @@ .repertoire-col > h2 { grid-row: 1; font-family: var(--font-display); - font-size: var(--step-0); + font-size: var(--step--1); letter-spacing: 0.12em; text-transform: uppercase; - color: var(--text-secondary); + color: var(--text-primary); font-weight: 398; margin: 0; - padding: var(--space-xs) var(--space-2xs) var(--space-3xs); - border-bottom: 1px solid var(--border-secondary); + padding: var(--space-xs) 0 var(--space-3xs) 0; + border-bottom: 1px solid var(--text-primary); align-self: end; hyphens: manual; word-break: normal; @@ -96,7 +96,7 @@ grid-row: 2; overflow-y: auto; overflow-x: hidden; - padding: var(--space-2xs) var(--space-2xs) var(--space-l); + padding: var(--space-2xs) 0 var(--space-l) 0; } /* Strip list chrome inside repertoire columns */ diff --git a/app/public/assets/css/tfe.css b/app/public/assets/css/tfe.css index 408e876..e8b96cb 100644 --- a/app/public/assets/css/tfe.css +++ b/app/public/assets/css/tfe.css @@ -255,7 +255,7 @@ .tfe-access-request-form label { font-size: var(--step--1); - font-weight: 600; + font-weight: 400; color: var(--text-primary); } diff --git a/app/public/assets/js/app/autosave-handler.js b/app/public/assets/js/app/autosave-handler.js index 546ecfc..45e0d96 100644 --- a/app/public/assets/js/app/autosave-handler.js +++ b/app/public/assets/js/app/autosave-handler.js @@ -5,7 +5,14 @@ * parse errors instead of silently swallowing them (unlike the * old autosave.js .catch(() => {}) pattern). */ -function _handleAutosaveResponse(event) { +function handleAutosaveResponse(event) { + // Only handle responses from autosave endpoints (draft.php). + // The htmx:afterRequest event bubbles, so child elements' + // HTMX requests (e.g. licence fragment, pill-search) also + // reach this handler. We filter by URL to avoid mixing them. + const url = event.detail.requestConfig?.path || ""; + if (!url.includes("draft.php")) return; + const form = event.target.closest("form"); const status = form ? form.querySelector("[data-autosave-status]") : null; @@ -56,11 +63,17 @@ function _handleAutosaveResponse(event) { // Show saving indicator while request is in flight document.body.addEventListener("htmx:beforeRequest", (e) => { + const url = e.detail.requestConfig?.path || ""; + if (!url.includes("draft.php")) return; + // The autosave request comes from the hidden probe div, so find + // the status indicator by searching the closest form. const el = e.target; - if (!el) return; - const status = el.querySelector("[data-autosave-status]"); + const form = el?.closest?.("form"); + const status = form?.querySelector?.("[data-autosave-status]"); if (status) { status.textContent = "Enregistrement…"; status.className = "autosave-status autosave-status--saving"; } }); + + diff --git a/app/public/partage/index.php b/app/public/partage/index.php index 1a4baa0..a5204a1 100644 --- a/app/public/partage/index.php +++ b/app/public/partage/index.php @@ -277,7 +277,11 @@ function renderShareLinkForm(string $slug, array $link): void // Filter out PACS from AP programs for student forms (spec: admin-only AP) $apPrograms = array_values(array_filter($apPrograms, fn($ap) => ($ap['code'] ?? '') !== 'PACS')); - $formData = $_SESSION['form_data_share_' . $slug] ?? []; + // Hydrate form data from session draft (autosave). Flash repopulation + // (from validation redirects) takes priority over stale draft entries. + $draftKey = 'partage_draft_' . $slug; + $draftData = $_SESSION[$draftKey] ?? []; + $formData = array_merge($draftData, $_SESSION['form_data_share_' . $slug] ?? []); unset($_SESSION['form_data_share_' . $slug]); // Determine allowed objet values for this link @@ -324,7 +328,8 @@ function renderShareLinkForm(string $slug, array $link): void // ── Shared form variables ────────────────────────────────────────────── $mode = 'partage'; $formAction = '/partage/' . urlencode($slug) . '/submit'; - $hiddenFields = ''; + $hiddenFields = '' + . ''; $oldFn = $shareOldFn; $withAutofocusFn = $shareWithAutofocusFn; @@ -399,6 +404,11 @@ function renderShareLinkForm(string $slug, array $link): void $currentContextNote = null; $currentContactVisible = null; + // ── Autosave wiring ─────────────────────────────────────────────────┐ + $autosaveUrl = '/partage/fragments/draft.php?slug=' . urlencode($slug); + $formExtraAttrs = ''; + $showAutosaveStatus = true; + include APP_ROOT . '/templates/partage/form-page.php'; ?>
@@ -537,6 +547,8 @@ function handleShareLinkSubmission(string $slug): void unset($_SESSION['share_verified_' . $slug]); unset($_SESSION['share_active']); unset($_SESSION['share_primed_files_' . $slug]); + // Clear autosave draft — submission succeeded + unset($_SESSION['partage_draft_' . $slug]); // Clear FilePond temp file tracking — files have been moved to permanent storage unset($_SESSION['filepond_tmp']); diff --git a/app/public/partage/recapitulatif.php b/app/public/partage/recapitulatif.php index b0ea3a8..ce10165 100644 --- a/app/public/partage/recapitulatif.php +++ b/app/public/partage/recapitulatif.php @@ -18,7 +18,8 @@ if ($thesisId <= 0) { } $db = Database::getInstance(); -$thesis = $db->getThesis($thesisId); +// Only published theses are visible via public recap (no slug-auth here). +$thesis = $db->getThesisById($thesisId); if (!$thesis) { http_response_code(404); die('TFE introuvable.'); diff --git a/app/src/AdminAuth.php b/app/src/AdminAuth.php index 9460394..d699902 100644 --- a/app/src/AdminAuth.php +++ b/app/src/AdminAuth.php @@ -3,9 +3,7 @@ /** * Minimal PHP session guard for the admin panel. * - * This is a defence-in-depth layer that sits behind nginx Basic Auth. - * It protects against proxy misconfiguration, bypass, and local-dev - * scenarios where the reverse proxy may be absent. + * Password-only authentication via an HTML login form. * * The admin password hash is stored in the site_settings table * (key = 'admin_password_hash'). @@ -17,6 +15,10 @@ class AdminAuth private const SESSION_KEY = 'admin_authenticated'; private const LOGIN_URL = '/admin/login.php'; + // Throttle: max 5 attempts before mandatory delay, cooldown 15 min. + private const MAX_ATTEMPTS = 5; + private const COOLDOWN_MINUTES = 15; + /** * Start the PHP session with hardened cookie parameters. * Idempotent — safe to call even if session is already active. @@ -61,10 +63,7 @@ class AdminAuth * Authentication order: * 1. No password hash configured → dev mode, pass through. * 2. Session already authenticated → pass through. - * 3. nginx Basic Auth password present in $_SERVER['PHP_AUTH_PW'] - * → validate it with password_verify; on success create session - * (seamless: user only sees the browser Basic Auth dialog). - * 4. Neither → redirect to the PHP login form. + * 3. Neither → redirect to the PHP login form. */ public static function requireLogin(): void { @@ -76,11 +75,6 @@ class AdminAuth if (!empty($_SESSION[self::SESSION_KEY])) { return; // Already authenticated via session. } - // Try to auto-authenticate from the nginx Basic Auth credentials. - if (isset($_SERVER['PHP_AUTH_PW']) && self::verifyHash($_SERVER['PHP_AUTH_PW'], $storedHash)) { - $_SESSION[self::SESSION_KEY] = true; - return; - } header('Location: ' . self::LOGIN_URL); exit; } @@ -89,15 +83,54 @@ class AdminAuth * Validate a plaintext password against the stored hash. * On success: regenerates the session ID and marks the session authenticated. * + * Throttling: after MAX_ATTEMPTS consecutive failures, a mandatory delay is + * enforced (incremental: 1s, 2s, 4s, … up to 60s). Returns the same `false` + * result as a wrong password so the attacker cannot distinguish the reason. + * * @return bool true on success, false on wrong password / no hash stored. */ public static function login(string $password): bool { $storedHash = self::getStoredHash(); - if ($storedHash === null || !self::verifyHash($password, $storedHash)) { + if ($storedHash === null) { return false; } + self::startSession(); + $alreadyAuthed = !empty($_SESSION[self::SESSION_KEY]); + + // ── Throttle: only on unauthenticated login attempts ──────────────── + if (!$alreadyAuthed) { + $attempts = (int) ($_SESSION['auth_attempts'] ?? 0); + $firstAt = (int) ($_SESSION['auth_first_attempt'] ?? 0); + $now = time(); + + // Cooldown window — reset after COOLDOWN_MINUTES + if ($attempts > 0 && ($now - $firstAt) > self::COOLDOWN_MINUTES * 60) { + $attempts = 0; + $firstAt = 0; + unset($_SESSION['auth_attempts'], $_SESSION['auth_first_attempt']); + } + + if ($attempts >= self::MAX_ATTEMPTS) { + $extra = $attempts - self::MAX_ATTEMPTS; + $delay = min(1 << min($extra, 6), 60); // 1s → 2s → 4s … → 60s cap + sleep($delay); + } + } + + if (!self::verifyHash($password, $storedHash)) { + if (!$alreadyAuthed) { + if ($attempts === 0) { + $_SESSION['auth_first_attempt'] = $now; + } + $_SESSION['auth_attempts'] = $attempts + 1; + } + return false; + } + + // ── Success: clear throttling, create/refresh session ────────────── + unset($_SESSION['auth_attempts'], $_SESSION['auth_first_attempt']); session_regenerate_id(true); $_SESSION[self::SESSION_KEY] = true; $_SESSION['admin_login_at'] = time(); @@ -145,12 +178,6 @@ class AdminAuth if (!empty($_SESSION[self::SESSION_KEY])) { return true; } - // Also accept nginx Basic Auth credentials directly (e.g. HTMX fragment - // requests that arrive before a PHP session has been established). - if (isset($_SERVER['PHP_AUTH_PW']) && self::verifyHash($_SERVER['PHP_AUTH_PW'], $storedHash)) { - $_SESSION[self::SESSION_KEY] = true; - return true; - } return false; } diff --git a/app/src/Controllers/CharteController.php b/app/src/Controllers/CharteController.php index 8e480b8..5031401 100644 --- a/app/src/Controllers/CharteController.php +++ b/app/src/Controllers/CharteController.php @@ -3,6 +3,7 @@ require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/ErrorHandler.php'; require_once APP_ROOT . '/src/EmailObfuscator.php'; +require_once APP_ROOT . '/src/MarkdownHelper.php'; use League\CommonMark\CommonMarkConverter; @@ -29,9 +30,12 @@ class CharteController $converter = new CommonMarkConverter(['html_input' => 'strip']); $html = EmailObfuscator::obfuscateHtml($converter->convert($content)->getContent()); + $tocItems = MarkdownHelper::extractToc($content); + return [ 'content' => $content, 'html' => $html, + 'tocItems' => $tocItems, 'pageTitle' => $pageTitle . ' – XAMXAM', 'metaDescription' => "Charte d'utilisation de XAMXAM, le répertoire des TFE de l'erg.", 'currentNav' => 'charte', @@ -39,4 +43,5 @@ class CharteController 'bodyClass' => 'apropos-body', ]; } + } diff --git a/app/src/Controllers/LicenceController.php b/app/src/Controllers/LicenceController.php index 82d8caf..aa1232a 100644 --- a/app/src/Controllers/LicenceController.php +++ b/app/src/Controllers/LicenceController.php @@ -3,6 +3,7 @@ require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/ErrorHandler.php'; require_once APP_ROOT . '/src/EmailObfuscator.php'; +require_once APP_ROOT . '/src/MarkdownHelper.php'; use League\CommonMark\CommonMarkConverter; @@ -29,9 +30,12 @@ class LicenceController $converter = new CommonMarkConverter(['html_input' => 'strip']); $html = EmailObfuscator::obfuscateHtml($converter->convert($content)->getContent()); + $tocItems = MarkdownHelper::extractToc($content); + return [ 'content' => $content, 'html' => $html, + 'tocItems' => $tocItems, 'pageTitle' => $pageTitle . ' – XAMXAM', 'metaDescription' => "Informations sur les licences d'utilisation des mémoires publiés sur XAMXAM, le répertoire des TFE de l'erg.", 'currentNav' => 'licence', @@ -39,4 +43,5 @@ class LicenceController 'bodyClass' => 'apropos-body', ]; } + } diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index 2f2ddbc..1978c38 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -162,6 +162,8 @@ class ThesisCreateController 'exemplaire_baiu' => $data['exemplaireBaiu'], 'exemplaire_erg' => $data['exemplaireErg'], 'cc2r' => $data['cc2r'], + 'duration_value' => $data['durationValue'], + 'duration_unit' => $data['durationUnit'], ]); $identifier = $this->db->getThesisIdentifier($thesisId); @@ -548,6 +550,22 @@ class ThesisCreateController $exemplaireErg = !empty($post['exemplaire_erg']); $cc2r = !empty($post['cc2r']); + // Duration: numeric value + unit (optional, admin-validated) + $validDurationUnits = ['pages', 'minutes', 'sec', 'heures', 'mo']; + $durationValue = $post['duration_value'] ?? null; + $durationUnit = $post['duration_unit'] ?? 'pages'; + if ($durationValue !== null && $durationValue !== '') { + $durationValue = filter_var($durationValue, FILTER_VALIDATE_FLOAT); + if ($durationValue === false || $durationValue <= 0) { + $durationValue = null; // ignore invalid + } + } else { + $durationValue = null; + } + if (!in_array($durationUnit, $validDurationUnits, true)) { + $durationUnit = 'pages'; + } + // Annexes are optional — no validation required $hasAnnexes = !empty($post['has_annexes']); @@ -577,7 +595,9 @@ class ThesisCreateController 'juryPoints', 'exemplaireBaiu', 'exemplaireErg', - 'cc2r' + 'cc2r', + 'durationValue', + 'durationUnit' ); } diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 2703438..6b31829 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -109,6 +109,8 @@ class ThesisEditController $currentAccessTypeId = $rawRow['access_type_id'] ?? null; $currentContextNote = $rawRow['context_note'] ?? ''; $currentContactVisible = $rawRow['contact_visible'] ?? ''; + $currentDurationValue = $rawRow['duration_value'] ?? null; + $currentDurationUnit = $rawRow['duration_unit'] ?? 'pages'; // Author contact info (from view) $contactInterne = $thesis['contact_interne'] ?? ''; @@ -132,6 +134,8 @@ class ThesisEditController 'currentAccessTypeId' => $currentAccessTypeId, 'currentContextNote' => $currentContextNote, 'currentContactVisible' => $currentContactVisible, + 'currentDurationValue' => $currentDurationValue, + 'currentDurationUnit' => $currentDurationUnit, 'contactInterne' => $contactInterne, 'contactPublic' => $contactPublic, 'currentRaw' => $rawRow, @@ -219,6 +223,8 @@ class ThesisEditController 'exemplaire_erg' => !empty($post['exemplaire_erg']), 'cc2r' => !empty($post['cc2r']), 'license_custom' => trim($post['license_custom'] ?? ''), + 'duration_value' => isset($post['duration_value']) && $post['duration_value'] !== '' ? (float)$post['duration_value'] : null, + 'duration_unit' => !empty($post['duration_unit']) ? $post['duration_unit'] : 'pages', ]; // Regenerate identifier if year changed or if identifier prefix doesn't match year $oldThesis = $this->db->getThesis($thesisId); diff --git a/app/src/Database.php b/app/src/Database.php index e950ce8..08b032c 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -41,18 +41,7 @@ class Database */ private function runMigrations(): void { - // Add 'name' column to share_links if missing - try { - $this->pdo->exec('ALTER TABLE share_links ADD COLUMN name TEXT'); - } catch (\PDOException $e) { - // Column already exists — ignore - } - // Add 'locked_year' column to share_links if missing - try { - $this->pdo->exec('ALTER TABLE share_links ADD COLUMN locked_year INTEGER'); - } catch (\PDOException $e) { - // Column already exists — ignore - } + (new DatabaseMigrations($this->pdo))->run(); } /** @@ -2037,7 +2026,7 @@ class Database public function getThesisRawFields(int $thesisId): ?array { $stmt = $this->pdo->prepare( - 'SELECT license_id, license_custom, access_type_id, context_note, contact_visible, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc2r, is_published FROM theses WHERE id = ? LIMIT 1' + 'SELECT license_id, license_custom, access_type_id, context_note, contact_visible, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc2r, duration_value, duration_unit, is_published FROM theses WHERE id = ? LIMIT 1' ); $stmt->execute([$thesisId]); $row = $stmt->fetch(); @@ -2181,6 +2170,8 @@ class Database exemplaire_baiu = ?, exemplaire_erg = ?, cc2r = ?, + duration_value = ?, + duration_unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? "); @@ -2212,6 +2203,8 @@ class Database !empty($data['exemplaire_baiu']) ? 1 : 0, !empty($data['exemplaire_erg']) ? 1 : 0, !empty($data['cc2r']) ? 1 : 0, + isset($data['duration_value']) && $data['duration_value'] !== '' ? (float)$data['duration_value'] : null, + !empty($data['duration_unit']) ? $data['duration_unit'] : 'pages', $thesisId, ]); $stmt->execute($params); @@ -2262,8 +2255,9 @@ class Database remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc2r, + duration_value, duration_unit, submitted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) '); $validObjet = ['tfe', 'thèse', 'frart']; @@ -2296,6 +2290,8 @@ class Database !empty($data['exemplaire_baiu']) ? 1 : 0, !empty($data['exemplaire_erg']) ? 1 : 0, !empty($data['cc2r']) ? 1 : 0, + isset($data['duration_value']) && $data['duration_value'] !== '' ? (float)$data['duration_value'] : null, + !empty($data['duration_unit']) ? $data['duration_unit'] : 'pages', ]); $newId = (int)$this->pdo->lastInsertId(); diff --git a/app/src/DatabaseMigrations.php b/app/src/DatabaseMigrations.php new file mode 100644 index 0000000..9025a9d --- /dev/null +++ b/app/src/DatabaseMigrations.php @@ -0,0 +1,92 @@ +pdo = $pdo; + } + + /** + * Run all pending migrations. + */ + public function run(): void + { + $this->migrateRenameFinalityTypes(); + $this->migrateShareLinksNameColumn(); + $this->migrateShareLinksLockedYearColumn(); + } + + /** + * 2026-06-11 — Rename finality types + relink theses. + * + * Spécialisé → Spécialisée + * Approfondi → Approfondie + * Enseignement → Didactique + */ + private function migrateRenameFinalityTypes(): void + { + try { + $renames = [ + 'Spécialisé' => 'Spécialisée', + 'Approfondi' => 'Approfondie', + 'Enseignement' => 'Didactique', + ]; + foreach ($renames as $old => $new) { + // Skip if only canonical row already exists + $oldCount = $this->pdo->query("SELECT COUNT(*) FROM finality_types WHERE name = '$old'")->fetchColumn(); + if ($oldCount == 0) { + continue; + } + // Relink theses from old row to canonical row (create if needed) + $canonical = $this->pdo->query("SELECT id FROM finality_types WHERE name = '$new'")->fetchColumn(); + if (!$canonical) { + $this->pdo->exec("INSERT INTO finality_types (name) VALUES ('$new')"); + $canonical = $this->pdo->lastInsertId(); + } + // Relink + $this->pdo->exec(" + UPDATE theses SET finality_id = $canonical + WHERE finality_id IN (SELECT id FROM finality_types WHERE name = '$old') + "); + // Delete old row + $this->pdo->exec("DELETE FROM finality_types WHERE name = '$old'"); + } + } catch (\PDOException $e) { + // Table may not exist yet on fresh install — ignore + } + } + + /** + * Add 'name' column to share_links if missing. + */ + private function migrateShareLinksNameColumn(): void + { + try { + $this->pdo->exec('ALTER TABLE share_links ADD COLUMN name TEXT'); + } catch (\PDOException $e) { + // Column already exists — ignore + } + } + + /** + * Add 'locked_year' column to share_links if missing. + */ + private function migrateShareLinksLockedYearColumn(): void + { + try { + $this->pdo->exec('ALTER TABLE share_links ADD COLUMN locked_year INTEGER'); + } catch (\PDOException $e) { + // Column already exists — ignore + } + } +} diff --git a/app/src/FilepondHandler.php b/app/src/FilepondHandler.php index 4132f58..40007dd 100644 --- a/app/src/FilepondHandler.php +++ b/app/src/FilepondHandler.php @@ -7,7 +7,7 @@ * called from both the admin panel and the student partage form. * * Auth is checked by the caller before invoking these methods: - * - Admin endpoints: nginx auth_basic + AdminAuth::requireLogin() + * - Admin endpoints: AdminAuth::requireLogin() * - Partagé endpoints: session_start() + verify share_active + CSRF * * All paths in this file assume the session is already started and CSRF is diff --git a/app/src/Form/FormBootstrap.php b/app/src/Form/FormBootstrap.php index 4234d16..e4e863a 100644 --- a/app/src/Form/FormBootstrap.php +++ b/app/src/Form/FormBootstrap.php @@ -32,6 +32,7 @@ class FormBootstrap '/assets/js/app/beforeunload-guard.js', '/assets/js/app/pill-search.js', '/assets/js/app/jury-autocomplete.js', + '/assets/js/app/autosave-handler.js', ], ]; } @@ -117,13 +118,47 @@ class FormBootstrap $generalitiesHtml = $helpFn('fieldset_generalites'); $defaultAccessTypeId = $options['defaultAccessTypeId'] ?? 2; + // ── Autosave draft wiring (add / edit only) ─────────────────────┐ + $autosaveUrl = '/admin/actions/draft.php'; + $formExtraAttrs = ''; + $showAutosaveStatus = false; + $extraHidden = ''; + if ($mode === 'add') { + // Reuse draft token from session so drafts survive page reloads + if (empty($_SESSION['admin_draft_add_token'])) { + $_SESSION['admin_draft_add_token'] = bin2hex(random_bytes(8)); + } + $draftToken = $_SESSION['admin_draft_add_token']; + $draftKey = 'admin_draft_' . $draftToken; + $extraHidden = ''; + // Hydrate from any previous session (survives accidental navigations) + $draft = $_SESSION[$draftKey] ?? []; + $formData = array_merge($draft, $formData); + $showAutosaveStatus = true; + } elseif ($mode === 'edit') { + $thesisId = (int)($options['thesisId'] ?? 0); + if ($thesisId > 0) { + $draftKey = 'admin_draft_edit_' . $thesisId; + $extraHidden = ''; + $draft = $_SESSION[$draftKey] ?? []; + $formData = array_merge($draft, $formData); + $showAutosaveStatus = true; + } + } + if ($showAutosaveStatus) { + $formExtraAttrs = 'hx-post="' . htmlspecialchars($autosaveUrl) . '"'; + } + return array_merge([ // Base 'mode' => $mode, 'formAction' => $formAction, - 'hiddenFields' => $hiddenFields, + 'hiddenFields' => $hiddenFields . $extraHidden, 'errorFieldName' => $autofocusField, 'synopsisExtra' => $options['synopsisExtra'] ?? '', + 'formExtraAttrs' => $formExtraAttrs, + 'showAutosaveStatus' => $showAutosaveStatus, + 'autosaveUrl' => $autosaveUrl, // Helpers 'helpFn' => $helpFn, @@ -174,6 +209,8 @@ class FormBootstrap 'contactPublic' => false, 'currentContextNote' => null, 'currentContactVisible' => null, + 'currentDurationValue' => null, + 'currentDurationUnit' => 'pages', // Files (edit mode) 'currentCover' => null, diff --git a/app/src/MarkdownHelper.php b/app/src/MarkdownHelper.php new file mode 100644 index 0000000..a594ca9 --- /dev/null +++ b/app/src/MarkdownHelper.php @@ -0,0 +1,34 @@ + + */ + public static function extractToc(string $content): array + { + $items = []; + $lines = explode("\n", $content); + foreach ($lines as $line) { + if (preg_match('/^#{1,3}\s+(.+)$/', $line, $m)) { + $label = trim($m[1]); + // Replicate CommonMark's default heading ID generation: + // lowercase, strip non-word chars (except hyphens/spaces), spaces→hyphens + $id = strtolower($label); + $id = preg_replace('/[^\w\s-]/u', '', $id); + $id = preg_replace('/\s+/', '-', $id); + $id = trim($id, '-'); + $items[] = ['label' => $label, 'href' => '#' . $id]; + } + } + return $items; + } +} diff --git a/app/src/ShareLink.php b/app/src/ShareLink.php index cdd2a2e..570b9b9 100644 --- a/app/src/ShareLink.php +++ b/app/src/ShareLink.php @@ -228,6 +228,16 @@ class ShareLink )->execute([$id]); } + /** + * Permanently delete an archived share link. + */ + public function delete(int $id): void + { + $this->db->getConnection()->prepare( + 'DELETE FROM share_links WHERE id = ? AND is_archived = 1' + )->execute([$id]); + } + /** * Increment the usage count for a share link. */ diff --git a/app/storage/schema.sql b/app/storage/schema.sql index 467b9cb..ff1a9cc 100644 --- a/app/storage/schema.sql +++ b/app/storage/schema.sql @@ -89,6 +89,8 @@ CREATE TABLE IF NOT EXISTS theses ( context_note TEXT, contact_visible TEXT DEFAULT NULL, remarks TEXT, + duration_value REAL, + duration_unit TEXT DEFAULT 'pages', access_type_id INTEGER, license_id INTEGER, jury_points DECIMAL(4,2), @@ -410,6 +412,8 @@ SELECT t.synopsis, t.context_note, t.contact_visible, + t.duration_value, + t.duration_unit, at.name as access_type, lt.name as license_type, t.license_id, @@ -526,9 +530,9 @@ INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Atelier Pratiques Situé INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Lieux, Interdisciplinarités, Écologie, Nécessité, Systèmes', 'LIENS'); INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Pratique de l''art - outils critiques, arts et contexte simultanés', 'PACS'); -INSERT OR IGNORE INTO finality_types (name) VALUES ('Approfondi'); -INSERT OR IGNORE INTO finality_types (name) VALUES ('Enseignement'); -INSERT OR IGNORE INTO finality_types (name) VALUES ('Spécialisé'); +INSERT OR IGNORE INTO finality_types (name) VALUES ('Approfondie'); +INSERT OR IGNORE INTO finality_types (name) VALUES ('Didactique'); +INSERT OR IGNORE INTO finality_types (name) VALUES ('Spécialisée'); INSERT OR IGNORE INTO languages (name) VALUES ('français'); INSERT OR IGNORE INTO languages (name) VALUES ('anglais'); @@ -563,6 +567,7 @@ INSERT OR IGNORE INTO site_settings (key, value) VALUES ('objet_frart_enabled', INSERT OR IGNORE INTO site_settings (key, value) VALUES ('objet_these_enabled', '0'); INSERT OR IGNORE INTO site_settings (key, value) VALUES ('peertube_upload_enabled', '1'); INSERT OR IGNORE INTO site_settings (key, value) VALUES ('restricted_files_enabled', '1'); +INSERT OR IGNORE INTO site_settings (key, value) VALUES ('admin_password_hash', ''); INSERT OR IGNORE INTO pages (slug, title, content, is_published) VALUES ('about', 'À propos', 'Contenu à venir', 1); INSERT OR IGNORE INTO pages (slug, title, content, is_published) VALUES ('charte', 'Charte', 'Contenu à venir', 1); diff --git a/app/storage/schema.sql.new b/app/storage/schema.sql.new index ec029ff..5762309 100644 --- a/app/storage/schema.sql.new +++ b/app/storage/schema.sql.new @@ -1318,6 +1318,8 @@ CREATE TABLE IF NOT EXISTS theses ( remarks TEXT, -- Internal remarks -- Duration/size + duration_value REAL, + duration_unit TEXT DEFAULT 'pages', access_type_id INTEGER, license_id INTEGER, @@ -4347,6 +4349,8 @@ SELECT ft.name as finality_type, t.synopsis, t.context_note, + t.duration_value, + t.duration_unit, at.name as access_type, lt.name as license_type, t.license_id, diff --git a/app/templates/admin/acces.php b/app/templates/admin/acces.php index 3123722..e11a678 100644 --- a/app/templates/admin/acces.php +++ b/app/templates/admin/acces.php @@ -152,6 +152,7 @@ Utilisations Expiration Créé le + Actions @@ -175,6 +176,12 @@ + + + @@ -641,6 +648,10 @@ function _executeArchiveLink() { const form = document.getElementById('archive-link-form-' + _pendingArchiveLinkId); if (form) form.submit(); } +function openDeleteArchivedLinkDialog(id) { + document.getElementById('delete-archived-link-id').value = id; + document.getElementById('delete-archived-link-dialog').showModal(); +} @@ -658,3 +669,24 @@ function _executeArchiveLink() { + + + +
+ + +
+
+

Supprimer définitivement ce lien archivé ? Cette action est irréversible.

+
+ +
diff --git a/app/templates/admin/contenus-edit.php b/app/templates/admin/contenus-edit.php index 9f0033c..b3e6dc9 100644 --- a/app/templates/admin/contenus-edit.php +++ b/app/templates/admin/contenus-edit.php @@ -1,30 +1,28 @@ -
+

Éditer :

Contenu de la page

-
+ "> - - +
+ Contenu (Markdown) : + + +
-
@@ -137,50 +135,46 @@ -
+ "> - - +
+ Contenu (Markdown) : + + +
-

Ce texte est affiché dans le formulaire de soumission des étudiant·es (lien de partage). Supporte le Markdown.

-
+ - - +
+ Contenu (Markdown) : + + +
-
diff --git a/app/templates/admin/index.php b/app/templates/admin/index.php index f9f39aa..c603758 100644 --- a/app/templates/admin/index.php +++ b/app/templates/admin/index.php @@ -69,14 +69,6 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input - + ✕ Réinitialiser diff --git a/app/templates/admin/parametres.php b/app/templates/admin/parametres.php index 1245804..bfb707b 100644 --- a/app/templates/admin/parametres.php +++ b/app/templates/admin/parametres.php @@ -360,14 +360,14 @@
- Supprimer la configuration du mot de passe PHP + Supprimer la configuration du mot de passe

Supprime le hash de la base de données. L'accès admin - dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée. + ne sera plus protégé (mode développement).

modal like the other confirmations */ ?>
+ onsubmit="return confirm('Supprimer le mot de passe ? L\'accès admin ne sera plus protégé.')"> diff --git a/app/templates/header.php b/app/templates/header.php index 789eb6b..8d5198b 100644 --- a/app/templates/header.php +++ b/app/templates/header.php @@ -2,6 +2,7 @@ // header.php — unified site header for public and admin sections. // Reads: $isAdmin (bool), $currentNav (string, public only) $_isAdmin = !empty($isAdmin); +$_isLogin = !empty($isLogin); $_navCurrent = $currentNav ?? ''; $_currentPage = basename($_SERVER['PHP_SELF']); $_thesisId = $_GET['id'] ?? null; @@ -9,7 +10,7 @@ $_thesisId = $_GET['id'] ?? null;
- +
- +

Section administrateur

@@ -95,7 +96,7 @@ $_thesisId = $_GET['id'] ?? null;
- + + diff --git a/app/templates/partials/form/checkbox-list.php b/app/templates/partials/form/checkbox-list.php index 9b8c68b..0ca6dfc 100644 --- a/app/templates/partials/form/checkbox-list.php +++ b/app/templates/partials/form/checkbox-list.php @@ -64,4 +64,6 @@ $ariaDescribedBy = ($errorFieldName === $name) ? ' aria-describedby="flash-error
' aria-describedby="couverture-hint"> - JPG, PNG ou WEBP. Format 4:3 recommandé. Max 20 MB. + JPG, PNG ou WEBP. Format 4:3 recommandé (ex. 1200 × 900 px). Max 20 MB.
diff --git a/app/templates/public/licence.php b/app/templates/public/licence.php index ba4d4da..10ecf6a 100644 --- a/app/templates/public/licence.php +++ b/app/templates/public/licence.php @@ -1,9 +1,30 @@
-
- - - -

Contenu à venir.

+
+ + + + + + +
+
+
+ + + +

Contenu à venir.

+ +
+
+
+
diff --git a/app/templates/public/search.php b/app/templates/public/search.php index 610ee3d..f5bd01c 100644 --- a/app/templates/public/search.php +++ b/app/templates/public/search.php @@ -17,18 +17,6 @@ - - + +