Massive commit with various changes. Added user ranks system (no admin interface yet) and ability for users to have custom user titles. Made cron framework accept fractions of hours through floating-point intervals. Modifed ACL editor to use miniPrompt framework for close confirmation box. Made avatar system use a special page as opposed to fetching the files directly for caching reasons.
authorDan
Sun, 04 May 2008 21:57:48 -0400
changeset 541 acb7e23b6ffa
parent 540 1e4b759da336
child 542 5841df0ab575
Massive commit with various changes. Added user ranks system (no admin interface yet) and ability for users to have custom user titles. Made cron framework accept fractions of hours through floating-point intervals. Modifed ACL editor to use miniPrompt framework for close confirmation box. Made avatar system use a special page as opposed to fetching the files directly for caching reasons.
cron.php
images/prompt-body.png
includes/clientside/css/enano-shared.css
includes/clientside/static/acl.js
includes/clientside/static/comments.js
includes/clientside/static/editor.js
includes/clientside/static/faders.js
includes/comment.php
includes/common.php
includes/common_cli.php
includes/constants.php
includes/functions.php
includes/pageprocess.php
includes/paths.php
includes/sessions.php
install/schemas/mysql_stage2.sql
install/schemas/postgresql_stage2.sql
install/schemas/upgrade/1.1.3-1.1.4-mysql.sql
install/schemas/upgrade/1.1.3-1.1.4-postgresql.sql
language/english/admin.json
language/english/tools.json
language/english/user.json
plugins/SpecialGroups.php
plugins/SpecialUserFuncs.php
plugins/SpecialUserPrefs.php
plugins/admin/GroupManager.php
plugins/admin/UserManager.php
--- a/cron.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/cron.php	Sun May 04 21:57:48 2008 -0400
@@ -29,8 +29,9 @@
 
 foreach ( $cron_tasks as $interval => $tasks )
 {
+  $interval = doubleval($interval);
   $last_run = intval(getConfig("cron_lastrun_ivl_$interval"));
-  $last_run_threshold = time() - ( 3600 * $interval );
+  $last_run_threshold = doubleval(time()) - ( 3600.0 * $interval );
   if ( $last_run_threshold >= $last_run )
   {
     foreach ( $tasks as $task )
Binary file images/prompt-body.png has changed
--- a/includes/clientside/css/enano-shared.css	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/clientside/css/enano-shared.css	Sun May 04 21:57:48 2008 -0400
@@ -749,6 +749,7 @@
 
 div.miniprompt {
   position: absolute;
+  z-index: 999;
 }
 
 div.miniprompt div.mp-top, div.miniprompt div.mp-bottom {
--- a/includes/clientside/static/acl.js	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/clientside/static/acl.js	Sun May 04 21:57:48 2008 -0400
@@ -695,7 +695,35 @@
   closer = document.createElement('input');
   closer.type = 'button';
   closer.value = $lang.get('etc_cancel_changes');
-  closer.onclick = function() { if(!confirm($lang.get('acl_msg_closeacl_confirm'))) return false; killACLManager(); return false; }
+  closer.onclick = function()
+  {
+    miniPromptMessage({
+      title: $lang.get('acl_msg_closeacl_confirm_title'),
+      message: $lang.get('acl_msg_closeacl_confirm_body'),
+      buttons: [
+        {
+          text: $lang.get('acl_btn_close'),
+          color: 'red',
+          style: {
+            fontWeight: 'bold'
+          },
+          onclick: function(e)
+          {
+            killACLManager();
+            miniPromptDestroy(this);
+          }
+        },
+        {
+          text: $lang.get('etc_cancel'),
+          onclick: function(e)
+          {
+            miniPromptDestroy(this);
+          }
+        }
+      ]
+    });
+    return false;
+  }
   
   spacer1 = document.createTextNode('  ');
   spacer2 = document.createTextNode('  ');
--- a/includes/clientside/static/comments.js	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/clientside/static/comments.js	Sun May 04 21:57:48 2008 -0400
@@ -130,13 +130,13 @@
       html+=' ' + $lang.get('comment_postform_blurb_unapp');
     html += ' <a id="leave_comment_button" href="#" onclick="displayCommentForm(); return false;">' + $lang.get('comment_postform_blurb_link') + '</a></p>';
     html += '<div id="comment_form" style="display: none;">';
-    html += '  <table border="0">';
+    html += '  <table border="0" style="width: 100%;">';
     html += '    <tr><td>' + $lang.get('comment_postform_field_name') + '</td><td>';
     if ( data.user_id > 1 ) html += data.username + '<input id="commentform_name" type="hidden" value="'+data.username+'" size="40" />';
-    else html += '<input id="commentform_name" type="text" size="40" />';
+    else html += '<input id="commentform_name" type="text" size="40" style="width: 100%;" />';
     html += '    </td></tr>';
-    html += '    <tr><td>' + $lang.get('comment_postform_field_subject') + '</td><td><input id="commentform_subject" type="text" size="40" /></td></tr>';
-    html += '    <tr><td>' + $lang.get('comment_postform_field_comment') + '</td><td><textarea id="commentform_message" rows="15" cols="50"></textarea></td></tr>';
+    html += '    <tr><td>' + $lang.get('comment_postform_field_subject') + '</td><td><input id="commentform_subject" type="text" size="40" style="width: 100%;" /></td></tr>';
+    html += '    <tr><td>' + $lang.get('comment_postform_field_comment') + '</td><td><textarea id="commentform_message" rows="15" cols="50" style="width: 100%;"></textarea></td></tr>';
     if ( !data.logged_in && data.guest_posting == '1' )
     {
       html += '  <tr><td>' + $lang.get('comment_postform_field_captcha_title') + '<br /><small>' + $lang.get('comment_postform_field_captcha_blurb') + '</small></td><td>';
@@ -181,21 +181,24 @@
   // Name
   tplvars.NAME = this_comment.name;
   if ( this_comment.user_id > 1 )
-    tplvars.NAME = '<a href="' + makeUrlNS('User', this_comment.name) + '">' + this_comment.name + '</a>';
+    tplvars.NAME = '<a href="' + makeUrlNS('User', this_comment.name) + '" style="' + this_comment.rank_style + '">' + this_comment.name + '</a>';
   
   // Avatar
   if ( this_comment.user_has_avatar == '1' )
   {
-    tplvars.AVATAR_URL = scriptPath + '/' + data.avatar_directory + '/' + this_comment.user_id + '.' + this_comment.avatar_type;
+    tplvars.AVATAR_URL = this_comment.avatar_path;
     tplvars.USERPAGE_LINK = makeUrlNS('User', this_comment.name);
     tplvars.AVATAR_ALT = $lang.get('usercp_avatar_image_alt', { username: this_comment.name });
   }
   
   // User level
-  tplvars.USER_LEVEL = $lang.get('user_type_guest');
-  if ( this_comment.user_level >= data.user_level.member ) tplvars.USER_LEVEL = $lang.get('user_type_member');
-  if ( this_comment.user_level >= data.user_level.mod ) tplvars.USER_LEVEL = $lang.get('user_type_mod');
-  if ( this_comment.user_level >= data.user_level.admin ) tplvars.USER_LEVEL = $lang.get('user_type_admin');
+  tplvars.USER_LEVEL = '';
+  if ( this_comment.user_title )
+    tplvars.USER_LEVEL += this_comment.user_title;
+  if ( this_comment.rank_title && this_comment.user_title )
+    tplvars.USER_LEVEL += '<br />';
+  if ( this_comment.rank_title )
+    tplvars.USER_LEVEL += $lang.get(this_comment.rank_title);
   
   // Send PM link
   tplvars.SEND_PM_LINK=(this_comment.user_id>1)?'<a onclick="window.open(this.href); return false;" href="'+ makeUrlNS('Special', 'PrivateMessages/Compose/To/' + ( this_comment.name.replace(/ /g, '_') )) +'">' + $lang.get('comment_btn_send_privmsg') + '</a><br />':'';
@@ -279,6 +282,7 @@
   var ta = document.createElement('textarea');
   ta.rows = '10';
   ta.cols = '40';
+  ta.style.width = '98%';
   ta.value = src;
   ta.id = 'comment_edit_'+id;
   cmt.appendChild(ta);
--- a/includes/clientside/static/editor.js	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/clientside/static/editor.js	Sun May 04 21:57:48 2008 -0400
@@ -366,11 +366,11 @@
       
       var img = document.createElement('img');
       img.src = makeUrlNS('Special', 'Captcha/' + captcha_hash);
-      img._captchaHash = captcha_hash;
+      img.setAttribute('enano:captcha_hash', captcha_hash);
       img.id = 'enano_editor_captcha_img';
       img.onclick = function()
       {
-        this.src = makeUrlNS('Special', 'Captcha/' + this._captchaHash + '/' + Math.floor(Math.random() * 100000));
+        this.src = makeUrlNS('Special', 'Captcha/' + this.getAttribute('enano:captcha_hash') + '/' + Math.floor(Math.random() * 100000));
       }
       img.style.cursor = 'pointer';
       td4_2.appendChild(img);
@@ -379,7 +379,7 @@
       var input = document.createElement('input');
       input.type = 'text';
       input.id = 'enano_editor_field_captcha';
-      input._captchaHash = captcha_hash;
+      input.setAttribute('enano:captcha_hash', captcha_hash);
       input.size = '9';
       td4_2.appendChild(input);
       
@@ -622,7 +622,7 @@
   };
   
   // Do we need to add captcha info?
-  if ( document.getElementById('enano_editor_field_captcha') )
+  if ( document.getElementById('enano_editor_field_captcha') && !is_draft )
   {
     var captcha_field = document.getElementById('enano_editor_field_captcha');
     if ( captcha_field.value == '' )
@@ -632,7 +632,7 @@
       return false;
     }
     json_packet.captcha_code = captcha_field.value;
-    json_packet.captcha_id = captcha_field._captchaHash;
+    json_packet.captcha_id = captcha_field.getAttribute('enano:captcha_hash');
   }
   
   json_packet = ajaxEscape(toJSONString(json_packet));
--- a/includes/clientside/static/faders.js	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/clientside/static/faders.js	Sun May 04 21:57:48 2008 -0400
@@ -153,7 +153,7 @@
     document.getElementById('specialLayer_darkener').style.zIndex = '5';
   }
   var master_div = document.createElement('div');
-  master_div.style.zIndex = '6';
+  master_div.style.zIndex = String(getHighestZ() + 5);
   var mydiv = document.createElement('div');
   mydiv.style.height = '200px';
   w = getWidth();
@@ -431,7 +431,16 @@
 
 function miniPrompt(call_on_create)
 {
-  darken(false, 40);
+  if ( document.getElementById('specialLayer_darkener') )
+  {
+    var opac = parseFloat(document.getElementById('specialLayer_darkener'));
+    opac = opac * 100;
+    darken(false, opac);
+  }
+  else
+  {
+    darken(false, 40);
+  }
   
   var wrapper = document.createElement('div');
   wrapper.className = 'miniprompt';
--- a/includes/comment.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/comment.php	Sun May 04 21:57:48 2008 -0400
@@ -71,6 +71,8 @@
   function process_json($json)
   {
     global $db, $session, $paths, $template, $plugins; // Common objects
+    global $lang;
+    
     $data = enano_json_decode($json);
     $data = decode_unicode_array($data);
     if ( !isset($data['mode']) )
@@ -87,7 +89,6 @@
     }
     $ret = Array();
     $ret['mode'] = $data['mode'];
-    $ret['avatar_directory'] = getConfig('avatar_directory');
     switch ( $data['mode'] )
     {
       case 'fetch':
@@ -102,6 +103,8 @@
                                  ON (u.user_id=c.user_id)
                                LEFT JOIN '.table_prefix.'buddies AS b
                                  ON ( ( b.user_id=' . $session->user_id.' AND b.buddy_user_id=c.user_id ) OR b.user_id IS NULL)
+                               LEFT JOIN '.table_prefix.'ranks AS r
+                                 ON ( ( u.user_rank = r.rank_id ) )
                                WHERE page_id=\'' . $this->page_id . '\'
                                  AND namespace=\'' . $this->namespace . '\'
                                GROUP BY c.comment_id,c.name,c.subject,c.comment_data,c.time,c.approved,c.ip_address,u.user_level,u.user_id,u.signature,u.user_has_avatar,u.avatar_type,b.buddy_id,b.is_friend
@@ -123,6 +126,9 @@
             if ( !$this->perms->get_permissions('mod_comments') && $row['approved'] == 0 )
               continue;
             
+            // Localize the rank
+            $row = array_merge($row, $session->get_user_rank(intval($row['user_id'])));
+            
             // Send the source
             $row['comment_source'] = $row['comment_data'];
             
@@ -150,6 +156,9 @@
             // Do we have the IP?
             $row['have_ip'] = ( $row['have_ip'] == 1 );
             
+            // Avatar URL
+            $row['avatar_path'] = make_avatar_url($row['user_id'], $row['avatar_type']);
+            
             // Add the comment to the list
             $ret['comments'][] = $row;
             
@@ -322,6 +331,7 @@
           $ret['auth_post_comments'] = $this->perms->get_permissions('post_comments');
           $ret['auth_edit_comments'] = $this->perms->get_permissions('edit_comments');
           $ret['user_id'] = $session->user_id;
+          $ret['rank_data'] = $session->get_user_rank($session->user_id);
           $ret['username'] = $session->username;
           $ret['logged_in'] = $session->user_logged_in;
           $ret['signature'] = RenderMan::render($row['signature']);
@@ -331,7 +341,7 @@
           $ret['user_level_list']['member'] = USER_LEVEL_MEMBER;
           $ret['user_level_list']['mod'] = USER_LEVEL_MOD;
           $ret['user_level_list']['admin'] = USER_LEVEL_ADMIN;
-          $ret['avatar_directory'] = getConfig('avatar_directory');
+          $ret['avatar_path'] = make_avatar_url($row['user_id'], $row['avatar_type']);
         }
         
         break;
--- a/includes/common.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/common.php	Sun May 04 21:57:48 2008 -0400
@@ -415,6 +415,10 @@
   
   profiler_log('Ran disabled-site checks and common_post');
   
+  load_rank_data();
+  
+  profiler_log('Loaded user rank data');
+  
   if ( isset($_GET['noheaders']) )
     $template->no_headers = true;
 }
--- a/includes/common_cli.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/common_cli.php	Sun May 04 21:57:48 2008 -0400
@@ -244,6 +244,10 @@
   
   profiler_log('Ran disabled-site checks and common_post');
   
+  load_rank_data();
+  
+  profiler_log('Loaded user rank data');
+  
   if ( isset($_GET['noheaders']) )
     $template->no_headers = true;
 }
