@@ -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); | |||
@@ -0,0 +1,88 @@ | |||
<?php | |||
declare(strict_types = 1); | |||
namespace Plugin\Pinboard\Controller; | |||
use App\Core\Cache; | |||
use App\Core\Controller; | |||
use App\Core\DB; | |||
use App\Core\Form; | |||
use function App\Core\I18n\_m; | |||
use App\Core\Router; | |||
use App\Util\Common; | |||
use App\Util\Exception\BugFoundException; | |||
use App\Util\Exception\ClientException; | |||
use Plugin\Pinboard as P; | |||
use Plugin\Pinboard\Entity\Token; | |||
use Symfony\Component\Form\Extension\Core\Type\SubmitType; | |||
use Symfony\Component\Form\Extension\Core\Type\TextType; | |||
use Symfony\Component\Form\SubmitButton; | |||
use Symfony\Component\HttpFoundation\Request; | |||
class Settings extends Controller | |||
{ | |||
public static function setup() | |||
{ | |||
$user = Common::ensureLoggedIn(); | |||
$token = Token::get(nickname: null, token: null, user: $user); | |||
$enabled = ($token?->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')); | |||
} | |||
} |
@@ -0,0 +1,137 @@ | |||
<?php | |||
declare(strict_types = 1); | |||
namespace Plugin\Pinboard\Entity; | |||
use App\Core\Cache; | |||
use App\Core\DB; | |||
use App\Core\Entity; | |||
use App\Entity\LocalUser; | |||
use App\Util\Common; | |||
use DateTimeInterface; | |||
class Token extends Entity | |||
{ | |||
// {{{ Autocode | |||
// @codeCoverageIgnoreStart | |||
private int $actor_id; | |||
private string $token; | |||
private bool $enabled = false; | |||
private DateTimeInterface $created; | |||
public function setActorId(int $actor_id): self | |||
{ | |||
$this->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'], | |||
]; | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -1,16 +0,0 @@ | |||
<?php | |||
declare(strict_types = 1); | |||
namespace Plugin\Pinboard; | |||
use App\Core\DB; | |||
use App\Entity\LocalUser; | |||
abstract class Util | |||
{ | |||
public static function validateToken(string $token): ?LocalUser | |||
{ | |||
return DB::findOneBy(LocalUser::class, ['nickname' => 'nickname']); | |||
} | |||
} |
@@ -0,0 +1,3 @@ | |||
<div> | |||
{{ form(form_view) }} | |||
</div> |
@@ -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 | |||
@@ -138,6 +138,9 @@ parameters: | |||
plugin_webhooks: | |||
method: post | |||
plugin_pinboard: | |||
token_length: 64 | |||
theme: | |||
server: | |||
ssl: | |||