<

Prevent Hot-Linking by Adding a Watermark to Your Images "On-the-Fly"

Published on
21,663 Points
4,663 Views
10 Endorsements
Last Modified:
Awarded
Protecting Your Images from Hotlinking
A recent question here at EE asked, "How can I watermark an image if it is 'hotlinked' from my server by another web site?"  This article shows how to store your images outside of the web root and serve the images via a PHP script.  The PHP script can use the session to detect whether the image has been requested by your web site or by another web site.  If the image has been requested by another web site, it can watermark the image.  Since the image is located outside of your web root, it has no URL and cannot be readily accessed over the internet.  The only method of access is through your PHP script.

Setting Up Our Test Scripts
Before we begin to write the anti-hotlinking script we want to set up a test environment so we can see if our script will successfully watermark the hotlinked images.  There need to be two tests - one from a foreign server and one from an authorized server.  Here is the test script when the image is pulled from the "foreign" server.  Note that the foreign server does not know the secret handshake!
 
<?php // FOREIGN/watermark_test_hotlink.php
error_reporting(E_ALL);

// ALWAYS START THE SESSION ON EVERY PAGE
session_start();

// THE FOREIGN SCRIPT DOES NOT KNOW THAT IT MUST DO THIS
// $_SESSION["noWaterMark"]   = array();
// $_SESSION["noWaterMark"][] = "r1.jpg";
// $_SESSION["noWaterMark"][] = "r2.jpg";

echo '<image src="http://www.iconoun.com/demo/watermark_hotlinked_image.php?q=r1.jpg" />';
echo '<image src="http://www.iconoun.com/demo/watermark_hotlinked_image.php?q=r2.jpg" />';

Open in new window

And here is the script on the authorized server, where the secret handshake ($_SESSION["image_link"]) is filled in correctly.
 
<?php // demo/watermark_test_hotlink.php
error_reporting(E_ALL);

// ALWAYS START THE SESSION ON EVERY PAGE
session_start();

// SET THE NAMES OF ALL OF THE IMAGES IN THIS FIELD OF THE SESSION
$_SESSION["noWaterMark"]   = array();
$_SESSION["noWaterMark"][] = "r1.jpg";
$_SESSION["noWaterMark"][] = "r2.jpg";

// THESE TWO IMAGES WILL NOT BE WATERMARKED
echo '<image src="http://www.iconoun.com/demo/watermark_hotlinked_image.php?q=r1.jpg" />';
echo '<image src="http://www.iconoun.com/demo/watermark_hotlinked_image.php?q=r2.jpg" />';

// THIS IMAGE IS NOT LISTED IN THE SESSION - IT WILL BE WATERMARKED
echo '<image src="http://www.iconoun.com/demo/watermark_hotlinked_image.php?q=r3.jpg" />';

// THIS IMAGE DOES NOT EXIST
echo '<image src="http://www.iconoun.com/demo/watermark_hotlinked_image.php?q=bogus.jpg" />';

Open in new window

Testing When the Foreign and Authorized Scripts are on the Same Shared Server
A word about testing on a shared server... You may find that you need to close all instances of the browser and open a new instance for each separate test of this process.  In practice this will not be much of an issue because it is unlikely that a foreign site hotlinking to your images will have access to session data that loads the secret handshake.  But if you're testing these links on the same machine with the same browser you may find that there is session-cross-pollination (for want of a better term).  All instances of the browser (windows or tabs) use the same cookie jar.  If the same cookie is returned to two different web sites on the same shared server, the session data may be found by both of the sites.  This can play havoc with your testing logic, but you can't prevent it from happening so just be aware of the risk.

The Design Strategy
The scripts that want to present our images need a way to communicate to the image rendering script that they are authorized to present the images.  We will do that via the PHP session.  If the session contains the authorization, the images will be presented without watermarks.  Of course we could simply refuse to present the images at all, but presenting the images with the copyright in the form of a watermark makes a clear statement about the copyright.

While writing and debugging this script, we will not have good access to error messages, so the first thing we have to do is ensure that we can see our errors.  To do that we set the highest level of error reporting and tell PHP to log errors.  We do this right at the top of the script.

If a Watermark is Not Needed
Next we complete some housekeeping activities.  We start the session, and we initialize the directory location of our image files.

Lines 15 and 16 use the "ternary operator" to assign the image URL to a variable.  If the image is not specified in the GET array, we just let the script die without any output.

Lines 19-22 get the file extension from the image URL.  We use the file extension to set the correct string for the image headers.  Our script is designed to work correctly with the two most popular web image formats, JPG and PNG.

On line 25 we again use the ternary operator to get an array.  It will be the array of permitted images, or an empty array, depending on the contents of $_SESSION.  If our image URL is in this array we can produce an unwatermarked image.  As soon as we know that is the case, we remove the image name from the session, and commit the session (lines 28-40).  In a live  environment this might not be necessary, but in my testing with a shared server, it seemed to help make the test results more predictable.

Finally, we send the headers that we developed on lines 21-22 and send the image file to the browser output stream.  For the authorized image renderings, our work is done at line 44 and the script dies at line 45.

When a Watermark is Needed
If the image URL (the basename() of the image) is not in the $_SESSION["noWaterMark"] array, our script has some work to do.  We must create a watermark and write it over the top of the image.  Some personalization is appropriate here; the exact text of the watermark is something you must choose.  Please see lines 61-63 where I put my copyright.  Substitute your own text here.  

The actual task of creating the watermark begins on lines 48-52 where we attempt to create an appropriate image resource from either the JPG or PNG image.  If the image resource cannot be created, the script dies.

From some experimentation, I found that 1/50 of the image width in pixels creates a good "point" size for the True-Type text.  This is not a rigid rule - it is just a "happy values" estimation.  We set the point size, the text, the font and the angle (lines 54-69) so that we can use the ImageTTFBbox() function to determine how big our watermark needs to be.  ImageTTFBbox() returns the coordinates of a bounding box for our text, but it does not include the spaces needed for the "descenders" like the tail that hangs below the line for the lower-case letter "p".  Some adjustment is needed (lines 80-82) and this adjustment seems to work well if you add the point size to the horizontal and vertical dimensions of the bounding box.  The resulting dimensions give us a good width and height for the watermark text area.

Armed with the correct dimensions we can create an image resource (line 85).  And once we have the image resource, we can assign some colors (lines 87-92).  We will use these colors to write text and shadows on the image resource, but first we must fill the image with a background color (line 94).

Again, through experimentation we found that some padding values were in order, to help align the text within the watermark image resource.  The values at lines 97 and 98 worked well.

We could have just put the text right onto the watermark image resource, but when that was tried, it looked jagged and clunky, so we decided to use a "drop-shadow" look for the watermark.  This provides much more eye-pleasing text.  Our strategy is to provide three levels of shadow (one pixel each) at the edge of the watermark text.  The code at lines 100-138 accomplishes this.  Finally we write the watermark text over the top of the shadows (lines 140-150) and our watermark image resource is complete.

The last step before rendering the image is to copy the watermark onto the original image.  We use the imageCopy() function to do this (lines 153-163).  The inline computations on lines 156 and 157 cause the watermark to be placed right in the middle of the image.

Our final step is to send the image to the browser.  We send a PNG header and render a PNG image.  It does not matter what kind of image we started with.  Our image resource could have started life as either PNG or JPG.  The PHP GD image processing functions do not care about the original format.  Once an image file is converted to an internal image resource these functions work the same for all image resources, no matter what the origin.

Here is the script that renders the images.
 
<?php // demo/watermark_hotlinked_image.php
error_reporting(E_ALL);
ini_set('log_errors', TRUE);

// WRITES A WATERMARK OVER THE IMAGE IF
// THE FILE IS HOTLINKED BY A FOREIGN SITE

// THERE WILL BE A SIGNAL IN THE SESSION IF IT OK TO PRODUCE UN-WATERMARKED IMAGES
session_start();

// HARDWIRED: THE IMAGE DIRECTORY (OUTSIDE OF THE WEB ROOT)
$image_dir = '../root_images' . DIRECTORY_SEPARATOR;

// GET THE IMAGE NAME
$image_url = (isset($_GET['q'])) ? $_GET['q'] : NULL;
if (!$image_url) die();

// GET THE IMAGE NAME, EXTENSION AND HEADER TYPE FOR JPEG OR PNG
$image_ext = end(explode('.', $image_url));
$image_ext = strtolower($image_ext);
$image_hed = 'jpeg';
if ($image_ext == 'png') $image_hed = 'png';