--- a/includes/constants.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/constants.php	Sun May 04 21:57:48 2008 -0400
@@ -40,9 +40,16 @@
 define('ACL_ALWAYS_ALLOW_ADMIN_EDIT_ACL', 1);
 
 // System groups
+define('GROUP_ID_EVERYONE', 1);
 define('GROUP_ID_ADMIN', 2);
 define('GROUP_ID_MOD', 3);
 
+// System ranks
+define('RANK_ID_MEMBER', 1);
+define('RANK_ID_MOD', 2);
+define('RANK_ID_ADMIN', 3);
+define('RANK_ID_GUEST', 4);
+
 // Page group types
 define('PAGE_GRP_CATLINK', 1);
 define('PAGE_GRP_TAGGED', 2);
@@ -52,6 +59,11 @@
 // Identifier for the default pseudo-language
 define('LANG_DEFAULT', 0);
 
+// Image types
+define('IMAGE_TYPE_PNG', 1);
+define('IMAGE_TYPE_GIF', 2);
+define('IMAGE_TYPE_JPG', 3);
+
 //
 // User types - don't touch these
 //
--- a/includes/functions.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/functions.php	Sun May 04 21:57:48 2008 -0400
@@ -3344,6 +3344,7 @@
 function register_cron_task($func, $hour_interval = 24)
 {
   global $cron_tasks;
+  $hour_interval = strval($hour_interval);
   if ( !isset($cron_tasks[$hour_interval]) )
     $cron_tasks[$hour_interval] = array();
   $cron_tasks[$hour_interval][] = $func;
@@ -3985,11 +3986,30 @@
 
 function make_avatar_url($user_id, $avi_type)
 {
+  static $img_types = array(
+      'png' => IMAGE_TYPE_PNG,
+      'gif' => IMAGE_TYPE_GIF,
+      'jpg' => IMAGE_TYPE_JPG
+    );
+  
   if ( !is_int($user_id) )
     return false;
-  if ( !in_array($avi_type, array('png', 'gif', 'jpg')) )
+  if ( !isset($img_types[$avi_type]) )
     return false;
-  return scriptPath . '/' . getConfig('avatar_directory') . '/' . $user_id . '.' . $avi_type;
+  $avi_relative_path = '/' . getConfig('avatar_directory') . '/' . $user_id . '.' . $avi_type;
+  if ( !file_exists(ENANO_ROOT . $avi_relative_path) )
+  {
+    return '';
+  }
+  
+  $img_type = $img_types[$avi_type];
+  
+  $dateline = @filemtime(ENANO_ROOT . $avi_relative_path);
+  $avi_id = pack('VVv', $dateline, $user_id, $img_type);
+  $avi_id = hexencode($avi_id, '', '');
+    
+  // return scriptPath . $avi_relative_path;
+  return makeUrlNS('Special', "Avatar/$avi_id");
 }
 
 /**
@@ -4312,6 +4332,91 @@
     }
 }
 
+/**
+ * Grabs and processes all rank information directly from the database.
+ */
+
+function fetch_rank_data()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  global $lang;
+  
+  $sql = $session->generate_rank_sql();
+  $q = $db->sql_query($sql);
+  if ( !$q )
+    $db->_die();
+  
+  $GLOBALS['user_ranks'] = array();
+  global $user_ranks;
+  
+  while ( $row = $db->fetchrow($q) )
+  {
+    $user_id = $row['user_id'];
+    $username = $row['username'];
+    $row = $session->calculate_user_rank($row);
+    $user_ranks[$username] =  $row;
+    $user_ranks[$user_id]  =& $user_ranks[$username];
+  }
+}
+
+/**
+ * Caches the computed user rank information.
+ */
+
+function generate_ranks_cache()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  global $lang;
+  global $user_ranks;
+  
+  fetch_rank_data();
+  
+  $user_ranks_stripped = array();
+  foreach ( $user_ranks as $key => $value )
+  {
+    if ( is_int($key) )
+      $user_ranks_stripped[$key] = $value;
+  }
+  
+  $ranks_exported = "<?php\n\n// Automatically generated user rank cache.\nglobal \$user_ranks;\n" . '$user_ranks = ' . $lang->var_export_string($user_ranks_stripped) . ';';
+  $uid_map = array();
+  foreach ( $user_ranks as $id => $row )
+  {
+    if ( !is_int($id) )
+    {
+      $username = $id;
+      continue;
+    }
+    
+    $un_san = addslashes($username);
+    $ranks_exported .= "\n\$user_ranks['$un_san'] =& \$user_ranks[{$row['user_id']}];";
+  }
+  $ranks_exported .= "\n\ndefine('ENANO_RANKS_CACHE_LOADED', 1); \n?>";
+  
+  // open ranks cache file
+  $fh = @fopen( ENANO_ROOT . '/cache/cache_ranks.php', 'w' );
+  if ( !$fh )
+    return false;
+  fwrite($fh, $ranks_exported);
+  fclose($fh);
+}
+
+/**
+ * Loads the rank data, first attempting the cache file and then the database.
+ */
+
+function load_rank_data()
+{
+  if ( file_exists( ENANO_ROOT . '/cache/cache_ranks.php' ) )
+  {
+    @include(ENANO_ROOT . '/cache/cache_ranks.php');
+  }
+  if ( !defined('ENANO_RANKS_CACHE_LOADED') )
+  {
+    fetch_rank_data();
+  }
+}
+
 //die('<pre>Original:  01010101010100101010100101010101011010'."\nProcessed: ".uncompress_bitfield(compress_bitfield('01010101010100101010100101010101011010')).'</pre>');
 
 ?>
--- a/includes/pageprocess.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/pageprocess.php	Sun May 04 21:57:48 2008 -0400
@@ -1307,6 +1307,9 @@
       }
     }
     
+    // get the user's rank
+    $rank_data = $session->get_user_rank(intval($userdata['authoritative_uid']));
+    
     $this->header();
     
     // if ( $send_headers )
@@ -1331,10 +1334,20 @@
     // Basic user info
     
     echo '<tr><th class="subhead">' . $lang->get('userpage_heading_basics', array('username' => htmlspecialchars($target_username))) . '</th></tr>';
+    
+    echo '<tr><td class="row1" style="text-align: center;">';
     if ( $userdata['user_has_avatar'] == '1' )
     {
-      echo '<tr><td class="row1" style="text-align: center;"><img alt="' . $lang->get('usercp_avatar_image_alt', array('username' => $userdata['username'])) . '" src="' . make_avatar_url(intval($userdata['authoritative_uid']), $userdata['avatar_type']) . '" /></td></tr>';
+      echo '<img alt="' . $lang->get('usercp_avatar_image_alt', array('username' => $userdata['username'])) . '" src="' . make_avatar_url(intval($userdata['authoritative_uid']), $userdata['avatar_type']) . '" /><br />';
     }
+    // username
+    echo '<big><span style="' . $rank_data['rank_style'] . '">' . htmlspecialchars($target_username) . '</span></big><br />';
+    // user title, if appropriate
+    if ( $rank_data['user_title'] )
+      echo htmlspecialchars($rank_data['user_title']) . '<br />';
+    // rank
+    echo htmlspecialchars($lang->get($rank_data['rank_title']));
+    echo '</td></tr>';
     echo '<tr><td class="row3">' . $lang->get('userpage_lbl_joined') . ' ' . enano_date('F d, Y h:i a', $userdata['reg_time']) . '</td></tr>';
     echo '<tr><td class="row1">' . $lang->get('userpage_lbl_num_comments') . ' ' . $userdata['n_comments'] . '</td></tr>';
     
