<?php
/**
 * Class to shorten Urls
 * @package url_shortener
 * @author Julius Beckmann
 * @link http://juliusbeckmann.de/classes/url_shortener/
 * @license http://opensource.org/licenses/gpl-license.php GNU Public License
 * @filesource
 */
/* 
 *             class.url_shortener.php
 *      
 *      Copyright 2009 Julius Beckmann
 *      
 *      This program is free software; you can redistribute it and/or modify
 *      it under the terms of the GNU General Public License as published by
 *      the Free Software Foundation; either version 2 of the License, or
 *      (at your option) any later version.
 *      
 *      This program is distributed in the hope that it will be useful,
 *      but WITHOUT ANY WARRANTY; without even the implied warranty of
 *      MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *      GNU General Public License for more details.
 *      
 *      You should have received a copy of the GNU General Public License
 *      along with this program; if not, write to the Free Software
 *      Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
 *      MA 02110-1301, USA.
 */



/*
MySQL Table for this Class:

CREATE TABLE IF NOT EXISTS `url_redirect` (
  `id` bigint(20) unsigned NOT NULL auto_increment,
  `url` varchar(255) NOT NULL,
  `md5` char(32) NOT NULL,
  `ts` int(10) unsigned NOT NULL,
  `ip` int(10) unsigned NOT NULL,
  PRIMARY KEY  (`id`),
  KEY `md5` (`md5`)
) ENGINE=MyISAM AUTO_INCREMENT=1000 ;


  
If you want to log number of hits, create such a table:

CREATE TABLE IF NOT EXISTS `url_hits` (
    `id` BIGINT UNSIGNED NOT NULL ,
    `hits` BIGINT UNSIGNED NOT NULL ,
    PRIMARY KEY (`id`)
) ENGINE = MYISAM;


This database layout is abled to store more then 4 Billion links. 
This limit is set by the MySQL BIGINT collumn used.
The md5 collumn is used for validating and searching duplicate Urls.
I did a short test and inserted about 100.000 Links and Hits
which needed only ~17 MB of space. => 180 Byte per Link (with index and hits)
The only limitation by the class is the convert method. 
After 1.000.000.000.000.000.000 links there would be a overflow.
*/


/**
 * Class for shortening urls
 * The urls will be stored in a MySQL database, read doku for table Format.
 * @name Url shorten class
 * @version v0.1_2009.09.10
 * @access public
 * @package url_shortener
 */
class url_shortener {
    
    
/**
     * Name of Database table for storing urls and keys
     * @access public
     * @var string
     */
    
var $db_table_redirect 'url_redirect';

    
/**
     * Name of Database table for storing redirect hits
     * @access public
     * @var string
     */
    
var $db_table_hits 'url_hits';

    
/**
     * Convertion basis for the key convert method.
     * Default and maximum is 36 (36 => 0..9 and a..z)
     * If you want to have hex keys set this to 16 (16 => 0..9 and a..f)
     * @access public
     * @var int
     */
    
var $key_base 36;
    
    
/**
     * Regex for URL validation
     * This one is very simple and does only allow urls
     * starting with http:// and https://
     * @access public
     * @var string
     */
    
var $regex_valid_url '@http(s?):\/\/@';
    
    
/**
     * List of forbidden URL parts
     * Can be used to avoid making short links from short links
     * @access public
     * @var array
     */
    
var $url_parts_forbidden = array('http://www.example.com');

    
// --- PUBLIC Methods ---
    // public means: You can _safely_ use this functions from outside.
    // They will not change.
    
    /**
     * Method for creating a new redirect
     * Will return data from database if url already shortened
     * @access public
     * @param string $url Url to shorten
     * @return array Array with all available data inside, or empty one
     */
    
function new_redirect($url) {
        
$ret = array();
        
$url trim($url);
        if(
$url && $this->_valid_url($url) && !$this->_forbidden_url($url)) {
            
// Check if url is already in database
            
$get $this->_get_redirect_by_url($url);
            if(
$get)
                
$ret $get;
            else
                
// If not, insert
                
$ret $this->_insert_url($url);
        }
        return 
$ret;
    }
    
    
/**
     * Method for selecting a redirect from database
     * @access public
     * @param string $key Redirect key to search
     * @return array Array with all available data inside, or empty one
     */
    
function get_redirect($key) {
        return 
$this->_get_redirect_by_key($key);
    }

    
/**
     * Logs a redirect hit by key
     * @access public
     * @param string $key Key of hitted link
     * @return bool false on error
     */
    
function log_redirect($key) {
        
$ret false;
        
$id $this->_convert_id($keyfalse);
        if(
$id) {
            
// Try to update first
            // We will use LOW_PRIORITY/DELAYED here to smoothen the database IO.
            
$sql 'UPDATE LOW_PRIORITY '.$this->db_table_hits.' SET hits=hits+1 
                            WHERE id='
.(int)$id.' LIMIT 1;';
            if(
mysql_query($sql))
                
$ret = (bool)mysql_affected_rows();
            
// Insert if update failed
            
if(!$ret) {
                
$sql 'INSERT DELAYED INTO '.$this->db_table_hits
                                
.' (id,hits)VALUES(\''.(int)$id.'\',\'1\');';
                if(
mysql_query($sql))
                    
$ret = (bool)mysql_affected_rows();
            }
            
        }
        return 
$ret;
    }

    
/**
     * Simply returns the number of current hits for this key
     * @access public
     * @param string $key Key of hitted link
     * @return int|false false on error
     */
    
function get_redirect_count($key) {
        
$ret false;
        
$sql 'SELECT count(*) AS count FROM '.$this->db_table_hits
                        
.' WHERE id = '.$this->_convert_id($keyfalse).';';
        
$query mysql_query($sql);
        if(
$query) {
            
$r mysql_fetch_assoc($query);
            
$ret = (int)$r['count'];
        }
        return 
$ret;
    }

    
/**
     * Simply returns the number of current urls
     * @access public
     * @return int|false false on error
     */
    
function count_urls() {
        
$ret false;
        
$sql 'SELECT count(*) AS count FROM '.$this->db_table_redirect.';';
        
$query mysql_query($sql);
        if(
$query) {
            
$r mysql_fetch_assoc($query);
            
$ret = (int)$r['count'];
        }
        return 
$ret;
    }

    
// --- PRIVATE Methods ---
    // "private" means: do _not_ use these functions from outside!
    // It is very likely that they will get change or removed.
    
