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.captcha.kittens; 018 019import java.awt.Dimension; 020import java.awt.Graphics2D; 021import java.awt.Point; 022import java.awt.image.BufferedImage; 023import java.awt.image.RescaleOp; 024import java.io.IOException; 025import java.io.Serializable; 026import java.lang.ref.SoftReference; 027import java.time.Instant; 028import java.util.ArrayList; 029import java.util.Collections; 030import java.util.List; 031import java.util.Random; 032import javax.imageio.ImageIO; 033import javax.imageio.stream.MemoryCacheImageInputStream; 034import org.apache.wicket.Component; 035import org.apache.wicket.ajax.AjaxEventBehavior; 036import org.apache.wicket.ajax.AjaxRequestTarget; 037import org.apache.wicket.ajax.attributes.AjaxCallListener; 038import org.apache.wicket.ajax.attributes.AjaxRequestAttributes; 039import org.apache.wicket.ajax.attributes.IAjaxCallListener; 040import org.apache.wicket.markup.head.IHeaderResponse; 041import org.apache.wicket.markup.head.JavaScriptHeaderItem; 042import org.apache.wicket.markup.head.OnDomReadyHeaderItem; 043import org.apache.wicket.markup.head.OnEventHeaderItem; 044import org.apache.wicket.markup.html.basic.Label; 045import org.apache.wicket.markup.html.image.Image; 046import org.apache.wicket.markup.html.image.NonCachingImage; 047import org.apache.wicket.markup.html.panel.Panel; 048import org.apache.wicket.model.IModel; 049import org.apache.wicket.request.IRequestParameters; 050import org.apache.wicket.request.Request; 051import org.apache.wicket.request.cycle.RequestCycle; 052import org.apache.wicket.request.http.WebResponse; 053import org.apache.wicket.request.mapper.parameter.PageParameters; 054import org.apache.wicket.request.resource.DynamicImageResource; 055import org.apache.wicket.request.resource.JavaScriptResourceReference; 056import org.slf4j.Logger; 057import org.slf4j.LoggerFactory; 058 059/** 060 * A unique and fun-to-use captcha technique I developed at Thoof. 061 * 062 * @author Jonathan Locke 063 */ 064public class KittenCaptchaPanel extends Panel 065{ 066 private static final long serialVersionUID = 2711167040323855070L; 067 068 private static final Logger LOG = LoggerFactory.getLogger(KittenCaptchaPanel.class); 069 070 // The background grass area 071 private static BufferedImage grass = load("images/grass.png"); 072 073 // The kittens and other animals 074 private static final List<Animal> kittens = new ArrayList<>(); 075 private static final List<Animal> nonKittens = new ArrayList<>(); 076 077 // Random number generator 078 private static Random random = new Random(-1); 079 080 // Load animals 081 static 082 { 083 kittens.add(new Animal("kitten_01", true)); 084 kittens.add(new Animal("kitten_02", true)); 085 kittens.add(new Animal("kitten_03", true)); 086 kittens.add(new Animal("kitten_04", true)); 087 nonKittens.add(new Animal("chick", false)); 088 nonKittens.add(new Animal("guinea_pig", false)); 089 nonKittens.add(new Animal("hamster", false)); 090 nonKittens.add(new Animal("puppy", false)); 091 nonKittens.add(new Animal("rabbit", false)); 092 } 093 094 /** 095 * @param filename 096 * The name of the file to load 097 * @return The image read form the file 098 */ 099 private static BufferedImage load(final String filename) 100 { 101 try 102 { 103 return ImageIO.read(new MemoryCacheImageInputStream( 104 KittenCaptchaPanel.class.getResourceAsStream(filename))); 105 } 106 catch (IOException e) 107 { 108 LOG.error("Error loading image", e); 109 return null; 110 } 111 } 112 113 /** 114 * The various animals as placed animals 115 */ 116 private final PlacedAnimalList animals; 117 118 /** 119 * Label that shows request status 120 */ 121 private final Label animalSelectionLabel; 122 123 /** 124 * The image component 125 */ 126 private final Image image; 127 128 /** 129 * The image resource referenced by the Image component 130 */ 131 private final CaptchaImageResource imageResource; 132 133 /** 134 * Size of this kitten panel's image 135 */ 136 private final Dimension imageSize; 137 138 /** 139 * @param id 140 * Component id 141 * @param imageSize 142 * Size of kitten captcha image 143 */ 144 public KittenCaptchaPanel(final String id, final Dimension imageSize) 145 { 146 super(id); 147 148 // Save image size 149 this.imageSize = imageSize; 150 151 // Create animal list 152 animals = new PlacedAnimalList(); 153 154 // Need to ajax refresh 155 setOutputMarkupId(true); 156 157 // Show how many animals have been selected 158 animalSelectionLabel = new Label("animalSelectionLabel", new IModel<String>() 159 { 160 @Override 161 public String getObject() 162 { 163 return imageResource.selectString(); 164 } 165 }); 166 animalSelectionLabel.setOutputMarkupId(true); 167 add(animalSelectionLabel); 168 169 // Image referencing captcha image resource 170 image = new NonCachingImage("image", imageResource = new CaptchaImageResource(animals)); 171 image.add(new AjaxEventBehavior("click") 172 { 173 private static final long serialVersionUID = 7480352029955897654L; 174 175 @Override 176 protected void updateAjaxAttributes(AjaxRequestAttributes attributes) 177 { 178 super.updateAjaxAttributes(attributes); 179 IAjaxCallListener ajaxCallListener = new AjaxCallListener() { 180 @Override 181 public CharSequence getBeforeSendHandler(Component component) 182 { 183 return "showLoadingIndicator();"; 184 } 185 }; 186 attributes.getAjaxCallListeners().add(ajaxCallListener); 187 List<CharSequence> dynamicExtraParameters = attributes.getDynamicExtraParameters(); 188 dynamicExtraParameters.add("return { x: getEventX(Wicket.$(attrs.c), attrs.event), y: getEventY(Wicket.$(attrs.c), attrs.event)}"); 189 } 190 191 @Override 192 protected void onEvent(final AjaxRequestTarget target) 193 { 194 // Get clicked cursor position 195 final Request request = RequestCycle.get().getRequest(); 196 IRequestParameters requestParameters = request.getRequestParameters(); 197 final int x = requestParameters.getParameterValue("x").toInt(0); 198 final int y = requestParameters.getParameterValue("y").toInt(0); 199 200 // Force refresh 201 imageResource.clearData(); 202 203 // Find any animal at the clicked location 204 final PlacedAnimal animal = animals.atLocation(new Point(x, y)); 205 206 // If the user clicked on an animal 207 if (animal != null) 208 { 209 // Toggle the animal's highlighting 210 animal.isHighlighted = !animal.isHighlighted; 211 212 // Instead of reload entire image just change the src 213 // attribute, this reduces the flicker 214 final StringBuilder javascript = new StringBuilder(); 215 javascript.append("Wicket.$('") 216 .append(image.getMarkupId()) 217 .append("').src = '"); 218 CharSequence url = image.urlForListener(new PageParameters()); 219 javascript.append(url); 220 javascript.append(url.toString().indexOf('?') > -1 ? "&" : "?") 221 .append("rand=") 222 .append(Math.random()); 223 javascript.append("'"); 224 target.appendJavaScript(javascript.toString()); 225 } 226 else 227 { 228 // The user didn't click on an animal, so hide the loading 229 // indicator 230 target.appendJavaScript(" hideLoadingIndicator();"); 231 } 232 233 // Update the selection label 234 target.add(animalSelectionLabel); 235 } 236 }); 237 add(image); 238 } 239 240 /** 241 * @return True if all (three) kittens have been selected 242 */ 243 public boolean allKittensSelected() 244 { 245 return imageResource.allKittensSelected(); 246 } 247 248 /** 249 * Resets for another go-around 250 */ 251 public void reset() 252 { 253 imageResource.reset(); 254 } 255 256 /** 257 * @param animals 258 * List of animals 259 * @param newAnimal 260 * New animal to place 261 * @return The placed animal 262 */ 263 private PlacedAnimal placeAnimal(final List<PlacedAnimal> animals, final Animal newAnimal) 264 { 265 // Try 100 times 266 for (int iter = 0; iter < 100; iter++) 267 { 268 // Get the new animal's width and height 269 final int width = newAnimal.image.getWidth(); 270 final int height = newAnimal.image.getHeight(); 271 272 // Pick a random position 273 final int x = random(imageSize.width - width); 274 final int y = random(imageSize.height - height); 275 final Point point = new Point(x, y); 276 277 // Determine if there is too much overlap with other animals 278 final double tooClose = new Point(width, height).distance(new Point(0, 0)) / 2.0; 279 boolean tooMuchOverlap = false; 280 for (final PlacedAnimal animal : animals) 281 { 282 if (point.distance(animal.location) < tooClose) 283 { 284 tooMuchOverlap = true; 285 break; 286 } 287 } 288 289 // If there was not too much overlap 290 if (!tooMuchOverlap) 291 { 292 // The animal is now placed at x, y 293 return new PlacedAnimal(newAnimal, new Point(x, y)); 294 } 295 } 296 297 // Could not place animal 298 return null; 299 } 300 301 @Override 302 public void renderHead(IHeaderResponse response) 303 { 304 super.renderHead(response); 305 response.render(JavaScriptHeaderItem.forReference( 306 new JavaScriptResourceReference(KittenCaptchaPanel.class, "kittencaptcha.js"))); 307 response.render(OnEventHeaderItem.forComponent(image, "load", "hideLoadingIndicator()")); 308 response.render(OnDomReadyHeaderItem.forScript("if (document.getElementById('" 309 + image.getMarkupId() + "').complete) hideLoadingIndicator();")); 310 } 311 312 /** 313 * @param max 314 * Maximum size of random value 315 * @return A random number between 0 and max - 1 316 */ 317 private int random(final int max) 318 { 319 return Math.abs(random.nextInt(max)); 320 } 321 322 /** 323 * @return A random kitten 324 */ 325 private Animal randomKitten() 326 { 327 return kittens.get(random(kittens.size())); 328 } 329 330 /** 331 * @return A random other animal 332 */ 333 private Animal randomNonKitten() 334 { 335 return nonKittens.get(random(nonKittens.size())); 336 } 337 338 /** 339 * Animal, whether kitten or non-kitten 340 */ 341 private static class Animal 342 { 343 /** 344 * The highlighted image 345 */ 346 private final BufferedImage highlightedImage; 347 348 /** 349 * The normal image 350 */ 351 private final BufferedImage image; 352 353 /** 354 * True if the animal is a kitten 355 */ 356 private final boolean isKitten; 357 358 /** 359 * The visible region of the animal 360 */ 361 private final OpaqueRegion visibleRegion; 362 363 /** 364 * @param filename 365 * The filename 366 * @param isKitten 367 * True if the animal is a kitten 368 */ 369 private Animal(final String filename, final boolean isKitten) 370 { 371 this.isKitten = isKitten; 372 image = load("images/" + filename); 373 highlightedImage = load("images/" + filename + "_highlight"); 374 visibleRegion = new OpaqueRegion(image); 375 } 376 377 /** 378 * @param filename 379 * The file to load 380 * @return The image in the file 381 */ 382 private BufferedImage load(final String filename) 383 { 384 try 385 { 386 final BufferedImage loadedImage = ImageIO.read(new MemoryCacheImageInputStream( 387 KittenCaptchaPanel.class.getResourceAsStream(filename + ".png"))); 388 final BufferedImage image = new BufferedImage(loadedImage.getWidth(), 389 loadedImage.getHeight(), BufferedImage.TYPE_INT_ARGB); 390 final Graphics2D graphics = image.createGraphics(); 391 graphics.drawImage(loadedImage, 0, 0, null); 392 graphics.dispose(); 393 return image; 394 } 395 catch (IOException e) 396 { 397 LOG.error("Error loading image", e); 398 return null; 399 } 400 } 401 } 402 403 /** 404 * Resource which renders the actual captcha image 405 */ 406 private static class CaptchaImageResource extends DynamicImageResource 407 { 408 private static final long serialVersionUID = -1560784998742404278L; 409 410 /** 411 * The placed animals 412 */ 413 private final PlacedAnimalList animals; 414 415 /** 416 * Image data array 417 */ 418 private transient SoftReference<byte[]> data = null; 419 420 @Override 421 protected void configureResponse(final ResourceResponse response, 422 final Attributes attributes) 423 { 424 super.configureResponse(response, attributes); 425 response.disableCaching(); 426 } 427 428 /** 429 * @param animals 430 * The positioned animals 431 */ 432 private CaptchaImageResource(final PlacedAnimalList animals) 433 { 434 this.animals = animals; 435 setFormat("jpg"); 436 } 437 438 /** 439 * @return Rendered image data 440 */ 441 @Override 442 protected byte[] getImageData(final Attributes attributes) 443 { 444 // Handle caching 445 setLastModifiedTime(Instant.now()); 446 final WebResponse response = (WebResponse)RequestCycle.get().getResponse(); 447 response.setHeader("Cache-Control", "no-cache, must-revalidate, max-age=0, no-store"); 448 449 // If we don't have data 450 if ((data == null) || (data.get() == null)) 451 { 452 // Create the image and turn it into data 453 final BufferedImage composedImage = animals.createImage(); 454 data = new SoftReference<>(toImageData(composedImage)); 455 } 456 457 // Return image data 458 return data.get(); 459 } 460 461 /** 462 * Invalidates the image data 463 */ 464 protected void invalidate() 465 { 466 data = null; 467 } 468 469 /** 470 * @return True if all kittens have been selected 471 */ 472 private boolean allKittensSelected() 473 { 474 return animals.allKittensSelected(); 475 } 476 477 /** 478 * Clears out image data 479 */ 480 private void clearData() 481 { 482 invalidate(); 483 setLastModifiedTime(Instant.now()); 484 } 485 486 /** 487 * Resets animals to default states 488 */ 489 private void reset() 490 { 491 animals.reset(); 492 } 493 494 /** 495 * @return Selection state string for animals 496 */ 497 private String selectString() 498 { 499 return animals.selectString(); 500 } 501 } 502 503 /** 504 * An animal that has a location 505 */ 506 private static class PlacedAnimal implements Serializable 507 { 508 private static final long serialVersionUID = -6703909440564862486L; 509 510 /** 511 * The animal 512 */ 513 private transient Animal animal; 514 515 /** 516 * Index in kitten or nonKitten list 517 */ 518 private final int index; 519 520 /** 521 * True if the animal is highlighted 522 */ 523 private boolean isHighlighted; 524 525 /** 526 * True if this animal is a kitten 527 */ 528 private final boolean isKitten; 529 530 /** 531 * The location of the animal 532 */ 533 private final Point location; 534 535 /** 536 * Scaling values 537 */ 538 private final float[] scales = { 1f, 1f, 1f, 1f }; 539 540 /** 541 * @param animal 542 * The animal 543 * @param location 544 * Where to put it 545 */ 546 public PlacedAnimal(final Animal animal, final Point location) 547 { 548 this.animal = animal; 549 this.location = location; 550 isKitten = animal.isKitten; 551 if (isKitten) 552 { 553 index = kittens.indexOf(animal); 554 } 555 else 556 { 557 index = nonKittens.indexOf(animal); 558 } 559 for (int i = 0; i < 3; i++) 560 { 561 scales[i] = random(0.9f, 1.0f); 562 } 563 scales[3] = random(0.7f, 1.0f); 564 } 565 566 /** 567 * {@inheritDoc} 568 */ 569 @Override 570 public String toString() 571 { 572 return (isKitten ? "kitten at " : "other at ") + location.x + ", " + location.y; 573 } 574 575 /** 576 * @param point 577 * The point 578 * @return True if this placed animal contains the given point 579 */ 580 private boolean contains(final Point point) 581 { 582 final Point relativePoint = new Point(point.x - location.x, point.y - location.y); 583 return getAnimal().visibleRegion.contains(relativePoint); 584 } 585 586 /** 587 * @param graphics 588 * The graphics to draw on 589 */ 590 private void draw(final Graphics2D graphics) 591 { 592 final float[] offsets = new float[4]; 593 final RescaleOp rop = new RescaleOp(scales, offsets, null); 594 if (isHighlighted) 595 { 596 graphics.drawImage(getAnimal().highlightedImage, rop, location.x, location.y); 597 } 598 else 599 { 600 graphics.drawImage(getAnimal().image, rop, location.x, location.y); 601 } 602 } 603 604 /** 605 * @return The animal that is placed 606 */ 607 private Animal getAnimal() 608 { 609 if (animal == null) 610 { 611 if (isKitten) 612 { 613 animal = kittens.get(index); 614 } 615 else 616 { 617 animal = nonKittens.get(index); 618 } 619 } 620 return animal; 621 } 622 623 /** 624 * @param min 625 * Minimum random value 626 * @param max 627 * Maximum random value 628 * @return A random value in the given range 629 */ 630 private float random(final float min, final float max) 631 { 632 return min + Math.abs(random.nextFloat() * (max - min)); 633 } 634 } 635 636 /** 637 * Holds a list of placed animals 638 */ 639 private class PlacedAnimalList implements Serializable 640 { 641 private static final long serialVersionUID = 6335852594326213439L; 642 643 /** 644 * List of placed animals 645 */ 646 private final List<PlacedAnimal> animals = new ArrayList<>(); 647 648 /** 649 * Arrange random animals and kittens 650 */ 651 private PlacedAnimalList() 652 { 653 // Place the three kittens 654 animals.add(placeAnimal(animals, randomKitten())); 655 animals.add(placeAnimal(animals, randomKitten())); 656 animals.add(placeAnimal(animals, randomKitten())); 657 658 // Try a few times 659 for (int iter = 0; iter < 500; iter++) 660 { 661 // Place a non kitten 662 final PlacedAnimal animal = placeAnimal(animals, randomNonKitten()); 663 664 // If we were able to place the animal 665 if (animal != null) 666 { 667 // add it to the list 668 animals.add(animal); 669 } 670 671 // 15 non-kittens is enough 672 if (animals.size() > 15) 673 { 674 break; 675 } 676 } 677 678 // Shuffle the animal order 679 Collections.shuffle(animals); 680 681 // Ensure kittens are visible enough 682 List<PlacedAnimal> strayKittens = new ArrayList<>(); 683 for (final PlacedAnimal animal : animals) 684 { 685 // If it's a kitten 686 if (animal.isKitten) 687 { 688 // Compute the area of the visible region in pixels 689 final int kittenArea = animal.getAnimal().visibleRegion.areaInPixels(); 690 691 // If at least 4/5ths of the given kitten is not visible 692 // (because it is obscured by other animal(s)) 693 if (visibleRegion(animal).areaInPixels() < kittenArea * 4 / 5) 694 { 695 // The user probably can't identify it, so add to the 696 // stray kittens list 697 strayKittens.add(animal); 698 } 699 } 700 } 701 702 // Remove any the stray kittens and then re-add them so they move to 703 // the top of the z-order 704 animals.removeAll(strayKittens); 705 animals.addAll(strayKittens); 706 } 707 708 /** 709 * @return True if all kittens are selected 710 */ 711 private boolean allKittensSelected() 712 { 713 for (final PlacedAnimal animal : animals) 714 { 715 if (animal.isKitten != animal.isHighlighted) 716 { 717 return false; 718 } 719 } 720 return true; 721 } 722 723 /** 724 * @param location 725 * The cursor location that was clicked 726 * @return Any animal that might be at the given location or null if none found (the user 727 * clicked on grass) 728 */ 729 private PlacedAnimal atLocation(final Point location) 730 { 731 // Reverse list for z-ordered hit-testing 732 final List<PlacedAnimal> reversedAnimals = new ArrayList<>(animals); 733 Collections.reverse(reversedAnimals); 734 735 // Return any animal at the given location 736 for (final PlacedAnimal animal : reversedAnimals) 737 { 738 if (animal.contains(location)) 739 { 740 return animal; 741 } 742 } 743 744 // No animal found 745 return null; 746 } 747 748 /** 749 * @return The kitten captcha image 750 */ 751 private BufferedImage createImage() 752 { 753 // Create image of the right size 754 final BufferedImage newImage = new BufferedImage(imageSize.width, imageSize.height, 755 BufferedImage.TYPE_INT_RGB); 756 757 // Draw the grass 758 final Graphics2D graphics = newImage.createGraphics(); 759 graphics.drawImage(grass, 0, 0, null); 760 761 // Draw each animal in order 762 for (final PlacedAnimal animal : animals) 763 { 764 animal.draw(graphics); 765 } 766 767 // Clean up graphics resource 768 graphics.dispose(); 769 770 // Return the rendered animals 771 return newImage; 772 } 773 774 /** 775 * Undo highlight states of animals 776 */ 777 private void reset() 778 { 779 for (final PlacedAnimal animal : animals) 780 { 781 animal.isHighlighted = false; 782 } 783 } 784 785 /** 786 * @return Selection string to show 787 */ 788 private String selectString() 789 { 790 int selected = 0; 791 for (final PlacedAnimal animal : animals) 792 { 793 if (animal.isHighlighted) 794 { 795 selected++; 796 } 797 } 798 if (selected == 0) 799 { 800 return getString("instructions"); 801 } 802 else 803 { 804 return selected + " " + getString("animalsSelected"); 805 } 806 } 807 808 /** 809 * @param animal 810 * The animal 811 * @return The visible region of the animal 812 */ 813 private OpaqueRegion visibleRegion(final PlacedAnimal animal) 814 { 815 // The index of the animal in the animal list 816 int index = animals.indexOf(animal); 817 818 // Check sanity 819 if (index == -1) 820 { 821 // Invalid animal somehow 822 throw new IllegalArgumentException("animal not in list"); 823 } 824 else 825 { 826 // Get the animal's visible region 827 OpaqueRegion visible = animal.getAnimal().visibleRegion; 828 829 // Go through the animals above the given animal 830 for (index++; index < animals.size(); index++) 831 { 832 833 // Remove the higher animal's visible region 834 final PlacedAnimal remove = animals.get(index); 835 visible = visible.subtract(remove.getAnimal().visibleRegion, new Point( 836 remove.location.x - animal.location.x, remove.location.y - 837 animal.location.y)); 838 } 839 return visible; 840 } 841 } 842 } 843}