--- a/includes/paths.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/paths.php	Sun May 04 21:57:48 2008 -0400
@@ -75,7 +75,7 @@
     $session->register_acl_type('create_page',            AUTH_WIKIMODE, 'perm_create_page',            Array(),                                                  'Article|User|Project|Template|File|Help|System|Category|Special');
     $session->register_acl_type('html_in_pages',          AUTH_DISALLOW, 'perm_html_in_pages',          Array('edit_page'),                                       'Article|User|Project|Template|File|Help|System|Category|Admin');
     $session->register_acl_type('php_in_pages',           AUTH_DISALLOW, 'perm_php_in_pages',           Array('edit_page', 'html_in_pages'),                      'Article|User|Project|Template|File|Help|System|Category|Admin');
-    $session->register_acl_type('custom_user_title',      AUTH_DISALLOW, 'perm_custom_user_title',      Array(''),                                                'User|Special');
+    $session->register_acl_type('custom_user_title',      AUTH_DISALLOW, 'perm_custom_user_title',      Array(),                                                  'User|Special');
     $session->register_acl_type('edit_acl',               AUTH_DISALLOW, 'perm_edit_acl',               Array('read', 'post_comments', 'edit_comments', 'edit_page', 'view_source', 'mod_comments', 'history_view', 'history_rollback', 'history_rollback_extra', 'protect', 'rename', 'clear_logs', 'vote_delete', 'vote_reset', 'delete_page', 'set_wiki_mode', 'password_set', 'password_reset', 'mod_misc', 'edit_cat', 'even_when_protected', 'upload_files', 'upload_new_version', 'create_page', 'php_in_pages'));
     
     // DO NOT add new admin pages here! Use a plugin to call $paths->addAdminNode();
--- a/includes/sessions.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/includes/sessions.php	Sun May 04 21:57:48 2008 -0400
@@ -148,6 +148,13 @@
    */
    
   var $valid_username = '([^<>&\?\'"%\n\r\t\a\/]+)';