    /**
     * Validates a URL with global regex
     * @access private
     * @param strin $url URL to validate
     * @return bool true if URL is ok
     */
    
function _valid_url($url) {
        
$ret true;
        
// Only validate if regex is set
        
if($this->regex_valid_url)    
            
$ret = (bool)preg_match($this->regex_valid_url$url);
        return 
$ret;
    }
    
    
/**
     * Checks if a URL contains forbidden parts
     * @access private
     * @param string $url URL to check
     * @return bool True if URL is forbidden
     */
    
function _forbidden_url($url) {
        
$ret false;
        if(
is_array($this->url_parts_forbidden)) {
            
$i 0;
            
$count count($this->url_parts_forbidden);
            while(!
$ret && $i $count)
                
$ret = (strpos($url$this->url_parts_forbidden[$i++]) !== false);
        }
        return 
$ret;
    }

    
/**
     * Inserts a URL to the database and returns the whole dataset
     * @access private
     * @param string $url Url to shorten
     * @return array Array with all available data inside, or empty one
     */
    
function _insert_url($url) {
        
$ret = array();
        
// Clear unwanted chars from url
        
$url str_ireplace(array("\n","\r","\t"), ''$url);
        
// We save timestamp and IP of submitter.
        // MD5 is needed for verification and faster search on multiple insert
        
$sql 'INSERT INTO '.$this->db_table_redirect.' (url,md5,ts,ip)
                        VALUES (
                        \''
.mysql_real_escape_string($url).'\',
                        \''
.md5($url).'\',
                        \''
.time().'\',
                        INET_ATON(\''
.$this->_get_ip().'\'));';
        if(
mysql_query($sql))
            
$ret $this->get_redirect($this->_convert_id(mysql_insert_id()));
        return 
$ret;
    }

    
/**
     * Searches a Key in the database and returns the whole dataset
     * @access private
     * @param string $key Key to search
     * @return array Array with all available data inside, or empty one
     */
    
function _get_redirect_by_key($key) {
        
$ret = array();
        
// Search for the 
        
$sql 'SELECT *, INET_NTOA(ip) as ip_long FROM '.
                        
$this->db_table_redirect.' WHERE id = \''.
                        
$this->_convert_id($keyfalse).'\' LIMIT 1;';
        
$query mysql_query($sql);
        if(
$query && $r mysql_fetch_assoc($query)) {
            
$ret $r;
            
$ret['key'] = $this->_convert_id($ret['id']);
        }
        return 
$ret;
    }

    
/**
     * Searches a URL in the database and returns the whole dataset
     * @access private
     * @param string $url Url to search
     * @return array Array with all available data inside, or empty one
     */
    
function _get_redirect_by_url($url) {
        
$ret = array();
        
// Search for MD5 of URL because it is a CHAR Collumn with a INDEX on it.
        
$sql 'SELECT *, INET_NTOA(ip) as ip_long FROM '.
                        
$this->db_table_redirect.' WHERE md5 = \''.md5($url).'\';';
        
$query mysql_query($sql);
        if(
$query) {
            while(
$r mysql_fetch_assoc($query))
                if(
$r['url'] == $url) {
                    
$ret $r;
                    
$ret['key'] = $this->_convert_id($ret['id']);
                }
        }
        return 
$ret;
    }

    
/**
     * Converts a numeric id from decimal to alphanumeric.
     * @access private
     * @param int|string $id Integer to convert
     * @param bool $dir Direction to convert, default=true => normal
     * @return string Converted id
     */
    
function _convert_id($id$dir=true) {
        if(
$dir)
            return 
base_convert($id10$this->key_base);
        else
            return 
base_convert($id$this->key_base10);
    }
    
    
/**
     * Returns the ip of current client
     * Checks for clients behind proxys
     * @access private
     * @return string IP of current client
     */
    
function _get_ip() {
        
$ip $_SERVER['REMOTE_ADDR'];
        if(
$_SERVER['HTTP_X_FORWARDED_FOR']) 
            
$ip $_SERVER['HTTP_X_FORWARDED_FOR'];
        if(
$_SERVER['HTTP_FORWARDED_FOR']) 
            
$ip $_SERVER['HTTP_FORWARDED_FOR'];
        if(
$_SERVER['HTTP_FORWARDED']) 
            
$ip $_SERVER['HTTP_FORWARDED'];
        return 
$ip;
    }
    
}

?>