    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}