+  
+  /**
+   * The current user's user title. Defaults to NULL.
+   * @var string
+   */
+  
+  var $user_title = null;
    
   /**
    * What we're allowed to do as far as permissions go. This changes based on the value of the "auth" URI param.
@@ -247,6 +254,19 @@
    
   var $group_mod = Array();
   
+  /**
+   * A constant array of user-level-to-rank default associations.
+   * @var array
+   */
+  
+  var $level_rank_table = array(
+      USER_LEVEL_ADMIN  => RANK_ID_ADMIN,
+      USER_LEVEL_MOD    => RANK_ID_MOD,
+      USER_LEVEL_MEMBER => RANK_ID_MEMBER,
+      USER_LEVEL_CHPREF => RANK_ID_MEMBER,
+      USER_LEVEL_GUEST  => RANK_ID_GUEST
+    );
+  
   # Basic functions
    
   /**
@@ -468,6 +488,7 @@
         $this->real_name =     $userdata['real_name'];
         $this->email =         $userdata['email'];
         $this->unread_pms =    $userdata['num_pms'];
+        $this->user_title =    $userdata['user_title'];
         if(!$this->compat)
         {
           $this->theme =         $userdata['theme'];
@@ -1232,7 +1253,7 @@
     $salt = $db->escape($keydata[3]);
     // using a normal call to $db->sql_query to avoid failing on errors here
     $query = $db->sql_query('SELECT u.user_id AS uid,u.username,u.password,u.email,u.real_name,u.user_level,u.theme,u.style,u.signature,' . "\n"
-                             . '    u.reg_time,u.account_active,u.activation_key,u.user_lang,k.source_ip,k.time,k.auth_level,COUNT(p.message_id) AS num_pms,' . "\n"
+                             . '    u.reg_time,u.account_active,u.activation_key,u.user_lang,u.user_title,k.source_ip,k.time,k.auth_level,COUNT(p.message_id) AS num_pms,' . "\n"
                              . '    u.user_timezone, x.* FROM '.table_prefix.'session_keys AS k' . "\n"
                              . '  LEFT JOIN '.table_prefix.'users AS u' . "\n"
                              . '    ON ( u.user_id=k.user_id )' . "\n"
@@ -2355,6 +2376,249 @@
   }
   
   #
+  # USER RANKS
+  #
+  
+  /**
+   * SYNOPSIS OF THE RANK SYSTEM
+   * Enano's rank logic calculates a user's rank based on a precedence scale. The way things are checked is:
+   *   1. Check to see if the user has a specific rank assigned. Use that if possible.
+   *   2. Check the user's primary group to see if it specifies a rank. Use that if possible.
+   *   3. Check the other groups a user is in. If one that has a custom rank is encountered, use that rank.
+   *   4. See if the user's user level has a specific rank hard-coded to be associated with it. (Always overrideable as can be seen above)
+   *   5. Use the "member" rank
+   */
+  
+  /**
+   * Generates a textual SQL query for fetching rank data to be sent to calculate_user_rank().
+   * @param string Text to append, possibly a WHERE clause or so
+   * @return string
+   */
+  
+  function generate_rank_sql($append = '')
+  {
+    // Generate level-to-rank associations
+    $assoc = array();
+    foreach ( $this->level_rank_table as $level => $rank )
+    {
+      $assoc[] = "        ( u.user_level = $level AND rl.rank_id = $rank )";
+    }
+    $assoc = implode(" OR\n", $assoc) . "\n";
+    
+    $gid_col = ( ENANO_DBLAYER == 'PGSQL' ) ?
+      'array_to_string(array_accum(m.group_id), \',\') AS group_list' :
+      'GROUP_CONCAT(m.group_id) AS group_list';
+    
+    // The actual query
+    $sql = "SELECT u.user_id, u.username, u.user_level, u.user_group, u.user_rank, u.user_title, g.group_rank,\n"
+         . "       COALESCE(ru.rank_id,    rg.rank_id,    rl.rank_id,    rd.rank_id   ) AS rank_id,\n"
+         . "       COALESCE(ru.rank_title, rg.rank_title, rl.rank_title, rd.rank_title) AS rank_title,\n"
+         . "       COALESCE(ru.rank_style, rg.rank_style, rl.rank_style, rd.rank_style) AS rank_style,\n"
+         . "       ( ru.rank_id IS NULL AND rg.rank_id IS NULL ) AS using_default,"
+         . "       ( ru.rank_id IS NULL AND rg.rank_id IS NOT NULL ) AS using_group,"
+         . "       $gid_col\n"
+         . "  FROM " . table_prefix . "users AS u\n"
+         . "  LEFT JOIN groups AS g\n"
+         . "    ON ( g.group_id = u.user_group )\n"
+         . "  LEFT JOIN " . table_prefix . "group_members AS m\n"
+         . "    ON ( u.user_id = m.user_id )\n"
+         . "  LEFT JOIN ranks AS ru\n"
+         . "    ON ( u.user_rank = ru.rank_id )\n"
+         . "  LEFT JOIN ranks AS rg\n"
+         . "    ON ( g.group_rank = rg.rank_id )\n"
+         . "  LEFT JOIN ranks AS rl\n"
+         . "    ON (\n"
+         . $assoc
+         . "      )\n"
+         . "  LEFT JOIN ranks AS rd\n"
+         . "    ON ( rd.rank_id = 1 )\n"
+         . "  GROUP BY u.user_id, u.username, u.user_level, u.user_group, u.user_rank, u.user_title, g.group_rank,\n"
+         . "       ru.rank_id, ru.rank_title, ru.rank_style,rg.rank_id, rg.rank_title, rg.rank_style,\n"
+         . "       rl.rank_id, rl.rank_title, rl.rank_style,rd.rank_id, rd.rank_title, rd.rank_style$append;";
+    
+    return $sql;
+  }
+  
+  /**
+   * Returns an associative array with a user's rank information.
+   * The array will contain the following values:
+   *   username: string  The user's username
+   *   user_id:  integer Numerical user ID
+   *   rank_id:  integer Numerical rank ID
+   *   rank:     string  The user's current rank
+   *   title:    string  The user's custom user title if applicable; should be displayed one line below the rank
+   *   style:    string  CSS for the username
+   * @param int|string Username *or* user ID
+   * @return array or false on failure
+   */
+  
+  function get_user_rank($id)
+  {
+    global $db, $session, $paths, $template, $plugins; // Common objects
+    global $lang;
+    global $user_ranks;
+    // cache info if possible
+    static $_cache = array();
+    
+    if ( is_int($id) )
+      $col = "user_id = $id";
+    else if ( is_string($id) )
+      $col = ENANO_SQLFUNC_LOWERCASE . "(username) = " . ENANO_SQLFUNC_LOWERCASE . "('" . $db->escape($id) . "')";
+    else
+      // invalid parameter
+      return false;
+      
+    // check the cache
+    if ( isset($_cache[$id]) )
+      return $_cache[$id];
+    
+    // check the disk cache
+    if ( is_int($id) )
+    {
+      if ( isset($user_ranks[$id]) )
+      {
+        $_cache[$id] =& $user_ranks[$id];
+        return $user_ranks[$id];
+      }
+    }
+    else if ( is_string($id) )
+    {
+      foreach ( $user_ranks as $key => $valarray )
+      {
+        if ( is_string($key) && strtolower($key) == strtolower($id) )
+        {
+          $_cache[$id] = $valarray;
+          return $valarray;
+        }
+      }
+    }
+    
+    $sql = $this->generate_rank_sql("\n  WHERE $col");
+    
+    $q = $this->sql($sql);
+    // any results?
+    if ( $db->numrows() < 1 )
+    {
+      // nuttin'.
+      $db->free_result();
+      $_cache[$id] = false;
+      return false;
+    }
+    
+    // Found something.
+    $row = $db->fetchrow();
+    $db->free_result();
+    
+    $row = $this->calculate_user_rank($row);
+    
+    $_cache[$id] = $row;
+    return $row;
+  }
+  
+  /**
+   * Performs the actual rank calculation based on the contents of a row.
+   * @param array
+   * @return array
+   */
+  
+  function calculate_user_rank($row)
+  {
+    global $db, $session, $paths, $template, $plugins; // Common objects
+    global $lang;
+    
+    static $rank_cache = array();
+    static $group_ranks = array();
+    
+    // try to cache that rank info
+    if ( !isset($rank_cache[ intval($row['rank_id']) ]) && $row['rank_id'] )
+    {
+      $rank_cache[ intval($row['rank_id']) ] = array(
+          'rank_id' => intval($row['rank_id']),
+          'rank_title' => intval($row['rank_title']),
+          'rank_style' => intval($row['rank_style'])
+        );
+    }
+    // cache group info (if appropriate)
+    if ( $row['using_group'] && !isset($group_ranks[ intval($row['user_group']) ]) )
+    {
+      $group_ranks[ intval($row['user_group']) ] = intval($row['group_rank_id']);
+    }
+    
+    // sanitize and process the as-of-yet rank data
+    $row['rank_id'] = intval($row["rank_id"]);
+    $row['rank_title'] = $row["rank_title"];
+    
+    // if we're falling back to some default, then see if we can use one of the user's other groups
+    if ( $row['using_default'] && !empty($row['group_list']) )
+    {
+      $group_list = explode(',', $row['group_list']);
+      if ( array_walk($group_list, 'intval') )
+      {
+        // go through the group list and see if any of them has a rank assigned
+        foreach ( $group_list as $group_id )
+        {
+          // cached in RAM? Preferably use that.
+          if ( !isset($group_ranks[$group_id]) )
+          {
+            // Not cached - grab it
+            $q = $this->sql('SELECT group_rank FROM ' . table_prefix . "groups WHERE group_id = $group_id;");
+            if ( $db->numrows() < 1 )
+            {
+              $db->free_result();
+              continue;
+            }
+            list($result) = $db->fetchrow_num();
+            $db->free_result();
+            
+            if ( $result === null || $result < 1 )
+            {
+              $group_ranks[$group_id] = false;
+            }
+            else
+            {
+              $group_ranks[$group_id] = intval($result);
+            }
+          }
+          // we've got it now
+          if ( $group_ranks[$group_id] )
+          {
+            // found a group with a rank assigned
+            // so get the rank info
+            $rank_id =& $group_ranks[$group_id];
+            if ( !isset($rank_cache[$rank_id]) )
+            {
+              $q = $this->sql('SELECT rank_id, rank_title, rank_style FROM ' . table_prefix . "ranks WHERE rank_id = $rank_id;");
+              if ( $db->numrows() < 1 )
+              {
+                $db->free_result();
+                continue;
+              }
+              $rank_cache[$rank_id] = $db->fetchrow();
+              $db->free_result();
+            }
+            // set the final rank parameters
+            // die("found member-of-group exception with uid {$row['user_id']} gid $group_id rid $rank_id rt {$rank_cache[$rank_id]['rank_title']}");
+            $row['rank_id'] = $rank_id;
+            $row['rank_title'] = $rank_cache[$rank_id]['rank_title'];
+            $row['rank_style'] = $rank_cache[$rank_id]['rank_style'];
+            break;
+          }
+        }
+      }
+    }
+    
+    if ( $row['user_title'] === NULL )
+      $row['user_title'] = false;
+    
+    $row['user_id'] = intval($row['user_id']);
+    $row['user_level'] = intval($row['user_level']);
+    $row['user_group'] = intval($row['user_group']);
+    
+    unset($row['user_rank'], $row['group_rank'], $row['group_list'], $row['using_default'], $row['using_group'], $row['user_level'], $row['user_group'], $row['username']);
+    return $row;
+  }
+  
+  #
   # Access Control Lists
   #
   
