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 ? "&amp;" : "?")
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}