001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.wicket.resource.bundles;
018
019import java.io.ByteArrayInputStream;
020import java.io.IOException;
021import java.io.InputStream;
022import java.io.Serializable;
023import java.time.Instant;
024import java.util.ArrayList;
025import java.util.List;
026import java.util.MissingResourceException;
027
028import javax.servlet.http.HttpServletResponse;
029
030import org.apache.wicket.Application;
031import org.apache.wicket.markup.head.IReferenceHeaderItem;
032import org.apache.wicket.request.resource.AbstractResource;
033import org.apache.wicket.request.resource.IResource;
034import org.apache.wicket.request.resource.ResourceReference;
035import org.apache.wicket.request.resource.caching.IStaticCacheableResource;
036import org.apache.wicket.resource.ITextResourceCompressor;
037import org.apache.wicket.util.io.ByteArrayOutputStream;
038import org.apache.wicket.util.io.IOUtils;
039import org.apache.wicket.util.lang.Args;
040import org.apache.wicket.util.lang.Bytes;
041import org.apache.wicket.util.lang.Classes;
042import org.apache.wicket.util.resource.AbstractResourceStream;
043import org.apache.wicket.util.resource.IResourceStream;
044import org.apache.wicket.util.resource.ResourceStreamNotFoundException;
045import org.slf4j.Logger;
046import org.slf4j.LoggerFactory;
047
048/**
049 * A {@linkplain IResource resource} that concatenates several resources into one download. This
050 * resource can only bundle {@link IStaticCacheableResource}s. The content type of the resource will
051 * be that of the first resource that specifies its content type.
052 * 
053 * @author papegaaij
054 */
055public class ConcatBundleResource extends AbstractResource implements IStaticCacheableResource
056{
057        private static final Logger log = LoggerFactory.getLogger(ConcatBundleResource.class);
058
059        private static final long serialVersionUID = 1L;
060
061        private final List<? extends IReferenceHeaderItem> providedResources;
062
063        private boolean cachingEnabled;
064
065        /**
066         * An optional compressor that will be used to compress the bundle resources
067         */
068        private ITextResourceCompressor compressor;
069
070        /**
071         * Construct.
072         * 
073         * @param providedResources
074         */
075        public ConcatBundleResource(List<? extends IReferenceHeaderItem> providedResources)
076        {
077                this.providedResources = Args.notNull(providedResources, "providedResources");
078                cachingEnabled = true;
079        }
080
081        @Override
082        protected ResourceResponse newResourceResponse(Attributes attributes)
083        {
084                final ResourceResponse resourceResponse = new ResourceResponse();
085
086                if (resourceResponse.dataNeedsToBeWritten(attributes))
087                {
088                        try
089                        {
090                                List<IResourceStream> resources = collectResourceStreams();
091                                if (resources == null)
092                                        return sendResourceError(resourceResponse, HttpServletResponse.SC_NOT_FOUND,
093                                                "Unable to find resource");
094
095                                resourceResponse.setContentType(findContentType(resources));
096
097                                // add Last-Modified header (to support HEAD requests and If-Modified-Since)
098                                final Instant lastModified = findLastModified(resources);
099
100                                if (lastModified != null)
101                                        resourceResponse.setLastModified(lastModified);
102
103                                // read resource data
104                                final byte[] bytes = readAllResources(resources);
105
106                                // send Content-Length header
107                                resourceResponse.setContentLength(bytes.length);
108
109                                // send response body with resource data
110                                resourceResponse.setWriteCallback(new WriteCallback()
111                                {
112                                        @Override
113                                        public void writeData(Attributes attributes)
114                                        {
115                                                attributes.getResponse().write(bytes);
116                                        }
117                                });
118                        }
119                        catch (IOException e)
120                        {
121                                log.debug(e.getMessage(), e);
122                                return sendResourceError(resourceResponse, 500, "Unable to read resource stream");
123                        }
124                        catch (ResourceStreamNotFoundException e)
125                        {
126                                log.debug(e.getMessage(), e);
127                                return sendResourceError(resourceResponse, 500, "Unable to open resource stream");
128                        }
129                }
130
131                return resourceResponse;
132        }
133
134        private List<IResourceStream> collectResourceStreams()
135        {
136                List<IResourceStream> ret = new ArrayList<>(providedResources.size());
137                for (IReferenceHeaderItem curItem : providedResources)
138                {
139                        IResourceStream stream = ((IStaticCacheableResource)curItem.getReference()
140                                .getResource()).getResourceStream();
141                        if (stream == null)
142                        {
143                                reportError(curItem.getReference(), "Cannot get resource stream for ");
144                                return null;
145                        }
146
147                        ret.add(stream);
148                }
149                return ret;
150        }
151
152        protected String findContentType(List<IResourceStream> resources)
153        {
154                for (IResourceStream curStream : resources)
155                        if (curStream.getContentType() != null)
156                                return curStream.getContentType();
157                return null;
158        }
159
160        protected Instant findLastModified(List<IResourceStream> resources)
161        {
162                Instant ret = null;
163                for (IResourceStream curStream : resources)
164                {
165                        Instant curLastModified = curStream.lastModifiedTime();
166                        if (ret == null || curLastModified.isAfter(ret))
167                                ret = curLastModified;
168                }
169                return ret;
170        }
171
172        protected byte[] readAllResources(List<IResourceStream> resources) throws IOException,
173                ResourceStreamNotFoundException
174        {
175                try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
176                        for (IResourceStream curStream : resources) {
177                                IOUtils.copy(curStream.getInputStream(), output);
178                        }
179
180                        return output.toByteArray();
181                }
182        }
183
184        private ResourceResponse sendResourceError(ResourceResponse resourceResponse, int errorCode,
185                String errorMessage)
186        {
187                if (log.isWarnEnabled())
188                {
189                        String msg = String.format("Bundled resource: %s (status=%d)", errorMessage, errorCode);
190                        log.warn(msg);
191                }
192
193                resourceResponse.setError(errorCode, errorMessage);
194                return resourceResponse;
195        }
196
197        @Override
198        public boolean isCachingEnabled()
199        {
200                return cachingEnabled;
201        }
202
203        public void setCachingEnabled(final boolean enabled)
204        {
205                cachingEnabled = enabled;
206        }
207
208        @Override
209        public Serializable getCacheKey()
210        {
211                ArrayList<Serializable> key = new ArrayList<>(providedResources.size());
212                for (IReferenceHeaderItem curItem : providedResources)
213                {
214                        Serializable curKey = ((IStaticCacheableResource)curItem.getReference().getResource()).getCacheKey();
215                        if (curKey == null)
216                        {
217                                reportError(curItem.getReference(), "Unable to get cache key for ");
218                                return null;
219                        }
220                        key.add(curKey);
221                }
222                return key;
223        }
224
225        /**
226         * If a bundle resource is missing then throws a {@link MissingResourceException} if
227         * {@link org.apache.wicket.settings.ResourceSettings#getThrowExceptionOnMissingResource()}
228         * says so, or logs a warning message if the logging level allows
229         * @param reference
230         *              The resource reference to the missing resource
231         * @param prefix
232         *              The error message prefix
233         */
234        private void reportError(ResourceReference reference, String prefix)
235        {
236                String scope = Classes.name(reference.getScope());
237                String name = reference.getName();
238                String message = prefix + reference.toString();
239
240                if (getThrowExceptionOnMissingResource())
241                {
242                        throw new MissingResourceException(message, scope, name);
243                }
244                else if (log.isWarnEnabled())
245                {
246                        log.warn(message);
247                }
248        }
249
250        @Override
251        public IResourceStream getResourceStream()
252        {
253                List<IResourceStream> streams = collectResourceStreams();
254
255                if (streams == null)
256                {
257                        return null;
258                }
259
260                final String contentType = findContentType(streams);
261                final Instant lastModified = findLastModified(streams);
262                AbstractResourceStream ret = new AbstractResourceStream()
263                {
264                        private static final long serialVersionUID = 1L;
265
266                        private byte[] bytes;
267                        
268                        private ByteArrayInputStream inputStream;
269
270                        private byte[] getBytes() {
271                                if (bytes == null) {
272                                        try
273                                        {
274                                                bytes = readAllResources(streams);
275                                        }
276                                        catch (IOException e)
277                                        {
278                                                return null;
279                                        }
280                                        catch (ResourceStreamNotFoundException e)
281                                        {
282                                                return null;
283                                        }
284                                }
285                                
286                                return bytes;
287                        }
288                        
289                        @Override
290                        public InputStream getInputStream() throws ResourceStreamNotFoundException
291                        {
292                                if (inputStream == null) {
293                                        inputStream = new ByteArrayInputStream(getBytes());                             
294                                }
295                                
296                                return inputStream;
297                        }
298
299                        @Override
300                        public Bytes length()
301                        {
302                                return Bytes.bytes(getBytes().length);
303                        }
304
305                        @Override
306                        public String getContentType()
307                        {
308                                return contentType;
309                        }
310
311                        @Override
312                        public Instant lastModifiedTime()
313                        {
314                                return lastModified;
315                        }
316
317                        @Override
318                        public void close() throws IOException
319                        {
320                                if (inputStream != null) {
321                                        inputStream.close();                                    
322                                }
323                        }
324                };
325                return ret;
326        }
327
328        public void setCompressor(ITextResourceCompressor compressor)
329        {
330                this.compressor = compressor;
331        }
332
333        public ITextResourceCompressor getCompressor()
334        {
335                return compressor;
336        }
337
338        /**
339         * @return the result of {@link org.apache.wicket.settings.ResourceSettings#getThrowExceptionOnMissingResource()}
340         */
341        protected boolean getThrowExceptionOnMissingResource()
342        {
343                return Application.get().getResourceSettings().getThrowExceptionOnMissingResource();
344        }
345}