001/* 002 Licensed to the Apache Software Foundation (ASF) under one 003 or more contributor license agreements. See the NOTICE file 004 distributed with this work for additional information 005 regarding copyright ownership. The ASF licenses this file 006 to you under the Apache License, Version 2.0 (the 007 "License"); you may not use this file except in compliance 008 with the License. You may obtain a copy of the License at 009 010 http://www.apache.org/licenses/LICENSE-2.0 011 012 Unless required by applicable law or agreed to in writing, 013 software distributed under the License is distributed on an 014 "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 015 KIND, either express or implied. See the License for the 016 specific language governing permissions and limitations 017 under the License. 018 */ 019package org.apache.wiki.search.kendra; 020 021import com.amazonaws.services.kendra.AWSkendra; 022import com.amazonaws.services.kendra.AWSkendraClientBuilder; 023import com.amazonaws.services.kendra.model.*; 024import com.amazonaws.util.IOUtils; 025import com.google.gson.Gson; 026import com.google.gson.GsonBuilder; 027import com.google.gson.reflect.TypeToken; 028import org.apache.commons.io.FilenameUtils; 029import org.apache.commons.lang3.Strings; 030import org.apache.logging.log4j.LogManager; 031import org.apache.logging.log4j.Logger; 032import org.apache.wiki.InternalWikiException; 033import org.apache.wiki.WatchDog; 034import org.apache.wiki.WikiBackgroundThread; 035import org.apache.wiki.api.core.Attachment; 036import org.apache.wiki.api.core.Context; 037import org.apache.wiki.api.core.Engine; 038import org.apache.wiki.api.core.Page; 039import org.apache.wiki.api.exceptions.NoRequiredPropertyException; 040import org.apache.wiki.api.exceptions.ProviderException; 041import org.apache.wiki.api.providers.PageProvider; 042import org.apache.wiki.api.search.SearchResult; 043import org.apache.wiki.api.spi.Wiki; 044import org.apache.wiki.attachment.AttachmentManager; 045import org.apache.wiki.auth.AuthorizationManager; 046import org.apache.wiki.auth.permissions.PagePermission; 047import org.apache.wiki.pages.PageManager; 048import org.apache.wiki.search.SearchProvider; 049import org.apache.wiki.util.TextUtil; 050 051import java.io.IOException; 052import java.io.InputStream; 053import java.io.InputStreamReader; 054import java.lang.reflect.Type; 055import java.nio.ByteBuffer; 056import java.nio.charset.StandardCharsets; 057import java.util.ArrayList; 058import java.util.Collection; 059import java.util.Collections; 060import java.util.HashMap; 061import java.util.List; 062import java.util.Map; 063import java.util.Properties; 064 065import static java.lang.String.format; 066 067/** 068 * Search provider that implements {link SearchProvider} using AWS Kendra for 069 * indexing. Note that we are using a Custom DataSource which limits the 070 * attributes that can be uploaded / searched for each page (as per 071 * https://docs.aws.amazon.com/kendra/latest/dg/custom-attributes.html). This 072 * could be overcome by using an S3 bucket where any custom attributes can be 073 * added. 074 * 075 * @since 2.11.0 076 */ 077public class KendraSearchProvider implements SearchProvider { 078 079 private static final Logger LOG = LogManager.getLogger( KendraSearchProvider.class ); 080 private Engine engine; 081 private Properties properties; 082 private Map< String, Object > contentTypes; 083 private AWSkendra kendra; 084 private String indexName; 085 private String indexId; 086 private String dataSourceName; 087 private String dataSourceId; 088 089 private final List< Page > updates = Collections.synchronizedList( new ArrayList<>() ); 090 091 private static final String PROP_KENDRA_INDEX_NAME = "jspwiki.kendra.indexName"; 092 private static final String PROP_KENDRA_DATA_SOURCE_NAME = "jspwiki.kendra.dataSourceName"; 093 private static final String PROP_KENDRA_INDEXDELAY = "jspwiki.kendra.indexdelay"; 094 private static final String PROP_KENDRA_INITIALDELAY = "jspwiki.kendra.initialdelay"; 095 096 public KendraSearchProvider() { 097 } 098 099 /** 100 * {@inheritDoc} 101 */ 102 @Override 103 public void initialize( final Engine engine, final Properties properties ) throws NoRequiredPropertyException, IOException { 104 this.engine = engine; 105 this.properties = properties; 106 this.contentTypes = getContentTypes(); 107 108 setKendra( buildClient() ); 109 110 this.indexName = TextUtil.getRequiredProperty( this.properties, PROP_KENDRA_INDEX_NAME ); 111 this.dataSourceName = TextUtil.getRequiredProperty( this.properties, PROP_KENDRA_DATA_SOURCE_NAME ); 112 final int initialDelay = TextUtil.getIntegerProperty( this.properties, PROP_KENDRA_INITIALDELAY, KendraUpdater.INITIAL_DELAY ); 113 final int indexDelay = TextUtil.getIntegerProperty( this.properties, PROP_KENDRA_INDEXDELAY, KendraUpdater.INDEX_DELAY ); 114 115 // Start the Kendra update thread, which waits first for a little while 116 // before starting to go through the "pages that need updating". 117 if ( initialDelay >= 0 ) { 118 final KendraUpdater updater = new KendraUpdater( engine, this, initialDelay, indexDelay ); 119 updater.start(); 120 } 121 } 122 123 private Map< String, Object > getContentTypes() { 124 final Gson gson = new GsonBuilder().create(); 125 try ( final InputStream in = KendraSearchProvider.class.getResourceAsStream( "content_types.json" ) ) { 126 if ( in != null ) { 127 final Type collectionType = new TypeToken< HashMap< String, Object > >() { 128 }.getType(); 129 return gson.fromJson( new InputStreamReader( in ), collectionType ); 130 } 131 } catch ( final IOException e ) { 132 LOG.error( "Unable to load default propertyfile 'content_types.json': {}", e.getMessage(), e ); 133 } 134 return null; 135 } 136 137 /** 138 * {@inheritDoc} 139 */ 140 @Override 141 public String getProviderInfo() { 142 return "KendraSearchProvider"; 143 } 144 145 /** 146 * {@inheritDoc} 147 */ 148 @Override 149 public void pageRemoved( final Page page ) { 150 final String pageName = page.getName(); 151 final BatchDeleteDocumentRequest request = new BatchDeleteDocumentRequest().withIndexId( indexId ) 152 .withDocumentIdList( pageName ); 153 final BatchDeleteDocumentResult result = getKendra().batchDeleteDocument( request ); 154 if (result.getFailedDocuments().isEmpty()) { 155 LOG.debug( "Page '{}' was removed from index", pageName ); 156 } else { 157 LOG.error( "Failed to remove Page '{}' from index", pageName ); 158 } 159 } 160 161 /** 162 * {@inheritDoc} 163 */ 164 @Override 165 public void reindexPage( final Page page ) { 166 if ( page != null ) { 167 updates.add( page ); 168 LOG.debug( format( "Scheduling page '%s' for indexing ...", page.getName() ) ); 169 } 170 } 171 172 /** 173 * {@inheritDoc} 174 */ 175 @Override 176 public Collection< SearchResult > findPages( final String query, final Context wikiContext ) throws ProviderException, IOException { 177 final QueryRequest request = new QueryRequest().withIndexId( indexId ).withQueryText( query ); 178 final List< QueryResultItem > items; 179 try { 180 items = getKendra().query( request ).getResultItems(); 181 } catch ( final ThrottlingException e ) { 182 LOG.error( "ThrottlingException. Skipping..." ); 183 return new ArrayList<>(); 184 } 185 final List< SearchResult > searchResults = new ArrayList<>( items.size() ); 186 final AuthorizationManager mgr = engine.getManager( AuthorizationManager.class ); 187 188 for ( final QueryResultItem item : items ) { 189 switch( QueryResultType.fromValue( item.getType() ) ) { 190 case DOCUMENT: 191 final String documentId = item.getDocumentId(); 192 final String documentExcerpt = item.getDocumentExcerpt().getText(); 193 final String scoreConfidence = item.getScoreAttributes().getScoreConfidence(); 194 final Page page = this.engine.getManager( PageManager.class ).getPage( documentId, PageProvider.LATEST_VERSION ); 195 if ( page != null ) { 196 final PagePermission pp = new PagePermission( page, PagePermission.VIEW_ACTION ); 197 if ( mgr.checkPermission( wikiContext.getWikiSession(), pp ) ) { 198 final SearchResult searchResult = new SearchResultImpl( page, confidence2score( scoreConfidence ), 199 new String[]{ documentExcerpt } ); 200 searchResults.add( searchResult ); 201 } else { 202 LOG.error( format( "Page '%s' is not accessible", documentId ) ); 203 } 204 } else { 205 LOG.error( 206 format( "Kendra found a result page '%s' that could not be loaded, removing from index", documentId ) ); 207 pageRemoved( Wiki.contents().page( this.engine, documentId ) ); 208 } 209 break; 210 default: 211 LOG.error( format( "Unknown query result type: %s", item.getType() ) ); 212 } 213 } 214 return searchResults; 215 } 216 217 /** 218 * This method initialize the AWS Kendra Index and Datasources to be used. 219 */ 220 public void initializeIndexAndDataSource() { 221 this.indexId = getIndexId( indexName ); 222 if ( this.indexId == null ) { 223 final String message = format( "Index '%s' does not exist", indexName ); 224 LOG.error( message ); 225 throw new IllegalArgumentException( message ); 226 } 227 this.dataSourceId = getDatasourceId( this.indexId, dataSourceName ); 228 if ( this.dataSourceId == null ) { 229 final String message = format( "Datasource '%s' does not exist in index %s", dataSourceName, indexName ); 230 LOG.error( message ); 231 throw new IllegalArgumentException( message ); 232 } 233 } 234 235 /** 236 * Given an Kendra's Index name, returns the corresponding Index Id, or 237 * {@code null} if it does not exists 238 * 239 * @param indexName the name of the index to look up 240 * @return the index id or {@code null} 241 */ 242 private String getIndexId( final String indexName ) { 243 ListIndicesRequest request = new ListIndicesRequest(); 244 ListIndicesResult result = getKendra().listIndices( request ); 245 String nextToken = ""; 246 while ( nextToken != null ) { 247 final List< IndexConfigurationSummary > items = result.getIndexConfigurationSummaryItems(); 248 if ( items == null || items.isEmpty() ) { 249 return null; 250 } 251 for ( final IndexConfigurationSummary item : items ) { 252 if ( Strings.CS.equals( item.getName(), indexName ) ) { 253 return item.getId(); 254 } 255 } 256 nextToken = result.getNextToken(); 257 request = new ListIndicesRequest().withNextToken( result.getNextToken() ); 258 result = getKendra().listIndices( request ); 259 } 260 return null; 261 } 262 263 /** 264 * Given an Kendra's Datasource name, returns the corresponding Datasource Id, 265 * or {@code null} if it does not exists 266 * 267 * @param dataSourceName the name of the datasource to look up 268 * @return the datasource id or {@code null} 269 */ 270 private String getDatasourceId( final String indexId, final String dataSourceName ) { 271 ListDataSourcesRequest request = new ListDataSourcesRequest().withIndexId( indexId ); 272 ListDataSourcesResult result = getKendra().listDataSources( request ); 273 String nextToken = ""; 274 while ( nextToken != null ) { 275 final List< DataSourceSummary > items = result.getSummaryItems(); 276 if( items == null || items.isEmpty() ) { 277 return null; 278 } 279 280 for( final DataSourceSummary item : items ) { 281 if( Strings.CS.equals( item.getName(), dataSourceName ) ) { 282 return item.getId(); 283 } 284 } 285 nextToken = result.getNextToken(); 286 request = new ListDataSourcesRequest().withNextToken( result.getNextToken() ); 287 result = getKendra().listDataSources( request ); 288 } 289 return null; 290 } 291 292 /* 293 * Converts a SCORE Confidence from Kendra to an "equivalent" integer score 294 */ 295 private int confidence2score( final String scoreConfidence ) { 296 return switch( ScoreConfidence.fromValue( scoreConfidence ) ) { 297 case VERY_HIGH -> 100; 298 case HIGH -> 75; 299 case MEDIUM -> 50; 300 case LOW -> 25; 301 default -> 0; 302 }; 303 } 304 305 /** 306 * This method re-index all the pages found in the Wiki. It is mainly used at 307 * startup. 308 * 309 * @throws IOException in case some page can not be read 310 */ 311 private void doFullReindex() throws IOException { 312 try { 313 final Collection< Page > pages = engine.getManager( PageManager.class ).getAllPages(); 314 if ( pages.isEmpty() ) { 315 return; 316 } 317 LOG.debug( format( "Indexing all %d pages. Please wait ...", pages.size() ) ); 318 final String executionId = startExecution(); 319 for ( final Page page : pages ) { 320 // Since I do not want to handle the size limit 321 // (https://docs.aws.amazon.com/goto/WebAPI/kendra-2019-02-03/BatchPutDocument) 322 // uploading documents one at a time 323 indexOnePage( page, executionId ); 324 } 325 } catch ( final ProviderException e ) { 326 LOG.error( e.getMessage() ); 327 throw new IOException( e ); 328 } finally { 329 stopExecution(); 330 } 331 } 332 333 /** 334 * This method re-index all pages marked as updated. It is used to periodically 335 * index pages that have been modified 336 */ 337 private void doPartialReindex() { 338 if ( updates.isEmpty() ) { 339 return; 340 } 341 LOG.debug( "Indexing updated pages. Please wait ..." ); 342 final String executionId = startExecution(); 343 synchronized ( updates ) { 344 try { 345 while (!updates.isEmpty()) { 346 indexOnePage( updates.remove( 0 ), executionId ); 347 } 348 } finally { 349 stopExecution(); 350 } 351 } 352 } 353 354 /** 355 * Returns an ExecutiuonId that is required to keep track of the modifed 356 * documents 357 * 358 * @return The execution id 359 */ 360 private String startExecution() { 361 final StartDataSourceSyncJobRequest request = new StartDataSourceSyncJobRequest().withIndexId( indexId ) 362 .withId( dataSourceId ); 363 final StartDataSourceSyncJobResult result = getKendra().startDataSourceSyncJob( request ); 364 return result.getExecutionId(); 365 } 366 367 /** 368 * Stop the execution for the given index Id and DataSource Id. 369 */ 370 private void stopExecution() { 371 final StopDataSourceSyncJobRequest request = new StopDataSourceSyncJobRequest().withIndexId( indexId ).withId( dataSourceId ); 372 getKendra().stopDataSourceSyncJob( request ); 373 } 374 375 /** 376 * Index on single {@link Page} into the Kendra Index 377 * 378 * @param page the {@link Page} to index 379 * @param executionId The Execution Id 380 */ 381 private void indexOnePage( final Page page, final String executionId ) { 382 final String pageName = page.getName(); 383 try { 384 final Document document = newDocument( page, executionId ); 385 final BatchPutDocumentRequest request = new BatchPutDocumentRequest().withIndexId( indexId ) 386 .withDocuments( document ); 387 final BatchPutDocumentResult result = getKendra().batchPutDocument( request ); 388 if (result.getFailedDocuments().isEmpty()) { 389 LOG.info( format( "Successfully indexed Page '%s' as %s", page.getName(), document.getContentType() ) ); 390 } else { 391 for ( final BatchPutDocumentResponseFailedDocument failedDocument : result.getFailedDocuments() ) { 392 LOG.error( format( "Failed to index Page '%s': %s", failedDocument.getId(), failedDocument.getErrorMessage() ) ); 393 } 394 } 395 } catch ( final IOException e ) { 396 LOG.error( format( "Failed to index Page '%s': %s", pageName, e.getMessage() ) ); 397 } 398 } 399 400 401 /** 402 * Given a {@link Page}, returns the corresponding Kendra {@link Document}. 403 * 404 * @param page the {@link Page} to be indexed 405 * @param executionId an execution id to identify when the {@link Page} was 406 * indexed for the last time. 407 * @return a {@link Document} containing the searchable attributes. 408 * @throws IOException if the {@link Page}'s {@link Attachment} can not be read. 409 */ 410 private Document newDocument( final Page page, final String executionId ) throws IOException { 411 final String pageName = page.getName(); 412 final List< DocumentAttribute > attrs = new ArrayList<>(); 413 // These 2 are required as per 414 // https://docs.aws.amazon.com/kendra/latest/dg/data-source-custom.html#custom-required-attributes 415 attrs.add( newAttribute( "_data_source_id", dataSourceId ) ); 416 attrs.add( newAttribute( "_data_source_sync_job_execution_id", executionId ) ); 417 418 final String title = TextUtil.beautifyString( pageName ); 419 ByteBuffer blob; 420 ContentType contentType = ContentType.PLAIN_TEXT; 421 if ( page instanceof final Attachment attachment ) { 422 InputStream is = null; 423 try { 424 final String filename = attachment.getFileName(); 425 contentType = getContentType( filename ); 426 is = engine.getManager( AttachmentManager.class ).getAttachmentStream( attachment ); 427 blob = ByteBuffer.wrap( IOUtils.toByteArray( is ) ); 428 } catch ( final ProviderException e ) { 429 throw new IOException( e ); 430 } finally { 431 IOUtils.closeQuietly( is, null ); 432 } 433 // contentType should be set to its real value 434 } else { 435 final String text = engine.getManager( PageManager.class ).getPureText( page ); 436 blob = ByteBuffer.wrap( text.getBytes( StandardCharsets.UTF_8 ) ); 437 } 438 return new Document().withId( pageName ).withTitle( title ).withAttributes( attrs ).withBlob( blob ) 439 .withContentType( contentType ); 440 } 441 442 private DocumentAttribute newAttribute( final String key, final String value ) { 443 return new DocumentAttribute().withKey( key ).withValue( new DocumentAttributeValue().withStringValue( value ) ); 444 } 445 446 @SuppressWarnings( "unchecked" ) 447 private ContentType getContentType( final String filename ) { 448 final String extention = FilenameUtils.getExtension( filename ); 449 final Map< String, String > ct = ( Map< String, String > ) this.contentTypes.get( "ContentTypes" ); 450 return ContentType.fromValue( ct.getOrDefault( extention, ContentType.PLAIN_TEXT.name() ) ); 451 } 452 453 /** 454 * Updater thread that updates Kendra indexes. 455 */ 456 private static final class KendraUpdater extends WikiBackgroundThread { 457 static final int INDEX_DELAY = 5; 458 static final int INITIAL_DELAY = 10; 459 private final KendraSearchProvider provider; 460 461 private final int initialDelay; 462 463 private WatchDog watchdog; 464 465 private KendraUpdater( final Engine engine, final KendraSearchProvider provider, final int initialDelay, final int indexDelay ) { 466 super( engine, indexDelay ); 467 this.provider = provider; 468 this.initialDelay = initialDelay; 469 setName( "JSPWiki Kendra Indexer" ); 470 } 471 472 @Override 473 public void startupTask() throws Exception { 474 watchdog = WatchDog.getCurrentWatchDog( getEngine() ); 475 try { 476 Thread.sleep( initialDelay * 1000L ); 477 } catch ( final InterruptedException e ) { 478 throw new InternalWikiException( "Interrupted while waiting to start.", e ); 479 } 480 watchdog.enterState( "Full reindex" ); 481 provider.initializeIndexAndDataSource(); 482 provider.doFullReindex(); 483 watchdog.exitState(); 484 } 485 486 @Override 487 public void backgroundTask() { 488 watchdog.enterState( "Reindexing ...", 60 ); 489 provider.doPartialReindex(); 490 watchdog.exitState(); 491 } 492 } 493 494 private static class SearchResultImpl implements SearchResult { 495 496 private final Page page; 497 private final int score; 498 private final String[] contexts; 499 500 public SearchResultImpl( final Page page, final int score, final String[] contexts ) { 501 this.page = page; 502 this.score = score; 503 this.contexts = contexts != null ? contexts.clone() : null; 504 } 505 506 @Override 507 public Page getPage() { 508 return this.page; 509 } 510 511 @Override 512 public int getScore() { 513 return this.score; 514 } 515 516 @Override 517 public String[] getContexts() { 518 return this.contexts; 519 } 520 } 521 522 public AWSkendra getKendra() { 523 return kendra; 524 } 525 526 public void setKendra( final AWSkendra kendra ) { 527 this.kendra = kendra; 528 } 529 530 protected AWSkendra buildClient() { 531 return AWSkendraClientBuilder.defaultClient(); 532 } 533 534 public String getIndexName() { 535 return indexName; 536 } 537 538 public String getDataSourceName() { 539 return dataSourceName; 540 } 541 542}