Implement RSSFromGit, a substitute for RSSFromSVN.
[xhtml-compiler.git] / XHTMLCompiler / Page.php
blob758ac7c5aab74fde51e5b546ec4c02bb9f866cb6
1 <?php
3 /**
4 * Represents a page in our content management system. This is loosely
5 * bound to the filesystem, although it doesn't actually refer to a
6 * specific file, just a class of files.
7 */
8 class XHTMLCompiler_Page
11 /**
12 * Filename identifier of this page without extension
14 protected $pathStem;
16 /** File extension of source files (no period) */
17 protected $sourceExt = 'xhtml';
18 /** File extension of cache/served files */
19 protected $cacheExt = 'html';
20 /** File extension of dependency files */
21 protected $depsExt = 'xc-deps';
23 /** Instance of XHTMLCompiler_File for source file */
24 protected $source;
25 /** Instance of XHTMLCompiler_File for cache file */
26 protected $cache;
27 /** Instance of XHTMLCompiler_File for dependency file */
28 protected $deps;
30 /** Instance of XHTMLCompiler_Directory for all of the above files*/
31 protected $dir;
33 /** Array of attributes about this file. Currently used by News/NewsLinker */
34 public $attr = array();
36 /**
37 * Constructs a page object, validates filename for correctness
38 * @param $path String path filename, can be from untrusted source
39 * @param $mute Whether or not to stop the class from complaining when
40 * the source file doesn't exist. This is a stopgap measure,
41 * please replace with better exception handling.
42 * @todo Cleanup into subroutines
43 * @todo Factor out allowed_directories realpath'ing to config class
45 public function __construct($path, $mute = false) {
47 $xc = XHTMLCompiler::getInstance();
48 $php = XHTMLCompiler::getPHPWrapper();
50 // test file extension
51 $info = pathinfo($path);
52 if (
53 empty($info['extension']) || (
54 $info['extension'] !== $this->sourceExt &&
55 $info['extension'] !== $this->cacheExt
57 ) {
58 throw new XHTMLCompiler_Exception(403, 'Forbidden extension',
59 'File extension cannot be processed by XHTML Compiler, check
60 for faulty <code>.htaccess</code> rules.');
63 // test for directory's existence and resolve to real path
64 $dir = $info['dirname'];
65 if ($dir == '.') $dir .= '/';
66 $dir = $php->realpath($dir);
67 if ($dir === false) {
68 throw new XHTMLCompiler_Exception(404, 'Missing directory',
69 'Requested directory cannot be found; check your file
70 path and try again.' );
72 if ($dir[strlen($dir)-1] == '/') $dir = substr($dir, 0, -1);
74 $allowed_dirs = $xc->getConf('allowed_dirs');
75 $ok = false;
77 foreach ($allowed_dirs as $allowed_dir => $recursive) {
78 $allowed_dir = $php->realpath($allowed_dir); // factor out!
79 if (!is_string($allowed_dir)) continue;
80 if ($dir === $allowed_dir) {
81 $ok = true;
82 break;
83 // slash is required to prevent $allowed_dir = 'subdir' from
84 // matching $dir = 'subdirectory', thanks Mordred!
85 } elseif (strpos($dir, $allowed_dir . '/') === 0 && $recursive) {
86 $ok = true;
87 break;
91 if (!$ok) throw new XHTMLCompiler_Exception(403, 'Forbidden directory',
92 'Requested directory is forbidden to XHTML Compiler; try
93 accessing it directly or check for faulty <code>.htaccess</code> rules.');
95 // cannot use pathinfo, since PATHINFO_FILENAME is PHP 5.2.0
96 $this->pathStem = substr($path, 0, strrpos($path, '.'));
98 // setup the files
99 $this->source = new XHTMLCompiler_File($this->pathStem . '.' . $this->sourceExt);
100 $this->cache = new XHTMLCompiler_File($this->pathStem . '.' . $this->cacheExt);
101 $this->deps = new XHTMLCompiler_File($this->pathStem . '.' . $this->depsExt);
103 $this->dir = new XHTMLCompiler_Directory(dirname($this->pathStem));
105 if (!$mute && !$this->source->exists()) {
106 // Apache may have redirected to an ErrorDocument which got directed
107 // via mod_rewrite to us, in that case, output the corresponding
108 // status code. Otherwise, we can give the regular 404.
109 $code = $php->getRedirectStatus();
110 if (!$code || $code == 200) $code = 404;
111 throw new XHTMLCompiler_Exception($code, 'Page not found', 'Requested page not found; check the URL in your address bar.');
115 // Note: Do not use this functions internally inside the class
117 /** Returns path stem, full filename without file extension */
118 public function getPathStem() { return $this->pathStem; }
119 /** Returns relative path to cache */
120 public function getCachePath() { return $this->cache->getName(); }
121 /** Returns relative path to source */
122 public function getSourcePath() { return $this->source->getName(); }
123 /** Returns XHTMLCompiler_Directory representation of directory */
124 public function getDir() { return $this->dir; }
125 /** Returns directory of the files without trailing slash */
126 public function getDirName() { return $this->dir->getName(); }
127 /** Returns directory of the files with trailing slash (unless there is none) */
128 public function getDirSName() { return $this->dir->getSName(); }
129 /** Returns how deep from the root the file is */
130 public function getDepth() { return substr_count($this->getSourcePath(), '/'); }
132 /** Normalizes a relative path as if it were from this page's directory */
133 public function normalizePath($path) {
134 return $this->getDirName() . '/' . $path;
138 * Returns a fully formed web path with web domain to the file. This path
139 * is valid anywhere on the web.
141 public function getWebPath() {
142 $xc = XHTMLCompiler::getInstance();
143 $domain = $xc->getConf('web_domain');
144 if (!$domain) {
145 throw new Exception('Configuration value web_domain must be set for command line');
147 return 'http://' . $domain . $this->getAbsolutePath();
151 * Returns a fully formed absolute web path valid anywhere on the
152 * current domain to the cached file.
154 public function getAbsolutePath() {
155 $xc = XHTMLCompiler::getInstance();
156 $name = $this->cache->getName();
157 // a little icky
158 if ($name[0] !== '/') $name = "/$name";
159 if (strncmp($name, './', 2) === 0) $name = substr($name, 1);
160 return $xc->getConf('web_path') . $name;
163 /** Returns contents of the cache/served file */
164 public function getCache() { return $this->cache->get(); }
165 /** Returns contents of the source file */
166 public function getSource() { return $this->source->get(); }
168 /** Reports whether or not cache file exists and is a file */
169 public function isCacheExistent() { return $this->cache->exists(); }
170 /** Reports whether or not source file exists and is a file */
171 public function isSourceExistent() { return $this->source->exists(); }
173 /** Removes the cache file, forcing this page to be re-updated as if
174 it were newly added.*/
175 public function purge() { return $this->cache->delete(); }
178 * Reports whether or not the cache is stale by comparing the file
179 * modification times between the source file and the cache file.
180 * @warning You must not call this function until you've also called
181 * isCacheExistent().
183 public function isCacheStale() {
184 if (!$this->cache->exists()) {
185 throw new Exception('Cannot check for stale cache when cache
186 does not exist, please call isCacheExistent and take
187 appropriate action with the result');
189 if ($this->source->getMTime() > $this->cache->getMTime()) return true;
190 // check dependencies
191 if (!$this->deps->exists()) return true; // we need a dependency file!
192 $deps = unserialize($this->deps->get());
193 foreach ($deps as $filename => $time) {
194 if ($time < filemtime($filename)) return true;
196 return false;
200 * Writes text to the cache file, overwriting any previous contents
201 * and creating the cache file if it doesn't exist.
202 * @param $contents String contents to write to cache
204 public function writeCache($contents) {$this->cache->write($contents);}
207 * Attempts to display contents from the cache, otherwise returns false
208 * @return True if successful, false if not.
209 * @todo Purge check needs to be factored into XHTMLCompiler
211 public function tryCache() {
212 if (
213 !isset($_GET['purge']) &&
214 $this->cache->exists() &&
215 !$this->isCacheStale()
217 // cached version is fresh, serve it. This shouldn't happen normally
218 set_response_code(200); // if we used ErrorDocument, override
219 readfile($this->getCachePath());
220 return true;
222 return false;
226 * Generates the final version of a page from the source file and writes
227 * it to the cache.
228 * @note This function needs to be extended greatly
229 * @return Generated contents from source
231 public function generate() {
232 $source = $this->source->get();
233 $xc = XHTMLCompiler::getInstance();
234 $filters = $xc->getFilterManager();
235 $contents = $filters->process($source, $this);
236 $deps = $filters->getDeps();
237 if (empty($contents)) return ''; // don't write, probably an error
238 $contents .= '<!-- generated by XHTML Compiler -->';
239 $this->cache->write($contents);
240 $this->cache->chmod(0664);
241 $this->deps->write(serialize($deps));
242 return $contents;
246 * Displays the page, either from cache or fresh regeneration.
248 public function display() {
249 if($this->tryCache()) return;
250 $ret = $this->generate();
251 if ($ret) {
252 if (stripos($_SERVER["HTTP_ACCEPT"], 'application/xhtml+xml') !== false) {
253 header("Content-type: application/xhtml+xml");
254 } else {
255 header("Content-type: text/html");
258 echo $ret;
262 * Retrieves the Git_Repo that represents this page.
264 public function getRepo() {
265 return new Git_Repo($this->source->getDirectory());
269 * Retrieves the filename relative to the Git repository root.
271 public function getGitPath() {
272 $repo = $this->getRepo();
273 // This won't work with bare repositories
274 return $name = str_replace(
275 '\\', '/', // account for Windows
276 substr(
277 realpath($this->source->getName()), // $repo->path is full
278 strlen(dirname($repo->path))+1 // chop off "repo" path (w/o .git) + leading slash
284 * Retrieves the log that represents this page.
286 public function getLog($kwargs = array()) {
287 // This doesn't account for sub-repositories
288 $repo = $this->getRepo();
289 return $repo->log('master', array($this->getGitPath()), array_merge(array('follow' => true), $kwargs));
292 // this is metadata stuff that needs to be moved and cached
295 * Retrieves the DateTime this page was created, according to Git's logs.
296 * If no logs are present, use filectime(), which isn't totally accurate
297 * but is the best information present.
299 public function getCreatedTime() {
300 // As a backwards-compatibility measure, we allow the first meta tag
301 // with the specific signature:
302 // <meta name="Date" contents="..."
303 // to specify an ISO 8601 formatted date (or date compatible with
304 // GNU strtotime; Metadata will convert it into ISO 8601 as per
305 // the Dublin core specification).
306 $source = $this->source->get();
307 if (($p = strpos($source, '<meta name="Date" content="')) !== false) {
308 $p += 27; // cursor is now after the quote
309 // Grab the time
310 $time = substr($source, $p, strpos($source, '"', $p) - $p);
311 return new DateTime($time);
314 $repo = $this->getRepo();
315 // This is extremely memory inefficient, but I can't figure out
316 // how to get Git to limit the commits (-n) without undoing
317 // --reverse.
318 $log = $repo->log('master', array($this->getGitPath()), array(
319 'reverse' => true,
321 if (empty($log)) return new DateTime('@' . $this->source->getCTime());
322 return $log[0]->authoredDate;
326 * Retrieves the DateTime this page was last updated, according to Git's logs,
327 * otherwise according to filemtime.
329 public function getLastModifiedTime() {
330 $repo = $this->getRepo();
331 $log = $repo->log('master', array($this->getGitPath()), array(
332 'n' => 1,
334 if (empty($log)) return new DateTime('@' . $this->source->getMTime());
335 // or committedDate?
336 return $log[0]->authoredDate;
340 * Touches the source file, meaning that any files that depend on this
341 * file should be regenerated. XHTML Compiler knows, however,
342 * that it's not the first time the cache has been generated. This is
343 * weaker than purge().
345 public function touch() {
346 $this->source->touch();