<?php
namespace App\Controller;
use App\Entity\Report;
use App\Entity\ReportSnapshot;
use App\Entity\User;
use App\Form\NewReportFormType;
use App\Message\Report as ReportMessage;
use App\Repository\ReportRepository;
use App\Repository\ReportSnapshotRepository;
use App\Service\Badge;
use App\Service\Certification;
use App\Service\Mixpanel;
use App\Service\StatisticsDataAggregation;
use App\Service\WorldMap;
use Doctrine\ORM\EntityManagerInterface;
use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use voku\helper\HtmlMin;
class ReportController extends AbstractController
{
private EntityManagerInterface $entityManager;
private Mixpanel $mixpanel;
private Certification $certification;
public function __construct(
EntityManagerInterface $entityManager,
Mixpanel $mixpanel,
Certification $certification,
) {
$this->entityManager = $entityManager;
$this->mixpanel = $mixpanel;
$this->certification = $certification;
}
/**
* @Route("/reports/new", name="app_reports_new")
*/
public function new(Request $request, EntityManagerInterface $entityManager, MessageBusInterface $bus): Response
{
/** @var User $user */
$user = $this->getUser();
$report = new Report();
$report->setUser($user);
$form = $this->createForm(NewReportFormType::class, $report);
if ($formData = $request->get('new_report_form')) {
$form->submit($formData);
} else {
$form->handleRequest($request);
}
if ($form->isSubmitted() && $form->isValid()) {
$existingReport = $entityManager->getRepository(Report::class)->findOneBy(
[
'user' => $user,
'domain' => $report->getDomain(),
]
);
if ($existingReport) {
$this->addFlash(
'error',
'Sorry, but we can\'t proceed with your request. You have already created a report with these configurations. Please select a new domain.'
);
$this->mixpanel->track($user, 'New Report Duplicate');
return $this->render('report/new.html.twig', [
'form' => $form->createView(),
]);
}
$entityManager->persist($report);
$entityManager->flush();
$this->mixpanel->track($user, 'Report Created');
$this->mixpanel->increment($user, 'Report');
$bus->dispatch(
new ReportMessage(
$report->getId()
)
);
return $this->redirectToRoute(
'app_report_show', [
'id' => $report->getId(),
]
);
}
$this->mixpanel->track($user, 'New Report');
return $this->render('report/new.html.twig', [
'form' => $form->createView(),
]);
}
/**
* @Route("/reports", name="app_reports")
*/
public function index(ReportRepository $repository): Response
{
/** @var User $user */
$user = $this->getUser();
$reports = $repository->findByUser($user);
return $this->render('report/index.html.twig', [
'reports' => $reports,
]);
}
protected function getSnapshotDate(array $snapshots): \DateTime
{
if (empty($snapshots)) {
return new \DateTime();
}
$createdDates = array_map(
function (ReportSnapshot $snapshot) {
return $snapshot->getCreatedAt();
},
$snapshots,
);
rsort($createdDates);
return reset($createdDates);
}
protected function getReferencePeriod(array $snapshots): string
{
if (empty($snapshots)) {
return (new \DateTime())->format('M Y');
}
if (1 === count($snapshots)) {
/** @var ReportSnapshot $snapshot */
$snapshot = reset($snapshots);
return $snapshot->getPeriodStart()->format('M Y');
}
$periods = array_map(
function (ReportSnapshot $snapshot) {
return [
'periodStart' => $snapshot->getPeriodStart(),
'periodEnd' => $snapshot->getPeriodEnd(),
];
},
$snapshots,
);
usort($periods, fn ($a, $b) => $a['periodStart'] <=> $b['periodStart']);
$periodStart = reset($periods)['periodStart'];
$periodEnd = end($periods)['periodEnd'];
return sprintf('%s - %s', $periodStart->format('M Y'), $periodEnd->format('M Y'));
}
protected function getReportData(Report $report, array $snapshots): array
{
$snapshotRepository = $this->entityManager->getRepository(ReportSnapshot::class);
$totalPageViews = $snapshotRepository->getTotalPageViews($snapshots);
$totalEmissions = $snapshotRepository->getTotals($snapshots);
$totalEmissionsByNations = [
ReportSnapshotRepository::SORT_FIELD_TCO2 => $snapshotRepository->getTotalEmissionsByNations($snapshots),
ReportSnapshotRepository::SORT_FIELD_KWH => $snapshotRepository->getTotalEmissionsByNations($snapshots, ReportSnapshotRepository::SORT_FIELD_KWH),
];
$totalEmissionsByPages = [];
if (1 === count($snapshots)) {
$totalEmissionsByPages = reset($snapshots)->getPageStatistics();
}
$groupedTotalEmissions = WorldMap::mergeTotalEmissions(
$totalEmissionsByNations['tCO2'],
$totalEmissionsByNations['kWh'],
);
$certification = $this->certification->getForDomain($report->getDomain());
$carbonIndex = 0;
if (count($snapshots) > 0) {
$carbonIndex = reset($snapshots)->getCarbonIndex();
}
return [
// @ToDo Remove
'isYearly' => 12 === count($snapshots),
'isEmpty' => 0.00 == $totalEmissions['tCO2'] && 0.00 == $totalEmissions['kWh'],
'snapshotDate' => $this->getSnapshotDate($snapshots),
'report' => $report,
'referencePeriod' => $this->getReferencePeriod($snapshots),
'totalPageViews' => $totalPageViews,
'totalEmissions' => $totalEmissions,
'totalEmissionsByNations' => $totalEmissionsByNations,
'totalEmissionsByPages' => $totalEmissionsByPages,
'groupedTotalEmissions' => $groupedTotalEmissions,
'certification' => $certification,
'carbonIndex' => $carbonIndex
];
}
/**
* @Route("/public/reports/{hash}/badge", name="app_report_public_badge_show")
*/
public function downloadBadge(Report $report, Badge $badge): Response
{
$response = new Response();
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, sprintf('%s-net-zero-badge.svg', $report->getId()));
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Content-Type', 'image/svg+xml');
$response->setContent(
$badge->carbonNeutral('dedalo.ai', '1b8654')
);
return $response;
}
/**
* @Route("/public/reports/{hash}", name="app_report_public_show")
*/
public function publicShow(Report $report): Response
{
$snapshot = $report->getLastSnapshot();
if (null === $snapshot) {
throw $this->createNotFoundException();
}
$data = $this->getReportData($report, [$snapshot]);
return $this->render('report/public_show.html.twig', $data);
}
/**
* @Route("/public/reports/{hash}/yearly", name="app_report_public_show_yearly")
*/
public function publicShowYearly(Report $report): Response
{
$snapshots = $this->entityManager->getRepository(ReportSnapshot::class)
->getYearly($report);
if (12 !== count($snapshots)) {
return $this->render('report/empty.html.twig', [
'report' => $report,
]);
}
$data = $this->getReportData($report, $snapshots);
return $this->render('report/public_show.html.twig', $data);
}
/**
* @Route("/reports/{id}/yearly", name="app_report_show_yearly")
*/
public function showYearly(Report $report): Response
{
$user = $this->getUser();
if ($report->getUser() !== $user) {
throw $this->createNotFoundException();
}
$snapshots = $this->entityManager->getRepository(ReportSnapshot::class)
->getYearly($report);
if (12 !== count($snapshots)) {
return $this->render('report/empty.html.twig', [
'report' => $report,
]);
}
$data = $this->getReportData($report, $snapshots);
return $this->render('report/show.html.twig', $data);
}
/**
* @Route("/reports/{id}", name="app_report_show")
*/
public function show(Report $report): Response
{
$user = $this->getUser();
if ($report->getUser() !== $user) {
throw $this->createNotFoundException();
}
/** @var ReportSnapshot|null $snapshot */
$snapshot = $report->getLastSnapshot();
if (null === $snapshot) {
return $this->render(
'report/waiting.html.twig',
[
'report' => $report,
],
new Response('', Response::HTTP_NOT_FOUND)
);
}
if ($snapshot->isEmpty()) {
return $this->render('report/empty.html.twig', [
'report' => $report,
]);
}
$data = $this->getReportData($report, [$snapshot]);
return $this->render('report/show.html.twig', $data);
}
/**
* @Route("/public/reports/{hash}/cdn-example", name="app_report_cdn_example")
*/
public function cdnExample(string $hash, EntityManagerInterface $entityManager): JsonResponse
{
$report = $entityManager->getRepository(Report::class)
->findOneBy(
[
'hash' => $hash,
]
);
if (null === $report) {
return new JsonResponse([], Response::HTTP_NOT_FOUND);
}
$client = new Client(
[
RequestOptions::HEADERS => [
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Encoding' => 'gzip, deflate',
],
],
);
$content = '';
try {
$response = $client->get(
$report->getDomain(),
[
'verify' => false,
'timeout' => 5,
'allow_redirects' => [
'max' => 3,
'strict' => true,
'protocols' => ['https', 'http'],
],
]
);
$content = $response->getBody()->getContents();
} catch (\Exception $exception) {
// nothing to do
}
$htmlMin = new HtmlMin();
$htmlMin->doOptimizeViaHtmlDomParser(); // optimize html via "HtmlDomParser()"
$htmlMin->doRemoveComments(); // remove default HTML comments (depends on "doOptimizeViaHtmlDomParser(true)")
$htmlMin->doSumUpWhitespace(); // sum-up extra whitespace from the Dom (depends on "doOptimizeViaHtmlDomParser(true)")
$htmlMin->doRemoveWhitespaceAroundTags(); // remove whitespace around tags (depends on "doOptimizeViaHtmlDomParser(true)")
$htmlMin->doOptimizeAttributes(); // optimize html attributes (depends on "doOptimizeViaHtmlDomParser(true)")
$htmlMin->doRemoveHttpPrefixFromAttributes(); // remove optional "http:"-prefix from attributes (depends on "doOptimizeAttributes(true)")
$htmlMin->doRemoveHttpsPrefixFromAttributes(); // remove optional "https:"-prefix from attributes (depends on "doOptimizeAttributes(true)")
$htmlMin->doKeepHttpAndHttpsPrefixOnExternalAttributes(); // keep "http:"- and "https:"-prefix for all external links
$htmlMin->doRemoveDefaultAttributes(); // remove defaults (depends on "doOptimizeAttributes(true)" | disabled by default)
$htmlMin->doRemoveDeprecatedAnchorName(); // remove deprecated anchor-jump (depends on "doOptimizeAttributes(true)")
$htmlMin->doRemoveDeprecatedScriptCharsetAttribute(); // remove deprecated charset-attribute - the browser will use the charset from the HTTP-Header, anyway (depends on "doOptimizeAttributes(true)")
$htmlMin->doRemoveDeprecatedTypeFromScriptTag(); // remove deprecated script-mime-types (depends on "doOptimizeAttributes(true)")
$htmlMin->doRemoveDeprecatedTypeFromStylesheetLink(); // remove "type=text/css" for css links (depends on "doOptimizeAttributes(true)")
$htmlMin->doRemoveDeprecatedTypeFromStyleAndLinkTag(); // remove "type=text/css" from all links and styles
$htmlMin->doRemoveDefaultMediaTypeFromStyleAndLinkTag(); // remove "media="all" from all links and styles
$htmlMin->doRemoveDefaultTypeFromButton(); // remove type="submit" from button tags
$htmlMin->doRemoveEmptyAttributes(); // remove some empty attributes (depends on "doOptimizeAttributes(true)")
$htmlMin->doRemoveValueFromEmptyInput(); // remove 'value=""' from empty <input> (depends on "doOptimizeAttributes(true)")
$htmlMin->doSortCssClassNames(); // sort css-class-names, for better gzip results (depends on "doOptimizeAttributes(true)")
$htmlMin->doSortHtmlAttributes(); // sort html-attributes, for better gzip results (depends on "doOptimizeAttributes(true)")
$htmlMin->doRemoveSpacesBetweenTags(); // remove more (aggressive) spaces in the dom (disabled by default)
$htmlMin->doRemoveOmittedQuotes(); // remove quotes e.g. class="lall" => class=lall
$htmlMin->doRemoveOmittedHtmlTags(); // remove ommitted html tags e.g. <p>lall</p> => <p>lall
$contentMinified = $htmlMin->minify($content, true);
$contentBytes = mb_strlen($content);
$contentMinifiedBytes = mb_strlen($contentMinified);
return new JsonResponse([
'content' => $content,
'content_bytes' => $contentBytes,
'content_minified' => $contentMinified,
'content_minified_bytes' => $contentMinifiedBytes,
'percentage_reduction' => round(100 - ($contentMinifiedBytes * 100 / $contentBytes), 2),
]);
}
/**
* @Route("/public/reports/{hash}/totals-by-nations", name="app_report_totals_by_nations")
*/
public function totalsByNations(
Request $request,
string $hash,
EntityManagerInterface $entityManager
): JsonResponse {
$report = $entityManager->getRepository(Report::class)
->findOneBy(
[
'hash' => $hash,
]
);
if (null === $report) {
return new JsonResponse([], Response::HTTP_NOT_FOUND);
}
$snapshots = [];
if (null === $request->get('yearly')) {
$snapshot = $report->getLastSnapshot();
if (null === $snapshot || $snapshot->isEmpty()) {
return new JsonResponse([], Response::HTTP_NOT_FOUND);
}
$snapshots[] = $snapshot;
} else {
$snapshots = $this->entityManager->getRepository(ReportSnapshot::class)
->getYearly($report);
if (12 !== count($snapshots)) {
return new JsonResponse([], Response::HTTP_NOT_FOUND);
}
}
$snapshotRepository = $entityManager->getRepository(ReportSnapshot::class);
$validFields = [
ReportSnapshotRepository::SORT_FIELD_TCO2,
ReportSnapshotRepository::SORT_FIELD_KWH,
];
$sortField = $request->get('field', ReportSnapshotRepository::SORT_FIELD_TCO2);
if (!in_array($sortField, $validFields)) {
$sortField = ReportSnapshotRepository::SORT_FIELD_TCO2;
}
$report = $snapshotRepository->getTotalEmissionsByNations($snapshots, $sortField);
return new JsonResponse(StatisticsDataAggregation::compact($report));
}
/**
* @Route("/reports/{id}/delete", name="app_report_delete")
*/
public function delete(Report $report, Request $request, EntityManagerInterface $entityManager): Response
{
$user = $this->getUser();
if ($report->getUser() !== $user) {
throw $this->createNotFoundException();
}
$this->addFlash(
'success',
sprintf('Thanks! Your report %s is now deleted.', $report->getDisplayName())
);
$entityManager->remove($report);
$entityManager->flush();
$this->mixpanel->track($user, 'Report Deleted');
$this->mixpanel->decrement($user, 'Report');
return $this->redirectToRoute('app_reports');
}
}