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; } [$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); 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']); } }