Repository navigation
Expand file tree
/
Copy pathModule.php
More file actions
523 lines (492 loc) · 23.1 KB
/
Copy pathModule.php
File metadata and controls
523 lines (492 loc) · 23.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
<?php
/**
* DRESearch — Omeka S module.
*
* Faceted, Typesense-backed search over the DRE "research items" corpus. The
* index is built on demand from the Omeka dashboard (Admin → DRE Search →
* Reindex), reading resources straight out of the Omeka database — there is no
* external ingestion pipeline.
*
* Typesense is OPTIONAL. With no connection configured the module installs
* cleanly, the admin status page says so, and the search block renders a quiet
* "search unavailable" notice instead of erroring. Nothing here assumes
* Typesense is reachable at request time.
*/
declare(strict_types=1);
namespace DRESearch;
/**
* The lowest supported PHP, matching composer.json ("php" and config.platform);
* a test keeps the two, CI and the CLI in step. Below it the vendor
* autoloader's Composer platform check throws on every request, so it is not
* loaded at all and the module degrades as it does without vendor/. That only
* works while this file and src/ still parse on the older PHP, so the floor is
* a support policy: hold back syntax newer than PHP 8.2 (typed class
* constants, for instance) until the next raise.
*/
const MIN_PHP_VERSION_ID = 80400;
const MIN_PHP_VERSION = '8.4';
// Load the module's Composer autoloader at file scope so DRESearch\… classes
// resolve even on first-time install, where Omeka instantiates Module and may
// call install()/getConfigForm() before the ModuleManager autoload pipeline
// runs. Omeka require_once's every active module's Module.php on EVERY request,
// so a missing vendor/ (a source checkout without `composer install`, GitHub's
// "Source code" archive) or a PHP below the minimum must degrade this module
// rather than fatal the whole site: install() refuses, and every runtime path
// reports search unavailable.
// @phpstan-ignore greaterOrEqual.alwaysTrue (true on every supported PHP; it guards an unsupported one)
if (PHP_VERSION_ID >= MIN_PHP_VERSION_ID && is_readable(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
} else {
// Keep the module's own classes loadable (view helpers, blocks, the admin
// page) so the theme's header bar and search blocks render their
// "unavailable" state instead of fataling; only the Typesense SDK is absent.
spl_autoload_register(static function (string $class): void {
if (str_starts_with($class, 'DRESearch\\')) {
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, 10)) . '.php';
if (is_file($file)) {
require $file;
}
}
});
}
use Laminas\EventManager\SharedEventManagerInterface;
use Laminas\Mvc\Controller\AbstractController;
use Laminas\Mvc\MvcEvent;
use Laminas\ServiceManager\ServiceLocatorInterface;
use Laminas\Stdlib\ArrayUtils;
use Laminas\View\Renderer\PhpRenderer;
use Omeka\Module\AbstractModule;
use Omeka\Module\Exception\ModuleCannotInstallException;
use Omeka\Permissions\Acl;
class Module extends AbstractModule
{
/** Settings keys owned by this module (created on configure, dropped on uninstall). */
private const SETTINGS = [
'dre_search_typesense_host',
'dre_search_typesense_port',
'dre_search_typesense_protocol',
'dre_search_typesense_api_key',
];
/**
* Every public SearchController action. A new action MUST be listed here:
* otherwise anonymous requests fail with PermissionDenied before the action
* runs (a test asserts the list matches the controller).
*/
public const PUBLIC_ACTIONS = [
'apiSearch', 'apiFacet', 'apiExport', 'apiSuggest', 'apiSuggestAll', 'apiPopular',
'apiSearchAll', 'apiUnion', 'apiMap', 'apiHealth', 'results',
];
/** Built on the first observed write, then reused for the request. */
private ?Indexer\ItemEventListener $itemEventListener = null;
private bool $itemEventListenerResolved = false;
public function getConfig(): array
{
return include __DIR__ . '/config/module.config.php';
}
/**
* True when the module's Composer dependencies are installed. The release
* archive (DRESearch.zip) always carries vendor/; a bare source checkout
* does not until `composer install` has run.
*/
public static function dependenciesAvailable(): bool
{
return class_exists(\Typesense\Client::class);
}
/** Whether this server's PHP meets the module's minimum ({@see MIN_PHP_VERSION}). */
public static function phpSupported(): bool
{
// @phpstan-ignore greaterOrEqual.alwaysTrue (see the autoloader guard above)
return PHP_VERSION_ID >= MIN_PHP_VERSION_ID;
}
public function install(ServiceLocatorInterface $services): void
{
if (!self::phpSupported()) {
throw new ModuleCannotInstallException(sprintf(
'DRE Search requires PHP %s or newer; this server runs PHP %s.',
MIN_PHP_VERSION,
PHP_VERSION,
));
}
if (!self::dependenciesAvailable()) {
throw new ModuleCannotInstallException(
'DRE Search is missing its vendor/ directory. Install the DRESearch.zip release asset, '
. 'or run "composer install --no-dev" in the module directory.'
);
}
$this->installOperationalTables($services);
// A site whose item-page blocks were saved ignores the module default
// (config resource_page_blocks_default): add Project records there.
Site\ResourcePagePlacement::repair($services->get('Omeka\Connection'));
}
/**
* Omeka runs upgrade() while the module is still in the "needs upgrade"
* state, which is NOT active: Omeka merges configuration only for active
* modules, so none of this module's service factories are registered here.
* Build what the migration needs from core services (the connection and
* the application config) — never `$services->get(<module service>)`.
*/
public function upgrade($oldVersion, $newVersion, ServiceLocatorInterface $services): void
{
if (version_compare((string) $oldVersion, '1.22.0', '<')) {
$this->installOperationalTables($services);
$connection = $services->get('Omeka\Connection');
// DBAL 2.13 (Omeka 4.2.x core) has no createSchemaManager().
$columns = $connection->getSchemaManager()->listTableColumns('dre_search_profile_state');
if (!isset($columns['dirty_revision'])) {
$connection->executeStatement("ALTER TABLE dre_search_profile_state ADD dirty_revision CHAR(32) NOT NULL DEFAULT ''");
}
(new Indexer\RebuildStateStore($connection))->markDirty(
$this->profileNames($services),
'Visibility rules changed. Rebuild all profiles before serving search.',
);
}
if (version_compare((string) $oldVersion, '1.23.0', '<')) {
// Coalesced drain worker lease + rejected-document report. No
// rebuild needed: the documents and their schema are unchanged.
$this->installOperationalTables($services);
$connection = $services->get('Omeka\Connection');
$columns = $connection->getSchemaManager()->listTableColumns('dre_search_profile_state');
if (!isset($columns['rejected_ids'])) {
$connection->executeStatement('ALTER TABLE dre_search_profile_state ADD rejected_ids TEXT NULL');
}
}
if (version_compare((string) $oldVersion, '1.25.0', '<')) {
// Editors' popular-search decisions (1.24.0 shipped without them).
// It starts empty, so nothing reaches visitors until an editor
// approves it.
$this->installOperationalTables($services);
}
if (version_compare((string) $oldVersion, '1.29.0', '<')) {
// Project records replace Linked resources on a project's item
// page, so the block must be on every site, including those whose
// saved Resource pages configuration predates it. Static and
// connection-only: no module service exists during upgrade().
Site\ResourcePagePlacement::repair($services->get('Omeka\Connection'));
}
}
/**
* Profile names as the running site will see them: the module's own
* profiles plus any `dre_search.profiles` added in config/local.config.php
* (which is part of the application config even while this module is
* inactive). Names only — a migration must not fail on an invalid override.
*
* @return list<string>
*/
private function profileNames(ServiceLocatorInterface $services): array
{
$profiles = $this->getConfig()['dre_search']['profiles'] ?? [];
if ($services->has('Config')) {
$overrides = $services->get('Config')['dre_search']['profiles'] ?? [];
if (is_array($overrides)) {
$profiles = ArrayUtils::merge($profiles, $overrides);
}
}
return array_values(array_map('strval', array_keys($profiles)));
}
/**
* ACL: open the public search proxy to anonymous visitors (the page block's
* search + autocomplete calls), and the admin maintenance, reindex and
* popular-search moderation actions to editors and above. The /admin parent route already enforces auth, so
* the second grant only narrows which admin roles pass.
*/
public function onBootstrap(MvcEvent $event): void
{
parent::onBootstrap($event);
/** @var Acl $acl */
$acl = $event->getApplication()->getServiceManager()->get('Omeka\Acl');
$acl->allow(
null,
[Controller\SearchController::class],
self::PUBLIC_ACTIONS
);
$acl->allow(
[Acl::ROLE_EDITOR, Acl::ROLE_SITE_ADMIN, Acl::ROLE_GLOBAL_ADMIN],
[Controller\Admin\MaintenanceController::class],
['index', 'reindex', 'moderate']
);
}
/**
* Queue changed items and their dependencies after Omeka writes. Handler
* bodies live in Indexer\ItemEventListener; a coalesced background worker
* performs the Typesense writes. The listener (and the indexer, profile
* registry and client provider behind it) is built on the first write
* event, not on every request: anonymous page views never construct it.
*
* Omeka's batch create/update/delete fire the per-resource events for each
* id, so batch events are deliberately not observed.
*/
public function attachListeners(SharedEventManagerInterface $sharedEventManager): void
{
$items = \Omeka\Api\Adapter\ItemAdapter::class;
$media = \Omeka\Api\Adapter\MediaAdapter::class;
$sets = \Omeka\Api\Adapter\ItemSetAdapter::class;
$templates = \Omeka\Api\Adapter\ResourceTemplateAdapter::class;
foreach (
[
[$items, 'api.create.post', 'onItemCreate'],
[$items, 'api.update.pre', 'onItemUpdatePre'],
[$items, 'api.update.post', 'onItemUpdate'],
[$items, 'api.delete.pre', 'onItemDeletePre'],
[$items, 'api.delete.post', 'onItemDelete'],
[$media, 'api.create.post', 'onMediaSave'],
[$media, 'api.update.pre', 'onMediaDeletePre'],
[$media, 'api.update.post', 'onMediaSave'],
[$media, 'api.delete.pre', 'onMediaDeletePre'],
[$media, 'api.delete.post', 'onMediaDelete'],
[$sets, 'api.delete.pre', 'onItemSetDeletePre'],
[$sets, 'api.delete.post', 'onItemSetDelete'],
[$templates, 'api.update.pre', 'onResourceTemplatePre'],
[$templates, 'api.update.post', 'onResourceTemplatePost'],
[$templates, 'api.delete.pre', 'onResourceTemplatePre'],
[$templates, 'api.delete.post', 'onResourceTemplatePost'],
] as [$identifier, $eventName, $method]
) {
$sharedEventManager->attach($identifier, $eventName, function (\Laminas\EventManager\EventInterface $event) use ($method): void {
$listener = $this->resolveItemEventListener();
if ($listener === null || !$event instanceof \Laminas\EventManager\Event) {
return;
}
try {
$listener->{$method}($event);
} catch (\Throwable $error) {
// Indexing must never roll back or break the user's write.
$this->logIndexingFailure('DRESearch: ' . $method . ' failed: ' . $error->getMessage());
}
});
}
}
/**
* Resolve (once) the ItemEventListener from the service manager. A failure
* — e.g. an invalid profile override in local.config.php — disables
* incremental indexing for this request and is logged, never silent.
*/
private function resolveItemEventListener(): ?Indexer\ItemEventListener
{
if ($this->itemEventListenerResolved) {
return $this->itemEventListener;
}
$this->itemEventListenerResolved = true;
try {
$sl = $this->getServiceLocator();
if ($sl === null) {
return null;
}
/** @var Indexer\ItemEventListener $listener */
$listener = $sl->get(Indexer\ItemEventListener::class);
return $this->itemEventListener = $listener;
} catch (\Throwable $error) {
$this->logIndexingFailure('DRESearch: incremental indexing is disabled for this request: ' . $error->getMessage());
return null;
}
}
private function logIndexingFailure(string $message): void
{
try {
$this->getServiceLocator()?->get('Omeka\Logger')->err($message);
} catch (\Throwable) {
error_log($message);
}
}
/**
* Module configuration form (Modules → DRE Search → Configure): the
* Typesense connection. Values are stored in Omeka settings; an env var or
* a module.config.php default can still override at resolve time (see
* Service\TypesenseClientProviderFactory).
*/
public function getConfigForm(PhpRenderer $renderer)
{
$services = $this->getServiceLocator();
$settings = $services->get('Omeka\Settings');
$form = $services->get('FormElementManager')->get(Form\ConfigForm::class);
$data = [];
foreach (self::SETTINGS as $key) {
// Never place the stored secret back into the rendered admin DOM.
$data[$key] = $key === 'dre_search_typesense_api_key' ? '' : $settings->get($key, '');
}
$form->setData($data);
return $renderer->formCollection($form, false) . $this->connectionSources($renderer, $settings);
}
/**
* Where each connection value in effect comes from. A saved setting wins
* over the environment, so a key saved once silently shadowed a rotated
* TYPESENSE_API_KEY; saying so here makes that visible.
*/
private function connectionSources(PhpRenderer $renderer, \Omeka\Settings\Settings $settings): string
{
$defaults = $this->getConfig()['dre_search']['typesense'] ?? [];
$rows = [];
foreach (
[
'Host' => ['dre_search_typesense_host', 'TYPESENSE_HOST', $defaults['host'] ?? ''],
'Port' => ['dre_search_typesense_port', 'TYPESENSE_PORT', $defaults['port'] ?? ''],
'Protocol' => ['dre_search_typesense_protocol', 'TYPESENSE_PROTOCOL', $defaults['protocol'] ?? ''],
'API key' => ['dre_search_typesense_api_key', 'TYPESENSE_API_KEY', ''],
] as $label => [$setting, $env, $default]
) {
if ((string) $settings->get($setting, '') !== '') {
$source = $renderer->translate('saved in these settings');
} elseif (($value = getenv($env)) !== false && $value !== '') {
$source = sprintf($renderer->translate('environment variable %s'), $env);
} elseif ((string) $default !== '') {
$source = $renderer->translate('module default');
} else {
$source = $renderer->translate('not set');
}
$rows[] = sprintf(
'<li><strong>%s</strong>: %s</li>',
$renderer->escapeHtml($renderer->translate($label)),
$renderer->escapeHtml($source),
);
}
return '<div class="field"><p>' . $renderer->escapeHtml($renderer->translate('Connection values in effect:')) . '</p><ul>'
. implode('', $rows) . '</ul></div>';
}
public function handleConfigForm(AbstractController $controller)
{
$services = $this->getServiceLocator();
$settings = $services->get('Omeka\Settings');
$form = $services->get('FormElementManager')->get(Form\ConfigForm::class);
$form->setData($controller->params()->fromPost());
if (!$form->isValid()) {
$controller->messenger()->addErrors($form->getMessages());
return false;
}
$data = $form->getData();
foreach (self::SETTINGS as $key) {
if ($key === 'dre_search_typesense_api_key') {
if (!empty($data['dre_search_clear_api_key'])) {
$settings->set($key, '');
} elseif ((string) ($data[$key] ?? '') !== '') {
$settings->set($key, (string) $data[$key]);
}
continue;
}
$settings->set($key, (string) ($data[$key] ?? ''));
}
return true;
}
public function uninstall(\Laminas\ServiceManager\ServiceLocatorInterface $services): void
{
$settings = $services->get('Omeka\Settings');
foreach (self::SETTINGS as $key) {
$settings->delete($key);
}
$connection = $services->get('Omeka\Connection');
// The generation table is the only record of which Typesense
// collections this module created. Typesense data is deliberately left
// in place (it may be shared); record the names so they can be removed
// from the Typesense side without guessing by prefix.
try {
$owned = $connection->executeQuery('SELECT collection_name FROM dre_search_generation')->fetchFirstColumn();
if ($owned !== [] && $services->has('Omeka\Logger')) {
$services->get('Omeka\Logger')->notice(
'DRESearch uninstalled. Typesense collections it created were left in place: ' . implode(', ', $owned)
);
}
} catch (\Throwable) {
// Table already gone: nothing to report.
}
$connection->executeStatement('DROP TABLE IF EXISTS dre_search_change');
$connection->executeStatement('DROP TABLE IF EXISTS dre_search_worker');
$connection->executeStatement('DROP TABLE IF EXISTS dre_search_cache');
$connection->executeStatement('DROP TABLE IF EXISTS dre_search_rate_limit');
$connection->executeStatement('DROP TABLE IF EXISTS dre_search_generation');
$connection->executeStatement('DROP TABLE IF EXISTS dre_search_profile_state');
$connection->executeStatement('DROP TABLE IF EXISTS dre_search_popular_moderation');
// Typesense collections are intentionally left untouched — they may be
// shared with a parallel install, and dropping data on uninstall is
// surprising. Clean them up from the Typesense side if needed.
}
private function installOperationalTables(ServiceLocatorInterface $services): void
{
$connection = $services->get('Omeka\Connection');
$connection->executeStatement(<<<'SQL'
CREATE TABLE IF NOT EXISTS dre_search_profile_state (
profile VARCHAR(100) NOT NULL PRIMARY KEY,
collection_alias VARCHAR(255) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'unconfigured',
live_collection VARCHAR(255) NULL,
previous_collection VARCHAR(255) NULL,
active_job_id VARCHAR(64) NULL,
active_collection VARCHAR(255) NULL,
dirty TINYINT(1) NOT NULL DEFAULT 0,
dirty_reason VARCHAR(255) NULL,
dirty_revision CHAR(32) NOT NULL DEFAULT '',
started_at DATETIME NULL,
finished_at DATETIME NULL,
last_success_at DATETIME NULL,
last_failure_at DATETIME NULL,
last_duration_ms INT NULL,
last_documents INT NULL,
documents_attempted INT NOT NULL DEFAULT 0,
documents_imported INT NOT NULL DEFAULT 0,
documents_failed INT NOT NULL DEFAULT 0,
last_error_code VARCHAR(64) NULL,
rejected_ids TEXT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_dre_search_state_status (status),
INDEX idx_dre_search_state_dirty (dirty)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
$connection->executeStatement(<<<'SQL'
CREATE TABLE IF NOT EXISTS dre_search_generation (
id INT AUTO_INCREMENT PRIMARY KEY,
profile VARCHAR(100) NOT NULL,
collection_name VARCHAR(255) NOT NULL,
session_token CHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL,
promoted_at DATETIME NULL,
UNIQUE INDEX uniq_dre_search_collection (collection_name),
INDEX idx_dre_search_generation_profile (profile, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
$connection->executeStatement(<<<'SQL'
CREATE TABLE IF NOT EXISTS dre_search_rate_limit (
bucket_key CHAR(64) NOT NULL PRIMARY KEY,
window_started DATETIME NOT NULL,
request_count INT NOT NULL DEFAULT 0,
INDEX idx_dre_search_rate_window (window_started)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
$connection->executeStatement(<<<'SQL'
CREATE TABLE IF NOT EXISTS dre_search_change (
profile VARCHAR(100) NOT NULL,
item_id INT NOT NULL,
revision CHAR(32) NOT NULL,
queued_at DATETIME NOT NULL,
PRIMARY KEY (profile, item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
$connection->executeStatement(<<<'SQL'
CREATE TABLE IF NOT EXISTS dre_search_worker (
name VARCHAR(32) NOT NULL PRIMARY KEY,
requested TINYINT(1) NOT NULL DEFAULT 0,
heartbeat DATETIME NULL,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
$connection->executeStatement(<<<'SQL'
CREATE TABLE IF NOT EXISTS dre_search_cache (
cache_key CHAR(64) NOT NULL PRIMARY KEY,
payload MEDIUMTEXT NOT NULL,
expires_at DATETIME NOT NULL,
INDEX idx_dre_cache_expiry (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
// query_hash is the SHA-256 of the case- and whitespace-folded query
// (Search\PopularModeration): variants share one decision without
// relying on the collation, which would also fold accents together.
$connection->executeStatement(<<<'SQL'
CREATE TABLE IF NOT EXISTS dre_search_popular_moderation (
profile VARCHAR(100) NOT NULL,
query_hash CHAR(64) NOT NULL,
query VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL,
decided_by INT NULL,
decided_at DATETIME NOT NULL,
PRIMARY KEY (profile, query_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
}