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 015 * GNU 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 java.io.IOException; 031import java.io.OutputStream; 032import java.nio.ByteBuffer; 033import java.nio.channels.SeekableByteChannel; 034import java.nio.charset.StandardCharsets; 035import java.nio.file.FileVisitResult; 036import java.nio.file.Files; 037import java.nio.file.LinkOption; 038import java.nio.file.NoSuchFileException; 039import java.nio.file.Path; 040import java.nio.file.SimpleFileVisitor; 041import java.nio.file.StandardCopyOption; 042import java.nio.file.StandardOpenOption; 043import java.nio.file.attribute.BasicFileAttributes; 044import java.util.Arrays; 045import java.util.UUID; 046import java.util.concurrent.TimeUnit; 047 048import com.google.common.cache.Cache; 049import com.google.common.cache.CacheBuilder; 050 051/** 052 * File system based storage for generated image cache entries.<p> 053 */ 054public class CmsFsImageCache extends CmsRfsImageCache { 055 056 /** Exception wrapper used to transport visitor exceptions through the file visitor API. */ 057 private static final class EntryVisitorException extends IOException { 058 059 /** Serial version id. */ 060 private static final long serialVersionUID = 1L; 061 062 /** Creates an exception wrapper. */ 063 EntryVisitorException(Exception cause) { 064 065 super(cause); 066 } 067 } 068 069 /** Maximum number of file lengths kept locally. */ 070 private static final int LENGTH_CACHE_MAX_SIZE = 10000; 071 072 /** Number of minutes after which a cached file length expires. */ 073 private static final int LENGTH_CACHE_EXPIRY_MINUTES = 10; 074 075 /** Locally cached file lengths. */ 076 private Cache<Path, Long> m_lengthCache = CacheBuilder.newBuilder().maximumSize( 077 LENGTH_CACHE_MAX_SIZE).expireAfterAccess(LENGTH_CACHE_EXPIRY_MINUTES, TimeUnit.MINUTES).build(); 078 079 /** 080 * Creates a new FS image cache.<p> 081 * 082 * @param path the file system path 083 * @throws Exception if the path can not be initialized 084 */ 085 public CmsFsImageCache(String path) 086 throws Exception { 087 088 super(path); 089 validateAvailable(); 090 } 091 092 /** 093 * @see org.opencms.loader.I_CmsImageCache#clear() 094 */ 095 @Override 096 public void clear() throws Exception { 097 098 try { 099 Path repository = getRepository(); 100 Files.walkFileTree(repository, new SimpleFileVisitor<Path>() { 101 102 @Override 103 public FileVisitResult postVisitDirectory(Path directory, IOException exception) throws IOException { 104 105 if (exception != null) { 106 throw exception; 107 } 108 if (!repository.equals(directory)) { 109 Files.deleteIfExists(directory); 110 } 111 return FileVisitResult.CONTINUE; 112 } 113 114 @Override 115 public FileVisitResult visitFile(Path path, BasicFileAttributes attributes) throws IOException { 116 117 Files.deleteIfExists(path); 118 return FileVisitResult.CONTINUE; 119 } 120 }); 121 } finally { 122 m_lengthCache.invalidateAll(); 123 } 124 } 125 126 /** 127 * @see org.opencms.loader.I_CmsImageCache#close() 128 */ 129 @Override 130 public void close() throws Exception { 131 132 m_lengthCache.invalidateAll(); 133 } 134 135 /** 136 * @see org.opencms.loader.I_CmsImageCache#exists(java.lang.String) 137 */ 138 @Override 139 public boolean exists(String key) throws Exception { 140 141 Path path = getPath(key); 142 try { 143 BasicFileAttributes attributes = Files.readAttributes( 144 path, 145 BasicFileAttributes.class, 146 LinkOption.NOFOLLOW_LINKS); 147 if (attributes.isRegularFile()) { 148 m_lengthCache.put(path, Long.valueOf(attributes.size())); 149 return true; 150 } 151 m_lengthCache.invalidate(path); 152 return false; 153 } catch (NoSuchFileException e) { 154 m_lengthCache.invalidate(path); 155 return false; 156 } 157 } 158 159 /** 160 * @see org.opencms.loader.I_CmsImageCache#getLength(java.lang.String) 161 */ 162 @Override 163 public long getLength(String key) throws Exception { 164 165 Path path = getPath(key); 166 Long cachedLength = m_lengthCache.getIfPresent(path); 167 if (cachedLength != null) { 168 return cachedLength.longValue(); 169 } 170 try { 171 long length = Files.size(path); 172 m_lengthCache.put(path, Long.valueOf(length)); 173 return length; 174 } catch (NoSuchFileException e) { 175 m_lengthCache.invalidate(path); 176 throw new CmsImageCacheEntryNotFoundException(key, e); 177 } 178 } 179 180 /** 181 * Invalidates locally cached metadata for an image cache entry.<p> 182 * 183 * This is used after maintenance operations which modify the shared file system outside the regular cache 184 * access path.<p> 185 * 186 * @param key the image cache key 187 */ 188 public void invalidateLocalMetadata(String key) { 189 190 m_lengthCache.invalidate(getPath(key)); 191 } 192 193 /** 194 * @see org.opencms.loader.I_CmsImageCache#visitEntries(org.opencms.loader.I_CmsImageCache.I_CmsImageCacheEntryVisitor) 195 */ 196 @Override 197 public void visitEntries(I_CmsImageCacheEntryVisitor visitor) throws Exception { 198 199 visitEntries("", visitor); 200 } 201 202 /** 203 * @see org.opencms.loader.I_CmsImageCache#visitEntries(java.lang.String, org.opencms.loader.I_CmsImageCache.I_CmsImageCacheEntryVisitor) 204 */ 205 @Override 206 public void visitEntries(String prefix, I_CmsImageCacheEntryVisitor visitor) throws Exception { 207 208 String normalizedPrefix = prefix == null ? "" : prefix; 209 while (normalizedPrefix.startsWith("/")) { 210 normalizedPrefix = normalizedPrefix.substring(1); 211 } 212 int lastSlash = normalizedPrefix.lastIndexOf('/'); 213 String directoryPrefix = lastSlash < 0 ? "" : normalizedPrefix.substring(0, lastSlash + 1); 214 Path scanRoot = directoryPrefix.isEmpty() ? getRepository() : getPath(directoryPrefix); 215 if (!Files.isDirectory(scanRoot, LinkOption.NOFOLLOW_LINKS)) { 216 return; 217 } 218 final String keyPrefix = normalizedPrefix; 219 try { 220 Files.walkFileTree(scanRoot, new SimpleFileVisitor<Path>() { 221 222 @Override 223 public FileVisitResult visitFile(Path path, BasicFileAttributes attributes) throws IOException { 224 225 if (attributes.isRegularFile()) { 226 String key = toKey(path); 227 if (key.startsWith(keyPrefix)) { 228 try { 229 visitor.visit(key, attributes.size()); 230 } catch (Exception e) { 231 throw new EntryVisitorException(e); 232 } 233 } 234 } 235 return FileVisitResult.CONTINUE; 236 } 237 }); 238 } catch (EntryVisitorException e) { 239 throw (Exception)e.getCause(); 240 } 241 } 242 243 /** 244 * @see org.opencms.loader.I_CmsImageCache#write(java.lang.String, byte[]) 245 */ 246 @Override 247 public void write(String key, byte[] content) throws Exception { 248 249 Path path = getPath(key); 250 Path parent = path.getParent(); 251 Files.createDirectories(parent); 252 Path temporaryFile = Files.createTempFile(parent, ".opencms-image-", ".tmp"); 253 try { 254 Files.write(temporaryFile, content, StandardOpenOption.TRUNCATE_EXISTING); 255 Files.move(temporaryFile, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); 256 temporaryFile = null; 257 m_lengthCache.put(path, Long.valueOf(content.length)); 258 } finally { 259 if (temporaryFile != null) { 260 Files.deleteIfExists(temporaryFile); 261 } 262 } 263 } 264 265 /** 266 * @see org.opencms.loader.I_CmsImageCache#writeRangeTo(java.lang.String, long, long, java.io.OutputStream) 267 */ 268 @Override 269 public void writeRangeTo(String key, long start, long length, OutputStream out) throws Exception { 270 271 Path path = getPath(key); 272 try (SeekableByteChannel channel = Files.newByteChannel(path, StandardOpenOption.READ)) { 273 channel.position(start); 274 byte[] buffer = new byte[8192]; 275 long remaining = length; 276 while (remaining > 0) { 277 ByteBuffer byteBuffer = ByteBuffer.wrap(buffer, 0, (int)Math.min(buffer.length, remaining)); 278 int read = channel.read(byteBuffer); 279 if (read < 0) { 280 return; 281 } 282 out.write(buffer, 0, read); 283 remaining -= read; 284 } 285 } catch (NoSuchFileException e) { 286 m_lengthCache.invalidate(path); 287 throw new CmsImageCacheEntryNotFoundException(key, e); 288 } catch (Exception e) { 289 m_lengthCache.invalidate(path); 290 throw e; 291 } 292 } 293 294 /** 295 * @see org.opencms.loader.I_CmsImageCache#writeTo(java.lang.String, java.io.OutputStream) 296 */ 297 @Override 298 public void writeTo(String key, OutputStream out) throws Exception { 299 300 Path path = getPath(key); 301 try { 302 Files.copy(path, out); 303 } catch (NoSuchFileException e) { 304 m_lengthCache.invalidate(path); 305 throw new CmsImageCacheEntryNotFoundException(key, e); 306 } catch (Exception e) { 307 m_lengthCache.invalidate(path); 308 throw e; 309 } 310 } 311 312 /** 313 * Returns the cache path for a key.<p> 314 * 315 * @param key the image cache key 316 * @return the cache path 317 */ 318 protected Path getPath(String key) { 319 320 String normalizedKey = key; 321 while (normalizedKey.startsWith("/")) { 322 normalizedKey = normalizedKey.substring(1); 323 } 324 Path repository = getRepository(); 325 Path result = repository.resolve(normalizedKey).normalize(); 326 if (!result.startsWith(repository)) { 327 throw new IllegalArgumentException("Image cache key outside repository: " + key); 328 } 329 return result; 330 } 331 332 /** Returns a portable cache key for a repository path. */ 333 private String toKey(Path path) { 334 335 return getRepository().relativize(path).toString().replace(path.getFileSystem().getSeparator(), "/"); 336 } 337 338 /** 339 * Validates read and write access to the configured file system repository.<p> 340 * 341 * @throws Exception if validation fails 342 */ 343 private void validateAvailable() throws Exception { 344 345 byte[] content = "OpenCms image cache health check".getBytes(StandardCharsets.UTF_8); 346 String key = ".opencms-healthcheck-" + UUID.randomUUID().toString(); 347 Path testFile = getPath(key); 348 try { 349 write(key, content); 350 if (!Arrays.equals(content, Files.readAllBytes(testFile))) { 351 throw new IllegalStateException("FS image cache health check returned different content."); 352 } 353 } finally { 354 Files.deleteIfExists(testFile); 355 m_lengthCache.invalidate(testFile); 356 } 357 } 358}