companies: Kurzfelder des Profils ohne Links, Adressen und Spam (NoPromotionalText)

Firmenname, Adresse und Pressekontakt erscheinen oeffentlich bzw. in
Mails, wurden aber nicht geprueft — nur die Beschreibung lief durch die
Rot-Ebene. Neue deterministische Regel: keine Links, keine E-Mail-
Adressen, keine Zeichen-/Emoji-Ketten, kein Blacklist-Treffer; nackte
Domains ohne Pfad bleiben erlaubt (Marke). Adresse zusaetzlich durch die
Rot-Ebene wie die Beschreibung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 07:55:07 +00:00
parent 4fd1db7825
commit 87ca9082a9
5 changed files with 246 additions and 7 deletions

View file

@ -0,0 +1,74 @@
<?php
namespace App\Rules;
use App\Services\PressRelease\BlacklistService;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Kurzfelder, die öffentlich oder in Mails erscheinen (Firmenname, Adresse,
* Pressekontakt), sind keine Werbefläche: keine Links, keine
* E-Mail-Adressen, kein Blacklist-Treffer, keine Zeichenorgien.
*
* Bewusst deterministisch und eng — eine KI für sechs Wörter wäre langsam,
* und falsche Treffer kosten hier echte Firmen den Namen. Nackte Domains
* („meinshop.de GmbH") bleiben deshalb erlaubt: Sie sind Marke, und die
* Website ist ohnehin das eine systemseitig ausgezeichnete Linkfeld.
* Der Fließtext („Über das Unternehmen") läuft separat durch die Rot-Ebene
* der Meldungsprüfung (CompanyProfileCheck).
*/
class NoPromotionalText implements ValidationRule
{
/** Schema, www. — oder eine nackte Domain mit Pfad („spam.example/deal"). */
private const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu';
private const EMAIL_PATTERN = '~[^\s@]+@[^\s@]+\.[a-z]{2,}~iu';
/** Drei und mehr Satz-/Währungszeichen am Stück: „!!!", „€€€", „$$$". */
private const NOISE_PATTERN = '~[!?$€£%*#]{3,}~u';
/** Drei und mehr Emoji/Symbole am Stück. */
private const EMOJI_PATTERN = '~(?:[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}]\x{FE0F}?){3,}~u';
public function __construct(private readonly BlacklistService $blacklist) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || trim($value) === '') {
return;
}
$reason = self::reasonFor($value, $this->blacklist);
if ($reason !== null) {
$fail($reason);
}
}
/**
* Grund der Ablehnung als Nutzertext, oder null, wenn der Text sauber ist.
*/
public static function reasonFor(string $value, BlacklistService $blacklist): ?string
{
if (preg_match(self::LINK_PATTERN, $value) === 1) {
return __('Links sind in diesem Feld nicht erlaubt. Die Website gehört in das Feld „Website".');
}
if (preg_match(self::EMAIL_PATTERN, $value) === 1) {
return __('E-Mail-Adressen gehören in das dafür vorgesehene Feld.');
}
if (preg_match(self::NOISE_PATTERN, $value) === 1 || preg_match(self::EMOJI_PATTERN, $value) === 1) {
return __('Bitte ohne Sonderzeichen-Ketten oder Emoji-Reihen.');
}
$word = $blacklist->find($value);
if ($word !== null) {
return __('Der Text enthält einen unzulässigen Begriff.');
}
return null;
}
}

View file

