001// Licensed under the Apache License, Version 2.0 (the "License"); 002// you may not use this file except in compliance with the License. 003// You may obtain a copy of the License at 004// 005// http://www.apache.org/licenses/LICENSE-2.0 006// 007// Unless required by applicable law or agreed to in writing, software 008// distributed under the License is distributed on an "AS IS" BASIS, 009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 010// See the License for the specific language governing permissions and 011// limitations under the License. 012 013package org.apache.tapestry5.internal.services; 014 015import org.apache.tapestry5.Asset; 016import org.apache.tapestry5.SymbolConstants; 017import org.apache.tapestry5.commons.Resource; 018import org.apache.tapestry5.http.TapestryHttpSymbolConstants; 019import org.apache.tapestry5.http.internal.TapestryHttpInternalConstants; 020import org.apache.tapestry5.http.services.Request; 021import org.apache.tapestry5.http.services.Response; 022import org.apache.tapestry5.internal.InternalConstants; 023import org.apache.tapestry5.internal.services.assets.ResourceChangeTracker; 024import org.apache.tapestry5.ioc.IOOperation; 025import org.apache.tapestry5.ioc.OperationTracker; 026import org.apache.tapestry5.ioc.annotations.InjectService; 027import org.apache.tapestry5.ioc.annotations.Symbol; 028import org.apache.tapestry5.services.AssetFactory; 029import org.apache.tapestry5.services.assets.*; 030 031import javax.servlet.http.HttpServletResponse; 032import java.io.IOException; 033import java.io.OutputStream; 034import java.util.Set; 035 036public class ResourceStreamerImpl implements ResourceStreamer 037{ 038 static final String IF_MODIFIED_SINCE_HEADER = "If-Modified-Since"; 039 040 private static final String QUOTE = "\""; 041 042 private final Request request; 043 044 private final Response response; 045 046 private final StreamableResourceSource streamableResourceSource; 047 048 private final boolean productionMode; 049 050 private final OperationTracker tracker; 051 052 private final ResourceChangeTracker resourceChangeTracker; 053 054 private final String omitExpirationCacheControlHeader; 055 056 private final AssetFactory classpathAssetFactory; 057 058 private final AssetFactory contextAssetFactory; 059 060 public ResourceStreamerImpl(Request request, 061 062 Response response, 063 064 StreamableResourceSource streamableResourceSource, 065 066 OperationTracker tracker, 067 068 @Symbol(TapestryHttpSymbolConstants.PRODUCTION_MODE) 069 boolean productionMode, 070 071 ResourceChangeTracker resourceChangeTracker, 072 073 @Symbol(SymbolConstants.OMIT_EXPIRATION_CACHE_CONTROL_HEADER) 074 String omitExpirationCacheControlHeader, 075 076 @InjectService("ClasspathAssetFactory") 077 AssetFactory classpathAssetFactory, 078 079 @InjectService("ContextAssetFactory") 080 AssetFactory contextAssetFactory) 081 { 082 this.request = request; 083 this.response = response; 084 this.streamableResourceSource = streamableResourceSource; 085 086 this.tracker = tracker; 087 this.productionMode = productionMode; 088 this.resourceChangeTracker = resourceChangeTracker; 089 this.omitExpirationCacheControlHeader = omitExpirationCacheControlHeader; 090 091 this.classpathAssetFactory = classpathAssetFactory; 092 this.contextAssetFactory = contextAssetFactory; 093 } 094 095 @Override 096 public boolean streamResource(final Resource resource, final String providedChecksum, final Set<Options> options) throws IOException 097 { 098 if (!resource.exists()) 099 { 100 // TODO: Or should we just return false here and not send back a specific error with the (eventual) 404? 101 102 response.sendError(HttpServletResponse.SC_NOT_FOUND, String.format("Unable to locate asset '%s' (the file does not exist).", resource)); 103 104 return true; 105 } 106 107 final boolean compress = providedChecksum != null && providedChecksum.startsWith("z"); 108 109 return tracker.perform("Streaming " + resource + (compress ? " (compressed)" : ""), new IOOperation<Boolean>() 110 { 111 public Boolean perform() throws IOException 112 { 113 StreamableResourceProcessing processing = compress 114 ? StreamableResourceProcessing.COMPRESSION_ENABLED 115 : StreamableResourceProcessing.COMPRESSION_DISABLED; 116 117 StreamableResource streamable = streamableResourceSource.getStreamableResource(resource, processing, resourceChangeTracker); 118 119 return streamResource(resource, streamable, compress ? providedChecksum.substring(1) : providedChecksum, options); 120 } 121 }); 122 } 123 124 @Override 125 public boolean streamResource(StreamableResource streamable, String providedChecksum, Set<Options> options) throws IOException 126 { 127 return streamResource(null, streamable, providedChecksum, options); 128 } 129 130 public boolean streamResource(Resource resource, StreamableResource streamable, String providedChecksum, Set<Options> options) throws IOException 131 { 132 assert streamable != null; 133 assert options != null; 134 135 String actualChecksum = streamable.getChecksum(); 136 137 if (providedChecksum != null && !providedChecksum.isEmpty() && !providedChecksum.equals(actualChecksum)) 138 { 139 140 // TAP5-2185: Trying to find the wrongly-checksummed resource in the classpath and context, 141 // so we can create an Asset with the correct checksum and redirect to it. 142 Asset asset = null; 143 if (resource != null) 144 { 145 asset = findAssetInsideWebapp(resource); 146 } 147 if (asset != null) 148 { 149 response.sendRedirect(asset.toClientURL()); 150 return true; 151 } 152 return false; 153 } 154 155 156 // ETag should be surrounded with quotes. 157 String token = QUOTE + actualChecksum + QUOTE; 158 159 // Even when sending a 304, we want the ETag associated with the request. 160 // In most cases (except JavaScript modules), the checksum is also embedded into the URL. 161 // However, E-Tags are also useful for enabling caching inside intermediate servers, CDNs, etc. 162 response.setHeader("ETag", token); 163 164 // If the client can send the correct ETag token, then its cache already contains the correct 165 // content. 166 String providedToken = request.getHeader("If-None-Match"); 167 168 if (token.equals(providedToken)) 169 { 170 response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); 171 return true; 172 } 173 174 long lastModified = streamable.getLastModified(); 175 176 long ifModifiedSince; 177 178 try 179 { 180 ifModifiedSince = request.getDateHeader(IF_MODIFIED_SINCE_HEADER); 181 } catch (IllegalArgumentException ex) 182 { 183 // Simulate the header being missing if it is poorly formatted. 184 185 ifModifiedSince = -1; 186 } 187 188 if (ifModifiedSince > 0 && ifModifiedSince >= lastModified) 189 { 190 response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); 191 return true; 192 } 193 194 // Prevent the upstream code from compressing when we don't want to. 195 196 response.disableCompression(); 197 198 response.setDateHeader("Last-Modified", lastModified); 199 200 201 if (productionMode && !options.contains(Options.OMIT_EXPIRATION)) 202 { 203 // Starting in 5.4, this is a lot less necessary; any change to a Resource will result 204 // in a new asset URL with the changed checksum incorporated into the URL. 205 response.setDateHeader("Expires", lastModified + InternalConstants.TEN_YEARS); 206 } 207 208 // This is really for modules, which can not have a content hash code in the URL; therefore, we want 209 // the browser to re-validate the resources on each new page render; because of the ETags, that will 210 // mostly result in quick SC_NOT_MODIFIED responses. 211 if (options.contains(Options.OMIT_EXPIRATION)) 212 { 213 response.setHeader("Cache-Control", omitExpirationCacheControlHeader); 214 } 215 216 if (streamable.getCompression() == CompressionStatus.COMPRESSED) 217 { 218 response.setHeader(TapestryHttpInternalConstants.CONTENT_ENCODING_HEADER, TapestryHttpInternalConstants.GZIP_CONTENT_ENCODING); 219 } 220 221 ResponseCustomizer responseCustomizer = streamable.getResponseCustomizer(); 222 223 if (responseCustomizer != null) 224 { 225 responseCustomizer.customizeResponse(streamable, response); 226 } 227 228 if (!request.getMethod().equals("HEAD")) 229 { 230 response.setContentLength(streamable.getSize()); 231 232 OutputStream os = response.getOutputStream(streamable.getContentType().toString()); 233 streamable.streamTo(os); 234 os.close(); 235 } 236 237 return true; 238 } 239 240 private Asset findAssetInsideWebapp(Resource resource) 241 { 242 Asset asset; 243 asset = findAssetFromClasspath(resource); 244 if (asset == null) 245 { 246 asset = findAssetFromContext(resource); 247 } 248 return asset; 249 } 250 251 private Asset findAssetFromContext(Resource resource) 252 { 253 Asset asset = null; 254 try 255 { 256 asset = contextAssetFactory.createAsset(resource); 257 } 258 catch (RuntimeException e) 259 { 260 // not an existing context asset. go ahead. 261 } 262 return asset; 263 } 264 265 private Asset findAssetFromClasspath(Resource resource) 266 { 267 Asset asset = null; 268 try 269 { 270 asset = classpathAssetFactory.createAsset(resource); 271 } 272 catch (RuntimeException e) 273 { 274 // not an existing classpath asset. go ahead. 275 } 276 return asset; 277 } 278 279}