commit 0ea1a9a8acbbe9d4a605e70f4b4b3043f1471446 Author: Jürgen Mummert Date: Mon Mar 2 21:28:17 2026 +0100 Initial release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ef87ac --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# mummert-media/newssubmission-bundle + +Contao 5.7 bundle for frontend news submissions with Symfony Forms, FilePond uploads and Altcha for anonymous users. + +## Installation + +1. Bundle als Composer-Paket in das Contao-Projekt einbinden. +2. `composer update mummert-media/newssubmission-bundle` +3. `ddev exec vendor/bin/contao-console contao:migrate --no-interaction --no-ansi` +4. `ddev exec vendor/bin/contao-console cache:clear --no-ansi` + +Hinweis: Die Datenbankfelder werden über die DCA-SQL-Definitionen des Bundles von `contao:migrate` angelegt. + +## Backend-Konfiguration + +1. Frontend-Modul **News-Einreichung** anlegen. +2. Felder setzen: `author`, `newsArchive`, `uploadFolder`, `thankYouPage`. +3. Tags einschränken über `allowedTags`. +4. Modul auf einer internen oder öffentlichen Seite einbinden. + +## Verhalten + +- Eingeloggt: Speicherung in `tl_news.submittedByMember` (UUID aus `tl_member.uuid`). +- Öffentlich: `submittedByMember` bleibt leer, optional `submittedByName`/`submittedByEmail`. +- Immer als Entwurf (`published=0`). +- Bild-Uploads laufen über FilePond-Endpoint mit temp storage und finalem Move beim Submit. +- Fotograf wird in `tl_news.caption` gespeichert (`Foto: `). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5ffe2e7 --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "mummert-media/newssubmission-bundle", + "description": "Frontend news submission bundle for Contao 5.7.", + "type": "contao-bundle", + "license": "proprietary", + "require": { + "php": "^8.4", + "contao/core-bundle": "^5.7", + "contao/news-bundle": "5.7.*", + "contao/manager-plugin": "^2.0", + "numero2/contao-tags": "^0.5", + "symfony/html-sanitizer": "^7.0" + }, + "autoload": { + "psr-4": { + "MummertMedia\\NewsSubmissionBundle\\": "src/" + } + }, + "extra": { + "contao-manager-plugin": "MummertMedia\\NewsSubmissionBundle\\Contao\\Manager\\Plugin" + }, + "config": { + "allow-plugins": { + "contao-components/installer": true, + "contao/manager-plugin": true + } + }, + "prefer-stable": true +} diff --git a/contao/config/services.yaml b/contao/config/services.yaml new file mode 100644 index 0000000..6924aff --- /dev/null +++ b/contao/config/services.yaml @@ -0,0 +1,17 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Contao\CoreBundle\Altcha\Altcha: '@contao.altcha' + + MummertMedia\NewsSubmissionBundle\: + resource: ../../src/ + exclude: + - ../../src/DependencyInjection/ + - ../../src/Contao/Manager/ + - ../../src/NewsSubmissionBundle.php + + MummertMedia\NewsSubmissionBundle\Service\UploadManager: + arguments: + $projectDir: '%kernel.project_dir%' diff --git a/contao/dca/tl_module.php b/contao/dca/tl_module.php new file mode 100644 index 0000000..b0ec455 --- /dev/null +++ b/contao/dca/tl_module.php @@ -0,0 +1,72 @@ + &$GLOBALS['TL_LANG']['tl_module']['author'], + 'exclude' => true, + 'inputType' => 'select', + 'foreignKey' => 'tl_user.name', + 'eval' => ['mandatory' => true, 'chosen' => true, 'includeBlankOption' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['newsArchive'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['newsArchive'], + 'exclude' => true, + 'inputType' => 'select', + 'foreignKey' => 'tl_news_archive.title', + 'eval' => ['mandatory' => true, 'chosen' => true, 'includeBlankOption' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['uploadFolder'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['uploadFolder'], + 'exclude' => true, + 'inputType' => 'fileTree', + 'eval' => ['fieldType' => 'radio', 'files' => false, 'mandatory' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['thankYouPage'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['thankYouPage'], + 'exclude' => true, + 'inputType' => 'pageTree', + 'eval' => ['fieldType' => 'radio', 'mandatory' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['allowedTags'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['allowedTags'], + 'exclude' => true, + 'inputType' => 'checkbox', + 'options_callback' => static function (): array { + $rows = Database::getInstance() + ->prepare("SELECT id, tag FROM tl_tags WHERE invisible='' ORDER BY tag ASC") + ->execute() + ->fetchAllAssoc(); + + $options = []; + + foreach ($rows as $row) { + $options[(int) $row['id']] = (string) $row['tag']; + } + + return $options; + }, + 'eval' => ['multiple' => true, 'tl_class' => 'clr'], + 'sql' => ['type' => 'blob', 'notnull' => false], + 'save_callback' => [ + static function ($value): array { + return array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true)))); + }, + ], +]; diff --git a/contao/dca/tl_news.php b/contao/dca/tl_news.php new file mode 100644 index 0000000..71b5425 --- /dev/null +++ b/contao/dca/tl_news.php @@ -0,0 +1,115 @@ +addLegend('submission_legend', 'publish_legend', 'before') + ->addField('submittedByMember', 'submission_legend', 'append') + ->addField('submittedByName', 'submission_legend', 'append') + ->addField('submittedByEmail', 'submission_legend', 'append') + ->applyToPalette('default', 'tl_news') + ->applyToPalette('internal', 'tl_news') + ->applyToPalette('article', 'tl_news') + ->applyToPalette('external', 'tl_news'); + +$GLOBALS['TL_DCA']['tl_news']['config']['onsubmit_callback'][] = static function (DataContainer $dc): void { + $newsId = (int) ($dc->id ?? 0); + + if ($newsId <= 0) { + return; + } + + $database = Database::getInstance(); + + $memberId = (int) $database + ->prepare('SELECT submittedByMember FROM tl_news WHERE id=?') + ->limit(1) + ->execute($newsId) + ->submittedByMember; + + if ($memberId <= 0) { + $database + ->prepare('UPDATE tl_news SET submittedByName=?, submittedByEmail=? WHERE id=?') + ->execute('', '', $newsId); + + return; + } + + $member = $database + ->prepare('SELECT firstname, lastname, email FROM tl_member WHERE id=?') + ->limit(1) + ->execute($memberId) + ->fetchAssoc(); + + if (false === $member || null === $member) { + $database + ->prepare('UPDATE tl_news SET submittedByName=?, submittedByEmail=? WHERE id=?') + ->execute('', '', $newsId); + + return; + } + + $name = trim((string) (($member['firstname'] ?? '').' '.($member['lastname'] ?? ''))); + $email = trim((string) ($member['email'] ?? '')); + + $database + ->prepare('UPDATE tl_news SET submittedByName=?, submittedByEmail=? WHERE id=?') + ->execute($name, $email, $newsId); +}; + +$GLOBALS['TL_DCA']['tl_news']['fields']['submittedByMember'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_news']['submittedByMember'], + 'exclude' => true, + 'inputType' => 'select', + 'options_callback' => static function (): array { + $rows = Database::getInstance() + ->prepare('SELECT id, firstname, lastname FROM tl_member ORDER BY lastname ASC, firstname ASC') + ->execute() + ->fetchAllAssoc(); + + $options = []; + + foreach ($rows as $row) { + $id = (int) ($row['id'] ?? 0); + + if ($id <= 0) { + continue; + } + + $lastName = trim((string) ($row['lastname'] ?? '')); + $firstName = trim((string) ($row['firstname'] ?? '')); + $label = trim($lastName.', '.$firstName, ' ,'); + + $options[$id] = '' !== $label ? $label : 'Mitglied #'.$id; + } + + return $options; + }, + 'save_callback' => [ + static function ($value): int { + return max(0, (int) $value); + }, + ], + 'eval' => ['chosen' => true, 'includeBlankOption' => true, 'tl_class' => 'clr w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'notnull' => false], +]; + +$GLOBALS['TL_DCA']['tl_news']['fields']['submittedByName'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_news']['submittedByName'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['readonly' => true, 'disabled' => true, 'tl_class' => 'w50'], + 'sql' => "varchar(255) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_news']['fields']['submittedByEmail'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_news']['submittedByEmail'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['readonly' => true, 'disabled' => true, 'rgxp' => 'email', 'tl_class' => 'w50'], + 'sql' => "varchar(255) NOT NULL default ''", +]; diff --git a/contao/languages/de/modules.php b/contao/languages/de/modules.php new file mode 100644 index 0000000..c5c0304 --- /dev/null +++ b/contao/languages/de/modules.php @@ -0,0 +1,6 @@ +Es liegt aktuell keine Einreichung zur Bestätigung vor.

+ {% else %} +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
{{ entry.articleText|raw }}
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ {% endif %} +{% endblock %} diff --git a/contao/templates/frontend/news_submission.html.twig b/contao/templates/frontend/news_submission.html.twig new file mode 100644 index 0000000..c1d1c07 --- /dev/null +++ b/contao/templates/frontend/news_submission.html.twig @@ -0,0 +1,509 @@ +{% extends "@Contao/frontend_module/_base.html.twig" %} + +{% block content %} + + + + {% if errorMessage %} +

{{ errorMessage }}

+ {% endif %} + + {{ form_start(form, { + action: app.request.uri, + attr: { + enctype: 'multipart/form-data', + id: 'news-submission-form', + 'aria-describedby': 'news-form-hint' + } + }) }} + + +

Pflichtfelder sind mit einem Stern markiert.

+ + {% if form.vars.submitted and not form.vars.valid %} + + {% endif %} + + {{ form_row(form.headline, { attr: { autocomplete: 'off' } }) }} + {{ form_row(form.subheadline, { attr: { autocomplete: 'off' } }) }} + {{ form_row(form.externalLink, { attr: { autocomplete: 'url', inputmode: 'url' } }) }} + +
+ {{ form.tags.vars.label }} +

Mehrfachauswahl möglich. Mit Leertaste ein- und ausschalten.

+
+ {% for tagField in form.tags %} + {% set tagLabelId = tagField.vars.id ~ '-label' %} +
+ {{ form_widget(tagField, { attr: { 'aria-describedby': tagField.vars.id ~ '-state', 'aria-labelledby': tagLabelId } }) }} + {{ form_label(tagField, null, { label_attr: { id: tagLabelId } }) }} + + {{ tagField.vars.checked ? 'Ein' : 'Aus' }} + +
+ {% endfor %} +
+
{{ form_errors(form.tags) }}
+
+ +
+ {{ form_label(form.articleText, null, { label_attr: { id: form.articleText.vars.id ~ '-label' } }) }} + + + +
+ Tastaturkürzel: +
    +
  • Tastenkürzel Strg oder Command plus B – Fett
  • +
  • Tastenkürzel Strg oder Command plus I – Kursiv
  • +
  • Tastenkürzel Strg oder Command plus U – Unterstrichen
  • +
  • Tastenkürzel Strg oder Command plus Z – Rückgängig
  • +
  • Tastenkürzel Strg oder Command plus Umschalt plus Z – Wiederholen
  • +
  • Pfeiltasten links/rechts in der Toolbar – zwischen Buttons wechseln
  • +
+
+ +
+
+ {{ form_widget(form.articleText, { attr: { class: 'js-news-article-source', rows: 8, 'aria-hidden': 'true', tabindex: '-1' } }) }} +
{{ form_errors(form.articleText) }}
+
+ +
+
+ {{ form_widget(form.addImage, { attr: { + 'aria-describedby': form.addImage.vars.id ~ '-state', + 'aria-controls': 'news-image-fields', + 'aria-expanded': form.addImage.vars.checked ? 'true' : 'false', + 'aria-labelledby': form.addImage.vars.id ~ '-label' + } }) }} + {{ form_label(form.addImage, null, { label_attr: { id: form.addImage.vars.id ~ '-label' } }) }} + + {{ form.addImage.vars.checked ? 'Ein' : 'Aus' }} + +
+

Wenn aktiviert, werden Felder für Bild, Fotograf und Alternativtext eingeblendet.

+ {{ form_errors(form.addImage) }} +
+ + + + {% if form.publicName is defined %} + {{ form_row(form.publicName) }} + {% endif %} + + {% if form.publicEmail is defined %} + {{ form_row(form.publicEmail) }} + {% endif %} + + {{ form_row(form.honeypot) }} + + {% if showAltcha %} +
+ + +
+ + {% endif %} + + + {{ form_end(form) }} + + + + +{% endblock %} diff --git a/public/editor.js b/public/editor.js new file mode 100644 index 0000000..65f2542 --- /dev/null +++ b/public/editor.js @@ -0,0 +1,330 @@ +import { Editor } from 'https://esm.sh/@tiptap/core@3'; +import Document from 'https://esm.sh/@tiptap/extension-document@3'; +import Paragraph from 'https://esm.sh/@tiptap/extension-paragraph@3'; +import Text from 'https://esm.sh/@tiptap/extension-text@3'; +import Heading from 'https://esm.sh/@tiptap/extension-heading@3'; +import Bold from 'https://esm.sh/@tiptap/extension-bold@3'; +import Italic from 'https://esm.sh/@tiptap/extension-italic@3'; +import Underline from 'https://esm.sh/@tiptap/extension-underline@3'; +import BulletList from 'https://esm.sh/@tiptap/extension-bullet-list@3'; +import OrderedList from 'https://esm.sh/@tiptap/extension-ordered-list@3'; +import ListItem from 'https://esm.sh/@tiptap/extension-list-item@3'; +import CharacterCount from 'https://esm.sh/@tiptap/extension-character-count@3'; +import History from 'https://esm.sh/@tiptap/extension-history@3'; + +const ARTICLE_TEXT_CHARACTER_LIMIT = 30000; + +(function () { + const editorMounts = document.querySelectorAll('[data-news-editor="tiptap"]'); + + if (!editorMounts.length) { + return; + } + + const syncState = (editor, textarea) => { + textarea.value = editor.getHTML(); + }; + + const updateEditorA11yState = (mount, textareaId, editor) => { + if (!mount || !textareaId || !editor) { + return; + } + + const errorNode = document.getElementById(`${textareaId}-errors`); + const hasError = !!(errorNode && errorNode.textContent && errorNode.textContent.trim().length > 0); + const plainTextLength = editor.storage?.characterCount?.characters?.() ?? 0; + const isEmpty = plainTextLength === 0; + + mount.setAttribute('aria-invalid', hasError ? 'true' : 'false'); + mount.setAttribute('aria-required', 'true'); + + const proseMirror = mount.querySelector('.ProseMirror'); + + if (!proseMirror) { + return; + } + + proseMirror.setAttribute('role', 'textbox'); + proseMirror.setAttribute('aria-multiline', 'true'); + proseMirror.setAttribute('aria-invalid', hasError ? 'true' : 'false'); + proseMirror.setAttribute('aria-required', 'true'); + proseMirror.setAttribute('aria-label', 'Artikeltext'); + proseMirror.setAttribute('data-empty', isEmpty ? 'true' : 'false'); + }; + + const updateCharacterCounter = (counterNode, editor) => { + if (!counterNode || !editor?.storage?.characterCount) { + return; + } + + const used = editor.storage.characterCount.characters(); + const words = editor.storage.characterCount.words(); + const remaining = ARTICLE_TEXT_CHARACTER_LIMIT - used; + + counterNode.textContent = `${words.toLocaleString('de-DE')} Wörter · ${used.toLocaleString('de-DE')} / ${ARTICLE_TEXT_CHARACTER_LIMIT.toLocaleString('de-DE')} Zeichen`; + counterNode.classList.toggle('is-over-limit', remaining < 0); + }; + + const setupToolbarKeyboardNavigation = (toolbar) => { + if (!toolbar) { + return; + } + + const getEnabledButtons = () => Array.from(toolbar.querySelectorAll('button[data-action]:not(:disabled)')); + + toolbar.addEventListener('keydown', (event) => { + const activeElement = event.target.closest('button[data-action]'); + + if (!activeElement) { + return; + } + + const buttons = getEnabledButtons(); + + if (!buttons.length) { + return; + } + + const currentIndex = buttons.indexOf(activeElement); + + if (currentIndex < 0) { + return; + } + + let nextIndex = currentIndex; + + switch (event.key) { + case 'ArrowRight': + nextIndex = (currentIndex + 1) % buttons.length; + break; + case 'ArrowLeft': + nextIndex = (currentIndex - 1 + buttons.length) % buttons.length; + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = buttons.length - 1; + break; + case 'Enter': + case ' ': + event.preventDefault(); + activeElement.click(); + return; + default: + return; + } + + event.preventDefault(); + buttons[nextIndex].focus(); + }); + }; + + const updateToolbarState = (toolbar, editor) => { + if (!toolbar) { + return; + } + + const canIndent = editor.can().chain().focus().sinkListItem('listItem').run(); + const canOutdent = editor.can().chain().focus().liftListItem('listItem').run(); + const canUndo = editor.can().chain().focus().undo().run(); + const canRedo = editor.can().chain().focus().redo().run(); + + toolbar.querySelectorAll('button[data-action]').forEach((button) => { + const action = button.getAttribute('data-action'); + let isActive = false; + let isDisabled = false; + + switch (action) { + case 'paragraph': + isActive = editor.isActive('paragraph'); + break; + case 'h2': + isActive = editor.isActive('heading', { level: 2 }); + break; + case 'h3': + isActive = editor.isActive('heading', { level: 3 }); + break; + case 'bold': + isActive = editor.isActive('bold'); + break; + case 'italic': + isActive = editor.isActive('italic'); + break; + case 'underline': + isActive = editor.isActive('underline'); + break; + case 'bulletList': + isActive = editor.isActive('bulletList'); + break; + case 'orderedList': + isActive = editor.isActive('orderedList'); + break; + case 'indent': + isDisabled = !canIndent; + break; + case 'outdent': + isDisabled = !canOutdent; + break; + case 'undo': + isDisabled = !canUndo; + break; + case 'redo': + isDisabled = !canRedo; + break; + default: + isActive = false; + break; + } + + button.classList.toggle('is-active', isActive); + button.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + button.disabled = isDisabled; + button.setAttribute('aria-disabled', isDisabled ? 'true' : 'false'); + }); + }; + + editorMounts.forEach((mount) => { + const textareaId = mount.getAttribute('data-textarea-id'); + + if (!textareaId) { + return; + } + + const textarea = document.getElementById(textareaId); + + if (!textarea) { + return; + } + + textarea.style.display = 'none'; + + const form = textarea.closest('form'); + const toolbar = document.querySelector(`[data-news-editor-toolbar="${textareaId}"]`); + const counterNode = document.querySelector(`[data-news-editor-counter-for="${textareaId}"]`); + + setupToolbarKeyboardNavigation(toolbar); + + const editor = new Editor({ + element: mount, + extensions: [ + Document, + Paragraph, + Text, + Heading.configure({ levels: [2, 3] }), + Bold, + Italic, + Underline, + BulletList, + OrderedList, + ListItem, + History, + CharacterCount.configure({ + limit: ARTICLE_TEXT_CHARACTER_LIMIT, + }), + ], + content: textarea.value || '

', + onCreate({ editor: currentEditor }) { + updateToolbarState(toolbar, currentEditor); + updateCharacterCounter(counterNode, currentEditor); + updateEditorA11yState(mount, textareaId, currentEditor); + }, + onUpdate({ editor: currentEditor }) { + syncState(currentEditor, textarea); + updateToolbarState(toolbar, currentEditor); + updateCharacterCounter(counterNode, currentEditor); + updateEditorA11yState(mount, textareaId, currentEditor); + }, + onSelectionUpdate({ editor: currentEditor }) { + updateToolbarState(toolbar, currentEditor); + updateEditorA11yState(mount, textareaId, currentEditor); + }, + }); + + editor.on('transaction', () => { + updateToolbarState(toolbar, editor); + updateCharacterCounter(counterNode, editor); + updateEditorA11yState(mount, textareaId, editor); + }); + + editor.on('focus', () => { + updateToolbarState(toolbar, editor); + updateCharacterCounter(counterNode, editor); + updateEditorA11yState(mount, textareaId, editor); + }); + + editor.on('blur', () => { + updateToolbarState(toolbar, editor); + updateCharacterCounter(counterNode, editor); + updateEditorA11yState(mount, textareaId, editor); + }); + + if (toolbar) { + toolbar.addEventListener('click', (event) => { + const target = event.target.closest('button[data-action]'); + + if (!target || target.disabled) { + return; + } + + event.preventDefault(); + + const action = target.getAttribute('data-action'); + const chain = editor.chain().focus(); + + switch (action) { + case 'paragraph': + chain.setParagraph().run(); + break; + case 'h2': + chain.toggleHeading({ level: 2 }).run(); + break; + case 'h3': + chain.toggleHeading({ level: 3 }).run(); + break; + case 'bold': + chain.toggleBold().run(); + break; + case 'italic': + chain.toggleItalic().run(); + break; + case 'underline': + chain.toggleUnderline().run(); + break; + case 'bulletList': + chain.toggleBulletList().run(); + break; + case 'orderedList': + chain.toggleOrderedList().run(); + break; + case 'indent': + chain.sinkListItem('listItem').run(); + break; + case 'outdent': + chain.liftListItem('listItem').run(); + break; + case 'undo': + chain.undo().run(); + break; + case 'redo': + chain.redo().run(); + break; + default: + break; + } + + syncState(editor, textarea); + updateToolbarState(toolbar, editor); + }); + } + + if (form) { + form.addEventListener('submit', () => { + syncState(editor, textarea); + updateEditorA11yState(mount, textareaId, editor); + }); + } + + updateEditorA11yState(mount, textareaId, editor); + }); +})(); diff --git a/public/icons/add_col_after.svg b/public/icons/add_col_after.svg new file mode 100755 index 0000000..49ed906 --- /dev/null +++ b/public/icons/add_col_after.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/add_col_before.svg b/public/icons/add_col_before.svg new file mode 100755 index 0000000..b183770 --- /dev/null +++ b/public/icons/add_col_before.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/add_row_after.svg b/public/icons/add_row_after.svg new file mode 100755 index 0000000..ae5012c --- /dev/null +++ b/public/icons/add_row_after.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/add_row_before.svg b/public/icons/add_row_before.svg new file mode 100755 index 0000000..441a9a8 --- /dev/null +++ b/public/icons/add_row_before.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/bold.svg b/public/icons/bold.svg new file mode 100755 index 0000000..985854b --- /dev/null +++ b/public/icons/bold.svg @@ -0,0 +1 @@ +text-bold diff --git a/public/icons/checklist.svg b/public/icons/checklist.svg new file mode 100755 index 0000000..20e8dc0 --- /dev/null +++ b/public/icons/checklist.svg @@ -0,0 +1 @@ +checklist-alternate diff --git a/public/icons/code.svg b/public/icons/code.svg new file mode 100755 index 0000000..18b960b --- /dev/null +++ b/public/icons/code.svg @@ -0,0 +1 @@ +angle-brackets diff --git a/public/icons/combine_cells.svg b/public/icons/combine_cells.svg new file mode 100755 index 0000000..e1fe66e --- /dev/null +++ b/public/icons/combine_cells.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/delete_col.svg b/public/icons/delete_col.svg new file mode 100755 index 0000000..8b6e304 --- /dev/null +++ b/public/icons/delete_col.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/delete_row.svg b/public/icons/delete_row.svg new file mode 100755 index 0000000..a396613 --- /dev/null +++ b/public/icons/delete_row.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/delete_table.svg b/public/icons/delete_table.svg new file mode 100755 index 0000000..819afd1 --- /dev/null +++ b/public/icons/delete_table.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/github.svg b/public/icons/github.svg new file mode 100755 index 0000000..7113b20 --- /dev/null +++ b/public/icons/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/h2.svg b/public/icons/h2.svg new file mode 100644 index 0000000..495bf46 --- /dev/null +++ b/public/icons/h2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/h3.svg b/public/icons/h3.svg new file mode 100644 index 0000000..a7519b8 --- /dev/null +++ b/public/icons/h3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/hr.svg b/public/icons/hr.svg new file mode 100755 index 0000000..47b52d3 --- /dev/null +++ b/public/icons/hr.svg @@ -0,0 +1,3 @@ +horizontal-rule + + diff --git a/public/icons/image.svg b/public/icons/image.svg new file mode 100755 index 0000000..388c745 --- /dev/null +++ b/public/icons/image.svg @@ -0,0 +1 @@ +paginate-filter-picture-alternate diff --git a/public/icons/indent.svg b/public/icons/indent.svg new file mode 100644 index 0000000..c3ec64b --- /dev/null +++ b/public/icons/indent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/italic.svg b/public/icons/italic.svg new file mode 100755 index 0000000..1916b10 --- /dev/null +++ b/public/icons/italic.svg @@ -0,0 +1 @@ +text-italic diff --git a/public/icons/link.svg b/public/icons/link.svg new file mode 100755 index 0000000..223282b --- /dev/null +++ b/public/icons/link.svg @@ -0,0 +1 @@ +hyperlink-2 diff --git a/public/icons/mention.svg b/public/icons/mention.svg new file mode 100755 index 0000000..9646071 --- /dev/null +++ b/public/icons/mention.svg @@ -0,0 +1 @@ +read-email-at-alternate \ No newline at end of file diff --git a/public/icons/ol.svg b/public/icons/ol.svg new file mode 100755 index 0000000..1e524f7 --- /dev/null +++ b/public/icons/ol.svg @@ -0,0 +1 @@ +list-numbers diff --git a/public/icons/outdent.svg b/public/icons/outdent.svg new file mode 100644 index 0000000..aa4d671 --- /dev/null +++ b/public/icons/outdent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/paragraph.svg b/public/icons/paragraph.svg new file mode 100755 index 0000000..4c23b82 --- /dev/null +++ b/public/icons/paragraph.svg @@ -0,0 +1 @@ +paragraph diff --git a/public/icons/quote.svg b/public/icons/quote.svg new file mode 100755 index 0000000..64c4d3f --- /dev/null +++ b/public/icons/quote.svg @@ -0,0 +1 @@ +close-quote diff --git a/public/icons/redo.svg b/public/icons/redo.svg new file mode 100755 index 0000000..fbfc27a --- /dev/null +++ b/public/icons/redo.svg @@ -0,0 +1 @@ +redo diff --git a/public/icons/remove.svg b/public/icons/remove.svg new file mode 100755 index 0000000..5339795 --- /dev/null +++ b/public/icons/remove.svg @@ -0,0 +1 @@ +delete-2-alternate diff --git a/public/icons/strike.svg b/public/icons/strike.svg new file mode 100755 index 0000000..5bf1e88 --- /dev/null +++ b/public/icons/strike.svg @@ -0,0 +1 @@ +text-strike-through diff --git a/public/icons/table.svg b/public/icons/table.svg new file mode 100755 index 0000000..45315e1 --- /dev/null +++ b/public/icons/table.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/ul.svg b/public/icons/ul.svg new file mode 100755 index 0000000..ab75480 --- /dev/null +++ b/public/icons/ul.svg @@ -0,0 +1 @@ +list-bullets diff --git a/public/icons/underline.svg b/public/icons/underline.svg new file mode 100755 index 0000000..26f8565 --- /dev/null +++ b/public/icons/underline.svg @@ -0,0 +1 @@ +text-underline diff --git a/public/icons/undo.svg b/public/icons/undo.svg new file mode 100755 index 0000000..833ab39 --- /dev/null +++ b/public/icons/undo.svg @@ -0,0 +1 @@ +undo diff --git a/src/Contao/Manager/Plugin.php b/src/Contao/Manager/Plugin.php new file mode 100644 index 0000000..dba5fb5 --- /dev/null +++ b/src/Contao/Manager/Plugin.php @@ -0,0 +1,35 @@ +setLoadAfter([ContaoCoreBundle::class, ContaoNewsBundle::class]), + ]; + } + + public function getRouteCollection(LoaderResolverInterface $resolver, KernelInterface $kernel): RouteCollection|null + { + return $resolver + ->resolve(__DIR__.'/../../Resources/config/routes.yaml') + ->load(__DIR__.'/../../Resources/config/routes.yaml') + ; + } +} diff --git a/src/Controller/EventSubmissionConfirmationModuleController.php b/src/Controller/EventSubmissionConfirmationModuleController.php new file mode 100644 index 0000000..c09ebd5 --- /dev/null +++ b/src/Controller/EventSubmissionConfirmationModuleController.php @@ -0,0 +1,93 @@ +getSession()?->get(self::SESSION_KEY_LAST_SUBMISSION); + + if (!\is_array($entry) || [] === $entry) { + $template->set('entry', null); + + return $template->getResponse(); + } + + $tagIds = array_values(array_unique(array_filter(array_map('intval', (array) ($entry['tags'] ?? [])), static fn (int $id): bool => $id > 0))); + $safeArticleHtml = $this->newsContentSanitizer->sanitize((string) ($entry['articleText'] ?? '')); + + $template->set('entry', [ + 'newsId' => (int) ($entry['newsId'] ?? 0), + 'headline' => (string) ($entry['headline'] ?? ''), + 'subheadline' => (string) ($entry['subheadline'] ?? ''), + 'externalLink' => (string) ($entry['externalLink'] ?? ''), + 'tagList' => implode(', ', $this->resolveTagLabels($tagIds)), + 'articleText' => $safeArticleHtml, + 'addImage' => (bool) ($entry['addImage'] ?? false), + 'photographer' => (string) ($entry['photographer'] ?? ''), + 'altText' => (string) ($entry['altText'] ?? ''), + 'submittedByName' => (string) ($entry['submittedByName'] ?? ''), + 'submittedByEmail' => (string) ($entry['submittedByEmail'] ?? ''), + ]); + + return $template->getResponse(); + } + + /** + * @param array $tagIds + * + * @return array + */ + private function resolveTagLabels(array $tagIds): array + { + if ([] === $tagIds) { + return []; + } + + $rows = $this->connection->createQueryBuilder() + ->select('id', 'tag') + ->from('tl_tags') + ->where('id IN (:ids)') + ->setParameter('ids', $tagIds, ArrayParameterType::INTEGER) + ->executeQuery() + ->fetchAllAssociative(); + + $labelsById = []; + + foreach ($rows as $row) { + $labelsById[(int) ($row['id'] ?? 0)] = (string) ($row['tag'] ?? ''); + } + + $labels = []; + + foreach ($tagIds as $id) { + if (isset($labelsById[$id]) && '' !== $labelsById[$id]) { + $labels[] = $labelsById[$id]; + } + } + + return $labels; + } +} diff --git a/src/Controller/FilePondUploadController.php b/src/Controller/FilePondUploadController.php new file mode 100644 index 0000000..73f22a6 --- /dev/null +++ b/src/Controller/FilePondUploadController.php @@ -0,0 +1,113 @@ +hasValidCsrfToken($request)) { + return new JsonResponse(['error' => 'Ungültiger CSRF-Token.'], Response::HTTP_FORBIDDEN); + } + + $file = $this->extractUploadedFile($request); + + if (!$file instanceof UploadedFile) { + return new JsonResponse(['error' => 'Es wurde keine Datei hochgeladen.'], Response::HTTP_BAD_REQUEST); + } + + try { + $token = $this->uploadManager->storeTemporaryUpload($file, $request->getClientIp()); + } catch (\Throwable $exception) { + return new JsonResponse(['error' => $exception->getMessage()], Response::HTTP_BAD_REQUEST); + } + + return new Response($token, Response::HTTP_OK, ['Content-Type' => 'text/plain']); + } + + #[Route('/_news-submission/revert', name: 'mummert_media_news_submission_revert', methods: ['POST'])] + public function revert(Request $request): Response + { + if (!$this->hasValidCsrfToken($request)) { + return new JsonResponse(['error' => 'Ungültiger CSRF-Token.'], Response::HTTP_FORBIDDEN); + } + + $token = trim($request->getContent()); + + if ('' === $token) { + return new Response('', Response::HTTP_OK); + } + + try { + $this->uploadManager->revertTemporaryUpload($token); + } catch (\Throwable) { + return new Response('', Response::HTTP_OK); + } + + return new Response('', Response::HTTP_OK); + } + + private function hasValidCsrfToken(Request $request): bool + { + $submittedToken = (string) ($request->headers->get('X-CSRF-Token') ?: $request->request->get('REQUEST_TOKEN', '')); + + if ('' === $submittedToken) { + return false; + } + + $tokenName = (string) $this->getParameter('contao.csrf_token_name'); + + return $this->csrfTokenManager->isTokenValid(new CsrfToken($tokenName, $submittedToken)); + } + + private function extractUploadedFile(Request $request): ?UploadedFile + { + $directFile = $request->files->get('file'); + + if ($directFile instanceof UploadedFile) { + return $directFile; + } + + return $this->findUploadedFile($request->files->all()); + } + + private function findUploadedFile(mixed $value): ?UploadedFile + { + if ($value instanceof UploadedFile) { + return $value; + } + + if (!\is_array($value)) { + return null; + } + + foreach ($value as $item) { + $file = $this->findUploadedFile($item); + + if ($file instanceof UploadedFile) { + return $file; + } + } + + return null; + } +} diff --git a/src/Controller/NewsSubmissionModuleController.php b/src/Controller/NewsSubmissionModuleController.php new file mode 100644 index 0000000..d99bdfd --- /dev/null +++ b/src/Controller/NewsSubmissionModuleController.php @@ -0,0 +1,175 @@ +getUser(); + $isLoggedIn = $user instanceof FrontendUser; + $errorMessage = null; + + $data = new NewsSubmissionData(); + $data->anonymousSubmission = !$isLoggedIn; + + $form = $this->createForm(NewsSubmissionType::class, $data, [ + 'show_public_fields' => !$isLoggedIn, + 'tag_choices' => $this->tagProvider->getTagChoices($model), + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted()) { + if ('' !== trim((string) $form->get('honeypot')->getData())) { + $errorMessage = 'Die Anfrage konnte nicht verarbeitet werden.'; + } + + if (!$isLoggedIn && !$this->validateAltcha($request)) { + $errorMessage = 'Die Captcha-Validierung ist fehlgeschlagen.'; + } + + if (null === $errorMessage && $form->isValid()) { + try { + $imageData = null; + + if ($data->addImage) { + $imageData = $this->uploadManager->moveTemporaryUploadToNewsFolder( + (string) $data->imageToken, + isset($model->uploadFolder) ? (string) $model->uploadFolder : null, + ); + } + + $submittedByMember = $isLoggedIn && $user instanceof FrontendUser + ? $this->resolveSubmittedByMember($user) + : null; + + $submittedByName = !$isLoggedIn + ? $data->publicName + : trim((string) (($user->firstname ?? '').' '.($user->lastname ?? ''))); + $submittedByEmail = !$isLoggedIn ? $data->publicEmail : (string) ($user->email ?? ''); + + $tagIds = $this->tagProvider->filterSubmittedTagIds($model, $data->tags); + + $newsId = $this->newsCreator->createDraft( + (int) ($model->newsArchive ?? 0), + (int) ($model->author ?? 0), + $data->headline, + $data->subheadline, + $data->articleText, + $data->externalLink, + $tagIds, + $submittedByMember, + $submittedByName, + $submittedByEmail, + $imageData, + $data->addImage ? $data->photographer : null, + $data->addImage ? $data->altText : null, + ); + + $request->getSession()?->set(self::SESSION_KEY_LAST_SUBMISSION, [ + 'newsId' => $newsId, + 'headline' => $data->headline, + 'subheadline' => $data->subheadline, + 'externalLink' => $data->externalLink, + 'tags' => $tagIds, + 'articleText' => $data->articleText, + 'addImage' => $data->addImage, + 'photographer' => $data->addImage ? $data->photographer : null, + 'altText' => $data->addImage ? $data->altText : null, + 'submittedByMember' => $submittedByMember, + 'submittedByName' => $submittedByName, + 'submittedByEmail' => $submittedByEmail, + ]); + + return new RedirectResponse($this->resolveThankYouUrl($model)); + } catch (\Throwable $exception) { + $errorMessage = $exception->getMessage(); + } + } + } + + $template->set('form', $form->createView()); + $template->set('errorMessage', $errorMessage); + $template->set('isLoggedIn', $isLoggedIn); + $template->set('requestToken', $this->csrfTokenManager->getDefaultTokenValue()); + $template->set('uploadProcessUrl', $this->generateUrl('mummert_media_news_submission_upload')); + $template->set('uploadRevertUrl', $this->generateUrl('mummert_media_news_submission_revert')); + $template->set('showAltcha', !$isLoggedIn); + $template->set('altchaChallengeUrl', $this->generateUrl(AltchaController::class)); + $template->set('altchaMaxNumber', $this->altcha->getRangeMax()); + + return $template->getResponse(); + } + + private function validateAltcha(Request $request): bool + { + $payload = trim((string) $request->request->get('altcha', '')); + + if ('' === $payload) { + return false; + } + + return $this->altcha->validate($payload); + } + + private function resolveThankYouUrl(ModuleModel $model): string + { + $pageId = (int) ($model->thankYouPage ?? 0); + + if ($pageId > 0) { + $page = $this->getContaoAdapter(PageModel::class)->findById($pageId); + + if ($page instanceof PageModel) { + return $this->generateContentUrl($page); + } + } + + $currentPage = $this->getPageModel(); + + return $currentPage instanceof PageModel ? $this->generateContentUrl($currentPage) : '/'; + } + + private function resolveSubmittedByMember(FrontendUser $user): ?int + { + $memberId = (int) ($user->id ?? 0); + + if ($memberId <= 0) { + return null; + } + + return $memberId; + } +} diff --git a/src/DependencyInjection/NewsSubmissionExtension.php b/src/DependencyInjection/NewsSubmissionExtension.php new file mode 100644 index 0000000..70cbeac --- /dev/null +++ b/src/DependencyInjection/NewsSubmissionExtension.php @@ -0,0 +1,19 @@ +load('services.yaml'); + } +} diff --git a/src/Form/Model/NewsSubmissionData.php b/src/Form/Model/NewsSubmissionData.php new file mode 100644 index 0000000..4c7d2db --- /dev/null +++ b/src/Form/Model/NewsSubmissionData.php @@ -0,0 +1,98 @@ + + */ + public array $tags = []; + + #[Assert\NotBlank(message: 'Bitte einen Artikeltext eingeben.')] + public ?string $articleText = null; + + public bool $addImage = false; + + public ?string $imageToken = null; + + #[Assert\Length(max: 255)] + public ?string $photographer = null; + + #[Assert\Length(max: 255)] + public ?string $altText = null; + + #[Assert\Length(max: 255)] + public ?string $publicName = null; + + #[Assert\Email] + #[Assert\Length(max: 255)] + public ?string $publicEmail = null; + + public bool $anonymousSubmission = false; + + #[Assert\Callback] + public function validate(ExecutionContextInterface $context): void + { + if ($this->addImage) { + if ('' === trim((string) $this->photographer)) { + $context->buildViolation('Bitte den Fotografen/Urheber angeben.') + ->atPath('photographer') + ->addViolation(); + } + + if ('' === trim((string) $this->imageToken)) { + $context->buildViolation('Bitte laden Sie ein Bild hoch.') + ->atPath('imageToken') + ->addViolation(); + } + } + + if ($this->anonymousSubmission && '' !== trim((string) $this->publicEmail) && '' === trim((string) $this->publicName)) { + $context->buildViolation('Wenn eine öffentliche E-Mail angegeben wird, bitte auch einen Namen angeben.') + ->atPath('publicName') + ->addViolation(); + } + + $externalLink = trim((string) $this->externalLink); + + if ('' !== $externalLink) { + if (!preg_match('#^[a-z][a-z0-9+\-.]*://#i', $externalLink)) { + $externalLink = 'https://'.$externalLink; + } + + if (false === filter_var($externalLink, FILTER_VALIDATE_URL)) { + $context->buildViolation('Bitte eine gültige URL angeben (z. B. example.org oder https://example.org).') + ->atPath('externalLink') + ->addViolation(); + } else { + $this->externalLink = $externalLink; + } + } + + $plainArticleText = trim(preg_replace('/\s+/u', ' ', strip_tags((string) $this->articleText)) ?? ''); + + if (mb_strlen($plainArticleText) > self::ARTICLE_TEXT_MAX_CHARACTERS) { + $context->buildViolation(sprintf('Der Artikeltext darf maximal %d Zeichen enthalten.', self::ARTICLE_TEXT_MAX_CHARACTERS)) + ->atPath('articleText') + ->addViolation(); + } + } +} diff --git a/src/Form/NewsSubmissionType.php b/src/Form/NewsSubmissionType.php new file mode 100644 index 0000000..cce3e53 --- /dev/null +++ b/src/Form/NewsSubmissionType.php @@ -0,0 +1,141 @@ +add('headline', TextType::class, [ + 'label' => 'Überschrift', + 'attr' => [ + 'autocomplete' => 'off', + ], + ]) + ->add('subheadline', TextType::class, [ + 'label' => 'Unterüberschrift', + 'required' => false, + 'attr' => [ + 'autocomplete' => 'off', + ], + ]) + ->add('externalLink', TextType::class, [ + 'label' => 'Externer Link', + 'required' => false, + 'attr' => [ + 'autocomplete' => 'url', + 'inputmode' => 'url', + ], + ]) + ->add('tags', ChoiceType::class, [ + 'label' => 'Tags', + 'required' => false, + 'multiple' => true, + 'expanded' => true, + 'choices' => $options['tag_choices'], + 'choice_attr' => static function (): array { + return [ + 'role' => 'switch', + 'class' => 'ns-tag-switch-input', + ]; + }, + ]) + ->add('articleText', TextareaType::class, [ + 'label' => 'Artikeltext', + 'required' => false, + 'attr' => [ + 'rows' => 7, + ], + ]) + ->add('addImage', CheckboxType::class, [ + 'label' => 'Bild hochladen', + 'required' => false, + 'attr' => [ + 'class' => 'js-add-image-toggle ns-tag-switch-input', + 'role' => 'switch', + ], + ]) + ->add('imageUpload', FileType::class, [ + 'label' => 'Bilddatei', + 'mapped' => false, + 'required' => false, + 'attr' => [ + 'class' => 'js-news-filepond', + 'accept' => 'image/png,image/jpeg,image/webp', + ], + ]) + ->add('imageToken', HiddenType::class, [ + 'required' => false, + 'attr' => [ + 'class' => 'js-image-token', + ], + ]) + ->add('photographer', TextType::class, [ + 'label' => 'Fotograf/Urheber', + 'required' => false, + 'attr' => [ + 'class' => 'js-photographer', + ], + ]) + ->add('altText', TextType::class, [ + 'label' => 'Alternative Bildbeschreibung', + 'required' => false, + 'help' => 'Wird für barrierefreie Umsetzung der Webseite empfohlen.', + ]); + + if ($options['show_public_fields']) { + $builder + ->add('publicName', TextType::class, [ + 'label' => 'Öffentlicher Name', + 'required' => false, + 'attr' => [ + 'autocomplete' => 'name', + ], + ]) + ->add('publicEmail', EmailType::class, [ + 'label' => 'Öffentliche E-Mail', + 'required' => false, + 'attr' => [ + 'autocomplete' => 'email', + 'inputmode' => 'email', + ], + ]); + } + + $builder->add('honeypot', HiddenType::class, [ + 'mapped' => false, + 'required' => false, + 'attr' => [ + 'autocomplete' => 'off', + 'tabindex' => '-1', + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => NewsSubmissionData::class, + 'show_public_fields' => false, + 'tag_choices' => [], + ]); + + $resolver->setAllowedTypes('show_public_fields', 'bool'); + $resolver->setAllowedTypes('tag_choices', 'array'); + } +} diff --git a/src/NewsSubmissionBundle.php b/src/NewsSubmissionBundle.php new file mode 100644 index 0000000..015b475 --- /dev/null +++ b/src/NewsSubmissionBundle.php @@ -0,0 +1,15 @@ +allowElement($tag); + } + + $this->sanitizer = new HtmlSanitizer($config); + } + + public function sanitize(string $html): string + { + return trim($this->sanitizer->sanitize($html)); + } +} diff --git a/src/Service/NewsCreator.php b/src/Service/NewsCreator.php new file mode 100644 index 0000000..e5d0361 --- /dev/null +++ b/src/Service/NewsCreator.php @@ -0,0 +1,207 @@ + $tagIds + * @param array{uuid:string,path:string}|null $imageData + */ + public function createDraft( + int $archiveId, + int $authorId, + string $headline, + ?string $subheadline, + ?string $teaser, + ?string $externalLink, + array $tagIds, + ?int $submittedByMember, + ?string $submittedByName, + ?string $submittedByEmail, + ?array $imageData, + ?string $photographer, + ?string $altText, + ): int { + if ($archiveId <= 0) { + throw new \RuntimeException('Im Modul ist kein gültiges News-Archiv konfiguriert.'); + } + + if ($authorId <= 0) { + throw new \RuntimeException('Im Modul ist kein gültiger Autor konfiguriert.'); + } + + $now = time(); + $alias = $this->generateUniqueAlias($headline); + $tagIds = array_values(array_unique(array_filter(array_map('intval', $tagIds), static fn (int $id): bool => $id > 0))); + $normalizedExternalLink = $this->normalizeExternalLink($externalLink); + $sanitizedTeaser = $this->newsContentSanitizer->sanitize((string) $teaser); + + if (!$this->hasMeaningfulText($sanitizedTeaser)) { + throw new \RuntimeException('Der Artikeltext enthält nach der Bereinigung keinen verwertbaren Inhalt.'); + } + + $hasExternalLink = null !== $normalizedExternalLink; + $hasImage = null !== $imageData; + $caption = '' !== trim((string) $photographer) ? 'Foto: '.trim((string) $photographer) : null; + + $data = [ + 'tstamp' => $now, + 'pid' => $archiveId, + 'headline' => trim($headline), + 'alias' => $alias, + 'author' => $authorId, + 'date' => $now, + 'time' => $now, + 'subheadline' => trim((string) $subheadline), + 'teaser' => $sanitizedTeaser, + 'source' => $hasExternalLink ? 'external' : 'default', + 'url' => (string) ($normalizedExternalLink ?? ''), + 'target' => 0, + 'published' => 0, + 'featured' => 0, + 'addImage' => $hasImage ? 1 : 0, + 'singleSRC' => $hasImage ? $imageData['uuid'] : null, + 'floating' => $hasImage ? 'above' : 'above', + 'overwriteMeta' => $hasImage && (null !== $caption || '' !== trim((string) $altText)) ? 1 : 0, + 'caption' => $hasImage ? $caption : null, + 'alt' => $hasImage ? trim((string) $altText) : null, + 'submittedByMember' => (int) ($submittedByMember ?? 0), + 'submittedByName' => trim((string) $submittedByName), + 'submittedByEmail' => trim((string) $submittedByEmail), + 'tags' => serialize(array_map('strval', $tagIds)), + ]; + + $types = [ + 'pid' => ParameterType::INTEGER, + 'author' => ParameterType::INTEGER, + 'date' => ParameterType::INTEGER, + 'time' => ParameterType::INTEGER, + 'published' => ParameterType::INTEGER, + 'featured' => ParameterType::INTEGER, + 'target' => ParameterType::INTEGER, + 'addImage' => ParameterType::INTEGER, + 'overwriteMeta' => ParameterType::INTEGER, + 'submittedByMember' => ParameterType::INTEGER, + ]; + + if ($hasImage) { + $types['singleSRC'] = ParameterType::BINARY; + } + + $this->connection->insert('tl_news', $data, $types); + + $newsId = (int) $this->connection->lastInsertId(); + + if ($newsId <= 0) { + throw new \RuntimeException('News-Datensatz konnte nicht erzeugt werden.'); + } + + $this->assignTags($newsId, $tagIds); + + return $newsId; + } + + /** + * @param array $tagIds + */ + private function assignTags(int $newsId, array $tagIds): void + { + $this->connection->delete( + 'tl_tags_rel', + ['ptable' => 'tl_news', 'field' => 'tags', 'pid' => $newsId], + ['pid' => ParameterType::INTEGER], + ); + + foreach ($tagIds as $tagId) { + if ($tagId <= 0) { + continue; + } + + $this->connection->insert( + 'tl_tags_rel', + [ + 'tag_id' => $tagId, + 'pid' => $newsId, + 'ptable' => 'tl_news', + 'field' => 'tags', + ], + [ + 'tag_id' => ParameterType::INTEGER, + 'pid' => ParameterType::INTEGER, + ], + ); + } + } + + private function generateUniqueAlias(string $headline): string + { + $baseAlias = StringUtil::generateAlias($headline); + + if ('' === $baseAlias) { + $baseAlias = 'news-'.date('YmdHis'); + } + + $candidate = $baseAlias; + $counter = 1; + + while ($this->aliasExists($candidate)) { + $candidate = sprintf('%s-%d', $baseAlias, $counter); + ++$counter; + } + + return $candidate; + } + + private function hasMeaningfulText(string $html): bool + { + $text = trim(str_replace("\xc2\xa0", ' ', strip_tags($html))); + + return '' !== $text; + } + + private function aliasExists(string $alias): bool + { + $exists = $this->connection->createQueryBuilder() + ->select('id') + ->from('tl_news') + ->where('alias = :alias') + ->setParameter('alias', $alias) + ->setMaxResults(1) + ->executeQuery() + ->fetchOne(); + + return false !== $exists; + } + + private function normalizeExternalLink(?string $externalLink): ?string + { + $externalLink = trim((string) $externalLink); + + if ('' === $externalLink) { + return null; + } + + if (!preg_match('#^[a-z][a-z0-9+\-.]*://#i', $externalLink)) { + $externalLink = 'https://'.$externalLink; + } + + if (false === filter_var($externalLink, FILTER_VALIDATE_URL)) { + throw new \RuntimeException('Der externe Link ist ungültig. Bitte eine gültige URL eingeben.'); + } + + return $externalLink; + } +} diff --git a/src/Service/TagProvider.php b/src/Service/TagProvider.php new file mode 100644 index 0000000..cc76ccc --- /dev/null +++ b/src/Service/TagProvider.php @@ -0,0 +1,76 @@ + + */ + public function getTagChoices(ModuleModel $moduleModel): array + { + $allowedIds = $this->normalizeIds(StringUtil::deserialize($moduleModel->allowedTags ?? null, true)); + + $rows = $this->connection->createQueryBuilder() + ->select('id', 'tag') + ->from('tl_tags') + ->where('invisible = :invisible') + ->setParameter('invisible', '') + ->orderBy('tag', 'ASC') + ->executeQuery() + ->fetchAllAssociative(); + + $choices = []; + + foreach ($rows as $row) { + $id = (int) ($row['id'] ?? 0); + $label = trim((string) ($row['tag'] ?? '')); + + if ($id <= 0 || '' === $label) { + continue; + } + + if ([] !== $allowedIds && !in_array($id, $allowedIds, true)) { + continue; + } + + $choices[$label] = $id; + } + + return $choices; + } + + /** + * @param array $submittedIds + * + * @return array + */ + public function filterSubmittedTagIds(ModuleModel $moduleModel, array $submittedIds): array + { + $allowedChoiceIds = array_values($this->getTagChoices($moduleModel)); + $submittedIds = $this->normalizeIds($submittedIds); + + return array_values(array_intersect($submittedIds, $allowedChoiceIds)); + } + + /** + * @param array $values + * + * @return array + */ + private function normalizeIds(array $values): array + { + return array_values(array_unique(array_filter(array_map('intval', $values), static fn (int $id): bool => $id > 0))); + } +} diff --git a/src/Service/UploadManager.php b/src/Service/UploadManager.php new file mode 100644 index 0000000..588fdd3 --- /dev/null +++ b/src/Service/UploadManager.php @@ -0,0 +1,247 @@ + */ + private const ALLOWED_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + ]; + + public function __construct( + private readonly string $projectDir, + ) { + } + + public function storeTemporaryUpload(UploadedFile $file, ?string $clientIp): string + { + $this->assertRateLimit($clientIp); + $this->assertValidUpload($file); + + $token = bin2hex(random_bytes(20)); + $tmpDir = $this->getTemporaryDirectory(); + $extension = strtolower((string) ($file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin')); + $extension = preg_replace('/[^a-z0-9]+/', '', $extension) ?: 'bin'; + + $filePath = $tmpDir.'/'.$token.'.'.$extension; + $metaPath = $tmpDir.'/'.$token.'.json'; + + $file->move($tmpDir, basename($filePath)); + + $metadata = [ + 'ip' => (string) $clientIp, + 'createdAt' => time(), + 'originalName' => $file->getClientOriginalName(), + 'storedPath' => $filePath, + ]; + + file_put_contents($metaPath, json_encode($metadata, JSON_THROW_ON_ERROR)); + + return $token; + } + + /** + * @return array{uuid:string,path:string} + */ + public function moveTemporaryUploadToNewsFolder(string $token, string|null $uploadFolderUuid): array + { + if ('' === trim((string) $uploadFolderUuid)) { + throw new RuntimeException('Kein Upload-Ordner im Modul konfiguriert.'); + } + + [$filePath, $metaPath, $metadata] = $this->getValidTemporaryUpload($token); + + $folderModel = FilesModel::findByUuid((string) $uploadFolderUuid); + + if (null === $folderModel) { + throw new RuntimeException('Der konfigurierte Upload-Ordner wurde nicht gefunden.'); + } + + $baseRelativeFolder = trim((string) $folderModel->path, '/'); + + if ('' === $baseRelativeFolder) { + throw new RuntimeException('Ungültiger Upload-Ordner.'); + } + + $yearFolder = $baseRelativeFolder.'/'.date('Y'); + $absoluteYearFolder = rtrim($this->projectDir, '/').'/'.$yearFolder; + + if (!is_dir($absoluteYearFolder) && !mkdir($absoluteYearFolder, 0775, true) && !is_dir($absoluteYearFolder)) { + throw new RuntimeException('Jahresordner konnte nicht erstellt werden.'); + } + + $originalName = (string) ($metadata['originalName'] ?? 'image'); + $pathInfo = pathinfo($originalName); + $baseName = $this->slugifyFilename((string) ($pathInfo['filename'] ?? 'image')); + $extension = strtolower((string) ($pathInfo['extension'] ?? pathinfo($filePath, PATHINFO_EXTENSION) ?? 'bin')); + $extension = preg_replace('/[^a-z0-9]+/', '', $extension) ?: 'bin'; + + $targetFilename = $this->buildUniqueFilename($absoluteYearFolder, $baseName, $extension); + $targetAbsolutePath = $absoluteYearFolder.'/'.$targetFilename; + + if (!rename($filePath, $targetAbsolutePath)) { + throw new RuntimeException('Temporäre Upload-Datei konnte nicht verschoben werden.'); + } + + $targetRelativePath = $yearFolder.'/'.$targetFilename; + Dbafs::addResource($yearFolder); + $model = Dbafs::addResource($targetRelativePath); + + if (null === $model) { + $model = FilesModel::findByPath($targetRelativePath); + } + + @unlink($metaPath); + + if (null === $model) { + throw new RuntimeException('Datei konnte nicht in der Dateiverwaltung registriert werden.'); + } + + return [ + 'uuid' => (string) $model->uuid, + 'path' => $targetRelativePath, + ]; + } + + public function revertTemporaryUpload(string $token): void + { + [$filePath, $metaPath] = $this->getValidTemporaryUpload($token); + + @unlink($filePath); + @unlink($metaPath); + } + + private function assertRateLimit(?string $clientIp): void + { + $clientIp = trim((string) $clientIp); + + if ('' === $clientIp) { + return; + } + + $tmpDir = $this->getTemporaryDirectory(); + $metaFiles = glob($tmpDir.'/*.json') ?: []; + $now = time(); + $count = 0; + + foreach ($metaFiles as $metaFile) { + $data = json_decode((string) file_get_contents($metaFile), true); + + if (!is_array($data)) { + continue; + } + + if (($data['ip'] ?? null) !== $clientIp) { + continue; + } + + $createdAt = (int) ($data['createdAt'] ?? 0); + + if ($createdAt >= $now - 60) { + ++$count; + } + } + + if ($count >= 20) { + throw new RuntimeException('Zu viele Upload-Anfragen. Bitte kurz warten.'); + } + } + + private function assertValidUpload(UploadedFile $file): void + { + if (!$file->isValid()) { + throw new RuntimeException('Der Upload ist fehlgeschlagen.'); + } + + if ($file->getSize() > self::MAX_FILE_SIZE) { + throw new RuntimeException('Die Datei ist zu groß. Maximal 6 MB erlaubt.'); + } + + $mimeType = strtolower((string) $file->getMimeType()); + + if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) { + throw new RuntimeException('Nur JPG, PNG und WEBP sind erlaubt.'); + } + } + + /** + * @return array{0:string,1:string,2?:array} + */ + private function getValidTemporaryUpload(string $token): array + { + if (!preg_match('/^[a-f0-9]{40}$/', $token)) { + throw new RuntimeException('Ungültiger Upload-Token.'); + } + + $tmpDir = $this->getTemporaryDirectory(); + $metaPath = $tmpDir.'/'.$token.'.json'; + + if (!is_file($metaPath)) { + throw new RuntimeException('Upload wurde nicht gefunden oder ist abgelaufen.'); + } + + $metadata = json_decode((string) file_get_contents($metaPath), true); + + if (!is_array($metadata)) { + throw new RuntimeException('Upload-Metadaten sind ungültig.'); + } + + $filePath = (string) ($metadata['storedPath'] ?? ''); + + if ('' === $filePath || !is_file($filePath)) { + throw new RuntimeException('Upload-Datei wurde nicht gefunden.'); + } + + if ((int) ($metadata['createdAt'] ?? 0) < time() - 86400) { + @unlink($filePath); + @unlink($metaPath); + throw new RuntimeException('Upload ist abgelaufen. Bitte erneut hochladen.'); + } + + return [$filePath, $metaPath, $metadata]; + } + + private function getTemporaryDirectory(): string + { + $directory = rtrim($this->projectDir, '/').'/var/news_submission_uploads/tmp'; + + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException('Temporäres Upload-Verzeichnis konnte nicht erstellt werden.'); + } + + return $directory; + } + + private function slugifyFilename(string $filename): string + { + $filename = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $filename) ?? ''; + $filename = trim($filename, '-_'); + + return '' !== $filename ? strtolower($filename) : 'bild'; + } + + private function buildUniqueFilename(string $absoluteFolder, string $baseName, string $extension): string + { + $candidate = $baseName.'.'.$extension; + $counter = 1; + + while (is_file($absoluteFolder.'/'.$candidate)) { + $candidate = sprintf('%s-%d.%s', $baseName, $counter, $extension); + ++$counter; + } + + return $candidate; + } +}