*/ private readonly array $allowedHosts; public function __construct( private readonly ContributionAggregator $aggregator, private readonly SvgRenderer $renderer, private readonly CacheInterface $cache, private readonly LoggerInterface $logger, private readonly ProviderHealthChecker $healthChecker, #[Autowire(env: 'ALLOWED_HOSTS')] string $allowedHosts = '', ) { $this->allowedHosts = array_values(array_filter(array_map('trim', explode(',', $allowedHosts)))); } /** * Serves the contribution heatmap SVG, from a 1-hour cache on repeat requests. * * Rejects requests to disallowed hosts (ALLOWED_HOSTS env) with a 403. * * @param Request $request query parameter `theme` selects "dark" (default) or "light" * @return Response image/svg+xml body, cacheable for 3600s */ #[Route('/graph.svg', name: 'contribution_graph', methods: ['GET'])] public function graph(Request $request): Response { if ($this->allowedHosts !== [] && !in_array($request->getHost(), $this->allowedHosts, true)) { $this->logger->warning('GraphController: rejected request from disallowed host', ['host' => $request->getHost()]); return new Response('Forbidden', 403); } $theme = $request->query->get('theme', 'dark'); $cacheKey = 'graph_' . $theme; $cacheMiss = false; $svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme, &$cacheMiss): string { $cacheMiss = true; $item->expiresAfter(3600); set_time_limit(30); // ponytail: providers fetch concurrently now (cost ~= max, not sum); GitLab pagination + per-request HTTP timeout (framework.yaml) still need headroom over the 30s default return $this->renderer->render($this->aggregator->aggregate(), $theme); }); $this->logger->debug('GraphController: cache ' . ($cacheMiss ? 'miss' : 'hit'), ['theme' => $theme]); return new Response($svg, 200, [ 'Content-Type' => 'image/svg+xml', 'Cache-Control' => 'public, max-age=3600', ]); } /** * Redirects "/" to "/graph.svg", forwarding any query string (e.g. ?theme=light). * * @param Request $request incoming request whose query string is preserved * @return RedirectResponse 302 redirect to /graph.svg */ #[Route('/', name: 'index', methods: ['GET'])] public function index(Request $request): RedirectResponse { $query = $request->query->all(); $url = '/graph.svg' . ($query ? '?' . http_build_query($query) : ''); return new RedirectResponse($url, 302); } /** * Reports per-provider connectivity status as JSON. * * @return Response 200 with {"status":"ok",...} or 503 with {"status":"degraded",...} * when any configured provider's probe reports an error */ #[Route('/health', name: 'health', methods: ['GET'])] public function health(): Response { $result = $this->healthChecker->check(); $statusCode = $result['status'] === 'degraded' ? 503 : 200; return new Response( json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), $statusCode, ['Content-Type' => 'application/json'], ); } }