getSchemeAndHttpHost(), '/'); return $this->respond($context, [FeedFetch::FEED_PORTAL], function () use ($context, $base): string { $releases = $this->releases($context)->get($this->columns()); $brand = (array) ($context->domainConfig['brand'] ?? []); return $this->render($context, $base, $releases, [ 'title' => __('web.rss.portal_title', ['brand' => $this->publisher($base)]), 'link' => $base.'/'.$context->language, 'self' => $base.'/'.$context->language.'/feed', 'description' => (string) ($brand['tagline_long'] ?? __('web.rss.default_description', ['brand' => $this->publisher($base)])), ]); }); } public function category(Request $request, string $edition, string $slug): Response { $context = WebPortalContext::fromRequest(); $base = rtrim($request->getSchemeAndHttpHost(), '/'); $language = $context->language; return $this->respond($context, [FeedFetch::FEED_CATEGORY, $slug], function () use ($context, $base, $slug, $language): string { $category = Category::query() ->whereIn('portal', $context->portalValues) ->where('is_active', true) ->whereHas('translations', fn (Builder $query) => $query->where('locale', $language)->where('slug', $slug)) ->with(['translations' => fn ($query) => $query->where('locale', $language)]) ->first(); if (! $category) { abort(404); } $categoryIds = Category::query() ->where('parent_id', $category->id) ->where('is_active', true) ->pluck('id') ->prepend($category->id) ->unique() ->values() ->all(); $releases = $this->releases($context)->whereIn('category_id', $categoryIds)->get($this->columns()); $translation = $category->translations->first(); $brand = (array) ($context->domainConfig['brand'] ?? []); return $this->render($context, $base, $releases, [ 'title' => __('web.rss.category_title', ['category' => (string) $translation?->name, 'brand' => $this->publisher($base)]), 'link' => $base.'/'.$language.'/category/'.$slug, 'self' => $base.'/'.$language.'/category/'.$slug.'/feed', 'description' => filled($translation?->description) ? (string) $translation->description : (string) ($brand['tagline_long'] ?? __('web.rss.default_description', ['brand' => $this->publisher($base)])), ]); }); } /** * Feed eines Newsrooms: dieselbe Sichtbarkeitsregel wie die Newsroom-Seite * (aktive Firma, nicht entfernt, mindestens eine veröffentlichte Meldung). * Die Prüfung läuft bewusst vor dem Cache, damit eine Entfernung sofort * greift; eine in einer Zusammenführung aufgegangene Firma führt per 301 * zum bleibenden Newsroom (Roadmap 1a.3). */ public function newsroom(Request $request, string $edition, string $slug): Response|RedirectResponse { $context = WebPortalContext::fromRequest(); $base = rtrim($request->getSchemeAndHttpHost(), '/'); $language = $context->language; $company = Company::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('portal', $context->portalValues) ->where('slug', $slug) ->where('is_active', true) ->whereNull('gone_at') ->first(['id', 'name', 'slug', 'boilerplate']); if (! $company) { $survivor = $this->mergedNewsrooms->survivorFor($context->portalValues, $slug); if ($survivor !== null && $survivor->is_active && $survivor->slug !== $slug) { return redirect($base.'/'.$language.'/newsroom/'.$survivor->slug.'/feed', 301); } abort(404); } return $this->respond($context, [FeedFetch::FEED_NEWSROOM, $slug], function () use ($context, $base, $company, $language): string { $releases = $this->releases($context)->where('company_id', $company->id)->get($this->columns()); if ($releases->isEmpty()) { abort(404); } $boilerplate = trim((string) Str::of(strip_tags((string) $company->boilerplate))->squish()); return $this->render($context, $base, $releases, [ 'title' => __('web.rss.newsroom_title', ['company' => (string) $company->name, 'brand' => $this->publisher($base)]), 'link' => $base.'/'.$language.'/newsroom/'.$company->slug, 'self' => $base.'/'.$language.'/newsroom/'.$company->slug.'/feed', 'description' => $boilerplate !== '' ? Str::limit($boilerplate, 300) : __('web.rss.newsroom_description', ['company' => (string) $company->name, 'brand' => $this->publisher($base)]), ]); }); } /** * Antwort aus dem Cache (oder frisch gebaut) plus Abruf-Protokoll: jeder * Feed-Abruf wird nach der Antwort ohne IP festgehalten (feed_fetches), * auch bei warmem Cache — der Abruf ist die Messgröße, nicht der Aufbau. * * @param array{0: string, 1?: string} $keySegments Feed-Art und Schlüssel (Slug) * @param Closure(): string $build */ private function respond(WebPortalContext $context, array $keySegments, Closure $build): Response { $xml = WebPageCache::remember( WebPageCache::key('feed', [$context->portal->value, $context->language, ...$keySegments]), $build, ); [$feed, $portal, $language, $userAgent] = [$keySegments[0], $context->portal->value, $context->language, request()->userAgent()]; $feedKey = $keySegments[1] ?? null; defer(static fn () => FeedFetch::recordDeferred($feed, $feedKey, $portal, $language, $userAgent)); return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']); } /** * @return Builder */ private function releases(WebPortalContext $context): Builder { return PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->forPortalValues($context->portalValues) ->forLanguage($context->language) ->visibleInArchive() ->with([ 'company:id,name', 'category.translations' => fn ($query) => $query->where('locale', $context->language), ]) ->orderByDesc('published_at') ->limit(self::MAX_ITEMS); } /** * @return list */ private function columns(): array { return ['id', 'slug', 'title', 'text', 'portal', 'language', 'category_id', 'company_id', 'published_at']; } private function publisher(string $base): string { return (string) StructuredData::publisher($base)['name']; } /** * @param Collection $releases * @param array{title: string, link: string, self: string, description: string} $channel */ private function render(WebPortalContext $context, string $base, Collection $releases, array $channel): string { $language = $context->language; $items = $releases->map(fn (PressRelease $release): array => [ 'title' => (string) $release->title, 'link' => $base.'/'.$language.'/press-release/'.$release->slug, 'published_at' => $release->published_at?->toRfc2822String() ?? '', // Der gespeicherte Text ist HTML: Entities erst auflösen, sonst // escaped Blade sie ein zweites Mal (&) — R-11. 'description' => html_entity_decode($release->teaserText(self::TEASER_LENGTH), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'category' => $release->localizedCategoryName($language), 'creator' => $release->company?->name, ])->all(); $channel += [ 'language' => $language, 'last_build' => ($releases->first()->published_at ?? Carbon::now())->toRfc2822String(), ]; return view('web.feed', ['channel' => $channel, 'items' => $items])->render(); } }