a tiny mvc framework for php using php-activerecord
1<?php
2
3namespace HalfMoon;
4
5class ApplicationController {
6 static $DEFAULT_CONTENT_TYPE = "text/html";
7
8 /* array of methods to call before processing any actions, bailing if any
9 * return false
10 * e.g. static $before_filter = array(
11 * "validate_logged_in_user",
12 * "validate_admin" => array("only" => array("create")),
13 * ...
14 */
15 static $before_filter = array();
16
17 /* array of methods to call after processing actions, which will be passed
18 * all buffered output and must return new output */
19 static $after_filter = array();
20
21 /* things to verify (like the method used) before processing any actions */
22 static $verify = array();
23
24 /* if non-empty, recurse through GET/POST params and filter out the values
25 * of any parameter names that match, replacing them with '[FILTERED]' */
26 static $filter_parameter_logging = array();
27
28 /* per-controller session options, can be "off", "on", or a per-action
29 * setting like: array("on" => array("only" => array("foo", "bar"))) */
30 static $session = "";
31
32 /* specify a different layout than controller name or application */
33 static $layout = array();
34
35 /* helpers to bring in (as "ref" => "filename"), other than
36 * application_helper and a controller-specific helper (if each exists) */
37 static $helpers = array();
38
39 /* protect all (or specific actions passed as an array) actions from
40 * forgery */
41 static $protect_from_forgery = true;
42
43 /* simple array of action names which will perform full page caching */
44 static $caches_page = array();
45
46 public $request = array();
47 public $params = array();
48 public $locals = array();
49
50 /* this will be set to a helper object before rendering a template */
51 public $helper = null;
52
53 /* keep track of the content-type being sent */
54 public $content_type = null;
55
56 private $did_render = false;
57 private $redirected_to = null;
58 private $did_layout = false;
59
60 /* set while we're processing a view so render() behaves differently */
61 private $in_view = false;
62
63 /* track what ob_get_level() was when we started buffering */
64 private $start_ob_level = 0;
65
66 private $etag;
67
68 public function __construct($request) {
69 $this->request = $request;
70 $this->params = &$request->params;
71
72 if (Config::log_level_at_least("full") && array_keys($this->params)) {
73 $params_log = " Parameters: ";
74
75 /* the closure can't access static vars */
76 $filters = static::$filter_parameter_logging;
77
78 /* build a string of parameters, less the ones matching
79 * $filter_parameter_logging */
80 $recursor = function($params) use (&$recursor, &$params_log,
81 $filters) {
82 $params_log .= "{";
83 $done_first = false;
84 foreach ($params as $k => $v) {
85 if ($done_first)
86 $params_log .= ", ";
87 else
88 $done_first = true;
89
90 $params_log .= "\"" . $k . "\"=>";
91
92 if (is_array($v))
93 $recursor($v);
94 else {
95 $filter = false;
96
97 if (is_array($filters) && !empty($filters)) {
98 foreach ($filters as $field)
99 if (preg_match("/" . preg_quote($field, "/")
100 . "/i", $k)) {
101 $filter = true;
102 break;
103 }
104 }
105
106 if ($filter)
107 $params_log .= "[FILTERED]";
108 else
109 $params_log .= "\"" . $v . "\"";
110 }
111 }
112 $params_log .= "}";
113 };
114 $recursor($this->params);
115
116 Log::info($params_log);
117 }
118 }
119
120 /* turn local class variables into $variables when rendering views */
121 public function __set($name, $value) {
122 $this->locals[$name] =& $value;
123 }
124 public function __get($name) {
125 if (isset($this->locals[$name]))
126 return $this->locals[$name];
127 else
128 return null;
129 }
130 public function __isset($name) {
131 return isset($this->locals[$name]);
132 }
133 public function __unset($name) {
134 unset($this->locals[$name]);
135 }
136
137 /* store an error in the session to be printed on the next view with the
138 * flash_errors() helper */
139 public function add_flash_error($string) {
140 if (!isset($_SESSION["flash_errors"]) ||
141 !is_array($_SESSION["flash_errors"]))
142 $_SESSION["flash_errors"] = array();
143
144 array_push($_SESSION["flash_errors"], $string);
145 }
146
147 /* store a notice in the session to be printed on the next view with the
148 * flash_notices() helper */
149 public function add_flash_notice($string) {
150 if (!isset($_SESSION["flash_notices"]) ||
151 !is_array($_SESSION["flash_notices"]))
152 $_SESSION["flash_notices"] = array();
153
154 array_push($_SESSION["flash_notices"], $string);
155 }
156
157 /* store a success message in the session to be printed on the next view
158 * with the flash_success() helper */
159 public function add_flash_success($string) {
160 if (!isset($_SESSION["flash_successes"]) ||
161 !is_array($_SESSION["flash_successes"]))
162 $_SESSION["flash_successes"] = array();
163
164 array_push($_SESSION["flash_successes"], $string);
165 }
166
167 /* cancel all buffered output, send a location: header and stop processing */
168 public function redirect_to($obj_or_url) {
169 $link = HTMLHelper::link_from_obj_or_string($obj_or_url);
170
171 /* prevent any content from getting to the user */
172 while (($l = ob_get_level()) && ($l >= $this->start_ob_level))
173 ob_end_clean();
174
175 if (Config::log_level_at_least("full"))
176 Log::info("Redirected to " . $link);
177
178 $this->redirected_to = $link;
179
180 /* end session first so it can write the cookie */
181 session_write_close();
182
183 Request::send_status_header(302);
184 header("Location: " . $link);
185 }
186
187 /* render a partial view, an action template, text, etc. */
188 public function render($template, $vars = array()) {
189 /* render(array("partial" => "somedir/file"), array("v" => $v)) */
190
191 if (!is_array($template))
192 $template = array("action" => $template);
193
194 if (isset($template["status"]))
195 Request::send_status_header($template["status"]);
196
197 $collection = array();
198 if (isset($template["collection"]) &&
199 is_array($template["collection"])) {
200 if (isset($template["as"]))
201 $collection = array($template["as"] =>
202 $template["collection"]);
203 else {
204 /* figure out the type of things in the collection */
205 $cl = strtolower(get_class($template["collection"][0]));
206 if ($cl != "")
207 $cl = \ActiveRecord\Utils::singularize($cl);
208
209 if ($cl == "")
210 throw new HalfMoonException("could not figure out type of "
211 . "collection");
212
213 $collection = array($cl => $template["collection"]);
214 }
215 }
216
217 /* just render text with no layout */
218 if (is_array($template) && array_key_exists("text", $template)) {
219 if (!$this->content_type_set())
220 $this->content_type = "text/plain";
221
222 if (Config::log_level_at_least("full"))
223 Log::info("Rendering text");
224
225 print $template["text"];
226 }
227
228 /* just render html with no layout */
229 elseif (is_array($template) && array_key_exists("html", $template)) {
230 if (!$this->content_type_set())
231 $this->content_type = "text/html";
232
233 if (Config::log_level_at_least("full"))
234 Log::info("Rendering HTML");
235
236 print $template["html"];
237 }
238
239 /* just render json with no layout */
240 elseif (is_array($template) && array_key_exists("json", $template)) {
241 if (!$this->content_type_set())
242 $this->content_type = "application/json";
243
244 if (Config::log_level_at_least("full"))
245 Log::info("Rendering json");
246
247 /* there's no way to know if we were passed a json-encoded string,
248 * or a string that needs to be encoded, so just encode everything
249 * and hope the user figures it out */
250 print json_encode($template["json"]);
251 }
252
253 /* just render javascript with no layout */
254 elseif (is_array($template) && array_key_exists("js", $template)) {
255 if (!$this->content_type_set())
256 $this->content_type = "text/javascript";
257
258 if (Config::log_level_at_least("full"))
259 Log::info("Rendering javascript");
260
261 print $template["js"];
262 }
263
264 /* assume we're dealing with files */
265 else {
266 $tf = "";
267
268 /* render a partial template */
269 if (is_array($template) && isset($template["partial"]))
270 $tf = $template["partial"];
271
272 /* render an action template */
273 elseif (is_array($template) && isset($template["action"]))
274 $tf = $template["action"];
275
276 /* just a filename, render it as an action */
277 elseif (is_array($template))
278 $tf = join("", array_values($template));
279
280 /* just a filename, render it as an action */
281 else
282 $tf = $template;
283
284 if (substr($tf, 0, 1) == "/")
285 /* full path, just use it */
286 ;
287 elseif (strpos($tf, "/") !== false)
288 /* path relative to base view path */
289 $tf = HALFMOON_ROOT . "/views/" . $tf;
290 else
291 /* just a file in this controller's directory
292 * (AdminSomethingController -> admin_something) */
293 $tf = $this->view_template_path() . $tf;
294
295 /* partial template files start with _ */
296 if (is_array($template) && isset($template["partial"]))
297 $tf = dirname($tf) . "/_" . basename($tf);
298
299 /* do the actual renders */
300 $filename = null;
301
302 /* regular php/html */
303 if (file_exists($filename = $tf . ".phtml")) {
304 if (!$this->content_type_set())
305 $this->content_type = "text/html";
306 }
307
308 /* xml */
309 elseif (file_exists($filename = $tf . ".pxml")) {
310 if (!$this->content_type_set())
311 $this->content_type = "application/xml";
312 }
313
314 /* php-javascript */
315 elseif (file_exists($filename = $tf . ".pjs")) {
316 if (!$this->content_type_set())
317 $this->content_type = "text/javascript";
318 }
319
320 else
321 throw new MissingTemplate("no template file " . $tf
322 . ".p{html,xml,js}");
323
324 if (count($collection)) {
325 $ck = Utils::A(array_keys($collection), 0);
326
327 /* it would be nice to be able to just read the template
328 * into a string and eval() it each time to save on i/o,
329 * but php won't let us catch parse errors properly and
330 * there may be some other fallout */
331 foreach ($collection[$ck] as $cobj) {
332 $vars[$ck] = $cobj;
333 $this->_really_render_file($filename, $vars);
334 }
335 } else
336 $this->_really_render_file($filename, $vars);
337 }
338
339 if (!$this->in_view) {
340 if (is_array($template) && array_key_exists("layout", $template))
341 $this::$layout = $template["layout"];
342
343 elseif ($this->content_type_set() &&
344 $this->content_type != static::$DEFAULT_CONTENT_TYPE)
345 /* if we were called from the controller, we're not outputting
346 * html, and no layout was explicitly specified, we probably
347 * don't want a layout */
348 $this::$layout = false;
349 }
350
351 $this->did_render = true;
352 }
353
354 /* do render() but capture all the output and return it */
355 public function render_to_string($template, $vars = array()) {
356 $old_did_render = $this->did_render;
357
358 /* store current content-type in case render() changes it */
359 $ct = $this->content_type;
360
361 ob_start();
362 $this->render($template, $vars);
363 $output = ob_get_contents();
364 ob_end_clean();
365
366 $this->did_render = $old_did_render;
367 $this->content_type = $ct;
368
369 return $output;
370 }
371
372 /* a private function to avoid taining the variable space after the
373 * require() */
374 private function _really_render_file($__file, $__vars) {
375 /* XXX: should this be checking for more special variable names? */
376 $__special_vars = array("__special_vars", "__vars", "__file",
377 "controller");
378
379 /* export variables set in the controller to the view */
380 foreach ((array)$this->locals as $__k => $__v) {
381 if (in_array($__k, $__special_vars)) {
382 Log::warn("tried to redefine \$" . $__k . " passed from "
383 . "controller");
384 continue;
385 }
386
387 $$__k = $__v;
388 }
389
390 /* and any passed as locals to the render() function */
391 foreach ((array)$__vars as $__k => $__v) {
392 if (in_array($__k, $__special_vars)) {
393 Log::warn("tried to redefine \$" . $__k . " passed "
394 . "from render() call");
395 continue;
396 }
397
398 $$__k = $__v;
399 }
400
401 /* make helpers available to the view */
402 $this->bring_in_helpers();
403 foreach ($this->_helper_refs as $__hn => $__hk) {
404 $$__hn = $__hk;
405 $$__hn->controller = $this;
406 $$__hn->C = $this;
407 }
408
409 /* define $controller and $C where $this can't be used */
410 $controller = $this;
411 $C = $this;
412
413 if (Config::log_level_at_least("full"))
414 Log::info("Rendering " . $__file);
415
416 $this->in_view = true;
417 require($__file);
418 $this->in_view = false;
419 }
420
421 /* setup each built-in helper to be $var = VarHelper, and the
422 * application_helper and controller-specific helper to be $helper */
423 private $_helper_refs = null;
424 private function bring_in_helpers() {
425 if ($this->_helper_refs)
426 return;
427
428 $this->_helper_refs = array();
429
430 foreach (get_declared_classes() as $class)
431 if (preg_match("/^HalfMoon\\\\(.+)Helper$/", $class, $m))
432 $this->_helper_refs[strtolower($m[1])] = new $class;
433
434 /* bring in the application-wide helpers */
435 if (file_exists($f = HALFMOON_ROOT . "/helpers/"
436 . "application_helper.php")) {
437 require_once($f);
438 $this->_helper_refs["helper"] = new \ApplicationHelper;
439 }
440
441 /* if a controller-specific helper exists, hopefully it descends from
442 * ApplicationHelper so the $helper ref we're going to point at it will
443 * still have access to methods in ApplicationHelper */
444 $controller = preg_replace("/Controller$/", "",
445 Utils::current_controller_name());
446
447 if (file_exists($f = HALFMOON_ROOT . "/helpers/"
448 . strtolower($controller . "_helper.php"))) {
449 require_once($f);
450
451 $n = $controller . "Helper";
452 $this->_helper_refs["helper"] = new $n;
453 }
454
455 /* bring in any extra helpers requested by the static $helpers */
456 foreach (static::$helpers as $ref => $file) {
457 require_once($f = HALFMOON_ROOT . "/helpers/" . $file
458 . "_helper.php");
459
460 $c = "\\" . $file . "Helper";
461
462 try {
463 $this->_helper_refs[$ref] = new $c;
464 } catch (Exception $e) {
465 throw new HalfMoonException("loaded helper " . $f . " which "
466 . "did not define " . $c);
467 }
468 }
469 }
470
471 /* the main entry point for the controller, sent by the router */
472 public function render_action($action) {
473 $this->enable_or_disable_sessions($action);
474
475 $this->verify_method($action);
476
477 $this->protect_from_forgery($action);
478
479 if (!$this->process_before_filters($action))
480 return false;
481
482 /* start our one output buffer that we'll pass to the after filters */
483 ob_start();
484 $this->start_ob_level = ob_get_level();
485
486 /* we only want to allow calling public methods in controllers, to
487 * avoid users getting directly to before_filters and other utility
488 * functions */
489 if (!in_array($action, Utils::get_public_class_methods($this)))
490 throw new UndefinedFunction("controller \"" . get_class($this)
491 . "\" does not have an action \"" . $action . "\"");
492
493 call_user_func_array(array($this, $action), array());
494
495 if (isset($this->redirected_to)) {
496 $this->request->redirected_to = $this->redirected_to;
497 return;
498 }
499
500 if (!$this->did_render)
501 $this->render(array("action" => $action), $this->locals);
502
503 if (!$this->did_layout)
504 $this->render_layout($action);
505
506 $this->process_after_filters($action);
507
508 /* end session first so it can write the cookie */
509 session_write_close();
510
511 if (!$this->content_type_set())
512 $this->content_type = static::$DEFAULT_CONTENT_TYPE;
513
514 if (!$this->content_type_sent())
515 header("Content-type: " . $this->content_type);
516
517 /* if we're caching this action as a full page, write out what we've
518 * buffered so far */
519 if (!Utils::is_blank(Config::instance()->cache_store_path) &&
520 is_array($this::$caches_page) &&
521 in_array($action, $this::$caches_page))
522 $this->write_cache_output();
523
524 /* flush out everything, we're done playing with buffers */
525 ob_end_flush();
526 }
527
528 /* capture the output of everything rendered and put it within the layout */
529 public function render_layout($action) {
530 /* get all buffered output and turn them off, except for our one last
531 * buffer needed for after_filters */
532 $content_for_layout = "";
533 while (ob_get_level() >= $this->start_ob_level) {
534 $content_for_layout = $content_for_layout . ob_get_contents();
535
536 if (ob_get_level() == $this->start_ob_level)
537 break;
538 else
539 ob_end_clean();
540 }
541
542 /* now that we have all of our content, clean our last buffer since
543 * we're going to print the layout (and content inside) into it */
544 ob_clean();
545
546 $tlayout = null;
547 if ($this::$layout === false)
548 $tlayout = false;
549 else {
550 $opts = Utils::options_for_key_from_options_hash($action,
551 $this::$layout);
552 if (!empty($opts[0]))
553 $tlayout = $opts[0];
554 }
555
556 /* if we don't want a layout at all, just print the content */
557 if ($tlayout === false || $tlayout === "false") {
558 print $content_for_layout;
559 return;
560 }
561
562 /* if no specific layout was set, check for a controller-specific one */
563 if (!$tlayout && isset($this->params["controller"]) &&
564 file_exists(HALFMOON_ROOT . "/views/layouts/" . $this->params["controller"]
565 . ".phtml"))
566 $tlayout = $this->params["controller"];
567
568 /* otherwise, default to "application" */
569 if (!$tlayout)
570 $tlayout = "application";
571
572 $this->did_layout = true;
573
574 if (!file_exists(HALFMOON_ROOT . "/views/layouts/" . $tlayout .
575 ".phtml"))
576 throw new MissingTemplate("no layout file " . $tlayout . ".phtml");
577
578 /* make helpers available to the layout */
579 $this->bring_in_helpers();
580 foreach ($this->_helper_refs as $__hn => $__hk) {
581 $$__hn = $__hk;
582 $$__hn->controller = $this;
583 $$__hn->C = $this;
584 }
585
586 /* define $controller where $this can't be used */
587 $controller = $this;
588 $C = $this;
589
590 if (Config::log_level_at_least("full"))
591 Log::info("Rendering layout " . $tlayout);
592
593 require(HALFMOON_ROOT . "/views/layouts/" . $tlayout . ".phtml");
594 }
595
596 public function form_authenticity_token() {
597 /* explicitly enable sessions so we can store/retrieve the token */
598 $this->start_session();
599
600 if (@!$_SESSION["_csrf_token"])
601 $_SESSION["_csrf_token"] = Utils::random_hash();
602
603 return $_SESSION["_csrf_token"];
604 }
605
606 public function view_template_path($absolute = true) {
607 $words = preg_split('/(?<=\\w)(?=[A-Z])/',
608 preg_replace("/Controller$/", "",
609 Utils::current_controller_name()));
610
611 $path = strtolower(join("_", $words)) . "/";
612 if ($absolute)
613 $path = HALFMOON_ROOT . "/views/" . $path;
614
615 return $path;
616 }
617
618 public function expire_page($p) {
619 if (!isset($p["action"]))
620 throw new HalfMoonException("cannot expire page cache without "
621 . "at least an action");
622
623 $path = Config::instance()->cache_store_path . "/" .
624 $this->view_template_path($abs = false) . "/" . $p["action"];
625
626 if (isset($p["id"]))
627 $path .= "/" . $p["id"];
628
629 $path .= ".html";
630 if (file_exists($path)) {
631 unlink($path);
632 Log::info("Expired cached file " . $path);
633 }
634 }
635
636 /* enable or disable sessions according to $session */
637 private function enable_or_disable_sessions($action) {
638 if (empty($this::$session) ||
639 (!is_array($this::$session) && $this::$session == "off"))
640 $sessions = false;
641 elseif (is_array($this::$session)) {
642 $opts = Utils::options_for_key_from_options_hash($action,
643 $this::$session);
644
645 if ($opts == array("on"))
646 $sessions = true;
647 } else
648 $sessions = true;
649
650 if ($sessions) {
651 session_cache_expire(0);
652 session_cache_limiter("private_no_expire");
653 $this->start_session();
654 } else
655 session_cache_limiter("public");
656 }
657
658 /* verify any options requiring verification */
659 private function verify_method($action) {
660 $to_verify = Utils::options_for_key_from_options_hash($action,
661 $this::$verify);
662
663 if (empty($to_verify))
664 return;
665
666 foreach ($to_verify as $v) {
667 if (isset($v["method"])) {
668 if (strtolower($this->request->request_method()) !=
669 strtolower($v["method"])) {
670 if (isset($v["redirect_to"]))
671 return $this->redirect_to($v["redirect_to"]);
672 else
673 throw new BadRequest();
674 }
675 }
676
677 /* TODO: support other verify options from rails
678 * http://railsapi.com/doc/v2.3.2/classes/ActionController/Verification/ClassMethods.html#M000331
679 */
680 }
681 }
682
683 /* xsrf protection: verify the passed authenticity token for non-GET/HEAD
684 * requests */
685 private function protect_from_forgery($action) {
686 if (!$this::$protect_from_forgery)
687 return;
688
689 if (strtoupper($this->request->request_method()) == "GET" ||
690 strtoupper($this->request->request_method()) == "HEAD")
691 return;
692
693 if (Utils::option_applies_for_key($action,
694 $this::$protect_from_forgery)) {
695 if (@$this->params["authenticity_token"] !=
696 $this->form_authenticity_token())
697 throw new InvalidAuthenticityToken();
698 }
699 }
700
701 /* return false if any before_filters return false */
702 private function process_before_filters($action) {
703 $filters = Utils::options_for_key_from_options_hash($action,
704 $this::$before_filter);
705
706 foreach ($filters as $filter) {
707 if (!method_exists($this, $filter))
708 throw new UndefinedFunction("before_filter \"" . $filter
709 . "\" function does not exist");
710
711 if (!call_user_func_array(array($this, $filter), array())) {
712 if (Config::log_level_at_least("short"))
713 Log::info("Filter chain halted as " . $filter
714 . " did not return true.");
715
716 return false;
717 }
718
719 if (isset($this->redirected_to))
720 return false;
721 }
722
723 return true;
724 }
725
726 /* pass all buffered output through after filters */
727 private function process_after_filters($action) {
728 $filters = Utils::options_for_key_from_options_hash($action,
729 $this::$after_filter);
730
731 foreach ($filters as $filter) {
732 if (!method_exists($this, $filter))
733 throw new UndefinedFunction("after_filter \"" . $filter
734 . "\" function does not exist");
735
736 /* get all buffered output, then replace it with the filtered
737 * output */
738 $output = ob_get_contents();
739 $output = call_user_func_array(array($this, $filter),
740 array($output));
741 ob_clean();
742 print $output;
743 }
744 }
745
746 private function start_session() {
747 try {
748 if (!session_id())
749 session_start();
750 } catch (\HalfMoon\InvalidCookieData $e) {
751 /* probably a decryption failure. rather than assume the user is
752 * an attacker, try to invalidate their session so they get a new
753 * cookie. on a reload, they'll at least start with a clean
754 * session instead of continuing to get 500 errors forever. */
755 session_destroy();
756
757 /* and then throw the error so they see a 500 and see that
758 * something was wrong, which may help explain their new session on
759 * the reload. */
760 throw $e;
761 }
762 }
763
764 private function content_type_set() {
765 if (empty($this->content_type))
766 if ($ct = $this->content_type_sent())
767 $this->content_type = $ct;
768
769 return !empty($this->content_type);
770 }
771
772 private function content_type_sent() {
773 foreach ((array)headers_list() as $header)
774 if (preg_match("/^Content-type: (.+)/i", $header, $m))
775 return $m[1];
776
777 return null;
778 }
779
780 private function write_cache_output() {
781 /* request path is already sanitized of .. and such, so we must
782 * assume it is safe to use relative to our cache store path */
783 $rp = $this->request->path;
784 if ($rp == "")
785 $rp = "index";
786
787 $path = Config::instance()->cache_store_path . "/" . $rp;
788
789 /* use .html as the default extension unless it looks like our url
790 * already had a format */
791 if (!preg_match('/\.[^\/]+/', $rp))
792 $path .= ".html";
793
794 /* append .html to our temporary file as a precaution in case it gets
795 * left around, we don't want it being interpreted by the web server as
796 * anything else */
797 $tmppath = $path . "." . bin2hex(openssl_random_pseudo_bytes(10))
798 . ".html";
799
800 if (!is_writable($path))
801 @mkdir(dirname($path), 0755, $recursive = true);
802
803 $fp = fopen($tmppath, "x");
804 if ($fp === false) {
805 Log::error("Error creating cache file " . $tmppath);
806 return;
807 }
808
809 fwrite($fp, ob_get_contents());
810 fclose($fp);
811
812 if (!rename($tmppath, $path)) {
813 Log::error("Error renaming " . $tmppath . " to " . $path);
814 unlink($tmppath);
815 return;
816 }
817
818 Log::info("Cached page output to " . realpath($path));
819 }
820}
821
822?>