diff --git a/plugins/Pinboard/Controller/APIv1.php b/plugins/Pinboard/Controller/APIv1.php index 8c4afa6b1f..9af6a2e1ec 100644 --- a/plugins/Pinboard/Controller/APIv1.php +++ b/plugins/Pinboard/Controller/APIv1.php @@ -23,7 +23,7 @@ use DateTimeImmutable; use DateTimeInterface; use Functional as F; use Plugin\Pinboard\Entity\Pin; -use Plugin\Pinboard\Util; +use Plugin\Pinboard\Entity\Token; use SimpleXMLElement; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -50,7 +50,7 @@ class APIv1 extends Controller return new Response('API requires authentication', status: 401); } - if (\is_null($user = Util::validateToken($auth_token))) { + if (\is_null($user = $this->validateToken($auth_token))) { return new Response('401 Forbidden', status: 401); } @@ -74,6 +74,15 @@ class APIv1 extends Controller } } + private function validateToken(string $input): ?LocalUser + { + if (!str_contains($input, ':')) { + return null; + } + [$nickame, $token] = explode(':', $input); + return Token::get($nickame, $token)?->getUser(); + } + private function deleteNoteAndMaybePin(LocalUser $user, Note $note, ?Pin $pin): void { $note->delete($user->getActor(), self::SOURCE); diff --git a/plugins/Pinboard/Controller/Settings.php b/plugins/Pinboard/Controller/Settings.php new file mode 100644 index 0000000000..c4b7ff9bf2 --- /dev/null +++ b/plugins/Pinboard/Controller/Settings.php @@ -0,0 +1,88 @@ +getEnabled() ?? false); + $form = Form::create([ + ['token', TextType::class, ['label' => _m('The token used to authenticate you via the Pinboard-compatible API'), 'data' => $token?->getUserTokenString(), 'disabled' => true]], + ['enable', SubmitType::class, ['label' => $enabled ? _m('Disable') : _m('Enable'), 'attr' => ['alt' => $enabled ? _m('Disable the use of the Pinboard-compatible API') : _m('Enable the use of the Pinboard-compatible API')]]], + ['regenerate', SubmitType::class, ['label' => _m('Regenerate Token'), 'disabled' => !$enabled]], + ], form_options: ['action' => Router::url(P\Pinboard::controller_route)]); + + return [ + '_template' => 'pinboard/settings.html.twig', + 'form_view' => $form->createView(), + 'form' => $form, + 'token' => $token, + 'was_enabled' => $enabled, + ]; + } + + public static function onPost(Request $request) + { + $user = Common::ensureLoggedIn(); + $params = self::setup(); + $form = $params['form']; + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var SubmitButton $enable_button */ + $enable_button = $form->get('enable'); + /** @var SubmitButton $regenerate_button */ + $regenerate_button = $form->get('regenerate'); + if ($enable_button->isClicked()) { + if ($params['was_enabled']) { + if (\is_null($params['token'])) { + throw new BugFoundException('Pinboard API can not be enabled if no token is present'); + } else { + $token = DB::refetch($parms['token']); + $token->setEnabled(false); + } + } else { + if (\is_null($params['token'])) { + DB::persist($token = Token::create(['actor_id' => $user->getId(), 'token' => Token::generateTokenString(), 'enabled' => true])); + } else { + $token = DB::refetch($parms['token']); + $token->setEnabled(true); + } + } + } elseif ($regenerate_button->isClicked()) { + if (\is_null($params['token'])) { + throw new ClientException(_m('Can not regenerate token when no token exists. Enable Pinboard first')); + } else { + $token = DB::refetch($params['token']); + $token->setToken(Token::generateTokenString()); + } + } else { + throw new ClientException(_m('Invalid form submission')); + } + Cache::set(Token::cacheKeys($user->getNickname())['user-token'], $token); + DB::flush(); + return Form::forceRedirect($form, $request); + } + throw new ClientException(_m('Do not GET this page')); + } +} diff --git a/plugins/Pinboard/Entity/Token.php b/plugins/Pinboard/Entity/Token.php new file mode 100644 index 0000000000..976ff02039 --- /dev/null +++ b/plugins/Pinboard/Entity/Token.php @@ -0,0 +1,137 @@ +actor_id = $actor_id; + return $this; + } + + public function getActorId(): int + { + return $this->actor_id; + } + + public function setToken(string $token): self + { + $this->token = mb_substr($token, 0, 64); + return $this; + } + + public function getToken(): string + { + return $this->token; + } + + public function setEnabled(bool $enabled): self + { + $this->enabled = $enabled; + return $this; + } + + public function getEnabled(): bool + { + return $this->enabled; + } + + public function setCreated(DateTimeInterface $created): self + { + $this->created = $created; + return $this; + } + + public function getCreated(): DateTimeInterface + { + return $this->created; + } + + // @codeCoverageIgnoreEnd + // }}} Autocode + + public static function cacheKeys(string $nickname): array + { + return [ + 'user-token' => "pinboard-token-{$nickname}", + ]; + } + + public function getUser(): LocalUser + { + return LocalUser::getById($this->getActorId()); + } + + /** + * Get a token for a $nickname and $token pair, unless given a $user, in which case the token field is not validated + * + * XXX: may need to verify it's timing safe + */ + public static function get(?string $nickname, ?string $token, ?LocalUser $user = null): ?self + { + if (!\is_null($user)) { + return Cache::get( + self::cacheKeys($user->getNickname())['user-token'], + fn () => DB::dql( + 'select t from \Plugin\Pinboard\Entity\Token t where t.actor_id = :id', + ['id' => $user->getId()], + options: ['limit' => 1], + ), + ); + } elseif (!\is_null($nickname) && !\is_null($token)) { + return Cache::get( + self::cacheKeys($nickname)['user-token'], + fn () => DB::dql( + <<<'EOF' + select lu from \App\Entity\LocalUser lu + join \Plugin\Pinboard\Entity\Token t on t.actor_id = lu.actor_id + where lu.nickname = :nickname and t.token = :token and t.enabled = true + EOF, + ['nickname' => $nickame, 'token' => $token], + options: ['limit' => 1], + ), + ); + } + } + + public function getUserTokenString() + { + return LocalUser::getById($this->getActorId())->getNickname() . ':' . $this->getToken(); + } + + public static function generateTokenString(): string + { + return bin2hex(random_bytes(Common::config('plugin_pinboard', 'token_length') / 2)); + } + + public static function schemaDef(): array + { + return [ + 'name' => 'pinboard_token', + 'fields' => [ + 'actor_id' => ['type' => 'int', 'not null' => true, 'description' => 'Actor who created this note'], + 'token' => ['type' => 'varchar', 'length' => 64, 'not null' => true, 'description' => 'The token this user has enabled'], + 'enabled' => ['type' => 'bool', 'not null' => true, 'default' => false, 'description whether this user enabled the pinboard API'], + 'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'], + ], + 'primary key' => ['actor_id'], + ]; + } +} diff --git a/plugins/Pinboard/Pinboard.php b/plugins/Pinboard/Pinboard.php index 7a533cc6fd..29bf6a66c3 100644 --- a/plugins/Pinboard/Pinboard.php +++ b/plugins/Pinboard/Pinboard.php @@ -36,11 +36,21 @@ use App\Core\Event; use App\Core\Modules\Plugin; use App\Core\Router; use Plugin\Pinboard\Controller as C; +use Symfony\Component\HttpFoundation\Request; class Pinboard extends Plugin { + public const controller_route = 'pinboard_settings'; + public function onAddRoute(Router $r): bool { + $r->connect( + self::controller_route, + '/pinboard/settings', + C\Settings::class, + options: ['method' => 'post'], + ); + $r->connect( 'pinboard_posts_update', '/pinboard/v1/posts/update', @@ -79,4 +89,17 @@ class Pinboard extends Plugin return Event::next; } + + public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool + { + if ($section === 'api') { + $tabs[] = [ + 'title' => 'Pinboard', + 'desc' => 'Pinboard API settings', + 'id' => 'settings-pinboard-api', + 'controller' => C\Settings::setup(), + ]; + } + return Event::next; + } } diff --git a/plugins/Pinboard/Util.php b/plugins/Pinboard/Util.php deleted file mode 100644 index a5977d2dc7..0000000000 --- a/plugins/Pinboard/Util.php +++ /dev/null @@ -1,16 +0,0 @@ - 'nickname']); - } -} diff --git a/plugins/Pinboard/templates/pinboard/settings.html.twig b/plugins/Pinboard/templates/pinboard/settings.html.twig new file mode 100644 index 0000000000..0d5ba54e8a --- /dev/null +++ b/plugins/Pinboard/templates/pinboard/settings.html.twig @@ -0,0 +1,3 @@ +
+ {{ form(form_view) }} +
diff --git a/plugins/WebHooks/WebHooks.php b/plugins/WebHooks/WebHooks.php index 92e26bc757..3d8e608743 100644 --- a/plugins/WebHooks/WebHooks.php +++ b/plugins/WebHooks/WebHooks.php @@ -45,7 +45,7 @@ class WebHooks extends Plugin public function onAddRoute(Router $r) { - $r->connect(self::controller_route, '/webhook-settings', C\WebHooks::class); + $r->connect(self::controller_route, '/webhook-settings', C\WebHooks::class, options: ['method' => 'post']); } public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool diff --git a/social.yaml b/social.yaml index e4eff3d6b2..3b59558c11 100644 --- a/social.yaml +++ b/social.yaml @@ -138,6 +138,9 @@ parameters: plugin_webhooks: method: post + plugin_pinboard: + token_length: 64 + theme: server: ssl: