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.I_CmsStorageDelivery;
031import org.opencms.file.CmsResource;
032import org.opencms.file.CmsStoredContentInfo;
033import org.opencms.flex.CmsFlexController;
034import org.opencms.main.CmsException;
035import org.opencms.util.CmsRequestUtil;
036import org.opencms.workplace.CmsWorkplaceManager;
037
038import java.io.IOException;
039
040import jakarta.servlet.http.HttpServletRequest;
041import jakarta.servlet.http.HttpServletResponse;
042
043/**
044 * Helper for delivering externally stored VFS content directly to an HTTP response.<p>
045 */
046public class CmsStoredContentDeliveryHelper {
047
048    /**
049     * Delivery result.<p>
050     */
051    public enum DeliveryResult {
052
053        /** The content has been delivered. */
054        DELIVERED,
055
056        /** The content can not be delivered by this helper. */
057        NOT_DELIVERABLE,
058
059        /** The response has been answered with 304. */
060        NOT_MODIFIED,
061
062        /** The response has been answered with 416. */
063        RANGE_NOT_SATISFIABLE
064    }
065
066    /** Weak ETag prefix. */
067    private static final String WEAK_ETAG_PREFIX = "W/";
068
069    /** The maximum age for delivered contents in the clients cache. */
070    private final long m_clientCacheMaxAge;
071
072    /**
073     * Creates a new delivery helper without explicit client cache max age.<p>
074     */
075    public CmsStoredContentDeliveryHelper() {
076
077        this(-1);
078    }
079
080    /**
081     * Creates a new delivery helper.<p>
082     *
083     * @param clientCacheMaxAge the maximum age for delivered contents in the clients cache
084     */
085    public CmsStoredContentDeliveryHelper(long clientCacheMaxAge) {
086
087        m_clientCacheMaxAge = clientCacheMaxAge;
088    }
089
090    /**
091     * Returns if the given content can be delivered by this helper.<p>
092     *
093     * @param info the stored content info
094     * @param storage the delivery storage
095     *
096     * @return if the content can be delivered
097     */
098    public boolean canDeliver(CmsStoredContentInfo info, I_CmsStorageDelivery storage) {
099
100        return (info != null) && info.isExternallyStored() && (info.getLength() >= 0) && (storage != null);
101    }
102
103    /**
104     * Delivers stored content to the servlet response.<p>
105     *
106     * @param info the stored content info
107     * @param storage the delivery storage
108     * @param req the servlet request
109     * @param res the servlet response
110     *
111     * @return the delivery result
112     *
113     * @throws IOException in case writing to the response fails
114     * @throws CmsException in case delivery fails
115     */
116    public DeliveryResult deliver(
117        CmsStoredContentInfo info,
118        I_CmsStorageDelivery storage,
119        HttpServletRequest req,
120        HttpServletResponse res)
121    throws IOException, CmsException {
122
123        if (!canDeliver(info, storage) || (req == null) || (res == null) || !isGetOrHeadRequest(req)) {
124            return DeliveryResult.NOT_DELIVERABLE;
125        }
126        CmsResource resource = info.getResource();
127        if (isNotModified(info, req, res)) {
128            return DeliveryResult.NOT_MODIFIED;
129        }
130        String rangeHeader = req.getHeader(CmsRequestUtil.HEADER_RANGE);
131        boolean rangeRequest = (rangeHeader != null)
132            && isGetRequest(req)
133            && storage.supportsRangeDelivery()
134            && matchesIfRange(req, resource, info);
135        if (storage.supportsRangeDelivery()) {
136            res.setHeader(CmsRequestUtil.HEADER_ACCEPT_RANGES, CmsByteRange.RANGE_UNIT_BYTES);
137        }
138        if (rangeRequest) {
139            CmsByteRange range = CmsByteRange.parse(rangeHeader, info.getLength());
140            if (range.isInvalid()) {
141                prepareRangeNotSatisfiableResponse(info, res);
142                return DeliveryResult.RANGE_NOT_SATISFIABLE;
143            } else if (!range.isIgnored()) {
144                preparePartialResponse(info, range, req, res);
145                if (!isHeadRequest(req)) {
146                    streamRangeTo(storage, info, range, res);
147                }
148                return DeliveryResult.DELIVERED;
149            }
150        }
151        prepareFullResponse(info, req, res);
152        if (!isHeadRequest(req)) {
153            streamTo(storage, info, res);
154        }
155        return DeliveryResult.DELIVERED;
156    }
157
158    /**
159     * Returns the ETag for a resource.<p>
160     *
161     * @param resource the resource
162     *
163     * @return the ETag
164     */
165    private String getETag(CmsStoredContentInfo info) {
166
167        return "\"" + info.getHash() + "\"";
168    }
169
170    /**
171     * Returns if the given value is an entity tag.<p>
172     *
173     * @param value the value
174     *
175     * @return <code>true</code> if the value is an entity tag
176     */
177    private boolean isEntityTag(String value) {
178
179        String tag = value.trim();
180        return tag.startsWith("\"") || tag.startsWith(WEAK_ETAG_PREFIX + "\"");
181    }
182
183    /**
184     * Returns if the request is a GET or HEAD request.<p>
185     *
186     * @param req the request
187     *
188     * @return if the request is a GET or HEAD request
189     */
190    private boolean isGetOrHeadRequest(HttpServletRequest req) {
191
192        return isGetRequest(req) || isHeadRequest(req);
193    }
194
195    /**
196     * Returns if the request is a GET request.<p>
197     *
198     * @param req the request
199     *
200     * @return if the request is a GET request
201     */
202    private boolean isGetRequest(HttpServletRequest req) {
203
204        return "GET".equalsIgnoreCase(req.getMethod());
205    }
206
207    /**
208     * Returns if the request is a HEAD request.<p>
209     *
210     * @param req the request
211     *
212     * @return if the request is a HEAD request
213     */
214    private boolean isHeadRequest(HttpServletRequest req) {
215
216        return "HEAD".equalsIgnoreCase(req.getMethod());
217    }
218
219    /**
220     * Returns if the content was not modified since the client request.<p>
221     *
222     * @param resource the resource
223     * @param req the request
224     * @param res the response
225     *
226     * @return if the content was not modified
227     */
228    private boolean isNotModified(CmsStoredContentInfo info, HttpServletRequest req, HttpServletResponse res) {
229
230        CmsResource resource = info.getResource();
231
232        boolean hasIfNoneMatch = req.getHeader(CmsRequestUtil.HEADER_IF_NONE_MATCH) != null;
233        boolean notModified = hasIfNoneMatch
234        ? matchesIfNoneMatch(req, info)
235        : CmsFlexController.isNotModifiedSince(req, resource.getDateLastModified());
236        if (resource.getState().isUnchanged() && !CmsWorkplaceManager.isWorkplaceUser(req) && notModified) {
237            long now = System.currentTimeMillis();
238            if ((resource.getDateReleased() < now) && (resource.getDateExpired() > now)) {
239                res.setHeader(CmsRequestUtil.HEADER_ETAG, getETag(info));
240                CmsFlexController.setDateExpiresHeader(res, resource.getDateExpired(), m_clientCacheMaxAge);
241                res.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
242                return true;
243            }
244        }
245        return false;
246    }
247
248    /**
249     * Returns if the If-None-Match header matches the resource ETag.<p>
250     *
251     * @param req the request
252     * @param info the stored content info
253     *
254     * @return if the entity tag matches
255     */
256    private boolean matchesIfNoneMatch(HttpServletRequest req, CmsStoredContentInfo info) {
257
258        String ifNoneMatch = req.getHeader(CmsRequestUtil.HEADER_IF_NONE_MATCH);
259        if (ifNoneMatch == null) {
260            return false;
261        }
262        String contentETag = stripWeakPrefix(getETag(info));
263        String[] tags = ifNoneMatch.split(",");
264        for (int i = 0; i < tags.length; i++) {
265            String tag = stripWeakPrefix(tags[i].trim());
266            if ("*".equals(tag) || contentETag.equals(tag)) {
267                return true;
268            }
269        }
270        return false;
271    }
272
273    /**
274     * Checks the If-Range header.<p>
275     *
276     * @param req the request
277     * @param resource the resource
278     *
279     * @return if a range request should be processed
280     */
281    private boolean matchesIfRange(HttpServletRequest req, CmsResource resource, CmsStoredContentInfo info) {
282
283        String ifRange = req.getHeader(CmsRequestUtil.HEADER_IF_RANGE);
284        if (ifRange == null) {
285            return true;
286        }
287        if (isEntityTag(ifRange)) {
288            return getETag(info).equals(ifRange.trim());
289        }
290        try {
291            long ifRangeDate = req.getDateHeader(CmsRequestUtil.HEADER_IF_RANGE);
292            return (ifRangeDate >= 0) && ((resource.getDateLastModified() / 1000) * 1000 == ifRangeDate);
293        } catch (IllegalArgumentException e) {
294            return false;
295        }
296    }
297
298    /**
299     * Prepares cache headers.<p>
300     *
301     * @param info the stored content info
302     * @param req the request
303     * @param res the response
304     */
305    private void prepareCacheHeaders(CmsStoredContentInfo info, HttpServletRequest req, HttpServletResponse res) {
306
307        CmsResource resource = info.getResource();
308        res.setHeader(CmsRequestUtil.HEADER_ETAG, getETag(info));
309        if (CmsWorkplaceManager.isWorkplaceUser(req)) {
310            res.setDateHeader(CmsRequestUtil.HEADER_LAST_MODIFIED, System.currentTimeMillis());
311            CmsRequestUtil.setNoCacheHeaders(res);
312        } else {
313            res.setDateHeader(CmsRequestUtil.HEADER_LAST_MODIFIED, resource.getDateLastModified());
314            if (!res.containsHeader(CmsRequestUtil.HEADER_CACHE_CONTROL)) {
315                long expireTime = resource.getDateExpired();
316                if (expireTime == CmsResource.DATE_EXPIRED_DEFAULT) {
317                    expireTime--;
318                }
319                CmsFlexController.setDateExpiresHeader(res, expireTime, m_clientCacheMaxAge);
320            }
321        }
322    }
323
324    /**
325     * Prepares the full response.<p>
326     *
327     * @param info the stored content info
328     * @param req the request
329     * @param res the response
330     */
331    private void prepareFullResponse(CmsStoredContentInfo info, HttpServletRequest req, HttpServletResponse res) {
332
333        res.setStatus(HttpServletResponse.SC_OK);
334        setContentLength(res, info.getLength());
335        prepareCacheHeaders(info, req, res);
336    }
337
338    /**
339     * Prepares the partial response.<p>
340     *
341     * @param info the stored content info
342     * @param range the byte range
343     * @param req the request
344     * @param res the response
345     */
346    private void preparePartialResponse(
347        CmsStoredContentInfo info,
348        CmsByteRange range,
349        HttpServletRequest req,
350        HttpServletResponse res) {
351
352        res.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
353        setContentLength(res, range.m_length);
354        res.setHeader(
355            CmsRequestUtil.HEADER_CONTENT_RANGE,
356            CmsByteRange.RANGE_UNIT_BYTES
357                + " "
358                + range.m_start
359                + "-"
360                + (range.m_start + range.m_length - 1)
361                + "/"
362                + info.getLength());
363        prepareCacheHeaders(info, req, res);
364    }
365
366    /**
367     * Prepares a 416 response.<p>
368     *
369     * @param info the stored content info
370     * @param res the response
371     */
372    private void prepareRangeNotSatisfiableResponse(CmsStoredContentInfo info, HttpServletResponse res) {
373
374        res.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
375        res.setHeader(CmsRequestUtil.HEADER_CONTENT_RANGE, CmsByteRange.RANGE_UNIT_BYTES + " */" + info.getLength());
376        res.setHeader(CmsRequestUtil.HEADER_ETAG, getETag(info));
377    }
378
379    /**
380     * Sets the content length.<p>
381     *
382     * @param res the response
383     * @param contentLength the content length
384     */
385    private void setContentLength(HttpServletResponse res, long contentLength) {
386
387        if (contentLength <= Integer.MAX_VALUE) {
388            res.setContentLength((int)contentLength);
389        } else {
390            res.setHeader(CmsRequestUtil.HEADER_CONTENT_LENGTH, String.valueOf(contentLength));
391        }
392    }
393
394    /**
395     * Streams a range to the response.<p>
396     *
397     * @param storage the delivery storage
398     * @param info the stored content info
399     * @param range the byte range
400     * @param res the response
401     *
402     * @throws IOException in case writing to the response fails
403     * @throws CmsException in case delivery fails
404     */
405    private void streamRangeTo(
406        I_CmsStorageDelivery storage,
407        CmsStoredContentInfo info,
408        CmsByteRange range,
409        HttpServletResponse res)
410    throws IOException, CmsException {
411
412        try {
413            storage.streamRangeTo(null, info.getHash(), range.m_start, range.m_length, res.getOutputStream());
414        } catch (IOException | CmsException e) {
415            throw e;
416        } catch (Exception e) {
417            throw new CmsException(
418                Messages.get().container(Messages.ERR_STORED_CONTENT_DELIVERY_2, info.getStorage(), info.getHash()),
419                e);
420        }
421    }
422
423    /**
424     * Streams the full content to the response.<p>
425     *
426     * @param storage the delivery storage
427     * @param info the stored content info
428     * @param res the response
429     *
430     * @throws IOException in case writing to the response fails
431     * @throws CmsException in case delivery fails
432     */
433    private void streamTo(I_CmsStorageDelivery storage, CmsStoredContentInfo info, HttpServletResponse res)
434    throws IOException, CmsException {
435
436        try {
437            storage.streamTo(null, info.getHash(), res.getOutputStream());
438        } catch (IOException | CmsException e) {
439            throw e;
440        } catch (Exception e) {
441            throw new CmsException(
442                Messages.get().container(Messages.ERR_STORED_CONTENT_DELIVERY_2, info.getStorage(), info.getHash()),
443                e);
444        }
445    }
446
447    /**
448     * Removes a weak entity tag prefix from the given value.<p>
449     *
450     * @param value the value
451     *
452     * @return the value without weak prefix
453     */
454    private String stripWeakPrefix(String value) {
455
456        return value.startsWith(WEAK_ETAG_PREFIX) ? value.substring(WEAK_ETAG_PREFIX.length()) : value;
457    }
458}