// TEST IF IT IS OK TO PRODUCE UN-WATERMARKED IMAGES
$image_set = isset($_SESSION["noWaterMark"]) ? $_SESSION["noWaterMark"] : array();
if (in_array($image_url, $image_set))
{
    // REMOVE THIS IMAGE FROM THE LIST
    foreach ($image_set as $key => $image_nam)
    {
        if ($image_url == $image_nam)
        {
            unset($image_set[$key]);
            break;
        }
    }

    // REPLACE THE LIST IN THE SESSION ARRAY AND COMMIT THE SESSION
    $_SESSION["noWaterMark"] = $image_set;
    session_write_close();

    // SEND THE UNWATERMARKED IMAGE - EITHER JPG OR PNG
    header("Content-Type: image/$image_hed");
    echo file_get_contents($image_dir . $image_url);
    exit;
}

// GET THE IMAGE RESOURCE
$image_res = FALSE;
if ($image_hed == 'png')  $image_res = ImageCreateFromPNG($image_dir . $image_url);
if ($image_hed == 'jpeg') $image_res = ImageCreateFromJPEG($image_dir . $image_url);
if (!$image_res) die();

// COMPUTE THE WATERMARK TEXT SIZE IN POINTS
$image_w   = ImagesX($image_res);
$image_h   = ImagesY($image_res);
$image_pct = $image_w / 100.0;

$image_size = round($image_pct * 2);

// SET THE WATERMARK TEXT
date_default_timezone_set('America/Chicago');
$image_wmk = '© ' . date('Y') . ' Ray Paseur';

// LOCATE THE FONT (VERDANA Z = BOLD ITALIC)
$font = 'fonts/verdanaz.ttf';

// PREPARE HORIZONTAL TEXT WITH ZERO ANGLE
$angle = 0;

// GET THE BOUNDING BOX SIZE FOR THE TEXT (DOES NOT INCLUDE DESCENDERS)
$poz = imageTTFBBox
( $image_size            // SIZE IN POINTS
, $angle                 // ONLY WORKS RIGHT IF ANGLE = 0
, $font                  // PATH TO TTF FILE
, $image_wmk             // TEXT
)
;

// GET THE WIDTH AND HEIGHT OF THE WATERMARK IMAGE WITH ROOM FOR DESCENDERS
$wmi_w = $poz[2] - $poz[0] + $image_size;
$wmi_h = $poz[1] - $poz[7] + $image_size;

// CREATE A TRUE COLOR WATERMARK IMAGE
$image_wmi = imageCreateTrueColor($wmi_w, $wmi_h);

// MAKE THE BACKGROUND, TEXT AND SHADOW COLORS
$wmi_bgc = imageColorAllocateAlpha($image_wmi,   0,    0,   0,  64);
$wmi_txt = imageColorAllocateAlpha($image_wmi, 224,  224, 224,  64);
$wmi_sh1 = imageColorAllocateAlpha($image_wmi, 128,  128, 128,  80);
$wmi_sh2 = imageColorAllocateAlpha($image_wmi,  96,   96,  96,  96);
$wmi_sh3 = imageColorAllocateAlpha($image_wmi,  80,   80,  80, 112);

imageFill($image_wmi, 0, 0, $wmi_bgc);

// SET SOME PADDING VALUES
$off_x = 6;
$off_y = (int)( $image_size / 2 ) + ( $off_x / 2);

// SHADOW THE TEXT ON THE WATERMARK IMAGE
imageTTFText
( $image_wmi             // IMAGE RESOURCE
, $image_size            // SIZE
, $angle                 // ANGLE
, $off_x + 3             // HORIZONTAL LEFTMOST BASEPOINT
, $wmi_h - $off_y + 3    // VERTICAL BASEPOINT
, $wmi_sh3               // COLOR INDEX
, $font                  // PATH TO TTF FILE
, $image_wmk             // TEXT
)
;

// SHADOW THE TEXT ON THE WATERMARK IMAGE
imageTTFText
( $image_wmi             // IMAGE RESOURCE
, $image_size            // SIZE
, $angle                 // ANGLE
, $off_x + 2             // HORIZONTAL LEFTMOST BASEPOINT
, $wmi_h - $off_y + 2    // VERTICAL BASEPOINT
, $wmi_sh2               // COLOR INDEX
, $font                  // PATH TO TTF FILE
, $image_wmk             // TEXT
)
;

// SHADOW THE TEXT ON THE WATERMARK IMAGE
imageTTFText
( $image_wmi             // IMAGE RESOURCE
, $image_size            // SIZE
, $angle                 // ANGLE
, $off_x + 1             // HORIZONTAL LEFTMOST BASEPOINT
, $wmi_h - $off_y + 1    // VERTICAL BASEPOINT
, $wmi_sh1               // COLOR INDEX
, $font                  // PATH TO TTF FILE
, $image_wmk             // TEXT
)
;

