diff --git a/includes/admin.form.inc b/includes/admin.form.inc index d34bd5d5..b0bb0642 100644 --- a/includes/admin.form.inc +++ b/includes/admin.form.inc @@ -55,6 +55,12 @@ function islandora_repository_admin(array $form, array &$form_state) { '#description' => t('The PID of the Root Collection Object'), '#required' => TRUE, ), + 'islandora_use_datastream_cache_headers' => array( + '#type' => 'checkbox', + '#title' => t('Generate/parse datastream HTTP cache headers'), + '#description' => t('HTTP caching can reduce network traffic, by allowing clients to used cached copies.'), + '#default_value' => variable_get('islandora_use_datastream_cache_headers', TRUE), + ), ), 'islandora_namespace' => array( '#type' => 'fieldset', diff --git a/includes/breadcrumb.inc b/includes/breadcrumb.inc index 872eb70a..05ab1bac 100644 --- a/includes/breadcrumb.inc +++ b/includes/breadcrumb.inc @@ -27,6 +27,8 @@ function islandora_get_breadcrumbs($object) { $breadcrumbs = islandora_get_breadcrumbs_recursive($object->id, $object->repository); array_pop($breadcrumbs); + $context = 'islandora'; + drupal_alter('islandora_breadcrumbs', $breadcrumbs, $context); return $breadcrumbs; } diff --git a/includes/datastream.inc b/includes/datastream.inc index 585fa2f5..1d506e3d 100644 --- a/includes/datastream.inc +++ b/includes/datastream.inc @@ -46,8 +46,6 @@ function islandora_view_datastream(AbstractDatastream $datastream, $download = F } } - header_remove('Cache-Control'); - header_remove('Expires'); header('Content-type: ' . $datastream->mimetype); if ($datastream->controlGroup == 'M' || $datastream->controlGroup == 'X') { header('Content-length: ' . $datastream->size); @@ -59,13 +57,164 @@ function islandora_view_datastream(AbstractDatastream $datastream, $download = F $filename = $datastream->label . '.' . $extension; header("Content-Disposition: attachment; filename=\"$filename\""); } + + $cache_check = islandora_view_datastream_cache_check($datastream); + if ($cache_check !== 200) { + if ($cache_check === 304) { + header('HTTP/1.1 304 Not Modified'); + } + elseif ($cache_check === 412) { + header('HTTP/1.0 412 Precondition Failed'); + } + } + islandora_view_datastream_set_cache_headers($datastream); + drupal_page_is_cacheable(FALSE); // Try not to load the file into PHP memory! - ob_end_flush(); - $datastream->getContent('php://output'); + // Close and flush ALL the output buffers! + while (@ob_end_flush()) { + }; + + // New content needed. + if ($cache_check === 200) { + $datastream->getContent('php://output'); + } exit(); } +/** + * Parse "etags" from HTTP If-Match or If-None-Match headers. + * + * Parses from the CSV-like struture supported by HTTP headers into an array, + * so `"asdf", "fdsa", W/"2132"` should become an array containing the strings: + * - asdf + * - fdsa + * - 2132 + * + * @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.24 + * @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26 + * + * @param string $header_value + * The value from the headers. + * + * @return array + * An array containing all the etags present. + */ +function islandora_parse_http_match_headers($header_value) { + $matches = array(); + // Match the CSV-like structure supported by the HTTP headers. + $count = preg_match_all('/(((W\/)?("?)(\*|.+?)\4)(, +)?)/', $header_value, $matches); + // The fifth sub-expression/group is which will contain the etags. + return $matches[5]; +} + +/** + * Validate cache headers. + * + * @param AbstractDatastream $datastream + * The datastream for which to check the request headers against. + * + * @return int + * An integer representing the HTTP response code. One of: + * - 200: Proceed as normal. (Full download). + * - 304: Resource hasn't changed; pass cache validation. + * - 412: Resource has changed; fail cache validation. + * + * @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html + */ +function islandora_view_datastream_cache_check(AbstractDatastream $datastream) { + if (!variable_get('islandora_use_datastream_cache_headers', TRUE)) { + return 200; + } + + // Let's assume that if we get here, we'll be able to complete the request. + $return = 200; + + if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { + $modified_since = DateTime::createFromFormat('D, d M Y H:i:s e', $_SERVER['HTTP_IF_MODIFIED_SINCE']); + if ($datastream->createdDate->getTimestamp() - $modified_since->getTimestamp() > 0) { + // Changed! + return $return; + } + else { + $return = 304; + } + } + if ($return === 200 && isset($_SERVER['HTTP_IF_UNMODIFIED_SINCE'])) { + $unmodified_since = DateTime::createFromFormat('D, d M Y H:i:s e', $_SERVER['HTTP_IF_UNMODIFIED_SINCE']); + if ($datastream->createdDate->getTimestamp() !== $unmodified_since->getTimestamp()) { + // Changed! + $return = 412; + } + else { + return $return; + } + } + + // Only consider Etags we have provided. + if (isset($datastream->checksum)) { + $tags = array(); + foreach ($datastream as $offset => $version) { + if (isset($version->checksum)) { + $tags[$offset] = $version->checksum; + } + } + + if ($return === 200 && isset($_SERVER['HTTP_IF_MATCH'])) { + $request_tags = islandora_parse_http_match_headers($_SERVER['HTTP_IF_MATCH']); + if (in_array('*', $request_tags) || count(array_intersect($tags, $request_tags)) > 0) { + // There's a match... Let things go ahead. + return $return; + } + else { + $return = 412; + } + } + if (in_array($return, array(200, 304), TRUE) && isset($_SERVER['HTTP_IF_NONE_MATCH'])) { + $request_tags = islandora_parse_http_match_headers($_SERVER['HTTP_IF_NONE_MATCH']); + if (in_array('*', $request_tags) || count(array_intersect($tags, $request_tags)) > 0) { + $return = 304; + } + else { + $return = 200; + } + } + } + + return $return; +} + +/** + * Set various HTTP headers for caching. + * + * @param AbstractDatastream $datastream + * The datastream being viewed/downloaded. + */ +function islandora_view_datastream_set_cache_headers(AbstractDatastream $datastream) { + if (variable_get('islandora_use_datastream_cache_headers', TRUE)) { + // Force cache revalidation. + header('Expires: Sun, 19 Nov 1978 05:00:00 GMT'); + $cache_control = array(); + if ($datastream->parent->repository->api->connection->username == 'anonymous') { + $cache_control[] = 'public'; + } + else { + $cache_control[] = 'private'; + } + $cache_control[] = 'must-revalidate'; + $cache_control[] = 'max-age=0'; + header('Cache-Control: ' . implode(', ', $cache_control)); + header('Last-Modified: ' . $datastream->createdDate->format('D, d M Y H:i:s \G\M\T')); + if (isset($datastream->checksum)) { + header("Etag: \"{$datastream->checksum}\""); + } + } + else { + header_remove('Cache-Control'); + header_remove('Expires'); + } +} + /** * Get the human readable size of the given datastream. * diff --git a/includes/solution_packs.inc b/includes/solution_packs.inc index a80c41a5..7cf0351a 100644 --- a/includes/solution_packs.inc +++ b/includes/solution_packs.inc @@ -745,7 +745,9 @@ function islandora_get_viewer($params = NULL, $variable_id = NULL, $fedora_objec $viewer_id = islandora_get_viewer_id($variable_id); if ($viewer_id AND $params !== NULL) { $callback = islandora_get_viewer_callback($viewer_id); - return $callback($params, $fedora_object); + if (function_exists($callback)) { + return $callback($params, $fedora_object); + } } } return FALSE; diff --git a/islandora.api.php b/islandora.api.php index 2e6ef831..39c6e454 100644 --- a/islandora.api.php +++ b/islandora.api.php @@ -86,7 +86,7 @@ function hook_CMODEL_PID_islandora_view_object_alter(&$object, &$rendered) { } /** - * Generate an object's management display. + * Generate an object's datastreams management display. * * @param AbstractObject $object * A Tuque FedoraObject @@ -98,7 +98,7 @@ function hook_islandora_edit_object($object) { } /** - * Generate an object's management display for the given content model. + * Generate an object's datastreams management display based on content model. * * Content models PIDs have colons and hyphens changed to underscores, to * create the hook name. @@ -113,7 +113,7 @@ function hook_CMODEL_PID_islandora_edit_object($object) { } /** - * Allow management display output to be altered. + * Allow datastreams management display output to be altered. * * @param AbstractObject $object * A Tuque FedoraObject @@ -650,6 +650,7 @@ function hook_CMODEL_PID_islandora_derivative() { } /** + * Retrieves PIDS of related objects for property updating. * * @param AbstractObject $object @@ -663,3 +664,14 @@ function hook_islandora_update_related_objects_properties(AbstractObject $object } return $pids_to_return; } +/** + * Alters breadcrumbs used on Solr search results and within Islandora views. + * + * @param array $breadcrumbs + * Breadcrumbs array to be altered by reference. Each element is markup. + * @param string $context + * Where the alter is originating from for distinguishing. + */ +function hook_islandora_breadcrumbs_alter(&$breadcrumbs, $context) { + +} diff --git a/islandora.info b/islandora.info index 4b48bc29..36ed0dff 100644 --- a/islandora.info +++ b/islandora.info @@ -18,5 +18,6 @@ files[] = tests/ingest.test files[] = tests/hooked_access.test files[] = tests/islandora_manage_permissions.test files[] = tests/datastream_versions.test +files[] = tests/datastream_cache.test files[] = tests/derivatives.test php = 5.3 diff --git a/islandora.module b/islandora.module index a81de4ec..b02bdc61 100644 --- a/islandora.module +++ b/islandora.module @@ -122,12 +122,21 @@ function islandora_menu() { 'access callback' => 'islandora_object_access_callback', 'access arguments' => array(ISLANDORA_VIEW_OBJECTS, 2), ); - $items['islandora/object/%islandora_object/print'] = array( + $items['islandora/object/%islandora_object/print_object'] = array( 'page callback' => 'islandora_printer_object', 'page arguments' => array(2), 'type' => MENU_NORMAL_ITEM, - 'access callback' => 'islandora_object_access_callback', + 'access callback' => 'islandora_object_access', + 'access arguments' => array(ISLANDORA_VIEW_OBJECTS, 2), + ); + $items['islandora/object/%islandora_object/print'] = array( + 'title' => 'Print Object', + 'page callback' => 'islandora_print_object', + 'page arguments' => array(2), + 'type' => MENU_CALLBACK, + 'access callback' => 'islandora_object_access', 'access arguments' => array(ISLANDORA_VIEW_OBJECTS, 2), + 'load arguments' => array(2), ); $items['islandora/object/%islandora_object/view'] = array( 'title' => 'View', @@ -154,13 +163,11 @@ function islandora_menu() { ISLANDORA_INGEST, ), 2), ); - $items['islandora/object/%islandora_object/manage/overview'] = array( 'title' => 'Overview', 'type' => MENU_DEFAULT_LOCAL_TASK, 'weight' => -20, ); - $items['islandora/object/%islandora_object/manage/datastreams'] = array( 'title' => 'Datastreams', 'type' => MENU_LOCAL_TASK, @@ -175,7 +182,6 @@ function islandora_menu() { ), 2), 'weight' => -10, ); - $items['islandora/object/%islandora_object/manage/properties'] = array( 'title' => 'Properties', 'page callback' => 'drupal_get_form', @@ -374,8 +380,13 @@ function islandora_theme() { 'file' => 'includes/solution_packs.inc', 'render element' => 'form', ), - // Print object view. + // Print used by the clipper. 'islandora_object_print' => array( + 'file' => 'theme/theme.inc', + 'variables' => array('object' => NULL, 'content' => array()), + ), + // Print object view, prints islandora objects. + 'islandora_object_print_object' => array( 'file' => 'theme/theme.inc', 'template' => 'theme/islandora-object-print', 'variables' => array( @@ -477,6 +488,22 @@ function islandora_permission() { ); } +/** + * Renders the print page for the given object. + * + * Modules can either implement preprocess functions to append content onto the + * 'content' variable, or override the display by providing a theme suggestion. + * + * @param AbstractObject $object + * The object. + * + * @return array + * A renderable array. + */ +function islandora_print_object(AbstractObject $object) { + drupal_set_title($object->label); + return theme('islandora_object_print', array('object' => $object)); +} /** * Implements hook_forms(). */ @@ -848,7 +875,12 @@ function islandora_view_object(AbstractObject $object) { drupal_add_js(array( 'islandora' => array( +<<<<<<< HEAD 'print_link' => 'islandora/object/' . $object->id . '/print')), array('type' => 'setting')); +======= + 'print_link' => 'islandora/object/' . $object->id . '/print_object')), + array('type' => 'setting')); +>>>>>>> dd50626677e1acbc5fe69daf25d13cfa65dae7af drupal_add_js($path . '/js/add_print.js'); drupal_set_title($object->label); @@ -977,7 +1009,7 @@ function islandora_default_islandora_printer_object($object, $alter) { } $variables = isset($dc_object) ? $dc_object->asArray() : array(); - $output = theme('islandora_object_print', array( + $output = theme('islandora_object_print_object', array( 'object' => $object, 'dc_array' => $variables, 'islandora_content' => $alter)); diff --git a/tests/datastream_cache.test b/tests/datastream_cache.test new file mode 100644 index 00000000..8118c08a --- /dev/null +++ b/tests/datastream_cache.test @@ -0,0 +1,160 @@ + 'Datastream Cache Headers', + 'description' => 'Check our headers work as we expect them to.', + 'group' => 'Islandora', + ); + } + + /** + * Creates an admin user and a connection to a fedora repository. + * + * @see IslandoraWebTestCase::setUp() + */ + public function setUp() { + parent::setUp(); + $this->repository = $this->admin->repository; + $this->purgeTestObjects(); + } + + /** + * Free any objects/resources created for this test. + * + * @see IslandoraWebTestCase::tearDown() + */ + public function tearDown() { + $this->purgeTestObjects(); + parent::tearDown(); + } + + /** + * Purge any objects created by the test's in this class. + */ + public function purgeTestObjects() { + $objects = array( + 'test:test', + ); + foreach ($objects as $object) { + try { + $object = $this->repository->getObject($object); + $this->repository->purgeObject($object->id); + } + catch (Exception $e) { + // Meh... Either it didn't exist or the purge failed. + } + } + } + + /** + * Create our test object. + */ + protected function createTestObject() { + $object = $this->repository->constructObject('test:test'); + $object->label = 'Test object'; + $object->models = 'test:model'; + $datastream = $object->constructDatastream('asdf', 'M'); + $datastream->label = 'datastream of doom'; + $datastream->mimetype = 'text/plain'; + $datastream->content = 'And then things happened.'; + $datastream->checksumType = 'SHA-1'; + $object->ingestDatastream($datastream); + $this->repository->ingestObject($object); + return $object; + } + + /** + * Test HTTP cache headers. + */ + public function testCacheHeaders() { + $object = $this->createTestObject(); + $datastream = $object['asdf']; + + $user = $this->drupalCreateUser(array(ISLANDORA_VIEW_OBJECTS)); + $this->drupalLogin($user); + + // Test If-Modified-Since. + $result = $this->drupalGet("islandora/object/{$object->id}/datastream/{$datastream->id}/view", array(), array( + 'If-Modified-Since: ' . $datastream->createdDate->format('D, d M Y H:i:s \G\M\T'), + )); + $this->assertResponse(304); + $result = $this->drupalGet("islandora/object/{$object->id}/datastream/{$datastream->id}/view", array(), array( + 'If-Modified-Since: ' . $datastream->createdDate->sub(new DateInterval('P1M'))->format('D, d M Y H:i:s \G\M\T'), + )); + $this->assertResponse(200); + + // Test If-Unmodified-Since. + $result = $this->drupalGet("islandora/object/{$object->id}/datastream/{$datastream->id}/view", array(), array( + 'If-Unmodified-Since: ' . $datastream->createdDate->format('D, d M Y H:i:s \G\M\T'), + )); + $this->assertResponse(200); + $result = $this->drupalGet("islandora/object/{$object->id}/datastream/{$datastream->id}/view", array(), array( + 'If-Unmodified-Since: ' . $datastream->createdDate->sub(new DateInterval('P1M'))->format('D, d M Y H:i:s \G\M\T'), + )); + $this->assertResponse(412); + + // Test If-Match. + $result = $this->drupalGet("islandora/object/{$object->id}/datastream/{$datastream->id}/view", array(), array( + format_string('If-Match: "!checksum"', array( + '!checksum' => $datastream->checksum, + )), + )); + $this->assertResponse(200); + $result = $this->drupalGet("islandora/object/{$object->id}/datastream/{$datastream->id}/view", array(), array( + format_string('If-Match: "!checksum"', array( + '!checksum' => 'dont-match' . $datastream->checksum, + )), + )); + $this->assertResponse(412); + + // Test If-None-Match. + $result = $this->drupalGet("islandora/object/{$object->id}/datastream/{$datastream->id}/view", array(), array( + format_string('If-None-Match: "!checksum"', array( + '!checksum' => $datastream->checksum, + )), + )); + $this->assertResponse(304); + $result = $this->drupalGet("islandora/object/{$object->id}/datastream/{$datastream->id}/view", array(), array( + format_string('If-None-Match: "!checksum"', array( + '!checksum' => 'dont-match' . $datastream->checksum, + )), + )); + $this->assertResponse(200); + + // Test combination of If-None-Match and If-Modified-Since + $result = $this->drupalGet("islandora/object/{$object->id}/datastream/{$datastream->id}/view", array(), array( + 'If-Modified-Since: ' . $datastream->createdDate->format('D, d M Y H:i:s \G\M\T'), + format_string('If-None-Match: "!checksum"', array( + '!checksum' => $datastream->checksum, + )), + )); + $this->assertResponse(304); + $result = $this->drupalGet("islandora/object/{$object->id}/datastream/{$datastream->id}/view", array(), array( + 'If-Modified-Since: ' . $datastream->createdDate->format('D, d M Y H:i:s \G\M\T'), + format_string('If-None-Match: "!checksum"', array( + '!checksum' => 'dont-match' . $datastream->checksum, + )), + )); + $this->assertResponse(200); + $result = $this->drupalGet("islandora/object/{$object->id}/datastream/{$datastream->id}/view", array(), array( + 'If-Modified-Since: ' . $datastream->createdDate->sub(new DateInterval('P1M'))->format('D, d M Y H:i:s \G\M\T'), + format_string('If-None-Match: "!checksum"', array( + '!checksum' => $datastream->checksum, + )), + )); + $this->assertResponse(200); + } +}