diff --git a/plugins/DeleteNote/Controller/DeleteNote.php b/plugins/DeleteNote/Controller/DeleteNote.php index 390392c5b6..b61621eebb 100644 --- a/plugins/DeleteNote/Controller/DeleteNote.php +++ b/plugins/DeleteNote/Controller/DeleteNote.php @@ -24,13 +24,89 @@ declare(strict_types = 1); namespace Plugin\DeleteNote\Controller; use App\Core\Controller; -use App\Util\Exception\NotImplementedException; +use App\Core\DB\DB; +use App\Core\Form; +use function App\Core\I18n\_m; +use App\Core\Log; +use App\Core\Router\Router; +use App\Entity\Note; +use App\Util\Common; +use App\Util\Exception\ClientException; +use App\Util\Exception\NoLoggedInUser; +use App\Util\Exception\NoSuchNoteException; +use App\Util\Exception\RedirectException; +use App\Util\Exception\ServerException; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\HttpFoundation\Request; class DeleteNote extends Controller { + /** + * Create delete note view + * @throws ClientException + * @throws NoLoggedInUser + * @throws RedirectException + * @throws ServerException + */ public function __invoke(Request $request) { - throw new NotImplementedException; + $user = Common::ensureLoggedIn(); + $note_id = (int) $request->get('note_id'); + $note = Note::getByPK($note_id); + if (\is_null($note) || !$note->isVisibleTo($user)) { + throw new NoSuchNoteException(); + } + + // Only let the original actor delete it + // TODO: should be anyone with permissions to do this? Admins and what not + $actor = $user->getActor(); + $actor_id = $actor->getId(); + if ($note->getActor()->getId() !== $actor_id) { + // Log this shenanigans and get the user redirected + Log::warning("Actor {$actor_id} attempted to delete note {$note_id} without any permissions to do so)"); + throw new RedirectException('root'); + } + + // We made sure that the note can be deleted, lets make the form + $form_delete = Form::create([ + ['delete_note', SubmitType::class, + [ + 'label' => _m('Delete it'), + 'attr' => [ + 'title' => _m('Press to delete this note'), + ], + ], + ], + ]); + + $form_delete->handleRequest($request); + if ($form_delete->isSubmitted()) { + if (!\is_null(\Plugin\DeleteNote\DeleteNote::deleteNote(note_id: $note_id, actor_id: $actor_id))) { + DB::flush(); + } else { + throw new ClientException(_m('Note already deleted!')); + } + + // Redirect user to where they came from + // Prevent open redirect + if (!\is_null($from = $this->string('from'))) { + if (Router::isAbsolute($from)) { + Log::warning("Actor {$actor_id} attempted to delete to a note and then get redirected to another host, or the URL was invalid ({$from})"); + throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive) + } else { + // TODO anchor on element id + throw new RedirectException(url: $from); + } + } else { + // If we don't have a URL to return to, go to the instance root + throw new RedirectException('root'); + } + } + + return [ + '_template' => 'delete_note/delete_note.html.twig', + 'note' => $note, + 'delete' => $form_delete->createView(), + ]; } } diff --git a/plugins/DeleteNote/DeleteNote.php b/plugins/DeleteNote/DeleteNote.php index b62cf2f110..3456f3cdd2 100644 --- a/plugins/DeleteNote/DeleteNote.php +++ b/plugins/DeleteNote/DeleteNote.php @@ -23,16 +23,19 @@ namespace Plugin\DeleteNote; use App\Core\DB\DB; use App\Core\Event; -use App\Core\Form; +use App\Util\Exception\BugFoundException; use function App\Core\I18n\_m; use App\Core\Modules\NoteHandlerPlugin; use App\Core\Router\RouteLoader; use App\Core\Router\Router; +use App\Entity\Activity; +use App\Entity\Actor; use App\Entity\Note; -use App\Util\Common; -use App\Util\Exception\RedirectException; -use Symfony\Component\Form\Extension\Core\Type\HiddenType; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use App\Util\Exception\DuplicateFoundException; +use App\Util\Exception\NotFoundException; +use Component\Attachment\Entity\Attachment; +use Component\Attachment\Entity\AttachmentToNote; +use Plugin\DeleteNote\Entity\DeleteNote as DeleteEntity; use Symfony\Component\HttpFoundation\Request; /** @@ -40,7 +43,7 @@ use Symfony\Component\HttpFoundation\Request; * Adds "delete this note" action to respective note if the user logged in is the author. * * @package GNUsocial - * @category ProfileColor + * @category DeleteNote * * @author Eliseu Amaro * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org @@ -48,75 +51,125 @@ use Symfony\Component\HttpFoundation\Request; */ class DeleteNote extends NoteHandlerPlugin { + /** + * Delete the given Note + * + * Bear in mind, in GNU social deleting a Note only replaces its content with a tombstone + * @param DeleteEntity $deleteNote + * @return bool + * @throws BugFoundException + */ + private static function undertaker(DeleteEntity $deleteNote): bool + { + $note = Note::getById($deleteNote->getNoteId()); + $actor = Actor::getById($deleteNote->getActorId()); + + // Only let the original actor delete it + // TODO: should be anyone with permissions to do this? Admins and what not + if ($note->getActor()->getId() !== $actor->getId()) { + return false; + } + + // Create note tombstone to be rendered in a bit + $time_deleted = $deleteNote->getCreated(); + $deletion_time = date_format($time_deleted, 'Y/m/d H:i:s'); + $note->setModified($time_deleted); + $note_tombstone = "Actor {$actor->getUrl()} deleted this note at {$deletion_time}"; + $note->setContent($note_tombstone); + + // TODO: set note url with a new route, stating the note was deleted + + // Get note attachments + $note_attachments = $note->getAttachments(); + // Remove every relation this note has to its attachments + AttachmentToNote::removeWhereNoteId($note->getId()); + // Iterate through all note attachments to decrement their lives + foreach ($note_attachments as $attachment_entity) { + if ($attachment_entity->livesDecrementAndGet() <= 0) { + // Remove attachment from DB if there are no lives remaining + DB::remove($attachment_entity); + } else { + // This means it can live... for now + DB::merge($attachment_entity); + } + } + // Flush DB, a lot of stuff happened + DB::flush(); + + // Get the note rendered with tombstone text + // TODO: not sure if I put the actor as a mention here + $mentions = []; + $rendered = null; + Event::handle('RenderNoteContent', [$note_tombstone, 'text/plain', &$rendered, $actor, $note->getLanguageLocale(), &$mentions]); + $note->setRendered($rendered); + + // Apply changes to Note and flush + DB::merge($note); + DB::flush(); + + // Undertaker successful + return true; + } + + public static function deleteNote(int $note_id, int $actor_id, string $source = 'web'): ?Activity + { + $opts = ['note_id' => $note_id, 'actor_id' => $actor_id]; + $activity = null; + + // Try and find if note was already deleted + try { + DB::findOneBy('delete_note', $opts); + } catch (DuplicateFoundException $e) { + } catch (NotFoundException $e) { + // If none found, then undertaker has a job to do + $delete_entity = DeleteEntity::create($opts); + if (self::undertaker($delete_entity)) { + // Undertaker believes the actor can terminate this note + // We should persist this entity then + DB::persist($delete_entity); + + // TODO: "the server MAY replace the object with a Tombstone of the object" + // not sure if I can do that yet? + $activity = Activity::create([ + 'actor_id' => $actor_id, + 'verb' => 'delete', + 'object_type' => 'note', + 'object_id' => $note_id, + 'source' => $source, + ]); + DB::persist($activity); + + Event::handle('NewNotification', [$actor = Actor::getById($actor_id), $activity, [], "{$actor->getNickname()} deleted note {$note_id}"]); + } + } + return $activity; + } + public function onAddRoute(RouteLoader $r) { - $r->connect(id: 'delete_note', uri_path: '/object/note/{id<\d+>}/delete', target: Controller\DeleteNote::class); + $r->connect(id: 'delete_note_action', uri_path: '/object/note/{note_id<\d+>}/delete', target: Controller\DeleteNote::class); + //$r->connect(id: 'note_deleted', uri_path: '/object/note/{note_id<\d+>}/404', target: Controller\DeleteNote::class); + return Event::next; } public function onAddExtraNoteActions(Request $request, Note $note, array &$actions) { - $actions[] = [ - 'title' => _m('Delete note'), - 'classes' => '', - 'url' => Router::url('delete_note', ['id' => $note->getId()]), - ]; - } - - /** - * HTML rendering event that adds the repeat form as a note - * action, if a user is logged in - * - * @throws RedirectException - */ - // TODO: Refactoring to link instead of a form - /* public function onAddNoteActions(Request $request, Note $note, array &$actions) - { - if (($user = Common::user()) === null) { - return Event::next; - } - $user_id = $user->getId(); - $note_actor_id = $note->getActor()->getId(); - if ($user_id !== $note_actor_id) { - return Event::next; + // Only add action if note wasn't already deleted! + try { + DB::findOneBy('delete_note', ['note_id' => $note->getId()]); + } catch (NotFoundException $e) { + $delete_action_url = Router::url('delete_note', ['note_id' => $note->getId()]); + $query_string = $request->getQueryString(); + $delete_action_url .= '?from=' . mb_substr($query_string, 2); + $actions[] = [ + 'title' => _m('Delete note'), + 'classes' => '', + 'url' => $delete_action_url, + ]; + } catch (DuplicateFoundException $e) { } - $note_id = $note->getId(); - $form_delete = Form::create([ - ['submit_delete', SubmitType::class, - [ - 'label' => ' ', - 'attr' => [ - 'class' => 'button-container delete-button-container', - 'title' => _m('Delete this note.'), - ], - ], - ], - ['note_id', HiddenType::class, ['data' => $note_id]], - ["delete-{$note_id}", HiddenType::class, []], - ]); - - // Handle form - $ret = self::noteActionHandle( - $request, - $form_delete, - $note, - "delete-{$note_id}", - function ($note, $note_id) { - DB::remove(DB::findOneBy('note', ['id' => $note_id])); - DB::flush(); - - // Prevent accidental refreshes from resubmitting the form - throw new RedirectException(); - - return Event::stop; - }, - ); - - if ($ret !== null) { - return $ret; - } - $actions[] = $form_delete->createView(); return Event::next; - }*/ + } } diff --git a/plugins/DeleteNote/Entity/DeleteNote.php b/plugins/DeleteNote/Entity/DeleteNote.php new file mode 100644 index 0000000000..1200cd6feb --- /dev/null +++ b/plugins/DeleteNote/Entity/DeleteNote.php @@ -0,0 +1,129 @@ +. + +// }}} + +namespace Plugin\DeleteNote\Entity; + +use App\Core\Entity; + +/** + * DeleteNote entity + * + * Stores the Note id and Actor who performed the action upon deletion of Note + * + * @package GNUsocial + * @category DeleteNote + * + * @author Eliseu Amaro + * @copyright 2020 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class DeleteNote extends Entity +{ + // {{{ Autocode + // @codeCoverageIgnoreStart + private int $id; + private int $note_id; + private int $actor_id; + private \DateTimeInterface $created; + private \DateTimeInterface $modified; + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setNoteId(int $note_id): self + { + $this->note_id = $note_id; + return $this; + } + + public function getNoteId(): int + { + return $this->note_id; + } + + public function setActorId(int $actor_id): self + { + $this->actor_id = $actor_id; + return $this; + } + + public function getActorId(): int + { + return $this->actor_id; + } + + public function setCreated(\DateTimeInterface $created): self + { + $this->created = $created; + return $this; + } + + public function getCreated(): \DateTimeInterface + { + return $this->created; + } + + public function setModified(\DateTimeInterface $modified): self + { + $this->modified = $modified; + return $this; + } + + public function getModified(): \DateTimeInterface + { + return $this->modified; + } + + // @codeCoverageIgnoreEnd + // }}} Autocode + + public static function schemaDef(): array + { + return [ + 'name' => 'delete_note', + 'fields' => [ + 'id' => ['type' => 'serial', 'not null' => true, 'description' => 'Serial identifier'], + 'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Deleted Note identifier'], + 'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Actor who deleted the Note'], + 'created' => ['type' => 'datetime', 'not null' => true, 'description' => 'date this record was created', 'default' => 'CURRENT_TIMESTAMP'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified', 'default' => 'CURRENT_TIMESTAMP'], + ], + 'primary key' => ['id'], + 'foreign keys' => [ + 'note_id_to_id_fkey' => ['note', ['note_id' => 'id']], + 'actor_id_to_id_fkey' => ['actor', ['actor_id' => 'id']], + ], + 'indexes' => [ + 'deleted_note_id_idx' => ['note_id'], + ], + ]; + } +} diff --git a/plugins/DeleteNote/templates/delete_note/delete_note.html.twig b/plugins/DeleteNote/templates/delete_note/delete_note.html.twig new file mode 100644 index 0000000000..fad12edd79 --- /dev/null +++ b/plugins/DeleteNote/templates/delete_note/delete_note.html.twig @@ -0,0 +1,19 @@ +{% extends 'stdgrid.html.twig' %} +{% import "/cards/note/view.html.twig" as noteView %} + +{% block title %}{{ 'Delete ' | trans }}{{ 'note' | trans }}{% endblock %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock stylesheets %} + +{% block body %} + {{ parent() }} +
+
+ {{ noteView.macro_note_minimal(note) }} + {{ form(delete) }} +
+
+{% endblock body %} diff --git a/public/assets/icons/missing.svg.twig b/public/assets/icons/missing.svg.twig new file mode 100644 index 0000000000..98b16a4874 --- /dev/null +++ b/public/assets/icons/missing.svg.twig @@ -0,0 +1,23 @@ + + Missing icon + + + + + + \ No newline at end of file