images: nur freigegebene Bilder in Feed und PDF, versionierte Veröffentlichungssnapshots, Bestätigung an das gezeigte Bild gebunden, getrennte Rückhaltegründe (Umsetzungsaudit 29.09.: B-01 bis B-14, N09, OPT-3 bis OPT-5; 02.10.: O03)
- B-01/B-02: Startseiten-Feed, Card-Helfer und Nachweis-PDF nur mit isPubliclyDeliverable() - B-03: Livewire-Temp-Upload 16 MiB - N09: Snapshot je Bytes- und Metadaten-Hash inkl. OG-Variante, put() geprüft, alle Fassungen ins Archiv - O03: published_ever_at in der Freigabetransaktion (markPublishedIfNowPublic) — Kundenbestätigung, Vorab-Sichtung, Korrektur - B-04/B-14: Bestätigung an Bild-ID und SHA gebunden, unter Sperre - B-05/B-06: Entfernen unter Sperre mit frischem Stand und Archivauftrag; Korrektur archiviert über den Auftrag, Nacharbeiten abgesichert - B-07: precheck_withheld_at getrennt von withheld_at (Migration + Übernahme) - B-08, B-09, B-11, B-12: published_ever_at nur bei Status published, Admin-Eigentümer mit Kundenregel, Sweep verwaister Originale, Verarbeitungsfehler als Formularmeldung - K-02-Teil: nach Kundenbestätigung erneut routen - OPT-3 bis OPT-5: pingImage für JPEG, Hash durchgereicht, erstes Frame Admin-PM-Detailseite enthält zusätzlich K-06 (Autorisierung) und A-08 (Nachweis per POST), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
60650baeb8
commit
f1c0e2766b
23 changed files with 1675 additions and 149 deletions
|
|
@ -75,7 +75,7 @@ new #[Layout('components.layouts.app'), Title('Bilder')] class extends Component
|
|||
|
||||
match ($this->state) {
|
||||
'review' => $query->awaitingPrecheckReview(),
|
||||
'withheld' => $query->whereNotNull('withheld_at'),
|
||||
'withheld' => $query->where(fn ($inner) => $inner->whereNotNull('withheld_at')->orWhereNotNull('precheck_withheld_at')),
|
||||
'unconfirmed' => $query->whereNotNull('rules_version')->whereNull('rights_confirmed_at'),
|
||||
'legacy' => $query->whereNull('rules_version'),
|
||||
'published' => $query->whereNotNull('published_ever_at'),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use App\Enums\ImagePrecheckCategory;
|
|||
use App\Services\Image\Precheck\ImagePrecheckReviewService;
|
||||
use App\Services\PressRelease\Images\ImageCorrectionService;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\QuotaExceededException;
|
||||
use Flux\Flux;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
|
@ -29,12 +30,28 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
$this->id = $id;
|
||||
}
|
||||
|
||||
/*
|
||||
* Berechtigung (Audit K-06): jede Aktion, die Status oder Bild ändert,
|
||||
* verlangt wie Liste und Bearbeiten `press-releases:publish`.
|
||||
*/
|
||||
public function publish(): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
} catch (QuotaExceededException $e) {
|
||||
Flux::toast(heading: __('Nicht veröffentlicht'), text: $e->getMessage(), variant: 'warning', duration: 8000);
|
||||
Flux::modal('confirm-show-publish')->close();
|
||||
|
||||
return;
|
||||
} catch (\LogicException $e) {
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
Flux::modal('confirm-show-publish')->close();
|
||||
|
||||
return;
|
||||
} catch (ImageRightsConfirmationRequiredException $e) {
|
||||
Flux::toast(heading: __('Titelbild nicht bestätigt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
|
||||
Flux::modal('confirm-show-publish')->close();
|
||||
|
|
@ -58,12 +75,22 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
public function reject(): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$this->validate([
|
||||
'rejectReason' => ['required', 'string', 'min:5', 'max:2000'],
|
||||
]);
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
app(PressReleaseService::class)->reject($pr, trim($this->rejectReason));
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->reject($pr, trim($this->rejectReason));
|
||||
} catch (\LogicException $e) {
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
Flux::modal('confirm-show-reject')->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->rejectReason = '';
|
||||
|
||||
|
|
@ -73,8 +100,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
public function archive(): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
app(PressReleaseService::class)->archive($pr);
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->archive($pr);
|
||||
} catch (\LogicException $e) {
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
Flux::modal('confirm-show-archive')->close();
|
||||
|
||||
return;
|
||||
}
|
||||
Flux::toast(text: __('Archiviert.'), variant: 'success');
|
||||
Flux::modal('confirm-show-archive')->close();
|
||||
}
|
||||
|
|
@ -91,6 +128,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
public function correctImage(ImageCorrectionService $corrections): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
|
||||
|
||||
|
|
@ -145,6 +184,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
private function decidePrecheck(ImagePrecheckReviewService $reviews, bool $approve): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
$image = $pr->images()->awaitingPrecheckReview()->orderByDesc('is_preview')->orderBy('sort_order')->first();
|
||||
|
||||
|
|
@ -170,6 +211,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
public function toggleWithheld(ImageCorrectionService $corrections): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
|
||||
|
||||
|
|
@ -182,13 +225,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
['correctionReason.required' => __('Bitte geben Sie den Grund an.')],
|
||||
);
|
||||
|
||||
$image->isWithheld()
|
||||
// Nur der Rückhalt aus dem Korrekturweg (B-07): eine offene
|
||||
// Vorab-Sichtung wird in der Sichtungs-Queue entschieden.
|
||||
$image->isWithheldByCorrection()
|
||||
? $corrections->release($image, auth()->user(), $this->correctionReason)
|
||||
: $corrections->withhold($image, auth()->user(), $this->correctionReason);
|
||||
|
||||
$this->reset(['correctionReason']);
|
||||
|
||||
Flux::toast(text: $image->fresh()->isWithheld() ? __('Bild vorläufig zurückgehalten.') : __('Bild wieder freigegeben.'), variant: 'success');
|
||||
Flux::toast(text: $image->fresh()->isWithheldByCorrection() ? __('Bild vorläufig zurückgehalten.') : __('Bild wieder freigegeben.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
|
|
@ -386,9 +431,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<div class="flex flex-wrap gap-2">
|
||||
<flux:button variant="primary" wire:click="approvePrecheck">{{ __('Bild freigeben') }}</flux:button>
|
||||
<flux:button variant="danger" wire:click="rejectPrecheck" wire:confirm="{{ __('Bild wirklich ablehnen und entfernen?') }}">{{ __('Bild ablehnen') }}</flux:button>
|
||||
<flux:button variant="ghost" size="sm" href="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id, 'purpose' => 'Bild-Sichtung nach Vorab-Check']) }}" target="_blank">
|
||||
{{ __('Beweisoriginal öffnen') }}
|
||||
</flux:button>
|
||||
{{-- Zweck per POST, nie in der URL (Audit A-08). --}}
|
||||
<form method="post" action="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id]) }}" target="_blank">
|
||||
@csrf
|
||||
<input type="hidden" name="purpose" value="Bild-Sichtung nach Vorab-Check">
|
||||
<flux:button type="submit" variant="ghost" size="sm">{{ __('Beweisoriginal öffnen') }}</flux:button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -428,11 +476,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<div class="flex flex-wrap gap-2">
|
||||
<flux:button variant="primary" wire:click="correctImage">{{ __('Korrektur übernehmen') }}</flux:button>
|
||||
<flux:button variant="filled" wire:click="toggleWithheld">
|
||||
{{ $adminTitleImage->isWithheld() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }}
|
||||
</flux:button>
|
||||
<flux:button variant="ghost" size="sm" href="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id, 'purpose' => 'Sichtprüfung im Admin']) }}" target="_blank">
|
||||
{{ __('Beweisoriginal öffnen') }}
|
||||
{{ $adminTitleImage->isWithheldByCorrection() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }}
|
||||
</flux:button>
|
||||
{{-- Zweck per POST, nie in der URL (Audit A-08). --}}
|
||||
<form method="post" action="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id]) }}" target="_blank">
|
||||
@csrf
|
||||
<input type="hidden" name="purpose" value="Sichtprüfung im Admin">
|
||||
<flux:button type="submit" variant="ghost" size="sm">{{ __('Beweisoriginal öffnen') }}</flux:button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -146,7 +146,8 @@ new class extends Component {
|
|||
// Vorab-Check (K.3): Ablehnung zeigt den Grund unter dem Dateifeld,
|
||||
// alles andere bleibt als Hinweis oder wird beim Speichern übernommen.
|
||||
try {
|
||||
$this->precheck = $uploader->precheck($this->newImage, $this->getPressRelease())?->toArray();
|
||||
// Hash aus der Vorprüfung weiterreichen (OPT-4), nicht neu rechnen.
|
||||
$this->precheck = $uploader->precheck($this->newImage, $this->getPressRelease(), $result->fileSha256)?->toArray();
|
||||
} catch (ImageUploadException $exception) {
|
||||
$this->addError('newImage', $exception->getMessage());
|
||||
$this->resetProvenance();
|
||||
|
|
@ -283,6 +284,13 @@ new class extends Component {
|
|||
} catch (ImageUploadException $exception) {
|
||||
$this->addError($exception->field === 'rights_confirmed' ? 'newRightsConfirmed' : 'newImage', $exception->getMessage());
|
||||
|
||||
return;
|
||||
} catch (\RuntimeException $exception) {
|
||||
// Verarbeitungsfehler der Bildbibliothek (z. B. animiertes WebP,
|
||||
// B-12): Hinweis am Dateifeld statt Serverfehler.
|
||||
report($exception);
|
||||
$this->addError('newImage', __('Das Bild konnte nicht verarbeitet werden. Bitte speichern Sie es als JPG oder PNG und laden Sie es erneut hoch.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -340,7 +348,14 @@ new class extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
$remover->remove($image);
|
||||
try {
|
||||
// Status-Regel unter der Sperre erneut prüfen (B-05).
|
||||
$remover->remove($image, auth()->user(), allowedStatuses: $this->allowedStatuses());
|
||||
} catch (ImageUploadException $exception) {
|
||||
Flux::toast(text: $exception->getMessage(), variant: 'danger');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('title-image-changed');
|
||||
|
||||
|
|
@ -475,12 +490,16 @@ new class extends Component {
|
|||
* Status-Regel des handelnden Nutzers: Kunden ändern das Titelbild nur bei
|
||||
* Entwürfen und abgelehnten PMs, Admins bei allem außer Archiv. Der
|
||||
* Uploader prüft dieselbe Liste unter der Sperre erneut (Audit N07).
|
||||
* Gekoppelt an actsAsAdmin() (B-09): Ein Admin, der selbst Eigentümer
|
||||
* ist, lädt als Kunde hoch (sofort bestätigt) und bekommt deshalb auch
|
||||
* die Kundenregel — sonst wäre ein Bild an einer veröffentlichten
|
||||
* Meldung ohne Admin-Weg sofort öffentlich.
|
||||
*
|
||||
* @return list<PressReleaseStatus>
|
||||
*/
|
||||
private function allowedStatuses(): array
|
||||
{
|
||||
if (auth()->user()?->canAccessAdmin()) {
|
||||
if ($this->actsAsAdmin()) {
|
||||
return [PressReleaseStatus::Draft, PressReleaseStatus::Review, PressReleaseStatus::Published, PressReleaseStatus::Rejected];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use App\Exceptions\InsufficientCreditsException;
|
|||
use App\Models\BookingConfirmation;
|
||||
use App\Models\LegalDocument;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Services\Billing\CreditPricingService;
|
||||
|
|
@ -121,6 +122,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
#[Locked]
|
||||
public ?string $imageLegalTextKey = null;
|
||||
|
||||
/**
|
||||
* Bild, das die Seite zur Bestätigung anzeigt (Umsetzungsaudit 29.09.,
|
||||
* B-04): Kennung und Datei-Hash werden beim Rendern gesetzt und beim
|
||||
* Bestätigen gegen das aktuelle Titelbild geprüft.
|
||||
*/
|
||||
#[Locked]
|
||||
public ?int $confirmImageId = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $confirmImageSha = null;
|
||||
|
||||
public function confirmImageRights(CustomerImageConfirmation $confirmation, ImageRightsTextRenderer $texts): void
|
||||
{
|
||||
$pr = $this->getMyPR();
|
||||
|
|
@ -134,6 +146,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
return;
|
||||
}
|
||||
|
||||
// Bestätigt wird nur das Bild, das der Kunde gesehen hat (B-04).
|
||||
if ($this->confirmImageId !== $image->id || $this->confirmImageSha === null || $image->original_sha256 === null || ! hash_equals($this->confirmImageSha, $image->original_sha256)) {
|
||||
$this->imageRightsConfirmed = false;
|
||||
$this->addError('imageRightsConfirmed', __('Das Bild wurde inzwischen geändert. Bitte prüfen Sie das aktuelle Titelbild und bestätigen Sie erneut.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate(
|
||||
['imageRightsConfirmed' => ['accepted']],
|
||||
['imageRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, damit das Bild veröffentlicht werden kann.')],
|
||||
|
|
@ -157,6 +177,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
request()->ip(),
|
||||
request()->userAgent(),
|
||||
route('agb', ['edition' => 'de']),
|
||||
$this->confirmImageSha,
|
||||
);
|
||||
} catch (ImageUploadException $exception) {
|
||||
$this->addError('imageRightsConfirmed', $exception->getMessage());
|
||||
|
|
@ -169,6 +190,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
Flux::toast(text: __('Bildrechte bestätigt. Das Titelbild kann jetzt veröffentlicht werden.'), variant: 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Angezeigtes Titelbild für die Bestätigung merken (B-04).
|
||||
*/
|
||||
private function rememberConfirmationImage(?PressReleaseImage $image): ?PressReleaseImage
|
||||
{
|
||||
$this->confirmImageId = $image?->id;
|
||||
$this->confirmImageSha = $image?->original_sha256;
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
|
|
@ -891,7 +923,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
'categoryName' => $categoryName,
|
||||
'coverUrl' => $cover->coverUrl($pr, 'cover'),
|
||||
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
|
||||
'titleImage' => $titleImage = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(),
|
||||
'titleImage' => $titleImage = $this->rememberConfirmationImage($pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first()),
|
||||
'awaitsImageConfirmation' => $titleImage?->awaitsCustomerConfirmation() === true && auth()->user()->can('confirmImageRights', $pr),
|
||||
'imageLegalShort' => $imageLegalText
|
||||
? app(ImageRightsTextRenderer::class)->renderShort($imageLegalText, route('agb', ['edition' => 'de']))
|
||||
|
|
@ -1671,6 +1703,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
{{ __('Nicht in der Sitemap — für Suchmaschinen ist die Erstfassung auf dem anderen Portal die maßgebliche Adresse (rel=canonical). Leser finden die Meldung weiterhin hier.') }}
|
||||
@elseif ($visibility['in_sitemap_since'])
|
||||
{{ __('Gelistet seit :date (:portals) — Veröffentlichung und Sitemap-Eintrag fallen zusammen.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y'), 'portals' => implode(', ', $visibility['sitemap_portals'] ?? [])]) }}
|
||||
@elseif (($visibility['sitemap_role'] ?? null) === 'source')
|
||||
{{-- Fassung auch auf dem anderen Portal: Die Rolle kann gewechselt haben, ein Datum wäre geraten (N17). --}}
|
||||
{{ __('In der Sitemap gelistet (:portals). Die Meldung liegt auch auf dem anderen Portal; seit wann diese Fassung die maßgebliche ist, lässt sich nicht genau sagen.', ['portals' => implode(', ', $visibility['sitemap_portals'] ?? [])]) }}
|
||||
@else
|
||||
{{ __('Derzeit nicht gelistet — die Meldung ist nicht öffentlich.') }}
|
||||
@endif
|
||||
|
|
@ -1711,7 +1746,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird.') }}</p>
|
||||
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Zugeordnet laut Kennung des Abrufers (User-Agent), die sich nicht überprüfen lässt. Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird. Crawler-Abrufe zählen nicht als Aufrufe.') }}</p>
|
||||
@else
|
||||
<p class="mt-1 mb-0 text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Noch kein Abruf protokolliert.') }}
|
||||
|
|
@ -1851,7 +1886,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
:confirm-label="__('Zur Prüfung freigeben')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining"
|
||||
:has-title-image="! $coverIsPlaceholder" />
|
||||
:has-title-image="! $coverIsPlaceholder"
|
||||
:legal-documents="$this->legalDocumentsForSubmit()" />
|
||||
@endif
|
||||
|
||||
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,9 @@ new class extends Component
|
|||
->with([
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $this->language),
|
||||
// Nur öffentlich auslieferbare Bilder (Audit 29.09., B-01).
|
||||
'images' => fn ($query) => $query
|
||||
->publiclyVisible()
|
||||
->orderByDesc('is_preview')
|
||||
->orderBy('sort_order')
|
||||
->limit(1),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue