[PLUGIN][Pinboard] Implement remaining API endpoints, restructure, fix template

This commit is contained in:
Hugo Sales 2022-03-28 23:19:28 +01:00
parent a9665177ea
commit e2501ee927
Signed by: someonewithpc
GPG Key ID: 7D0C7EAFC9D835A0
5 changed files with 419 additions and 146 deletions

View File

@ -1,133 +0,0 @@
<?php
declare(strict_types = 1);
namespace Plugin\Pinboard\Controller;
use App\Core\Controller;
use App\Core\DB;
use App\Core\Router;
use App\Core\VisibilityScope;
use App\Entity\Note;
use App\Util\Exception\ClientException;
use App\Util\Exception\ServerException;
use App\Util\Formatting;
use Component\Conversation\Conversation;
use Datetime;
use Plugin\Pinboard\Entity\Pin;
use Plugin\Pinboard\Util;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class API extends Controller
{
private function before()
{
$format = $this->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'],
);
}
}

View File

@ -0,0 +1,327 @@
<?php
declare(strict_types = 1);
namespace Plugin\Pinboard\Controller;
use App\Core\Cache;
use App\Core\Controller;
use App\Core\DB;
use function App\Core\I18n\_m;
use App\Core\Router;
use App\Core\VisibilityScope;
use App\Entity\LocalUser;
use App\Entity\Note;
use App\Util\Exception\BugFoundException;
use App\Util\Exception\ClientException;
use App\Util\Exception\ServerException;
use App\Util\Formatting;
use Component\Conversation\Conversation;
use Component\Tag\Entity\NoteTag;
use Datetime;
use DateTimeImmutable;
use DateTimeInterface;
use Functional as F;
use Plugin\Pinboard\Entity\Pin;
use Plugin\Pinboard\Util;
use SimpleXMLElement;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
class APIv1 extends Controller
{
public const SOURCE = 'Pinboard API v1';
private function before()
{
$format = $this->string('format');
$auth_token = $this->string('auth_token');
if (\is_null($format)) {
return new Response(_m('Format must be specified'), status: 400);
}
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;
}
private function respond(array $result, int $status = 200): Response
{
$format = $this->string('format');
if ($format === 'json') {
return new JsonResponse($result, status: $status);
} elseif ($format === 'xml') {
// $encoder = new XmlEncoder();
// $xml = $encoder->encode($result, 'xml');
[$tag_names, $keys] = F\zip(...F\map(array_keys($result), fn (string $k) => explode('_', $k)));
$xml = new SimpleXMLElement('<' . $tag_names[0] . '/>');
dd($tag_names, $keys, $result, $xml, (string) $xml);
$xml->addChild();
dd($xml);
return new Response(content: $xml, status: $statis);
}
}
private function deleteNoteAndMaybePin(LocalUser $user, Note $note, ?Pin $pin): void
{
$note->delete($user->getActor(), self::SOURCE);
if (!\is_null($note)) {
DB::remove($pin);
}
DB::flush();
}
private function parseTags(): array
{
$tags = [];
if (!\is_null($tags_text = $this->string('tags'))) {
Formatting::toArray($tags_text, $tags, Formatting::SPLIT_BY_BOTH);
}
return $tags;
}
private function getLatestModified(LocalUser $user): string
{
return Cache::get(
Pin::cacheKeys($user)['last-modified'],
fn () => DB::dql(
<<<'EOF'
select MAX(p.modified) as max from \Plugin\Pinboard\Entity\Pin p
where p.actor_id = :actor_id
group by p.modified
EOF,
['actor_id' => $user->getId()],
)[0]['max'],
);
}
/**
* @param Pin[] $pins
*
* @return array json for response
*/
private function formatPins(LocalUser $user, array $pins): array
{
return [
'date' => self::getLatestModified($user),
'user' => $user->getNickname(),
'posts' => F\map(
$pins,
fn (Pin $pin) => [
'href' => $pin->getUrl(),
'description' => $pin->getTitle(),
'extended' => $pin->getDescription(),
'meta' => hash('md5', $pin->getModified()->format(DateTimeInterface::ISO8601)),
'hash' => hash('md5', $pin->getUrl() . $pin->getTitle() . $pin->getDescription()), // idk...
'time' => $pin->getModified()->format(DateTimeInterface::ISO8601),
'shared' => $pin->getPublic(),
'toread' => $pin->getUnread(),
'tags' => implode(' ', F\map($pin->getTags(), fn (NoteTag $tag) => $tag->getTag())),
],
),
];
}
// ----------------------
/**
* Returns the most recent time a bookmark was added, updated or
* deleted. Use this before calling posts/all to see if the data
* has changed since the last fetch
*/
public function posts_update(Request $request)
{
$user = self::before();
return self::respond(['update_time' => self::getLatestModified($user)]);
}
/**
* Add a bookmark
*/
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 = self::parseTags();
$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,
'title' => $title,
'description' => $description,
'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
$this->deleteNoteAndMaybePin($user, $pin->getNote(), pin: null);
}
} 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' => self::SOURCE,
'scope' => $public ? VisibilityScope::EVERYWHERE->value : VisibilityScope::ADDRESSEE->value,
'language_id' => $user->getActor()->getTopLanguage()->getId(),
'type' => 'page',
'title' => $title,
]));
$note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL));
$pin->setNoteId($note->getId());
Conversation::assignLocalConversation($note, null);
DB::persist($pin);
// TODO handle tags
DB::flush();
$result_code = 'done';
}
return self::respond(['result_code' => $result_code]);
}
/**
* Delete a bookmark
*/
public function posts_delete(Request $request)
{
$user = self::before($request);
$url = $this->string('url');
if (\is_null($url)) {
throw new ClientException('URL must be provided');
}
$pin = DB::findOneBy(Pin::class, ['actor_id' => $user->getId(), 'url_hash' => hash('sha256', $url)], return_null: true);
if (\is_null($pin)) {
return self::respond(['result_code' => 'item not found']);
} else {
$this->deleteNoteAndMaybePin($user, $pin->getNote(), $pin);
return self::respond(['result_code' => 'done']);
}
}
/**
* Returns one or more posts on a single day matching the
* arguments. If no date or url is given, date of most recent
* bookmark will be used
*/
public function posts_get(Request $request)
{
$user = self::before($request);
$tags = self::parseTags();
$day = $this->string('dt');
$url = $this->string('url');
$meta = $this->bool('meta');
if (!\is_null($tags) && $tags !== []) {
throw new ClientException(_m('tags attribute not implemented'));
}
if (\is_null($url) && \is_null($day)) {
$pins = DB::findBy(Pin::class, ['actor_id' => $user->getId(), 'gte' => ['modified' => new DateTimeImmutable('today')]]);
} elseif (!\is_null($day)) {
$day = new DateTimeImmutable($day);
$pins = DB::findBy(Pin::class, ['actor_id' => $user->getId(), 'gte' => ['modified' => $day], 'lt' => ['modified' => $day->modify('+1 day')]]);
} elseif (!\is_null($url)) {
$pins = DB::findBy(Pin::class, ['actor_id' => $user->getId(), 'url_hash' => hash('sha256', $url)]);
} else {
throw new BugFoundException('Wonky logic in pinboard/posts/get');
}
return self::respond(self::formatPins($user, $pins));
}
/**
* Returns a list of the user's most recent posts, filtered by tag
*/
public function posts_recent(Request $request)
{
$user = self::before($request);
$tags = self::parseTags();
$limit = min($this->int('count') ?? 15, 100);
if (!\is_null($tags) && $tags !== []) {
throw new ClientException('tags attribute not implemented');
}
$pins = DB::findBy(Pin::class, ['actor_id' => $user->getId()], order_by: ['modified' => 'asc'], limit: $limit);
return self::respond(self::formatPins($user, $pins));
}
/**
* Returns a list of dates with the number of posts at each date
*/
public function posts_dates(Request $request)
{
return self::respond(['result_code' => 'unimplemented']);
}
/**
* Returns all bookmarks in the user's account
*/
public function posts_all(Request $request)
{
$user = self::before($request);
$tags = self::parseTags();
$offset = $this->int('start');
$limit = $this->int('results');
$start_time = $this->string('fromdt');
$end_time = $this->string('todt');
$meta = $this->bool('meta');
if (!\is_null($tags) && $tags !== []) {
throw new ClientException('tags attribute not implemented');
}
$criteria = ['actor_id' => $user->getId()];
if (!\is_null($start_time)) {
$criteria['gte'] = ['modified' => new DateTimeImmutable($start_time)];
}
if (!\is_null($end_time)) {
$criteria['lt'] = ['modified' => new DateTimeImmutable($end_time)];
}
$pins = DB::findBy(Pin::class, $criteria, order_by: ['modified' => 'asc'], offset: $offset, limit: $limit);
return self::respond(self::formatPins($user, $pins)['posts']);
}
}

