__('Page Render', __FILE__), // Module Title 'summary' => __('Adds a render method to Page and caches page output.', __FILE__), // Module Summary 'version' => 103, 'permanent' => true, 'singular' => true, 'autoload' => true, ); } /** * Instance of Config, cached wire('config') * */ protected $config; /** * Stack of pages when rendering recursively * */ protected $pageStack = array(); /** * Keeps track of recursion level when rendering recursively * * Used to determine when pageStack should be maintained * */ protected $renderRecursionLevel = 0; /** * Initialize the hooks * */ public function init() { $this->config = $this->fuel('config'); $this->addHook('Page::render', $this, 'renderPage'); $this->pages->addHookAfter('save', $this, 'clearCacheFile'); $this->pages->addHookAfter('delete', $this, 'clearCacheFile'); // $this->addHookAfter('Fieldtype::savePageField', $this, 'savePageField'); // removed, see note in commented function } /** * If $page->save($field) was called (which calls Fieldtype::savePageField), then clear out the Page's cache * * Removed because too much compromise in speed, but kept here for reference in case we determine a faster solution. * public function savePageField($event) { $page = $event->arguments[0]; if(!$page->template->cache_time) return; $this->getCacheFile($page)->remove(); } */ /** * Is the page render cache allowed for this request? * * @param Page $page * @return bool * */ public function isCacheAllowed($page) { if(!$page->template || !$page->template->cache_time) return false; if(!$this->user->isGuest()) { if(!$page->template->useCacheForUsers) return false; if($page->editable()) return false; } $allowed = true; if(count($_GET) && $page->template->noCacheGetVars) { $vars = explode(' ', $page->template->noCacheGetVars); foreach($vars as $name) if($name && isset($_GET[$name])) $allowed = false; } if($allowed && count($_POST) && $page->template->noCachePostVars) { $vars = explode(' ', $page->template->noCachePostVars); foreach($vars as $name) if($name && isset($_POST[$name])) $allowed = false; } // NOTE: other modules may set a session var of PageRenderNoCachePage containing a page ID to temporarily // remove caching for some page, if necessary. if($this->session->PageRenderNoCachePage && $this->session->PageRenderNoCachePage == $page->id) $allowed = false; return $allowed; } /** * Get a CacheFile object corresponding to this Page * * Note that this does not check if the page is cachable. This is so that if a cachable setting changes the cache can still be removed. * * @param Page $page * @param array $options * @return CacheFile * @throws WireException * */ public function getCacheFile(Page $page, array $options = array()) { $defaults = array( 'prependFile' => '', 'appendFile' => '', 'filename' => '', ); $options = array_merge($defaults, $options); $path = $this->config->paths->cache . self::cacheDirName . "/"; $id = $page->id; $cacheTime = $page->template->cache_time; if(!is_dir($path)) { if(!@mkdir($path)) throw new WireException("Cache path does not exist: $path"); if($this->config->chmodDir) chmod($path, octdec($this->config->chmodDir)); } $cacheFile = new CacheFile($path, $id, $cacheTime); if($this->config->chmodFile) $cacheFile->setChmodFile($this->config->chmodFile); if($this->config->chmodDir) $cacheFile->setChmodDir($this->config->chmodDir); if($this->wire('page') === $page) { $secondaryID = ''; $pageNum = $this->input->pageNum; $urlSegments = $this->input->urlSegments; if(count($urlSegments)) { foreach($urlSegments as $urlSegment) { $secondaryID .= $this->sanitizer->pageName($urlSegment) . '+'; } } if($options['prependFile'] || $options['appendFile'] || $options['filename']) { $secondaryID .= md5($options['prependFile'] . '+' . $options['appendFile'] . '+' . $options['filename']) . '+'; } if($pageNum > 1) $secondaryID .= "page{$pageNum}"; $secondaryID = rtrim($secondaryID, '+'); if(wire('languages')) { $language = wire('user')->language; if($language && $language->id && !$language->isDefault()) $secondaryID .= "_" . $language->id; } if($secondaryID) $cacheFile->setSecondaryID($secondaryID); } return $cacheFile; } /** * Hook to clear the cache file after a Pages::save or Pages::delete call * */ public function clearCacheFile($event) { $page = $event->arguments[0]; if(!$page->template->cache_time) return; $cacheExpire = $page->template->cacheExpire; if($cacheExpire == Template::cacheExpireNone) { if($event->method == 'delete') $cacheExpire = Template::cacheExpirePage; else return; } $cacheFile = $this->getCacheFile($page); if($cacheExpire == Template::cacheExpireSite) { // expire entire cache $cacheFile->expireAll(); if($this->config->debug) $this->message("Expired page cache for entire site"); } else if($cacheExpire == Template::cacheExpireParents || $cacheExpire == Template::cacheExpireSpecific) { // expire specific pages or parents $selected = array(); if($cacheExpire == Template::cacheExpireParents) { $selected = $page->parents; } else if(is_array($page->template->cacheExpirePages) && count($page->template->cacheExpirePages)) { $selected = $this->fuel('pages')->getById($page->template->cacheExpirePages); } foreach($selected as $p) { if(!$p->template->cache_time) continue; $cf = $this->getCacheFile($p); if($cf->exists()) $cf->remove(); if($this->config->debug) $this->message("Cleared cache file: $cf"); } } if($cacheFile->exists()) { $cacheFile->remove(); if($this->config->debug) $this->message("Cleared cache file: $cacheFile"); } } /** * Return a string with the rendered output of this Page (per it's Template) * * If the page's template has caching enabled, then this method will return a cached page render, when valid, * or save a new cache. Caches are only saved on guest users. * * #param array|string options Array of options, or filename (string) to render. Options [all optional] may be: * forceBuildCache: If true, the cache will be re-created for this page, regardless of whether it's expired or not. (default=false) * allowCache: Allow cache to be used when template settings ask for it. (default=true) * filename: Filename to render, optionally relative to /site/templates/. Absolute paths must resolve somewhere in PW's install. (default=blank) * prependFile: Filename to prepend to output, must be in /site/templates/. (default=$config->prependTemplateFile) * prependFiles: Array of additional filenames to prepend to output, must be relative to /site/templates/ (default=array($page->template->prependFile)) * appendFile: Filename to append to output, must be in /site/templates/. (default=$config->appendTemplateFile) * appendFiles: Array of additional filenames to append to output, must be relative to /site/templates/ (default=array($page->template->appendFile)) * pageStack: An array of pages, when recursively rendering. Used internally. You can examine it but not change it. * * #param array $options * If you specified a filename for the first option, you may use the options array mentioned above as 2nd argument. * This $options array will also be passed to the template as variable $options. Given that, you may add additional * variables of your own names to $options as needed for communication with the template, if it suits your need. * * @param HookEvent $event * @return string rendered data * @throws WirePermissionException|WireException * */ public function ___renderPage($event) { wire('pages')->setOutputFormatting(true); $page = $event->object; if($page->status >= Page::statusUnpublished && !$page->viewable()) { throw new WirePermissionException("Page '{$page->url}' is not currently viewable."); } $_page = $this->wire('page'); // just in case one page is rendering another, save the previous $config = $this->wire('config'); $this->renderRecursionLevel++; // set the context of the new page to be system-wide // only applicable if rendering a page within a page if(!$_page || $page->id != $_page->id) $this->wire('page', $page); if($this->renderRecursionLevel > 1) $this->pageStack[] = $_page; // arguments to $page->render() may be a string with filename to render or array of options $options = $event->arguments(0); $options2 = $event->arguments(1); // normalize options to array if(is_string($options) && strlen($options)) $options = array('filename' => $options); // arg1 is filename if(!is_array($options)) $options = array(); // no args specified if(is_array($options2)) $options = array_merge($options2, $options); // arg2 is $options $defaultOptions = array( 'filename' => '', // default blank means filename comes from $page 'prependFile' => $page->template->noPrependTemplateFile ? null : $config->prependTemplateFile, 'prependFiles' => $page->template->prependFile ? array($page->template->prependFile) : array(), 'appendFile' => $page->template->noAppendTemplateFile ? null : $config->appendTemplateFile, 'appendFiles' => $page->template->appendFile ? array($page->template->appendFile) : array(), 'allowCache' => true, 'forceBuildCache' => false, 'pageStack' => array(), // set after array_merge ); $options = array_merge($defaultOptions, $options); $options['pageStack'] = $this->pageStack; $cacheAllowed = $options['allowCache'] && $this->isCacheAllowed($page); $cacheFile = null; if($cacheAllowed) { $cacheFile = $this->getCacheFile($page, $options); if(!$options['forceBuildCache'] && ($data = $cacheFile->get()) !== false) { $event->return = $data; if($_page) $this->wire('page', $_page); return; } } $of = $page->of(); if(!$of) $page->of(true); $data = ''; $output = $page->output(true); if($output) { // global prepend/append include files apply only to user-defined templates, not system templates if(!($page->template->flags & Template::flagSystem)) { /* if($options['prependFile']) array_unshift($options['prependFiles'], $options['prependFile']); //$output->setPrependFilename($config->paths->templates . $this->wire('sanitizer')->name($options['prependFile'])); if($options['appendFile']) array_unshift($options['appendFiles'], $options['appendFile']); //$output->setPrependFilename($config->paths->templates . $this->wire('sanitizer')->name($options['prependFile'])); if($options['appendFile']) $output->setAppendFilename($config->paths->templates . $this->wire('sanitizer')->name($options['appendFile'])); // files defined at template level */ foreach(array('prependFile' => 'prependFiles', 'appendFile' => 'appendFiles') as $singular => $plural) { if($options[$singular]) array_unshift($options[$plural], $options[$singular]); foreach($options[$plural] as $file) { if(!ctype_alnum(str_replace(array(".", "-", "_", "/"), "", $file))) continue; if(strpos($file, '..') !== false || strpos($file, '/.') !== false) continue; $file = $config->paths->templates . trim($file, '/'); if(!is_file($file)) continue; if($plural == 'prependFiles') $output->setPrependFilename($file); else $output->setAppendFilename($file); } } } // option to change the filename that is used for output rendering if($options['filename'] && strpos($options['filename'], '..') === false) { $filename = $config->paths->templates . ltrim($options['filename'], '/'); $setFilename = ''; if(is_file($filename)) { // path relative from /site/templates/ $setFilename = $filename; } else { // absolute path, ensure it is somewhere within web root $filename = $options['filename']; if(strpos($filename, $config->paths->root) === 0 && is_file($filename)) $setFilename = $filename; } if($setFilename) { $output->setFilename($setFilename); $options['filename'] = $setFilename; } else { throw new WireException("Invalid output file location or specified file does not exist. $setFilename"); } } else { $options['filename'] = $page->template->filename; } // pass along the $options as a local variable to the template so that one can provide their // own additional variables in it if they want to $output->set('options', $options); $data = $output->render(); } if($data && $cacheAllowed && $cacheFile) $cacheFile->save($data); $event->return = $data; if(!$of) $page->of($of); if($_page && $_page->id != $page->id) { $this->wire('page', $_page); } if(count($this->pageStack)) array_pop($this->pageStack); $this->renderRecursionLevel--; } /** * Provide a disk cache clearing capability within the module's configuration screen * */ static public function getModuleConfigInputfields(array $data) { $path = Wire::getFuel('config')->paths->cache . self::cacheDirName . '/'; $numPages = 0; $numFiles = 0; $inputfields = new InputfieldWrapper(); $dir = null; $clearNow = Wire::getFuel('input')->post->clearCache ? true : false; try { $dir = new DirectoryIterator($path); } catch(Exception $e) { } if($dir) foreach($dir as $file) { if(!$file->isDir() || $file->isDot() || !ctype_digit($file->getFilename())) continue; $numPages++; if(!$clearNow) continue; $d = new DirectoryIterator($file->getPathname()); foreach($d as $f) { if(!$f->isDir() && preg_match('/\.cache$/D', $f->getFilename())) { $numFiles++; @unlink($f->getPathname()); } } @rmdir($file->getPathname()); } if($clearNow) { $inputfields->message(sprintf(__('Cleared %d cache files for %d pages'), $numFiles, $numPages)); $numPages = 0; } $name = "clearCache"; $f = Wire::getFuel('modules')->get('InputfieldCheckbox'); $f->attr('name', $name); $f->attr('value', 1); $f->label = __('Clear the Page Render Disk Cache?'); $f->description = sprintf(__('There are currently %d pages cached in %s'), $numPages, $path); $inputfields->append($f); return $inputfields; } }