Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und Medienpfade sowie die PressRelease-Helfer schreiben jetzt withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein. Der Vorschau-Test prüfte bisher forceDelete statt delete. F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER. Neuer Index contacts_email_idx. F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt- Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache vorher nicht und traf den Fehler nie. F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst (ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung gewinnt die Erstquelle. F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren Vertrag. F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht als verlorenes Logo. Block 4 — KI-Prüfung und Scheduler F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen (ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum aktuellen Text passt, sondern plant eine Prüfung mit Routing ein. F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period). Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der Reset ist idempotent und lässt den laufenden Monat in Ruhe. F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen Fehler-Exit-Code, damit der Failure-Hook greift. F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt. F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
127 lines
4.3 KiB
PHP
127 lines
4.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\PressRelease;
|
|
|
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Storage for press release attachments (PDFs and other documents).
|
|
*
|
|
* Files are stored on the private `local` disk under
|
|
* press-releases/{id}/attachments/{uuid}-{slug}.{ext}
|
|
*
|
|
* Access is served through a controller which checks publication state or the
|
|
* authenticated user's policy.
|
|
*/
|
|
class PressReleaseAttachmentStorage
|
|
{
|
|
public const MAX_BYTES = 25 * 1024 * 1024;
|
|
|
|
/** @var list<string> */
|
|
public const ALLOWED_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'txt'];
|
|
|
|
/** @var list<string> */
|
|
public const ALLOWED_MIMES = [
|
|
'application/pdf',
|
|
'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'application/zip',
|
|
'application/x-zip-compressed',
|
|
'text/plain',
|
|
];
|
|
|
|
/**
|
|
* Store the uploaded attachment for the given press release.
|
|
*
|
|
* @return array{disk:string,path:string,original_name:string,mime:string,size:int}
|
|
*/
|
|
public function store(UploadedFile $upload, int $pressReleaseId, string $diskName = 'local'): array
|
|
{
|
|
$this->assertValidUpload($upload);
|
|
|
|
$extension = strtolower($upload->getClientOriginalExtension() ?: $upload->extension());
|
|
$originalName = $upload->getClientOriginalName();
|
|
$baseSlug = Str::slug(pathinfo($originalName, PATHINFO_FILENAME)) ?: 'attachment';
|
|
$baseSlug = Str::limit($baseSlug, 60, '');
|
|
|
|
$directory = sprintf('press-releases/%d/attachments', $pressReleaseId);
|
|
$filename = Str::uuid()->toString().'-'.$baseSlug.'.'.$extension;
|
|
$relativePath = $directory.'/'.$filename;
|
|
|
|
$disk = $this->disk($diskName);
|
|
$stream = fopen($upload->getRealPath(), 'r');
|
|
|
|
if ($stream === false) {
|
|
throw new RuntimeException('Could not open uploaded file stream.');
|
|
}
|
|
|
|
try {
|
|
$written = $disk->put($relativePath, $stream, $diskName === 'public' ? 'public' : 'private');
|
|
} finally {
|
|
if (is_resource($stream)) {
|
|
fclose($stream);
|
|
}
|
|
}
|
|
|
|
// Die Disks sind mit throw=false konfiguriert: Ein fehlgeschlagener
|
|
// Schreibvorgang (voller Datenträger, Rechte) kommt nur als false
|
|
// zurück. Ohne Prüfung entstünde ein Datensatz, dessen Datei nie
|
|
// existiert hat (F23).
|
|
if (! $written) {
|
|
throw new RuntimeException('Der Anhang konnte nicht gespeichert werden.');
|
|
}
|
|
|
|
return [
|
|
'disk' => $diskName,
|
|
'path' => $relativePath,
|
|
'original_name' => $originalName,
|
|
'mime' => $upload->getMimeType() ?: 'application/octet-stream',
|
|
'size' => $upload->getSize() ?: 0,
|
|
];
|
|
}
|
|
|
|
public function delete(string $disk, string $path): void
|
|
{
|
|
if (blank($path)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
Storage::disk($disk)->delete($path);
|
|
} catch (\Throwable) {
|
|
// Swallow — deletion is best-effort; a missing file is acceptable.
|
|
}
|
|
}
|
|
|
|
private function assertValidUpload(UploadedFile $upload): void
|
|
{
|
|
$extension = strtolower($upload->getClientOriginalExtension() ?: $upload->extension());
|
|
|
|
if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
|
|
throw new RuntimeException('Unsupported attachment extension: '.$extension);
|
|
}
|
|
|
|
$mime = $upload->getMimeType();
|
|
|
|
if ($mime === null || ! in_array($mime, self::ALLOWED_MIMES, true)) {
|
|
throw new RuntimeException('Unsupported attachment MIME type: '.($mime ?? 'unknown'));
|
|
}
|
|
|
|
if ($upload->getSize() > self::MAX_BYTES) {
|
|
throw new RuntimeException('Attachment exceeds maximum size.');
|
|
}
|
|
}
|
|
|
|
private function disk(string $diskName): Filesystem
|
|
{
|
|
return Storage::disk($diskName);
|
|
}
|
|
}
|