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 * @Override 059 * protected void onSubmit(IPartialPageRequestHandler handler, Form<?> 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}