get('database'), $container->get('current_user'), $container->get('datetime.time'), $container->get('uuid'), ); } /** * Returns an IIIF AnnotationPage for a canvas. */ public function collection(Request $request): JsonResponse { $canvas_id = trim((string) $request->query->get('canvas', '')); if ($canvas_id === '') { throw new BadRequestHttpException('The canvas query parameter is required.'); } return new JsonResponse($this->annotationPage($canvas_id, $request)); } /** * Returns annotations for a set of canvases in one request. * * This is used by the "All annotations" manifest sidebar. It deliberately * accepts canvas IDs rather than re-fetching the manifest on the server, so * it also works with authenticated/proxied manifests already available to * the browser. */ public function manifestCollection(Request $request): JsonResponse { $data = $this->decodeRequest($request); $canvas_ids = $data['canvasIds'] ?? NULL; if (!is_array($canvas_ids)) { throw new BadRequestHttpException('canvasIds must be an array.'); } $canvas_ids = array_values(array_unique(array_filter(array_map( static fn ($value) => is_string($value) ? trim($value) : '', $canvas_ids )))); // Keep an accidental or malicious request from building an enormous IN // clause. IIIF manifests with thousands of canvases remain supported. if (count($canvas_ids) > 10000) { throw new BadRequestHttpException('Too many canvas IDs requested.'); } if (!$canvas_ids) { return new JsonResponse(['items' => []]); } $items = []; foreach (array_chunk($canvas_ids, 500) as $chunk) { $keys = array_map(static fn ($id) => hash('sha256', $id), $chunk); $result = $this->database->select('islandora_mirador_annotation', 'a') ->fields('a', ['canvas_id', 'annotation_json', 'created']) ->condition('canvas_key', $keys, 'IN') ->orderBy('created', 'ASC') ->execute(); foreach ($result as $row) { try { $annotation = json_decode($row->annotation_json, TRUE, 512, JSON_THROW_ON_ERROR); if (is_array($annotation)) { $items[] = [ 'canvasId' => $row->canvas_id, 'annotation' => $annotation, ]; } } catch (\JsonException) { // Ignore a corrupt row rather than breaking the entire manifest list. } } } return new JsonResponse(['items' => $items]); } /** * Creates an annotation and returns the updated AnnotationPage. */ public function store(Request $request): JsonResponse { [$canvas_id, $annotation] = $this->payload($request); if (empty($annotation['id'])) { $annotation['id'] = 'urn:uuid:' . $this->uuid->generate(); } $annotation_id = (string) $annotation['id']; $now = $this->time->getRequestTime(); $record = [ 'annotation_key' => hash('sha256', $annotation_id), 'annotation_id' => $annotation_id, 'canvas_key' => hash('sha256', $canvas_id), 'canvas_id' => $canvas_id, 'uid' => (int) $this->currentAccount->id(), 'annotation_json' => json_encode($annotation, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'created' => $now, 'changed' => $now, ]; // Treat a repeated create as an upsert; this makes retries safe. $existing = $this->database->select('islandora_mirador_annotation', 'a') ->fields('a', ['id']) ->condition('annotation_key', $record['annotation_key']) ->execute() ->fetchAssoc(); if ($existing) { unset($record['created']); $this->database->update('islandora_mirador_annotation') ->fields($record) ->condition('id', $existing['id']) ->execute(); } else { $this->database->insert('islandora_mirador_annotation') ->fields($record) ->execute(); } return new JsonResponse($this->annotationPage($canvas_id, $request), 201); } /** * Updates an annotation and returns the updated AnnotationPage. */ public function update(Request $request): JsonResponse { [$canvas_id, $annotation] = $this->payload($request); $annotation_id = trim((string) ($annotation['id'] ?? '')); if ($annotation_id === '') { throw new BadRequestHttpException('The annotation must contain an id.'); } $count = $this->database->update('islandora_mirador_annotation') ->fields([ 'canvas_key' => hash('sha256', $canvas_id), 'canvas_id' => $canvas_id, 'annotation_json' => json_encode($annotation, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'changed' => $this->time->getRequestTime(), ]) ->condition('annotation_key', hash('sha256', $annotation_id)) ->execute(); if (!$count) { throw new NotFoundHttpException('Annotation not found.'); } return new JsonResponse($this->annotationPage($canvas_id, $request)); } /** * Deletes an annotation and returns the updated AnnotationPage. */ public function delete(Request $request): JsonResponse { $data = $this->decodeRequest($request); $canvas_id = trim((string) ($data['canvasId'] ?? '')); $annotation_id = trim((string) ($data['annotationId'] ?? '')); if ($canvas_id === '' || $annotation_id === '') { throw new BadRequestHttpException('canvasId and annotationId are required.'); } $this->database->delete('islandora_mirador_annotation') ->condition('annotation_key', hash('sha256', $annotation_id)) ->execute(); return new JsonResponse($this->annotationPage($canvas_id, $request)); } /** * Parses a create/update request. */ private function payload(Request $request): array { $data = $this->decodeRequest($request); $canvas_id = trim((string) ($data['canvasId'] ?? '')); $annotation = $data['annotation'] ?? NULL; if ($canvas_id === '' || !is_array($annotation)) { throw new BadRequestHttpException('canvasId and annotation are required.'); } // The Web Annotation target is authoritative. MAE can occasionally save // through an adapter instance that was created for a previously active // canvas during rapid page changes. Store the annotation against its // actual target canvas so Mirador can retrieve and render it correctly. $target_canvas_id = $this->targetCanvasId($annotation); if ($target_canvas_id !== NULL) { $canvas_id = $target_canvas_id; } return [$canvas_id, $annotation]; } /** * Returns the canvas targeted by a Web Annotation. */ private function targetCanvasId(array $annotation): ?string { $target = $annotation['target'] ?? NULL; if (is_string($target)) { $value = explode('#', $target, 2)[0]; return $value !== '' ? $value : NULL; } if (!is_array($target)) { return NULL; } $source = $target['source'] ?? NULL; if (is_string($source)) { $value = explode('#', $source, 2)[0]; return $value !== '' ? $value : NULL; } if (is_array($source)) { $value = $source['id'] ?? $source['@id'] ?? NULL; if (is_string($value) && $value !== '') { return explode('#', $value, 2)[0]; } } $value = $target['id'] ?? $target['@id'] ?? NULL; if (is_string($value) && $value !== '') { return explode('#', $value, 2)[0]; } return NULL; } /** * Decodes a JSON request body. */ private function decodeRequest(Request $request): array { try { $data = json_decode($request->getContent(), TRUE, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { throw new BadRequestHttpException('Invalid JSON request body.', $e); } if (!is_array($data)) { throw new BadRequestHttpException('A JSON object is required.'); } return $data; } /** * Builds an AnnotationPage from stored rows. */ private function annotationPage(string $canvas_id, Request $request): array { $items = []; $result = $this->database->select('islandora_mirador_annotation', 'a') ->fields('a', ['annotation_json']) ->condition('canvas_key', hash('sha256', $canvas_id)) ->orderBy('created', 'ASC') ->execute(); foreach ($result as $row) { try { $annotation = json_decode($row->annotation_json, TRUE, 512, JSON_THROW_ON_ERROR); if (is_array($annotation)) { $items[] = $annotation; } } catch (\JsonException) { // Ignore a corrupt row rather than breaking every annotation on canvas. } } $page_id = $request->getSchemeAndHttpHost() . $request->getBaseUrl() . '/islandora-mirador/annotations?canvas=' . rawurlencode($canvas_id); return [ 'id' => $page_id, 'type' => 'AnnotationPage', 'items' => $items, ]; } }