You are here

hacked.module in Hacked! 5

The Hacked! module, shows which project have been changed since download.

We download the original project file, and hash all the files contained within, then we hash our local copies and compare. This module should never be used on a production server.

File

hacked.module
View source
<?php

/**
 * @file
 * The Hacked! module, shows which project have been changed since download.
 *
 * We download the original project file, and hash all the files contained
 * within, then we hash our local copies and compare.
 * This module should never be used on a production server.
 */
define('HACKED_CACHE_TABLE', 'cache_hacked');
define('HACKED_STATUS_UNCHECKED', 1);
define('HACKED_STATUS_UNHACKED', 2);
define('HACKED_STATUS_HACKED', 3);
define('HACKED_STATUS_DELETED', 4);
define('HACKED_STATUS_PERMISSION_DENIED', 5);

/**
 * Implementation of hook_menu().
 */
function hacked_menu($may_cache) {
  $items = array();
  if ($may_cache) {
    $items[] = array(
      'path' => 'admin/logs/hacked',
      'title' => 'Hacked',
      'description' => 'Get a code hacking report about your installed modules and themes.',
      'callback' => 'hacked_reports_hacked',
      'access' => user_access('administer site configuration'),
      'weight' => 10,
    );
  }
  else {

    // Get a list of all the projects on the site
    if (arg(0) == 'admin' && arg(1) == 'logs' && arg(2) == 'hacked' && arg(3)) {
      $items[] = array(
        'path' => 'admin/logs/hacked/' . arg(3),
        //'title callback' => 'hacked_reports_hacked_details_title',

        ///'title arguments' => array(3),
        'callback' => '_hacked_reports_hacked_details',
        'callback arguments' => array(
          arg(3),
        ),
        'access' => user_access('administer site configuration'),
        'type' => MENU_CALLBACK,
      );
      if (arg(4) == 'diff' && _hacked_get_menu_tail(6)) {
        $items[] = array(
          'path' => 'admin/logs/hacked/' . arg(3) . '/diff/' . _hacked_get_menu_tail(6),
          //'title callback' => 'hacked_reports_hacked_diff_title',

          //'title arguments' => array(3, 5),

          //'load arguments'   => array('%map', '%index'),
          'callback' => '_hacked_reports_hacked_diff',
          'callback arguments' => array(
            arg(3),
            _hacked_get_menu_tail(6),
          ),
          'access' => user_access('view diffs of changed files'),
          'type' => MENU_CALLBACK,
        );
      }
    }
  }
  return $items;
}

/**
 * Helper function for grabbing menu tail.
 */
function _hacked_get_menu_tail($limit) {

  // Extract tail as remainder of path
  $path = explode('/', $_GET['q'], $limit);
  return count($path) == $limit ? $path[$limit - 1] : FALSE;
}
function _hacked_reports_hacked_details($project_name) {
  require_once drupal_get_path('module', 'hacked') . '/hacked.details.inc';

  // Load the project:
  if ($project = hacked_project_load($project_name)) {

    // Set the page title:
    hacked_reports_hacked_details_title($project);
    return hacked_reports_hacked_details($project);
  }
  else {
    drupal_not_found();
  }
}
function _hacked_reports_hacked_diff($project_name, $tail) {
  require_once drupal_get_path('module', 'hacked') . '/hacked.diff.inc';

  // Load the project:
  if ($project = hacked_project_nocache_load($project_name)) {

    // Set the page title:
    hacked_reports_hacked_diff_title($project, $tail);
    return hacked_reports_hacked_diff($project, $tail);
  }
  else {
    drupal_not_found();
  }
}

/**
 * Menu loader for loading a project from its short name.
 *
 * In this function we call the calculate function both the update module and
 * our hacked module. This may mean we return FALSE when there is no internet
 * connection.
 *
 * @param $short_name
 *   The short name of the project to load.
 * @param $ensure_downloaded
 *   Should the project be downloaded to the local cache.
 */
function hacked_project_load($short_name, $ensure_downloaded = FALSE) {
  $available = update_status_get_available();
  $data = update_status_calculate_project_data($available);
  foreach ($data as $key => $project) {
    if ($project['short_name'] == $short_name) {
      $data_truncated = array(
        $key => $project,
      );
      $data_truncated = hacked_calculate_project_data($data_truncated, $ensure_downloaded);
      return $data_truncated[$key];
    }
  }
  return FALSE;
}

/**
 * Menu loader for loading a project from its short name.
 *
 * In this function we call the calculate function both the update module and
 * our hacked module. This may mean we return FALSE when there is no internet
 * connection.
 *
 * @param $short_name The short name of the project to load.
 */
function hacked_project_nocache_load($short_name) {
  return hacked_project_load($short_name, TRUE);
}

/**
 * Menu title callback for the hacked details page.
 */
function hacked_reports_hacked_details_title($project) {
  return t('Hacked status for @project', array(
    '@project' => $project['title'],
  ));
}

/**
 * Menu title callback for the hacked site report page.
 */
function hacked_reports_hacked_diff_title($project, $file) {
  return t('Hacked status for file @file in project @project', array(
    '@project' => $project['title'],
    '@file' => $file,
  ));
}

/**
 * Implementation of hook_flush_caches().
 */
function hacked_flush_caches() {
  return array(
    HACKED_CACHE_TABLE,
  );
}

/**
 * Implementation of the hook_theme() registry.
 */
function hacked_init() {
  require_once drupal_get_path('module', 'hacked') . '/hacked.theme.inc';
}

/**
 * Implementation of hook_perm().
 */
function hacked_perm() {
  return array(
    'view diffs of changed files',
  );
}
function hacked_reports_hacked() {

  // We're going to be borrowing heavily from the update module
  if ($available = update_status_get_available(TRUE)) {
    $data = update_status_calculate_project_data($available);
    $data = hacked_calculate_project_data($data);
    return theme('hacked_report', $data);
  }
  else {
    return theme('update_status_report', _update_status_no_data());
  }
  return 'her';
}
function hacked_process_module($project) {
  hacked_hash_project($project);
}
function hacked_calculate_project_data($projects, $ensure_downloaded = FALSE) {
  foreach ($projects as $project_key => $project) {

    //if ($project['install_type'] == 'official') {
    $projects[$project_key]['hacked_status'] = HACKED_STATUS_UNCHECKED;

    // Go get the hashes of the clean copy of the installed version:
    $projects[$project_key]['clean_hashes'] = hacked_hash_project($project, $ensure_downloaded);

    // If we got some hashes, let's compare it with the local copy:
    if ($projects[$project_key]['clean_hashes']) {
      hacked_hash_local($projects[$project_key]);
      $hacked_count = 0;
      $deleted_count = 0;
      $unreadable_count = 0;

      // Now do the comparison:
      foreach ($projects[$project_key]['clean_hashes'] as $file => $hash) {

        // Has the file been deleted:
        if (!isset($projects[$project_key]['local_hashes'][$file])) {
          $deleted_count++;
          $projects[$project_key]['hacked_results'][$file] = HACKED_STATUS_DELETED;
        }
        else {

          // If we can't read the file, mark it as permission denied
          if (!is_readable(hacked_find_local_project_directory($projects[$project_key]) . '/' . $file)) {
            $unreadable_count++;
            $projects[$project_key]['hacked_results'][$file] = HACKED_STATUS_PERMISSION_DENIED;
          }
          elseif ($projects[$project_key]['local_hashes'][$file] != $hash) {
            $hacked_count++;
            $projects[$project_key]['hacked_results'][$file] = HACKED_STATUS_HACKED;
          }
          else {
            $projects[$project_key]['hacked_results'][$file] = HACKED_STATUS_UNHACKED;
          }
        }
      }

      // Record aggregate stats
      $projects[$project_key]['changed_count'] = $hacked_count;
      $projects[$project_key]['deleted_count'] = $deleted_count;
      $projects[$project_key]['unreadable_count'] = $unreadable_count;
      if ($hacked_count) {
        $projects[$project_key]['hacked_status'] = HACKED_STATUS_HACKED;
      }
      else {
        $projects[$project_key]['hacked_status'] = HACKED_STATUS_UNHACKED;
      }
    }

    //}
  }
  return $projects;
}
function hacked_hash_project($project, $ensure_downloaded = FALSE) {
  if ($project['project_type'] == 'module' || $project['project_type'] == 'theme' || $project['project_type'] == 'core') {
    if (isset($project['existing_version']) && isset($project['releases'][$project['existing_version']])) {
      $this_release = $project['releases'][$project['existing_version']];

      // Can we get this from the cache?
      if ($ensure_downloaded || !hacked_project_hashes_are_cached($project['project_type'], $project['short_name'], $project['existing_version'])) {
        $dir = hacked_download_release($this_release['download_link'], $project['project_type'], $project['short_name'], $project['existing_version']);
      }
      $hashed = hacked_release_generate_hashes_cached($project['project_type'], $project['short_name'], $project['existing_version']);
      return $hashed;
    }
  }
}
function hacked_hash_local(&$project) {

  // Are there other types of project we should handle?
  if ($project['project_type'] == 'module' || $project['project_type'] == 'theme' || $project['project_type'] == 'core') {
    if ($dir = hacked_find_local_project_directory($project)) {
      $project['local_hashes'] = hacked_scan_directory_generate_hashes($dir, TRUE);
    }
    else {
      $project['local_hashes'] = array();
    }
  }
}

