Commit 5d320a2c authored by Emma's avatar Emma 🏳🌈

soft-delete submissions/refactor submission stuff

parent 9686fc35
Pipeline #60830186 passed with stages
in 8 minutes and 23 seconds
......@@ -14,4 +14,8 @@
&--warn {
background: var(--bg-orange);
}
&--error {
background: var(--bg-red);
}
}
......@@ -227,6 +227,12 @@
"css": "sun-inv",
"code": 59416,
"src": "iconic"
},
{
"uid": "5211af474d3a9848f67f945e2ccaf143",
"css": "cancel",
"code": 59415,
"src": "fontawesome"
}
]
}
\ No newline at end of file
......@@ -5,6 +5,7 @@
height="0"><defs>
<symbol id="attention" viewBox="0 0 1000 1000"><path d="M571 767V661q0-8-5-13t-12-5H446q-7 0-12 5t-5 13v106q0 8 5 13t12 6h108q7 0 12-6t5-13zm-1-208l10-257q0-6-5-10-7-6-14-6H439q-6 0-14 6-5 4-5 12l9 255q0 5 6 9t13 3h103q8 0 14-3t5-9zm-7-522l428 786q20 35-1 70-9 17-26 26t-35 10H71q-18 0-35-10t-26-26q-21-35-1-70L438 37q9-17 26-27t36-10 36 10 27 27z"/></symbol>
<symbol id="block" viewBox="0 0 857.1 1000"><path d="M732 498q0-90-48-164L263 754q76 50 166 50 62 0 118-25t96-65 65-97 24-119zM175 665l421-421q-75-50-167-50-83 0-153 40T166 345t-41 153q0 91 50 167zm682-167q0 88-34 168t-91 137-137 92-166 34-167-34-137-92-91-137T0 498t34-167 91-137 137-91 167-34 166 34 137 91 91 137 34 167z"/></symbol>
<symbol id="cancel" viewBox="0 0 785.7 1000"><path d="M724 738q0 22-15 38l-76 76q-16 15-38 15t-38-15L393 687 229 852q-16 15-38 15t-38-15l-76-76q-16-16-16-38t16-38l164-164L77 372q-16-16-16-38t16-38l76-76q16-16 38-16t38 16l164 164 164-164q16-16 38-16t38 16l76 76q15 15 15 38t-15 38L545 536l164 164q15 15 15 38z"/></symbol>
<symbol id="clock" viewBox="0 0 857.1 1000"><path d="M500 304v250q0 7-5 12t-13 5H304q-8 0-13-5t-5-12v-36q0-8 5-13t13-5h125V304q0-8 5-13t12-5h36q8 0 13 5t5 13zm232 196q0-83-41-152T581 237t-152-41-153 41-110 111-41 152 41 152 110 111 153 41 152-41 110-111 41-152zm125 0q0 117-57 215T644 871t-215 58-216-58T58 715 0 500t58-215 155-156 216-58 215 58 156 156 57 215z"/></symbol>
<symbol id="comment" viewBox="0 0 1000 1000"><path d="M1000 500q0 97-67 179T751 809t-251 48q-39 0-81-4-110 97-257 135-27 8-63 12-10 1-17-5t-10-16v-1q-2-2 0-6t1-6 2-5l4-5 4-5 4-5q4-5 17-19t20-22 17-22 18-28 15-33 15-42q-88-50-138-123T0 500q0-73 40-139t106-114 160-76 194-28q136 0 251 48t182 130 67 179z"/></symbol>
<symbol id="down" viewBox="0 0 928 1000"><path d="M911 499L464 947 18 499l158-158 177 176V53h223v464l176-175z"/></symbol>
......
......@@ -18,7 +18,7 @@ submission_shortcut:
comment:
controller: App\Controller\SubmissionController::commentPermalink
defaults: { slug: '-' }
defaults: { slug: - }
path: /f/{forum_name}/{submission_id}/{slug}/comment/{comment_id}
requirements: { submission_id: "%number_regex%", comment_id: "%number_regex%" }
......@@ -31,49 +31,56 @@ comment_legacy_redirect:
edit_submission:
controller: App\Controller\SubmissionController::editSubmission
defaults: { slug: '-' }
defaults: { slug: - }
path: /f/{forum_name}/{submission_id}/{slug}/edit
methods: [GET, POST]
requirements: { submission_id: "%number_regex%" }
submission_delete_immediately:
controller: App\Controller\SubmissionController::deleteImmediately
defaults: { slug: '-' }
submission_delete_own:
controller: App\Controller\SubmissionController::deleteOwn
defaults: { slug: - }
path: /f/{forum_name}/{submission_id}/{slug}/delete
methods: [POST]
requirements: { submission_id: "%number_regex%" }
submission_delete_with_reason:
controller: App\Controller\SubmissionController::deleteWithReason
defaults: { slug: '-' }
path: /f/{forum_name}/{submission_id}/{slug}/delete_with_reason
submission_mod_delete:
controller: App\Controller\SubmissionController::modDelete
defaults: { slug: -, purge: false }
path: /f/{forum_name}/{submission_id}/{slug}/mod_delete
methods: [GET, POST]
requirements: { submission_id: "%number_regex%" }
submission_purge:
controller: App\Controller\SubmissionController::modDelete
defaults: { slug: -, purge: true }
path: /f/{forum_name}/{submission_id}/{slug}/purge
methods: [GET, POST]
requirements: { submission_id: "%number_regex%" }
lock:
controller: App\Controller\SubmissionController::lock
defaults: { lock: true, slug: '-' }
defaults: { lock: true, slug: - }
path: /f/{forum_name}/{submission_id}/{slug}/lock
methods: [POST]
requirements: { submission_id: "%number_regex%" }
unlock:
controller: App\Controller\SubmissionController::lock
defaults: { lock: false, slug: '-' }
defaults: { lock: false, slug: - }
path: /f/{forum_name}/{submission_id}/{slug}/unlock
methods: [POST]
requirements: { submission_id: "%number_regex%" }
pin:
controller: App\Controller\SubmissionController::pin
defaults: { pin: true, slug: '-' }
defaults: { pin: true, slug: - }
path: /f/{forum_name}/{submission_id}/{slug}/pin
methods: [POST]
requirements: { submission_id: "%number_regex%" }
unpin:
controller: App\Controller\SubmissionController::pin
defaults: { pin: false, slug: '-' }
defaults: { pin: false, slug: - }
path: /f/{forum_name}/{submission_id}/{slug}/unpin
methods: [POST]
requirements: { submission_id: "%number_regex%" }
......
......@@ -12,9 +12,8 @@ user_shortcut:
user_submissions:
controller: App\Controller\UserController::submissions
defaults: { page: 1 }
path: /user/{username}/submissions/{page}
path: /user/{username}/submissions
methods: [GET]
requirements: { page: \d+ }
user_comments:
controller: App\Controller\UserController::comments
......
This diff is collapsed.
......@@ -3,6 +3,7 @@
namespace App\Controller;
use App\Entity\Forum;
use App\Entity\Submission;
use App\Entity\User;
use App\Entity\UserBlock;
use App\Form\Model\UserBlockData;
......@@ -83,16 +84,10 @@ final class UserController extends AbstractController {
]);
}
/**
* @param User $user
* @param int $page
*
* @return Response
*/
public function submissions(User $user, int $page) {
$submissions = $user->getPaginatedSubmissions($page);
$this->submissions->hydrate(...$submissions);
public function submissions(User $user, Request $request): Response {
$submissions = $this->submissions->findSubmissions(Submission::SORT_NEW, [
'users' => [$user],
], $request);
return $this->render('user/submissions.html.twig', [
'submissions' => $submissions,
......
......@@ -3,6 +3,7 @@
namespace App\Entity;
use App\Entity\Exception\BannedFromForumException;
use App\Entity\Exception\SubmissionLockedException;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
......@@ -20,9 +21,13 @@ use Symfony\Component\Serializer\Annotation\SerializedName;
* @ORM\Index(name="submissions_comment_count_id_idx", columns={"comment_count", "id"}),
* @ORM\Index(name="submissions_net_score_id_idx", columns={"net_score", "id"}),
* @ORM\Index(name="submissions_search_idx", columns={"search_doc"}),
* @ORM\Index(name="submissions_visibility_idx", columns={"visibility"}),
* })
*/
class Submission extends Votable {
public const VISIBILITY_VISIBLE = 'visible';
public const VISIBILITY_DELETED = 'deleted';
public const FRONT_FEATURED = 'featured';
public const FRONT_SUBSCRIBED = 'subscribed';
public const FRONT_ALL = 'all';
......@@ -77,7 +82,7 @@ class Submission extends Votable {
*
* @Groups({"submission:read", "abbreviated_relations"})
*
* @var int
* @var int|null
*/
private $id;
......@@ -144,6 +149,15 @@ class Submission extends Votable {
*/
private $lastActive;
/**
* @ORM\Column(type="text")
*
* @Groups({"submission:read"})
*
* @var string
*/
private $visibility = self::VISIBILITY_VISIBLE;
/**
* @ORM\JoinColumn(nullable=false)
* @ORM\ManyToOne(targetEntity="Forum", inversedBy="submissions")
......@@ -173,7 +187,7 @@ class Submission extends Votable {
private $votes;
/**
* @ORM\OneToMany(targetEntity="SubmissionMention", mappedBy="submission", cascade={"remove"})
* @ORM\OneToMany(targetEntity="SubmissionMention", mappedBy="submission", cascade={"remove"}, orphanRemoval=true)
*
* @var SubmissionMention[]|Collection
*/
......@@ -182,7 +196,7 @@ class Submission extends Votable {
/**
* @ORM\Column(type="text", nullable=true)
*
* @var string
* @var string|null
*/
private $image;
......@@ -232,7 +246,7 @@ class Submission extends Votable {
*
* @var int
*/
private $userFlag;
private $userFlag = UserFlags::FLAG_NONE;
/**
* @ORM\Column(type="boolean", options={"default": false})
......@@ -275,8 +289,6 @@ class Submission extends Votable {
Forum $forum,
User $user,
?string $ip,
bool $sticky = false,
int $userFlag = UserFlags::FLAG_NONE,
\DateTime $timestamp = null
) {
if ($ip !== null && !filter_var($ip, FILTER_VALIDATE_IP)) {
......@@ -293,9 +305,7 @@ class Submission extends Votable {
$this->forum = $forum;
$this->user = $user;
$this->ip = $user->isTrustedOrAdmin() ? null : $ip;
$this->sticky = $sticky;
$this->setUserFlag($userFlag);
$this->timestamp = $timestamp ?: new \DateTime('@'.time());
$this->timestamp = $timestamp ?? new \DateTime('@'.time());
$this->comments = new ArrayCollection();
$this->votes = new ArrayCollection();
$this->mentions = new ArrayCollection();
......@@ -311,7 +321,7 @@ class Submission extends Votable {
return $this->title;
}
public function setTitle(string $title) {
public function setTitle(string $title): void {
$this->title = $title;
}
......@@ -319,7 +329,7 @@ class Submission extends Votable {
return $this->url;
}
public function setUrl(?string $url) {
public function setUrl(?string $url): void {
$this->url = $url;
}
......@@ -327,7 +337,7 @@ class Submission extends Votable {
return $this->body;
}
public function setBody(?string $body) {
public function setBody(?string $body): void {
$this->body = $body;
}
......@@ -356,7 +366,7 @@ class Submission extends Votable {
return $comments;
}
public function addComment(Comment ...$comments) {
public function addComment(Comment ...$comments): void {
foreach ($comments as $comment) {
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
......@@ -368,7 +378,7 @@ class Submission extends Votable {
$this->updateLastActive();
}
public function removeComment(Comment ...$comments) {
public function removeComment(Comment ...$comments): void {
// hydrate the collection
$this->comments->get(-1);
......@@ -417,6 +427,21 @@ class Submission extends Votable {
}
}
public function getVisibility(): string {
return $this->visibility;
}
public function softDelete(): void {
$this->visibility = self::VISIBILITY_DELETED;
$this->title = '';
$this->url = null;
$this->body = null;
$this->image = null;
$this->sticky = false;
$this->userFlag = 0;
$this->mentions->clear();
}
public function getForum(): Forum {
return $this->forum;
}
......@@ -426,23 +451,21 @@ class Submission extends Votable {
}
/**
* {@inheritdoc}
* @return Collection|SubmissionVote[]
*/
public function getVotes(): Collection {
return $this->votes;
}
/**
* {@inheritdoc}
*/
protected function createVote(User $user, ?string $ip, int $choice): Vote {
return new SubmissionVote($user, $ip, $choice, $this);
}
/**
* {@inheritdoc}
*/
public function vote(User $user, ?string $ip, int $choice): void {
if ($this->visibility === self::VISIBILITY_DELETED) {
throw new SubmissionLockedException();
}
if ($this->forum->userIsBanned($user)) {
throw new BannedFromForumException();
}
......@@ -453,7 +476,7 @@ class Submission extends Votable {
$this->updateRanking();
}
public function addMention(User $mentioned) {
public function addMention(User $mentioned): void {
if ($mentioned === $this->getUser()) {
// don't notify yourself
return;
......@@ -476,7 +499,7 @@ class Submission extends Votable {
return $this->image;
}
public function setImage(?string $image) {
public function setImage(?string $image): void {
$this->image = $image;
}
......@@ -488,18 +511,15 @@ class Submission extends Votable {
return $this->sticky;
}
public function setSticky(bool $sticky) {
public function setSticky(bool $sticky): void {
$this->sticky = $sticky;
}
/**
* @return int
*/
public function getRanking(): int {
return $this->ranking;
}
public function updateRanking() {
public function updateRanking(): void {
$netScore = $this->getNetScore();
$netScoreAdvantage = $netScore * self::NETSCORE_MULTIPLIER;
......@@ -518,7 +538,7 @@ class Submission extends Votable {
return $this->editedAt;
}
public function setEditedAt(?\DateTime $editedAt) {
public function setEditedAt(?\DateTime $editedAt): void {
$this->editedAt = $editedAt;
}
......@@ -526,7 +546,7 @@ class Submission extends Votable {
return $this->moderated;
}
public function setModerated(bool $moderated) {
public function setModerated(bool $moderated): void {
$this->moderated = $moderated;
}
......@@ -537,14 +557,12 @@ class Submission extends Votable {
/**
* @Groups({"submission:read"})
* @SerializedName("userFlag")
*
* @return string|null
*/
public function getReadableUserFlag(): ?string {
return UserFlags::toReadable($this->userFlag);
}
public function setUserFlag(int $userFlag) {
public function setUserFlag(int $userFlag): void {
if (!in_array($userFlag, UserFlags::FLAGS, true)) {
throw new \InvalidArgumentException('Bad flag');
}
......@@ -556,7 +574,7 @@ class Submission extends Votable {
return $this->locked;
}
public function setLocked(bool $locked) {
public function setLocked(bool $locked): void {
$this->locked = $locked;
}
......
......@@ -425,20 +425,6 @@ class User implements UserInterface, EquatableInterface {
return $this->submissions;
}
/**
* @param int $page
* @param int $maxPerPage
*
* @return Pagerfanta|Comment[]
*/
public function getPaginatedSubmissions(int $page, int $maxPerPage = 25): Pagerfanta {
$submissions = new Pagerfanta(new DoctrineCollectionAdapter($this->submissions));
$submissions->setMaxPerPage($maxPerPage);
$submissions->setCurrentPage($page);
return $submissions;
}
public function getSubmissionVotes(): Collection {
return $this->submissionVotes;
}
......
......@@ -68,17 +68,11 @@ class SubmissionData {
return $self;
}
public function toSubmission(User $user, $ip): Submission {
return new Submission(
$this->title,
$this->url,
$this->body,
$this->forum,
$user,
$ip,
false,
$this->userFlag
);
public function toSubmission(User $user, ?string $ip): Submission {
$submission = new Submission($this->title, $this->url, $this->body, $this->forum, $user, $ip);
$submission->setUserFlag($this->userFlag);
return $submission;
}
public function updateSubmission(Submission $submission, User $editingUser) {
......
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20190511110139 extends AbstractMigration {
public function getDescription(): string {
return 'Add visibility field to submissions';
}
public function up(Schema $schema): void {
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE submissions ADD visibility TEXT NOT NULL DEFAULT \'visible\'');
$this->addSql('ALTER TABLE submissions ALTER visibility DROP DEFAULT');
$this->addSql('CREATE INDEX submissions_visibility_idx ON submissions (visibility)');
}
public function down(Schema $schema): void {
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE submissions DROP visibility');
}
}
......@@ -88,6 +88,8 @@ class SubmissionRepository extends ServiceEntityRepository {
$qb = $this->_em->getConnection()->createQueryBuilder()
->select($rsm->generateSelectClause())
->from('submissions', 's')
->where('s.visibility IN (:visibility)')
->setParameter('visibility', Submission::VISIBILITY_VISIBLE)
->setMaxResults($maxPerPage + 1);
if (!\in_array($sortBy, Submission::SORT_OPTIONS, true)) {
......
......@@ -122,8 +122,10 @@ class UserRepository extends ServiceEntityRepository implements UserLoaderInterf
->from(Submission::class, 's')
->where('s.user = ?1')
->andWhere('s.timestamp <= ?2')
->andWhere('s.visibility = ?3')
->setParameter(1, $user)
->setParameter(2, $nextTimestamp, Type::DATETIMETZ)
->setParameter(3, Submission::VISIBILITY_VISIBLE)
->orderBy('s.timestamp', 'DESC')
->setMaxResults(26)
->getQuery()
......
......@@ -5,85 +5,114 @@ namespace App\Security\Voter;
use App\Entity\Submission;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class SubmissionVoter extends Voter {
const ATTRIBUTES = [
'delete_own',
'edit',
'delete_with_reason',
'delete_immediately',
'sticky',
'lock',
'mod_delete',
'pin',
'purge'
];
/**
* @var AccessDecisionManagerInterface
*/
private $decisionManager;
public function __construct(AccessDecisionManagerInterface $decisionManager) {
$this->decisionManager = $decisionManager;
}
/**
* {@inheritdoc}
*/
protected function supports($attribute, $subject) {
return $subject instanceof Submission && in_array($attribute, self::ATTRIBUTES);
return $subject instanceof Submission && \in_array($attribute, self::ATTRIBUTES, true);
}
/**
* {@inheritdoc}
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token) {
if (!$token->getUser() instanceof User) {
return false;
}
switch ($attribute) {
case 'delete_immediately':
return $this->canDeleteImmediately($subject, $token);
case 'delete_with_reason':
return $this->canDeleteWithReason($subject, $token);
case 'delete_own':
return $this->canDeleteOwn($subject, $token);
case 'edit':
return $this->canEdit($subject, $token);
case 'sticky':
return $this->canSticky($subject, $token);
case 'lock':
return $this->canLock($subject, $token);
case 'mod_delete':
return $this->canModDelete($subject, $token);
case 'pin':
return $this->canPin($subject, $token);
case 'purge':
return $this->canPurge($subject, $token);
default:
throw new \RuntimeException('Invalid attribute');
throw new \RuntimeException("Invalid attribute '$attribute'");
}
}
private function canDeleteImmediately(Submission $submission, TokenInterface $token): bool {
return $submission->getUser() === $token->getUser();
private function canDeleteOwn(Submission $submission, TokenInterface $token): bool {
if ($submission->getVisibility() === Submission::VISIBILITY_DELETED) {
return false;
}
if ($submission->getUser() !== $token->getUser()) {
return false;
}
return true;
}
private function canDeleteWithReason(Submission $submission, TokenInterface $token): bool {
return $submission->getForum()->userIsModerator($token->getUser());
private function canModDelete(Submission $submission, TokenInterface $token): bool {
if ($submission->getVisibility() === Submission::VISIBILITY_DELETED) {
return false;
}
if ($submission->getUser() === $token->getUser()) {
return false;
}
if (!$submission->getForum()->userIsModerator($token->getUser())) {
return false;
}
return true;
}
private function canPurge(Submission $submission, TokenInterface $token): bool {
if ($submission->getCommentCount() === 0) {
return false;
}
if (!$submission->getForum()->userIsModerator($token->getUser())) {
return false;
}
return true;
}
private function canEdit(Submission $submission, TokenInterface $token): bool {
if ($this->decisionManager->decide($token, ['ROLE_ADMIN'])) {
return true;
if ($submission->getVisibility() === Submission::VISIBILITY_DELETED) {
return false;
}
if ($submission->getForum()->userIsModerator($token->getUser())) {
if ($token->getUser()->isAdmin()) {
return true;
}
if ($submission->getUser() === $token->getUser()) {
// users can only edit if their submissions weren't moderated
return !$submission->isModerated();
if ($submission->getUser() !== $token->getUser()) {
return false;
}
if ($submission->isModerated()) {
return false;
}
return false;
return true;
}
private function canSticky(Submission $submission, TokenInterface $token): bool {
if ($this->decisionManager->decide($token, ['ROLE_ADMIN'])) {
return true;
private function canPin(Submission $submission, TokenInterface $token): bool {
if ($submission->getVisibility() === Submission::VISIBILITY_DELETED) {
return false;
}
return $submission->getForum()->userIsModerator($token->getUser());
}
private function canLock(Submission $submission, TokenInterface $token): bool {
return $submission->getForum()->userIsModerator($token->getUser());
}
}
......@@ -102,7 +102,7 @@
<strong>{{ comment.user.username }}</strong>
</a>
{%- else -%}
{{- 'comments.author_deleted'|trans -}}
{{- 'placeholder.deleted'|trans -}}
{%- endif -%}
{% endfilter %}
......
This diff is collapsed.
{% extends 'submission/base.html.twig' %}
{% block title 'title.delete_submission'|trans %}
{% block title purge ? 'title.purge_submission'|trans : 'title.delete_submission'|trans %}
{% block body %}
{% from 'submission/_macros.html.twig' import submission %}
......@@ -12,8 +12,14 @@
{{ form_start(form) }}
{{ form_row(form.reason) }}
{% if purge %}
<div class="form__row alert alert--error">
<p>{{ 'flash.purge_submission_warning'|trans }}</p>
</div>
{% endif %}
<div class="form__button-row">
<button class="button">{{ 'action.delete'|trans }}</button>
<button class="button">{{ purge ? 'action.purge'|trans : 'action.delete'|trans }}</button>
</div>
{{ form_end(form) }}
{% endblock %}
......@@ -16,7 +16,9 @@ class SubmissionTest extends TestCase {
* @dataProvider constructorArgsProvider
*/
public function testConstructor($title, $url, $body, $forum, $user, $ip, $sticky, $userFlag) {
$submission = new Submission($title, $url, $body, $forum, $user, $ip, $sticky, $userFlag);
$submission = new Submission($title, $url, $body, $forum, $user, $ip);
$submission->setSticky($sticky);
$submission->setUserFlag($userFlag);
$this->assertSame($title, $submission->getTitle());
$this->assertSame($url, $submission->getUrl());
......
......@@ -31,8 +31,6 @@ class LoadExampleSubmissions extends AbstractFixture implements DependentFixture
$forum,
$user,
$data['ip'],