// WRITE THE TEXT ON THE WATERMARK IMAGE
imageTTFText
( $image_wmi             // IMAGE RESOURCE
, $image_size            // SIZE
, $angle                 // ANGLE
, $off_x + 0             // HORIZONTAL LEFTMOST BASEPOINT
, $wmi_h - $off_y + 0    // VERTICAL BASEPOINT
, $wmi_txt               // COLOR INDEX
, $font                  // PATH TO TTF FILE
, $image_wmk             // TEXT
)
;

// PUT THE WATERMARK IN THE MIDDLE OF THE IMAGE
imageCopy
( $image_res                     // DESTINATION
, $image_wmi                     // SOURCE (WATERMARK)
, ($image_w / 2) - ($wmi_w / 2)  // DESTINATION X-AXIS IN PIXELS
, ($image_h / 2) - ($wmi_h / 2)  // DESTINATION Y-AXIS IN PIXELS
, 0                              // SOURCE X-AXIS IN PIXELS
, 0                              // SOURCE Y-AXIS IN PIXELS
, $wmi_w                         // SOURCE WIDTH
, $wmi_h                         // SOURCE HEIGHT
)
;

// RENDER THE IMAGES AND RELEASE THE MEMORY
header('Content-Type: image/png');
imagePNG($image_res);

imageDestroy($image_wmi);
imageDestroy($image_res);

Open in new window

You can copy this code, install it on your server (changing the $image_dir variable as needed) and test it out.

Sorry, But This Does Not Prevent Image Theft
Now here is the bad news.  Let's say that you put this in place, and a bad actor visits your web site, scrapes the HTML and tries to use your image.  Zap - you've put a watermark into his site.  He does not have a clue about what the secret might be that tells the rendering script to omit the watermark.  But he knows he cannot hotlink without looking foolish.  So he goes back to your site, finds the image he want to steal, right-clicks and uses "save as" to make his own copy.  I am sure you have found JavaScript that will disable right-clicks.  No problem - he can just bypass the JavaScript.  Or he can use a screen capture and get the image that way.  

Some Suggestions About How to Find Stolen Photos
http://chrismartino.com/blog/2012/01/how-to-find-stolen-photos/

An Official PHP Example
http://php.net/manual/en/image.examples.merged-watermark.php

Executive Summary
This article shows how to deter hotlinking, and that will help keep the foreign web sites from subjecting your server to the traffic load that comes from hotlinking.  But it will not protect you from the theft of your images.  If you publish an image on the WWW you have released it into the wild.  So mark your EXIF data with your copyright, register your copyrights with the government, have a good lawyer on retainer, use Tineye and Google Image Search frequently.

More information about putting your images online is available here:
http://www.experts-exchange.com/articles/5931/Putting-Your-Photos-Online.html

Please give us your feedback!
If you found this article helpful, please click the "thumb's up" button below. Doing so lets the E-E community know what is valuable for E-E members and helps provide direction for future articles.  If you have questions or comments, please add them.  Thanks!
 
10
Comment
Author:Ray Paseur
3 Comments
 
LVL 38

Expert Comment

by:younghv
Ray - another example of some great work. Thank you for publishing this.
"Yes" vote above.
Vic
0
 
LVL 83

Expert Comment

by:leakim971
Marvelous as usual!
0
 
LVL 111

Author Comment

by:Ray Paseur
@leakim971: Thank you.  The compliment means even more considering that you are the source!  Best always, ~Ray
0

Featured Post

Industry Leaders: We Want Your Opinion!

We value your feedback.

Take our survey and automatically be enter to win anyone of the following:
Yeti Cooler, Amazon eGift Card, and Movie eGift Card!

Join & Write a Comment

The viewer will receive an overview of the basics of CSS showing inline styles. In the head tags set up your style tags: (CODE) Reference the nav tag and set your properties.: (CODE) Set the reference for the UL element and styles for it to ensu…
This video shows how to quickly and easily deploy an email signature for all users in Office 365 and prevent it from being added to replies and forwards. (the resulting signature is applied on the server level in Exchange Online) The email signat…
Suggested Courses
Course of the Month15 days, 21 hours left to enroll

Keep in touch with Experts Exchange

Tech news and trends delivered to your inbox every month