@@ -3656,4 +3920,10 @@
 // Once a week
 register_cron_task('cron_clean_old_admin_keys', 168);
 
+/**
+ * Cron task - regenerate cached user rank information
+ */
+
+register_cron_task('generate_ranks_cache', 0.25);
+
 ?>
--- a/install/schemas/mysql_stage2.sql	Sat Apr 26 17:25:28 2008 -0400
+++ b/install/schemas/mysql_stage2.sql	Sun May 04 21:57:48 2008 -0400
@@ -110,8 +110,10 @@
   user_has_avatar tinyint(1) NOT NULL DEFAULT 0,
   avatar_type ENUM('jpg', 'png', 'gif') NOT NULL DEFAULT 'png',
   user_registration_ip varchar(39),
-  user_rank int(12) UNSIGNED NOT NULL DEFAULT 1,
+  user_rank int(12) UNSIGNED DEFAULT NULL,
   user_timezone int(12) UNSIGNED NOT NULL DEFAULT 0,
+  user_title varchar(64) DEFAULT NULL,
+  user_group mediumint(5) NOT NULL DEFAULT 1,
   PRIMARY KEY  (user_id)
 ) CHARACTER SET `utf8` COLLATE `utf8_bin`;
 
@@ -376,7 +378,8 @@
 INSERT INTO {{TABLE_PREFIX}}ranks(rank_id, rank_title, rank_style) VALUES
   (1, 'user_rank_member', ''),
   (2, 'user_rank_mod', 'font-weight: bold; color: #00AA00;'),
-  (3, 'user_rank_admin', 'font-weight: bold; color: #AA0000;');
+  (3, 'user_rank_admin', 'font-weight: bold; color: #AA0000;'),
+  (4, 'user_rank_guest', '');
 
 INSERT INTO {{TABLE_PREFIX}}groups(group_id,group_name,group_type,system_group) VALUES(1, 'Everyone', 3, 1),
   (2,'Administrators',3,1),
--- a/install/schemas/postgresql_stage2.sql	Sat Apr 26 17:25:28 2008 -0400
+++ b/install/schemas/postgresql_stage2.sql	Sun May 04 21:57:48 2008 -0400
@@ -110,8 +110,10 @@
   user_has_avatar smallint NOT NULL,
   avatar_type varchar(3) NOT NULL,
   user_registration_ip varchar(39),
-  user_rank int NOT NULL DEFAULT 1,
+  user_rank int DEFAULT NULL,
   user_timezone int NOT NULL DEFAULT 0,
+  user_title varchar(64) DEFAULT NULL,
+  user_group int NOT NULL DEFAULT 1,
   CHECK (avatar_type IN ('jpg', 'png', 'gif')),
   PRIMARY KEY  (user_id)
 );
@@ -326,6 +328,17 @@
   PRIMARY KEY ( plugin_id )
 );
 
+-- Aggregate function array_accum
+-- http://www.postgresql.org/docs/current/static/xaggr.html
+
+CREATE AGGREGATE {{TABLE_PREFIX}}array_accum (anyelement)
+(
+    sfunc = array_append,
+    stype = anyarray,
+    initcond = '{}'
+);
+
+
 INSERT INTO {{TABLE_PREFIX}}config(config_name, config_value) VALUES
   ('site_name', '{{SITE_NAME}}'),
   ('main_page', 'Main_Page'),
@@ -355,7 +368,7 @@
   ('powered_btn', '1');
 
 INSERT INTO {{TABLE_PREFIX}}page_text(page_id, namespace, page_text, char_tag) VALUES
-  ('Main_Page', 'Article', '{{MAIN_PAGE_CONTENT}}', '');
+  ('Main_Page', 'Article', E'{{MAIN_PAGE_CONTENT}}', '');
   
 INSERT INTO {{TABLE_PREFIX}}logs(time_id, date_string, log_type, action, page_id, namespace, author, page_text) VALUES
   ({{UNIX_TIME}}, 'DEPRECATED', 'page', 'edit', 'Main_Page', 'Article', '{{ADMIN_USER}}', '{{MAIN_PAGE_CONTENT}}');
@@ -377,7 +390,8 @@
 INSERT INTO {{TABLE_PREFIX}}ranks(rank_id, rank_title, rank_style) VALUES
   (1, 'user_rank_member', ''),
   (2, 'user_rank_mod', 'font-weight: bold; color: #00AA00;'),
-  (3, 'user_rank_admin', 'font-weight: bold; color: #AA0000;');
+  (3, 'user_rank_admin', 'font-weight: bold; color: #AA0000;'),
+  (4, 'user_rank_guest', '');
 
 INSERT INTO {{TABLE_PREFIX}}groups(group_id,group_name,group_type,system_group) VALUES(1, 'Everyone', 3, 1),
   (2,'Administrators',3,1),
--- a/install/schemas/upgrade/1.1.3-1.1.4-mysql.sql	Sat Apr 26 17:25:28 2008 -0400
+++ b/install/schemas/upgrade/1.1.3-1.1.4-mysql.sql	Sun May 04 21:57:48 2008 -0400
@@ -8,3 +8,14 @@
   PRIMARY KEY ( plugin_id )
 ) ENGINE `MyISAM` CHARACTER SET `utf8` COLLATE `utf8_bin`;
 
