Last commit july 5th

This commit is contained in:
2024-07-05 13:46:23 +02:00
parent dad0d86e8c
commit b0e4dfbb76
24982 changed files with 2621219 additions and 413 deletions

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Api;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Comment;
use Doctrine\ORM\QueryBuilder;
class FilterPublishedCommentQueryExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void
{
if (Comment::class === $resourceClass) {
$queryBuilder->andWhere(sprintf("%s.state = 'published'", $queryBuilder->getRootAliases()[0]));
}
}
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void
{
if (Comment::class === $resourceClass) {
$queryBuilder->andWhere(sprintf("%s.state = 'published'", $queryBuilder->getRootAliases()[0]));
}
}
}

0
src/ApiResource/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Command;
use App\Repository\CommentRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('app:comment:cleanup', 'Deletes rejected and spam comments from the database')]
class CommentCleanupCommand extends Command
{
public function __construct(
private CommentRepository $commentRepository,
) {
parent::__construct();
}
protected function configure()
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry run')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if ($input->getOption('dry-run')) {
$io->note('Dry mode enabled');
$count = $this->commentRepository->countOldRejected();
} else {
$count = $this->commentRepository->deleteOldRejected();
}
$io->success(sprintf('Deleted "%d" old rejected/spam comments.', $count));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
use Symfony\Contracts\Cache\CacheInterface;
#[AsCommand('app:step:info')]
class StepInfoCommand extends Command
{
public function __construct(
private CacheInterface $cache,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$step = $this->cache->get('app.current_step', function ($item) {
$process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
$process->mustRun();
$item->expiresAfter(30);
return $process->getOutput();
});
$output->writeln($step);
return Command::SUCCESS;
}
}

View File

@@ -52,6 +52,7 @@ class CommentCrudController extends AbstractCrudController
->setLabel('Photo')
->onlyOnIndex()
;
yield TextField::new('state');
$createdAt = DateTimeField::new('createdAt')->setFormTypeOptions([
'years' => range(date('Y'), date('Y') + 5),

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Controller;
use App\Entity\Comment;
use App\Message\CommentMessage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Workflow\WorkflowInterface;
use Twig\Environment;
#[Route('/admin')]
class AdminController extends AbstractController
{
public function __construct(
private Environment $twig,
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus,
) {
}
#[Route('/comment/review/{id}', name: 'review_comment')]
public function reviewComment(Request $request, Comment $comment, WorkflowInterface $commentStateMachine): Response
{
$accepted = !$request->query->get('reject');
if ($commentStateMachine->can($comment, 'publish')) {
$transition = $accepted ? 'publish' : 'reject';
} elseif ($commentStateMachine->can($comment, 'publish_ham')) {
$transition = $accepted ? 'publish_ham' : 'reject_ham';
} else {
return new Response('Comment already reviewed or not in the right state.');
}
$commentStateMachine->apply($comment, $transition);
$this->entityManager->flush();
if ($accepted) {
$reviewUrl = $this->generateUrl('review_comment', ['id' => $comment->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
$this->bus->dispatch(new CommentMessage($comment->getId(), $reviewUrl));
}
return new Response($this->twig->render('admin/review.html.twig', [
'transition' => $transition,
'comment' => $comment,
]));
}
#[Route('/http-cache/{uri<.*>}', methods: ['PURGE'])]
public function purgeHttpCache(KernelInterface $kernel, Request $request, string $uri, StoreInterface $store): Response
{
if ('prod' === $kernel->getEnvironment()) {
return new Response('KO', 400);
}
$store->purge($request->getSchemeAndHttpHost().'/'.$uri);
return new Response('Done');
}
}

View File

@@ -6,40 +6,63 @@ namespace App\Controller;
use App\Entity\Comment;
use App\Entity\Conference;
use App\Form\CommentType;
use App\Message\CommentMessage;
use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
use App\SpamChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class ConferenceController extends AbstractController
{
public function __construct(
private EntityManagerInterface $entityManager,
) {
private MessageBusInterface $bus,
)
{
}
#[Route('/', name: 'homepage')]
#[Route('/')]
public function indexNoLocale(): Response
{
return $this->redirectToRoute('homepage', ['_locale' => 'en']);
}
#[Route('/{_locale<%app.supported_locales%>}/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
return $this->render('conference/index.html.twig', [
'conferences' => $conferenceRepository->findAll(),
])->setSharedMaxAge(3600);
}
#[Route('/{_locale<%app.supported_locales%>}/conference_header', name: 'conference_header')]
public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
{
return $this->render('conference/header.html.twig', [
'conferences' => $conferenceRepository->findAll(),
]);
}
#[Route('/conference/{slug}', name: 'conference')]
#[Route('/{_locale<%app.supported_locales%>}/conference/{slug}', name: 'conference')]
public function show(
Request $request,
Conference $conference,
CommentRepository $commentRepository,
SpamChecker $spamChecker,
ConferenceRepository $conferenceRepository,
Request $request,
Conference $conference,
CommentRepository $commentRepository,
NotifierInterface $notifier,
ConferenceRepository $conferenceRepository,
#[Autowire('%photo_dir%')] string $photoDir,
): Response {
): Response
{
$comment = new Comment();
$form = $this->createForm(CommentType::class, $comment);
@@ -47,25 +70,31 @@ class ConferenceController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$comment->setConference($conference);
if ($photo = $form['photo']->getData()) {
$filename = bin2hex(random_bytes(6)).'.'.$photo->guessExtension();
$filename = bin2hex(random_bytes(6)) . '.' . $photo->guessExtension();
$photo->move($photoDir, $filename);
$comment->setPhotoFilename($filename);
}
$this->entityManager->persist($comment);
$this->entityManager->flush();
$context = [
'user_ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('user-agent'),
'referrer' => $request->headers->get('referer'),
'permalink' => $request->getUri(),
];
if (2 === $spamChecker->getSpamScore($comment, $context)) {
throw new \RuntimeException('Blatant spam, go away!');
}
$this->entityManager->flush();
$reviewUrl = $this->generateUrl('review_comment', ['id' => $comment->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
$this->bus->dispatch(new CommentMessage($comment->getId(), $reviewUrl, $context));
$notifier->send(new Notification('Thank you for the feedback; your comment will be posted after moderation.', ['browser']));
return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);
}
if ($form->isSubmitted()) {
$notifier->send(new Notification('Can you check your submission? There are some problems with it.', ['browser']));
}
$offset = max(0, $request->query->getInt('offset', 0));
$paginator = $commentRepository->getCommentPaginator($conference, $offset);

View File

@@ -0,0 +1,17 @@
<?php
namespace App\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
// $product = new Product();
// $manager->persist($product);
$manager->flush();
}
}

View File

@@ -39,6 +39,9 @@ class Comment
#[ORM\Column(length: 255, nullable: true)]
private ?string $photoFilename = null;
#[ORM\Column(length: 255, options: ['default' => 'submitted'])]
private ?string $state = 'submitted';
public function getId(): ?int
{
return $this->id;
@@ -120,4 +123,16 @@ class Comment
return $this;
}
public function getState(): ?string
{
return $this->state;
}
public function setState(string $state): static
{
$this->state = $state;
return $this;
}
}

View File

@@ -2,29 +2,45 @@
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Repository\ConferenceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\String\Slugger\SluggerInterface;
#[ORM\Entity(repositoryClass: ConferenceRepository::class)]
#[UniqueEntity('slug')]
#[ApiResource(
operations: [
new Get(normalizationContext: ['groups' => 'conference:item']),
new GetCollection(normalizationContext: ['groups' => 'conference:list'])
],
order: ['year' => 'DESC', 'city' => 'ASC'],
paginationEnabled: false,
)]
class Conference
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['conference:list', 'conference:item'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['conference:list', 'conference:item'])]
private ?string $city = null;
#[ORM\Column(length: 4)]
#[Groups(['conference:list', 'conference:item'])]
private ?string $year = null;
#[ORM\Column]
#[Groups(['conference:list', 'conference:item'])]
private ?bool $isInternational = null;
/**
@@ -34,15 +50,17 @@ class Conference
private Collection $comments;
#[ORM\Column(length: 255, unique: true)]
#[Groups(['conference:list', 'conference:item'])]
private ?string $slug = null;
public function __construct()
{
$this->comments = new ArrayCollection();
}
public function __toString(): string
{
return $this->city.' '.$this->year;
return $this->city . ' ' . $this->year;
}
public function getId(): ?int
@@ -53,7 +71,7 @@ class Conference
public function computeSlug(SluggerInterface $slugger)
{
if (!$this->slug || '-' === $this->slug) {
$this->slug = (string) $slugger->slug((string) $this)->lower();
$this->slug = (string)$slugger->slug((string)$this)->lower();
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace App\EventSubscriber;
use App\Repository\ConferenceRepository;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Twig\Environment;
class TwigEventSubscriber implements EventSubscriberInterface
{
private $twig;
private $conferenceRepository;
public function __construct(Environment $twig, ConferenceRepository $conferenceRepository)
{
$this->twig = $twig;
$this->conferenceRepository = $conferenceRepository;
}
public function onControllerEvent(ControllerEvent $event): void
{
$this->twig->addGlobal('conferences', $this->conferenceRepository->findAll());
}
public static function getSubscribedEvents(): array
{
return [
ControllerEvent::class => 'onControllerEvent',
];
}
}

35
src/ImageOptimizer.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App;
use Imagine\Gd\Imagine;
use Imagine\Image\Box;
class ImageOptimizer
{
private const MAX_WIDTH = 200;
private const MAX_HEIGHT = 150;
private $imagine;
public function __construct()
{
$this->imagine = new Imagine();
}
public function resize(string $filename): void
{
list($iwidth, $iheight) = getimagesize($filename);
$ratio = $iwidth / $iheight;
$width = self::MAX_WIDTH;
$height = self::MAX_HEIGHT;
if ($width / $height > $ratio) {
$width = $height * $ratio;
} else {
$height = $width / $ratio;
}
$photo = $this->imagine->open($filename);
$photo->resize(new Box($width, $height))->save($filename);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Message;
class CommentMessage
{
public function __construct(
private int $id,
private string $reviewUrl,
private array $context = [],
) {
}
public function getReviewUrl(): string
{
return $this->reviewUrl;
}
public function getId(): int
{
return $this->id;
}
public function getContext(): array
{
return $this->context;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\MessageHandler;
use App\ImageOptimizer;
use App\Message\CommentMessage;
use App\Notification\CommentReviewNotification;
use App\Repository\CommentRepository;
use App\SpamChecker;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Workflow\WorkflowInterface;
#[AsMessageHandler]
class CommentMessageHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
private SpamChecker $spamChecker,
private CommentRepository $commentRepository,
private MessageBusInterface $bus,
private WorkflowInterface $commentStateMachine,
private NotifierInterface $notifier,
private ImageOptimizer $imageOptimizer,
#[Autowire('%photo_dir%')] private string $photoDir,
private ?LoggerInterface $logger = null,
)
{
}
public function __invoke(CommentMessage $message)
{
$comment = $this->commentRepository->find($message->getId());
if (!$comment) {
return;
}
if ($this->commentStateMachine->can($comment, 'accept')) {
$score = $this->spamChecker->getSpamScore($comment, $message->getContext());
$transition = match ($score) {
2 => 'reject_spam',
1 => 'might_be_spam',
default => 'accept',
};
$this->commentStateMachine->apply($comment, $transition);
$this->entityManager->flush();
$this->bus->dispatch($message);
} elseif ($this->commentStateMachine->can($comment, 'publish') || $this->commentStateMachine->can($comment, 'publish_ham')) {
$notification = new CommentReviewNotification($comment, $message->getReviewUrl());
$this->notifier->send($notification, ...$this->notifier->getAdminRecipients());
} elseif ($this->commentStateMachine->can($comment, 'optimize')) {
if ($comment->getPhotoFilename()) {
$this->imageOptimizer->resize($this->photoDir . '/' . $comment->getPhotoFilename());
}
$this->commentStateMachine->apply($comment, 'optimize');
$this->entityManager->flush();
} elseif ($this->logger) {
$this->logger->debug('Dropping comment message', ['comment' => $comment->getId(), 'state' => $comment->getState()]);
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Notification;
use App\Entity\Comment;
use Symfony\Component\Notifier\Bridge\Slack\Block\SlackDividerBlock;
use Symfony\Component\Notifier\Bridge\Slack\Block\SlackSectionBlock;
use Symfony\Component\Notifier\Bridge\Slack\SlackOptions;
use Symfony\Component\Notifier\Message\ChatMessage;
use Symfony\Component\Notifier\Message\EmailMessage;
use Symfony\Component\Notifier\Notification\ChatNotificationInterface;
use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
class CommentReviewNotification extends Notification implements EmailNotificationInterface, ChatNotificationInterface
{
public function __construct(
private Comment $comment,
private string $reviewUrl,
)
{
parent::__construct('New comment posted');
}
public function asChatMessage(RecipientInterface $recipient, string $transport = null): ?ChatMessage
{
if ('slack' !== $transport) {
return null;
}
$message = ChatMessage::fromNotification($this, $recipient, $transport);
$message->subject($this->getSubject());
$message->options((new SlackOptions())
->iconEmoji('tada')
->iconUrl('https://guestbook.example.com')
->username('Guestbook')
->block((new SlackSectionBlock())->text($this->getSubject()))
->block(new SlackDividerBlock())
->block((new SlackSectionBlock())
->text(sprintf('%s (%s) says: %s', $this->comment->getAuthor(), $this->comment->getEmail(), $this->comment->getText()))
)
->block((new SlackActionsBlock())
->button('Accept', $this->reviewUrl, 'primary')
->button('Reject', $this->reviewUrl . '?reject=1', 'danger')
)
);
return $message;
}
public function asEmailMessage(EmailRecipientInterface $recipient, string $transport = null): ?EmailMessage
{
$message = EmailMessage::fromNotification($this, $recipient, $transport);
$message->getMessage()
->htmlTemplate('emails/comment_notification.html.twig')
->context(['comment' => $this->comment]);
return $message;
}
public function getChannels(RecipientInterface $recipient): array
{
if (preg_match('{\b(great|awesome)\b}i', $this->comment->getText())) {
return ['email', 'chat/slack'];
}
$this->importance(Notification::IMPORTANCE_LOW);
return ['email'];
}
}

View File

@@ -5,7 +5,9 @@ namespace App\Repository;
use App\Entity\Comment;
use App\Entity\Conference;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator;
/**
@@ -13,17 +15,41 @@ use Doctrine\ORM\Tools\Pagination\Paginator;
*/
class CommentRepository extends ServiceEntityRepository
{
private const DAYS_BEFORE_REJECTED_REMOVAL = 7;
public const COMMENTS_PER_PAGE = 2;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Comment::class);
}
public function countOldRejected(): int
{
return $this->getOldRejectedQueryBuilder()->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
}
public function deleteOldRejected(): int
{
return $this->getOldRejectedQueryBuilder()->delete()->getQuery()->execute();
}
private function getOldRejectedQueryBuilder(): QueryBuilder
{
return $this->createQueryBuilder('c')
->andWhere('c.state = :state_rejected or c.state = :state_spam')
->andWhere('c.createdAt < :date')
->setParameter('state_rejected', 'rejected')
->setParameter('state_spam', 'spam')
->setParameter('date', new \DateTimeImmutable(-self::DAYS_BEFORE_REJECTED_REMOVAL.' days'))
;
}
public function getCommentPaginator(Conference $conference, int $offset): Paginator
{
$query = $this->createQueryBuilder('c')
->andWhere('c.conference = :conference')
->andWhere('c.state = :state')
->setParameter('conference', $conference)
->setParameter('state', 'published')
->orderBy('c.createdAt', 'DESC')
->setMaxResults(self::COMMENTS_PER_PAGE)
->setFirstResult($offset)