001/* 002 * This library is part of OpenCms - 003 * the Open Source Content Management System 004 * 005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com) 006 * 007 * This library is free software; you can redistribute it and/or 008 * modify it under the terms of the GNU Lesser General Public 009 * License as published by the Free Software Foundation; either 010 * version 2.1 of the License, or (at your option) any later version. 011 * 012 * This library is distributed in the hope that it will be useful, 013 * but WITHOUT ANY WARRANTY; without even the implied warranty of 014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 015 * Lesser General Public License for more details. 016 * 017 * For further information about Alkacon Software GmbH & Co. KG, please see the 018 * company website: https://www.alkacon.com 019 * 020 * For further information about OpenCms, please see the 021 * project website: https://www.opencms.org 022 * 023 * You should have received a copy of the GNU Lesser General Public 024 * License along with this library; if not, write to the Free Software 025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 026 */ 027 028package org.opencms.loader; 029 030import org.opencms.db.storage.CmsStorageBlobNotFoundException; 031import org.opencms.db.storage.s3.CmsGenericS3Client; 032import org.opencms.db.storage.s3.CmsS3ClientConfiguration; 033import org.opencms.db.storage.s3.CmsS3DeleteResult; 034import org.opencms.db.storage.s3.CmsS3ObjectMetadata; 035import org.opencms.db.storage.s3.I_CmsS3Client; 036import org.opencms.loader.imagecache.CmsImageCacheEntry; 037import org.opencms.loader.imagecache.I_CmsImageCacheAccessMetadataProvider; 038import org.opencms.util.CmsStringUtil; 039 040import java.io.OutputStream; 041import java.nio.charset.StandardCharsets; 042import java.time.Instant; 043import java.util.ArrayList; 044import java.util.Arrays; 045import java.util.HashMap; 046import java.util.HashSet; 047import java.util.LinkedHashMap; 048import java.util.List; 049import java.util.Map; 050import java.util.Set; 051import java.util.UUID; 052import java.util.concurrent.TimeUnit; 053 054import com.google.common.cache.Cache; 055import com.google.common.cache.CacheBuilder; 056 057/** 058 * S3 based storage for generated image cache entries.<p> 059 */ 060public class CmsS3ImageCache implements I_CmsImageCache, I_CmsImageCacheAccessMetadataProvider { 061 062 /** Default and maximum S3 delete batch size. */ 063 private static final int DEFAULT_DELETE_BATCH_SIZE = CmsGenericS3Client.MAX_DELETE_OBJECTS; 064 065 /** Maximum number of S3 object lengths kept locally. */ 066 private static final int LENGTH_CACHE_MAX_SIZE = 10000; 067 068 /** Number of minutes after which a cached S3 object length expires. */ 069 private static final int LENGTH_CACHE_EXPIRY_MINUTES = 10; 070 071 /** Locally cached S3 object lengths. */ 072 private Cache<String, Long> m_lengthCache = CacheBuilder.newBuilder().maximumSize( 073 LENGTH_CACHE_MAX_SIZE).expireAfterAccess(LENGTH_CACHE_EXPIRY_MINUTES, TimeUnit.MINUTES).build(); 074 075 /** Metadata obtained without an extra request while delivering S3 objects. */ 076 private Cache<String, CmsImageCacheEntry> m_recentAccessMetadata = CacheBuilder.newBuilder().maximumSize( 077 LENGTH_CACHE_MAX_SIZE).expireAfterAccess(LENGTH_CACHE_EXPIRY_MINUTES, TimeUnit.MINUTES).build(); 078 079 /** The S3 client. */ 080 private I_CmsS3Client m_s3Client; 081 082 /** 083 * Creates a new image cache.<p> 084 * 085 * @param configuration the S3 configuration 086 * @throws Exception if the configured bucket can not be accessed 087 */ 088 public CmsS3ImageCache(CmsS3ClientConfiguration configuration) 089 throws Exception { 090 091 initClient(new CmsGenericS3Client(configuration)); 092 } 093 094 /** 095 * Creates a new image cache with a custom client.<p> 096 * 097 * @param s3Client the S3 client 098 * @throws Exception if the configured bucket can not be accessed 099 */ 100 CmsS3ImageCache(I_CmsS3Client s3Client) 101 throws Exception { 102 103 initClient(s3Client); 104 } 105 106 /** 107 * @see org.opencms.loader.I_CmsImageCache#clear() 108 */ 109 @Override 110 public void clear() throws Exception { 111 112 clear(DEFAULT_DELETE_BATCH_SIZE); 113 } 114 115 /** 116 * Clears the image cache using S3 multi-object delete requests.<p> 117 * 118 * @param deleteBatchSize the number of keys per request 119 * @throws Exception if listing or deleting fails 120 */ 121 public void clear(int deleteBatchSize) throws Exception { 122 123 ensureInitialized(); 124 if ((deleteBatchSize < 1) || (deleteBatchSize > CmsGenericS3Client.MAX_DELETE_OBJECTS)) { 125 throw new IllegalArgumentException( 126 "S3 delete batch size must be between 1 and " + CmsGenericS3Client.MAX_DELETE_OBJECTS + "."); 127 } 128 List<String> batch = new ArrayList<String>(deleteBatchSize); 129 List<Exception> failures = new ArrayList<Exception>(); 130 try { 131 m_s3Client.visitObjects("", metadata -> { 132 if (isImageCacheObjectKey(metadata.getKey())) { 133 batch.add(metadata.getKey()); 134 if (batch.size() == deleteBatchSize) { 135 failures.addAll(deleteObjectKeys(batch).getFailures().values()); 136 batch.clear(); 137 } 138 } 139 }); 140 if (!batch.isEmpty()) { 141 failures.addAll(deleteObjectKeys(batch).getFailures().values()); 142 } 143 if (!failures.isEmpty()) { 144 Exception failure = new Exception("Unable to delete " + failures.size() + " S3 image cache entries."); 145 for (Exception suppressed : failures) { 146 failure.addSuppressed(suppressed); 147 } 148 throw failure; 149 } 150 } finally { 151 m_lengthCache.invalidateAll(); 152 m_recentAccessMetadata.invalidateAll(); 153 } 154 } 155 156 /** 157 * @see org.opencms.loader.I_CmsImageCache#close() 158 */ 159 @Override 160 public void close() throws Exception { 161 162 I_CmsS3Client client = m_s3Client; 163 m_s3Client = null; 164 m_lengthCache.invalidateAll(); 165 m_recentAccessMetadata.invalidateAll(); 166 if (client != null) { 167 client.close(); 168 } 169 } 170 171 /** 172 * Deletes an image cache entry.<p> 173 * 174 * @param key the image cache key 175 * @throws Exception if deleting fails 176 */ 177 public void delete(String key) throws Exception { 178 179 ensureInitialized(); 180 String objectKey = getObjectKey(key); 181 try { 182 m_s3Client.deleteObject(objectKey); 183 } finally { 184 m_lengthCache.invalidate(objectKey); 185 m_recentAccessMetadata.invalidate(normalizeKey(key)); 186 } 187 } 188 189 /** 190 * Deletes image cache entries with one S3 multi-object request.<p> 191 * 192 * @param keys the image cache keys 193 * @return the per-entry result using normalized image cache keys 194 * @throws Exception if the complete request fails 195 */ 196 public CmsS3DeleteResult deleteBatch(List<String> keys) throws Exception { 197 198 ensureInitialized(); 199 List<String> objectKeys = new ArrayList<String>(keys.size()); 200 Map<String, String> objectToCacheKey = new HashMap<String, String>(); 201 List<String> normalizedKeys = new ArrayList<String>(keys.size()); 202 Set<String> uniqueKeys = new HashSet<String>(); 203 for (String key : keys) { 204 String normalizedKey = normalizeKey(key); 205 if (!uniqueKeys.add(normalizedKey)) { 206 throw new IllegalArgumentException("Duplicate normalized image cache key: " + normalizedKey); 207 } 208 String objectKey = normalizedKey; 209 normalizedKeys.add(normalizedKey); 210 objectKeys.add(objectKey); 211 objectToCacheKey.put(objectKey, normalizedKey); 212 } 213 try { 214 CmsS3DeleteResult objectResult = m_s3Client.deleteObjects(objectKeys); 215 Map<String, Exception> failures = new LinkedHashMap<String, Exception>(); 216 for (Map.Entry<String, Exception> failure : objectResult.getFailures().entrySet()) { 217 String cacheKey = objectToCacheKey.get(failure.getKey()); 218 failures.put(cacheKey == null ? failure.getKey() : cacheKey, failure.getValue()); 219 } 220 return new CmsS3DeleteResult(normalizedKeys, failures); 221 } finally { 222 for (String objectKey : objectKeys) { 223 m_lengthCache.invalidate(objectKey); 224 } 225 for (String normalizedKey : normalizedKeys) { 226 m_recentAccessMetadata.invalidate(normalizedKey); 227 } 228 } 229 } 230 231 /** 232 * @see org.opencms.loader.I_CmsImageCache#exists(java.lang.String) 233 */ 234 public boolean exists(String key) throws Exception { 235 236 ensureInitialized(); 237 String objectKey = getObjectKey(key); 238 Long cachedLength = m_lengthCache.getIfPresent(objectKey); 239 if (cachedLength != null) { 240 return true; 241 } 242 long length = m_s3Client.getObjectLengthIfExists(objectKey); 243 if (length >= 0) { 244 m_lengthCache.put(objectKey, Long.valueOf(length)); 245 return true; 246 } 247 return false; 248 } 249 250 /** 251 * @see org.opencms.loader.I_CmsImageCache#existsAuthoritatively(java.lang.String) 252 */ 253 @Override 254 public boolean existsAuthoritatively(String key) throws Exception { 255 256 ensureInitialized(); 257 String objectKey = getObjectKey(key); 258 long length = m_s3Client.getObjectLengthIfExists(objectKey); 259 if (length >= 0) { 260 m_lengthCache.put(objectKey, Long.valueOf(length)); 261 return true; 262 } 263 m_lengthCache.invalidate(objectKey); 264 m_recentAccessMetadata.invalidate(normalizeKey(key)); 265 return false; 266 } 267 268 /** 269 * @see org.opencms.loader.I_CmsImageCache#getLength(java.lang.String) 270 */ 271 public long getLength(String key) throws Exception { 272 273 ensureInitialized(); 274 String objectKey = getObjectKey(key); 275 Long cachedLength = m_lengthCache.getIfPresent(objectKey); 276 if (cachedLength != null) { 277 return cachedLength.longValue(); 278 } 279 try { 280 long length = m_s3Client.getObjectLength(objectKey); 281 m_lengthCache.put(objectKey, Long.valueOf(length)); 282 return length; 283 } catch (CmsStorageBlobNotFoundException e) { 284 m_lengthCache.invalidate(objectKey); 285 m_recentAccessMetadata.invalidate(normalizeKey(key)); 286 throw new CmsImageCacheEntryNotFoundException(key, e); 287 } 288 } 289 290 /** 291 * Loads metadata for a single image cache entry.<p> 292 * 293 * @param key the image cache key 294 * @return the object metadata using the normalized image cache key 295 * @throws Exception if the metadata can not be loaded 296 */ 297 public CmsS3ObjectMetadata getMetadata(String key) throws Exception { 298 299 ensureInitialized(); 300 String objectKey = getObjectKey(key); 301 CmsS3ObjectMetadata metadata = m_s3Client.getObjectMetadata(objectKey); 302 m_lengthCache.put(objectKey, Long.valueOf(metadata.getLength())); 303 CmsS3ObjectMetadata result = new CmsS3ObjectMetadata( 304 normalizeKey(key), 305 metadata.getLength(), 306 metadata.getLastModified(), 307 metadata.getRevision()); 308 rememberAccessMetadata(result); 309 return result; 310 } 311 312 /** 313 * @see org.opencms.loader.imagecache.I_CmsImageCacheAccessMetadataProvider#getRecentAccessMetadata(java.lang.String) 314 */ 315 @Override 316 public CmsImageCacheEntry getRecentAccessMetadata(String key) { 317 318 return m_recentAccessMetadata.getIfPresent(normalizeKey(key)); 319 } 320 321 /** 322 * Renews an image cache entry by conditionally copying the S3 object onto itself.<p> 323 * 324 * @param key the image cache key 325 * @param expectedRevision the expected object revision 326 * @param renewalTime the requested renewal time 327 * @return {@code true} if the entry was renewed, or {@code false} if it no longer matched 328 * @throws Exception if renewal fails 329 */ 330 public boolean renew(String key, String expectedRevision, Instant renewalTime) throws Exception { 331 332 ensureInitialized(); 333 if (CmsStringUtil.isEmptyOrWhitespaceOnly(expectedRevision)) { 334 throw new IllegalArgumentException("S3 image cache renewal requires an expected object revision."); 335 } 336 if (renewalTime == null) { 337 throw new IllegalArgumentException("S3 image cache renewal requires a renewal time."); 338 } 339 String objectKey = getObjectKey(key); 340 boolean renewed = m_s3Client.renewObject(objectKey, expectedRevision, renewalTime); 341 m_recentAccessMetadata.invalidate(normalizeKey(key)); 342 if (!renewed) { 343 m_lengthCache.invalidate(objectKey); 344 } 345 return renewed; 346 } 347 348 /** 349 * @see org.opencms.loader.I_CmsImageCache#supportsRangeDelivery() 350 */ 351 public boolean supportsRangeDelivery() { 352 353 return true; 354 } 355 356 /** 357 * @see org.opencms.loader.I_CmsImageCache#visitEntries(org.opencms.loader.I_CmsImageCache.I_CmsImageCacheEntryVisitor) 358 */ 359 @Override 360 public void visitEntries(I_CmsImageCacheEntryVisitor visitor) throws Exception { 361 362 visitEntries("", visitor); 363 } 364 365 /** 366 * @see org.opencms.loader.I_CmsImageCache#visitEntries(java.lang.String, org.opencms.loader.I_CmsImageCache.I_CmsImageCacheEntryVisitor) 367 */ 368 @Override 369 public void visitEntries(String prefix, I_CmsImageCacheEntryVisitor visitor) throws Exception { 370 371 visitEntriesWithMetadata(prefix, metadata -> visitor.visit(metadata.getKey(), metadata.getLength())); 372 } 373 374 /** 375 * Visits image cache entries with metadata returned directly by the S3 listing.<p> 376 * 377 * @param visitor the metadata visitor 378 * @throws Exception if listing fails 379 */ 380 public void visitEntriesWithMetadata(I_CmsS3Client.I_CmsS3ObjectMetadataVisitor visitor) throws Exception { 381 382 visitEntriesWithMetadata("", visitor); 383 } 384 385 /** 386 * Visits image cache entries with the given cache key prefix and metadata returned directly by the S3 listing.<p> 387 * 388 * @param prefix the image cache key prefix, or an empty string for all entries 389 * @param visitor the metadata visitor 390 * @throws Exception if listing fails 391 */ 392 public void visitEntriesWithMetadata(String prefix, I_CmsS3Client.I_CmsS3ObjectMetadataVisitor visitor) 393 throws Exception { 394 395 ensureInitialized(); 396 String objectPrefix = normalizeKey(prefix == null ? "" : prefix); 397 m_s3Client.visitObjects(objectPrefix, metadata -> { 398 String objectKey = metadata.getKey(); 399 if (isImageCacheObjectKey(objectKey)) { 400 String key = objectKey; 401 m_lengthCache.put(objectKey, Long.valueOf(metadata.getLength())); 402 visitor.visit( 403 new CmsS3ObjectMetadata( 404 key, 405 metadata.getLength(), 406 metadata.getLastModified(), 407 metadata.getRevision())); 408 } 409 }); 410 } 411 412 /** 413 * @see org.opencms.loader.I_CmsImageCache#write(java.lang.String, byte[]) 414 */ 415 public void write(String key, byte[] content) throws Exception { 416 417 ensureInitialized(); 418 String objectKey = getObjectKey(key); 419 m_s3Client.putObject(objectKey, content); 420 m_lengthCache.put(objectKey, Long.valueOf(content.length)); 421 m_recentAccessMetadata.invalidate(normalizeKey(key)); 422 } 423 424 /** 425 * @see org.opencms.loader.I_CmsImageCache#writeRangeTo(java.lang.String, long, long, java.io.OutputStream) 426 */ 427 public void writeRangeTo(String key, long start, long length, OutputStream out) throws Exception { 428 429 ensureInitialized(); 430 String objectKey = getObjectKey(key); 431 try { 432 CmsS3ObjectMetadata metadata = m_s3Client.writeObjectRangeToWithMetadata(objectKey, start, length, out); 433 Long completeLength = m_lengthCache.getIfPresent(objectKey); 434 rememberAccessMetadata( 435 new CmsS3ObjectMetadata( 436 normalizeKey(key), 437 completeLength == null ? metadata.getLength() : completeLength.longValue(), 438 metadata.getLastModified(), 439 metadata.getRevision())); 440 } catch (CmsStorageBlobNotFoundException e) { 441 m_lengthCache.invalidate(objectKey); 442 m_recentAccessMetadata.invalidate(normalizeKey(key)); 443 throw new CmsImageCacheEntryNotFoundException(key, e); 444 } catch (Exception e) { 445 m_lengthCache.invalidate(objectKey); 446 m_recentAccessMetadata.invalidate(normalizeKey(key)); 447 throw e; 448 } 449 } 450 451 /** 452 * @see org.opencms.loader.I_CmsImageCache#writeTo(java.lang.String, java.io.OutputStream) 453 */ 454 public void writeTo(String key, OutputStream out) throws Exception { 455 456 ensureInitialized(); 457 String objectKey = getObjectKey(key); 458 try { 459 CmsS3ObjectMetadata metadata = m_s3Client.writeObjectToWithMetadata(objectKey, out); 460 rememberAccessMetadata( 461 new CmsS3ObjectMetadata( 462 normalizeKey(key), 463 metadata.getLength(), 464 metadata.getLastModified(), 465 metadata.getRevision())); 466 } catch (CmsStorageBlobNotFoundException e) { 467 m_lengthCache.invalidate(objectKey); 468 m_recentAccessMetadata.invalidate(normalizeKey(key)); 469 throw new CmsImageCacheEntryNotFoundException(key, e); 470 } catch (Exception e) { 471 m_lengthCache.invalidate(objectKey); 472 m_recentAccessMetadata.invalidate(normalizeKey(key)); 473 throw e; 474 } 475 } 476 477 /** 478 * Initializes the S3 client.<p> 479 * 480 * @param s3Client the S3 client 481 * @throws Exception if the configured bucket can not be accessed 482 */ 483 protected void initClient(I_CmsS3Client s3Client) throws Exception { 484 485 String healthCheckKey = ".opencms-healthcheck/" + UUID.randomUUID().toString(); 486 byte[] healthCheckContent = "OpenCms image cache health check".getBytes(StandardCharsets.UTF_8); 487 boolean healthCheckStored = false; 488 Exception failure = null; 489 try { 490 s3Client.validateBucketAccess(); 491 s3Client.putObject(healthCheckKey, healthCheckContent); 492 healthCheckStored = true; 493 if (!Arrays.equals(healthCheckContent, s3Client.getObject(healthCheckKey))) { 494 throw new IllegalStateException("S3 image cache health check returned different content."); 495 } 496 } catch (Exception e) { 497 failure = e; 498 throw e; 499 } finally { 500 if (healthCheckStored) { 501 try { 502 s3Client.deleteObject(healthCheckKey); 503 } catch (Exception e) { 504 if (failure != null) { 505 failure.addSuppressed(e); 506 } else { 507 failure = e; 508 } 509 } 510 } 511 if (failure != null) { 512 try { 513 s3Client.close(); 514 } catch (Exception closeException) { 515 failure.addSuppressed(closeException); 516 } 517 } 518 } 519 if (failure != null) { 520 throw failure; 521 } 522 if ((m_s3Client != null) && (m_s3Client != s3Client)) { 523 m_s3Client.close(); 524 } 525 m_s3Client = s3Client; 526 m_lengthCache.invalidateAll(); 527 } 528 529 /** Deletes a batch of complete S3 object keys. */ 530 private CmsS3DeleteResult deleteObjectKeys(List<String> objectKeys) throws Exception { 531 532 List<String> keys = new ArrayList<String>(objectKeys); 533 try { 534 return m_s3Client.deleteObjects(keys); 535 } finally { 536 for (String key : keys) { 537 m_lengthCache.invalidate(key); 538 } 539 } 540 } 541 542 /** 543 * Ensures the cache has been initialized.<p> 544 */ 545 private void ensureInitialized() { 546 547 if (m_s3Client == null) { 548 throw new IllegalStateException("Image cache has not been initialized."); 549 } 550 } 551 552 /** 553 * Returns the S3 object key.<p> 554 * 555 * @param key the image cache key 556 * @return the object key 557 */ 558 private String getObjectKey(String key) { 559 560 return normalizeKey(key); 561 } 562 563 /** 564 * Checks whether an object key belongs to this image cache.<p> 565 * 566 * @param objectKey the object key 567 * @return <code>true</code> if the object key belongs to this image cache 568 */ 569 private boolean isImageCacheObjectKey(String objectKey) { 570 571 return !objectKey.isEmpty(); 572 } 573 574 /** Normalizes an image cache key. */ 575 private String normalizeKey(String key) { 576 577 String normalizedKey = key; 578 while (normalizedKey.startsWith("/")) { 579 normalizedKey = normalizedKey.substring(1); 580 } 581 return normalizedKey; 582 } 583 584 /** Stores S3 response metadata for access-triggered renewal. */ 585 private void rememberAccessMetadata(CmsS3ObjectMetadata metadata) { 586 587 if ((metadata.getLastModified() != null) && CmsStringUtil.isNotEmptyOrWhitespaceOnly(metadata.getRevision())) { 588 m_recentAccessMetadata.put( 589 normalizeKey(metadata.getKey()), 590 new CmsImageCacheEntry( 591 normalizeKey(metadata.getKey()), 592 metadata.getLength(), 593 metadata.getLastModified(), 594 metadata.getRevision())); 595 } 596 } 597}