From bd868a267549db2aa8c0b4de02c840dc338a64a3 Mon Sep 17 00:00:00 2001 From: Hugo Sales Date: Mon, 28 Mar 2022 03:19:35 +0100 Subject: [PATCH] [PLUGINS][Pinboard] Add initial implementation of Pinboard API, lacking authentication, tags and feed endpoints --- plugins/Pinboard/Controller/API.php | 133 ++++++++++++++++++ plugins/Pinboard/Entity/Pin.php | 131 +++++++++++++++++ plugins/Pinboard/Pinboard.php | 58 ++++++++ plugins/Pinboard/Util.php | 16 +++ .../templates/pinboard/render.html.twig | 4 + 5 files changed, 342 insertions(+) create mode 100644 plugins/Pinboard/Controller/API.php create mode 100644 plugins/Pinboard/Entity/Pin.php create mode 100644 plugins/Pinboard/Pinboard.php create mode 100644 plugins/Pinboard/Util.php create mode 100644 plugins/Pinboard/templates/pinboard/render.html.twig diff --git a/plugins/Pinboard/Controller/API.php b/plugins/Pinboard/Controller/API.php new file mode 100644 index 0000000000..9b6a00a0f1 --- /dev/null +++ b/plugins/Pinboard/Controller/API.php @@ -0,0 +1,133 @@ +string('format'); + $auth_token = $this->string('auth_token'); + if ($format !== 'json') { + throw new ServerException(_m('Only JSON is supported')); + } + + if (\is_null($auth_token)) { + return new Response('API requires authentication', status: 401); + } + + if (\is_null($user = Util::validateToken($auth_token))) { + return new Response('401 Forbidden', status: 401); + } + + return $user; + } + + /** + * Last post update + */ + public function posts_update(Request $request) + { + self::before(); + + return new JsonResponse( + ['update_time' => date(\DATE_ISO8601)], // TODO fetch latest and reply accordingly + status: 200, + headers: ['content-type' => 'application/json'], + ); + } + + /** + * Add a pin + */ + public function posts_add(Request $request) + { + $user = self::before(); + + if (\is_null($url = $this->string('url'))) { + throw new ClientException('URL must be provided'); + } + + if (\is_null($title = $this->string('description'))) { // Logically. + throw new ClientException('Desciption must be provided'); + } + + $description = $this->string('extended') ?? ''; + + $tags = []; + if (!\is_null($tags_text = $this->string('tags'))) { + Formatting::toArray($tags_text, $tags, Formatting::SPLIT_BY_BOTH); + } + + $modified = $this->string('dt') ?? new Datetime; + $replace = $this->bool('replace') ?? true; + $public = $this->bool('shared') ?? true; + $unread = $this->bool('toread') ?? false; + + $result_code = 'something went wrong'; + [$pin, $existed] = Pin::checkExistingAndCreateOrUpdate( + args: [ + 'actor_id' => $user->getId(), + 'url_hash' => hash('sha256', $url), + 'url' => $url, + 'replace' => $replace, + 'public' => $public, + 'unread' => $unread, + 'modified' => $modified, + ], + find_by_keys: ['actor_id', 'url_hash'], + ); + + if ($existed) { + if (!$replace) { + $result_code = 'item already exists'; + } else { + throw new ServerException('Updating is unimplemented'); // TODO delete old note, create new one + } + } else { + DB::persist($note = Note::create([ + 'actor_id' => $user->getId(), + 'content' => $url, + 'content_type' => 'text/uri-list', + 'rendered' => Formatting::twigRenderFile('pinboard/render.html.twig', ['url' => $url, 'title' => $title, 'description' => $description]), + 'reply_to' => null, + 'is_local' => true, + 'source' => 'Pinboard API', + 'scope' => $public ? VisibilityScope::EVERYWHERE->value : VisibilityScope::ADDRESSEE->value, + 'language_id' => $user->getActor()->getTopLanguage()->getId(), + 'type' => 'pin', + 'title' => $title, + ])); + $note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL)); + $pin->setNoteId($note->getId()); + Conversation::assignLocalConversation($note, null); + // TODO handle tags + DB::flush(); + $result_code = 'done'; + } + + return new JsonResponse( + ['result_code' => $result_code], + status: 200, + headers: ['content-type' => 'application/json'], + ); + } +} diff --git a/plugins/Pinboard/Entity/Pin.php b/plugins/Pinboard/Entity/Pin.php new file mode 100644 index 0000000000..0883fa77d9 --- /dev/null +++ b/plugins/Pinboard/Entity/Pin.php @@ -0,0 +1,131 @@ +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 setUrlHash(string $url_hash): self + { + $this->url_hash = mb_substr($url_hash, 0, 64); + return $this; + } + + public function getUrlHash(): string + { + return $this->url_hash; + } + + public function setUrl(string $url): self + { + $this->url = $url; + return $this; + } + + public function getUrl(): string + { + return $this->url; + } + + public function setReplace(bool $replace): self + { + $this->replace = $replace; + return $this; + } + + public function getReplace(): bool + { + return $this->replace; + } + + public function setPublic(bool $public): self + { + $this->public = $public; + return $this; + } + + public function getPublic(): bool + { + return $this->public; + } + + public function setUnread(bool $unread): self + { + $this->unread = $unread; + return $this; + } + + public function getUnread(): bool + { + return $this->unread; + } + + 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' => 'pinboard_pin', + 'fields' => [ + 'note_id' => ['type' => 'int', 'not null' => true, 'description' => 'Id of the note this pin created'], + 'actor_id' => ['type' => 'int', 'not null' => true, 'description' => 'Actor who created this note'], + 'url_hash' => ['type' => 'char', 'length' => 64, 'not null' => true, 'description' => 'Hash of the url, for indexing'], + 'url' => ['type' => 'text', 'not null' => true, 'description' => 'Plain URL this pin refers to (gets rendered in the corresponding note, useful for replace)'], + 'replace' => ['type' => 'bool', 'not null' => true, 'description' => 'Replace any existing bookmark with this URL. Default is yes. If set to no, will throw an error if bookmark exists'], + 'public' => ['type' => 'bool', 'not null' => true, 'description' => 'Whether private or public'], + 'unread' => ['type' => 'bool', 'not null' => true, 'description' => 'Has this been read'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'], + ], + 'primary key' => ['actor_id', 'url_hash'], + ]; + } +} diff --git a/plugins/Pinboard/Pinboard.php b/plugins/Pinboard/Pinboard.php new file mode 100644 index 0000000000..2be31f214e --- /dev/null +++ b/plugins/Pinboard/Pinboard.php @@ -0,0 +1,58 @@ +. +// }}} + +/** + * Pinboard server API, doesn't (currently) allow importing from the + * official website + * + * @package GNUsocial + * + * @author Hugo Sales + * @copyright 2022 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ + +namespace Plugin\Pinboard; + +use App\Core\Event; +use App\Core\Modules\Plugin; +use App\Core\Router; +use Plugin\Pinboard\Controller as C; + +class Pinboard extends Plugin +{ + public function onAddRoute(Router $r): bool + { + $r->connect( + 'pinboard_posts_update', + '/pinboard/v1/posts/update', + [C\API::class, 'posts_update'], + ); + + $r->connect( + 'pinboard_posts_add', + '/pinboard/v1/posts/add', + [C\API::class, 'posts_add'], + ); + + return Event::next; + } +} diff --git a/plugins/Pinboard/Util.php b/plugins/Pinboard/Util.php new file mode 100644 index 0000000000..a5977d2dc7 --- /dev/null +++ b/plugins/Pinboard/Util.php @@ -0,0 +1,16 @@ + 'nickname']); + } +} diff --git a/plugins/Pinboard/templates/pinboard/render.html.twig b/plugins/Pinboard/templates/pinboard/render.html.twig new file mode 100644 index 0000000000..b6aea66eae --- /dev/null +++ b/plugins/Pinboard/templates/pinboard/render.html.twig @@ -0,0 +1,4 @@ +
+ {# User provided title, do not translate #} + {{ description }} {# Likewise for the description, which may be empty#} +