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