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