1: <?php
2: /**
3: * Horde Routes package
4: *
5: * This package is heavily inspired by the Python "Routes" library
6: * by Ben Bangert (http://routes.groovie.org). Routes is based
7: * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
8: *
9: * @author Maintainable Software, LLC. (http://www.maintainable.com)
10: * @author Mike Naberezny <mike@maintainable.com>
11: * @license http://www.horde.org/licenses/bsd BSD
12: * @package Routes
13: */
14:
15: /**
16: * Utility functions for use in templates and controllers
17: *
18: * @package Routes
19: */
20: class Horde_Routes_Utils
21: {
22: /**
23: * @var Horde_Routes_Mapper
24: */
25: public $mapper;
26:
27: /**
28: * Match data from last match; implements for urlFor() route memory
29: * @var array
30: */
31: public $mapperDict = array();
32:
33: /**
34: * Callback function used for redirectTo()
35: * @var callback
36: */
37: public $redirect;
38:
39: /**
40: * Constructor
41: *
42: * @param Horde_Routes_Mapper $mapper Mapper for these utilities
43: * @param callback $redirect Redirect callback for redirectTo()
44: */
45: public function __construct(Horde_Routes_Mapper $mapper, $redirect = null)
46: {
47: $this->mapper = $mapper;
48: $this->redirect = $redirect;
49: }
50:
51: /**
52: * Generates a URL.
53: *
54: * All keys given to urlFor are sent to the Routes Mapper instance for
55: * generation except for::
56: *
57: * anchor specified the anchor name to be appened to the path
58: * host overrides the default (current) host if provided
59: * protocol overrides the default (current) protocol if provided
60: * qualified creates the URL with the host/port information as
61: * needed
62: *
63: * The URL is generated based on the rest of the keys. When generating a new
64: * URL, values will be used from the current request's parameters (if
65: * present). The following rules are used to determine when and how to keep
66: * the current requests parameters:
67: *
68: * * If the controller is present and begins with '/', no defaults are used
69: * * If the controller is changed, action is set to 'index' unless otherwise
70: * specified
71: *
72: * For example, if the current request yielded a dict (associative array) of
73: * array('controller'=>'blog', 'action'=>'view', 'id'=>2), with the standard
74: * ':controller/:action/:id' route, you'd get the following results::
75: *
76: * urlFor(array('id'=>4)) => '/blog/view/4',
77: * urlFor(array('controller'=>'/admin')) => '/admin',
78: * urlFor(array('controller'=>'admin')) => '/admin/view/2'
79: * urlFor(array('action'=>'edit')) => '/blog/edit/2',
80: * urlFor(array('action'=>'list', id=NULL)) => '/blog/list'
81: *
82: * **Static and Named Routes**
83: *
84: * If there is a string present as the first argument, a lookup is done
85: * against the named routes table to see if there's any matching routes. The
86: * keyword defaults used with static routes will be sent in as GET query
87: * arg's if a route matches.
88: *
89: * If no route by that name is found, the string is assumed to be a raw URL.
90: * Should the raw URL begin with ``/`` then appropriate SCRIPT_NAME data will
91: * be added if present, otherwise the string will be used as the url with
92: * keyword args becoming GET query args.
93: */
94: public function urlFor($first = array(), $second = array())
95: {
96: if (is_array($first)) {
97: // urlFor(array('controller' => 'foo', ...))
98: $routeName = null;
99: $kargs = $first;
100: } else {
101: // urlFor('named_route')
102: // urlFor('named_route', array('id' => 3, ...))
103: // urlFor('static_path')
104: $routeName = (string)$first;
105: $kargs = $second;
106: }
107:
108: $anchor = isset($kargs['anchor']) ? $kargs['anchor'] : null;
109: $host = isset($kargs['host']) ? $kargs['host'] : null;
110: $protocol = isset($kargs['protocol']) ? $kargs['protocol'] : null;
111: $qualified = isset($kargs['qualified']) ? $kargs['qualified'] : null;
112: unset($kargs['qualified']);
113:
114: // Remove special words from kargs, convert placeholders
115: foreach (array('anchor', 'host', 'protocol') as $key) {
116: if (array_key_exists($key, $kargs)) {
117: unset($kargs[$key]);
118: }
119: if (array_key_exists($key . '_', $kargs)) {
120: $kargs[$key] = $kargs[$key . '_'];
121: unset($kargs[$key . '_']);
122: }
123: }
124:
125: $route = null;
126: $routeArgs = array();
127: $static = false;
128: $encoding = $this->mapper->encoding;
129: $environ = $this->mapper->environ;
130: $url = '';
131:
132: if (isset($routeName)) {
133: if (isset($kargs['format']) && isset($this->mapper->routeNames['formatted_' . $routeName])) {
134: $route = $this->mapper->routeNames['formatted_' . $routeName];
135: } elseif (isset($this->mapper->routeNames[$routeName])) {
136: $route = $this->mapper->routeNames[$routeName];
137: }
138:
139: if ($route && array_key_exists('_static', $route->defaults)) {
140: $static = true;
141: $url = $route->routePath;
142: }
143:
144: // No named route found, assume the argument is a relative path
145: if ($route === null) {
146: $static = true;
147: $url = $routeName;
148: }
149:
150: if ((substr($url, 0, 1) == '/') &&
151: isset($environ['SCRIPT_NAME'])) {
152: $url = $environ['SCRIPT_NAME'] . $url;
153: }
154:
155: if ($static) {
156: if (!empty($kargs)) {
157: $url .= '?';
158: $query_args = array();
159: foreach ($kargs as $key => $val) {
160: $query_args[] = urlencode(utf8_decode($key)) . '=' .
161: urlencode(utf8_decode($val));
162: }
163: $url .= implode('&', $query_args);
164: }
165: }
166: }
167:
168: if (! $static) {
169: if ($route) {
170: $routeArgs = array($route);
171: $newargs = $route->defaults;
172: foreach ($kargs as $key => $value) {
173: $newargs[$key] = $value;
174: }
175:
176: // If this route has a filter, apply it
177: if (!empty($route->filter)) {
178: $newargs = call_user_func($route->filter, $newargs);
179: }
180:
181: $newargs = $this->_subdomainCheck($newargs);
182: } else {
183: $newargs = $this->_screenArgs($kargs);
184: }
185:
186: $anchor = (isset($newargs['_anchor'])) ? $newargs['_anchor'] : $anchor;
187: unset($newargs['_anchor']);
188:
189: $host = (isset($newargs['_host'])) ? $newargs['_host'] : $host;
190: unset($newargs['_host']);
191:
192: $protocol = (isset($newargs['_protocol'])) ? $newargs['_protocol'] : $protocol;
193: unset($newargs['_protocol']);
194:
195: $url = $this->mapper->generate($routeArgs, $newargs);
196: }
197:
198: if (!empty($anchor)) {
199: $url .= '#' . self::urlQuote($anchor, $encoding);
200: }
201:
202: if (!empty($host) || !empty($qualified) || !empty($protocol)) {
203: $http_host = isset($environ['HTTP_HOST']) ? $environ['HTTP_HOST'] : null;
204: $server_name = isset($environ['SERVER_NAME']) ? $environ['SERVER_NAME'] : null;
205: $fullhost = !is_null($http_host) ? $http_host : $server_name;
206:
207: if (empty($host) && empty($qualified)) {
208: $host = explode(':', $fullhost);
209: $host = $host[0];
210: } else if (empty($host)) {
211: $host = $fullhost;
212: }
213: if (empty($protocol)) {
214: if (!empty($environ['HTTPS']) && $environ['HTTPS'] != 'off') {
215: $protocol = 'https';
216: } else {
217: $protocol = 'http';
218: }
219: }
220: if ($url !== null) {
221: $url = $protocol . '://' . $host . $url;
222: }
223: }
224:
225: return $url;
226: }
227:
228: /**
229: * Issues a redirect based on the arguments.
230: *
231: * Redirects *should* occur as a "302 Moved" header, however the web
232: * framework may utilize a different method.
233: *
234: * All arguments are passed to urlFor() to retrieve the appropriate URL, then
235: * the resulting URL it sent to the redirect function as the URL.
236: *
237: * @param mixed $first First argument in varargs, same as urlFor()
238: * @param mixed $second Second argument in varargs
239: * @return mixed Result of redirect callback
240: */
241: public function redirectTo($first = array(), $second = array())
242: {
243: $target = $this->urlFor($first, $second);
244: return call_user_func($this->redirect, $target);
245: }
246:
247: /**
248: * Pretty-print a listing of the routes connected to the mapper.
249: *
250: * @param stream|null $stream Output stream for printing (optional)
251: * @param string|null $eol Line ending (optional)
252: * @return void
253: */
254: public function printRoutes($stream = null, $eol = PHP_EOL)
255: {
256: $printer = new Horde_Routes_Printer($this->mapper);
257: $printer->printRoutes($stream, $eol);
258: }
259:
260: /**
261: * Scan a directory for PHP files and use them as controllers. Used
262: * as the default scanner callback for Horde_Routes_Mapper. See the
263: * constructor of that class for more information.
264: *
265: * Given a directory with:
266: * foo.php, bar.php, baz.php
267: * Returns an array:
268: * foo, bar, baz
269: *
270: * @param string $dirname Directory to scan for controller files
271: * @param string $prefix Prefix controller names (optional)
272: * @return array Array of controller names
273: */
274: public static function controllerScan($dirname = null, $prefix = '')
275: {
276: $controllers = array();
277:
278: if ($dirname === null) {
279: return $controllers;
280: }
281:
282: $baseregexp = preg_quote($dirname . DIRECTORY_SEPARATOR, '/');
283:
284: foreach (new RecursiveIteratorIterator(
285: new RecursiveDirectoryIterator($dirname)) as $entry) {
286: if (!$entry->isFile()) {
287: continue;
288: }
289: // Match .php files that don't start with an underscore
290: if (preg_match('/^[^_]{1,1}.*\.php$/', basename($entry->getFilename())) == 0) {
291: continue;
292: }
293: // Strip off base path: dirname/admin/users.php -> admin/users.php
294: $controller = preg_replace("/^$baseregexp(.*)\.php/", '\\1', $entry->getPathname());
295:
296: // PrepareController -> prepare_controller -> prepare
297: $controller = Horde_String::lower(
298: preg_replace('/([a-z])([A-Z])/',
299: "\${1}_\${2}", $controller));
300: if (preg_match('/_controller$/', $controller)) {
301: $controller = substr($controller, 0, -(strlen('_controller')));
302: }
303:
304: // Normalize directory separators.
305: $controller = str_replace(DIRECTORY_SEPARATOR, '/', $controller);
306:
307: // Add to controller list.
308: $controllers[] = $prefix . $controller;
309: }
310:
311: usort($controllers, array('Horde_Routes_Utils', 'longestFirst'));
312:
313: return $controllers;
314: }
315:
316: /**
317: * Private function that takes a dict, and screens it against the current
318: * request dict to determine what the dict should look like that is used.
319: * This is responsible for the requests "memory" of the current.
320: */
321: private function _screenArgs($kargs)
322: {
323: if ($this->mapper->explicit && $this->mapper->subDomains) {
324: return $this->_subdomainCheck($kargs);
325: } else if ($this->mapper->explicit) {
326: return $kargs;
327: }
328:
329: $controllerName = (isset($kargs['controller'])) ? $kargs['controller'] : null;
330:
331: if (!empty($controllerName) && substr($controllerName, 0, 1) == '/') {
332: // If the controller name starts with '/', ignore route memory
333: $kargs['controller'] = substr($kargs['controller'], 1);
334: return $kargs;
335: } else if (!empty($controllerName) && !array_key_exists('action', $kargs)) {
336: // Fill in an action if we don't have one, but have a controller
337: $kargs['action'] = 'index';
338: }
339:
340: $memoryKargs = $this->mapperDict;
341:
342: // Remove keys from memory and kargs if kargs has them as null
343: foreach ($kargs as $key => $value) {
344: if ($value === null) {
345: unset($kargs[$key]);
346: if (array_key_exists($key, $memoryKargs)) {
347: unset($memoryKargs[$key]);
348: }
349: }
350: }
351:
352: // Merge the new args on top of the memory args
353: foreach ($kargs as $key => $value) {
354: $memoryKargs[$key] = $value;
355: }
356:
357: // Setup a sub-domain if applicable
358: if (!empty($this->mapper->subDomains)) {
359: $memoryKargs = $this->_subdomainCheck($memoryKargs);
360: }
361:
362: return $memoryKargs;
363: }
364:
365: /**
366: * Screen the kargs for a subdomain and alter it appropriately depending
367: * on the current subdomain or lack therof.
368: */
369: private function _subdomainCheck($kargs)
370: {
371: if ($this->mapper->subDomains) {
372: $subdomain = (isset($kargs['subDomain'])) ? $kargs['subDomain'] : null;
373: unset($kargs['subDomain']);
374:
375: $environ = $this->mapper->environ;
376: $http_host = isset($environ['HTTP_HOST']) ? $environ['HTTP_HOST'] : null;
377: $server_name = isset($environ['SERVER_NAME']) ? $environ['SERVER_NAME'] : null;
378: $fullhost = !is_null($http_host) ? $http_host : $server_name;
379:
380: $hostmatch = explode(':', $fullhost);
381: $host = $hostmatch[0];
382: $port = '';
383: if (count($hostmatch) > 1) {
384: $port .= ':' . $hostmatch[1];
385: }
386:
387: $subMatch = '^.+?\.(' . $this->mapper->domainMatch . ')$';
388: $domain = preg_replace("@$subMatch@", '$1', $host);
389:
390: if ($subdomain && (substr($host, 0, strlen($subdomain)) != $subdomain)
391: && (! in_array($subdomain, $this->mapper->subDomainsIgnore))) {
392: $kargs['_host'] = $subdomain . '.' . $domain . $port;
393: } else if (($subdomain === null || in_array($subdomain, $this->mapper->subDomainsIgnore))
394: && $domain != $host) {
395: $kargs['_host'] = $domain . $port;
396: }
397: return $kargs;
398: } else {
399: return $kargs;
400: }
401: }
402:
403: /**
404: * Quote a string containing a URL in a given encoding.
405: *
406: * @todo This is a placeholder. Multiple encodings aren't yet supported.
407: *
408: * @param string $url URL to encode
409: * @param string $encoding Encoding to use
410: */
411: public static function urlQuote($url, $encoding = null)
412: {
413: if ($encoding === null) {
414: return str_replace('%2F', '/', urlencode($url));
415: } else {
416: return str_replace('%2F', '/', urlencode(utf8_decode($url)));
417: }
418: }
419:
420: /**
421: * Callback used by usort() in controllerScan() to sort controller
422: * names by the longest first.
423: *
424: * @param string $fst First string to compare
425: * @param string $lst Last string to compare
426: * @return integer Difference of string length (first - last)
427: */
428: public static function longestFirst($fst, $lst)
429: {
430: return strlen($lst) - strlen($fst);
431: }
432:
433: /**
434: */
435: public static function arraySubtract($a1, $a2)
436: {
437: foreach ($a2 as $key) {
438: if (in_array($key, $a1)) {
439: unset($a1[array_search($key, $a1)]);
440: }
441: }
442: return $a1;
443: }
444: }
445: