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 = $this->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 validateToken(string $input): ?LocalUser { if (!str_contains($input, ':')) { return null; } [$id, $token] = explode(':', $input); if (filter_var($id, \FILTER_VALIDATE_INT) !== false) { return Token::get((int) $id, $token)?->getUser(); } else { return null; } } private function deleteNoteAndMaybePin(LocalUser $user, Note $note, ?Pin $pin): void { $note->delete($user->getActor(), self::SOURCE); if (!\is_null($pin)) { 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; } /** * @param Pin[] $pins * @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) !== []); } 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 preCheck calling posts/all to see if the data * has changed since the last fetch */ public function posts_update(Request $request) { $check = self::preCheck(); if (!$check instanceof LocalUser) { return $check; } else { $user = $check; } return self::respond(['update_time' => self::getLatestModified($user)]); } /** * Add a bookmark */ public function posts_add(Request $request) { $check = self::preCheck(); if (!$check instanceof LocalUser) { return $check; } else { $user = $check; } 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); // Continue below } } 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]) { DB::flush(); $result_code = 'done'; } return self::respond(['result_code' => $result_code]); } /** * Delete a bookmark */ public function posts_delete(Request $request) { $check = self::preCheck(); if (!$check instanceof LocalUser) { return $check; } else { $user = $check; } $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) { $check = self::preCheck(); if (!$check instanceof LocalUser) { return $check; } else { $user = $check; } $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')]]); } 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'); } $pins = self::filterByTags($pins, $tags); 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) { $check = self::preCheck(); if (!$check instanceof LocalUser) { return $check; } else { $user = $check; } $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); $pins = self::filterByTags($pins, $tags); 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) { $check = self::preCheck(); if (!$check instanceof LocalUser) { return $check; } else { $user = $check; } $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); $pins = self::filterByTags($pins, $tags); return self::respond(self::formatPins($user, $pins)['posts']); } /** * 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; } $tags_freq = []; foreach ($user->getActor()->getNoteTags(Pin::note_type) as $tag) { if (!isset($tags_freq[$tag->getCanonical()])) { $tags_freq[$tag->getCanonical()] = []; } $tags_freq[$tag->getCanonical()][] = $tag->getTag(); } foreach ($tags_freq as $canon => $variations) { $freqs = array_count_values($variations); arsort($freqs); $tags_freq[$canon] = $freqs[array_key_first($freqs)]; } return self::respond($tags_freq); } 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']); } }