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}