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.extensions.ajax;
018
019import java.util.Locale;
020import javax.servlet.http.Cookie;
021import org.apache.wicket.Component;
022import org.apache.wicket.IRequestListener;
023import org.apache.wicket.WicketRuntimeException;
024import org.apache.wicket.ajax.AbstractDefaultAjaxBehavior;
025import org.apache.wicket.ajax.AjaxRequestTarget;
026import org.apache.wicket.ajax.json.JSONFunction;
027import org.apache.wicket.behavior.Behavior;
028import org.apache.wicket.core.request.handler.IPartialPageRequestHandler;
029import org.apache.wicket.markup.head.IHeaderResponse;
030import org.apache.wicket.markup.head.JavaScriptHeaderItem;
031import org.apache.wicket.request.Response;
032import org.apache.wicket.request.cycle.RequestCycle;
033import org.apache.wicket.request.handler.resource.ResourceReferenceRequestHandler;
034import org.apache.wicket.request.http.WebResponse;
035import org.apache.wicket.request.mapper.parameter.PageParameters;
036import org.apache.wicket.request.resource.ContentDisposition;
037import org.apache.wicket.request.resource.IResource;
038import org.apache.wicket.request.resource.IResource.Attributes;
039import org.apache.wicket.request.resource.ResourceReference;
040import org.apache.wicket.resource.JQueryPluginResourceReference;
041import org.apache.wicket.util.cookies.CookieDefaults;
042import org.apache.wicket.util.lang.Args;
043
044import com.github.openjson.JSONObject;
045
046/**
047 * Download resources via Ajax.
048 * <p>
049 * Usage:
050 *
051 * <pre>
052 * final AjaxDownloadBehavior download = new AjaxDownloadBehavior(resource);
053 * add(download);
054 *
055 * add(new AjaxButton("download")
056 * {
057 *      &#64;Override
058 *      protected void onSubmit(IPartialPageRequestHandler handler, Form&lt;?&gt; form)
059 *      {
060 *              download.initiate(handler);
061 *      }
062 * });
063 * </pre>
064 *
065 * <p>To set the name of the downloaded resource make use of
066 * {@link org.apache.wicket.request.resource.ResourceStreamResource#setFileName(String)} or
067 * {@link org.apache.wicket.request.resource.AbstractResource.ResourceResponse#setFileName(String)}</p>
068 *
069 * @author svenmeier
070 * @author Martin Grigorov
071 * @author Maxim Solodovnik
072 */
073public class AjaxDownloadBehavior extends AbstractDefaultAjaxBehavior
074{
075        private static final long serialVersionUID = 1L;
076
077        public enum Location {
078                /**
079                 * The resource will be downloaded into a {@code blob}.
080                 * <p>
081                 * This is recommended for modern browsers.
082                 */
083                Blob,
084
085                /**
086                 * The resource will be downloaded via a temporary created iframe, the resource has to be a
087                 * {@link ContentDisposition#ATTACHMENT}.
088                 * <p>
089                 * This is recommended when there are resources in the DOM which will be
090                 * closed automatically on JavaScript <em>unload</em> event, like WebSockets.
091                 * Supports both <em>success</em> and <em>failure</em> callbacks!
092                 */
093                IFrame,
094
095                /**
096                 * The resource will be downloaded by changing the location of the current DOM document,
097                 * the resource has to be a {@link ContentDisposition#ATTACHMENT}.
098                 * <p>
099                 * Note: This will trigger JavaScript <em>unload</em> event on the page!
100                 * Does not support {@link AjaxDownloadBehavior#onDownloadFailed(AjaxRequestTarget)} callback,
101                 * i.e. it is not possible to detect when the download has failed!
102                 */
103                SameWindow,
104
105                /**
106                 * The resource will be downloaded in a new browser window by using JavaScript <code>window.open()</code> API,
107                 * the resource has to be a {@link ContentDisposition#INLINE}.
108                 */
109                NewWindow
110        }
111
112        /**
113         * Name of parameter used to transfer the download identifier to the resource.
114         *
115         * @see #markCompleted(Attributes)
116         */
117        private static final String RESOURCE_PARAMETER_NAME = "wicket-ajaxdownload";
118
119        private static final ResourceReference JS = new JQueryPluginResourceReference(
120                AjaxDownloadBehavior.class, "wicket-ajaxdownload.js");
121
122        private final ResourceReference resourceReference;
123
124        private final ResourceBehavior resourceBehavior;
125
126        private PageParameters resourceParameters;
127
128        private Location location = Location.Blob;
129
130        private CookieDefaults.SameSite sameSite = null;
131
132        /**
133         * Download of a {@link IResource}.
134         *
135         * @param resource
136         *            resource to download
137         */
138        public AjaxDownloadBehavior(IResource resource)
139        {
140                Args.notNull(resource, "resource");
141                this.resourceBehavior = new ResourceBehavior(resource);
142                this.resourceReference = null;
143        }
144
145        /**
146         * Download of a {@link ResourceReference}.
147         * <p>
148         * The {@link IResource} returned by {@link ResourceReference#getResource()} must call
149         * {@link #markCompleted(Attributes)} when responding, otherwise the callback
150         * {@link #onDownloadSuccess(AjaxRequestTarget)} will not work.
151         *
152         * @param reference
153         *            reference to resource to download
154         */
155        public AjaxDownloadBehavior(ResourceReference reference)
156        {
157                this(reference, null);
158        }
159
160        /**
161         * Download of a {@link ResourceReference}.
162         * <p>
163         * The {@link IResource} returned by {@link ResourceReference#getResource()} must call
164         * {@link #markCompleted(Attributes)} when responding, otherwise the callback
165         * {@link #onDownloadSuccess(AjaxRequestTarget)} will not work.
166         *
167         * @param reference
168         *            reference to resource to download
169         * @param resourceParameters
170         *            parameters for the resource
171         */
172        public AjaxDownloadBehavior(ResourceReference reference, PageParameters resourceParameters)
173        {
174                this.resourceBehavior = null;
175
176                this.resourceReference = Args.notNull(reference, "reference");
177                this.resourceParameters = resourceParameters;
178        }
179
180        @Override
181        protected void onBind()
182        {
183                super.onBind();
184
185                if (resourceBehavior != null)
186                {
187                        getComponent().add(resourceBehavior);
188                }
189        }
190
191        @Override
192        protected void onUnbind()
193        {
194                super.onUnbind();
195
196                if (resourceBehavior != null)
197                {
198                        getComponent().remove(resourceBehavior);
199                }
200        }
201
202        /**
203         * Call this method to initiate the download. You can use the {@link #resourceParameters} to dynamically pass
204         * information to the {@link org.apache.wicket.request.resource.IResource} in order to generate contents.
205         *
206         * @param handler
207         *          the initiating RequestHandler
208         * @param resourceParameters
209         *          Some PageParameters that might be used by the resource in order to generate content
210         */
211        public void initiate(IPartialPageRequestHandler handler, PageParameters resourceParameters)
212        {
213                this.resourceParameters = resourceParameters;
214                initiate(handler);
215        }
216
217        /**
218         * Call this method to initiate the download.
219         *
220         * @param handler
221         *            the initiating RequestHandler
222         */
223        public void initiate(IPartialPageRequestHandler handler)
224        {
225                if (getComponent() == null)
226                {
227                        throw new WicketRuntimeException("not bound to a component");
228                }
229
230                CharSequence url;
231                if (resourceBehavior == null)
232                {
233                        if (resourceReference.canBeRegistered())
234                        {
235                                getComponent().getApplication().getResourceReferenceRegistry()
236                                        .registerResourceReference(resourceReference);
237                        }
238
239                        PageParameters parameters = new PageParameters();
240                        if (resourceParameters != null)
241                        {
242                                parameters.mergeWith(resourceParameters);
243                        }
244                        parameters.set(RESOURCE_PARAMETER_NAME, getName());
245
246                        url = getComponent().getRequestCycle()
247                                .urlFor(new ResourceReferenceRequestHandler(resourceReference, parameters));
248                }
249                else
250                {
251                        url = resourceBehavior.getUrl();
252                }
253
254                JSONObject settings = new JSONObject();
255                settings.put("attributes", new JSONFunction(renderAjaxAttributes(getComponent())));
256                settings.put("name", getName());
257                settings.put("downloadUrl", url);
258                settings.put("sameSite", generateSameSiteAttribute());
259                settings.put("method", getLocation().name().toLowerCase(Locale.ROOT));
260
261                handler.appendJavaScript(String.format("Wicket.AjaxDownload.initiate(%s);", settings));
262
263                onBeforeDownload(handler);
264        }
265
266        private String generateSameSiteAttribute() {
267                if (sameSite == null)
268                {
269                        return "";
270                }
271
272                StringBuilder stringBuffer = new StringBuilder(30);
273                if (sameSite.equals(CookieDefaults.SameSite.None))
274                {
275                        stringBuffer.append("; Secure");
276                }
277
278                stringBuffer.append("; SameSite=");
279                stringBuffer.append(sameSite.name());
280
281                return stringBuffer.toString();
282
283        }
284
285        protected void onBeforeDownload(IPartialPageRequestHandler handler)
286        {
287        }
288
289        /**
290         * A callback executed when the download of the resource finished successfully.
291         *
292         * @param target The Ajax request handler
293         */
294        protected void onDownloadSuccess(AjaxRequestTarget target)
295        {
296        }
297
298        /**
299         * A callback executed when the download of the resource failed for some reason,
300         * e.g. an error at the server side.
301         * <p>
302         * Since the HTTP status code of the download is not available to Wicket, any HTML in the resource response
303         * will be interpreted as a failure HTTP status message. Thus is it not possible to download HTML resources
304         * via {@link AjaxDownloadBehavior}.
305         *
306         * @param target The Ajax request handler
307         */
308        protected void onDownloadFailed(AjaxRequestTarget target)
309        {
310        }
311
312        /**
313         * A callback executed when the download of the resource finished successfully or with a failure.
314         *
315         * @param target The Ajax request handler
316         */
317        protected void onDownloadCompleted(AjaxRequestTarget target)
318        {
319        }
320
321        @Override
322        public void renderHead(Component component, IHeaderResponse response)
323        {
324                super.renderHead(component, response);
325
326                response.render(JavaScriptHeaderItem.forReference(JS));
327        }
328
329        @Override
330        protected void respond(AjaxRequestTarget target)
331        {
332                String result = getComponent().getRequest().getRequestParameters().getParameterValue("result").toOptionalString();
333                if ("success".equals(result)) {
334                        onDownloadSuccess(target);
335                } else if ("failed".equals(result)) {
336                        onDownloadFailed(target);
337                }
338                onDownloadCompleted(target);
339        }
340
341        public Location getLocation() {
342                return location;
343        }
344
345        public AjaxDownloadBehavior setLocation(final Location location) {
346                this.location = Args.notNull(location, "location");
347                return this;
348        }
349
350        /**
351         * Identifying name of this behavior.
352         */
353        private String getName()
354        {
355                return String.format("wicket-ajaxdownload-%s-%s", getComponent().getMarkupId(),
356                        getComponent().getBehaviorId(this));
357        }
358
359        /**
360         * The behavior responding with the actual resource.
361         */
362        private class ResourceBehavior extends Behavior implements IRequestListener
363        {
364                private static final long serialVersionUID = 1L;
365                private final IResource resource;
366
367                private ResourceBehavior(IResource resource)
368                {
369                        this.resource = Args.notNull(resource, "resource");
370                }
371
372                @Override
373                public boolean rendersPage()
374                {
375                        return false;
376                }
377
378                @Override
379                public void onRequest()
380                {
381                        final RequestCycle requestCycle = RequestCycle.get();
382                        final Response response = requestCycle.getResponse();
383                        ((WebResponse) response).addCookie(cookie(getName()));
384
385                        Attributes a = new Attributes(requestCycle.getRequest(), response, null);
386
387                        resource.respond(a);
388                }
389
390                public CharSequence getUrl()
391                {
392                        return getComponent().urlForListener(this, null);
393                }
394        }
395
396        /**
397         * Mark a resource as complete.
398         * <p>
399         * Has to be called from {@link IResource#respond(Attributes)} when downloaded via
400         * {@link #AjaxDownloadBehavior(IResource)}.
401         *
402         * @param attributes
403         *            resource attributes
404         */
405        public static void markCompleted(IResource.Attributes attributes)
406        {
407                String cookieName = attributes.getParameters().get(RESOURCE_PARAMETER_NAME).toString();
408
409                ((WebResponse)attributes.getResponse()).addCookie(cookie(cookieName));
410        }
411
412
413        private static Cookie cookie(String name)
414        {
415                Cookie cookie = new Cookie(name, "complete");
416
417                // has to be on root, otherwise JavaScript will not be able to access the
418                // cookie when it is set from a different path - which is the case when a
419                // ResourceReference is used
420                cookie.setPath("/");
421
422                return cookie;
423        }
424
425        /**
426         * @return The {@link org.apache.wicket.util.cookies.CookieDefaults.SameSite} attribute to be used for the complete download.
427         */
428        public CookieDefaults.SameSite getSameSite()
429        {
430                return sameSite;
431        }
432
433        /**
434         * Setter for the same {@link org.apache.wicket.util.cookies.CookieDefaults.SameSite}
435         *
436         * @param sameSite The non-null sameSite attribute
437         */
438        public void setSameSite(CookieDefaults.SameSite sameSite)
439        {
440                Args.notNull(sameSite, "sameSite");
441                this.sameSite = sameSite;
442        }
443}