View File

@ -5,6 +5,10 @@ declare(strict_types = 1);
namespace Plugin\Pinboard\Entity;
use App\Core\Entity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Entity\Note;
use Component\Tag\Entity\NoteTag;
use DateTimeInterface;
class Pin extends Entity
@ -15,6 +19,8 @@ class Pin extends Entity
private int $actor_id;
private string $url_hash;
private string $url;
private string $title;
private ?string $description = null;
private bool $replace;
private bool $public;
private bool $unread;
@ -64,6 +70,28 @@ class Pin extends Entity
return $this->url;
}
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
public function getTitle(): string
{
return $this->title;
}
public function setDescription(?string $description): self
{
$this->description = $description;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setReplace(bool $replace): self
{
$this->replace = $replace;
@ -111,6 +139,27 @@ class Pin extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function cacheKeys(int|LocalUser|Actor $user): array
{
$id = \is_int($user) ? $user : $user->getId();
return [
'last-modified' => "pinboard-pin-{$id}-last-modified",
];
}
public function getNote(): Note
{
return Note::getById($this->getNoteId());
}
/**
* @return NoteTag[]
*/
public function getTags(): array
{
return [];
}
public static function schemaDef(): array
{
return [
@ -120,12 +169,17 @@ class Pin extends Entity
'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)'],
'title' => ['type' => 'text', 'not null' => true, 'description' => 'Title given to this bookmark'],
'description' => ['type' => 'text', 'description' => 'Description given to this bookmark'],
'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'],
'indexes' => [
'actor_modified_idx' => ['actor_id', 'modified'],
],
];
}
}

View File

@ -44,13 +44,37 @@ class Pinboard extends Plugin
$r->connect(
'pinboard_posts_update',
'/pinboard/v1/posts/update',
[C\API::class, 'posts_update'],
[C\APIv1::class, 'posts_update'],
);
$r->connect(
'pinboard_posts_add',
'/pinboard/v1/posts/add',
[C\API::class, 'posts_add'],
[C\APIv1::class, 'posts_add'],
);
$r->connect(
'pinboard_posts_delete',
'/pinboard/v1/posts/delete',
[C\APIv1::class, 'posts_delete'],
);
$r->connect(
'pinboard_posts_get',
'/pinboard/v1/posts/get',
[C\APIv1::class, 'posts_get'],
);
$r->connect(
'pinboard_posts_recent',
'/pinboard/v1/posts/recent',
[C\APIv1::class, 'posts_recent'],
);
$r->connect(
'pinboard_posts_all',
'/pinboard/v1/posts/all',
[C\APIv1::class, 'posts_all'],
);
return Event::next;

View File

@ -1,4 +1,5 @@
<details>
<summary><a href="{{ url }}" rel="noopener noreferrer" title="{{ title }}"></a></summary> {# User provided title, do not translate #}
{{ description }} {# Likewise for the description, which may be empty#}
<details class="details-summary-title">
<summary>{{ title }}</summary> {# User provided title, do not translate #}
<a href="{{ url }}" rel="noopener noreferrer">{{ url }}</a>
<p>{{ description }}</p> {# Likewise for the description, which may be empty #}
</details>