@ -1471,3 +1471,38 @@ Tests: `ManualReviewGateTest` (+1 → 6, Mail beim Block, Textvarianten,
Opt-out), `BladePhpBlockGuardTest` (1).
**Damit ist Block 1d komplett, ohne offene Punkte.**
## Nachtrag 17.09.2026 — Profil-Kurzfelder sind keine Werbefläche
**Anlass:** Frage des Nutzers nach der Live-Sichtung von 1d, ob beim
Speichern des Profils auf Spam geprüft wird.
**Stand vorher:** Die Beschreibung („Über das Unternehmen") lief seit 1b.3
durch die Rot-Ebene (`CompanyProfileCheck`, KI mit Blacklist-Fallback).
Nicht geprüft waren die Kurzfelder, die ebenfalls öffentlich bzw. in Mails
erscheinen: Firmenname, Adresse, Pressekontakt (Name, Zuständigkeit).
**Umsetzung:** Regel `App\Rules\NoPromotionalText` — deterministisch,
ohne KI: keine Links (Schema, `www.`, nackte Domain mit Pfad), keine
E-Mail-Adressen, keine Zeichenketten (`!!!`, Emoji-Reihen), kein
Blacklist-Treffer; jeweils mit eigener Fehlermeldung am Feld. Nackte
Domains ohne Pfad („meinshop.de GmbH") bleiben bewusst erlaubt — Marke,
nicht Link. Angewendet in Firmen ▸ Stammdaten (Name, Adresse), Firma
anlegen (Name, Adresse) und Pressekontakt (Vorname, Nachname,
Zuständigkeit). Die Adresse läuft zusätzlich wie die Beschreibung durch die
Rot-Ebene, nur bei Änderung.
**Befund im Bestand (Dev-DB = Live-Kopie):**
- 4.390 aktive Firmen tragen bbcode-/HTML-Linkspam in der Adresse
(Moncler, Chanel-Outlet …), 4.381 davon mit `website = "180"` — ein
Legacy-Importfehler; **keine davon hat je eine Meldung veröffentlicht**,
und ohne veröffentlichte Meldung gibt es keine Newsroom-Seite
(`NewsroomDetailPageData`). Nicht öffentlich, aber Datenmüll.
- 38 öffentliche Firmen (mit Meldungen, jüngste 09.09.2026) haben eine URL
im **Namen** („Steinreinigungsuntermehmen - https://www.bgn.at/…"),
überwiegend über die API eingeliefert. Diese Namen stehen auf der
Newsroom-Seite und in der Übersicht.
- Kontakte: 0 Treffer. Beschreibungen: 0 Treffer.
Tests: `NoPromotionalTextTest` (20 inkl. Datensätze). Entscheidung zu
API-Eingang und Bestandsbereinigung offen (siehe Frage an den Nutzer).

View file

@ -3,6 +3,7 @@
use App\Enums\CompanyType;
use App\Enums\Portal;
use App\Models\Company;
use App\Rules\NoPromotionalText;
use Flux\Flux;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
@ -37,7 +38,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
{
try {
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'name' => ['required', 'string', 'max:255', app(NoPromotionalText::class)],
// „Beide Portale" ist bei der Firmen-Anlage bewusst nicht
// wählbar: PMs übernehmen das Portal der Firma und würden
// sonst automatisch auf beiden Portalen veröffentlicht.
@ -45,7 +46,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
Portal::Presseecho->value,
Portal::Businessportal24->value,
])],
'address' => ['nullable', 'string', 'max:1000'],
'address' => ['nullable', 'string', 'max:1000', app(NoPromotionalText::class)],
'email' => ['nullable', 'email', 'max:190'],
'phone' => ['nullable', 'string', 'max:40'],
'website' => ['nullable', 'url', 'max:190'],

View file

@ -3,6 +3,7 @@
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Rules\NoPromotionalText;
use App\Services\Company\CompanyProfileCheck;
use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext;
@ -100,8 +101,10 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
$this->authorize('update', $company);
$validated = $this->validate([
'companyName' => ['required', 'string', 'max:255'],
'companyAddress' => ['nullable', 'string', 'max:1000'],
// Öffentliche Kurzfelder sind keine Werbefläche: keine Links,
// keine Adressen, kein Blacklist-Treffer (NoPromotionalText).
'companyName' => ['required', 'string', 'max:255', app(NoPromotionalText::class)],
'companyAddress' => ['nullable', 'string', 'max:1000', app(NoPromotionalText::class)],
'companyEmail' => ['nullable', 'email', 'max:190'],
'companyPhone' => ['nullable', 'string', 'max:40'],
'companyWebsite' => ['nullable', 'url', 'max:190'],
@ -114,14 +117,22 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
]);
$boilerplate = trim(strip_tags((string) $validated['companyBoilerplate']));
$address = trim((string) $validated['companyAddress']);
// Rot-Ebene der bestehenden Prüfung — dieselbe wie für eine Meldung.
// Gilt für beide Fließtextfelder, jeweils nur bei Änderung.
if ($boilerplate !== '' && $boilerplate !== trim((string) $company->boilerplate) && $profileCheck->isRed($company, $boilerplate)) {
throw ValidationException::withMessages([
'companyBoilerplate' => __('Die Beschreibung enthält unzulässige Inhalte und kann so nicht gespeichert werden.'),
]);
}
if ($address !== '' && $address !== trim((string) $company->address) && $profileCheck->isRed($company, $address)) {
throw ValidationException::withMessages([
'companyAddress' => __('Die Adresse enthält unzulässige Inhalte und kann so nicht gespeichert werden.'),
]);
}
$company->fill([
'name' => $validated['companyName'],
'address' => $validated['companyAddress'] ?: null,
@ -195,9 +206,9 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
$this->authorize('update', $company);
$validated = $this->validate([
'contactFirstName' => ['nullable', 'string', 'max:80'],
'contactLastName' => ['nullable', 'string', 'max:80'],
'contactResponsibility' => ['nullable', 'string', 'max:255'],
'contactFirstName' => ['nullable', 'string', 'max:80', app(NoPromotionalText::class)],
'contactLastName' => ['nullable', 'string', 'max:80', app(NoPromotionalText::class)],
'contactResponsibility' => ['nullable', 'string', 'max:255', app(NoPromotionalText::class)],
'contactEmail' => ['required', 'email', 'max:255'],
'contactPhone' => ['nullable', 'string', 'max:40'],
]);

View file

@ -0,0 +1,118 @@
<?php
use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use App\Rules\NoPromotionalText;
use Illuminate\Support\Facades\Validator;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Öffentliche Kurzfelder des Newsrooms (Firmenname, Adresse, Pressekontakt)
* sind keine Werbefläche — deterministische Regel, ohne KI (17.09.2026).
*/
function promotionalReason(string $value): ?string
{
$validator = Validator::make(['field' => $value], ['field' => ['nullable', 'string', app(NoPromotionalText::class)]]);
return $validator->fails() ? $validator->errors()->first('field') : null;
}
beforeEach(function (): void {
config()->set('blacklist.words', ['spam-keyword', 'casino royale']);
config()->set('scoring.classification.provider', 'deterministic');
});
test('ordinary company names, addresses and roles pass', function (string $value) {
expect(promotionalReason($value))->toBeNull();
})->with([
'Muster GmbH & Co. KG',
'meinshop.de GmbH',
'Hörwelt Basel e.V.',
'Musterstraße 12, 12345 Musterstadt',
'Leiterin Unternehmenskommunikation',
'Pressesprecher (Region Süd) – erreichbar Mo–Fr!',
'SAP SE',
'',
]);
test('links, addresses, noise and blacklist hits are rejected with a specific reason', function (string $value, string $expected) {
expect(promotionalReason($value))->toContain($expected);
})->with([
['Casino Bonus https://spam.example/bonus', 'Links'],
['Gewinne jetzt www.spam.example', 'Links'],
['Angebote auf spam.example/deal', 'Links'],
['Bestellen unter kontakt@spam.example', 'E-Mail-Adressen'],
['JETZT KAUFEN!!!', 'Sonderzeichen'],
['Gratis 🎰🎰🎰 Bonus', 'Sonderzeichen'],
['Ein spam-keyword Anbieter', 'unzulässigen Begriff'],
['Willkommen im Casino Royale', 'unzulässigen Begriff'],
]);
function newsroomEditor(TestCase $test): Company
{
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->businessportal24()->create(['name' => 'Muster GmbH', 'slug' => 'muster-gmbh', 'boilerplate' => null, 'address' => null]);
$customer->companies()->attach($company->id, ['role' => 'responsible']);
$test->actingAs($customer);
return $company;
}
test('a company name or address with a link is not saved', function () {
/** @var TestCase $this */
$company = newsroomEditor($this);
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->call('startEditCompany')
->set('companyName', 'Muster GmbH – https://spam.example')
->set('companyAddress', "Musterstraße 1\nwww.spam.example")
->call('saveCompany')
->assertHasErrors(['companyName', 'companyAddress']);
expect($company->fresh()->name)->toBe('Muster GmbH')
->and($company->fresh()->address)->toBeNull();
});
test('an address that hits the red level of the release check is not saved', function () {
/** @var TestCase $this */
$company = newsroomEditor($this);
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->call('startEditCompany')
->set('companyAddress', 'Musterstraße 1, spam-keyword Musterstadt')
->call('saveCompany')
->assertHasErrors(['companyAddress']);
expect($company->fresh()->address)->toBeNull();
});
test('a press contact with a link in the role is not saved', function () {
/** @var TestCase $this */
$company = newsroomEditor($this);
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->call('startCreateContact')
->set('contactLastName', 'Muster')
->set('contactResponsibility', 'Pressesprecher – Angebote unter https://spam.example')
->set('contactEmail', 'presse@muster.example')
->call('saveContact')
->assertHasErrors(['contactResponsibility']);
expect(Contact::query()->where('company_id', $company->id)->count())->toBe(0);
});
test('creating a company with a promotional name is refused', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-kits.create')
->set('name', 'Casino Royale Bonus')
->set('portal', 'businessportal24')
->call('save')
->assertHasErrors(['name']);
expect(Company::query()->where('name', 'Casino Royale Bonus')->exists())->toBeFalse();
});