/**
 * Return the location of the installed project.
 *
 * As drupal modules do not need to be named the same as the projects they are
 * part of we need to be a little smarter about how we find the project
 * directory to start hashing in.
 */
function hacked_find_local_project_directory($project) {

  // Do we have at least some modules to check for:
  if (!is_array($project['modules']) || !count($project['modules'])) {
    return FALSE;
  }

  // If this project is drupal it, we need to handle it specially
  if ($project['project_type'] != 'core') {
    $include = array_shift(array_keys($project['modules']));
    $include_type = $project['project_type'];
  }
  else {

    // Just use the system module to find where we've installed drupal
    $include = 'system';
    $include_type = 'module';
  }
  $path = drupal_get_path($include_type, $include);

  // Now we need to find the path of the info file in the downloaded package:
  // TODO: Can replace this with using the info file stuff we put there earlier:
  $temp = '';
  foreach ($project['clean_hashes'] as $file => $hash) {
    if (strpos($file, "{$include}.info") !== FALSE) {

      // TODO: Replace this with a regular expression
      $temp = str_replace("{$include}.info", '', $file);
      break;
    }
  }

  // How many '/' were in that path:
  $slash_count = substr_count($temp, '/');
  $back_track = str_repeat('/..', $slash_count);
  return realpath($path . $back_track);
}

/**
 * A standard method of forming the path name of the local copy of a project
 */
