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, 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.db.storage; 029 030import org.opencms.db.CmsDbContext; 031import org.opencms.file.I_CmsFileContentStreamHandler; 032 033import java.io.IOException; 034import java.io.InputStream; 035import java.io.OutputStream; 036import java.nio.ByteBuffer; 037import java.nio.channels.SeekableByteChannel; 038import java.nio.charset.StandardCharsets; 039import java.nio.file.DirectoryNotEmptyException; 040import java.nio.file.FileAlreadyExistsException; 041import java.nio.file.Files; 042import java.nio.file.LinkOption; 043import java.nio.file.NoSuchFileException; 044import java.nio.file.Path; 045import java.nio.file.Paths; 046import java.nio.file.StandardCopyOption; 047import java.nio.file.StandardOpenOption; 048import java.util.Arrays; 049import java.util.stream.Stream; 050 051/** 052 * File system backend for the OpenCms deduplicating media storage engine (DMSE).<p> 053 * 054 * This implementation stores binary content as files in the local file system. 055 * It uses a 3-level directory structure based on the content hash to avoid 056 * having too many files in a single directory.<p> 057 * 058 * @see org.opencms.db.storage.CmsStorageManager 059 */ 060public class CmsFsStorage extends A_CmsStorage implements I_CmsEnumerableStorage, I_CmsStorageDelivery { 061 062 /** The type name of the storage implementation. */ 063 public static final String STORAGE_TYPE = "fs"; 064 065 /** The base path of the storage repository. */ 066 private final Path m_repository; 067 068 /** The configured backend id. */ 069 private final String m_name; 070 071 /** 072 * Creates a new file system storage.<p> 073 * 074 * @param name the configured backend id used as storage identifier 075 * @param basePath the base path for the storage repository 076 */ 077 public CmsFsStorage(String name, String basePath) { 078 079 m_name = name; 080 m_repository = Paths.get(basePath).toAbsolutePath().normalize(); 081 } 082 083 /** 084 * @see org.opencms.db.storage.I_CmsStorage#deleteContent(org.opencms.db.CmsDbContext, java.lang.String) 085 */ 086 @Override 087 public void deleteContent(CmsDbContext dbc, String hash) throws Exception { 088 089 Path file = getContentPath(hash); 090 if (Files.exists(file, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { 091 throw new IOException(Messages.get().getBundle().key(Messages.ERR_STORAGE_PATH_NOT_REGULAR_FILE_1, file)); 092 } 093 if (Files.deleteIfExists(file)) { 094 deleteEmptyParents(file.getParent()); 095 } 096 } 097 098 /** 099 * @see org.opencms.db.storage.I_CmsStorage#getStorageIdentifier() 100 */ 101 @Override 102 public String getStorageIdentifier() { 103 104 return m_name; 105 } 106 107 /** 108 * @see org.opencms.db.storage.I_CmsStorage#loadContent(org.opencms.db.CmsDbContext, java.lang.String) 109 */ 110 @Override 111 public byte[] loadContent(CmsDbContext dbc, String hash) throws Exception { 112 113 return Files.readAllBytes(getExistingRegularContentPath(hash)); 114 } 115 116 /** 117 * @see I_CmsStorage#loadContentFrom(CmsDbContext, String, I_CmsFileContentStreamHandler) 118 */ 119 @Override 120 public void loadContentFrom(CmsDbContext dbc, String hash, I_CmsFileContentStreamHandler handler) throws Exception { 121 122 try (InputStream in = Files.newInputStream(getExistingRegularContentPath(hash))) { 123 handler.read(in); 124 } 125 } 126 127 /** 128 * @see I_CmsStorage#loadContentTo(CmsDbContext, String, OutputStream) 129 */ 130 @Override 131 public void loadContentTo(CmsDbContext dbc, String hash, OutputStream out) throws Exception { 132 133 Files.copy(getExistingRegularContentPath(hash), out); 134 } 135 136 /** 137 * @see org.opencms.db.storage.I_CmsStorage#storeContent(org.opencms.db.CmsDbContext, java.lang.String, byte[]) 138 */ 139 @Override 140 public void storeContent(CmsDbContext dbc, String hash, byte[] content) throws Exception { 141 142 if (content == null) { 143 throw new IllegalArgumentException(Messages.get().getBundle().key(Messages.ERR_STORAGE_CONTENT_NULL_0)); 144 } 145 Path file = getContentPath(hash); 146 if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { 147 if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { 148 throw new IOException( 149 Messages.get().getBundle().key(Messages.ERR_STORAGE_PATH_NOT_REGULAR_FILE_1, file)); 150 } 151 return; 152 } 153 Path parent = file.getParent(); 154 Files.createDirectories(parent); 155 Path tempFile = Files.createTempFile(parent, hash, ".tmp"); 156 try { 157 Files.write(tempFile, content); 158 Files.move(tempFile, file, StandardCopyOption.ATOMIC_MOVE); 159 } catch (FileAlreadyExistsException e) { 160 Files.deleteIfExists(tempFile); 161 if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { 162 throw new IOException( 163 Messages.get().getBundle().key(Messages.ERR_STORAGE_PATH_NOT_REGULAR_FILE_1, file), 164 e); 165 } 166 } catch (Exception e) { 167 Files.deleteIfExists(tempFile); 168 throw e; 169 } 170 } 171 172 /** 173 * @see org.opencms.db.storage.I_CmsStorageDelivery#streamRangeTo(org.opencms.db.CmsDbContext, java.lang.String, long, long, java.io.OutputStream) 174 */ 175 @Override 176 public void streamRangeTo(CmsDbContext dbc, String hash, long start, long length, OutputStream out) 177 throws Exception { 178 179 try (SeekableByteChannel channel = Files.newByteChannel( 180 getExistingRegularContentPath(hash), 181 StandardOpenOption.READ)) { 182 channel.position(start); 183 byte[] buffer = new byte[8192]; 184 long remaining = length; 185 while (remaining > 0) { 186 ByteBuffer byteBuffer = ByteBuffer.wrap(buffer, 0, (int)Math.min(buffer.length, remaining)); 187 int read = channel.read(byteBuffer); 188 if (read < 0) { 189 return; 190 } 191 out.write(buffer, 0, read); 192 remaining -= read; 193 } 194 } 195 } 196 197 /** 198 * @see org.opencms.db.storage.I_CmsStorageDelivery#streamTo(org.opencms.db.CmsDbContext, java.lang.String, java.io.OutputStream) 199 */ 200 @Override 201 public void streamTo(CmsDbContext dbc, String hash, OutputStream out) throws Exception { 202 203 loadContentTo(dbc, hash, out); 204 } 205 206 /** 207 * @see org.opencms.db.storage.I_CmsStorageDelivery#supportsRangeDelivery() 208 */ 209 @Override 210 public boolean supportsRangeDelivery() { 211 212 return true; 213 } 214 215 /** 216 * @see org.opencms.db.storage.I_CmsStorage#validateAvailable(org.opencms.db.CmsDbContext) 217 */ 218 @Override 219 public void validateAvailable(CmsDbContext dbc) throws Exception { 220 221 if (!Files.exists(m_repository, LinkOption.NOFOLLOW_LINKS)) { 222 throw new IOException( 223 Messages.get().getBundle().key(Messages.ERR_STORAGE_REPOSITORY_MISSING_1, m_repository)); 224 } 225 if (!Files.isDirectory(m_repository, LinkOption.NOFOLLOW_LINKS)) { 226 throw new IOException( 227 Messages.get().getBundle().key(Messages.ERR_STORAGE_REPOSITORY_NOT_DIRECTORY_1, m_repository)); 228 } 229 byte[] content = "OpenCms storage health check".getBytes(StandardCharsets.UTF_8); 230 Path testFile = Files.createTempFile(m_repository, ".opencms-healthcheck-", ".tmp"); 231 try { 232 Files.write(testFile, content); 233 byte[] readContent = Files.readAllBytes(testFile); 234 if (!Arrays.equals(content, readContent)) { 235 throw new IOException( 236 Messages.get().getBundle().key(Messages.ERR_STORAGE_REPOSITORY_READ_WRITE_1, m_repository)); 237 } 238 } finally { 239 Files.deleteIfExists(testFile); 240 } 241 } 242 243 /** 244 * @see org.opencms.db.storage.I_CmsEnumerableStorage#visitContentHashes(org.opencms.db.CmsDbContext, org.opencms.db.storage.I_CmsEnumerableStorage.I_CmsContentHashVisitor) 245 */ 246 @Override 247 public void visitContentHashes(CmsDbContext dbc, I_CmsContentHashVisitor visitor) throws Exception { 248 249 try (Stream<Path> files = Files.walk(m_repository)) { 250 for (Path file : (Iterable<Path>)files::iterator) { 251 if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { 252 continue; 253 } 254 String name = file.getFileName().toString(); 255 if (isHash(name)) { 256 visitor.visit(name.toLowerCase()); 257 } 258 } 259 } 260 } 261 262 /** 263 * Deletes empty parent folders up to, but not including, the repository root.<p> 264 * 265 * @param start the folder to start with 266 * @throws IOException if deleting an empty folder fails 267 */ 268 private void deleteEmptyParents(Path start) throws IOException { 269 270 Path current = start; 271 while ((current != null) && !current.equals(m_repository)) { 272 try { 273 Files.delete(current); 274 } catch (DirectoryNotEmptyException e) { 275 return; 276 } catch (NoSuchFileException e) { 277 return; 278 } 279 current = current.getParent(); 280 } 281 } 282 283 /** 284 * Resolves the storage path for the given content hash.<p> 285 * 286 * @param hash the content hash 287 * @return the storage path 288 */ 289 private Path getContentPath(String hash) { 290 291 String normalizedHash = validateHash(hash); 292 Path result = m_repository.resolve(normalizedHash.substring(0, 2)).resolve( 293 normalizedHash.substring(2, 4)).resolve(normalizedHash.substring(4, 6)).resolve(normalizedHash).normalize(); 294 if (!result.startsWith(m_repository)) { 295 throw new IllegalArgumentException( 296 Messages.get().getBundle().key(Messages.ERR_STORAGE_HASH_OUTSIDE_REPOSITORY_0)); 297 } 298 return result; 299 } 300 301 /** 302 * Returns the existing regular content file for the given hash.<p> 303 * 304 * @param hash the SHA-512 content hash 305 * @return the content file path 306 * @throws IOException if the content path is invalid 307 * @throws CmsStorageBlobNotFoundException if the content file does not exist 308 */ 309 private Path getExistingRegularContentPath(String hash) throws IOException, CmsStorageBlobNotFoundException { 310 311 Path file = getContentPath(hash); 312 if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { 313 throw new CmsStorageBlobNotFoundException( 314 Messages.get().getBundle().key(Messages.ERR_STORAGE_BLOB_MISSING_2, getStorageIdentifier(), hash)); 315 } 316 if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { 317 throw new IOException(Messages.get().getBundle().key(Messages.ERR_STORAGE_PATH_NOT_REGULAR_FILE_1, file)); 318 } 319 return file; 320 } 321 322 /** 323 * Checks if the given string looks like a SHA-512 hash.<p> 324 * 325 * @param value the value 326 * @return true if the value looks like a hash 327 */ 328 private boolean isHash(String value) { 329 330 if ((value == null) || (value.length() != 128)) { 331 return false; 332 } 333 for (int i = 0; i < value.length(); i++) { 334 char c = value.charAt(i); 335 if (!(((c >= '0') && (c <= '9')) || ((c >= 'a') && (c <= 'f')) || ((c >= 'A') && (c <= 'F')))) { 336 return false; 337 } 338 } 339 return true; 340 } 341 342}