diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index 08840a1..88f0298 100644 --- a/.php-cs-fixer.cache +++ b/.php-cs-fixer.cache @@ -1 +1 @@ -{"php":"8.3.6","version":"3.89.1:v3.89.1#f34967da2866ace090a2b447de1f357356474573","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"strict_param":true,"declare_strict_types":true,"no_unused_imports":true,"single_quote":true},"hashes":{"templates\/header.php":"f4b64c4ecb4dadec166cb7935309096a","templates\/footer.php":"3111b4701ea698ba11c3423260657e28","public\/login\/oidc.php":"8ec86d6f3af33f64d586109ec17f817d","public\/login\/config.php":"5b7b3e2937b349c76a2fd239c3ae06f8","src\/Infrastructure\/DbAdapter.php":"3899a835130c146e2d30dbcca88d8f33","src\/Infrastructure\/Database.php":"6f2848ed70b29d9c2e2d259be611b9b0","src\/Infrastructure\/Session.php":"3538a1147cc81678c470d45ea8574a95","src\/Domain\/User.php":"02213454f7edf43f4afae3f2f81aaf01","src\/Http\/Csrf.php":"55631812cab4b1192f8e30c5d35fd5eb","src\/FileManager.php":"a51dda44f293f238aea295fd56b2fa99","src\/PostManager.php":"25f0179c4d96e9aa04218d54bf45a029","src\/helpers.php":"3a83a4872b1e3e3c58898b54f51e72b4","src\/db.php":"8888b7fbc9740eb3c60dd2374d0cb5d6","src\/ConfigRepo.php":"c2dcee160a272d27725d480a90e76dcf","src\/Parsedown.php":"85da2b47eca1a703fdfe44753bf912df","src\/Service\/Validator.php":"7c267b8b9f3f1bac0f2520dd10364831","src\/Service\/UiFormRenderer.php":"065617191c6d680ce97588f4fa159688","src\/mailer.php":"17e6b19103c880cc9a6c6634486506c2","src\/Repository\/DictionnaryRepository.php":"f937e98cf0f27b59ae00e430b52a586d","src\/Repository\/ProfileRepository.php":"b1cd483652500ee4e2aaaa9e0330ff1d","versions.php":"51a72261e1a507d3435b4a24e5f5fc09","config\/config.php":"a8b7698b01ab9b40eea655e8fcc194fc","phpstan-bootstrap.php":"d74864c2f107b740523f070d077d715e","src\/Service\/MailQueue.php":"20db418b83dcf426b7c6ad6787644cde","src\/Service\/AuthService.php":"f95a9ab097dcfc4ac6cbcf908cf4cd90","src\/Repository\/UserRepository.php":"d0ccc80374b54a5c4f20cb04c00fb083","src\/Service\/MailService.php":"7bff5df8cac3274a1a4ab8fe137514f2","templates\/licenses.php":"c1b8db32773de42bb4392063c50155e2","bootstrap.php":"23e657accae3b860a9ffc227daa30d27","public\/file.php":"4e9de9cbe565e895e7bd809028754cb9","public\/logout.php":"68bc31b06a9e23aa7a43ca7642365bcd","src\/ArticleManager.php":"43e0061b20b22037596aeac8c96de3ae","templates\/post_view.php":"73d3588e0b4c3470e4ce5b6182dfb2fa","templates\/post_list.php":"5ad535f5bdb6de8a6cf9630cefc48e58","templates\/layout.php":"5a9c8a96723eabafc186d6c23dd5cfa3","templates\/contact.php":"9d2026fd4250e0cef3ff736812cd2b12","templates\/post_form.php":"b444d7937eaa5d3b068e25d5d14bc2f7","templates\/legal.php":"b8eaf0c118bb06c92171aaecca091550","templates\/about.php":"022d078aaa40d2067352c81509144182","public\/login\/magic.php":"3d9447ed551e6401c3d43429b1600247","public\/login\/index.php":"7a27b066b232e943e0a98850f0993c52","public\/route.php":"8a478892d21a95352cae93b85169f424","public\/oidc\/callback.php":"be3e7a4e00b40d956c6a54de38fcfe50","public\/oidc\/start.php":"0ecd2496ed9b591e177abe7354a7809f","public\/oidc\/me.php":"d979853cfab834a645ed0c6df5297ed7","public\/index.php":"d48d76494172e7f172ef82010ea8ce93","src\/auth.php":"73d056745086cd0e63053eab808d2b5e"}} \ No newline at end of file +{"php":"8.3.6","version":"3.89.1:v3.89.1#f34967da2866ace090a2b447de1f357356474573","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"strict_param":true,"declare_strict_types":true,"no_unused_imports":true,"single_quote":true},"hashes":{"templates\/header.php":"f4b64c4ecb4dadec166cb7935309096a","templates\/footer.php":"3111b4701ea698ba11c3423260657e28","public\/login\/oidc.php":"8ec86d6f3af33f64d586109ec17f817d","public\/login\/config.php":"5b7b3e2937b349c76a2fd239c3ae06f8","src\/Infrastructure\/DbAdapter.php":"3899a835130c146e2d30dbcca88d8f33","src\/Infrastructure\/Database.php":"6f2848ed70b29d9c2e2d259be611b9b0","src\/Infrastructure\/Session.php":"3538a1147cc81678c470d45ea8574a95","src\/Domain\/User.php":"02213454f7edf43f4afae3f2f81aaf01","src\/Http\/Csrf.php":"55631812cab4b1192f8e30c5d35fd5eb","src\/FileManager.php":"a51dda44f293f238aea295fd56b2fa99","src\/PostManager.php":"25f0179c4d96e9aa04218d54bf45a029","src\/db.php":"8888b7fbc9740eb3c60dd2374d0cb5d6","src\/ConfigRepo.php":"c2dcee160a272d27725d480a90e76dcf","src\/Parsedown.php":"85da2b47eca1a703fdfe44753bf912df","src\/Service\/Validator.php":"7c267b8b9f3f1bac0f2520dd10364831","src\/Service\/UiFormRenderer.php":"065617191c6d680ce97588f4fa159688","src\/Repository\/DictionnaryRepository.php":"f937e98cf0f27b59ae00e430b52a586d","src\/Repository\/ProfileRepository.php":"b1cd483652500ee4e2aaaa9e0330ff1d","phpstan-bootstrap.php":"d74864c2f107b740523f070d077d715e","src\/Service\/MailQueue.php":"20db418b83dcf426b7c6ad6787644cde","src\/Service\/AuthService.php":"f95a9ab097dcfc4ac6cbcf908cf4cd90","src\/Repository\/UserRepository.php":"d0ccc80374b54a5c4f20cb04c00fb083","src\/Service\/MailService.php":"7bff5df8cac3274a1a4ab8fe137514f2","public\/file.php":"4e9de9cbe565e895e7bd809028754cb9","public\/logout.php":"68bc31b06a9e23aa7a43ca7642365bcd","public\/login\/magic.php":"3d9447ed551e6401c3d43429b1600247","public\/route.php":"8a478892d21a95352cae93b85169f424","database\/migrate.php":"259bacce606e05eadfa5d6ca8f5fe0d5","database\/migrate-init.php":"55c7b9bf5fb04a2ba434b2b6a59a87c6","templates\/author_articles.php":"dc9d7ebade3f7b8c551c5f99a5439509","templates\/sources.php":"42aa657413768450b5d39d0c540fb80a","templates\/comments_section.php":"652632dbfd033d7c6910bccfeaeed62b","templates\/edit_tags.php":"a06766336e2904292821af40679f4e36","templates\/import_image.php":"273bf10d41a750e750c21f5d68d109e7","templates\/flux.php":"f5895686e72c8bce875fbb3099cb75b2","templates\/author_profile.php":"c85167ec05d9e75bcd14459c9b98fbca","templates\/admin_role_edit.php":"eb5cfd7fc3f89a696871feb6fddb9e73","templates\/profile.php":"02bc0f8e3b6ff4cfb0f65b4bf5c11dc1","templates\/admin.php":"4c4ce239c7286879b48d33e7e5f0f9dd","templates\/404.php":"af571fc91a824de8c14d505e989e1423","templates\/post_confirm.php":"8b2ed7733d6384a2c9164a69545886a0","templates\/categories.php":"9e5bc9719de64c9e3cb37d2842920d7e","templates\/search.php":"9370b3a475966b36afa1fc6fb38b250d","templates\/liens.php":"2f597438502f97c43880c8574d7ef872","templates\/add_files.php":"85169163967fe62fdd63260bdbeea8c4","templates\/import_image_step2.php":"7a8b7087c39d947b844fe6708b888a89","templates\/diff.php":"0d0b379522675cb33719a27e092555a0","templates\/copyright_ack.php":"94524eaf5f07e4e0b99b39a39a36dedb","public\/sitemap.php":"4d81e9f04290e5160c5e9216fc4997ad","public\/feed.php":"fdbb086c3f9002373b22fdf25b29a861","src\/CommentManager.php":"6f2660a76b738226d8c44e0c411a9fad","src\/RatingManager.php":"4f1dd5924facfab0e0d60c30ae9b31af","src\/FeedFetcher.php":"e03c0e6a24715dc204c61d74ab5bdbec","src\/ReactionManager.php":"1e383e3fb6dc68d91f0c4ebffea8df8b","src\/SmtpSettings.php":"02285cd4d4bd2ea1ad3223a9474cc1a3","src\/SiteSettings.php":"c2db0927622a3e83d6b69f235aec3a53","src\/SearchLogParser.php":"def0c80a3f8ba9b32c151c4632aa34f9","src\/SearchEngine.php":"1c1934b0ac81223566c5440d4c23fd3c","src\/TagSuggester.php":"94a15f13cef084ac1ae9e195c7ec47e7","templates\/post_view.php":"8b5cf7b5a16dfe6c186b71eba001019b","templates\/wizard\/step6.php":"4bd662c4484fedabc027234b77b7fc83","templates\/wizard\/step4.php":"dc67f6460a068bdf11908727f3157378","templates\/wizard\/nav.php":"c752cfc72cc683b16020b834eac21aea","templates\/wizard\/step1.php":"e1d11677587a3c0902ec1e49a134dc97","templates\/wizard\/step2.php":"4e34d77097ee53c1e8e7e20ed3a2f629","templates\/wizard\/step3.php":"7d1c7ec84e6d60b50be96e7a09d4e2af","templates\/wizard\/step5.php":"8edce714601d0267db0c2791fe10a418","templates\/licenses.php":"38606fef87b66034927714ca0634b0f6","templates\/post_list.php":"74671463ca3b98ebaf426ebcf9764e74","templates\/layout.php":"c9c5de1620a949ea0f7d9532984e38af","templates\/contact.php":"fe723ed1f97d5b1e94c7b2c19b6c8433","templates\/post_form.php":"895ee0e15b2d8f22ee7067d4c4ccd716","templates\/legal.php":"024ab0c9ede825c85cc8251071dab508","templates\/about.php":"a4e7bbd5b6268455befdcc37642e6eb7","bootstrap.php":"5b4f17ddc425d4a7add3a9fa857cc878","public\/login\/index.php":"cadec933ef617c451d1723bbe6da6173","public\/oidc\/callback.php":"20e138166d892491d3671a73994cf49a","public\/oidc\/start.php":"6505a18cf7028c6614140f9d0a5a2d24","public\/oidc\/me.php":"6ecfed0e783a214550279b035d96ae13","src\/helpers.php":"3f8ff8b9b179d2739afb58ea708e4291","src\/auth.php":"766190d929a5036f67ba91bb3abb6b1f","src\/ArticleManager.php":"26143d96b991f6b2df3e2f63e496f180","src\/mailer.php":"065fc7a1e39c929d0a14fc544846ead8","versions.php":"08447f315d2ac3ad9ce0bd76b10afa24","config\/config.php":"db35fc35b1ec6c20e2ddd4f6cd80bd61","public\/index.php":"bac67ffb25a948ea3d31d54151ba0ff5"}} \ No newline at end of file diff --git a/bootstrap.php b/bootstrap.php index c123030..903574b 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -8,6 +8,10 @@ if (!defined('BASE_PATH')) { if (session_status() === PHP_SESSION_NONE) { $isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; + $sessionName = $_ENV['SESSION_NAME'] ?? (getenv('SESSION_NAME') ?: null); + if ($sessionName !== null && $sessionName !== '') { + session_name($sessionName); + } session_set_cookie_params([ 'lifetime' => 0, 'path' => '/', diff --git a/docs/notes-dev.md b/docs/notes-dev.md index 89f5d7d..6eee705 100644 --- a/docs/notes-dev.md +++ b/docs/notes-dev.md @@ -37,3 +37,28 @@ $dateValue = $published_at ?? date('Y-m-d\TH:i'); ## Permissions serveur PHP-FPM tourne en `www-data`. Les fichiers sensibles (`.env`) appartiennent à `cedrix:www-data 640`. Voir `PROJET.md` § Permissions serveur. + +## Configuration PHP-FPM recommandée + +Sur un serveur 2 GB RAM, chaque worker PHP-FPM consomme ~40 MB. Pool recommandé (`/etc/php/8.3/fpm/pool.d/.conf`) : + +```ini +pm = dynamic +pm.max_children = 20 +pm.start_servers = 3 +pm.min_spare_servers = 2 +pm.max_spare_servers = 8 +``` + +Symptôme de saturation : `server reached pm.max_children` dans `/var/log/php8.3-fpm.log`. + +## Protection contre les bots (anciennes URLs DokuWiki) + +Les anciens sites migrés depuis DokuWiki reçoivent du trafic de bots sur `/lib/`, `/doku.php`, etc. Utiliser `RedirectMatch 410` dans Apache plutôt que `Require all denied` — le 410 "Gone" est un signal définitif qui pousse les moteurs à retirer ces URLs de leur index. + +```apache +# Dans le VirtualHost +RedirectMatch 410 "^/(lib|doku\.php|feed\.php|install\.php|_media|_detail)(/.*)?$" +``` + +Un 403 ("accès refusé") est ignoré par les bots sérieux qui continuent de réessayer. Un 410 ("disparu définitivement") les fait arrêter. diff --git a/public/.htaccess b/public/.htaccess index f43ae24..ab70c0c 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -3,6 +3,10 @@ DirectoryIndex index.php RewriteEngine On +# Paramètres DokuWiki (?do=media, ?do=export_pdf, etc.) — 410 Gone, jamais de contenu ici +RewriteCond %{QUERY_STRING} (^|&)do= [NC] +RewriteRule ^ - [R=410,L] + # Fichiers et répertoires réels servis directement RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -d @@ -19,7 +23,10 @@ RewriteRule ^page/([0-9a-f-]{36})/?$ /index.php?cursor=$1 [L,QSA] # Édition / création RewriteRule ^edit/([0-9a-f-]{36})/tags/(.+?)/?$ /index.php?action=edit_tags&uuid=$1&tag_type=$2 [L,QSA,B] +RewriteRule ^edit/([0-9a-f-]{36})/discard/?$ /index.php?action=edit_discard_draft&uuid=$1 [L,QSA] +RewriteRule ^edit/([0-9a-f-]{36})/([1-6])/?$ /index.php?action=edit&uuid=$1&step=$2 [L,QSA] RewriteRule ^edit/([0-9a-f-]{36})/?$ /index.php?action=edit&uuid=$1 [L,QSA] +RewriteRule ^new/([0-9a-f-]{36})/([1-5])/?$ /index.php?action=create&uuid=$1&step=$2 [L,QSA] RewriteRule ^new/?$ /index.php?action=create [L,QSA] RewriteRule ^delete/([0-9a-f-]{36})/?$ /index.php?action=delete&uuid=$1 [L,QSA] diff --git a/public/assets/js/wizard.js b/public/assets/js/wizard.js new file mode 100644 index 0000000..599277d --- /dev/null +++ b/public/assets/js/wizard.js @@ -0,0 +1,298 @@ +// wizard.js — autosave, insertions, couleur catégorie, génération slug + +document.addEventListener('DOMContentLoaded', function () { + + var page = document.getElementById('vl-page'); + var uuid = page ? page.dataset.uuid : ''; + var autosaveUrl = page ? page.dataset.autosaveUrl : ''; + + // ─── Auto-resize textarea + scroll curseur ────────────────────────────── + var ta = document.getElementById('wz-content'); + if (ta) { + function resizeTa() { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; } + ta.addEventListener('input', resizeTa); + resizeTa(); + + function scrollToCursor() { + var lineH = parseFloat(getComputedStyle(ta).lineHeight) || 20; + var padT = parseFloat(getComputedStyle(ta).paddingTop) || 8; + var lines = ta.value.substr(0, ta.selectionStart).split('\n').length; + var cursorY = ta.getBoundingClientRect().top + padT + lines * lineH; + var margin = lineH * 3; + if (cursorY > window.innerHeight - margin) { + window.scrollBy({ top: cursorY - window.innerHeight + margin, behavior: 'instant' }); + } else if (cursorY < margin) { + window.scrollBy({ top: cursorY - margin, behavior: 'instant' }); + } + } + ta.addEventListener('keyup', scrollToCursor); + ta.addEventListener('click', scrollToCursor); + } + + // ─── Ctrl+Enter soumet le formulaire ──────────────────────────────────── + var form = document.querySelector('form[method="POST"]'); + if (form) { + form.addEventListener('keydown', function (e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { form.submit(); } + }); + } + + // ─── Génération slug automatique (étape 1 / création) ─────────────────── + var titleInput = document.getElementById('wz-title'); + var slugField = document.getElementById('slug'); + var slugPreview = document.getElementById('slug-preview'); + + function slugify(s) { + var map = {'à':'a','â':'a','ä':'a','é':'e','è':'e','ê':'e','ë':'e','î':'i','ï':'i','ô':'o','ö':'o','ù':'u','û':'u','ü':'u','ç':'c','æ':'ae','œ':'oe'}; + return s.toLowerCase() + .replace(/[àâäéèêëîïôöùûüçæœ]/g, function(c) { return map[c] || c; }) + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + + if (titleInput && slugField) { + if (slugField.value !== '') slugField._auto = false; + titleInput.addEventListener('input', function () { + if (slugField._auto !== false) { + var gen = slugify(this.value); + slugField.value = gen; + if (slugPreview) slugPreview.textContent = gen; + } + }); + slugField.addEventListener('input', function () { + this._auto = (this.value === ''); + if (slugPreview) slugPreview.textContent = this.value; + }); + } + + // ─── Autosave ──────────────────────────────────────────────────────────── + var indicator = document.getElementById('autosave-indicator'); + if (indicator && uuid && autosaveUrl) { + var timer = null; + var titleEl = document.getElementById('wz-title'); + var contentEl = document.getElementById('wz-content'); + + function scheduleAutosave() { + clearTimeout(timer); + timer = setTimeout(doAutosave, 3000); + } + + async function doAutosave() { + if (!titleEl || !contentEl) return; + indicator.textContent = 'Sauvegarde…'; + try { + var res = await fetch(autosaveUrl, { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: new URLSearchParams({ + title: titleEl.value, + content: contentEl.value, + slug: slugField ? slugField.value : '', + }), + }); + var data = await res.json(); + indicator.textContent = data.ok ? 'Brouillon sauvegardé à ' + data.time : 'Erreur de sauvegarde'; + } catch (err) { + indicator.textContent = 'Erreur de sauvegarde'; + } + } + + if (titleEl) titleEl.addEventListener('input', scheduleAutosave); + if (ta) ta.addEventListener('input', scheduleAutosave); + } + + // ─── Insertion Markdown depuis miniatures ──────────────────────────────── + var insertUrl = page ? page.dataset.insertUrl : ''; + + document.querySelectorAll('[data-insert-ref]').forEach(function (el) { + el.addEventListener('click', function () { + if (!ta) return; + var ref = this.dataset.insertRef; + var isImage = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(ref); + var md = isImage ? '![](' + ref + ')' : '[' + ref + '](' + ref + ')'; + var sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : ''; + ta.value += sep + md; + ta.focus(); + ta.selectionStart = ta.selectionEnd = ta.value.length; + ta.dispatchEvent(new Event('input')); + }); + }); + + if (insertUrl) { + var isImg = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(insertUrl); + var name = decodeURIComponent(insertUrl.split('/').pop().split('?')[0]) || 'fichier'; + var ref = isImg ? '![](' + insertUrl + ')' : '[' + name + '](' + insertUrl + ')'; + if (ta) { + var sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : ''; + ta.value += sep + ref; + ta.dispatchEvent(new Event('input')); + } + } + + // ─── Copier référence Markdown (bouton MD dans la liste des fichiers) ──── + document.querySelectorAll('[data-copy-md-name]').forEach(function (btn) { + btn.addEventListener('click', function () { + if (!ta) return; + var name = this.dataset.copyMdName; + var isImage = this.dataset.copyMdIsImage === '1'; + var md = isImage ? '![](' + name + ')' : '[' + name + '](' + name + ')'; + var sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : ''; + ta.value += sep + md; + ta.focus(); + ta.dispatchEvent(new Event('input')); + }); + }); + + // ─── Aperçu couleur catégorie (étape 3) ───────────────────────────────── + var KNOWN_CATS = { + 'actualité': 10, 'travaux': 35, 'scolaire': 55, + 'linux': 120, 'domotique': 160, 'télécom': 190, + 'blog': 220, 'informatique': 255, 'réflexion': 285, + 'loisirs': 320, 'perso': 345, + }; + var FREE_HUES = [87, 140, 205, 237, 302]; + + var catInput = document.getElementById('category'); + var catSwatch = document.getElementById('cat-swatch'); + var catHint = document.getElementById('cat-hint'); + var catSwatches = document.getElementById('cat-free-swatches'); + + function catHue(name) { + var key = name.toLowerCase().trim(); + if (KNOWN_CATS[key] !== undefined) return KNOWN_CATS[key]; + var h = 0; + for (var i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) & 0xffff; + return h % 360; + } + + function updateCatSwatch() { + if (!catInput || !catSwatch) return; + var v = catInput.value.trim(); + if (v === '') { + catSwatch.style.background = '#e5e7eb'; + catSwatch.title = ''; + if (catHint) catHint.textContent = ''; + } else { + var hue = catHue(v); + catSwatch.style.background = 'hsl(' + hue + ',55%,52%)'; + catSwatch.title = 'hsl(' + hue + ', 55%, 52%)'; + if (catHint) { + var known = KNOWN_CATS[v.toLowerCase()] !== undefined; + catHint.textContent = known ? 'Couleur fixe' : 'Nouvelle catégorie (couleur générée)'; + } + } + } + + if (catInput) { + catInput.addEventListener('input', updateCatSwatch); + updateCatSwatch(); + + if (catSwatches) { + FREE_HUES.forEach(function (h) { + var sw = document.createElement('span'); + sw.style.cssText = 'display:inline-block;width:20px;height:20px;border-radius:4px;cursor:pointer;background:hsl(' + h + ',55%,52%)'; + sw.title = 'hsl(' + h + ', 55%, 52%)'; + sw.addEventListener('click', function () { + // trouver ou créer le nom correspondant + catInput.dispatchEvent(new Event('input')); + }); + catSwatches.appendChild(sw); + }); + } + } + + // ─── Plan (TOC dynamique) ──────────────────────────────────────────────── + var tocList = document.getElementById('wz-toc-list'); + if (tocList && ta) { + function buildToc() { + var lines = ta.value.split('\n'); + var items = []; + lines.forEach(function (line) { + var m = line.match(/^(#{1,6})\s+(.+)/); + if (m) { items.push({ level: m[1].length, text: m[2].trim() }); } + }); + if (items.length === 0) { + tocList.innerHTML = '
  • Aucun titre
  • '; + return; + } + var minLevel = Math.min.apply(null, items.map(function (i) { return i.level; })); + tocList.innerHTML = items.map(function (item) { + var indent = (item.level - minLevel) * 12; + var escaped = item.text.replace(/&/g,'&').replace(/' + + 'H' + item.level + '' + + escaped + ''; + }).join(''); + } + ta.addEventListener('input', buildToc); + buildToc(); + } + + // ─── Sélection catégorie — pills .wz-cat-pick (étape 3) ───────────────── + document.querySelectorAll('.wz-cat-pick').forEach(function (btn) { + btn.addEventListener('click', function () { + var catInp = document.getElementById('category'); + if (catInp) { + catInp.value = this.dataset.cat; + catInp.dispatchEvent(new Event('input')); + } + document.querySelectorAll('.wz-cat-pick').forEach(function (b) { b.classList.remove('active'); }); + this.classList.add('active'); + }); + }); + + // ─── Toggle tags — pills .wz-tag-pill (étape 4) ────────────────────────── + document.querySelectorAll('.wz-tag-pills').forEach(function (container) { + var targetId = container.dataset.target; + var inp = document.getElementById(targetId); + if (!inp) return; + container.querySelectorAll('.wz-tag-pill').forEach(function (pill) { + pill.addEventListener('click', function () { + var val = this.dataset.value; + var parts = inp.value.split(',').map(function (s) { return s.trim(); }).filter(Boolean); + var idx = parts.indexOf(val); + if (idx >= 0) { + parts.splice(idx, 1); + this.classList.remove('btn-secondary', 'btn-info'); + this.classList.add(this.classList.contains('btn-outline-info') ? 'btn-outline-info' : 'btn-outline-secondary'); + } else { + parts.push(val); + var isDetected = this.classList.contains('btn-outline-info') || this.classList.contains('btn-info'); + this.classList.remove('btn-outline-secondary', 'btn-outline-info'); + this.classList.add(isDetected ? 'btn-info' : 'btn-secondary'); + } + inp.value = parts.join(', '); + }); + }); + }); + + // ─── Image de couverture .wz-cover-thumb (étape 5) ─────────────────────── + document.querySelectorAll('.wz-cover-thumb').forEach(function (img) { + img.addEventListener('click', function () { + document.querySelectorAll('.wz-cover-thumb').forEach(function (i) { i.classList.remove('wz-cover-selected'); }); + this.classList.add('wz-cover-selected'); + }); + }); + + // ─── Compteurs SEO (étape 5) ────────────────────────────────────────────── + (function () { + function counter(inputId, counterId, warn) { + var el = document.getElementById(inputId); + var ct = document.getElementById(counterId); + if (!el || !ct) return; + function upd() { var l = el.value.length; ct.textContent = l + ' / ' + warn; ct.className = 'small ' + (l > warn ? 'text-danger' : (l < warn * 0.5 ? 'text-muted' : 'text-success')); } + el.addEventListener('input', upd); + upd(); + } + counter('seo_title', 'seo_title_counter', 60); + counter('seo_description', 'seo_desc_counter', 155); + }()); + + // ─── Confirmation data-confirm ──────────────────────────────────────────── + document.querySelectorAll('[data-confirm]').forEach(function (el) { + el.addEventListener('click', function (e) { + if (!confirm(this.dataset.confirm)) e.preventDefault(); + }); + }); + +}); diff --git a/public/index.php b/public/index.php index b1a54dc..87e48f0 100644 --- a/public/index.php +++ b/public/index.php @@ -4,16 +4,24 @@ declare(strict_types=1); define('BASE_PATH', realpath(__DIR__ . '/../')); -if (session_status() === PHP_SESSION_NONE) { +// Charger .env avant de lire SESSION_NAME, sinon getenv() retourne '' et le mauvais cookie est chargé +require_once BASE_PATH . '/vendor/autoload.php'; +require_once BASE_PATH . '/config/config.php'; + +$_sessionName = $_ENV['SESSION_NAME'] ?? (getenv('SESSION_NAME') ?: 'PHPSESSID'); +if (session_status() === PHP_SESSION_NONE + && (isset($_COOKIE[$_sessionName]) || $_SERVER['REQUEST_METHOD'] === 'POST') +) { $isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; + session_name($_sessionName); session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']); session_start(); } +unset($_sessionName); require_once BASE_PATH . '/src/helpers.php'; require_once BASE_PATH . '/src/auth.php'; require_once BASE_PATH . '/src/SiteSettings.php'; -require_once BASE_PATH . '/config/config.php'; require_once BASE_PATH . '/src/ArticleManager.php'; $articles = new ArticleManager(BASE_PATH . '/data'); @@ -483,56 +491,165 @@ switch ($action) { case 'create': requireAuth(); - $title = $_POST['title'] ?? ''; - $content = $_POST['content'] ?? ''; - $postSlug = $_POST['slug'] ?? ''; - $published = isset($_POST['published']); - $published_at = str_replace('T', ' ', $_POST['published_at'] ?? date('Y-m-d H:i:s')); - $seoTitle = $_POST['seo_title'] ?? ''; - $seoDescription = $_POST['seo_description'] ?? ''; - $ogImage = $_POST['og_image'] ?? ''; - $category = $_POST['category'] ?? ''; - $errors = []; + $step = max(1, min(5, (int)($_GET['step'] ?? 1))); + $totalSteps = 5; + $mode = 'create'; + $errors = []; - $postTags = []; - foreach (($_POST['tags'] ?? []) as $_tk => $_tv) { - $_vals = array_values(array_filter(array_map('trim', explode(',', (string)$_tv)), fn ($v) => $v !== '')); - if ($_vals !== []) { - $postTags[trim((string)$_tk)] = $_vals; - } + // UUID depuis l'URL ou la session + if ($uuid === '') { + $uuid = $_SESSION['wizard_create'] ?? ''; + } + $draft = $uuid !== '' ? $articles->getByUuid($uuid) : null; + + // Si session pointe vers un UUID inexistant, on repart de zéro + if ($draft === null && $uuid !== '') { + unset($_SESSION['wizard_create']); + $uuid = ''; } - if ($_SERVER['REQUEST_METHOD'] === 'POST') { - if (trim($title) === '') { - $errors[] = 'Le titre est obligatoire.'; - } - if (empty($errors)) { - $newUuid = $articles->create($title, $content, $published, $postSlug, $published_at, currentUserEmail() ?? '', $seoTitle, $seoDescription, $ogImage, $category, $postTags); + switch ($step) { - foreach ($_FILES['files']['tmp_name'] ?? [] as $i => $tmpName) { - if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) { - $articles->addFile($newUuid, [ - 'name' => $_FILES['files']['name'][$i], - 'tmp_name' => $tmpName, - 'error' => $_FILES['files']['error'][$i], - ]); + case 1: + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $title = trim($_POST['title'] ?? ''); + $content = $_POST['content'] ?? ''; + $postSlug = trim($_POST['slug'] ?? ''); + if ($title === '') { + $errors[] = 'Le titre est obligatoire.'; + } else { + if ($draft === null) { + $uuid = $articles->create($title, $content, false, $postSlug, date('Y-m-d H:i:s'), currentUserEmail() ?? '', '', '', '', '', []); + foreach ($_FILES['files']['tmp_name'] ?? [] as $_fi => $_tmpName) { + if ($_FILES['files']['error'][$_fi] === UPLOAD_ERR_OK) { + $articles->addFile($uuid, ['name' => $_FILES['files']['name'][$_fi], 'tmp_name' => $_tmpName, 'error' => UPLOAD_ERR_OK]); + } + } + $_SESSION['wizard_create'] = $uuid; + } else { + $articles->autosave($uuid, $title, $content, $postSlug); + } + header('Location: /new/' . rawurlencode($uuid) . '/2'); + exit; } } + $title = $draft['title'] ?? ($_POST['title'] ?? ''); + $content = $draft['content'] ?? ''; + $postSlug = $draft['slug'] ?? ''; + $existingFiles = $uuid !== '' ? $articles->getFiles($uuid) : []; + $article = $draft; + $insertUrl = ''; + $formAction = $uuid !== '' ? '/new/' . rawurlencode($uuid) . '/1' : '/new'; + include BASE_PATH . '/templates/wizard/step1.php'; + break; - header('Location: /'); - exit; - } - } + case 2: + if ($draft === null) { + header('Location: /new'); + exit; + } + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $articles->updatePartialMeta($uuid, [ + 'published' => isset($_POST['published']) && $_POST['published'] !== '', + 'published_at' => str_replace('T', ' ', $_POST['published_at'] ?? date('Y-m-d H:i:s')), + ]); + header('Location: /new/' . rawurlencode($uuid) . '/3'); + exit; + } + $published = (bool)($draft['published'] ?? false); + $published_at = $draft['published_at'] ?? date('Y-m-d H:i:s'); + include BASE_PATH . '/templates/wizard/step2.php'; + break; - $formAction = '/new'; - $action = 'create'; - $tagTypes = $articles->getTagTypes(); - $articleTags = $postTags; - $allTagValues = []; - foreach ($tagTypes as $_tk => $_) { - $allTagValues[$_tk] = $articles->getAllTagValues($_tk); + case 3: + if ($draft === null) { + header('Location: /new'); + exit; + } + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $articles->updatePartialMeta($uuid, ['category' => trim($_POST['category'] ?? '')]); + header('Location: /new/' . rawurlencode($uuid) . '/4'); + exit; + } + $category = $draft['category'] ?? ''; + $allCategories = $articles->getCategories(); + $privateCats = $articles->getPrivateCategories(); + include BASE_PATH . '/templates/wizard/step3.php'; + break; + + case 4: + if ($draft === null) { + header('Location: /new'); + exit; + } + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $_vals = array_values(array_filter(array_map('trim', explode(',', (string)($_POST['tags_flat'] ?? ''))), fn ($_v) => $_v !== '')); + $articles->updatePartialMeta($uuid, ['tags' => $_vals !== [] ? ['tags' => $_vals] : []]); + header('Location: /new/' . rawurlencode($uuid) . '/5'); + exit; + } + $_tagTypes = $articles->getTagTypes(); + $flatTagValues = []; + foreach ($_tagTypes as $_tk => $_) { + foreach ($articles->getAllTagValues($_tk) as $_v) { + $flatTagValues[$_v] = true; + } + } + foreach ($articles->getAllTagValues('tags') as $_v) { + $flatTagValues[$_v] = true; + } + ksort($flatTagValues); + $flatTagValues = array_keys($flatTagValues); + $flatArticleTags = []; + foreach (($draft['tags'] ?? []) as $_tagVals) { + foreach ((array)$_tagVals as $_v) { + if (!in_array($_v, $flatArticleTags, true)) { + $flatArticleTags[] = $_v; + } + } + } + $draftContent = (string)($draft['content'] ?? ''); + require_once BASE_PATH . '/src/TagSuggester.php'; + include BASE_PATH . '/templates/wizard/step4.php'; + break; + + case 5: + if ($draft === null) { + header('Location: /new'); + exit; + } + require_once BASE_PATH . '/src/Parsedown.php'; + $_pd = new Parsedown(); + $autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…'); + unset($_pd); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $seoTitle = trim($_POST['seo_title'] ?? ''); + $seoDesc = trim($_POST['seo_description'] ?? '') ?: $autoSeoDesc; + $coverFile = trim($_POST['cover_file'] ?? '') ?: ($draft['cover'] ?? ''); + $ogImage = $coverFile !== '' + ? rtrim(APP_URL, '/') . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFile) + : ($draft['og_image'] ?? ''); + $articles->update($uuid, $draft['title'], $draft['content'], $draft['published'], $draft['slug'] ?? '', $draft['published_at'] ?? '', 'Création', $seoTitle, $seoDesc, $ogImage, $draft['category'] ?? '', $draft['tags'] ?? []); + if ($coverFile !== '' && $coverFile !== ($draft['cover'] ?? '')) { + $articles->setCover($uuid, $coverFile); + } + unset($_SESSION['wizard_create']); + $final = $articles->getByUuid($uuid); + header('Location: /post/' . rawurlencode($final['slug'] ?? $uuid)); + exit; + } + $title = $draft['title'] ?? ''; + $seoTitle = $draft['seo_title'] ?? ''; + $seoDescription = $draft['seo_description'] ?? ''; + $existingFiles = $articles->getFiles($uuid); + $category = $draft['category'] ?? ''; + $published = (bool)($draft['published'] ?? false); + $published_at = $draft['published_at'] ?? ''; + $postSlug = $draft['slug'] ?? ''; + $article = $draft; + include BASE_PATH . '/templates/wizard/step5.php'; + break; } - include BASE_PATH . '/templates/post_form.php'; break; case 'view': @@ -713,165 +830,191 @@ switch ($action) { echo 'Article introuvable.'; exit; } - if (!canDoOnArticle('edit_articles', $article)) { http_response_code(403); echo 'Accès refusé.'; exit; } + // Toggle featured (admin only) — conservé depuis l'ancienne version if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['_toggle_featured'])) { if (!isAdmin()) { http_response_code(403); exit; } $articles->setFeatured($uuid, !((bool)($article['featured'] ?? false))); - header('Location: /edit/' . rawurlencode($uuid)); + header('Location: /edit/' . rawurlencode($uuid) . '/1'); exit; } - $title = $_POST['title'] ?? $article['title']; - $content = $_POST['content'] ?? $article['content']; - $postSlug = $_POST['slug'] ?? $article['slug']; - $published = isset($_POST['published']) ? true : $article['published']; - $published_at = $_POST['published_at'] - ?? date('Y-m-d\TH:i', strtotime((string)($article['published_at'] ?? 'now'))); - $seoTitle = $_POST['seo_title'] ?? ($article['seo_title'] ?? ''); - $seoDescription = $_POST['seo_description'] ?? ($article['seo_description'] ?? ''); - $ogImage = $_POST['og_image'] ?? ($article['og_image'] ?? ''); - $category = $_POST['category'] ?? ($article['category'] ?? ''); - $errors = []; + $step = (int)($_GET['step'] ?? 0); + $totalSteps = 6; + $mode = 'edit'; + $errors = []; - // Tags : lire depuis POST si soumis, sinon depuis l'article - $pendingTags = []; - if ($_SERVER['REQUEST_METHOD'] === 'POST') { - foreach (($_POST['tags'] ?? []) as $_tk => $_tv) { - $_vals = array_values(array_filter(array_map('trim', explode(',', (string)$_tv)), fn ($v) => $v !== '')); - if ($_vals !== []) { - $pendingTags[trim((string)$_tk)] = $_vals; - } - } - } else { - $pendingTags = $article['tags'] ?? []; + // Sans step : rediriger vers l'étape 1 + if ($step === 0) { + header('Location: /edit/' . rawurlencode($uuid) . '/1'); + exit; } - if ($_SERVER['REQUEST_METHOD'] === 'POST') { - if (trim($title) === '') { - $errors[] = 'Le titre est obligatoire.'; - } - if (empty($errors)) { - if (!empty($_POST['_confirm'])) { - $coverFile = trim($_POST['cover_file'] ?? '') ?: ($article['cover'] ?? ''); - $ogImageFromCover = $coverFile !== '' - ? rtrim(APP_URL, '/') . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFile) - : ''; + // Base de travail : draft overlay s'il existe, sinon article original + $draft = $articles->getDraftOverlay($uuid) ?? $article; - $articles->update( - $uuid, - $title, - $content, - $published, - $_POST['slug'] ?? '', - str_replace('T', ' ', $_POST['published_at'] ?? ''), - $_POST['revision_comment'] ?? '', - $_POST['seo_title'] ?? '', - $_POST['seo_description'] ?? '', - $ogImageFromCover, - $_POST['category'] ?? '', - $pendingTags - ); + switch ($step) { - $fmetaNames = $_POST['fmeta_name'] ?? []; - $fmetaAuthors = $_POST['fmeta_author'] ?? []; - $fmetaSources = $_POST['fmeta_source'] ?? []; - foreach ($fmetaNames as $fi => $fname) { - $articles->addFileMeta($uuid, $fname, trim($fmetaAuthors[$fi] ?? ''), trim($fmetaSources[$fi] ?? '')); + case 1: + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $title = trim($_POST['title'] ?? ''); + $content = $_POST['content'] ?? ''; + if ($title === '') { + $errors[] = 'Le titre est obligatoire.'; + } else { + $articles->saveDraftOverlay($uuid, ['title' => $title, 'slug' => trim($_POST['slug'] ?? $draft['slug'])], $content); + header('Location: /edit/' . rawurlencode($uuid) . '/2'); + exit; } + } + $title = $draft['title']; + $content = $draft['content']; + $postSlug = $draft['slug']; + $existingFiles = $articles->getFiles($uuid); + $insertUrl = ''; + if (isset($_GET['insert_url']) && filter_var($_GET['insert_url'], FILTER_VALIDATE_URL)) { + $insertUrl = $_GET['insert_url']; + } + $formAction = '/edit/' . rawurlencode($uuid) . '/1'; + include BASE_PATH . '/templates/wizard/step1.php'; + break; - $coverFile = trim($_POST['cover_file'] ?? ''); - if ($coverFile !== '') { - $articles->setCover($uuid, $coverFile); - } - - $updated = $articles->getByUuid($uuid); - header('Location: /post/' . rawurlencode($updated['slug'] ?? $uuid)); + case 2: + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $articles->saveDraftOverlay($uuid, [ + 'published' => isset($_POST['published']) && $_POST['published'] !== '', + 'published_at' => str_replace('T', ' ', $_POST['published_at'] ?? ($draft['published_at'] ?? '')), + ]); + header('Location: /edit/' . rawurlencode($uuid) . '/3'); exit; } + $published = (bool)($draft['published'] ?? false); + $published_at = $draft['published_at'] ?? ''; + include BASE_PATH . '/templates/wizard/step2.php'; + break; - // ─── Page de confirmation ──────────────────────────────────── - $diffLines = lineDiff((string)($article['content'] ?? ''), $content); - $titleChanged = ($title !== ($article['title'] ?? '')); - $autoSlug = slugify($title); + case 3: + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $articles->saveDraftOverlay($uuid, ['category' => trim($_POST['category'] ?? '')]); + header('Location: /edit/' . rawurlencode($uuid) . '/4'); + exit; + } + $category = $draft['category'] ?? ''; + $allCategories = $articles->getCategories(); + $privateCats = $articles->getPrivateCategories(); + include BASE_PATH . '/templates/wizard/step3.php'; + break; - $changes = []; + case 4: + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $_vals = array_values(array_filter(array_map('trim', explode(',', (string)($_POST['tags_flat'] ?? ''))), fn ($_v) => $_v !== '')); + $articles->saveDraftOverlay($uuid, ['tags' => $_vals !== [] ? ['tags' => $_vals] : []]); + header('Location: /edit/' . rawurlencode($uuid) . '/5'); + exit; + } + $_tagTypes = $articles->getTagTypes(); + $flatTagValues = []; + foreach ($_tagTypes as $_tk => $_) { + foreach ($articles->getAllTagValues($_tk) as $_v) { + $flatTagValues[$_v] = true; + } + } + foreach ($articles->getAllTagValues('tags') as $_v) { + $flatTagValues[$_v] = true; + } + ksort($flatTagValues); + $flatTagValues = array_keys($flatTagValues); + $flatArticleTags = []; + foreach (($draft['tags'] ?? []) as $_tagVals) { + foreach ((array)$_tagVals as $_v) { + if (!in_array($_v, $flatArticleTags, true)) { + $flatArticleTags[] = $_v; + } + } + } + $draftContent = (string)($draft['content'] ?? ''); + require_once BASE_PATH . '/src/TagSuggester.php'; + include BASE_PATH . '/templates/wizard/step4.php'; + break; + + case 5: + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $articles->saveDraftOverlay($uuid, [ + 'seo_title' => trim($_POST['seo_title'] ?? ''), + 'seo_description' => trim($_POST['seo_description'] ?? ''), + ]); + header('Location: /edit/' . rawurlencode($uuid) . '/6'); + exit; + } + require_once BASE_PATH . '/src/Parsedown.php'; + $_pd = new Parsedown(); + $autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…'); + unset($_pd); + $title = $draft['title']; + $seoTitle = $draft['seo_title'] ?? ''; + $seoDescription = $draft['seo_description'] ?? ''; + $postSlug = $draft['slug']; + $published = (bool)($draft['published'] ?? false); + $published_at = $draft['published_at'] ?? ''; + $category = $draft['category'] ?? ''; + include BASE_PATH . '/templates/wizard/step5.php'; + break; + + case 6: + if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['_confirm'])) { + $revisionComment = trim($_POST['revision_comment'] ?? ''); + // Si le slug a été modifié dans le formulaire de confirmation, le propager + if (!empty($_POST['slug'])) { + $articles->saveDraftOverlay($uuid, ['slug' => trim($_POST['slug'])]); + } + $articles->commitDraftOverlay($uuid, $revisionComment); + $final = $articles->getByUuid($uuid); + header('Location: /post/' . rawurlencode($final['slug'] ?? $uuid)); + exit; + } + $draftData = $articles->getDraftOverlay($uuid) ?? $article; + require_once BASE_PATH . '/src/Parsedown.php'; + $_pd = new Parsedown(); + $autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draftData['content'] ?? ''))))), 0, 155, '…'); + unset($_pd); + $diffLines = lineDiff((string)($article['content'] ?? ''), (string)($draftData['content'] ?? '')); + $titleChanged = ($draftData['title'] ?? '') !== ($article['title'] ?? ''); + $autoSlug = slugify($draftData['title'] ?? ''); + $postSlug = $draftData['slug'] ?? $article['slug']; + $changes = []; if ($titleChanged) { $changes[] = 'titre modifié'; } - if (($category ?? '') !== ($article['category'] ?? '')) { + if (($draftData['category'] ?? '') !== ($article['category'] ?? '')) { $changes[] = 'catégorie modifiée'; } - if ($pendingTags !== ($article['tags'] ?? [])) { + if (($draftData['tags'] ?? []) !== ($article['tags'] ?? [])) { $changes[] = 'tags modifiés'; } - if ($content !== ($article['content'] ?? '')) { + if (($draftData['content'] ?? '') !== ($article['content'] ?? '')) { $changes[] = 'contenu modifié'; } - $oldPublished = (bool)($article['published'] ?? false); - if ($published !== $oldPublished) { - $changes[] = $published ? 'article publié' : 'article dépublié'; - } - $newCover = trim($_POST['cover_file'] ?? ''); - if ($newCover !== '' && $newCover !== ($article['cover'] ?? '')) { - $changes[] = 'couverture modifiée'; - } - $fmetaNames = $_POST['fmeta_name'] ?? []; - $fmetaAuthors = $_POST['fmeta_author'] ?? []; - $fmetaSources = $_POST['fmeta_source'] ?? []; - foreach ($fmetaNames as $fi => $fname) { - $savedMeta = ($article['files_meta'][$fname] ?? []); - if (trim($fmetaAuthors[$fi] ?? '') !== ($savedMeta['author'] ?? '') - || trim($fmetaSources[$fi] ?? '') !== ($savedMeta['source_url'] ?? '')) { - $changes[] = 'métadonnées fichiers modifiées'; - break; - } + if ((bool)($draftData['published'] ?? false) !== (bool)($article['published'] ?? false)) { + $changes[] = ($draftData['published'] ?? false) ? 'article publié' : 'article dépublié'; } $autoRevisionComment = !empty($changes) ? ucfirst(implode(', ', $changes)) : ''; - - require_once BASE_PATH . '/src/Parsedown.php'; - $_pd = new Parsedown(); - $autoSeoDesc = mb_strimwidth( - trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text($content)))), - 0, - 155, - '…' - ); - unset($_pd); - - // Tags sous forme de chaînes CSV pour les champs hidden du formulaire de confirmation - $confirmTags = []; - foreach ($pendingTags as $_tk => $_vals) { - $confirmTags[$_tk] = implode(', ', $_vals); - } - - include BASE_PATH . '/templates/post_confirm.php'; - exit; - } + $title = $draftData['title'] ?? ''; + $seoTitle = $draftData['seo_title'] ?? ''; + $seoDescription = $draftData['seo_description'] ?? ''; + $published = (bool)($draftData['published'] ?? false); + $published_at = $draftData['published_at'] ?? ''; + $category = $draftData['category'] ?? ''; + include BASE_PATH . '/templates/wizard/step6.php'; + break; } - - $formAction = '/edit/' . rawurlencode($uuid); - $action = 'edit'; - $existingFiles = $articles->getFiles($uuid); - $insertUrl = ''; - if (isset($_GET['insert_url']) && filter_var($_GET['insert_url'], FILTER_VALIDATE_URL)) { - $insertUrl = $_GET['insert_url']; - } - $tagTypes = $articles->getTagTypes(); - $articleTags = $pendingTags; - $allTagValues = []; - foreach ($tagTypes as $_tk => $_) { - $allTagValues[$_tk] = $articles->getAllTagValues($_tk); - } - include BASE_PATH . '/templates/post_form.php'; break; case 'edit_tags': @@ -1333,6 +1476,40 @@ switch ($action) { echo json_encode(['ok' => $ok, 'time' => date('H:i:s')]); exit; + case 'autosave_draft': + requireAuth(); + header('Content-Type: application/json'); + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uuid === '') { + echo json_encode(['ok' => false]); + exit; + } + $_adArticle = $articles->getByUuid($uuid); + if (!$_adArticle || !canDoOnArticle('edit_articles', $_adArticle)) { + echo json_encode(['ok' => false]); + exit; + } + $_adTitle = trim($_POST['title'] ?? ''); + $_adContent = $_POST['content'] ?? null; + if ($_adTitle === '') { + echo json_encode(['ok' => false]); + exit; + } + $articles->saveDraftOverlay($uuid, ['title' => $_adTitle], $_adContent); + echo json_encode(['ok' => true, 'time' => date('H:i:s')]); + exit; + + case 'edit_discard_draft': + requireAuth(); + $_ddArticle = $articles->getByUuid($uuid); + if (!$_ddArticle || !canDoOnArticle('edit_articles', $_ddArticle)) { + http_response_code(403); + echo 'Accès refusé.'; + exit; + } + $articles->discardDraftOverlay($uuid); + header('Location: /post/' . rawurlencode($_ddArticle['slug'] ?? $uuid)); + exit; + case 'copy_file': requireAuth(); header('Content-Type: application/json'); @@ -1888,10 +2065,10 @@ switch ($action) { exit; } - // CSRF - $csrfOk = isset($_POST['_token'], $_SESSION['comment_csrf']) - && hash_equals($_SESSION['comment_csrf'], $_POST['_token']); - unset($_SESSION['comment_csrf']); + // CSRF (double-submit cookie — pas de session requise pour les visiteurs) + $csrfOk = isset($_POST['_token'], $_COOKIE['_csrf_c']) + && hash_equals($_COOKIE['_csrf_c'], $_POST['_token']); + setcookie('_csrf_c', '', ['expires' => time() - 3600, 'path' => '/', 'samesite' => 'Strict', 'httponly' => true]); if (!$csrfOk) { header('Location: /'); exit; diff --git a/public/oidc/callback.php b/public/oidc/callback.php index 3623be2..a538cf6 100644 --- a/public/oidc/callback.php +++ b/public/oidc/callback.php @@ -2,9 +2,12 @@ declare(strict_types=1); +if (!defined('BASE_PATH')) { + define('BASE_PATH', dirname(__DIR__, 2)); +} require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; -require_once dirname(__DIR__, 2) . '/bootstrap.php'; require_once dirname(__DIR__, 2) . '/config/config.php'; +require_once dirname(__DIR__, 2) . '/bootstrap.php'; if (!function_exists('env')) { function env(string $key, ?string $default = null): ?string diff --git a/public/oidc/me.php b/public/oidc/me.php index 91cf8f5..0d4816f 100644 --- a/public/oidc/me.php +++ b/public/oidc/me.php @@ -4,9 +4,12 @@ // version : 20251005 declare(strict_types=1); +if (!defined('BASE_PATH')) { + define('BASE_PATH', dirname(__DIR__, 2)); +} require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; -require_once dirname(__DIR__, 2) . '/bootstrap.php'; require_once dirname(__DIR__, 2) . '/config/config.php'; +require_once dirname(__DIR__, 2) . '/bootstrap.php'; function maskToken(?string $t): string { diff --git a/public/oidc/start.php b/public/oidc/start.php index 5461ec8..fb823bd 100644 --- a/public/oidc/start.php +++ b/public/oidc/start.php @@ -2,9 +2,12 @@ declare(strict_types=1); +if (!defined('BASE_PATH')) { + define('BASE_PATH', dirname(__DIR__, 2)); +} require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; -require_once dirname(__DIR__, 2) . '/bootstrap.php'; require_once dirname(__DIR__, 2) . '/config/config.php'; +require_once dirname(__DIR__, 2) . '/bootstrap.php'; if (!function_exists('env')) { function env(string $key, ?string $default = null): ?string diff --git a/src/ArticleManager.php b/src/ArticleManager.php index b7c2711..1ce4397 100644 --- a/src/ArticleManager.php +++ b/src/ArticleManager.php @@ -228,6 +228,126 @@ class ArticleManager return true; } + public function updatePartialMeta(string $uuid, array $updates): void + { + if (!$this->isValidUuid($uuid)) { + return; + } + $dir = $this->dataDir . '/' . $uuid; + $raw = @file_get_contents($dir . '/meta.json'); + if ($raw === false) { + return; + } + $meta = json_decode($raw, true); + if (!is_array($meta)) { + return; + } + foreach ($updates as $key => $value) { + $meta[$key] = $value; + } + $meta['updated_at'] = date('Y-m-d H:i:s'); + $this->writeMeta($dir, $meta); + } + + public function saveDraftOverlay(string $uuid, array $metaFields, ?string $content = null): void + { + if (!$this->isValidUuid($uuid)) { + return; + } + $dir = $this->dataDir . '/' . $uuid; + $existing = []; + $raw = @file_get_contents($dir . '/draft_overlay.json'); + if ($raw !== false) { + $existing = json_decode($raw, true) ?? []; + } + $overlay = array_merge($existing, $metaFields); + $overlay['_updated_at'] = date('Y-m-d H:i:s'); + file_put_contents( + $dir . '/draft_overlay.json', + json_encode($overlay, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n" + ); + if ($content !== null) { + file_put_contents($dir . '/draft_overlay.md', $content); + } + } + + public function getDraftOverlay(string $uuid): ?array + { + if (!$this->isValidUuid($uuid)) { + return null; + } + $dir = $this->dataDir . '/' . $uuid; + if (!file_exists($dir . '/draft_overlay.json')) { + return null; + } + $article = $this->getByUuid($uuid); + if (!$article) { + return null; + } + $raw = file_get_contents($dir . '/draft_overlay.json'); + if ($raw === false) { + return null; + } + $overlay = json_decode($raw, true); + if (!is_array($overlay)) { + return null; + } + $merged = $article; + foreach ($overlay as $key => $value) { + if (!str_starts_with($key, '_')) { + $merged[$key] = $value; + } + } + if (file_exists($dir . '/draft_overlay.md')) { + $c = file_get_contents($dir . '/draft_overlay.md'); + if ($c !== false) { + $merged['content'] = $c; + } + } + return $merged; + } + + public function hasDraftOverlay(string $uuid): bool + { + if (!$this->isValidUuid($uuid)) { + return false; + } + return file_exists($this->dataDir . '/' . $uuid . '/draft_overlay.json'); + } + + public function discardDraftOverlay(string $uuid): void + { + if (!$this->isValidUuid($uuid)) { + return; + } + $dir = $this->dataDir . '/' . $uuid; + @unlink($dir . '/draft_overlay.json'); + @unlink($dir . '/draft_overlay.md'); + } + + public function commitDraftOverlay(string $uuid, string $revisionComment = ''): void + { + $draft = $this->getDraftOverlay($uuid); + if (!$draft) { + return; + } + $this->update( + $uuid, + $draft['title'], + $draft['content'], + (bool)$draft['published'], + $draft['slug'] ?? '', + $draft['published_at'] ?? '', + $revisionComment, + $draft['seo_title'] ?? '', + $draft['seo_description'] ?? '', + $draft['og_image'] ?? '', + $draft['category'] ?? '', + $draft['tags'] ?? [] + ); + $this->discardDraftOverlay($uuid); + } + public function addFileMeta(string $uuid, string $filename, string $author, string $sourceUrl, string $title = '', array $extraMeta = []): void { if (!$this->isValidUuid($uuid)) { @@ -867,6 +987,13 @@ class ArticleManager $this->rebuildSearchIndex(); return $this->searchIndexCache; } + // Rebuild si des UUID ont été supprimés hors CMS (ex. rsync, suppression manuelle) + foreach ($data as $entry) { + if (!is_dir($this->dataDir . '/' . ($entry['uuid'] ?? ''))) { + $this->rebuildSearchIndex(); + return $this->searchIndexCache; + } + } $this->searchIndexCache = $data; return $this->searchIndexCache; } diff --git a/templates/comments_section.php b/templates/comments_section.php index e131a8a..f6af609 100644 --- a/templates/comments_section.php +++ b/templates/comments_section.php @@ -15,7 +15,13 @@ $_reactionDefs = [ ]; $_csrfToken = bin2hex(random_bytes(16)); -$_SESSION['comment_csrf'] = $_csrfToken; +setcookie('_csrf_c', $_csrfToken, [ + 'expires' => 0, + 'path' => '/', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'httponly' => true, + 'samesite' => 'Strict', +]); ?> diff --git a/templates/wizard/nav.php b/templates/wizard/nav.php new file mode 100644 index 0000000..afa2a06 --- /dev/null +++ b/templates/wizard/nav.php @@ -0,0 +1,36 @@ + + + diff --git a/templates/wizard/step1.php b/templates/wizard/step1.php new file mode 100644 index 0000000..0341980 --- /dev/null +++ b/templates/wizard/step1.php @@ -0,0 +1,182 @@ + + + +
    + + +
    +
    +

    + + + +
    +
    + Annuler + +
    +
    + + +
    + + + + +
    +
    + + +
    + + +
    + + +
    + + +
    + +
    + + +
    +
    +
    +
    Plan
    +
    +
      +
      +
      +
      +
      +
      + + + +
      + + +
      Les fichiers seront attachés à l'article après création.
      +
      + + + + + +
      +

      Fichiers attachés ()

      +
      + $_f): + $_fUrl = '/file?uuid=' . rawurlencode($_wizUuid) . '&name=' . rawurlencode($_f['name']); + $_isCover = ($_f['name'] === $_coverFile); + ?> +
      + + + + '🎬', + str_starts_with($_f['mime'], 'audio/') => '🎵', + $_f['mime'] === 'application/pdf' => '📑', + default => '📄', + } ?> + +
      +
      +
      + + +
      +
      +
      + +
      +
      + + $_f['is_image']); ?> + +
      +

      Images (clic → insère dans le contenu)

      +
      + + <?= htmlspecialchars($_img['name']) ?> + +
      +
      + + + + +
      +

      Liens externes

      +
        + +
      • + + + + +
      • + + +
      +
      + + + + + + + + $_f): ?> +
      + +
      + + + + + +
      + +
      +

      Publication

      +
      + ← Retour + +
      +
      + + + +
      +
      + +
      +
      +
      +

      Visibilité

      +
      + > + +
      +
      + > + +
      +
      Un brouillon n'est visible que par les utilisateurs authentifiés.
      +
      + +
      + + +
      Une date future crée une avant-première (visible aux utilisateurs avec la capacité view_previews).
      +
      +
      +
      + +
      +
      +
      + +
      + +
      +

      Catégorie

      +
      + ← Retour + +
      +
      + + + +
      +
      + +
      +
      +
      + +
      + +
      +
      + +
      +
      + + +
      +

      Catégories existantes :

      +
      + $_count): + $_isPriv = in_array($_cat, $privateCats ?? [], true); + ?> + + + +
      +
      + +
      +
      + +
      +
      +
      + + +suggest($draftContent, $flatTagValues, $flatArticleTags) + : []; + +$_knownInText = array_keys(array_filter($_candidates, fn ($_c) => $_c['known'])); +$_detectedInText = array_keys(array_filter($_candidates, fn ($_c) => !$_c['known'])); +?> +
      + +
      +

      Tags

      +
      + ← Retour + +
      +
      + + + +
      +
      + + + + + +
      + +
      Séparer par des virgules.
      +
      + + + +
      +

      Valeurs déjà utilisées :

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

      Détectés dans le texte (abréviations, noms propres, mots composés) :

      +
      + 'ABR', + 'camel' => 'CC', + 'proper' => 'NP', + default => '', + }; + ?> + + +
      +
      + + +
      +
      + +
      + + +
      + +
      +

      SEO

      +
      + ← Retour + + + + + +
      +
      + + + +
      + + +
      +
      +
      + Aperçu moteur de recherche +
      +
      +
      +
      + +
      +
      + +
      +
      + +
      +
      + + + + + + + + + + + + + + + + + +
      StatutPublic' : 'Brouillon' ?>
      Date
      Catégorie
      +
      +
      +
      + + +
      +
      +
      Métadonnées SEO
      +
      +
      + + +
      + Idéal : 30–60 caractères + 0 / 60 +
      +
      + +
      + + +
      + Idéal : 120–155 caractères + 0 / 155 +
      +
      + + + $_f['is_image']); ?> + +
      + +
      + + + +
      +
      + + +
      +
      + +
      + +
      +
      + + + + + + + + + +
      + + + +
      +
      +

      Confirmer les modifications

      + +

      + +

      Aucune modification détectée.

      + +
      +
      + ← Retour + + +
      +
      + + +
      + + +
      + + +
      +

      Diff du contenu

      + +
      Contenu identique.
      + +
      + − Supprimé + + Ajouté +
      +
      + + + +
      + + + +
      − 
      + +
      + +
      + +
        
      + + +
      + +
      + +
      + + +