+-- User title
+ALTER TABLE {{TABLE_PREFIX}}users ADD COLUMN user_title varchar(64) DEFAULT NULL;
+ALTER TABLE {{TABLE_PREFIX}}users MODIFY COLUMN user_rank int(12) unsigned DEFAULT NULL;
+ALTER TABLE {{TABLE_PREFIX}}users ADD COLUMN user_group mediumint(5) NOT NULL DEFAULT 1;
+UPDATE {{TABLE_PREFIX}}users SET user_rank = NULL;
+
+-- The "guest" rank
+-- No frontend to this yet so ranks should not have been created.
+DELETE FROM {{TABLE_PREFIX}}ranks WHERE rank_id = 4;
+INSERT INTO {{TABLE_PREFIX}}ranks(rank_id, rank_title, rank_style) VALUES
+  (4, 'user_rank_guest', '');
--- a/install/schemas/upgrade/1.1.3-1.1.4-postgresql.sql	Sat Apr 26 17:25:28 2008 -0400
+++ b/install/schemas/upgrade/1.1.3-1.1.4-postgresql.sql	Sun May 04 21:57:48 2008 -0400
@@ -7,3 +7,30 @@
   plugin_version varchar(16),
   PRIMARY KEY ( plugin_id )
 );
+
+-- User title
+ALTER TABLE {{TABLE_PREFIX}}users ADD COLUMN user_title varchar(64) DEFAULT NULL;
+
+-- Modifications to user_rank column
+-- http://pgsqld.active-venture.com/ddl-alter.html#AEN1984
+ALTER TABLE {{TABLE_PREFIX}}users ALTER COLUMN user_rank DROP NOT NULL,
+              ALTER COLUMN user_rank DROP DEFAULT;
+ALTER TABLE {{TABLE_PREFIX}}users ADD COLUMN user_group int NOT NULL DEFAULT 1;
+UPDATE {{TABLE_PREFIX}}users SET user_rank = NULL;
+              
+-- Aggregate function array_accum
+-- http://www.postgresql.org/docs/current/static/xaggr.html
+
+CREATE AGGREGATE {{TABLE_PREFIX}}array_accum (anyelement)
+(
+    sfunc = array_append,
+    stype = anyarray,
+    initcond = '{}'
+);
+
+-- The "guest" rank
+-- No frontend to this yet so ranks should not have been created.
+DELETE FROM {{TABLE_PREFIX}}ranks WHERE rank_id = 4;
+INSERT INTO {{TABLE_PREFIX}}ranks(rank_id, rank_title, rank_style) VALUES
+  (4, 'user_rank_guest', '');
+
--- a/language/english/admin.json	Sat Apr 26 17:25:28 2008 -0400
+++ b/language/english/admin.json	Sun May 04 21:57:48 2008 -0400
@@ -133,7 +133,8 @@
       
       msg_guest_howto: 'To edit permissions for guests, select "a specific user", and enter Anonymous as the username.',
       msg_deleterule_confirm: 'Do you really want to delete this rule?',
-      msg_closeacl_confirm: 'Do you really want to close the ACL manager?',
+      msg_closeacl_confirm_title: 'Close the ACL manager?',
+      msg_closeacl_confirm_body: 'This will cancel any changes that you haven\'t saved.',
       msg_deny_everyone_confirm: 'CAUTION: You are setting a Deny ruling for everyone on this site. This will block the selected actions from being performed at all. Do you really want to do this?\n\nPlease also note that the following core pages will not be blocked from being accessed: Special:Login, Special:Logout, and Special:LangExportJSON.',
       
       msg_scale_intro_title: 'Existing ACL rules',
@@ -162,6 +163,7 @@
       btn_returnto_editor: 'Return to ACL editor',
       btn_returnto_userscope: 'Return to user/scope selection',
       btn_show_existing: '&raquo; View existing rules',
+      btn_close: 'Close ACL wizard',
     },
     acphome: {
       heading_main: 'Welcome to Runt, the Enano administration panel.',
--- a/language/english/tools.json	Sat Apr 26 17:25:28 2008 -0400
+++ b/language/english/tools.json	Sun May 04 21:57:48 2008 -0400
@@ -48,6 +48,7 @@
       language_export: 'Language exporter',
       private_messages: 'Private Messages',
       recent_changes: 'Recent changes',
+      avatar: 'Fetch avatar'
     },
     search: {
       th_advanced_search: 'Advanced Search',
--- a/language/english/user.json	Sat Apr 26 17:25:28 2008 -0400
+++ b/language/english/user.json	Sun May 04 21:57:48 2008 -0400
@@ -120,6 +120,11 @@
       type_mod: 'Moderator',
       type_admin: 'Administrator',
       
+      rank_member: '%this.user_type_member%',
+      rank_mod: '%this.user_type_mod%',
+      rank_admin: '%this.user_type_admin%',
+      rank_guest: '%this.user_type_guest%',
+      
       msg_elev_timed_out: '<b>Your administrative session has timed out.</b> <a href="%login_link%" onclick="ajaxLogonToElev(); return false;">Log in again</a>',
       
       reg_err_captcha: 'The confirmation code you entered was incorrect.',
@@ -289,6 +294,8 @@
       publicinfo_field_changetheme: 'Change my theme...',
       publicinfo_field_timezone: 'Time zone:',
       publicinfo_field_timezone_hint: 'Select the time zone you live in and when Daylight Savings Time occurs, if at all.',
+      publicinfo_field_usertitle_title: 'User title:',
+      publicinfo_field_usertitle_hint: 'This can be some text that will be displayed underneath your username.',
       publicinfo_th_im: 'Instant messenger contact information',
       publicinfo_field_aim: 'AIM handle:',
       publicinfo_field_wlm: '<acronym title="Windows&trade; Live Messenger">WLM</acronym> handle:<br /><small>If you don\'t specify the domain (@whatever.com), "@hotmail.com" will be assumed.</small>',
--- a/plugins/SpecialGroups.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/plugins/SpecialGroups.php	Sun May 04 21:57:48 2008 -0400
@@ -223,6 +223,9 @@
           $members[] = $r;
           $db->free_result();
           
+          // just added a user to the group, so regenerate the ranks cache
+          generate_ranks_cache();
+          
           break;
         case 'del_users':
           foreach ( $members as $i => $member )
@@ -235,6 +238,9 @@
               unset($members[$i]);
             }
           }
+          // regenerate the ranks cache
+          generate_ranks_cache();
+          
           break;
         case 'pending':
           foreach ( $pending as $i => $member )
@@ -259,6 +265,9 @@
               }
             }
           }
+          // memberships updated/changed, regenerate ranks cache
+          generate_ranks_cache();
+          
           echo '<div class="info-box">' . $lang->get('groupcp_msg_pending_updated') . '</div>';
           break;
       }
