diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 2c44ebdb0..c849d3343 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -54,3 +54,4 @@ @import 'themes/default'; @import 'themes/solarized'; @import 'themes/tokyo-night'; +@import 'components/tag'; diff --git a/assets/styles/components/_tag.scss b/assets/styles/components/_tag.scss new file mode 100644 index 000000000..fd97a57ec --- /dev/null +++ b/assets/styles/components/_tag.scss @@ -0,0 +1,21 @@ +.section.tag { + header { + text-align: center; + + h4 { + font-size: 1.2rem; + margin-bottom: 0; + margin-top: .5rem; + } + } + + .tag__actions { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + margin-top: 1rem; + margin-bottom: 2.5rem; + gap: .25rem; + } +} \ No newline at end of file diff --git a/config/kbin_routes/tag.yaml b/config/kbin_routes/tag.yaml index 577341d38..2165a47aa 100644 --- a/config/kbin_routes/tag.yaml +++ b/config/kbin_routes/tag.yaml @@ -30,3 +30,13 @@ tag_people: path: tag/{name}/people methods: [GET] requirements: { sortBy: "%front_sort_options%" } + +tag_ban: + path: /tag/{name}/ban + methods: [POST] + controller: App\Controller\Tag\TagBanController::ban + +tag_unban: + path: /tag/{name}/unban + methods: [POST] + controller: App\Controller\Tag\TagBanController::unban \ No newline at end of file diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index ddbd2c6a7..31320c0ad 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -1,8 +1,11 @@ doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' + types: + citext: App\DoctrineExtensions\DBAL\Types\Citext mapping_types: user_type: string + citext: citext # IMPORTANT: You MUST configure your server version, # either here or in the DATABASE_URL env var (see .env file) diff --git a/migrations/Version20240330101300.php b/migrations/Version20240330101300.php new file mode 100644 index 000000000..e38d46c10 --- /dev/null +++ b/migrations/Version20240330101300.php @@ -0,0 +1,217 @@ +addSql('CREATE EXTENSION IF NOT EXISTS citext'); + $this->addSql('CREATE SEQUENCE hashtag_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE hashtag_link_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE hashtag (id INT NOT NULL, tag citext NOT NULL, banned BOOLEAN DEFAULT false NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_5AB52A61389B783 ON hashtag (tag)'); + $this->addSql('CREATE TABLE hashtag_link (id INT NOT NULL, hashtag_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_83957168FB34EF56 ON hashtag_link (hashtag_id)'); + $this->addSql('CREATE INDEX IDX_83957168BA364942 ON hashtag_link (entry_id)'); + $this->addSql('CREATE INDEX IDX_8395716860C33421 ON hashtag_link (entry_comment_id)'); + $this->addSql('CREATE INDEX IDX_839571684B89032C ON hashtag_link (post_id)'); + $this->addSql('CREATE INDEX IDX_83957168DB1174D2 ON hashtag_link (post_comment_id)'); + $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_83957168FB34EF56 FOREIGN KEY (hashtag_id) REFERENCES hashtag (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_83957168BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_8395716860C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_839571684B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_83957168DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // migrate entry tags + $select = "SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry e + JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE + WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array' + UNION ALL + SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry e + JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE + WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object' + ORDER BY created_at DESC"; + $foreachStatement = "IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN + INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag); + END IF; + IF NOT EXISTS (SELECT l.id FROM hashtag_link l + INNER JOIN hashtag def ON def.id=l.hashtag_id + WHERE l.entry_id = temprow.id AND def.tag = temprow.hashtag) + THEN + INSERT INTO hashtag_link (id, entry_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag = temprow.hashtag)); + END IF;"; + + $this->addSql('DO + $do$ + declare temprow record; + BEGIN + FOR temprow IN + '.$select.' + LOOP + '.$foreachStatement.' + END LOOP; + END + $do$;'); + + // migrate entry comments tags + $select = "SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry_comment e + JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE + WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array' + UNION ALL + SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry_comment e + JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE + WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object' + ORDER BY created_at DESC"; + $foreachStatement = "IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN + INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag); + END IF; + IF NOT EXISTS (SELECT l.id FROM hashtag_link l + INNER JOIN hashtag def ON def.id=l.hashtag_id + WHERE l.entry_comment_id = temprow.id AND def.tag = temprow.hashtag) + THEN + INSERT INTO hashtag_link (id, entry_comment_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag=temprow.hashtag)); + END IF;"; + + $this->addSql('DO + $do$ + declare temprow record; + BEGIN + FOR temprow IN + '.$select.' + LOOP + '.$foreachStatement.' + END LOOP; + END + $do$;'); + + // migrate post tags + $select = "SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post e + JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE + WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array' + UNION ALL + SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post e + JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE + WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object' + ORDER BY created_at DESC"; + $foreachStatement = "IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN + INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag); + END IF; + IF NOT EXISTS (SELECT l.id FROM hashtag_link l + INNER JOIN hashtag def ON def.id=l.hashtag_id + WHERE l.post_id = temprow.id AND def.tag = temprow.hashtag) + THEN + INSERT INTO hashtag_link (id, post_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag=temprow.hashtag)); + END IF;"; + + $this->addSql('DO + $do$ + declare temprow record; + BEGIN + FOR temprow IN + '.$select.' + LOOP + '.$foreachStatement.' + END LOOP; + END + $do$;'); + // migrate post comment tags + $select = "SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post_comment e + JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE + WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array' + UNION ALL + SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post_comment e + JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE + WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object' + ORDER BY created_at DESC"; + $foreachStatement = "IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN + INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag); + END IF; + IF NOT EXISTS (SELECT l.id FROM hashtag_link l + INNER JOIN hashtag def ON def.id=l.hashtag_id + WHERE l.post_comment_id = temprow.id AND def.tag = temprow.hashtag) + THEN + INSERT INTO hashtag_link (id, post_comment_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag=temprow.hashtag)); + END IF;"; + + $this->addSql('DO + $do$ + declare temprow record; + BEGIN + FOR temprow IN + '.$select.' + LOOP + '.$foreachStatement.' + END LOOP; + END + $do$;'); + + $this->addSql('ALTER TABLE entry DROP COLUMN tags'); + $this->addSql('ALTER TABLE entry_comment DROP COLUMN tags'); + $this->addSql('ALTER TABLE post DROP COLUMN tags'); + $this->addSql('ALTER TABLE post_comment DROP COLUMN tags'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE entry_comment ADD tags JSONB DEFAULT NULL'); + $this->addSql('ALTER TABLE post_comment ADD tags JSONB DEFAULT NULL'); + $this->addSql('ALTER TABLE post ADD tags JSONB DEFAULT NULL'); + $this->addSql('ALTER TABLE entry ADD tags JSONB DEFAULT NULL'); + + $this->addSql('DO +$do$ + declare temprow record; +BEGIN + FOR temprow IN + SELECT hl.entry_id, hl.entry_comment_id, hl.post_id, hl.post_comment_id, h.tag FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id + LOOP + IF temprow.entry_id IS NOT NULL THEN + IF NOT EXISTS (SELECT id FROM entry e WHERE e.id = temprow.entry_id AND e.tags IS NOT NULL) THEN + UPDATE entry SET tags = \'[]\'::jsonb WHERE entry.id = temprow.entry_id; + END IF; + UPDATE entry SET tags = tags || to_jsonb(temprow.tag) WHERE entry.id = temprow.entry_id; + END IF; + IF temprow.entry_comment_id IS NOT NULL THEN + IF NOT EXISTS (SELECT id FROM entry_comment ec WHERE ec.id = temprow.entry_comment_id AND ec.tags IS NOT NULL) THEN + UPDATE entry_comment SET tags = \'[]\'::jsonb WHERE entry_comment.id = temprow.entry_comment_id; + END IF; + UPDATE entry_comment SET tags = tags || to_jsonb(temprow.tag) WHERE entry_comment.id = temprow.entry_comment_id; + END IF; + IF temprow.post_id IS NOT NULL THEN + IF NOT EXISTS (SELECT id FROM post p WHERE p.id = temprow.post_id AND p.tags IS NOT NULL) THEN + UPDATE post SET tags = \'[]\'::jsonb WHERE post.id = temprow.post_id; + END IF; + UPDATE post SET tags = tags || to_jsonb(temprow.tag) WHERE post.id = temprow.post_id; + END IF; + IF temprow.post_comment_id IS NOT NULL THEN + IF NOT EXISTS (SELECT id FROM post_comment pc WHERE pc.id = temprow.post_comment_id AND pc.tags IS NOT NULL) THEN + UPDATE post_comment SET tags = \'[]\'::jsonb WHERE post_comment.id = temprow.post_comment_id; + END IF; + UPDATE post_comment SET tags = tags || to_jsonb(temprow.tag) WHERE post_comment.id = temprow.post_comment_id; + END IF; + END LOOP; +END +$do$;'); + + $this->addSql('DROP SEQUENCE hashtag_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE hashtag_link_id_seq CASCADE'); + $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_83957168FB34EF56'); + $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_83957168BA364942'); + $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_8395716860C33421'); + $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_839571684B89032C'); + $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_83957168DB1174D2'); + $this->addSql('DROP TABLE hashtag'); + $this->addSql('DROP TABLE hashtag_link'); + } +} diff --git a/src/Command/MoveEntriesByTagCommand.php b/src/Command/MoveEntriesByTagCommand.php index 9e92bc0d7..0702e3bb2 100644 --- a/src/Command/MoveEntriesByTagCommand.php +++ b/src/Command/MoveEntriesByTagCommand.php @@ -54,11 +54,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $qb = $this->entryRepository->createQueryBuilder('e'); - - $qb->andWhere("JSONB_CONTAINS(e.tags, '\"".$tag."\"') = true"); - - $entries = $qb->getQuery()->getResult(); + $entries = $this->entryRepository->createQueryBuilder('e') + ->where('t.tag = :tag') + ->join('e.hashtags', 'h') + ->join('h.hashtag', 't') + ->setParameter('tag', $tag) + ->getQuery() + ->getResult(); foreach ($entries as $entry) { /* diff --git a/src/Command/MovePostsByTagCommand.php b/src/Command/MovePostsByTagCommand.php index 17a6a92e5..38a32cd95 100644 --- a/src/Command/MovePostsByTagCommand.php +++ b/src/Command/MovePostsByTagCommand.php @@ -54,7 +54,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $qb = $this->postRepository->createQueryBuilder('p'); - $qb->andWhere("JSONB_CONTAINS(p.tags, '\"".$tag."\"') = true"); + $qb->andWhere('t.tag = :tag') + ->join('p.hashtags', 'h') + ->join('h.hashtag', 't') + ->setParameter('tag', $tag); $posts = $qb->getQuery()->getResult(); diff --git a/src/Command/PostMagazinesUpdateCommand.php b/src/Command/PostMagazinesUpdateCommand.php index d11695840..c1b101961 100644 --- a/src/Command/PostMagazinesUpdateCommand.php +++ b/src/Command/PostMagazinesUpdateCommand.php @@ -7,6 +7,7 @@ use App\Entity\Post; use App\Repository\MagazineRepository; use App\Repository\PostRepository; +use App\Repository\TagLinkRepository; use App\Service\PostManager; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -22,6 +23,7 @@ class PostMagazinesUpdateCommand extends Command public function __construct( private readonly PostRepository $postRepository, private readonly PostManager $postManager, + private readonly TagLinkRepository $tagLinkRepository, private readonly MagazineRepository $magazineRepository ) { parent::__construct(); @@ -39,12 +41,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function handleMagazine(Post $post, OutputInterface $output): void { - if (!$post->tags) { - return; - } + $tags = $this->tagLinkRepository->getTagsOfPost($post); $output->writeln((string) $post->getId()); - foreach ($post->tags as $tag) { + foreach ($tags as $tag) { if ($magazine = $this->magazineRepository->findOneByName($tag)) { $output->writeln($magazine->name); $this->postManager->changeMagazine($post, $magazine); diff --git a/src/Command/Update/TagsToJsonbUpdateCommand.php b/src/Command/Update/TagsToJsonbUpdateCommand.php deleted file mode 100644 index dabe5cff6..000000000 --- a/src/Command/Update/TagsToJsonbUpdateCommand.php +++ /dev/null @@ -1,52 +0,0 @@ -update($this->entityManager->getRepository(Entry::class)); - $this->update($this->entityManager->getRepository(EntryComment::class)); - $this->update($this->entityManager->getRepository(Post::class)); - $this->update($this->entityManager->getRepository(PostComment::class)); - - return Command::SUCCESS; - } - - private function update(TagRepositoryInterface $repository) - { - /** @var TagInterface $entry */ - foreach ($repository->findWithTags() as $entry) { - $entry->tagsTmp = $entry->getTags(); - $this->entityManager->persist($entry); - echo $entry->getId().PHP_EOL; - } - - $this->entityManager->flush(); - } -} diff --git a/src/Command/Update/TagsUpdateCommand.php b/src/Command/Update/TagsUpdateCommand.php index 20ac0ea0f..7cd4e5432 100644 --- a/src/Command/Update/TagsUpdateCommand.php +++ b/src/Command/Update/TagsUpdateCommand.php @@ -7,7 +7,7 @@ use App\Entity\EntryComment; use App\Entity\Post; use App\Entity\PostComment; -use App\Service\TagManager; +use App\Service\TagExtractor; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -21,7 +21,7 @@ class TagsUpdateCommand extends Command { public function __construct( - private readonly TagManager $tagManager, + private readonly TagExtractor $tagExtractor, private readonly EntityManagerInterface $entityManager ) { parent::__construct(); @@ -31,19 +31,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $comments = $this->entityManager->getRepository(EntryComment::class)->findAll(); foreach ($comments as $comment) { - $comment->tags = $this->tagManager->extract($comment->body, $comment->magazine->name); + $comment->tags = $this->tagExtractor->extract($comment->body, $comment->magazine->name); $this->entityManager->persist($comment); } $posts = $this->entityManager->getRepository(Post::class)->findAll(); foreach ($posts as $post) { - $post->tags = $this->tagManager->extract($post->body, $post->magazine->name); + $post->tags = $this->tagExtractor->extract($post->body, $post->magazine->name); $this->entityManager->persist($post); } $comments = $this->entityManager->getRepository(PostComment::class)->findAll(); foreach ($comments as $comment) { - $comment->tags = $this->tagManager->extract($comment->body, $comment->magazine->name); + $comment->tags = $this->tagExtractor->extract($comment->body, $comment->magazine->name); $this->entityManager->persist($comment); } diff --git a/src/Controller/ActivityPub/EntryCommentController.php b/src/Controller/ActivityPub/EntryCommentController.php index d25caea60..506988d2d 100644 --- a/src/Controller/ActivityPub/EntryCommentController.php +++ b/src/Controller/ActivityPub/EntryCommentController.php @@ -10,6 +10,7 @@ use App\Entity\EntryComment; use App\Entity\Magazine; use App\Factory\ActivityPub\EntryCommentNoteFactory; +use App\Repository\TagLinkRepository; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -19,8 +20,10 @@ class EntryCommentController extends AbstractController { use PrivateContentTrait; - public function __construct(private readonly EntryCommentNoteFactory $commentNoteFactory) - { + public function __construct( + private readonly EntryCommentNoteFactory $commentNoteFactory, + private readonly TagLinkRepository $tagLinkRepository, + ) { } public function __invoke( @@ -38,7 +41,7 @@ public function __invoke( $this->handlePrivateContent($comment); - $response = new JsonResponse($this->commentNoteFactory->create($comment, true)); + $response = new JsonResponse($this->commentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfEntryComment($comment), true)); $response->headers->set('Content-Type', 'application/activity+json'); diff --git a/src/Controller/ActivityPub/EntryController.php b/src/Controller/ActivityPub/EntryController.php index a68a25af1..d9f066db6 100644 --- a/src/Controller/ActivityPub/EntryController.php +++ b/src/Controller/ActivityPub/EntryController.php @@ -8,6 +8,7 @@ use App\Entity\Entry; use App\Entity\Magazine; use App\Factory\ActivityPub\EntryPageFactory; +use App\Repository\TagLinkRepository; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -15,8 +16,10 @@ class EntryController extends AbstractController { - public function __construct(private readonly EntryPageFactory $pageFactory) - { + public function __construct( + private readonly EntryPageFactory $pageFactory, + private readonly TagLinkRepository $tagLinkRepository, + ) { } public function __invoke( @@ -30,7 +33,7 @@ public function __invoke( return $this->redirect($entry->apId); } - $response = new JsonResponse($this->pageFactory->create($entry, true)); + $response = new JsonResponse($this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfEntry($entry), true)); $response->headers->set('Content-Type', 'application/activity+json'); diff --git a/src/Controller/ActivityPub/PostCommentController.php b/src/Controller/ActivityPub/PostCommentController.php index 529ad9beb..785dc3f7e 100644 --- a/src/Controller/ActivityPub/PostCommentController.php +++ b/src/Controller/ActivityPub/PostCommentController.php @@ -10,6 +10,7 @@ use App\Entity\Post; use App\Entity\PostComment; use App\Factory\ActivityPub\PostCommentNoteFactory; +use App\Repository\TagLinkRepository; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -19,8 +20,10 @@ class PostCommentController extends AbstractController { use PrivateContentTrait; - public function __construct(private readonly PostCommentNoteFactory $commentNoteFactory) - { + public function __construct( + private readonly PostCommentNoteFactory $commentNoteFactory, + private readonly TagLinkRepository $tagLinkRepository, + ) { } public function __invoke( @@ -38,7 +41,7 @@ public function __invoke( $this->handlePrivateContent($post); - $response = new JsonResponse($this->commentNoteFactory->create($comment, true)); + $response = new JsonResponse($this->commentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfPostComment($comment), true)); $response->headers->set('Content-Type', 'application/activity+json'); diff --git a/src/Controller/ActivityPub/PostController.php b/src/Controller/ActivityPub/PostController.php index 7720f7858..a86b866d4 100644 --- a/src/Controller/ActivityPub/PostController.php +++ b/src/Controller/ActivityPub/PostController.php @@ -8,6 +8,7 @@ use App\Entity\Magazine; use App\Entity\Post; use App\Factory\ActivityPub\PostNoteFactory; +use App\Repository\TagLinkRepository; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -15,8 +16,10 @@ class PostController extends AbstractController { - public function __construct(private readonly PostNoteFactory $postNoteFactory) - { + public function __construct( + private readonly PostNoteFactory $postNoteFactory, + private readonly TagLinkRepository $tagLinkRepository, + ) { } public function __invoke( @@ -30,7 +33,7 @@ public function __invoke( return $this->redirect($post->apId); } - $response = new JsonResponse($this->postNoteFactory->create($post, true)); + $response = new JsonResponse($this->postNoteFactory->create($post, $this->tagLinkRepository->getTagsOfPost($post), true)); $response->headers->set('Content-Type', 'application/activity+json'); diff --git a/src/Controller/Api/BaseApi.php b/src/Controller/Api/BaseApi.php index 6b521d5ac..598ebbc88 100644 --- a/src/Controller/Api/BaseApi.php +++ b/src/Controller/Api/BaseApi.php @@ -31,8 +31,13 @@ use App\Factory\PostFactory; use App\Form\Constraint\ImageConstraint; use App\Repository\Criteria; +use App\Repository\EntryCommentRepository; +use App\Repository\EntryRepository; use App\Repository\ImageRepository; use App\Repository\OAuth2ClientAccessRepository; +use App\Repository\PostCommentRepository; +use App\Repository\PostRepository; +use App\Repository\TagLinkRepository; use App\Schema\PaginationSchema; use App\Service\IpResolver; use App\Service\ReportManager; @@ -72,6 +77,11 @@ public function __construct( protected readonly EntryCommentFactory $entryCommentFactory, protected readonly MagazineFactory $magazineFactory, protected readonly RequestStack $request, + protected readonly TagLinkRepository $tagLinkRepository, + protected readonly EntryRepository $entryRepository, + protected readonly EntryCommentRepository $entryCommentRepository, + protected readonly PostRepository $postRepository, + protected readonly PostCommentRepository $postCommentRepository, private readonly ImageRepository $imageRepository, private readonly ReportManager $reportManager, private readonly OAuth2ClientAccessRepository $clientAccessRepository, @@ -163,7 +173,7 @@ public function serializeContentInterface(ContentInterface $content, bool $force /** * @var Entry $content */ - $dto = $this->entryFactory->createResponseDto($content); + $dto = $this->entryFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfEntry($content)); $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility; $toReturn = $dto->jsonSerialize(); $toReturn['itemType'] = 'entry'; @@ -172,7 +182,7 @@ public function serializeContentInterface(ContentInterface $content, bool $force /** * @var EntryComment $content */ - $dto = $this->entryCommentFactory->createResponseDto($content); + $dto = $this->entryCommentFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfEntryComment($content)); $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility; $toReturn = $dto->jsonSerialize(); $toReturn['itemType'] = 'entry_comment'; @@ -181,7 +191,7 @@ public function serializeContentInterface(ContentInterface $content, bool $force /** * @var Post $content */ - $dto = $this->postFactory->createResponseDto($content); + $dto = $this->postFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfPost($content)); $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility; $toReturn = $dto->jsonSerialize(); $toReturn['itemType'] = 'post'; @@ -190,7 +200,7 @@ public function serializeContentInterface(ContentInterface $content, bool $force /** * @var PostComment $content */ - $dto = $this->postCommentFactory->createResponseDto($content); + $dto = $this->postCommentFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfPostComment($content)); $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility; $toReturn = $dto->jsonSerialize(); $toReturn['itemType'] = 'post_comment'; @@ -220,6 +230,7 @@ protected function serializeLogItem(MagazineLog $log): array $this->entryCommentFactory, $this->postFactory, $this->postCommentFactory, + $this->tagLinkRepository, ); if ($response->subject) { diff --git a/src/Controller/Api/Entry/Admin/EntriesChangeMagazineApi.php b/src/Controller/Api/Entry/Admin/EntriesChangeMagazineApi.php index ff1904437..c64961bb2 100644 --- a/src/Controller/Api/Entry/Admin/EntriesChangeMagazineApi.php +++ b/src/Controller/Api/Entry/Admin/EntriesChangeMagazineApi.php @@ -86,7 +86,7 @@ public function __invoke( $manager->changeMagazine($entry, $target); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry)), + $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Comments/EntryCommentsCreateApi.php b/src/Controller/Api/Entry/Comments/EntryCommentsCreateApi.php index c565aa1ea..0b179a8d9 100644 --- a/src/Controller/Api/Entry/Comments/EntryCommentsCreateApi.php +++ b/src/Controller/Api/Entry/Comments/EntryCommentsCreateApi.php @@ -123,7 +123,7 @@ public function __invoke( $dto->parent = $parent; return new JsonResponse( - $this->serializeComment($dto), + $this->serializeComment($dto, $this->tagLinkRepository->getTagsOfEntryComment($comment)), status: 201, headers: $headers ); @@ -237,7 +237,7 @@ public function uploadImage( $dto->parent = $parent; return new JsonResponse( - $this->serializeComment($dto), + $this->serializeComment($dto, $this->tagLinkRepository->getTagsOfEntryComment($comment)), status: 201, headers: $headers ); diff --git a/src/Controller/Api/Entry/Comments/EntryCommentsFavouriteApi.php b/src/Controller/Api/Entry/Comments/EntryCommentsFavouriteApi.php index e1f376f44..ad547668f 100644 --- a/src/Controller/Api/Entry/Comments/EntryCommentsFavouriteApi.php +++ b/src/Controller/Api/Entry/Comments/EntryCommentsFavouriteApi.php @@ -78,7 +78,7 @@ public function __invoke( $manager->toggle($this->getUserOrThrow(), $comment); return new JsonResponse( - $this->serializeComment($factory->createDto($comment)), + $this->serializeComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfEntryComment($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Comments/EntryCommentsVoteApi.php b/src/Controller/Api/Entry/Comments/EntryCommentsVoteApi.php index 655b0c662..63cac1e16 100644 --- a/src/Controller/Api/Entry/Comments/EntryCommentsVoteApi.php +++ b/src/Controller/Api/Entry/Comments/EntryCommentsVoteApi.php @@ -91,7 +91,7 @@ public function __invoke( $manager->vote($choice, $comment, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializeComment($factory->createDto($comment)), + $this->serializeComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfEntryComment($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetAdultApi.php b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetAdultApi.php index d2069a252..f6421be29 100644 --- a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetAdultApi.php +++ b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetAdultApi.php @@ -86,7 +86,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializeComment($factory->createDto($comment)), + $this->serializeComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfEntryComment($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetLanguageApi.php b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetLanguageApi.php index c17144c82..08d16ddb2 100644 --- a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetLanguageApi.php +++ b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetLanguageApi.php @@ -100,7 +100,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializeComment($factory->createDto($comment)), + $this->serializeComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfEntryComment($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsTrashApi.php b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsTrashApi.php index ac60f91cc..4ca8a3d02 100644 --- a/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsTrashApi.php +++ b/src/Controller/Api/Entry/Comments/Moderate/EntryCommentsTrashApi.php @@ -82,7 +82,7 @@ public function trash( // Force response to have all fields visible $visibility = $comment->visibility; $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE; - $response = $this->serializeComment($factory->createDto($comment))->jsonSerialize(); + $response = $this->serializeComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfEntryComment($comment))->jsonSerialize(); $response['visibility'] = $visibility; return new JsonResponse( @@ -159,7 +159,7 @@ public function restore( } return new JsonResponse( - $this->serializeComment($factory->createDto($comment)), + $this->serializeComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfEntryComment($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/DomainEntriesRetrieveApi.php b/src/Controller/Api/Entry/DomainEntriesRetrieveApi.php index 0356ff616..dce32dcc5 100644 --- a/src/Controller/Api/Entry/DomainEntriesRetrieveApi.php +++ b/src/Controller/Api/Entry/DomainEntriesRetrieveApi.php @@ -150,7 +150,7 @@ public function __invoke( try { \assert($value instanceof Entry); $this->handlePrivateContent($value); - array_push($dtos, $this->serializeEntry($factory->createDto($value))); + array_push($dtos, $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfEntry($value))); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Api/Entry/EntriesBaseApi.php b/src/Controller/Api/Entry/EntriesBaseApi.php index ccdf82369..1111086a2 100644 --- a/src/Controller/Api/Entry/EntriesBaseApi.php +++ b/src/Controller/Api/Entry/EntriesBaseApi.php @@ -34,9 +34,9 @@ public function setCommentsFactory(EntryCommentFactory $commentsFactory) /** * Serialize a single entry to JSON. */ - protected function serializeEntry(EntryDto|Entry $dto) + protected function serializeEntry(EntryDto|Entry $dto, array $tags) { - $response = $this->entryFactory->createResponseDto($dto); + $response = $this->entryFactory->createResponseDto($dto, $tags); if ($this->isGranted('ROLE_OAUTH2_ENTRY:VOTE')) { $response->isFavourited = $dto instanceof EntryDto ? $dto->isFavourited : $dto->isFavored($this->getUserOrThrow()); @@ -94,9 +94,9 @@ protected function deserializeEntryFromForm(): EntryRequestDto /** * Serialize a single comment to JSON. */ - protected function serializeComment(EntryCommentDto $comment): EntryCommentResponseDto + protected function serializeComment(EntryCommentDto $comment, array $tags): EntryCommentResponseDto { - $response = $this->entryCommentFactory->createResponseDto($comment); + $response = $this->entryCommentFactory->createResponseDto($comment, $tags); if ($this->isGranted('ROLE_OAUTH2_ENTRY_COMMENT:VOTE')) { $response->isFavourited = $comment->isFavourited; diff --git a/src/Controller/Api/Entry/EntriesFavouriteApi.php b/src/Controller/Api/Entry/EntriesFavouriteApi.php index f63bc1fdd..e050fb504 100644 --- a/src/Controller/Api/Entry/EntriesFavouriteApi.php +++ b/src/Controller/Api/Entry/EntriesFavouriteApi.php @@ -69,7 +69,7 @@ public function __invoke( $manager->toggle($this->getUserOrThrow(), $entry); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry)), + $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/EntriesRetrieveApi.php b/src/Controller/Api/Entry/EntriesRetrieveApi.php index 8d0685f87..0201cc63f 100644 --- a/src/Controller/Api/Entry/EntriesRetrieveApi.php +++ b/src/Controller/Api/Entry/EntriesRetrieveApi.php @@ -82,7 +82,7 @@ public function __invoke( $dto = $factory->createDto($entry); return new JsonResponse( - $this->serializeEntry($dto), + $this->serializeEntry($dto, $this->tagLinkRepository->getTagsOfEntry($entry)), headers: $headers ); } @@ -196,7 +196,7 @@ public function collection( try { \assert($value instanceof Entry); $this->handlePrivateContent($value); - array_push($dtos, $this->serializeEntry($factory->createDto($value))); + array_push($dtos, $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfEntry($value))); } catch (AccessDeniedException $e) { continue; } @@ -302,7 +302,7 @@ public function subscribed( try { \assert($value instanceof Entry); $this->handlePrivateContent($value); - array_push($dtos, $this->serializeEntry($factory->createDto($value))); + array_push($dtos, $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfEntry($value))); } catch (\Exception $e) { continue; } @@ -408,7 +408,7 @@ public function moderated( try { \assert($value instanceof Entry); $this->handlePrivateContent($value); - array_push($dtos, $this->serializeEntry($factory->createDto($value))); + array_push($dtos, $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfEntry($value))); } catch (\Exception $e) { continue; } @@ -514,7 +514,7 @@ public function favourited( try { \assert($value instanceof Entry); $this->handlePrivateContent($value); - array_push($dtos, $this->serializeEntry($factory->createDto($value))); + array_push($dtos, $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfEntry($value))); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Api/Entry/EntriesUpdateApi.php b/src/Controller/Api/Entry/EntriesUpdateApi.php index e112e16c0..51b6d0a7d 100644 --- a/src/Controller/Api/Entry/EntriesUpdateApi.php +++ b/src/Controller/Api/Entry/EntriesUpdateApi.php @@ -96,7 +96,7 @@ public function __invoke( $entry = $manager->edit($entry, $dto); return new JsonResponse( - $this->serializeEntry($entry), + $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfEntry($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/EntriesVoteApi.php b/src/Controller/Api/Entry/EntriesVoteApi.php index 158e2cbb0..b88cba96c 100644 --- a/src/Controller/Api/Entry/EntriesVoteApi.php +++ b/src/Controller/Api/Entry/EntriesVoteApi.php @@ -90,7 +90,7 @@ public function __invoke( $manager->vote($choice, $entry, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry)), + $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/MagazineEntriesRetrieveApi.php b/src/Controller/Api/Entry/MagazineEntriesRetrieveApi.php index 0107e8675..2ee8fbe4c 100644 --- a/src/Controller/Api/Entry/MagazineEntriesRetrieveApi.php +++ b/src/Controller/Api/Entry/MagazineEntriesRetrieveApi.php @@ -151,7 +151,7 @@ public function __invoke( foreach ($entries->getCurrentPageResults() as $value) { try { \assert($value instanceof Entry); - array_push($dtos, $this->serializeEntry($factory->createDto($value))); + array_push($dtos, $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfEntry($value))); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Api/Entry/MagazineEntryCreateApi.php b/src/Controller/Api/Entry/MagazineEntryCreateApi.php index ab255f570..9c6d08e3f 100644 --- a/src/Controller/Api/Entry/MagazineEntryCreateApi.php +++ b/src/Controller/Api/Entry/MagazineEntryCreateApi.php @@ -101,7 +101,7 @@ public function article( ]); return new JsonResponse( - $this->serializeEntry($manager->createDto($entry)), + $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), status: 201, headers: $headers ); @@ -179,7 +179,7 @@ public function link( ]); return new JsonResponse( - $this->serializeEntry($manager->createDto($entry)), + $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), status: 201, headers: $headers ); @@ -252,7 +252,7 @@ public function video( ]); return new JsonResponse( - $this->serializeEntry($manager->createDto($entry)), + $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), status: 201, headers: $headers ); @@ -363,7 +363,7 @@ public function uploadImage( $entry = $manager->create($dto, $this->getUserOrThrow()); return new JsonResponse( - $this->serializeEntry($manager->createDto($entry)), + $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), status: 201, headers: $headers ); diff --git a/src/Controller/Api/Entry/Moderate/EntriesPinApi.php b/src/Controller/Api/Entry/Moderate/EntriesPinApi.php index b2e135b20..d74dce7a4 100644 --- a/src/Controller/Api/Entry/Moderate/EntriesPinApi.php +++ b/src/Controller/Api/Entry/Moderate/EntriesPinApi.php @@ -76,7 +76,7 @@ public function __invoke( $manager->pin($entry); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry)), + $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Moderate/EntriesSetAdultApi.php b/src/Controller/Api/Entry/Moderate/EntriesSetAdultApi.php index e55e6c84d..bd909003d 100644 --- a/src/Controller/Api/Entry/Moderate/EntriesSetAdultApi.php +++ b/src/Controller/Api/Entry/Moderate/EntriesSetAdultApi.php @@ -86,7 +86,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry)), + $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Moderate/EntriesSetLanguageApi.php b/src/Controller/Api/Entry/Moderate/EntriesSetLanguageApi.php index db734921a..aa410eca8 100644 --- a/src/Controller/Api/Entry/Moderate/EntriesSetLanguageApi.php +++ b/src/Controller/Api/Entry/Moderate/EntriesSetLanguageApi.php @@ -100,7 +100,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializeEntry($factory->createDto($entry)), + $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/Moderate/EntriesTrashApi.php b/src/Controller/Api/Entry/Moderate/EntriesTrashApi.php index 8ed8b7449..c24f0ae0f 100644 --- a/src/Controller/Api/Entry/Moderate/EntriesTrashApi.php +++ b/src/Controller/Api/Entry/Moderate/EntriesTrashApi.php @@ -79,7 +79,7 @@ public function trash( $manager->trash($moderator, $entry); - $response = $this->serializeEntry($factory->createDto($entry)); + $response = $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)); // Force response to have all fields visible $visibility = $response->visibility; @@ -161,7 +161,7 @@ public function restore( } return new JsonResponse( - $this->serializeEntry($factory->createDto($entry)), + $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfEntry($entry)), headers: $headers ); } diff --git a/src/Controller/Api/Entry/UserEntriesRetrieveApi.php b/src/Controller/Api/Entry/UserEntriesRetrieveApi.php index ad66d95bc..dffdf81ad 100644 --- a/src/Controller/Api/Entry/UserEntriesRetrieveApi.php +++ b/src/Controller/Api/Entry/UserEntriesRetrieveApi.php @@ -151,7 +151,7 @@ public function __invoke( foreach ($entries->getCurrentPageResults() as $value) { try { \assert($value instanceof Entry); - array_push($dtos, $this->serializeEntry($factory->createDto($value))); + array_push($dtos, $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfEntry($value))); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Api/Notification/NotificationBaseApi.php b/src/Controller/Api/Notification/NotificationBaseApi.php index 7cc734475..c176e7b89 100644 --- a/src/Controller/Api/Notification/NotificationBaseApi.php +++ b/src/Controller/Api/Notification/NotificationBaseApi.php @@ -51,7 +51,7 @@ protected function serializeNotification(Notification $dto) * @var \App\Entity\EntryMentionedNotification $dto */ $entry = $dto->getSubject(); - $toReturn['subject'] = $this->entryFactory->createResponseDto($entry); + $toReturn['subject'] = $this->entryFactory->createResponseDto($entry, $this->tagLinkRepository->getTagsOfEntry($entry)); break; case 'entry_comment_created_notification': case 'entry_comment_edited_notification': @@ -62,7 +62,7 @@ protected function serializeNotification(Notification $dto) * @var \App\Entity\EntryCommentMentionedNotification $dto */ $comment = $dto->getSubject(); - $toReturn['subject'] = $this->entryCommentFactory->createResponseDto($comment); + $toReturn['subject'] = $this->entryCommentFactory->createResponseDto($comment, $this->tagLinkRepository->getTagsOfEntryComment($comment)); break; case 'post_created_notification': case 'post_edited_notification': @@ -72,7 +72,7 @@ protected function serializeNotification(Notification $dto) * @var \App\Entity\PostMentionedNotification $dto */ $post = $dto->getSubject(); - $toReturn['subject'] = $this->postFactory->createResponseDto($post); + $toReturn['subject'] = $this->postFactory->createResponseDto($post, $this->tagLinkRepository->getTagsOfPost($post)); break; case 'post_comment_created_notification': case 'post_comment_edited_notification': @@ -83,7 +83,7 @@ protected function serializeNotification(Notification $dto) * @var \App\Entity\PostCommentMentionedNotification $dto */ $comment = $dto->getSubject(); - $toReturn['subject'] = $this->postCommentFactory->createResponseDto($comment); + $toReturn['subject'] = $this->postCommentFactory->createResponseDto($comment, $this->tagLinkRepository->getTagsOfPostComment($comment)); break; case 'message_notification': if (!$this->isGranted('ROLE_OAUTH2_USER:MESSAGE:READ')) { diff --git a/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetAdultApi.php b/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetAdultApi.php index 718d0d7d1..588ce7378 100644 --- a/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetAdultApi.php +++ b/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetAdultApi.php @@ -86,7 +86,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment)), + $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfPostComment($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetLanguageApi.php b/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetLanguageApi.php index ed18fcd94..3104b2208 100644 --- a/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetLanguageApi.php +++ b/src/Controller/Api/Post/Comments/Moderate/PostCommentsSetLanguageApi.php @@ -100,7 +100,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment)), + $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfPostComment($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Comments/Moderate/PostCommentsTrashApi.php b/src/Controller/Api/Post/Comments/Moderate/PostCommentsTrashApi.php index f98fd7bfd..f84f49f16 100644 --- a/src/Controller/Api/Post/Comments/Moderate/PostCommentsTrashApi.php +++ b/src/Controller/Api/Post/Comments/Moderate/PostCommentsTrashApi.php @@ -82,7 +82,7 @@ public function trash( // Force response to have all fields visible $visibility = $comment->visibility; $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE; - $response = $this->serializePostComment($factory->createDto($comment))->jsonSerialize(); + $response = $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfPostComment($comment))->jsonSerialize(); $response['visibility'] = $visibility; return new JsonResponse( @@ -159,7 +159,7 @@ public function restore( } return new JsonResponse( - $this->serializePostComment($factory->createDto($comment)), + $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfPostComment($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Comments/PostCommentsCreateApi.php b/src/Controller/Api/Post/Comments/PostCommentsCreateApi.php index 9cb52704c..5aa81c413 100644 --- a/src/Controller/Api/Post/Comments/PostCommentsCreateApi.php +++ b/src/Controller/Api/Post/Comments/PostCommentsCreateApi.php @@ -121,7 +121,7 @@ public function __invoke( $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment)), + $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfPostComment($comment)), status: 201, headers: $headers ); @@ -232,7 +232,7 @@ public function uploadImage( $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment)), + $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfPostComment($comment)), status: 201, headers: $headers ); diff --git a/src/Controller/Api/Post/Comments/PostCommentsFavouriteApi.php b/src/Controller/Api/Post/Comments/PostCommentsFavouriteApi.php index 5a9f58d2b..0f35c2c15 100644 --- a/src/Controller/Api/Post/Comments/PostCommentsFavouriteApi.php +++ b/src/Controller/Api/Post/Comments/PostCommentsFavouriteApi.php @@ -78,7 +78,7 @@ public function __invoke( $manager->toggle($this->getUserOrThrow(), $comment); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment)), + $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfPostComment($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Comments/PostCommentsVoteApi.php b/src/Controller/Api/Post/Comments/PostCommentsVoteApi.php index b85387a8e..ee626ba7d 100644 --- a/src/Controller/Api/Post/Comments/PostCommentsVoteApi.php +++ b/src/Controller/Api/Post/Comments/PostCommentsVoteApi.php @@ -91,7 +91,7 @@ public function __invoke( $manager->vote($choice, $comment, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePostComment($factory->createDto($comment)), + $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfPostComment($comment)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Moderate/PostsPinApi.php b/src/Controller/Api/Post/Moderate/PostsPinApi.php index a5e0a1626..a196fd9a7 100644 --- a/src/Controller/Api/Post/Moderate/PostsPinApi.php +++ b/src/Controller/Api/Post/Moderate/PostsPinApi.php @@ -76,7 +76,7 @@ public function __invoke( $manager->pin($post); return new JsonResponse( - $this->serializePost($factory->createDto($post)), + $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfPost($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Moderate/PostsSetAdultApi.php b/src/Controller/Api/Post/Moderate/PostsSetAdultApi.php index 61742c279..c9d85b5fd 100644 --- a/src/Controller/Api/Post/Moderate/PostsSetAdultApi.php +++ b/src/Controller/Api/Post/Moderate/PostsSetAdultApi.php @@ -86,7 +86,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializePost($factory->createDto($post)), + $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfPost($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Moderate/PostsSetLanguageApi.php b/src/Controller/Api/Post/Moderate/PostsSetLanguageApi.php index 3002f0106..0976e42b9 100644 --- a/src/Controller/Api/Post/Moderate/PostsSetLanguageApi.php +++ b/src/Controller/Api/Post/Moderate/PostsSetLanguageApi.php @@ -100,7 +100,7 @@ public function __invoke( $manager->flush(); return new JsonResponse( - $this->serializePost($factory->createDto($post)), + $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfPost($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/Moderate/PostsTrashApi.php b/src/Controller/Api/Post/Moderate/PostsTrashApi.php index ea7b17b05..56faf6aff 100644 --- a/src/Controller/Api/Post/Moderate/PostsTrashApi.php +++ b/src/Controller/Api/Post/Moderate/PostsTrashApi.php @@ -82,7 +82,7 @@ public function trash( // Force response to have all fields visible $visibility = $post->visibility; $post->visibility = VisibilityInterface::VISIBILITY_VISIBLE; - $response = $this->serializePost($factory->createDto($post))->jsonSerialize(); + $response = $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfPost($post))->jsonSerialize(); $response['visibility'] = $visibility; return new JsonResponse( @@ -159,7 +159,7 @@ public function restore( } return new JsonResponse( - $this->serializePost($factory->createDto($post)), + $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfPost($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/PostsBaseApi.php b/src/Controller/Api/Post/PostsBaseApi.php index 0195f82f7..b45f94000 100644 --- a/src/Controller/Api/Post/PostsBaseApi.php +++ b/src/Controller/Api/Post/PostsBaseApi.php @@ -18,12 +18,12 @@ class PostsBaseApi extends BaseApi /** * Serialize a single post to JSON. */ - protected function serializePost(PostDto $dto): PostResponseDto + protected function serializePost(PostDto $dto, array $tags): PostResponseDto { if (null === $dto) { return []; } - $response = $this->postFactory->createResponseDto($dto); + $response = $this->postFactory->createResponseDto($dto, $tags); if ($this->isGranted('ROLE_OAUTH2_POST:VOTE')) { $response->isFavourited = $dto instanceof PostDto ? $dto->isFavourited : $dto->isFavored($this->getUserOrThrow()); @@ -74,9 +74,9 @@ protected function deserializePostFromForm(PostDto $dto = null): PostDto /** * Serialize a single comment to JSON. */ - protected function serializePostComment(PostCommentDto $comment): PostCommentResponseDto + protected function serializePostComment(PostCommentDto $comment, array $tags): PostCommentResponseDto { - $response = $this->postCommentFactory->createResponseDto($comment); + $response = $this->postCommentFactory->createResponseDto($comment, $tags); if ($this->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE')) { $response->isFavourited = $comment instanceof PostCommentDto ? $comment->isFavourited : $comment->isFavored($this->getUserOrThrow()); diff --git a/src/Controller/Api/Post/PostsCreateApi.php b/src/Controller/Api/Post/PostsCreateApi.php index c046488df..18028ed4e 100644 --- a/src/Controller/Api/Post/PostsCreateApi.php +++ b/src/Controller/Api/Post/PostsCreateApi.php @@ -105,7 +105,7 @@ public function __invoke( $post = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePost($manager->createDto($post)), + $this->serializePost($manager->createDto($post), $this->tagLinkRepository->getTagsOfPost($post)), status: 201, headers: $headers ); @@ -201,7 +201,7 @@ public function uploadImage( $post = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePost($manager->createDto($post)), + $this->serializePost($manager->createDto($post), $this->tagLinkRepository->getTagsOfPost($post)), status: 201, headers: $headers ); diff --git a/src/Controller/Api/Post/PostsFavouriteApi.php b/src/Controller/Api/Post/PostsFavouriteApi.php index 9185d1c4a..fd22dd10c 100644 --- a/src/Controller/Api/Post/PostsFavouriteApi.php +++ b/src/Controller/Api/Post/PostsFavouriteApi.php @@ -69,7 +69,7 @@ public function __invoke( $manager->toggle($this->getUserOrThrow(), $post); return new JsonResponse( - $this->serializePost($factory->createDto($post)), + $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfPost($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/PostsRetrieveApi.php b/src/Controller/Api/Post/PostsRetrieveApi.php index 338559966..cfca43d68 100644 --- a/src/Controller/Api/Post/PostsRetrieveApi.php +++ b/src/Controller/Api/Post/PostsRetrieveApi.php @@ -82,7 +82,7 @@ public function __invoke( $dto = $factory->createDto($post); return new JsonResponse( - $this->serializePost($dto), + $this->serializePost($dto, $this->tagLinkRepository->getTagsOfPost($post)), headers: $headers ); } @@ -192,7 +192,7 @@ public function collection( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - array_push($dtos, $this->serializePost($factory->createDto($value))); + array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfPost($value))); } catch (\Exception $e) { continue; } @@ -312,7 +312,7 @@ public function subscribed( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - array_push($dtos, $this->serializePost($factory->createDto($value))); + array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfPost($value))); } catch (\Exception $e) { continue; } @@ -418,7 +418,7 @@ public function moderated( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - array_push($dtos, $this->serializePost($factory->createDto($value))); + array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfPost($value))); } catch (\Exception $e) { continue; } @@ -521,7 +521,7 @@ public function favourited( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - array_push($dtos, $this->serializePost($factory->createDto($value))); + array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfPost($value))); } catch (\Exception $e) { continue; } @@ -654,7 +654,7 @@ public function byMagazine( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - array_push($dtos, $this->serializePost($factory->createDto($value))); + array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfPost($value))); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Api/Post/PostsUpdateApi.php b/src/Controller/Api/Post/PostsUpdateApi.php index 8dccdb9cb..8d627f83e 100644 --- a/src/Controller/Api/Post/PostsUpdateApi.php +++ b/src/Controller/Api/Post/PostsUpdateApi.php @@ -99,7 +99,7 @@ public function __invoke( $post = $manager->edit($post, $dto); return new JsonResponse( - $this->serializePost($factory->createDto($post)), + $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfPost($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/PostsVoteApi.php b/src/Controller/Api/Post/PostsVoteApi.php index 26ad13292..fd58b650c 100644 --- a/src/Controller/Api/Post/PostsVoteApi.php +++ b/src/Controller/Api/Post/PostsVoteApi.php @@ -94,7 +94,7 @@ public function __invoke( $manager->vote($choice, $post, $this->getUserOrThrow(), rateLimit: false); return new JsonResponse( - $this->serializePost($factory->createDto($post)), + $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfPost($post)), headers: $headers ); } diff --git a/src/Controller/Api/Post/UserPostsRetrieveApi.php b/src/Controller/Api/Post/UserPostsRetrieveApi.php index f235bd7e4..e70118cd3 100644 --- a/src/Controller/Api/Post/UserPostsRetrieveApi.php +++ b/src/Controller/Api/Post/UserPostsRetrieveApi.php @@ -144,7 +144,7 @@ public function __invoke( try { \assert($value instanceof Post); $this->handlePrivateContent($value); - array_push($dtos, $this->serializePost($factory->createDto($value))); + array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfPost($value))); } catch (\Exception $e) { continue; } diff --git a/src/Controller/Entry/EntryCreateController.php b/src/Controller/Entry/EntryCreateController.php index 20ca2897f..c19e6cd58 100644 --- a/src/Controller/Entry/EntryCreateController.php +++ b/src/Controller/Entry/EntryCreateController.php @@ -7,11 +7,15 @@ use App\Controller\AbstractController; use App\DTO\EntryDto; use App\Entity\Magazine; +use App\Exception\TagBannedException; use App\PageView\EntryPageView; use App\Repository\Criteria; +use App\Repository\TagLinkRepository; +use App\Repository\TagRepository; use App\Service\EntryCommentManager; use App\Service\EntryManager; use App\Service\IpResolver; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -24,6 +28,9 @@ class EntryCreateController extends AbstractController use EntryFormTrait; public function __construct( + private readonly LoggerInterface $logger, + private readonly TagLinkRepository $tagLinkRepository, + private readonly TagRepository $tagRepository, private readonly EntryManager $manager, private readonly EntryCommentManager $commentManager, private readonly ValidatorInterface $validator, @@ -44,6 +51,7 @@ public function __invoke(?Magazine $magazine, ?string $type, Request $request): $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + /** @var EntryDto $dto */ $dto = $form->getData(); $dto->ip = $this->ipResolver->resolve(); @@ -52,6 +60,15 @@ public function __invoke(?Magazine $magazine, ?string $type, Request $request): } $entry = $this->manager->create($dto, $this->getUserOrThrow()); + foreach ($dto->tags ?? [] as $tag) { + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); + if (!$hashtag) { + $hashtag = $this->tagRepository->create($tag); + } elseif ($this->tagLinkRepository->entryHasTag($entry, $hashtag)) { + continue; + } + $this->tagLinkRepository->addTagToEntry($entry, $hashtag); + } $this->addFlash('success', 'flash_thread_new_success'); @@ -70,9 +87,24 @@ public function __invoke(?Magazine $magazine, ?string $type, Request $request): ], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) ); + } catch (TagBannedException $e) { + // Show an error to the user + $this->addFlash('error', 'flash_thread_tag_banned_error'); + $this->logger->error($e); + + return $this->render( + $this->getTemplateName((new EntryPageView(1))->resolveType($type)), + [ + 'magazine' => $magazine, + 'user' => $user, + 'form' => $form->createView(), + ], + new Response(null, 422) + ); } catch (\Exception $e) { // Show an error to the user $this->addFlash('error', 'flash_thread_new_error'); + $this->logger->error($e); return $this->render( $this->getTemplateName((new EntryPageView(1))->resolveType($type)), diff --git a/src/Controller/Post/PostCreateController.php b/src/Controller/Post/PostCreateController.php index 33e8a072f..edca962c9 100644 --- a/src/Controller/Post/PostCreateController.php +++ b/src/Controller/Post/PostCreateController.php @@ -9,6 +9,7 @@ use App\Repository\Criteria; use App\Service\IpResolver; use App\Service\PostManager; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -17,6 +18,7 @@ class PostCreateController extends AbstractController { public function __construct( + private readonly LoggerInterface $logger, private readonly PostManager $manager, private readonly IpResolver $ipResolver ) { @@ -26,7 +28,7 @@ public function __construct( public function __invoke(Request $request): Response { $form = $this->createForm(PostType::class); - + $user = $this->getUserOrThrow(); try { // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image) $form->handleRequest($request); @@ -39,7 +41,7 @@ public function __invoke(Request $request): Response throw new AccessDeniedHttpException(); } - $this->manager->create($dto, $this->getUserOrThrow()); + $this->manager->create($dto, $user); $this->addFlash('success', 'flash_post_new_success'); @@ -52,6 +54,7 @@ public function __invoke(Request $request): Response ); } } catch (\Exception $e) { + $this->logger->error('{user} tried to create a post, but an exception occurred: {ex} - {message}', ['user' => $user->username, 'ex' => \get_class($e), 'message' => $e->getMessage(), 'stacktrace' => $e->getTrace()]); // Show an error to the user $this->addFlash('error', 'flash_post_new_error'); } diff --git a/src/Controller/Tag/TagBanController.php b/src/Controller/Tag/TagBanController.php new file mode 100644 index 000000000..b6cad5814 --- /dev/null +++ b/src/Controller/Tag/TagBanController.php @@ -0,0 +1,50 @@ +validateCsrf('ban', $request->request->get('token')); + + $hashtag = $this->tagRepository->findOneBy(['tag' => $name]); + if (null === $hashtag) { + $hashtag = $this->tagRepository->create($name); + } + $this->tagManager->ban($hashtag); + + return $this->redirectToRoute('tag_overview', ['name' => $hashtag->tag]); + } + + #[IsGranted('ROLE_ADMIN')] + public function unban(string $name, Request $request): Response + { + $this->validateCsrf('ban', $request->request->get('token')); + + $hashtag = $this->tagRepository->findOneBy(['tag' => $name]); + if ($hashtag) { + $this->tagManager->unban($hashtag); + + return $this->redirectToRoute('tag_overview', ['name' => $hashtag->tag]); + } else { + throw $this->createNotFoundException(); + } + } +} diff --git a/src/Controller/Tag/TagCommentFrontController.php b/src/Controller/Tag/TagCommentFrontController.php index aee8be617..dceed1b09 100644 --- a/src/Controller/Tag/TagCommentFrontController.php +++ b/src/Controller/Tag/TagCommentFrontController.php @@ -7,7 +7,8 @@ use App\Controller\AbstractController; use App\PageView\EntryCommentPageView; use App\Repository\EntryCommentRepository; -use App\Service\TagManager; +use App\Repository\TagRepository; +use App\Service\TagExtractor; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -15,7 +16,8 @@ class TagCommentFrontController extends AbstractController { public function __construct( private readonly EntryCommentRepository $repository, - private readonly TagManager $tagManager + private readonly TagRepository $tagRepository, + private readonly TagExtractor $tagManager ) { } @@ -29,6 +31,7 @@ public function __invoke(string $name, ?string $sortBy, ?string $time, Request $ $params = [ 'comments' => $this->repository->findByCriteria($criteria), 'tag' => $name, + 'counts' => $this->tagRepository->getCounts($name), ]; return $this->render( diff --git a/src/Controller/Tag/TagEntryFrontController.php b/src/Controller/Tag/TagEntryFrontController.php index caf0a1784..1eba97475 100644 --- a/src/Controller/Tag/TagEntryFrontController.php +++ b/src/Controller/Tag/TagEntryFrontController.php @@ -8,7 +8,8 @@ use App\PageView\EntryPageView; use App\Repository\Criteria; use App\Repository\EntryRepository; -use App\Service\TagManager; +use App\Repository\TagRepository; +use App\Service\TagExtractor; use Pagerfanta\PagerfantaInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -17,7 +18,8 @@ class TagEntryFrontController extends AbstractController { public function __construct( private readonly EntryRepository $entryRepository, - private readonly TagManager $tagManager + private readonly TagRepository $tagRepository, + private readonly TagExtractor $tagManager ) { } @@ -36,6 +38,7 @@ public function __invoke(?string $name, ?string $sortBy, ?string $time, ?string [ 'tag' => $name, 'entries' => $listing, + 'counts' => $this->tagRepository->getCounts($name), ] ); } diff --git a/src/Controller/Tag/TagOverviewController.php b/src/Controller/Tag/TagOverviewController.php index aa6e9167d..e37563070 100644 --- a/src/Controller/Tag/TagOverviewController.php +++ b/src/Controller/Tag/TagOverviewController.php @@ -7,14 +7,15 @@ use App\Controller\AbstractController; use App\Repository\TagRepository; use App\Service\SubjectOverviewManager; -use App\Service\TagManager; +use App\Service\TagExtractor; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class TagOverviewController extends AbstractController { public function __construct( - private readonly TagManager $tagManager, + private readonly TagExtractor $tagManager, private readonly TagRepository $tagRepository, private readonly SubjectOverviewManager $overviewManager ) { @@ -27,13 +28,17 @@ public function __invoke(string $name, Request $request): Response $this->tagManager->transliterate(strtolower($name)) ); - return $this->render( - 'tag/overview.html.twig', - [ - 'tag' => $name, - 'results' => $this->overviewManager->buildList($activity), - 'pagination' => $activity, - ] - ); + $params = [ + 'tag' => $name, + 'results' => $this->overviewManager->buildList($activity), + 'pagination' => $activity, + 'counts' => $this->tagRepository->getCounts($name), + ]; + + if ($request->isXmlHttpRequest()) { + return new JsonResponse(['html' => $this->renderView('tag/_list.html.twig', $params)]); + } + + return $this->render('tag/overview.html.twig', $params); } } diff --git a/src/Controller/Tag/TagPeopleFrontController.php b/src/Controller/Tag/TagPeopleFrontController.php index 008fa89b4..7ae50e209 100644 --- a/src/Controller/Tag/TagPeopleFrontController.php +++ b/src/Controller/Tag/TagPeopleFrontController.php @@ -7,6 +7,7 @@ use App\Controller\AbstractController; use App\Repository\MagazineRepository; use App\Repository\PostRepository; +use App\Repository\TagRepository; use App\Service\PeopleManager; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -15,6 +16,7 @@ class TagPeopleFrontController extends AbstractController { public function __construct( private readonly PeopleManager $manager, + private readonly TagRepository $tagRepository, private readonly MagazineRepository $magazineRepository ) { } @@ -35,6 +37,7 @@ public function __invoke( ), 'local' => $this->manager->general(), 'federated' => $this->manager->general(true), + 'counts' => $this->tagRepository->getCounts($name), ] ); } diff --git a/src/Controller/Tag/TagPostFrontController.php b/src/Controller/Tag/TagPostFrontController.php index e45e3e67a..39687f10d 100644 --- a/src/Controller/Tag/TagPostFrontController.php +++ b/src/Controller/Tag/TagPostFrontController.php @@ -7,14 +7,17 @@ use App\Controller\AbstractController; use App\PageView\PostPageView; use App\Repository\PostRepository; -use App\Service\TagManager; +use App\Repository\TagRepository; +use App\Service\TagExtractor; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class TagPostFrontController extends AbstractController { - public function __construct(private readonly TagManager $tagManager) - { + public function __construct( + private readonly TagExtractor $tagManager, + private readonly TagRepository $tagRepository, + ) { } public function __invoke( @@ -36,6 +39,7 @@ public function __invoke( [ 'tag' => $name, 'posts' => $posts, + 'counts' => $this->tagRepository->getCounts($name), ] ); } diff --git a/src/DTO/EntryCommentDto.php b/src/DTO/EntryCommentDto.php index 57aaca147..787fec59d 100644 --- a/src/DTO/EntryCommentDto.php +++ b/src/DTO/EntryCommentDto.php @@ -39,7 +39,6 @@ class EntryCommentDto public ?string $ip = null; public ?string $apId = null; public ?array $mentions = null; - public ?array $tags = null; public ?\DateTimeImmutable $createdAt = null; public ?\DateTimeImmutable $editedAt = null; public ?\DateTime $lastActive = null; diff --git a/src/DTO/EntryRequestDto.php b/src/DTO/EntryRequestDto.php index 304487b8d..3068fbd45 100644 --- a/src/DTO/EntryRequestDto.php +++ b/src/DTO/EntryRequestDto.php @@ -65,7 +65,6 @@ public function mergeIntoDto(EntryDto $dto): EntryDto { $dto->title = $this->title ?? $dto->title; $dto->body = $this->body ?? $dto->body; - $dto->tags = $this->tags ?? $dto->tags; // TODO: Support for badges when they're implemented // $dto->badges = $this->badges ?? $dto->badges; $dto->isAdult = $this->isAdult ?? $dto->isAdult; diff --git a/src/DTO/MagazineLogResponseDto.php b/src/DTO/MagazineLogResponseDto.php index eca920d64..466116ccb 100644 --- a/src/DTO/MagazineLogResponseDto.php +++ b/src/DTO/MagazineLogResponseDto.php @@ -12,6 +12,7 @@ use App\Factory\EntryFactory; use App\Factory\PostCommentFactory; use App\Factory\PostFactory; +use App\Repository\TagLinkRepository; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; use Symfony\Component\Serializer\Annotation\Ignore; @@ -75,27 +76,28 @@ public function setSubject( EntryCommentFactory $entryCommentFactory, PostFactory $postFactory, PostCommentFactory $postCommentFactory, + TagLinkRepository $tagLinkRepository, ): void { switch ($this->type) { case 'log_entry_deleted': case 'log_entry_restored': \assert($subject instanceof Entry); - $this->subject = $entryFactory->createResponseDto($subject); + $this->subject = $entryFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfEntry($subject)); break; case 'log_entry_comment_deleted': case 'log_entry_comment_restored': \assert($subject instanceof EntryComment); - $this->subject = $entryCommentFactory->createResponseDto($subject); + $this->subject = $entryCommentFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfEntryComment($subject)); break; case 'log_post_deleted': case 'log_post_restored': \assert($subject instanceof Post); - $this->subject = $postFactory->createResponseDto($subject); + $this->subject = $postFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfPost($subject)); break; case 'log_post_comment_deleted': case 'log_post_comment_restored': \assert($subject instanceof PostComment); - $this->subject = $postCommentFactory->createResponseDto($subject); + $this->subject = $postCommentFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfPostComment($subject)); break; default: break; diff --git a/src/DTO/PostCommentDto.php b/src/DTO/PostCommentDto.php index ed76975f7..9061e4496 100644 --- a/src/DTO/PostCommentDto.php +++ b/src/DTO/PostCommentDto.php @@ -39,7 +39,6 @@ class PostCommentDto implements ContentVisibilityInterface public ?string $ip = null; public ?string $apId = null; public ?array $mentions = null; - public ?array $tags = null; public ?\DateTimeImmutable $createdAt = null; public ?\DateTimeImmutable $editedAt = null; public ?\DateTime $lastActive = null; diff --git a/src/DTO/PostDto.php b/src/DTO/PostDto.php index bc7cecb5d..439541507 100644 --- a/src/DTO/PostDto.php +++ b/src/DTO/PostDto.php @@ -36,7 +36,6 @@ class PostDto implements ContentVisibilityInterface public ?int $userVote = null; public ?string $visibility = VisibilityInterface::VISIBILITY_VISIBLE; public ?string $ip = null; - public ?array $tags = null; public ?array $mentions = null; public ?string $apId = null; public ?\DateTimeImmutable $createdAt = null; diff --git a/src/DoctrineExtensions/DBAL/Types/Citext.php b/src/DoctrineExtensions/DBAL/Types/Citext.php new file mode 100644 index 000000000..d45468563 --- /dev/null +++ b/src/DoctrineExtensions/DBAL/Types/Citext.php @@ -0,0 +1,26 @@ +getDoctrineTypeMapping(self::CITEXT); + } +} diff --git a/src/Entity/Contracts/TagInterface.php b/src/Entity/Contracts/TagInterface.php deleted file mode 100644 index e5a8c9595..000000000 --- a/src/Entity/Contracts/TagInterface.php +++ /dev/null @@ -1,10 +0,0 @@ - true])] - public ?array $tags = null; - #[Column(type: 'json', nullable: true, options: ['jsonb' => true])] public ?array $mentions = null; #[OneToMany(mappedBy: 'entry', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $comments; @@ -128,6 +125,8 @@ class Entry implements VotableInterface, CommentInterface, DomainInterface, Visi public Collection $favourites; #[OneToMany(mappedBy: 'entry', targetEntity: EntryCreatedNotification::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $notifications; + #[OneToMany(mappedBy: 'entry', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] + public Collection $hashtags; #[OneToMany(mappedBy: 'entry', targetEntity: EntryBadge::class, cascade: ['remove', 'persist'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $badges; public array $children = []; @@ -395,11 +394,6 @@ public function getDescription(): string return ''; // @todo get first author comment } - public function getTags(): array - { - return array_values($this->tags ?? []); - } - public function __sleep() { return []; diff --git a/src/Entity/EntryComment.php b/src/Entity/EntryComment.php index 41311e04b..13c33d0f6 100644 --- a/src/Entity/EntryComment.php +++ b/src/Entity/EntryComment.php @@ -8,7 +8,6 @@ use App\Entity\Contracts\ContentInterface; use App\Entity\Contracts\FavouriteInterface; use App\Entity\Contracts\ReportInterface; -use App\Entity\Contracts\TagInterface; use App\Entity\Contracts\VisibilityInterface; use App\Entity\Contracts\VotableInterface; use App\Entity\Traits\ActivityPubActivityTrait; @@ -36,7 +35,7 @@ #[Index(columns: ['last_active'], name: 'entry_comment_last_active_at_idx')] #[Index(columns: ['created_at'], name: 'entry_comment_created_at_idx')] #[Index(columns: ['body_ts'], name: 'entry_comment_body_ts_idx')] -class EntryComment implements VotableInterface, VisibilityInterface, ReportInterface, FavouriteInterface, TagInterface, ActivityPubActivityInterface +class EntryComment implements VotableInterface, VisibilityInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface { use VotableTrait; use VisibilityTrait; @@ -76,8 +75,6 @@ class EntryComment implements VotableInterface, VisibilityInterface, ReportInter public ?\DateTime $lastActive = null; #[Column(type: 'string', nullable: true)] public ?string $ip = null; - #[Column(type: 'json', nullable: true, options: ['jsonb' => true])] - public ?array $tags = null; #[Column(type: 'json', nullable: true)] public ?array $mentions = null; #[OneToMany(mappedBy: 'parent', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] @@ -94,6 +91,8 @@ class EntryComment implements VotableInterface, VisibilityInterface, ReportInter public Collection $favourites; #[OneToMany(mappedBy: 'entryComment', targetEntity: EntryCommentCreatedNotification::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $notifications; + #[OneToMany(mappedBy: 'entryComment', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] + public Collection $hashtags; #[Id] #[GeneratedValue] #[Column(type: 'integer')] @@ -235,11 +234,6 @@ public function isFavored(User $user): bool return $this->favourites->matching($criteria)->count() > 0; } - public function getTags(): array - { - return array_values($this->tags ?? []); - } - public function __sleep() { return []; diff --git a/src/Entity/Hashtag.php b/src/Entity/Hashtag.php new file mode 100644 index 000000000..64b4756c4 --- /dev/null +++ b/src/Entity/Hashtag.php @@ -0,0 +1,30 @@ + false])] + public bool $banned = false; + + #[OneToMany(mappedBy: 'hashtag', targetEntity: HashtagLink::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] + public Collection $linkedPosts; +} diff --git a/src/Entity/HashtagLink.php b/src/Entity/HashtagLink.php new file mode 100644 index 000000000..2df61a73a --- /dev/null +++ b/src/Entity/HashtagLink.php @@ -0,0 +1,41 @@ + true])] - public ?array $tags = null; - #[Column(type: 'json', nullable: true, options: ['jsonb' => true])] public ?array $mentions = null; #[OneToMany(mappedBy: 'post', targetEntity: PostComment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $comments; @@ -96,6 +93,8 @@ class Post implements VotableInterface, CommentInterface, VisibilityInterface, R public Collection $favourites; #[OneToMany(mappedBy: 'post', targetEntity: PostCreatedNotification::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $notifications; + #[OneToMany(mappedBy: 'post', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] + public Collection $hashtags; public array $children = []; #[Id] #[GeneratedValue] @@ -352,11 +351,6 @@ public function isAdult(): bool return $this->isAdult || $this->magazine->isAdult; } - public function getTags(): array - { - return array_values($this->tags ?? []); - } - public function __sleep() { return []; diff --git a/src/Entity/PostComment.php b/src/Entity/PostComment.php index 05d078044..bd3e92a6a 100644 --- a/src/Entity/PostComment.php +++ b/src/Entity/PostComment.php @@ -8,7 +8,6 @@ use App\Entity\Contracts\ContentInterface; use App\Entity\Contracts\FavouriteInterface; use App\Entity\Contracts\ReportInterface; -use App\Entity\Contracts\TagInterface; use App\Entity\Contracts\VisibilityInterface; use App\Entity\Contracts\VotableInterface; use App\Entity\Traits\ActivityPubActivityTrait; @@ -36,7 +35,7 @@ #[Index(columns: ['last_active'], name: 'post_comment_last_active_at_idx')] #[Index(columns: ['created_at'], name: 'post_comment_created_at_idx')] #[Index(columns: ['body_ts'], name: 'post_comment_body_ts_idx')] -class PostComment implements VotableInterface, VisibilityInterface, ReportInterface, FavouriteInterface, TagInterface, ActivityPubActivityInterface +class PostComment implements VotableInterface, VisibilityInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface { use VotableTrait; use VisibilityTrait; @@ -75,8 +74,6 @@ class PostComment implements VotableInterface, VisibilityInterface, ReportInterf #[Column(type: 'string', nullable: true)] public ?string $ip = null; #[Column(type: 'json', nullable: true, options: ['jsonb' => true])] - public ?array $tags = null; - #[Column(type: 'json', nullable: true, options: ['jsonb' => true])] public ?array $mentions = null; #[Column(type: 'boolean', nullable: false)] public bool $isAdult = false; @@ -96,6 +93,8 @@ class PostComment implements VotableInterface, VisibilityInterface, ReportInterf public Collection $favourites; #[OneToMany(mappedBy: 'postComment', targetEntity: PostCommentCreatedNotification::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $notifications; + #[OneToMany(mappedBy: 'postComment', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] + public Collection $hashtags; #[Id] #[GeneratedValue] #[Column(type: 'integer')] diff --git a/src/EventSubscriber/Post/PostCreateSubscriber.php b/src/EventSubscriber/Post/PostCreateSubscriber.php index d940a8bae..5d4ee2a38 100644 --- a/src/EventSubscriber/Post/PostCreateSubscriber.php +++ b/src/EventSubscriber/Post/PostCreateSubscriber.php @@ -11,6 +11,7 @@ use App\Message\Notification\PostCreatedNotificationMessage; use App\Repository\MagazineRepository; use App\Repository\PostRepository; +use App\Repository\TagLinkRepository; use App\Service\PostManager; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -21,6 +22,7 @@ class PostCreateSubscriber implements EventSubscriberInterface public function __construct( private readonly MessageBusInterface $bus, private readonly MagazineRepository $magazineRepository, + private readonly TagLinkRepository $tagLinkRepository, private readonly PostRepository $postRepository, private readonly PostManager $postManager, private readonly EntityManagerInterface $entityManager @@ -54,11 +56,9 @@ public function onPostCreated(PostCreatedEvent $event): void private function handleMagazine(Post $post): void { - if (!$post->tags) { - return; - } + $tags = $this->tagLinkRepository->getTagsOfPost($post); - foreach ($post->tags as $tag) { + foreach ($tags as $tag) { if ($magazine = $this->magazineRepository->findOneByName($tag)) { $this->postManager->changeMagazine($post, $magazine); break; diff --git a/src/Exception/TagBannedException.php b/src/Exception/TagBannedException.php new file mode 100644 index 000000000..36ed71d06 --- /dev/null +++ b/src/Exception/TagBannedException.php @@ -0,0 +1,9 @@ + $this->pageFactory->create($activity, $context), - $activity instanceof EntryComment => $this->entryNoteFactory->create($activity, $context), - $activity instanceof Post => $this->postNoteFactory->create($activity, $context), - $activity instanceof PostComment => $this->postCommentNoteFactory->create($activity, $context), + $activity instanceof Entry => $this->pageFactory->create($activity, $this->tagLinkRepository->getTagsOfEntry($activity), $context), + $activity instanceof EntryComment => $this->entryNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfEntryComment($activity), $context), + $activity instanceof Post => $this->postNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfPost($activity), $context), + $activity instanceof PostComment => $this->postCommentNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfPostComment($activity), $context), default => throw new \LogicException(), }; } diff --git a/src/Factory/ActivityPub/EntryCommentNoteFactory.php b/src/Factory/ActivityPub/EntryCommentNoteFactory.php index 258c568cc..3db222f9f 100644 --- a/src/Factory/ActivityPub/EntryCommentNoteFactory.php +++ b/src/Factory/ActivityPub/EntryCommentNoteFactory.php @@ -34,13 +34,12 @@ public function __construct( ) { } - public function create(EntryComment $comment, bool $context = false): array + public function create(EntryComment $comment, array $tags, bool $context = false): array { if ($context) { $note['@context'] = $this->contextProvider->referencedContexts(); } - $tags = $comment->tags ?? []; if ('random' !== $comment->magazine->name && !$comment->magazine->apId) { // @todo $tags[] = $comment->magazine->name; } diff --git a/src/Factory/ActivityPub/EntryPageFactory.php b/src/Factory/ActivityPub/EntryPageFactory.php index 0cbd4dfb5..e2f6d42a2 100644 --- a/src/Factory/ActivityPub/EntryPageFactory.php +++ b/src/Factory/ActivityPub/EntryPageFactory.php @@ -33,13 +33,12 @@ public function __construct( ) { } - public function create(Entry $entry, bool $context = false): array + public function create(Entry $entry, array $tags, bool $context = false): array { if ($context) { $page['@context'] = $this->contextProvider->referencedContexts(); } - $tags = $entry->tags ?? []; if ('random' !== $entry->magazine->name && !$entry->magazine->apId) { // @todo $tags[] = $entry->magazine->name; } diff --git a/src/Factory/ActivityPub/PostCommentNoteFactory.php b/src/Factory/ActivityPub/PostCommentNoteFactory.php index a01ac695d..1bbe55225 100644 --- a/src/Factory/ActivityPub/PostCommentNoteFactory.php +++ b/src/Factory/ActivityPub/PostCommentNoteFactory.php @@ -34,13 +34,12 @@ public function __construct( ) { } - public function create(PostComment $comment, bool $context = false): array + public function create(PostComment $comment, array $tags, bool $context = false): array { if ($context) { $note['@context'] = $this->contextProvider->referencedContexts(); } - $tags = $comment->tags ?? []; if ('random' !== $comment->magazine->name && !$comment->magazine->apId) { // @todo $tags[] = $comment->magazine->name; } diff --git a/src/Factory/ActivityPub/PostNoteFactory.php b/src/Factory/ActivityPub/PostNoteFactory.php index dcf71f8f3..1c4f5362b 100644 --- a/src/Factory/ActivityPub/PostNoteFactory.php +++ b/src/Factory/ActivityPub/PostNoteFactory.php @@ -15,7 +15,7 @@ use App\Service\ActivityPub\Wrapper\TagsWrapper; use App\Service\ActivityPubManager; use App\Service\MentionManager; -use App\Service\TagManager; +use App\Service\TagExtractor; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class PostNoteFactory @@ -30,23 +30,22 @@ public function __construct( private readonly ApHttpClient $client, private readonly ActivityPubManager $activityPubManager, private readonly MentionManager $mentionManager, - private readonly TagManager $tagManager, + private readonly TagExtractor $tagExtractor, private readonly MarkdownConverter $markdownConverter ) { } - public function create(Post $post, bool $context = false): array + public function create(Post $post, array $tags, bool $context = false): array { if ($context) { $note['@context'] = $this->contextProvider->referencedContexts(); } - $tags = $post->tags ?? []; if ('random' !== $post->magazine->name && !$post->magazine->apId) { // @todo $tags[] = $post->magazine->name; } - $body = $this->tagManager->joinTagsToBody( + $body = $this->tagExtractor->joinTagsToBody( $post->body, $tags ); diff --git a/src/Factory/EntryCommentFactory.php b/src/Factory/EntryCommentFactory.php index 784ba5ef1..c8d7a66fb 100644 --- a/src/Factory/EntryCommentFactory.php +++ b/src/Factory/EntryCommentFactory.php @@ -8,6 +8,7 @@ use App\DTO\EntryCommentResponseDto; use App\Entity\EntryComment; use App\Entity\User; +use App\Repository\TagLinkRepository; use Symfony\Bundle\SecurityBundle\Security; class EntryCommentFactory @@ -17,6 +18,7 @@ public function __construct( private readonly ImageFactory $imageFactory, private readonly UserFactory $userFactory, private readonly MagazineFactory $magazineFactory, + private readonly TagLinkRepository $tagLinkRepository, ) { } @@ -31,7 +33,7 @@ public function createFromDto(EntryCommentDto $dto, User $user): EntryComment ); } - public function createResponseDto(EntryCommentDto|EntryComment $comment, int $childCount = 0): EntryCommentResponseDto + public function createResponseDto(EntryCommentDto|EntryComment $comment, array $tags, int $childCount = 0): EntryCommentResponseDto { $dto = $comment instanceof EntryComment ? $this->createDto($comment) : $comment; @@ -52,7 +54,7 @@ public function createResponseDto(EntryCommentDto|EntryComment $comment, int $ch $dto->visibility, $dto->apId, $dto->mentions, - $dto->tags, + $tags, $dto->createdAt, $dto->editedAt, $dto->lastActive, @@ -63,7 +65,7 @@ public function createResponseDto(EntryCommentDto|EntryComment $comment, int $ch public function createResponseTree(EntryComment $comment, int $depth = -1): EntryCommentResponseDto { $commentDto = $this->createDto($comment); - $toReturn = $this->createResponseDto($commentDto, array_reduce($comment->children->toArray(), EntryCommentResponseDto::class.'::recursiveChildCount', 0)); + $toReturn = $this->createResponseDto($commentDto, $this->tagLinkRepository->getTagsOfEntryComment($comment), array_reduce($comment->children->toArray(), EntryCommentResponseDto::class.'::recursiveChildCount', 0)); $toReturn->isFavourited = $commentDto->isFavourited; $toReturn->userVote = $commentDto->userVote; @@ -96,7 +98,6 @@ public function createDto(EntryComment $comment): EntryCommentDto $dto->dv = $comment->countDownVotes(); $dto->favouriteCount = $comment->favouriteCount; $dto->mentions = $comment->mentions; - $dto->tags = $comment->tags; $dto->createdAt = $comment->createdAt; $dto->editedAt = $comment->editedAt; $dto->lastActive = $comment->lastActive; diff --git a/src/Factory/EntryFactory.php b/src/Factory/EntryFactory.php index 96f5edfd7..b88e9dc8a 100644 --- a/src/Factory/EntryFactory.php +++ b/src/Factory/EntryFactory.php @@ -9,6 +9,7 @@ use App\Entity\Badge; use App\Entity\Entry; use App\Entity\User; +use App\Repository\TagLinkRepository; use Symfony\Bundle\SecurityBundle\Security; class EntryFactory @@ -20,6 +21,7 @@ public function __construct( private readonly MagazineFactory $magazineFactory, private readonly UserFactory $userFactory, private readonly BadgeFactory $badgeFactory, + private readonly TagLinkRepository $tagLinkRepository, ) { } @@ -38,7 +40,7 @@ public function createFromDto(EntryDto $dto, User $user): Entry ); } - public function createResponseDto(EntryDto|Entry $entry): EntryResponseDto + public function createResponseDto(EntryDto|Entry $entry, array $tags): EntryResponseDto { $dto = $entry instanceof Entry ? $this->createDto($entry) : $entry; $badges = $dto->badges ? array_map(fn (Badge $badge) => $this->badgeFactory->createDto($badge), $dto->badges->toArray()) : null; @@ -53,7 +55,7 @@ public function createResponseDto(EntryDto|Entry $entry): EntryResponseDto $dto->image, $dto->body, $dto->lang, - $dto->tags, + $tags, $badges, $dto->comments, $dto->uv, @@ -95,7 +97,6 @@ public function createDto(Entry $entry): EntryDto $dto->score = $entry->score; $dto->visibility = $entry->visibility; $dto->ip = $entry->ip; - $dto->tags = $entry->tags; $dto->createdAt = $entry->createdAt; $dto->editedAt = $entry->editedAt; $dto->lastActive = $entry->lastActive; @@ -103,6 +104,7 @@ public function createDto(Entry $entry): EntryDto $dto->isPinned = $entry->sticky; $dto->type = $entry->type; $dto->apId = $entry->apId; + $dto->tags = $this->tagLinkRepository->getTagsOfEntry($entry); $currentUser = $this->security->getUser(); // Only return the user's vote if permission to control voting has been given diff --git a/src/Factory/PostCommentFactory.php b/src/Factory/PostCommentFactory.php index 33d847256..81bc0bc8b 100644 --- a/src/Factory/PostCommentFactory.php +++ b/src/Factory/PostCommentFactory.php @@ -9,6 +9,7 @@ use App\Entity\PostComment; use App\Entity\User; use App\Repository\PostRepository; +use App\Repository\TagLinkRepository; use Symfony\Bundle\SecurityBundle\Security; class PostCommentFactory @@ -19,6 +20,7 @@ public function __construct( private readonly MagazineFactory $magazineFactory, private readonly ImageFactory $imageFactory, private readonly PostRepository $postRepository, + private readonly TagLinkRepository $tagLinkRepository, ) { } @@ -33,7 +35,7 @@ public function createFromDto(PostCommentDto $dto, User $user): PostComment ); } - public function createResponseDto(PostCommentDto|PostComment $comment, int $childCount = 0): PostCommentResponseDto + public function createResponseDto(PostCommentDto|PostComment $comment, array $tags, int $childCount = 0): PostCommentResponseDto { $dto = $comment instanceof PostComment ? $this->createDto($comment) : $comment; @@ -54,7 +56,7 @@ public function createResponseDto(PostCommentDto|PostComment $comment, int $chil $dto->visibility, $dto->apId, $dto->mentions, - $dto->tags, + $tags, $dto->createdAt, $dto->editedAt, $dto->lastActive @@ -64,7 +66,7 @@ public function createResponseDto(PostCommentDto|PostComment $comment, int $chil public function createResponseTree(PostComment $comment, int $depth): PostCommentResponseDto { $commentDto = $this->createDto($comment); - $toReturn = $this->createResponseDto($commentDto, array_reduce($comment->children->toArray(), PostCommentResponseDto::class.'::recursiveChildCount', 0)); + $toReturn = $this->createResponseDto($commentDto, $this->tagLinkRepository->getTagsOfPostComment($comment), array_reduce($comment->children->toArray(), PostCommentResponseDto::class.'::recursiveChildCount', 0)); $toReturn->isFavourited = $commentDto->isFavourited; $toReturn->userVote = $commentDto->userVote; @@ -101,7 +103,6 @@ public function createDto(PostComment $comment): PostCommentDto $dto->setId($comment->getId()); $dto->parent = $comment->parent; $dto->mentions = $comment->mentions; - $dto->tags = $comment->tags; $currentUser = $this->security->getUser(); // Only return the user's vote if permission to control voting has been given diff --git a/src/Factory/PostFactory.php b/src/Factory/PostFactory.php index ee61f9c20..6b189c8f5 100644 --- a/src/Factory/PostFactory.php +++ b/src/Factory/PostFactory.php @@ -31,7 +31,7 @@ public function createFromDto(PostDto $dto, User $user): Post ); } - public function createResponseDto(PostDto|Post $post): PostResponseDto + public function createResponseDto(PostDto|Post $post, array $tags): PostResponseDto { $dto = $post instanceof Post ? $this->createDto($post) : $post; @@ -49,7 +49,7 @@ public function createResponseDto(PostDto|Post $post): PostResponseDto $dto->dv, $dto->favouriteCount, $dto->visibility, - $dto->tags, + $tags, $dto->mentions, $dto->apId, $dto->createdAt, @@ -80,7 +80,6 @@ public function createDto(Post $post): PostDto $dto->editedAt = $post->editedAt; $dto->lastActive = $post->lastActive; $dto->ip = $post->ip; - $dto->tags = $post->tags; $dto->mentions = $post->mentions; $dto->apId = $post->apId; $dto->setId($post->getId()); diff --git a/src/Factory/ReportFactory.php b/src/Factory/ReportFactory.php index a87d50e4f..63c8d5f75 100644 --- a/src/Factory/ReportFactory.php +++ b/src/Factory/ReportFactory.php @@ -15,6 +15,7 @@ use App\Entity\PostCommentReport; use App\Entity\PostReport; use App\Entity\Report; +use App\Repository\TagLinkRepository; use Doctrine\ORM\EntityManagerInterface; class ReportFactory @@ -27,6 +28,7 @@ public function __construct( private readonly PostFactory $postFactory, private readonly EntryCommentFactory $entryCommentFactory, private readonly PostCommentFactory $postCommentFactory, + private readonly TagLinkRepository $tagLinkRepository, ) { } @@ -56,19 +58,19 @@ public function createResponseDto(Report $report): ReportResponseDto switch (\get_class($report)) { case EntryReport::class: \assert($subject instanceof Entry); - $toReturn->subject = $this->entryFactory->createResponseDto($subject); + $toReturn->subject = $this->entryFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfEntry($subject)); break; case EntryCommentReport::class: \assert($subject instanceof EntryComment); - $toReturn->subject = $this->entryCommentFactory->createResponseDto($subject); + $toReturn->subject = $this->entryCommentFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfEntryComment($subject)); break; case PostReport::class: \assert($subject instanceof Post); - $toReturn->subject = $this->postFactory->createResponseDto($subject); + $toReturn->subject = $this->postFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfPost($subject)); break; case PostCommentReport::class: \assert($subject instanceof PostComment); - $toReturn->subject = $this->postCommentFactory->createResponseDto($subject); + $toReturn->subject = $this->postCommentFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfPostComment($subject)); break; default: throw new \LogicException(); diff --git a/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php b/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php index 228a1093d..13eca0413 100644 --- a/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php @@ -8,6 +8,8 @@ use App\Entity\EntryComment; use App\Entity\Post; use App\Entity\PostComment; +use App\Exception\TagBannedException; +use App\Exception\UserBannedException; use App\Message\ActivityPub\Inbox\AnnounceMessage; use App\Message\ActivityPub\Inbox\ChainActivityMessage; use App\Message\ActivityPub\Inbox\DislikeMessage; @@ -111,6 +113,10 @@ private function retrieveObject(string $apUrl): Entry|EntryComment|Post|PostComm default: $this->logger->warning('Could not create an object from type {t} on {url}: {o}', ['t' => $object['type'], 'url' => $apUrl, 'o' => $object]); } + } catch (UserBannedException) { + $this->logger->error('the user is banned, url: {url}', ['url' => $apUrl]); + } catch (TagBannedException) { + $this->logger->error('one of the used tags is banned, url: {url}', ['url' => $apUrl]); } catch (\Exception $e) { $this->logger->error('There was an exception while getting {url}: {ex} - {m}. {o}', ['url' => $apUrl, 'ex' => \get_class($e), 'm' => $e->getMessage(), 'o' => $e]); } diff --git a/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php b/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php index da39c549e..5f3038329 100644 --- a/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/CreateHandler.php @@ -8,6 +8,8 @@ use App\Entity\EntryComment; use App\Entity\Post; use App\Entity\PostComment; +use App\Exception\TagBannedException; +use App\Exception\UserBannedException; use App\Message\ActivityPub\Inbox\ChainActivityMessage; use App\Message\ActivityPub\Inbox\CreateMessage; use App\Message\ActivityPub\Outbox\AnnounceMessage; @@ -32,25 +34,34 @@ public function __construct( ) { } - public function __invoke(CreateMessage $message) + /** + * @throws \Exception + */ + public function __invoke(CreateMessage $message): void { $this->object = $message->payload; $this->logger->debug('Got a CreateMessage of type {t}', [$message->payload['type'], $message->payload]); - if ('Note' === $this->object['type']) { - $this->handleChain(); - } + try { + if ('Note' === $this->object['type']) { + $this->handleChain(); + } - if ('Page' === $this->object['type']) { - $this->handlePage(); - } + if ('Page' === $this->object['type']) { + $this->handlePage(); + } - if ('Article' === $this->object['type']) { - $this->handlePage(); - } + if ('Article' === $this->object['type']) { + $this->handlePage(); + } - if ('Question' === $this->object['type']) { - $this->handleChain(); + if ('Question' === $this->object['type']) { + $this->handleChain(); + } + } catch (UserBannedException) { + $this->logger->info('Did not create the post, because the user is banned'); + } catch (TagBannedException) { + $this->logger->info('Did not create the post, because one of the used tags is banned'); } } @@ -75,6 +86,11 @@ private function handleChain(): void } } + /** + * @throws \Exception + * @throws UserBannedException + * @throws TagBannedException + */ private function handlePage(): void { $page = $this->page->create($this->object); diff --git a/src/Pagination/NativeQueryAdapter.php b/src/Pagination/NativeQueryAdapter.php new file mode 100644 index 000000000..959d47b60 --- /dev/null +++ b/src/Pagination/NativeQueryAdapter.php @@ -0,0 +1,62 @@ +numOfResults) { + $sql2 = 'SELECT COUNT(*) as cnt FROM ('.$sql.') sub'; + $stmt2 = $this->conn->prepare($sql2); + foreach ($this->parameters as $key => $value) { + $stmt2->bindValue($key, $value); + } + $result = $stmt2->executeQuery()->fetchAllAssociative(); + $this->numOfResults = $result[0]['cnt']; + } + + $this->statement = $this->conn->prepare($sql.' LIMIT :limit OFFSET :offset'); + foreach ($this->parameters as $key => $value) { + $this->statement->bindValue($key, $value); + } + } + + public function getNbResults(): int + { + return $this->numOfResults; + } + + public function getSlice(int $offset, int $length): iterable + { + $this->statement->bindValue('offset', $offset); + $this->statement->bindValue('limit', $length); + + return $this->transformer->transform($this->statement->executeQuery()->fetchAllAssociative()); + } +} diff --git a/src/Pagination/QueryAdapter.php b/src/Pagination/QueryAdapter.php index fba19495c..19d1c6c40 100644 --- a/src/Pagination/QueryAdapter.php +++ b/src/Pagination/QueryAdapter.php @@ -41,7 +41,7 @@ public function __construct( */ public function getNbResults(): int { - return \count($this->paginator); + return $this->paginator->count(); } /** diff --git a/src/Pagination/Transformation/ContentPopulationTransformer.php b/src/Pagination/Transformation/ContentPopulationTransformer.php new file mode 100644 index 000000000..522e98d97 --- /dev/null +++ b/src/Pagination/Transformation/ContentPopulationTransformer.php @@ -0,0 +1,47 @@ +entityManager->getRepository(Entry::class)->findBy( + ['id' => $this->getOverviewIds((array) $input, 'entry')] + ); + $entryComments = $this->entityManager->getRepository(EntryComment::class)->findBy( + ['id' => $this->getOverviewIds((array) $input, 'entry_comment')] + ); + $post = $this->entityManager->getRepository(Post::class)->findBy( + ['id' => $this->getOverviewIds((array) $input, 'post')] + ); + $postComment = $this->entityManager->getRepository(PostComment::class)->findBy( + ['id' => $this->getOverviewIds((array) $input, 'post_comment')] + ); + + $result = array_merge($entries, $entryComments, $post, $postComment); + uasort($result, fn ($a, $b) => $a->getCreatedAt() > $b->getCreatedAt() ? -1 : 1); + + return $result; + } + + private function getOverviewIds(array $result, string $type): array + { + $result = array_filter($result, fn ($subject) => $subject['type'] === $type); + + return array_map(fn ($subject) => $subject['id'], $result); + } +} diff --git a/src/Pagination/Transformation/ResultTransformer.php b/src/Pagination/Transformation/ResultTransformer.php new file mode 100644 index 000000000..56133ee5f --- /dev/null +++ b/src/Pagination/Transformation/ResultTransformer.php @@ -0,0 +1,10 @@ +addTimeClause($qb, $criteria); $this->filter($qb, $criteria); + $this->addBannedHashtagClause($qb); return $qb; } @@ -116,6 +117,18 @@ private function addTimeClause(QueryBuilder $qb, Criteria $criteria): void } } + private function addBannedHashtagClause(QueryBuilder $qb): void + { + $dql = $this->getEntityManager()->createQueryBuilder() + ->select('hl2') + ->from(HashtagLink::class, 'hl2') + ->join('hl2.hashtag', 'h2') + ->where('h2.banned = true') + ->andWhere('hl2.entryComment = c') + ->getDQL(); + $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql))); + } + private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder { $user = $this->security->getUser(); @@ -159,7 +172,10 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder } if ($criteria->tag) { - $qb->andWhere("JSONB_CONTAINS(c.tags, '\"".$criteria->tag."\"') = true"); + $qb->andWhere('t.tag = :tag') + ->join('c.hashtags', 'h') + ->join('h.hashtag', 't') + ->setParameter('tag', $criteria->tag); } if ($criteria->subscribed) { @@ -327,12 +343,4 @@ public function findToDelete(User $user, int $limit): array ->getQuery() ->getResult(); } - - public function findWithTags(): array - { - return $this->createQueryBuilder('c') - ->where('c.tags IS NOT NULL') - ->getQuery() - ->getResult(); - } } diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php index 55f330582..7fba4188c 100644 --- a/src/Repository/EntryRepository.php +++ b/src/Repository/EntryRepository.php @@ -13,6 +13,7 @@ use App\Entity\DomainSubscription; use App\Entity\Entry; use App\Entity\EntryFavourite; +use App\Entity\HashtagLink; use App\Entity\Magazine; use App\Entity\MagazineBlock; use App\Entity\MagazineSubscription; @@ -22,7 +23,6 @@ use App\Entity\UserFollow; use App\PageView\EntryPageView; use App\Pagination\AdapterFactory; -use App\Repository\Contract\TagRepositoryInterface; use App\Service\SettingsManager; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; @@ -44,7 +44,7 @@ * @method Entry[] findAll() * @method Entry[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ -class EntryRepository extends ServiceEntityRepository implements TagRepositoryInterface +class EntryRepository extends ServiceEntityRepository { public const SORT_DEFAULT = 'hot'; public const TIME_DEFAULT = Criteria::TIME_ALL; @@ -107,6 +107,7 @@ private function getEntryQueryBuilder(EntryPageView $criteria): QueryBuilder $this->addTimeClause($qb, $criteria); $this->addStickyClause($qb, $criteria); $this->filter($qb, $criteria); + $this->addBannedHashtagClause($qb); return $qb; } @@ -132,6 +133,22 @@ private function addStickyClause(QueryBuilder $qb, EntryPageView $criteria): voi } } + private function addBannedHashtagClause(QueryBuilder $qb): void + { + $dql = $this->getEntityManager()->createQueryBuilder() + ->select('hl2') + ->from(HashtagLink::class, 'hl2') + ->join('hl2.hashtag', 'h2') + ->where('h2.banned = true') + ->andWhere('hl2.entry = e') + ->getDQL(); + $qb->andWhere( + $qb->expr()->not( + $qb->expr()->exists($dql) + ) + ); + } + private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder { $user = $this->security->getUser(); @@ -156,7 +173,10 @@ private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder } if ($criteria->tag) { - $qb->andWhere("JSONB_CONTAINS(e.tags, '\"".$criteria->tag."\"') = true"); + $qb->andWhere('t.tag = :tag') + ->join('e.hashtags', 'h') + ->join('h.hashtag', 't') + ->setParameter('tag', $criteria->tag); } if ($criteria->domain) { @@ -324,17 +344,22 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array $qb = $this->createQueryBuilder('e'); return $qb - ->andWhere("JSONB_CONTAINS(e.tags, '\"".$tag."\"') = true") ->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') ->andWhere('u.isDeleted = false') ->andWhere('m.isAdult = false') ->andWhere('e.isAdult = false') + ->andWhere('h.tag = :tag') ->join('e.magazine', 'm') ->join('e.user', 'u') + ->join('e.hashtags', 'hl') + ->join('hl.hashtag', 'h') ->orderBy('e.createdAt', 'DESC') - ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE]) + ->setParameters([ + 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, + 'tag' => $tag, + ]) ->setMaxResults($limit) ->getQuery() ->getResult(); @@ -385,14 +410,6 @@ public function findLast(int $limit): array ->getResult(); } - public function findWithTags(): array - { - return $this->createQueryBuilder('e') - ->where('e.tags IS NOT NULL') - ->getQuery() - ->getResult(); - } - private function countAll(EntryPageView|Criteria $criteria): int { return $this->cache->get( diff --git a/src/Repository/PostCommentRepository.php b/src/Repository/PostCommentRepository.php index 8e2b41499..f33e8801b 100644 --- a/src/Repository/PostCommentRepository.php +++ b/src/Repository/PostCommentRepository.php @@ -9,12 +9,12 @@ namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; +use App\Entity\HashtagLink; use App\Entity\PostComment; use App\Entity\User; use App\Entity\UserBlock; use App\Entity\UserFollow; use App\PageView\PostCommentPageView; -use App\Repository\Contract\TagRepositoryInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; @@ -33,7 +33,7 @@ * @method PostComment[] findAll() * @method PostComment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ -class PostCommentRepository extends ServiceEntityRepository implements TagRepositoryInterface +class PostCommentRepository extends ServiceEntityRepository { public const PER_PAGE = 15; @@ -100,6 +100,7 @@ private function getCommentQueryBuilder(Criteria $criteria): QueryBuilder $this->addTimeClause($qb, $criteria); $this->filter($qb, $criteria); + $this->addBannedHashtagClause($qb); return $qb; } @@ -114,6 +115,18 @@ private function addTimeClause(QueryBuilder $qb, Criteria $criteria): void } } + private function addBannedHashtagClause(QueryBuilder $qb): void + { + $dql = $this->getEntityManager()->createQueryBuilder() + ->select('hl2') + ->from(HashtagLink::class, 'hl2') + ->join('hl2.hashtag', 'h2') + ->where('h2.banned = true') + ->andWhere('hl2.postComment = c') + ->getDQL(); + $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql))); + } + private function filter(QueryBuilder $qb, Criteria $criteria) { if ($criteria->post) { @@ -136,6 +149,13 @@ private function filter(QueryBuilder $qb, Criteria $criteria) ->setParameter('user', $criteria->user); } + if ($criteria->tag) { + $qb->andWhere('t.tag = :tag') + ->join('p.hashtags', 'h') + ->join('h.hashtag', 't') + ->setParameter('tag', $criteria->tag); + } + $user = $this->security->getUser(); if ($user && !$criteria->moderated) { $qb->andWhere( @@ -224,12 +244,4 @@ public function findFederated() ->getQuery() ->getResult(); } - - public function findWithTags(): array - { - return $this->createQueryBuilder('c') - ->where('c.tags IS NOT NULL') - ->getQuery() - ->getResult(); - } } diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php index 0ff2ae80c..870b20490 100644 --- a/src/Repository/PostRepository.php +++ b/src/Repository/PostRepository.php @@ -9,6 +9,7 @@ namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; +use App\Entity\HashtagLink; use App\Entity\Magazine; use App\Entity\MagazineBlock; use App\Entity\MagazineSubscription; @@ -21,7 +22,6 @@ use App\PageView\EntryPageView; use App\PageView\PostPageView; use App\Pagination\AdapterFactory; -use App\Repository\Contract\TagRepositoryInterface; use App\Service\SettingsManager; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; @@ -43,7 +43,7 @@ * @method Post[] findAll() * @method Post[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ -class PostRepository extends ServiceEntityRepository implements TagRepositoryInterface +class PostRepository extends ServiceEntityRepository { public const PER_PAGE = 15; public const SORT_DEFAULT = 'hot'; @@ -89,7 +89,7 @@ private function getEntryQueryBuilder(PostPageView $criteria): QueryBuilder if ($user && VisibilityInterface::VISIBILITY_VISIBLE === $criteria->visibility) { $qb->orWhere( - 'p.user IN (SELECT IDENTITY(puf.following) FROM '.UserFollow::class.' puf WHERE puf.follower = :puf_user AND p.visibility = :puf_visibility)' + 'EXISTS (SELECT IDENTITY(puf.following) FROM '.UserFollow::class.' puf WHERE puf.follower = :puf_user AND p.visibility = :puf_visibility AND puf.following = p.user)' ) ->setParameter('puf_user', $user) ->setParameter('puf_visibility', VisibilityInterface::VISIBILITY_PRIVATE); @@ -103,6 +103,7 @@ private function getEntryQueryBuilder(PostPageView $criteria): QueryBuilder $this->addTimeClause($qb, $criteria); $this->addStickyClause($qb, $criteria); $this->filter($qb, $criteria); + $this->addBannedHashtagClause($qb); return $qb; } @@ -128,6 +129,18 @@ private function addStickyClause(QueryBuilder $qb, PostPageView $criteria): void } } + private function addBannedHashtagClause(QueryBuilder $qb): void + { + $dql = $this->getEntityManager()->createQueryBuilder() + ->select('hl2') + ->from(HashtagLink::class, 'hl2') + ->join('hl2.hashtag', 'h2') + ->where('h2.banned = true') + ->andWhere('hl2.post = p') + ->getDQL(); + $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql))); + } + private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder { $user = $this->security->getUser(); @@ -147,14 +160,17 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder } if ($criteria->tag) { - $qb->andWhere("JSONB_CONTAINS(p.tags, '\"".$criteria->tag."\"') = true"); + $qb->andWhere('t.tag = :tag') + ->join('p.hashtags', 'h') + ->join('h.hashtag', 't') + ->setParameter('tag', $criteria->tag); } if ($criteria->subscribed) { $qb->andWhere( - 'p.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user) + 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) OR - p.user IN (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user) + EXISTS (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user AND uf.following = p.user) OR p.user = :user' ); @@ -163,14 +179,14 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder if ($criteria->moderated) { $qb->andWhere( - 'p.magazine IN (SELECT IDENTITY(mm.magazine) FROM '.Moderator::class.' mm WHERE mm.user = :user)' + 'EXISTS (SELECT IDENTITY(mm.magazine) FROM '.Moderator::class.' mm WHERE mm.user = :user AND mm.magazine = p.magazine)' ); $qb->setParameter('user', $this->security->getUser()); } if ($criteria->favourite) { $qb->andWhere( - 'p.id IN (SELECT IDENTITY(pf.post) FROM '.PostFavourite::class.' pf WHERE pf.user = :user)' + 'EXISTS (SELECT IDENTITY(pf.post) FROM '.PostFavourite::class.' pf WHERE pf.user = :user AND pf.post = p)' ); $qb->setParameter('user', $this->security->getUser()); } @@ -182,12 +198,12 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) { $qb->andWhere( - 'p.user NOT IN (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker)' + 'NOT EXISTS (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker AND ub.blocked = p.user)' ); $qb->setParameter('blocker', $user); $qb->andWhere( - 'p.magazine NOT IN (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :magazineBlocker)' + 'NOT EXISTS (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :magazineBlocker AND mb.magazine = p.magazine)' ); $qb->setParameter('magazineBlocker', $user); } @@ -296,17 +312,22 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array $qb = $this->createQueryBuilder('p'); return $qb - ->andWhere("JSONB_CONTAINS(p.tags, '\"".$tag."\"') = true") ->andWhere('p.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') ->andWhere('m.name != :name') ->andWhere('p.isAdult = false') ->andWhere('m.isAdult = false') + ->andWhere('h.tag = :name') ->join('p.magazine', 'm') ->join('p.user', 'u') + ->join('p.hashtags', 'hl') + ->join('hl.hashtag', 'h') ->orderBy('p.createdAt', 'DESC') - ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'name' => $tag]) + ->setParameters([ + 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, + 'name' => $tag, + ]) ->setMaxResults($limit) ->getQuery() ->getResult(); @@ -406,14 +427,6 @@ public function findUsers(Magazine $magazine, ?bool $federated = false): array ->getResult(); } - public function findWithTags(): array - { - return $this->createQueryBuilder('p') - ->where('p.tags IS NOT NULL') - ->getQuery() - ->getResult(); - } - private function countAll(EntryPageView|Criteria $criteria): int { return $this->cache->get( diff --git a/src/Repository/SearchRepository.php b/src/Repository/SearchRepository.php index a09e0d704..a5d89e5eb 100644 --- a/src/Repository/SearchRepository.php +++ b/src/Repository/SearchRepository.php @@ -5,26 +5,23 @@ namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; -use App\Entity\Entry; -use App\Entity\EntryComment; use App\Entity\Magazine; use App\Entity\Moderator; -use App\Entity\Post; -use App\Entity\PostComment; use App\Entity\User; +use App\Pagination\NativeQueryAdapter; +use App\Pagination\Transformation\ContentPopulationTransformer; use Doctrine\ORM\EntityManagerInterface; -use Pagerfanta\Adapter\ArrayAdapter; -use Pagerfanta\Exception\NotValidCurrentPageException; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class SearchRepository { public const PER_PAGE = 25; - public function __construct(private EntityManagerInterface $entityManager) - { + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly ContentPopulationTransformer $transformer, + ) { } public function countModerated(User $user): int @@ -42,220 +39,119 @@ public function countModerated(User $user): int public function countBoosts(User $user): int { - // @todo union adapter $conn = $this->entityManager->getConnection(); - $sql = " - (SELECT entry_id as id, created_at, 'entry' AS type FROM entry_vote WHERE user_id = :userId AND choice = 1) - UNION - (SELECT comment_id as id, created_at, 'entry_comment' AS type FROM entry_comment_vote WHERE user_id = :userId AND choice = 1) - UNION - (SELECT post_id as id, created_at, 'post' AS type FROM post_vote WHERE user_id = :userId AND choice = 1) - UNION - (SELECT comment_id as id, created_at, 'post_comment' AS type FROM post_comment_vote WHERE user_id = :userId AND choice = 1) - ORDER BY created_at DESC"; + $sql = "SELECT COUNT(*) as cnt FROM ( + SELECT entry_id as id, created_at, 'entry' AS type FROM entry_vote WHERE user_id = :userId AND choice = 1 + UNION ALL + SELECT comment_id as id, created_at, 'entry_comment' AS type FROM entry_comment_vote WHERE user_id = :userId AND choice = 1 + UNION ALL + SELECT post_id as id, created_at, 'post' AS type FROM post_vote WHERE user_id = :userId AND choice = 1 + UNION ALL + SELECT comment_id as id, created_at, 'post_comment' AS type FROM post_comment_vote WHERE user_id = :userId AND choice = 1 + ) sub"; $stmt = $conn->prepare($sql); $stmt->bindValue('userId', $user->getId()); $stmt = $stmt->executeQuery(); - return $stmt->rowCount(); + return $stmt->fetchAllAssociative()[0]['cnt']; } public function findBoosts(int $page, User $user): PagerfantaInterface { - // @todo union adapter $conn = $this->entityManager->getConnection(); $sql = " - (SELECT entry_id as id, created_at, 'entry' AS type FROM entry_vote WHERE user_id = :userId AND choice = 1) - UNION - (SELECT comment_id as id, created_at, 'entry_comment' AS type FROM entry_comment_vote WHERE user_id = :userId AND choice = 1) - UNION - (SELECT post_id as id, created_at, 'post' AS type FROM post_vote WHERE user_id = :userId AND choice = 1) - UNION - (SELECT comment_id as id, created_at, 'post_comment' AS type FROM post_comment_vote WHERE user_id = :userId AND choice = 1) + SELECT entry_id as id, created_at, 'entry' AS type FROM entry_vote WHERE user_id = :userId AND choice = 1 + UNION ALL + SELECT comment_id as id, created_at, 'entry_comment' AS type FROM entry_comment_vote WHERE user_id = :userId AND choice = 1 + UNION ALL + SELECT post_id as id, created_at, 'post' AS type FROM post_vote WHERE user_id = :userId AND choice = 1 + UNION ALL + SELECT comment_id as id, created_at, 'post_comment' AS type FROM post_comment_vote WHERE user_id = :userId AND choice = 1 ORDER BY created_at DESC"; - $stmt = $conn->prepare($sql); - $stmt->bindValue('userId', $user->getId()); - $stmt = $stmt->executeQuery(); - $stmt->rowCount(); - $pagerfanta = new Pagerfanta( - new ArrayAdapter( - $stmt->fetchAllAssociative() - ) - ); + $pagerfanta = new Pagerfanta(new NativeQueryAdapter($conn, $sql, [ + 'userId' => $user->getId(), + ], transformer: $this->transformer)); - $countAll = $pagerfanta->count(); + $pagerfanta->setCurrentPage($page); - try { - $pagerfanta->setMaxPerPage(2000); - $pagerfanta->setCurrentPage(1); - } catch (NotValidCurrentPageException $e) { - throw new NotFoundHttpException(); - } - - $result = $pagerfanta->getCurrentPageResults(); - - return $this->buildResult($result, $page, $countAll); + return $pagerfanta; } public function search(?User $searchingUser, string $query, int $page = 1): PagerfantaInterface { - // @todo union adapter $conn = $this->entityManager->getConnection(); $sql = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e - INNER JOIN public.user u ON u.id = user_id + INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id - WHERE body_ts @@ plainto_tsquery( :query ) = true OR title_ts @@ plainto_tsquery( :query ) = true AND e.visibility = :visibility AND u.is_deleted = false + WHERE (body_ts @@ plainto_tsquery( :query ) = true OR title_ts @@ plainto_tsquery( :query ) = true) + AND e.visibility = :visibility + AND u.is_deleted = false AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) - AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) + AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) + AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_id = e.id) UNION ALL SELECT e.id, e.created_at, e.visibility, 'entry_comment' AS type FROM entry_comment e - INNER JOIN public.user u ON u.id = user_id + INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id - WHERE body_ts @@ plainto_tsquery( :query ) = true AND e.visibility = :visibility AND u.is_deleted = false + WHERE body_ts @@ plainto_tsquery( :query ) = true + AND e.visibility = :visibility + AND u.is_deleted = false AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) - AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) + AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) + AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_comment_id = e.id) UNION ALL SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e - INNER JOIN public.user u ON u.id = user_id + INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id - WHERE body_ts @@ plainto_tsquery( :query ) = true AND e.visibility = :visibility AND u.is_deleted = false + WHERE body_ts @@ plainto_tsquery( :query ) = true + AND e.visibility = :visibility + AND u.is_deleted = false AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) - AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) + AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) + AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_id = e.id) UNION ALL SELECT e.id, e.created_at, e.visibility, 'post_comment' AS type FROM post_comment e - INNER JOIN public.user u ON u.id = user_id + INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id - WHERE body_ts @@ plainto_tsquery( :query ) = true AND e.visibility = :visibility AND u.is_deleted = false + WHERE body_ts @@ plainto_tsquery( :query ) = true + AND e.visibility = :visibility + AND u.is_deleted = false AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) - AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) + AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) + AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_comment_id = e.id) ORDER BY created_at DESC"; - $stmt = $conn->prepare($sql); - $stmt->bindValue('query', $query); - $stmt->bindValue('visibility', VisibilityInterface::VISIBILITY_VISIBLE); - $stmt->bindValue('queryingUser', $searchingUser?->getId() ?? -1); - $stmt = $stmt->executeQuery(); - - $pagerfanta = new Pagerfanta( - new ArrayAdapter( - $stmt->fetchAllAssociative() - ) - ); - - $countAll = $pagerfanta->count(); - - try { - $pagerfanta->setMaxPerPage(20000); - $pagerfanta->setCurrentPage(1); - } catch (NotValidCurrentPageException $e) { - throw new NotFoundHttpException(); - } + $adapter = new NativeQueryAdapter($conn, $sql, [ + 'query' => $query, + 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, + 'queryingUser' => $searchingUser?->getId() ?? -1, + ], transformer: $this->transformer); - $result = $pagerfanta->getCurrentPageResults(); + $pagerfanta = new Pagerfanta($adapter); + $pagerfanta->setCurrentPage($page); - return $this->buildResult($result, $page, $countAll); + return $pagerfanta; } public function findByApId($url): array { - // @todo union adapter $conn = $this->entityManager->getConnection(); $sql = " - (SELECT id, created_at, 'entry' AS type FROM entry WHERE ap_id ILIKE :url) - UNION - (SELECT id, created_at, 'entry_comment' AS type FROM entry_comment WHERE ap_id ILIKE :url) - UNION - (SELECT id, created_at, 'post' AS type FROM post WHERE ap_id ILIKE :url) - UNION - (SELECT id, created_at, 'post_comment' AS type FROM post_comment WHERE ap_id ILIKE :url) + SELECT id, created_at, 'entry' AS type FROM entry WHERE ap_id ILIKE :url + UNION ALL + SELECT id, created_at, 'entry_comment' AS type FROM entry_comment WHERE ap_id ILIKE :url + UNION ALL + SELECT id, created_at, 'post' AS type FROM post WHERE ap_id ILIKE :url + UNION ALL + SELECT id, created_at, 'post_comment' AS type FROM post_comment WHERE ap_id ILIKE :url ORDER BY created_at DESC "; - $stmt = $conn->prepare($sql); - $stmt->bindValue('url', '%'.$url.'%'); - $stmt = $stmt->executeQuery(); - - $pagerfanta = new Pagerfanta( - new ArrayAdapter( - $stmt->fetchAllAssociative() - ) - ); - - $countAll = $pagerfanta->count(); - - try { - $pagerfanta->setMaxPerPage(1); - $pagerfanta->setCurrentPage(1); - } catch (NotValidCurrentPageException $e) { - throw new NotFoundHttpException(); - } - $result = $pagerfanta->getCurrentPageResults(); + $pagerfanta = new Pagerfanta(new NativeQueryAdapter($conn, $sql, [ + 'url' => "%$url%", + ], transformer: $this->transformer)); - $objects = []; - if ($this->getOverviewIds((array) $result, 'entry')) { - $objects = $this->entityManager->getRepository(Entry::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'entry')] - ); - } - if ($this->getOverviewIds((array) $result, 'entry_comment')) { - $objects = $this->entityManager->getRepository(EntryComment::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'entry_comment')] - ); - } - if ($this->getOverviewIds((array) $result, 'post')) { - $objects = $this->entityManager->getRepository(Post::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'post')] - ); - } - if ($this->getOverviewIds((array) $result, 'post_comment')) { - $objects = $this->entityManager->getRepository(Post::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'post_comment')] - ); - } - - return $objects ?? []; - } - - private function getOverviewIds(array $result, string $type): array - { - $result = array_filter($result, fn ($subject) => $subject['type'] === $type); - - return array_map(fn ($subject) => $subject['id'], $result); - } - - private function buildResult(array $result, $page, $countAll) - { - $entries = $this->entityManager->getRepository(Entry::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'entry')] - ); - $entryComments = $this->entityManager->getRepository(EntryComment::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'entry_comment')] - ); - $post = $this->entityManager->getRepository(Post::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'post')] - ); - $postComment = $this->entityManager->getRepository(PostComment::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'post_comment')] - ); - - $result = array_merge($entries, $entryComments, $post, $postComment); - uasort($result, fn ($a, $b) => $a->getCreatedAt() > $b->getCreatedAt() ? -1 : 1); - - $pagerfanta = new Pagerfanta( - new ArrayAdapter( - $result - ) - ); - - try { - $pagerfanta->setMaxPerPage(self::PER_PAGE); - $pagerfanta->setCurrentPage($page); - $pagerfanta->setMaxNbPages($countAll > 0 ? ((int) ceil($countAll / self::PER_PAGE)) : 1); - } catch (NotValidCurrentPageException $e) { - throw new NotFoundHttpException(); - } - - return $pagerfanta; + return $pagerfanta->getCurrentPageResults(); } } diff --git a/src/Repository/TagLinkRepository.php b/src/Repository/TagLinkRepository.php new file mode 100644 index 000000000..fbb7bc828 --- /dev/null +++ b/src/Repository/TagLinkRepository.php @@ -0,0 +1,140 @@ +findBy(['entry' => $entry]); + + return array_map(fn ($row) => $row->hashtag->tag, $result); + } + + public function removeTagOfEntry(Entry $entry, Hashtag $tag): void + { + $link = $this->findOneBy(['entry' => $entry, 'hashtag' => $tag]); + $this->entityManager->remove($link); + $this->entityManager->flush(); + } + + public function addTagToEntry(Entry $entry, Hashtag $tag): void + { + $link = new HashtagLink(); + $link->entry = $entry; + $link->hashtag = $tag; + $this->entityManager->persist($link); + $this->entityManager->flush(); + } + + public function entryHasTag(Entry $entry, Hashtag $tag): bool + { + return null !== $this->findOneBy(['entry' => $entry, 'hashtag' => $tag]); + } + + /** + * @return string[] + */ + public function getTagsOfEntryComment(EntryComment $entryComment): array + { + $result = $this->findBy(['entryComment' => $entryComment]); + + return array_map(fn ($row) => $row->hashtag->tag, $result); + } + + public function removeTagOfEntryComment(EntryComment $entryComment, Hashtag $tag): void + { + $link = $this->findOneBy(['entryComment' => $entryComment, 'hashtag' => $tag]); + $this->entityManager->remove($link); + $this->entityManager->flush(); + } + + public function addTagToEntryComment(EntryComment $entryComment, Hashtag $tag): void + { + $link = new HashtagLink(); + $link->entryComment = $entryComment; + $link->hashtag = $tag; + $this->entityManager->persist($link); + $this->entityManager->flush(); + } + + /** + * @return string[] + */ + public function getTagsOfPost(Post $post): array + { + $result = $this->findBy(['post' => $post]); + + return array_map(fn ($row) => $row->hashtag->tag, $result); + } + + public function removeTagOfPost(Post $post, Hashtag $tag): void + { + $link = $this->findOneBy(['post' => $post, 'hashtag' => $tag]); + $this->entityManager->remove($link); + $this->entityManager->flush(); + } + + public function addTagToPost(Post $post, Hashtag $tag): void + { + $link = new HashtagLink(); + $link->post = $post; + $link->hashtag = $tag; + $this->entityManager->persist($link); + $this->entityManager->flush(); + } + + /** + * @return string[] + */ + public function getTagsOfPostComment(PostComment $postComment): array + { + $result = $this->findBy(['postComment' => $postComment]); + + return array_map(fn ($row) => $row->hashtag->tag, $result); + } + + public function removeTagOfPostComment(PostComment $postComment, Hashtag $tag): void + { + $link = $this->findOneBy(['postComment' => $postComment, 'hashtag' => $tag]); + $this->entityManager->remove($link); + $this->entityManager->flush(); + } + + public function addTagToPostComment(PostComment $postComment, Hashtag $tag): void + { + $link = new HashtagLink(); + $link->postComment = $postComment; + $link->hashtag = $tag; + $this->entityManager->persist($link); + $this->entityManager->flush(); + } +} diff --git a/src/Repository/TagRepository.php b/src/Repository/TagRepository.php index f133f1abd..a59d2b926 100644 --- a/src/Repository/TagRepository.php +++ b/src/Repository/TagRepository.php @@ -5,86 +5,78 @@ namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; -use App\Entity\Entry; -use App\Entity\EntryComment; -use App\Entity\Post; -use App\Entity\PostComment; +use App\Entity\Hashtag; +use App\Pagination\NativeQueryAdapter; +use App\Pagination\Transformation\ContentPopulationTransformer; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\EntityManagerInterface; -use Pagerfanta\Adapter\ArrayAdapter; +use Doctrine\Persistence\ManagerRegistry; +use JetBrains\PhpStorm\ArrayShape; use Pagerfanta\Exception\NotValidCurrentPageException; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -class TagRepository +/** + * @method Hashtag|null find($id, $lockMode = null, $lockVersion = null) + * @method Hashtag|null findOneBy(array $criteria, array $orderBy = null) + * @method Hashtag[] findAll() + * @method Hashtag[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class TagRepository extends ServiceEntityRepository { public const PER_PAGE = 25; - public function __construct(private EntityManagerInterface $entityManager) - { + public function __construct( + ManagerRegistry $registry, + private readonly EntityManagerInterface $entityManager, + private readonly TagLinkRepository $tagLinkRepository, + private readonly ContentPopulationTransformer $populationTransformer + ) { + parent::__construct($registry, Hashtag::class); } public function findOverall(int $page, string $tag): PagerfantaInterface { - // @todo union adapter + $hashtag = $this->findBy(['tag' => $tag]); + $countAll = $this->tagLinkRepository->createQueryBuilder('link') + ->select('count(link.id)') + ->where('link.hashtag = :tag') + ->setParameter(':tag', $hashtag) + ->getQuery() + ->getSingleScalarResult(); + $conn = $this->entityManager->getConnection(); - $sql = " - (SELECT id, created_at, 'entry' AS type FROM entry WHERE tags @> :tag = true AND visibility = :visibility) - UNION - (SELECT id, created_at, 'entry_comment' AS type FROM entry_comment WHERE tags @> :tag = true AND visibility = :visibility) - UNION - (SELECT id, created_at, 'post' AS type FROM post WHERE tags @> :tag = true AND visibility = :visibility) - UNION - (SELECT id, created_at, 'post_comment' AS type FROM post_comment WHERE tags @> :tag = true AND visibility = :visibility) + $sql = "SELECT e.id, e.created_at, 'entry' AS type FROM entry e + INNER JOIN hashtag_link l ON e.id = l.entry_id + INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag + WHERE visibility = :visibility + UNION ALL + SELECT ec.id, ec.created_at, 'entry_comment' AS type FROM entry_comment ec + INNER JOIN hashtag_link l ON ec.id = l.entry_comment_id + INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag + WHERE visibility = :visibility + UNION ALL + SELECT p.id, p.created_at, 'post' AS type FROM post p + INNER JOIN hashtag_link l ON p.id = l.post_id + INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag + WHERE visibility = :visibility + UNION ALL + SELECT pc.id, created_at, 'post_comment' AS type FROM post_comment pc + INNER JOIN hashtag_link l ON pc.id = l.post_comment_id + INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag WHERE visibility = :visibility ORDER BY created_at DESC"; - $stmt = $conn->prepare($sql); - $stmt->bindValue('tag', "\"$tag\""); - $stmt->bindValue('visibility', VisibilityInterface::VISIBILITY_VISIBLE); - $stmt = $stmt->executeQuery(); - $pagerfanta = new Pagerfanta( - new ArrayAdapter( - $stmt->fetchAllAssociative() - ) - ); + $adapter = new NativeQueryAdapter($conn, $sql, [ + 'tag' => $tag, + 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, + ], $countAll, $this->populationTransformer); - $countAll = $pagerfanta->count(); - - try { - $pagerfanta->setMaxPerPage(20000); - $pagerfanta->setCurrentPage(1); - } catch (NotValidCurrentPageException $e) { - throw new NotFoundHttpException(); - } - - $result = $pagerfanta->getCurrentPageResults(); - - $entries = $this->entityManager->getRepository(Entry::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'entry')] - ); - $entryComments = $this->entityManager->getRepository(EntryComment::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'entry_comment')] - ); - $post = $this->entityManager->getRepository(Post::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'post')] - ); - $postComment = $this->entityManager->getRepository(PostComment::class)->findBy( - ['id' => $this->getOverviewIds((array) $result, 'post_comment')] - ); - - $result = array_merge($entries, $entryComments, $post, $postComment); - uasort($result, fn ($a, $b) => $a->getCreatedAt() > $b->getCreatedAt() ? -1 : 1); - - $pagerfanta = new Pagerfanta( - new ArrayAdapter( - $result - ) - ); + $pagerfanta = new Pagerfanta($adapter); try { $pagerfanta->setMaxPerPage(self::PER_PAGE); $pagerfanta->setCurrentPage($page); - $pagerfanta->setMaxNbPages($countAll > 0 ? ((int) ceil($countAll / self::PER_PAGE)) : 1); } catch (NotValidCurrentPageException $e) { throw new NotFoundHttpException(); } @@ -92,10 +84,38 @@ public function findOverall(int $page, string $tag): PagerfantaInterface return $pagerfanta; } - private function getOverviewIds(array $result, string $type): array + public function create(string $tag): Hashtag + { + $entity = new Hashtag(); + $entity->tag = $tag; + $this->entityManager->persist($entity); + $this->entityManager->flush(); + + return $entity; + } + + #[ArrayShape([ + 'entry' => 'int', + 'entry_comment' => 'int', + 'post' => 'int', + 'post_comment' => 'int', + ])] + public function getCounts(string $tag): ?array { - $result = array_filter($result, fn ($subject) => $subject['type'] === $type); + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare('SELECT COUNT(entry_id) as entry, COUNT(entry_comment_id) as entry_comment, COUNT(post_id) as post, COUNT(post_comment_id) as post_comment + FROM hashtag_link INNER JOIN public.hashtag h ON h.id = hashtag_link.hashtag_id AND h.tag = :tag GROUP BY h.tag'); + $stmt->bindValue('tag', $tag); + $result = $stmt->executeQuery()->fetchAllAssociative(); + if (1 === \sizeof($result)) { + return $result[0]; + } - return array_map(fn ($subject) => $subject['id'], $result); + return [ + 'entry' => 0, + 'entry_comment' => 0, + 'post' => 0, + 'post_comment' => 0, + ]; } } diff --git a/src/Service/ActivityPub/MarkdownConverter.php b/src/Service/ActivityPub/MarkdownConverter.php index 3cb3a9c0f..5bd477bba 100644 --- a/src/Service/ActivityPub/MarkdownConverter.php +++ b/src/Service/ActivityPub/MarkdownConverter.php @@ -6,14 +6,14 @@ use App\Service\ActivityPubManager; use App\Service\MentionManager; -use App\Service\TagManager; +use App\Service\TagExtractor; use League\HTMLToMarkdown\Converter\TableConverter; use League\HTMLToMarkdown\HtmlConverter; class MarkdownConverter { public function __construct( - private readonly TagManager $tagManager, + private readonly TagExtractor $tagExtractor, private readonly MentionManager $mentionManager, private readonly ActivityPubManager $activityPubManager ) { @@ -37,7 +37,7 @@ public function convert(string $value): string $value = str_replace($match[0], $replace, $value); } - if ($this->tagManager->extract($match[1])) { + if ($this->tagExtractor->extract($match[1])) { $value = str_replace($match[0], $match[1], $value); } } diff --git a/src/Service/ActivityPub/Note.php b/src/Service/ActivityPub/Note.php index 902c9cda3..af7e973d5 100644 --- a/src/Service/ActivityPub/Note.php +++ b/src/Service/ActivityPub/Note.php @@ -15,6 +15,8 @@ use App\Entity\Post; use App\Entity\PostComment; use App\Entity\User; +use App\Exception\TagBannedException; +use App\Exception\UserBannedException; use App\Factory\ImageFactory; use App\Repository\ApActivityRepository; use App\Service\ActivityPubManager; @@ -35,13 +37,17 @@ public function __construct( private readonly PostCommentManager $postCommentManager, private readonly ActivityPubManager $activityPubManager, private readonly EntityManagerInterface $entityManager, - private readonly MarkdownConverter $markdownConverter, private readonly SettingsManager $settingsManager, private readonly ImageFactory $imageFactory, private readonly ApObjectExtractor $objectExtractor, ) { } + /** + * @throws TagBannedException + * @throws UserBannedException + * @throws \Exception + */ public function create(array $object, array $root = null): EntryComment|PostComment|Post { $current = $this->repository->findByObjectId($object['id']); @@ -90,6 +96,11 @@ public function create(array $object, array $root = null): EntryComment|PostComm return $this->createPost($object); } + /** + * @throws TagBannedException + * @throws UserBannedException + * @throws \Exception + */ private function createEntryComment(array $object, ActivityPubActivityInterface $parent, ActivityPubActivityInterface $root = null): EntryComment { $dto = new EntryCommentDto(); diff --git a/src/Service/ActivityPub/Page.php b/src/Service/ActivityPub/Page.php index da34ef7db..e0c837ab1 100644 --- a/src/Service/ActivityPub/Page.php +++ b/src/Service/ActivityPub/Page.php @@ -12,19 +12,21 @@ use App\Entity\Contracts\VisibilityInterface; use App\Entity\Entry; use App\Entity\User; +use App\Exception\TagBannedException; +use App\Exception\UserBannedException; use App\Factory\ImageFactory; use App\Repository\ApActivityRepository; use App\Service\ActivityPubManager; use App\Service\EntryManager; use App\Service\SettingsManager; use Doctrine\ORM\EntityManagerInterface; +use Exception; use Psr\Log\LoggerInterface; class Page { public function __construct( private readonly ApActivityRepository $repository, - private readonly MarkdownConverter $markdownConverter, private readonly EntryManager $entryManager, private readonly ActivityPubManager $activityPubManager, private readonly EntityManagerInterface $entityManager, @@ -35,12 +37,17 @@ public function __construct( ) { } + /** + * @throws TagBannedException + * @throws UserBannedException + * @throws \Exception if the user could not be found or a sub exception occurred + */ public function create(array $object): Entry { $actor = $this->activityPubManager->findActorOrCreate($object['attributedTo']); if (!empty($actor)) { if ($actor->isBanned) { - throw new \Exception('User is banned.'); + throw new UserBannedException(); } $current = $this->repository->findByObjectId($object['id']); @@ -101,6 +108,9 @@ public function create(array $object): Entry } } + /** + * @throws \Exception + */ private function getVisibility(array $object, User $actor): string { if (!\in_array( @@ -147,6 +157,9 @@ private function handleUrl(EntryDto $dto, ?array $object): void } } + /** + * @throws \Exception + */ private function handleDate(EntryDto $dto, string $date): void { $dto->createdAt = new \DateTimeImmutable($date); diff --git a/src/Service/EntryCommentManager.php b/src/Service/EntryCommentManager.php index d720acbee..380b76db9 100644 --- a/src/Service/EntryCommentManager.php +++ b/src/Service/EntryCommentManager.php @@ -15,6 +15,7 @@ use App\Event\EntryComment\EntryCommentEditedEvent; use App\Event\EntryComment\EntryCommentPurgedEvent; use App\Event\EntryComment\EntryCommentRestoredEvent; +use App\Exception\TagBannedException; use App\Exception\UserBannedException; use App\Factory\EntryCommentFactory; use App\Message\DeleteImageMessage; @@ -31,6 +32,7 @@ class EntryCommentManager implements ContentManagerInterface { public function __construct( private readonly TagManager $tagManager, + private readonly TagExtractor $tagExtractor, private readonly MentionManager $mentionManager, private readonly EntryCommentFactory $factory, private readonly RateLimiterFactory $entryCommentLimiter, @@ -54,6 +56,10 @@ public function create(EntryCommentDto $dto, User $user, $rateLimit = true): Ent throw new UserBannedException(); } + if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) { + throw new TagBannedException(); + } + $comment = $this->factory->createFromDto($dto, $user); $comment->magazine = $dto->entry->magazine; @@ -63,7 +69,6 @@ public function create(EntryCommentDto $dto, User $user, $rateLimit = true): Ent if ($comment->image && !$comment->image->altText) { $comment->image->altText = $dto->imageAlt; } - $comment->tags = $dto->body ? $this->tagManager->extract($dto->body, $comment->magazine->name) : null; $comment->mentions = $dto->body ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment)) : $dto->mentions; @@ -82,6 +87,8 @@ public function create(EntryCommentDto $dto, User $user, $rateLimit = true): Ent $this->entityManager->persist($comment); $this->entityManager->flush(); + $this->tagManager->updateEntryCommentTags($comment, $this->tagExtractor->extract($comment->body) ?? []); + $this->dispatcher->dispatch(new EntryCommentCreatedEvent($comment)); return $comment; @@ -98,7 +105,7 @@ public function edit(EntryComment $comment, EntryCommentDto $dto): EntryComment if ($dto->image) { $comment->image = $this->imageRepository->find($dto->image->id); } - $comment->tags = $dto->body ? $this->tagManager->extract($dto->body, $comment->magazine->name) : null; + $this->tagManager->updateEntryCommentTags($comment, $this->tagManager->getTagsFromEntryCommentDto($dto)); $comment->mentions = $dto->body ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment)) : $dto->mentions; diff --git a/src/Service/EntryManager.php b/src/Service/EntryManager.php index e7f44b1ea..d26979cc8 100644 --- a/src/Service/EntryManager.php +++ b/src/Service/EntryManager.php @@ -16,6 +16,7 @@ use App\Event\Entry\EntryEditedEvent; use App\Event\Entry\EntryPinEvent; use App\Event\Entry\EntryRestoredEvent; +use App\Exception\TagBannedException; use App\Exception\UserBannedException; use App\Factory\EntryFactory; use App\Message\DeleteImageMessage; @@ -39,6 +40,7 @@ class EntryManager implements ContentManagerInterface { public function __construct( private readonly LoggerInterface $logger, + private readonly TagExtractor $tagExtractor, private readonly TagManager $tagManager, private readonly MentionManager $mentionManager, private readonly EntryCommentManager $entryCommentManager, @@ -57,6 +59,12 @@ public function __construct( ) { } + /** + * @throws TagBannedException + * @throws UserBannedException + * @throws TooManyRequestsHttpException + * @throws \Exception if title, body and image are empty + */ public function create(EntryDto $dto, User $user, bool $rateLimit = true): Entry { if ($rateLimit) { @@ -70,6 +78,10 @@ public function create(EntryDto $dto, User $user, bool $rateLimit = true): Entry throw new UserBannedException(); } + if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) { + throw new TagBannedException(); + } + $this->logger->debug('creating entry from dto'); $entry = $this->factory->createFromDto($dto, $user); @@ -81,10 +93,6 @@ public function create(EntryDto $dto, User $user, bool $rateLimit = true): Entry if ($entry->image && !$entry->image->altText) { $entry->image->altText = $dto->imageAlt; } - $entry->tags = $dto->tags ? $this->tagManager->extract( - implode(' ', array_map(fn ($tag) => str_starts_with($tag, '#') ? $tag : '#'.$tag, $dto->tags)), - $entry->magazine->name - ) : null; $entry->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null; $entry->visibility = $dto->visibility; $entry->apId = $dto->apId; @@ -105,6 +113,8 @@ public function create(EntryDto $dto, User $user, bool $rateLimit = true): Entry $this->entityManager->persist($entry); $this->entityManager->flush(); + $this->tagManager->updateEntryTags($entry, $this->tagExtractor->extract($entry->body) ?? []); + $this->dispatcher->dispatch(new EntryCreatedEvent($entry)); return $entry; @@ -154,10 +164,8 @@ public function edit(Entry $entry, EntryDto $dto): Entry if ($dto->image) { $entry->image = $this->imageRepository->find($dto->image->id); } - $entry->tags = $dto->tags ? $this->tagManager->extract( - implode(' ', array_map(fn ($tag) => str_starts_with($tag, '#') ? $tag : '#'.$tag, $dto->tags)), - $entry->magazine->name - ) : null; + $this->tagManager->updateEntryTags($entry, $this->tagManager->getTagsFromEntryDto($dto)); + $entry->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null; $entry->isOc = $dto->isOc; $entry->lang = $dto->lang; diff --git a/src/Service/FeedManager.php b/src/Service/FeedManager.php index 594a8daef..195656a31 100644 --- a/src/Service/FeedManager.php +++ b/src/Service/FeedManager.php @@ -10,6 +10,7 @@ use App\Repository\Criteria; use App\Repository\EntryRepository; use App\Repository\MagazineRepository; +use App\Repository\TagLinkRepository; use App\Repository\UserRepository; use App\Utils\IriGenerator; use FeedIo\Feed; @@ -27,6 +28,7 @@ public function __construct( private readonly EntryRepository $entryRepository, private readonly MagazineRepository $magazineRepository, private readonly UserRepository $userRepository, + private readonly TagLinkRepository $tagLinkRepository, private readonly RouterInterface $router, private readonly EntryFactory $entryFactory, ) { @@ -111,7 +113,7 @@ public function getEntries(\ArrayIterator $entries): \Generator $item->setPublicId(IriGenerator::getIriFromResource($entry)); $item->setAuthor((new Item\Author())->setName($entry->user->username)); - foreach ($entry->getTags() as $tag) { + foreach ($this->tagLinkRepository->getTagsOfEntry($entry) as $tag) { $category = new Category(); $category->setLabel($tag); diff --git a/src/Service/PostCommentManager.php b/src/Service/PostCommentManager.php index 45e99324a..50d56993d 100644 --- a/src/Service/PostCommentManager.php +++ b/src/Service/PostCommentManager.php @@ -15,6 +15,7 @@ use App\Event\PostComment\PostCommentEditedEvent; use App\Event\PostComment\PostCommentPurgedEvent; use App\Event\PostComment\PostCommentRestoredEvent; +use App\Exception\TagBannedException; use App\Exception\UserBannedException; use App\Factory\PostCommentFactory; use App\Message\DeleteImageMessage; @@ -31,6 +32,7 @@ class PostCommentManager implements ContentManagerInterface { public function __construct( private readonly TagManager $tagManager, + private readonly TagExtractor $tagExtractor, private readonly MentionManager $mentionManager, private readonly PostCommentFactory $factory, private readonly ImageRepository $imageRepository, @@ -41,6 +43,12 @@ public function __construct( ) { } + /** + * @throws TagBannedException + * @throws UserBannedException + * @throws TooManyRequestsHttpException + * @throws \Exception + */ public function create(PostCommentDto $dto, User $user, $rateLimit = true): PostComment { if ($rateLimit) { @@ -54,6 +62,10 @@ public function create(PostCommentDto $dto, User $user, $rateLimit = true): Post throw new UserBannedException(); } + if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) { + throw new TagBannedException(); + } + $comment = $this->factory->createFromDto($dto, $user); $comment->magazine = $dto->post->magazine; @@ -63,7 +75,6 @@ public function create(PostCommentDto $dto, User $user, $rateLimit = true): Post if ($comment->image && !$comment->image->altText) { $comment->image->altText = $dto->imageAlt; } - $comment->tags = $dto->body ? $this->tagManager->extract($dto->body, $comment->magazine->name) : null; $comment->mentions = $dto->body ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment)) : $dto->mentions; @@ -82,11 +93,16 @@ public function create(PostCommentDto $dto, User $user, $rateLimit = true): Post $this->entityManager->persist($comment); $this->entityManager->flush(); + $this->tagManager->updatePostCommentTags($comment, $this->tagExtractor->extract($comment->body) ?? []); + $this->dispatcher->dispatch(new PostCommentCreatedEvent($comment)); return $comment; } + /** + * @throws \Exception + */ public function edit(PostComment $comment, PostCommentDto $dto): PostComment { Assert::same($comment->post->getId(), $dto->post->getId()); @@ -98,7 +114,7 @@ public function edit(PostComment $comment, PostCommentDto $dto): PostComment if ($dto->image) { $comment->image = $this->imageRepository->find($dto->image->id); } - $comment->tags = $dto->body ? $this->tagManager->extract($dto->body, $comment->magazine->name) : null; + $this->tagManager->updatePostCommentTags($comment, $this->tagExtractor->extract($dto->body) ?? []); $comment->mentions = $dto->body ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment)) : $dto->mentions; @@ -173,6 +189,9 @@ private function isTrashed(User $user, PostComment $comment): bool return !$comment->isAuthor($user); } + /** + * @throws \Exception + */ public function restore(User $user, PostComment $comment): void { if (VisibilityInterface::VISIBILITY_TRASHED !== $comment->visibility) { diff --git a/src/Service/PostManager.php b/src/Service/PostManager.php index 5af466ac8..6deff08c0 100644 --- a/src/Service/PostManager.php +++ b/src/Service/PostManager.php @@ -15,6 +15,7 @@ use App\Event\Post\PostDeletedEvent; use App\Event\Post\PostEditedEvent; use App\Event\Post\PostRestoredEvent; +use App\Exception\TagBannedException; use App\Exception\UserBannedException; use App\Factory\PostFactory; use App\Message\DeleteImageMessage; @@ -41,6 +42,7 @@ public function __construct( private readonly MentionManager $mentionManager, private readonly PostCommentManager $postCommentManager, private readonly TagManager $tagManager, + private readonly TagExtractor $tagExtractor, private readonly PostFactory $factory, private readonly EventDispatcherInterface $dispatcher, private readonly RateLimiterFactory $postLimiter, @@ -53,6 +55,12 @@ public function __construct( ) { } + /** + * @throws TagBannedException + * @throws UserBannedException + * @throws TooManyRequestsHttpException + * @throws \Exception + */ public function create(PostDto $dto, User $user, $rateLimit = true): Post { if ($rateLimit) { @@ -66,6 +74,10 @@ public function create(PostDto $dto, User $user, $rateLimit = true): Post throw new UserBannedException(); } + if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) { + throw new TagBannedException(); + } + $post = $this->factory->createFromDto($dto, $user); $post->lang = $dto->lang; @@ -76,7 +88,6 @@ public function create(PostDto $dto, User $user, $rateLimit = true): Post if ($post->image && !$post->image->altText) { $post->image->altText = $dto->imageAlt; } - $post->tags = $dto->body ? $this->tagManager->extract($dto->body, $post->magazine->name) : null; $post->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null; $post->visibility = $dto->visibility; $post->apId = $dto->apId; @@ -91,6 +102,8 @@ public function create(PostDto $dto, User $user, $rateLimit = true): Post $this->entityManager->persist($post); $this->entityManager->flush(); + $this->tagManager->updatePostTags($post, $this->tagExtractor->extract($post->body) ?? []); + $this->dispatcher->dispatch(new PostCreatedEvent($post)); return $post; @@ -108,7 +121,7 @@ public function edit(Post $post, PostDto $dto): Post if ($dto->image) { $post->image = $this->imageRepository->find($dto->image->id); } - $post->tags = $dto->body ? $this->tagManager->extract($dto->body, $post->magazine->name) : null; + $this->tagManager->updatePostTags($post, $this->tagExtractor->extract($dto->body) ?? []); $post->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null; $post->visibility = $dto->visibility; $post->editedAt = new \DateTimeImmutable('@'.time()); diff --git a/src/Service/TagExtractor.php b/src/Service/TagExtractor.php new file mode 100644 index 000000000..b1140c4ab --- /dev/null +++ b/src/Service/TagExtractor.php @@ -0,0 +1,81 @@ +extract($body) ?? []; + + $join = array_unique(array_merge(array_diff($tags, $current))); + + if (!empty($join)) { + if (!empty($body)) { + $lastTag = end($current); + if (($lastTag && !str_ends_with($body, $lastTag)) || !$lastTag) { + $body = $body.PHP_EOL.PHP_EOL; + } + } + + $body = $body.' #'.implode(' #', $join); + } + + return $body; + } + + public function extract(?string $val, string $magazineName = null): ?array + { + if (null === $val) { + return null; + } + + preg_match_all(RegPatterns::LOCAL_TAG, $val, $matches); + + $result = $matches[1]; + $result = array_map(fn ($tag) => mb_strtolower(trim($tag)), $result); + + $result = array_values($result); + + $result = array_map(fn ($tag) => $this->transliterate($tag), $result); + + if ($magazineName) { + $result = array_diff($result, [$magazineName]); + } + + return \count($result) ? array_unique(array_values($result)) : null; + } + + /** + * transliterate and normalize a hashtag identifier. + * + * mostly recreates Mastodon's hashtag normalization rules, using ICU rules + * - try to transliterate modified latin characters to ASCII regions + * - normalize widths for fullwidth/halfwidth letters + * - strip characters that shouldn't be part of a hashtag + * (borrowed the character set from Mastodon) + * + * @param string $tag input hashtag identifier to normalize + * + * @return string normalized hashtag identifier + * + * @see https://github.com/mastodon/mastodon/blob/main/app/lib/hashtag_normalizer.rb + * @see https://github.com/mastodon/mastodon/blob/main/app/models/tag.rb + */ + public function transliterate(string $tag): string + { + $rules = <<<'ENDRULE' + :: Latin-ASCII; + :: [\uFF00-\uFFEF] NFKC; + :: [^[:alnum:][\u0E47-\u0E4E][_\u00B7\u30FB\u200c]] Remove; + ENDRULE; + + $normalizer = \Transliterator::createFromRules($rules); + + return iconv('UTF-8', 'UTF-8//TRANSLIT', $normalizer->transliterate($tag)); + } +} diff --git a/src/Service/TagManager.php b/src/Service/TagManager.php index 072c029a9..cc7f11385 100644 --- a/src/Service/TagManager.php +++ b/src/Service/TagManager.php @@ -4,74 +4,177 @@ namespace App\Service; -use App\Utils\RegPatterns; +use App\DTO\EntryCommentDto; +use App\DTO\EntryDto; +use App\Entity\Entry; +use App\Entity\EntryComment; +use App\Entity\Hashtag; +use App\Entity\Post; +use App\Entity\PostComment; +use App\Repository\TagLinkRepository; +use App\Repository\TagRepository; +use Doctrine\ORM\EntityManagerInterface; +use JetBrains\PhpStorm\ArrayShape; class TagManager { - public function joinTagsToBody(string $body, array $tags): string - { - $current = $this->extract($body, null, false) ?? []; + public function __construct( + private readonly TagRepository $tagRepository, + private readonly TagLinkRepository $tagLinkRepository, + private readonly EntityManagerInterface $entityManager, + private readonly TagExtractor $tagExtractor, + ) { + } - $join = array_unique(array_merge(array_diff($tags, $current))); + public function extract(?string $val, string $magazineName = null): ?array + { + return $this->tagExtractor->extract($val, $magazineName); + } - if (!empty($join)) { - if (!empty($body)) { - $lastTag = end($current); - if (($lastTag && !str_ends_with($body, $lastTag)) || !$lastTag) { - $body = $body.PHP_EOL.PHP_EOL; - } - } + /** + * @param string[] $newTags + */ + public function updateEntryTags(Entry $entry, array $newTags): void + { + $this->updateTags($newTags, + fn () => $this->tagLinkRepository->getTagsOfEntry($entry), + fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfEntry($entry, $hashtag), + fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToEntry($entry, $hashtag) + ); + } - $body = $body.' #'.implode(' #', $join); - } + public function getTagsFromEntryDto(EntryDto $dto): array + { + return array_unique( + array_filter( + array_merge( + $dto->tags ?? [], + $this->tagExtractor->extract($dto->body ?? '') ?? [] + ) + ) + ); + } - return $body; + /** + * @param string[] $newTags + */ + public function updateEntryCommentTags(EntryComment $entryComment, array $newTags): void + { + $this->updateTags($newTags, + fn () => $this->tagLinkRepository->getTagsOfEntryComment($entryComment), + fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfEntryComment($entryComment, $hashtag), + fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToEntryComment($entryComment, $hashtag) + ); } - public function extract(string $val, string $magazineName = null): ?array + public function getTagsFromEntryCommentDto(EntryCommentDto $dto): array { - preg_match_all(RegPatterns::LOCAL_TAG, $val, $matches); + return array_unique( + array_filter( + array_merge( + $dto->tags ?? [], + $this->tagExtractor->extract($dto->body ?? '') ?? [] + ) + ) + ); + } - $result = $matches[1]; - $result = array_map(fn ($tag) => mb_strtolower(trim($tag)), $result); + /** + * @param string[] $newTags + */ + public function updatePostTags(Post $post, array $newTags): void + { + $this->updateTags($newTags, + fn () => $this->tagLinkRepository->getTagsOfPost($post), + fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfPost($post, $hashtag), + fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToPost($post, $hashtag) + ); + } - $result = array_values($result); + /** + * @param string[] $newTags + */ + public function updatePostCommentTags(PostComment $postComment, array $newTags): void + { + $this->updateTags($newTags, + fn () => $this->tagLinkRepository->getTagsOfPostComment($postComment), + fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfPostComment($postComment, $hashtag), + fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToPostComment($postComment, $hashtag) + ); + } - $result = array_map(fn ($tag) => $this->transliterate($tag), $result); + /** + * @param string[] $newTags + * @param callable(): string[] $getTags a callable that should return all the tags of the entity as a string array + * @param callable(Hashtag): void $removeTag a callable that gets a string as parameter and should remove the tag + * @param callable(Hashtag): void $addTag + */ + private function updateTags(array $newTags, callable $getTags, callable $removeTag, callable $addTag): void + { + $oldTags = $getTags(); + $actions = $this->intersectOldAndNewTags($oldTags, $newTags); + foreach ($actions['tagsToRemove'] as $tag) { + $removeTag($this->tagRepository->findOneBy(['tag' => $tag])); + } + foreach ($actions['tagsToCreate'] as $tag) { + $tagEntity = $this->tagRepository->findOneBy(['tag' => $tag]); + if (null === $tagEntity) { + $tagEntity = $this->tagRepository->create($tag); + } + $addTag($tagEntity); + } + } - if ($magazineName) { - $result = array_diff($result, [$magazineName]); + #[ArrayShape([ + 'tagsToRemove' => 'string[]', + 'tagsToCreate' => 'string[]', + ])] + private function intersectOldAndNewTags(array $oldTags, array $newTags): array + { + /** @var string[] $tagsToRemove */ + $tagsToRemove = []; + /** @var string[] $tagsToCreate */ + $tagsToCreate = []; + foreach ($oldTags as $tag) { + if (!\in_array($tag, $newTags)) { + $tagsToRemove[] = $tag; + } + } + foreach ($newTags as $tag) { + if (!\in_array($tag, $oldTags)) { + $tagsToCreate[] = $tag; + } } - return \count($result) ? array_unique(array_values($result)) : null; + return [ + 'tagsToCreate' => $tagsToCreate, + 'tagsToRemove' => $tagsToRemove, + ]; } - /** - * transliterate and normalize a hashtag identifier. - * - * mostly recreates Mastodon's hashtag normalization rules, using ICU rules - * - try to transliterate modified latin characters to ASCII regions - * - normalize widths for fullwidth/halfwidth letters - * - strip characters that shouldn't be part of a hashtag - * (borrowed the character set from Mastodon) - * - * @param string $tag input hashtag identifier to normalize - * - * @return normalized hashtag identifier - * - * @see https://github.com/mastodon/mastodon/blob/main/app/lib/hashtag_normalizer.rb - * @see https://github.com/mastodon/mastodon/blob/main/app/models/tag.rb - */ - public function transliterate(string $tag): string + public function ban(Hashtag $hashtag): void { - $rules = <<<'ENDRULE' - :: Latin-ASCII; - :: [\uFF00-\uFFEF] NFKC; - :: [^[:alnum:][\u0E47-\u0E4E][_\u00B7\u30FB\u200c]] Remove; - ENDRULE; + $hashtag->banned = true; + $this->entityManager->persist($hashtag); + $this->entityManager->flush(); + } - $normalizer = \Transliterator::createFromRules($rules); + public function unban(Hashtag $hashtag): void + { + $hashtag->banned = false; + $this->entityManager->persist($hashtag); + $this->entityManager->flush(); + } + + public function isAnyTagBanned(?array $tags): bool + { + if ($tags) { + $result = $this->tagRepository->findBy(['tag' => $tags, 'banned' => true]); + if ($result && 0 !== \sizeof($result)) { + return true; + } + } - return iconv('UTF-8', 'UTF-8//TRANSLIT', $normalizer->transliterate($tag)); + return false; } } diff --git a/src/Twig/Components/TagActionComponent.php b/src/Twig/Components/TagActionComponent.php new file mode 100644 index 000000000..f2d52a9e6 --- /dev/null +++ b/src/Twig/Components/TagActionComponent.php @@ -0,0 +1,13 @@ +security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException(); + } + + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); + if (null === $hashtag) { + return false; + } + + return $hashtag->banned; + } } diff --git a/templates/components/tag_actions.html.twig b/templates/components/tag_actions.html.twig new file mode 100644 index 000000000..970c841c0 --- /dev/null +++ b/templates/components/tag_actions.html.twig @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/templates/entry/_info.html.twig b/templates/entry/_info.html.twig index 2264936fd..62623a689 100644 --- a/templates/entry/_info.html.twig +++ b/templates/entry/_info.html.twig @@ -23,11 +23,11 @@ - {% if entry.tags %} + {% if entry.hashtags is not empty %}

{{ 'tags'|trans }}

- {% for tag in entry.tags %} - #{{ tag }} + {% for link in entry.hashtags %} + #{{ link.hashtag.tag }} {% endfor %}
{% endif %} diff --git a/templates/layout/_sidebar.html.twig b/templates/layout/_sidebar.html.twig index 2aa709f8f..0c4401e6f 100644 --- a/templates/layout/_sidebar.html.twig +++ b/templates/layout/_sidebar.html.twig @@ -88,6 +88,9 @@ }) }} {% include 'magazine/_moderators_sidebar.html.twig' %} {% endif %} +{% if tag is defined and tag %} + {% include 'tag/_panel.html.twig' %} +{% endif %} {{ component('related_magazines', {magazine: magazine is defined and magazine ? magazine.name : null, tag: tag is defined and tag ? tag : null}) }} {% if not is_route_name_contains('people') %} {{ component('active_users', {magazine: magazine is defined and magazine ? magazine : null}) }} diff --git a/templates/magazine/panel/tags.html.twig b/templates/magazine/panel/tags.html.twig index 21a68bfd5..b75b8dfdd 100644 --- a/templates/magazine/panel/tags.html.twig +++ b/templates/magazine/panel/tags.html.twig @@ -23,7 +23,6 @@
{{ form_start(form) }} - {{ form_row(form.tags) }}
{{ form_row(form.submit, { 'label': 'save'|trans, attr: {class: 'btn btn__primary'} }) }}
diff --git a/templates/tag/_list.html.twig b/templates/tag/_list.html.twig new file mode 100644 index 000000000..f1b0d4b99 --- /dev/null +++ b/templates/tag/_list.html.twig @@ -0,0 +1,3 @@ +
+ {% include 'layout/_subject_list.html.twig' with {entryCommentAttributes: {showMagazineName: true, showEntryTitle: true}, postCommentAttributes: {withPost: false}} %} +
\ No newline at end of file diff --git a/templates/tag/_options.html.twig b/templates/tag/_options.html.twig index 52a451a9b..9058afa2e 100644 --- a/templates/tag/_options.html.twig +++ b/templates/tag/_options.html.twig @@ -1,16 +1,3 @@ diff --git a/templates/tag/_panel.html.twig b/templates/tag/_panel.html.twig new file mode 100644 index 000000000..4fdb29f24 --- /dev/null +++ b/templates/tag/_panel.html.twig @@ -0,0 +1,36 @@ +
+

{{ 'tag'|trans }}

+ +
+
+

+ + #{{ tag }} + +

+
+
+ + {{ component('tag_actions', {tag: tag}) }} + + {% if false %} + {{ component('magazine_sub', {magazine: magazine}) }} + + {% if showInfo %} +
    +
  • {{ 'subscribers'|trans }}: {{ computed.magazine.subscriptionsCount }}
  • +
+ {% endif %} + {% endif %} + +
    + {{ _self.meta_item('threads'|trans, counts["entry"]) }} + {{ _self.meta_item('comments'|trans, counts["entry_comment"]) }} + {{ _self.meta_item('posts'|trans, counts["post"]) }} + {{ _self.meta_item('replies'|trans, counts["post_comment"]) }} +
+ + {% macro meta_item(name, count) %} +
  • {{ name }}{{ count }}
  • + {% endmacro %} +
    diff --git a/templates/tag/overview.html.twig b/templates/tag/overview.html.twig index 485df4d5e..0c7006e8b 100644 --- a/templates/tag/overview.html.twig +++ b/templates/tag/overview.html.twig @@ -11,6 +11,24 @@ {% endblock %} {% block sidebar_top %} + {% if app.user and app.user.admin %} +
    +

    {{ 'admin_panel'|trans }}

    +
    +
    + + +
    +
    +
    + {% endif %} {% endblock %} {% block body %} @@ -19,6 +37,6 @@ 'show-comment-avatar': app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')) is same as 'true' or not app.request.cookies.has(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')), 'show-post-avatar': app.request.cookies.get(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) is same as 'true' or not app.request.cookies.has(constant('App\\Controller\\User\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) }) }}"> - {% include 'layout/_subject_list.html.twig' with {entryCommentAttributes: {showMagazineName: true, showEntryTitle: true}, postCommentAttributes: {withPost: false}} %} + {% include 'tag/_list.html.twig' %}
    {% endblock %} diff --git a/tests/Unit/Service/TagManagerTest.php b/tests/Unit/Service/TagExtractorTest.php similarity index 91% rename from tests/Unit/Service/TagManagerTest.php rename to tests/Unit/Service/TagExtractorTest.php index 0a14a3ada..6716b7fa0 100644 --- a/tests/Unit/Service/TagManagerTest.php +++ b/tests/Unit/Service/TagExtractorTest.php @@ -4,17 +4,17 @@ namespace App\Tests\Unit\Service; -use App\Service\TagManager; +use App\Service\TagExtractor; use PHPUnit\Framework\TestCase; -class TagManagerTest extends TestCase +class TagExtractorTest extends TestCase { /** * @dataProvider provider */ public function testExtract(string $input, ?array $output): void { - $this->assertEquals($output, (new TagManager())->extract($input, 'kbin')); + $this->assertEquals($output, (new TagExtractor())->extract($input, 'kbin')); } public static function provider(): array diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 8d6185de8..b78fb8cd2 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -125,6 +125,7 @@ url: URL title: Title body: Body tags: Tags +tag: Tag badges: Badges is_adult: 18+ / NSFW eng: ENG @@ -310,6 +311,13 @@ magazine_panel: Magazine panel reject: Reject approve: Approve ban: Ban +unban: Unban +ban_hashtag_btn: Ban Hashtag +ban_hashtag_description: Banning a hashtag will stop posts with this hashtag from being created, + as well as hiding existing posts with this hashtag. +unban_hashtag_btn: Unban Hashtag +unban_hashtag_description: Unbanning a hashtag will allow creating posts with this hashtag again. + Existing posts with this hashtag are no longer hidden. filters: Filters approved: Approved rejected: Rejected @@ -732,6 +740,7 @@ position_bottom: Bottom position_top: Top pending: Pending flash_thread_new_error: Thread could not be created. Something went wrong. +flash_thread_tag_banned_error: Thread could not be created. The content is not allowed. flash_email_was_sent: Email has been successfully sent. flash_email_failed_to_sent: Email could not be sent. flash_post_new_success: Post has been successfully created.