You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
317 lines
9.9 KiB
317 lines
9.9 KiB
<?php |
|
|
|
declare(strict_types=1); |
|
|
|
namespace Drupal\islandora_mirador_annotations\Controller; |
|
|
|
use Drupal\Core\Controller\ControllerBase; |
|
use Drupal\Core\Database\Connection; |
|
use Drupal\Component\Datetime\TimeInterface; |
|
use Drupal\Core\Session\AccountProxyInterface; |
|
use Drupal\Component\Uuid\UuidInterface; |
|
use Symfony\Component\DependencyInjection\ContainerInterface; |
|
use Symfony\Component\HttpFoundation\JsonResponse; |
|
use Symfony\Component\HttpFoundation\Request; |
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; |
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; |
|
|
|
/** |
|
* Provides the annotation persistence endpoint used by the Mirador adapter. |
|
*/ |
|
final class AnnotationController extends ControllerBase { |
|
|
|
public function __construct( |
|
private readonly Connection $database, |
|
private readonly AccountProxyInterface $currentAccount, |
|
private readonly TimeInterface $time, |
|
private readonly UuidInterface $uuid, |
|
) {} |
|
|
|
/** |
|
* {@inheritdoc} |
|
*/ |
|
public static function create(ContainerInterface $container): static { |
|
return new static( |
|
$container->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, |
|
]; |
|
} |
|
|
|
}
|
|
|