--- a/plugins/SpecialUserFuncs.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/plugins/SpecialUserFuncs.php	Sun May 04 21:57:48 2008 -0400
@@ -100,6 +100,13 @@
       \'special\'=>0,\'visible\'=>0,\'comments_on\'=>0,\'protected\'=>1,\'delvotes\'=>0,\'delvote_ips\'=>\'\',
       ));
       
+    $paths->add_page(Array(
+      \'name\'=>\'specialpage_avatar\',
+      \'urlname\'=>\'Avatar\',
+      \'namespace\'=>\'Special\',
+      \'special\'=>0,\'visible\'=>0,\'comments_on\'=>0,\'protected\'=>1,\'delvotes\'=>0,\'delvote_ips\'=>\'\',
+      ));
+      
     ');
 
 // function names are IMPORTANT!!! The name pattern is: page_<namespace ID>_<page URLname, without namespace>
@@ -2021,4 +2028,76 @@
   
 }
 
+/**
+ * Fetches and displays an avatar from the filesystem. Avatar fetching is abstracted as of 1.1.4.
+ */
+
+function page_Special_Avatar()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  global $aggressive_optimize_html;
+  $aggressive_optimize_html = false;
+  
+  $img_types = array(
+      IMAGE_TYPE_PNG => 'png',
+      IMAGE_TYPE_GIF => 'gif',
+      IMAGE_TYPE_JPG => 'jpg'
+    );
+  
+  $avi_id = $paths->getParam(0);
+  if ( !$avi_id || !@preg_match('/^[a-f0-9]+$/', $avi_id) )
+  {
+    echo 'Doesn\'t match the regexp';
+    return true;
+  }
+  
+  $avi_id_dec = hexdecode($avi_id);
+  $avi_id_dec = @unpack('Vdate/Vuid/vimg_type', $avi_id_dec);
+  if ( !$avi_id_dec )
+  {
+    echo 'Bad unpack';
+    return true;
+  }
+  
+  // check parameters
+  if ( !isset($img_types[$avi_id_dec['img_type']]) )
+  {
+    echo 'Invalid image type';
+    return true;
+  }
+  
+  // build file path
+  $avi_type = $img_types[$avi_id_dec['img_type']];
+  $avi_path = ENANO_ROOT . '/' . getConfig('avatar_directory') . '/' . $avi_id_dec['uid'] . '.' . $avi_type;
+  if ( file_exists($avi_path) )
+  {
+    $avi_mod_time = @filemtime($avi_path);
+    $avi_mod_time = date('r', $avi_mod_time);
+    $avi_size = @filesize($avi_path);
+    header("Last-Modified: $avi_mod_time");
+    header("Content-Length: $avi_size");
+    header("Content-Type: image/$avi_type");
+    // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
+    header("Cache-Control: public");
+    
+    $fh = @fopen($avi_path, 'r');
+    if ( !$fh )
+    {
+      echo 'Could not open file';
+      return true;
+    }
+    
+    while ( $fd = @fread($fh, 1024) )
+    {
+      echo $fd;
+    }
+    fclose($fh);
+    
+    gzip_output();
+    
+    return true;
+  }
+  return true;
+}
+
 ?>
\ No newline at end of file
--- a/plugins/SpecialUserPrefs.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/plugins/SpecialUserPrefs.php	Sun May 04 21:57:48 2008 -0400
@@ -542,7 +542,25 @@
         $session->user_extra['user_hobbies'] = $hobbies;
         $session->user_extra['email_public'] = intval($email_public);
         
-        $q = $db->sql_query('UPDATE '.table_prefix."users SET real_name='$real_name', user_timezone = $tz_local WHERE user_id=$session->user_id;");
+        // user title
+        $user_title_col = '';
+        if ( $session->get_permissions('custom_user_title') && isset($_POST['user_title']) )
+        {
+          $user_title = trim($_POST['user_title']);
+          if ( empty($user_title) )
+          {
+            $colval = 'NULL';
+            $session->user_title = null;
+          }
+          else
+          {
+            $colval = "'" . $db->escape($user_title) . "'";
+            $session->user_title = $user_title;
+          }
+          $user_title_col = ", user_title = $colval";
+        }
+        
+        $q = $db->sql_query('UPDATE '.table_prefix."users SET real_name='$real_name', user_timezone = $tz_local{$user_title_col} WHERE user_id=$session->user_id;");
         if ( !$q )
           $db->_die();
         
@@ -580,6 +598,8 @@
           $db->free_result();
         }
         
+        generate_ranks_cache();
+        
         echo '<div class="info-box" style="margin: 0 0 10px 0;">' . $lang->get('usercp_publicinfo_msg_save_success') . '</div>';
       }
       
@@ -644,6 +664,21 @@
             <td class="row2"><?php echo $lang->get('usercp_publicinfo_field_timezone'); ?><br /><small><?php echo $lang->get('usercp_publicinfo_field_timezone_hint'); ?></small></td>
             <td class="row1"><?php echo $tz_select; ?></td>
           </tr>
+          <?php
+          if ( $session->get_permissions('custom_user_title') ):
+          ?>
+            <tr>
+              <td class="row2">
+                <?php echo $lang->get('usercp_publicinfo_field_usertitle_title'); ?><br />
+                <small><?php echo $lang->get('usercp_publicinfo_field_usertitle_hint'); ?></small>
+              </td>
+              <td class="row1">
+                <input type="text" name="user_title" value="<?php echo htmlspecialchars($session->user_title); ?>" />
+              </td>
+            </tr>
+          <?php
+          endif;
+          ?>
           <tr>
             <th class="subhead" colspan="2">
               <?php echo $lang->get('usercp_publicinfo_th_im'); ?>
--- a/plugins/admin/GroupManager.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/plugins/admin/GroupManager.php	Sun May 04 21:57:48 2008 -0400
@@ -236,6 +236,7 @@
           }
           else
           {
+            
             echo '<div class="info-box" style="margin: 0 0 10px 0;"">
                     ' . $lang->get('acpug_msg_user_added', array('username' => htmlspecialchars($_POST['edit_add_username']))) . '
                   </div>';
@@ -244,6 +245,7 @@
         else
           echo '<div class="warning-box">' . $lang->get('acpug_err_username_not_exist', array('username' => htmlspecialchars($_POST['edit_add_username']))) . '</div>';
       }
+      generate_ranks_cache();
     }
     $sg_disabled = ( $row['system_group'] == 1 ) ?
              ' value="' . $lang->get('acpug_btn_cant_delete') . '" disabled="disabled" style="color: #FF9773" ' :
--- a/plugins/admin/UserManager.php	Sat Apr 26 17:25:28 2008 -0400
+++ b/plugins/admin/UserManager.php	Sun May 04 21:57:48 2008 -0400
@@ -389,6 +389,9 @@
             }
           }
           
+          // user level updated, regenerate the ranks cache
+          generate_ranks_cache();
+          
           echo '<div class="info-box">' . $lang->get('acpum_msg_save_success') . '</div>';
         }
       }