    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    public boolean streamResource(final Resource resource, final String providedChecksum, final Set<Options> options) throws IOException
    096    {
    097        if (!resource.exists())
    098        {
    099            // TODO: Or should we just return false here and not send back a specific error with the (eventual) 404?
    100
    101            response.sendError(HttpServletResponse.SC_NOT_FOUND, String.format("Unable to locate asset '%s' (the file does not exist).", resource));
    102
    103            return true;
    104        }
    105
    106        final boolean compress = providedChecksum.startsWith("z");
    107
    108        return tracker.perform("Streaming " + resource + (compress ? " (compressed)" : ""), new IOOperation<Boolean>()
    109        {
    110            public Boolean perform() throws IOException
    111            {
    112                StreamableResourceProcessing processing = compress
    113                        ? StreamableResourceProcessing.COMPRESSION_ENABLED
    114                        : StreamableResourceProcessing.COMPRESSION_DISABLED;
    115
    116                StreamableResource streamable = streamableResourceSource.getStreamableResource(resource, processing, resourceChangeTracker);
    117
    118                return streamResource(resource, streamable, compress ? providedChecksum.substring(1) : providedChecksum, options);
    119            }
    120        });
    121    }
    122
    123    public boolean streamResource(StreamableResource streamable, String providedChecksum, Set<Options> options) throws IOException
    124    {
    125        return streamResource(null, streamable, providedChecksum, options);
    126    }
    127
    128    public boolean streamResource(Resource resource, StreamableResource streamable, String providedChecksum, Set<Options> options) throws IOException
    129    {
    130        assert streamable != null;
    131        assert providedChecksum != null;
    132        assert options != null;
    133
    134        String actualChecksum = streamable.getChecksum();
    135
    136        if (providedChecksum.length() > 0 && !providedChecksum.equals(actualChecksum))
    137        {
    138
    139            // TAP5-2185: Trying to find the wrongly-checksummed resource in the classpath and context,
    140            // so we can create an Asset with the correct checksum and redirect to it.
    141            Asset asset = null;
    142            if (resource != null)
    143            {
    144                asset = findAssetInsideWebapp(resource);
    145            }
    146            if (asset != null)
    147            {
    148                response.sendRedirect(asset.toClientURL());
    149                return true;
    150            }
    151            return false;
    152        }
    153
    154
    155        // ETag should be surrounded with quotes.
    156        String token = QUOTE + actualChecksum + QUOTE;
    157
    158        // Even when sending a 304, we want the ETag associated with the request.
    159        // In most cases (except JavaScript modules), the checksum is also embedded into the URL.
    160        // However, E-Tags are also useful for enabling caching inside intermediate servers, CDNs, etc.
    161        response.setHeader("ETag", token);
    162
    163        // If the client can send the correct ETag token, then its cache already contains the correct
    164        // content.
    165        String providedToken = request.getHeader("If-None-Match");
    166
    167        if (token.equals(providedToken))
    168        {
    169            response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
    170            return true;
    171        }
    172
    173        long lastModified = streamable.getLastModified();
    174
    175        long ifModifiedSince;
    176
    177        try
    178        {
    179            ifModifiedSince = request.getDateHeader(IF_MODIFIED_SINCE_HEADER);
    180        } catch (IllegalArgumentException ex)
    181        {
    182            // Simulate the header being missing if it is poorly formatted.
    183
    184            ifModifiedSince = -1;
    185        }
    186
    187        if (ifModifiedSince > 0 && ifModifiedSince >= lastModified)
    188        {
    189            response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
    190            return true;
    191        }
    192
    193        // Prevent the upstream code from compressing when we don't want to.
    194
    195        response.disableCompression();
    196
    197        response.setDateHeader("Last-Modified", lastModified);
    198
    199
    200        if (productionMode && !options.contains(Options.OMIT_EXPIRATION))
    201        {
    202            // Starting in 5.4, this is a lot less necessary; any change to a Resource will result
    203            // in a new asset URL with the changed checksum incorporated into the URL.
    204            response.setDateHeader("Expires", lastModified + InternalConstants.TEN_YEARS);
    205        }
    206
    207        // This is really for modules, which can not have a content hash code in the URL; therefore, we want
    208        // the browser to re-validate the resources on each new page render; because of the ETags, that will
    209        // mostly result in quick SC_NOT_MODIFIED responses.
    210        if (options.contains(Options.OMIT_EXPIRATION))
    211        {
    212            response.setHeader("Cache-Control", omitExpirationCacheControlHeader);
    213        }
    214
    215        if (streamable.getCompression() == CompressionStatus.COMPRESSED)
    216        {
    217            response.setHeader(TapestryHttpInternalConstants.CONTENT_ENCODING_HEADER, TapestryHttpInternalConstants.GZIP_CONTENT_ENCODING);
    218        }
    219
    220        ResponseCustomizer responseCustomizer = streamable.getResponseCustomizer();
    221
    222        if (responseCustomizer != null)
    223        {
    224            responseCustomizer.customizeResponse(streamable, response);
    225        }
    226
    227        if (!request.getMethod().equals("HEAD"))
    228        {
    229            response.setContentLength(streamable.getSize());
    230
    231            OutputStream os = response.getOutputStream(streamable.getContentType().toString());
    232            streamable.streamTo(os);
    233            os.close();
    234        }
    235
    236        return true;
    237    }
    238
    239    private Asset findAssetInsideWebapp(Resource resource)
    240    {
    241        Asset asset;
    242        asset = findAssetFromClasspath(resource);
    243        if (asset == null)
    244        {
    245            asset = findAssetFromContext(resource);
    246        }
    247        return asset;
    248    }
    249
    250    private Asset findAssetFromContext(Resource resource)
    251    {
    252        Asset asset = null;
    253        try
    254        {
    255            asset = contextAssetFactory.createAsset(resource);
    256        }
    257        catch (RuntimeException e)
    258        {
    259            // not an existing context asset. go ahead.
    260        }
    261        return asset;
    262    }
    263
    264    private Asset findAssetFromClasspath(Resource resource)
    265    {
    266        Asset asset = null;
    267        try
    268        {
    269            asset = classpathAssetFactory.createAsset(resource);
    270        }
    271        catch (RuntimeException e)
    272        {
    273            // not an existing classpath asset. go ahead.
    274        }
    275        return asset;
    276    }
    277
    278}