function hacked_release_form_path_name($type, $name, $version) {
  $dir = file_directory_temp() . "/hacked-cache/{$type}/{$name}";

  // Build the destination folder tree if it doesn't already exists.
  if (!file_check_directory($dir, FILE_CREATE_DIRECTORY) && !mkdir($dir, 0775, TRUE)) {
    watchdog('hacked', 'Failed to create temp directory: %dir', array(
      '%dir' => $dir,
    ), WATCHDOG_ERROR);
    return FALSE;
  }
  return file_create_path(file_directory_temp() . "/hacked-cache/{$type}/{$name}/{$version}");
}
function hacked_download_release($release_url, $type, $short_name, $version) {

  // Compute the path where we'll store this release:
  $dir = hacked_release_form_path_name($type, $short_name, $version);

  // If our directory already exists, we can just return the path to this cached version
  if (file_exists($dir)) {
    return $dir;
  }

  // We've not downloaded this release before:
  // Let's try to download it:
  $request = drupal_http_request($release_url);

  // If we downloaded it, try to unpack it:
  if ($request->code == 200) {

    // Build the destination folder tree if it doesn't already exists.
    if (!file_check_directory($dir, FILE_CREATE_DIRECTORY) && !mkdir($dir, 0775, TRUE)) {
      watchdog('hacked', 'Failed to create temp directory: %dir', array(
        '%dir' => $dir,
      ), WATCHDOG_ERROR);
      return FALSE;
    }

    // Save the tarball someplace:
    $project_path = file_create_path($dir . '/' . basename($release_url));
    file_save_data($request->data, $project_path);
    shell_exec("cd {$dir}; tar -zxf " . basename($project_path));
    file_delete($project_path);

    // If we unpacked it, return the path:
    return $dir;
  }

  // Something went wrong:
  return FALSE;
}
function hacked_release_generate_hashes($type, $short_name, $version) {
  $dir = hacked_release_form_path_name($type, $short_name, $version);

  // More special handling for core:
  if ($type != 'core') {
    $module_dir = $dir . "/{$short_name}";
  }
  else {
    $module_dir = $dir . "/{$short_name}-{$version}";
  }

  // Scan the directory for files:
  $hashes = hacked_scan_directory_generate_hashes($module_dir);
  return $hashes;
}
function hacked_project_hashes_are_cached($type, $short_name, $version) {
  static $cached = array();

  // Return from the static cache if we can:
  if (!isset($cached[$type][$short_name][$version])) {

    // Return form the cache system if we can:
    $key = "hacked:clean:hashes:{$type}:{$short_name}:{$version}";
    $cache = cache_get($key, HACKED_CACHE_TABLE);
    if ($cache && isset($cache->data)) {
      $cached[$type][$short_name][$version] = TRUE;
    }
    else {
      $cached[$type][$short_name][$version] = FALSE;
    }
  }
  return $cached[$type][$short_name][$version];
}
function hacked_release_generate_hashes_cached($type, $short_name, $version) {
  static $cached = array();

  // Return from the static cache if we can:
  if (isset($cached[$type][$short_name][$version])) {
    return $cached[$type][$short_name][$version];
  }

  // Return form the cache system if we can:
  $key = "hacked:clean:hashes:{$type}:{$short_name}:{$version}";
  $cache = cache_get($key, HACKED_CACHE_TABLE);
  if ($cache && isset($cache->data)) {
    return unserialize($cache->data);
  }

  // Otherwise pass through to the actual function:
  $cached[$type][$short_name][$version] = hacked_release_generate_hashes($type, $short_name, $version);

  // Save into the cache table:
  cache_set($key, HACKED_CACHE_TABLE, serialize($cached[$type][$short_name][$version]));

  // Return the hashes:
  return $cached[$type][$short_name][$version];
}

/**
 * Hash the contents of a directory, optionally retrieving from cache.
 *
 * @param $directory The directory to hash.
 * @param $cache Can I use a cache for the files in this directory?
 */
function hacked_scan_directory_generate_hashes($directory, $cache = FALSE) {
  $timestamps = array();

  // Try to load some details from the cache:
  if ($cache) {
    $key = "hacked:directory:timestamps:{$directory}";

    // The key could get really long, guard against that:
    if (strlen($key) > 255) {
      $key = "hacked:directory:timestamps:" . sha1($directory);
    }
    $cache_ob = cache_get($key, HACKED_CACHE_TABLE);
    if ($cache_ob && isset($cache_ob->data)) {
      $timestamps = unserialize($cache_ob->data);
    }
  }
  $hashes = array();
  $files = hacked_file_scan_directory($directory, '/.*/', array(
    '.',
    '..',
    'CVS',
    '.svn',
    '.git',
  ));
  foreach ($files as $file) {
    $filename = str_replace($directory . '/', '', $file->filename);

    // Check the timestamp if available:
    if (isset($timestamps[$file->filename]) && filemtime($file->filename) == $timestamps[$file->filename]['timestamp']) {
      $hashes[$filename] = $timestamps[$file->filename]['hash'];
    }
    else {
      $timestamps[$file->filename]['hash'] = $hashes[$filename] = sha1_file($file->filename);
      $timestamps[$file->filename]['timestamp'] = filemtime($file->filename);
    }
  }
  if ($cache) {
    cache_set($key, HACKED_CACHE_TABLE, serialize($timestamps));
  }
  return $hashes;
}

/**
 * Determine if a file is a binary file.
 *
 * Taken from: http://www.ultrashock.com/forums/server-side/checking-if-a-file-is-binary-98391.html
 * and then tweaked in: http://drupal.org/node/760362.
 */
function hacked_file_is_binary($file) {
  if (file_exists($file)) {
    if (!is_file($file)) {
      return 0;
    }
    if (!is_readable($file)) {
      return 1;
    }
    $fh = fopen($file, "r");
    $blk = fread($fh, 512);
    fclose($fh);
    clearstatcache();
    return 0 or substr_count($blk, "^\r\n") / 512 > 0.3 or substr_count($blk, "^ -~") / 512 > 0.3 or substr_count($blk, "\0") > 0;
  }
  return 0;
}

/**
 * Hacked! version of the core function, can return hidden files too.
 *
 * @see file_scan_directory().
 */
function hacked_file_scan_directory($dir, $mask, $nomask = array(
  '.',
  '..',
  'CVS',
), $callback = 0, $recurse = TRUE, $key = 'filename', $min_depth = 0, $depth = 0) {
  $key = in_array($key, array(
    'filename',
    'basename',
    'name',
  )) ? $key : 'filename';
  $files = array();
  if (is_dir($dir) && ($handle = opendir($dir))) {
    while (FALSE !== ($file = readdir($handle))) {
      if (!in_array($file, $nomask)) {
        if (is_dir("{$dir}/{$file}") && $recurse) {

          // Give priority to files in this folder by merging them in after any subdirectory files.
          $files = array_merge(hacked_file_scan_directory("{$dir}/{$file}", $mask, $nomask, $callback, $recurse, $key, $min_depth, $depth + 1), $files);
        }
        elseif ($depth >= $min_depth && preg_match($mask, $file)) {

          // Always use this match over anything already set in $files with the same $$key.
          $filename = "{$dir}/{$file}";
          $basename = basename($file);
          $name = substr($basename, 0, strrpos($basename, '.'));
          $files[${$key}] = new stdClass();
          $files[${$key}]->filename = $filename;
          $files[${$key}]->basename = $basename;
          $files[${$key}]->name = $name;
          if ($callback) {
            $callback($filename);
          }
        }
      }
    }
    closedir($handle);
  }
  return $files;
}

Functions

Namesort descending Description
hacked_calculate_project_data
hacked_download_release
hacked_file_is_binary Determine if a file is a binary file.
hacked_file_scan_directory Hacked! version of the core function, can return hidden files too.
hacked_find_local_project_directory Return the location of the installed project.
hacked_flush_caches Implementation of hook_flush_caches().
hacked_hash_local
hacked_hash_project
hacked_init Implementation of the hook_theme() registry.
hacked_menu Implementation of hook_menu().
hacked_perm Implementation of hook_perm().
hacked_process_module
hacked_project_hashes_are_cached
hacked_project_load Menu loader for loading a project from its short name.
hacked_project_nocache_load Menu loader for loading a project from its short name.
hacked_release_form_path_name A standard method of forming the path name of the local copy of a project
hacked_release_generate_hashes
hacked_release_generate_hashes_cached
hacked_reports_hacked
hacked_reports_hacked_details_title Menu title callback for the hacked details page.
hacked_reports_hacked_diff_title Menu title callback for the hacked site report page.
hacked_scan_directory_generate_hashes Hash the contents of a directory, optionally retrieving from cache.
_hacked_get_menu_tail Helper function for grabbing menu tail.
_hacked_reports_hacked_details
_hacked_reports_hacked_diff

Constants

Namesort descending Description
HACKED_CACHE_TABLE @file The Hacked! module, shows which project have been changed since download.
HACKED_STATUS_DELETED
HACKED_STATUS_HACKED
HACKED_STATUS_PERMISSION_DENIED
HACKED_STATUS_UNCHECKED
HACKED_STATUS_UNHACKED