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:
parent
4dffb1368f
commit
5d8e379e85
5 changed files with 246 additions and 7 deletions
74
app/Rules/NoPromotionalText.php
Normal file
74
app/Rules/NoPromotionalText.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
]);
|
||||
|
|
|
|||
118
tests/Feature/NoPromotionalTextTest.php
Normal file
118
tests/Feature/NoPromotionalTextTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue