2022-03-29 07:19:28 +09:00
|
|
|
<?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;
|
2022-10-20 06:38:49 +09:00
|
|
|
use App\Util\Exception\InvalidRequestException;
|
2022-03-29 07:19:28 +09:00
|
|
|
use App\Util\Exception\ServerException;
|
|
|
|
use App\Util\Formatting;
|
|
|
|
use Component\Conversation\Conversation;
|
2022-04-01 08:17:57 +09:00
|
|
|
use Component\Link\Link;
|
2022-03-29 07:19:28 +09:00
|
|
|
use Component\Tag\Entity\NoteTag;
|
2022-04-01 08:17:57 +09:00
|
|
|
use Component\Tag\Tag;
|
2022-03-29 07:19:28 +09:00
|
|
|
use Datetime;
|
|
|
|
use DateTimeImmutable;
|
|
|
|
use DateTimeInterface;
|
|
|
|
use Functional as F;
|
|
|
|
use Plugin\Pinboard\Entity\Pin;
|
2022-03-31 11:28:26 +09:00
|
|
|
use Plugin\Pinboard\Entity\Token;
|
2022-03-29 07:19:28 +09:00
|
|
|
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';
|
|
|
|
|
2022-04-01 08:16:04 +09:00
|
|
|
private function preCheck()
|
2022-03-29 07:19:28 +09:00
|
|
|
{
|
|
|
|
$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);
|
|
|
|
}
|
|
|
|
|
2022-03-31 11:28:26 +09:00
|
|
|
if (\is_null($user = $this->validateToken($auth_token))) {
|
2022-03-29 07:19:28 +09:00
|
|
|
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)));
|
2022-10-20 06:38:49 +09:00
|
|
|
$xml = new SimpleXMLElement('<' . $tag_names[0] . '/>');
|
|
|
|
// dd($tag_names, $keys, $result, $xml, (string) $xml);
|
|
|
|
// $xml->addChild();
|
|
|
|
// dd($xml);
|
2022-10-20 06:38:49 +09:00
|
|
|
return new Response(content: (string) $xml, status: $status);
|
2022-10-20 06:38:49 +09:00
|
|
|
} else {
|
|
|
|
throw new InvalidRequestException;
|
2022-03-29 07:19:28 +09:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-03-31 11:28:26 +09:00
|
|
|
private function validateToken(string $input): ?LocalUser
|
|
|
|
{
|
|
|
|
if (!str_contains($input, ':')) {
|
|
|
|
return null;
|
|
|
|
}
|
2022-04-01 06:06:37 +09:00
|
|
|
[$id, $token] = explode(':', $input);
|
2022-04-01 08:17:57 +09:00
|
|
|
if (filter_var($id, \FILTER_VALIDATE_INT) !== false) {
|
2022-04-01 08:16:04 +09:00
|
|
|
return Token::get((int) $id, $token)?->getUser();
|
|
|
|
} else {
|
|
|
|
return null;
|
|
|
|
}
|
2022-03-31 11:28:26 +09:00
|
|
|
}
|
|
|
|
|
2022-03-29 07:19:28 +09:00
|
|
|
private function deleteNoteAndMaybePin(LocalUser $user, Note $note, ?Pin $pin): void
|
|
|
|
{
|
|
|
|
$note->delete($user->getActor(), self::SOURCE);
|
2022-04-01 08:16:04 +09:00
|
|
|
if (!\is_null($pin)) {
|
2022-03-29 07:19:28 +09:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2022-04-01 08:16:04 +09:00
|
|
|
/**
|
2022-04-01 08:17:57 +09:00
|
|
|
* @param Pin[] $pins
|
2022-04-01 08:16:04 +09:00
|
|
|
* @param string[] $tags
|
|
|
|
*/
|
|
|
|
private function filterByTags(array $pins, array $tags): array
|
|
|
|
{
|
|
|
|
return F\select($pins, fn (Pin $pin) => array_intersect(F\map($pin->getTags(), fn (NoteTag $tag) => $tag->getTag()), $tags) !== []);
|
|
|
|
}
|
|
|
|
|
2022-03-29 07:19:28 +09:00
|
|
|
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
|
2022-04-01 08:16:04 +09:00
|
|
|
* deleted. Use this preCheck calling posts/all to see if the data
|
2022-03-29 07:19:28 +09:00
|
|
|
* has changed since the last fetch
|
|
|
|
*/
|
|
|
|
public function posts_update(Request $request)
|
|
|
|
{
|
2022-04-01 08:16:04 +09:00
|
|
|
$check = self::preCheck();
|
|
|
|
if (!$check instanceof LocalUser) {
|
|
|
|
return $check;
|
|
|
|
} else {
|
|
|
|
$user = $check;
|
|
|
|
}
|
2022-03-29 07:19:28 +09:00
|
|
|
|
|
|
|
return self::respond(['update_time' => self::getLatestModified($user)]);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add a bookmark
|
|
|
|
*/
|
|
|
|
public function posts_add(Request $request)
|
|
|
|
{
|
2022-04-01 08:16:04 +09:00
|
|
|
$check = self::preCheck();
|
|
|
|
if (!$check instanceof LocalUser) {
|
|
|
|
return $check;
|
|
|
|
} else {
|
|
|
|
$user = $check;
|
|
|
|
}
|
2022-03-29 07:19:28 +09:00
|
|
|
|
|
|
|
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 {
|
|
|
|
$this->deleteNoteAndMaybePin($user, $pin->getNote(), pin: null);
|
2022-04-01 08:16:04 +09:00
|
|
|
// Continue below
|
2022-03-29 07:19:28 +09:00
|
|
|
}
|
2022-04-01 08:16:04 +09:00
|
|
|
}
|
|
|
|
DB::persist($note = Note::create([
|
|
|
|
'actor_id' => $user->getId(),
|
|
|
|
'content' => "Bookmark: {$url}\nTitle: {$title}\nDescription: {$description}",
|
|
|
|
'content_type' => 'text/plain',
|
|
|
|
'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' => Pin::note_type,
|
|
|
|
'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);
|
|
|
|
foreach ($tags as $tag) {
|
|
|
|
if (!\is_null($nt = Tag::maybeCreateTag(tag: $tag, note_id: $note->getId(), lang_id: $note->getLanguageId()))) {
|
|
|
|
DB::persist($nt);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (array_values(Link::maybeCreateLink($url, $note->getId())) !== [null, null]) {
|
2022-03-29 07:19:28 +09:00
|
|
|
DB::flush();
|
|
|
|
$result_code = 'done';
|
|
|
|
}
|
|
|
|
|
|
|
|
return self::respond(['result_code' => $result_code]);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Delete a bookmark
|
|
|
|
*/
|
|
|
|
public function posts_delete(Request $request)
|
|
|
|
{
|
2022-04-01 08:16:04 +09:00
|
|
|
$check = self::preCheck();
|
|
|
|
if (!$check instanceof LocalUser) {
|
|
|
|
return $check;
|
|
|
|
} else {
|
|
|
|
$user = $check;
|
|
|
|
}
|
|
|
|
|
2022-04-01 08:17:57 +09:00
|
|
|
$url = $this->string('url');
|
2022-03-29 07:19:28 +09:00
|
|
|
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)
|
|
|
|
{
|
2022-04-01 08:16:04 +09:00
|
|
|
$check = self::preCheck();
|
|
|
|
if (!$check instanceof LocalUser) {
|
|
|
|
return $check;
|
|
|
|
} else {
|
|
|
|
$user = $check;
|
|
|
|
}
|
|
|
|
|
2022-03-29 07:19:28 +09:00
|
|
|
$tags = self::parseTags();
|
|
|
|
$day = $this->string('dt');
|
|
|
|
$url = $this->string('url');
|
|
|
|
$meta = $this->bool('meta');
|
|
|
|
|
|
|
|
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')]]);
|
2022-10-20 06:38:49 +09:00
|
|
|
} elseif (!\is_null($url)) { // @phpstan-ignore-line this is here for clarity
|
2022-03-29 07:19:28 +09:00
|
|
|
$pins = DB::findBy(Pin::class, ['actor_id' => $user->getId(), 'url_hash' => hash('sha256', $url)]);
|
|
|
|
} else {
|
|
|
|
throw new BugFoundException('Wonky logic in pinboard/posts/get');
|
|
|
|
}
|
|
|
|
|
2022-04-01 08:16:04 +09:00
|
|
|
$pins = self::filterByTags($pins, $tags);
|
|
|
|
|
2022-03-29 07:19:28 +09:00
|
|
|
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)
|
|
|
|
{
|
2022-04-01 08:16:04 +09:00
|
|
|
$check = self::preCheck();
|
|
|
|
if (!$check instanceof LocalUser) {
|
|
|
|
return $check;
|
|
|
|
} else {
|
|
|
|
$user = $check;
|
|
|
|
}
|
|
|
|
|
2022-03-29 07:19:28 +09:00
|
|
|
$tags = self::parseTags();
|
|
|
|
$limit = min($this->int('count') ?? 15, 100);
|
|
|
|
|
|
|
|
$pins = DB::findBy(Pin::class, ['actor_id' => $user->getId()], order_by: ['modified' => 'asc'], limit: $limit);
|
2022-04-01 08:16:04 +09:00
|
|
|
$pins = self::filterByTags($pins, $tags);
|
2022-03-29 07:19:28 +09:00
|
|
|
|
|
|
|
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)
|
|
|
|
{
|
2022-04-01 08:16:04 +09:00
|
|
|
$check = self::preCheck();
|
|
|
|
if (!$check instanceof LocalUser) {
|
|
|
|
return $check;
|
|
|
|
} else {
|
|
|
|
$user = $check;
|
|
|
|
}
|
|
|
|
|
2022-03-29 07:19:28 +09:00
|
|
|
$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');
|
|
|
|
|
|
|
|
$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);
|
2022-04-01 08:16:04 +09:00
|
|
|
$pins = self::filterByTags($pins, $tags);
|
|
|
|
|
2022-03-29 07:19:28 +09:00
|
|
|
return self::respond(self::formatPins($user, $pins)['posts']);
|
|
|
|
}
|
2022-04-01 08:16:04 +09:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns a full list of the user's tags along with the number of times they were used
|
|
|
|
*/
|
|
|
|
public function tags_get(Request $request)
|
|
|
|
{
|
|
|
|
$check = self::preCheck();
|
|
|
|
if (!$check instanceof LocalUser) {
|
|
|
|
return $check;
|
|
|
|
} else {
|
|
|
|
$user = $check;
|
|
|
|
}
|
|
|
|
|
2022-04-01 10:10:12 +09:00
|
|
|
// Convert the list of all tags to a map from canon to
|
|
|
|
// variations (i.e. tags that canonicalize to the same)
|
|
|
|
$canon_to_variations = [];
|
2022-04-01 08:16:04 +09:00
|
|
|
foreach ($user->getActor()->getNoteTags(Pin::note_type) as $tag) {
|
2022-04-01 10:10:12 +09:00
|
|
|
if (!isset($canon_to_variations[$tag->getCanonical()])) {
|
|
|
|
$canon_to_variations[$tag->getCanonical()] = [];
|
2022-04-01 08:16:04 +09:00
|
|
|
}
|
2022-04-01 10:10:12 +09:00
|
|
|
$canon_to_variations[$tag->getCanonical()][] = $tag->getTag();
|
2022-04-01 08:16:04 +09:00
|
|
|
}
|
|
|
|
|
2022-04-01 10:10:12 +09:00
|
|
|
// Pick the most common variation and assign to it the count
|
|
|
|
// of all the variations of each canon tag
|
|
|
|
$most_common_variant_frequencies = [];
|
|
|
|
foreach ($canon_to_variations as $variations) {
|
2022-04-01 08:16:04 +09:00
|
|
|
$freqs = array_count_values($variations);
|
|
|
|
arsort($freqs);
|
2022-04-01 10:10:12 +09:00
|
|
|
$most_common_variant_frequencies[array_key_first($freqs)] = \count($variations);
|
2022-04-01 08:16:04 +09:00
|
|
|
}
|
|
|
|
|
2022-04-01 10:10:12 +09:00
|
|
|
return self::respond($most_common_variant_frequencies);
|
2022-04-01 08:16:04 +09:00
|
|
|
}
|
2022-04-01 08:17:57 +09:00
|
|
|
|
|
|
|
public function unimplmented(Request $request)
|
|
|
|
{
|
|
|
|
$check = self::preCheck();
|
|
|
|
if (!$check instanceof LocalUser) {
|
|
|
|
return $check;
|
|
|
|
} else {
|
|
|
|
$user = $check;
|
|
|
|
}
|
|
|
|
return self::respond(['result_code' => 'something went wrong', 'reason' => 'Unimplemented']);
|
|
|
|
}
|
2022-03-29 07:19:28 +09:00
|
|
|
}
|