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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% 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 %}
+
+
Bitte korrigieren Sie die markierten Felder:
+
+
+ {% 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_row(form.imageUpload) }}
+ {{ form_row(form.imageToken) }}
+ {{ form_row(form.photographer) }}
+ {{ form_row(form.altText) }}
+
+
+ {% 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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
\ 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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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;
+ }
+}