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}