1<?php 2 3/* 4 * This file is part of Twig. 5 * 6 * (c) Fabien Potencier 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Twig\Loader; 13 14use Twig\Error\LoaderError; 15use Twig\Source; 16 17/** 18 * Loads template from the filesystem. 19 * 20 * @author Fabien Potencier <fabien@symfony.com> 21 */ 22class FilesystemLoader implements LoaderInterface 23{ 24 /** Identifier of the main namespace. */ 25 public const MAIN_NAMESPACE = '__main__'; 26 27 /** 28 * @var array<string, list<string>> 29 */ 30 protected $paths = []; 31 protected $cache = []; 32 protected $errorCache = []; 33 34 private $rootPath; 35 36 /** 37 * @param string|string[] $paths A path or an array of paths where to look for templates 38 * @param string|null $rootPath The root path common to all relative paths (null for getcwd()) 39 */ 40 public function __construct($paths = [], ?string $rootPath = null) 41 { 42 $this->rootPath = ($rootPath ?? getcwd()).\DIRECTORY_SEPARATOR; 43 if (null !== $rootPath && false !== ($realPath = realpath($rootPath))) { 44 $this->rootPath = $realPath.\DIRECTORY_SEPARATOR; 45 } 46 47 if ($paths) { 48 $this->setPaths($paths); 49 } 50 } 51 52 /** 53 * Returns the paths to the templates. 54 * 55 * @return list<string> 56 */ 57 public function getPaths(string $namespace = self::MAIN_NAMESPACE): array 58 { 59 return $this->paths[$namespace] ?? []; 60 } 61 62 /** 63 * Returns the path namespaces. 64 * 65 * The main namespace is always defined. 66 * 67 * @return list<string> 68 */ 69 public function getNamespaces(): array 70 { 71 return array_keys($this->paths); 72 } 73 74 /** 75 * @param string|string[] $paths A path or an array of paths where to look for templates 76 */ 77 public function setPaths($paths, string $namespace = self::MAIN_NAMESPACE): void 78 { 79 if (!\is_array($paths)) { 80 $paths = [$paths]; 81 } 82 83 $this->paths[$namespace] = []; 84 foreach ($paths as $path) { 85 $this->addPath($path, $namespace); 86 } 87 } 88 89 /** 90 * @throws LoaderError 91 */ 92 public function addPath(string $path, string $namespace = self::MAIN_NAMESPACE): void 93 { 94 // invalidate the cache 95 $this->cache = $this->errorCache = []; 96 97 $checkPath = $this->isAbsolutePath($path) ? $path : $this->rootPath.$path; 98 if (!is_dir($checkPath)) { 99 throw new LoaderError(\sprintf('The "%s" directory does not exist ("%s").', $path, $checkPath)); 100 } 101 102 $this->paths[$namespace][] = rtrim($path, '/\\'); 103 } 104 105 /** 106 * @throws LoaderError 107 */ 108 public function prependPath(string $path, string $namespace = self::MAIN_NAMESPACE): void 109 { 110 // invalidate the cache 111 $this->cache = $this->errorCache = []; 112 113 $checkPath = $this->isAbsolutePath($path) ? $path : $this->rootPath.$path; 114 if (!is_dir($checkPath)) { 115 throw new LoaderError(\sprintf('The "%s" directory does not exist ("%s").', $path, $checkPath)); 116 } 117 118 $path = rtrim($path, '/\\'); 119 120 if (!isset($this->paths[$namespace])) { 121 $this->paths[$namespace][] = $path; 122 } else { 123 array_unshift($this->paths[$namespace], $path); 124 } 125 } 126 127 public function getSourceContext(string $name): Source 128 { 129 if (null === $path = $this->findTemplate($name)) { 130 return new Source('', $name, ''); 131 } 132 133 return new Source(file_get_contents($path), $name, $path); 134 } 135 136 public function getCacheKey(string $name): string 137 { 138 if (null === $path = $this->findTemplate($name)) { 139 return ''; 140 } 141 $len = \strlen($this->rootPath); 142 if (0 === strncmp($this->rootPath, $path, $len)) { 143 return substr($path, $len); 144 } 145 146 return $path; 147 } 148 149 /** 150 * @return bool 151 */ 152 public function exists(string $name) 153 { 154 $name = $this->normalizeName($name); 155 156 if (isset($this->cache[$name])) { 157 return true; 158 } 159 160 return null !== $this->findTemplate($name, false); 161 } 162 163 public function isFresh(string $name, int $time): bool 164 { 165 // false support to be removed in 3.0 166 if (null === $path = $this->findTemplate($name)) { 167 return false; 168 } 169 170 return filemtime($path) < $time; 171 } 172 173 /** 174 * @return string|null 175 */ 176 protected function findTemplate(string $name, bool $throw = true) 177 { 178 $name = $this->normalizeName($name); 179 180 if (isset($this->cache[$name])) { 181 return $this->cache[$name]; 182 } 183 184 if (isset($this->errorCache[$name])) { 185 if (!$throw) { 186 return null; 187 } 188 189 throw new LoaderError($this->errorCache[$name]); 190 } 191 192 try { 193 [$namespace, $shortname] = $this->parseName($name); 194 195 $this->validateName($shortname); 196 } catch (LoaderError $e) { 197 if (!$throw) { 198 return null; 199 } 200 201 throw $e; 202 } 203 204 if (!isset($this->paths[$namespace])) { 205 $this->errorCache[$name] = \sprintf('There are no registered paths for namespace "%s".', $namespace); 206 207 if (!$throw) { 208 return null; 209 } 210 211 throw new LoaderError($this->errorCache[$name]); 212 } 213 214 foreach ($this->paths[$namespace] as $path) { 215 if (!$this->isAbsolutePath($path)) { 216 $path = $this->rootPath.$path; 217 } 218 219 if (is_file($path.'/'.$shortname)) { 220 if (false !== $realpath = realpath($path.'/'.$shortname)) { 221 return $this->cache[$name] = $realpath; 222 } 223 224 return $this->cache[$name] = $path.'/'.$shortname; 225 } 226 } 227 228 $this->errorCache[$name] = \sprintf('Unable to find template "%s" (looked into: %s).', $name, implode(', ', $this->paths[$namespace])); 229 230 if (!$throw) { 231 return null; 232 } 233 234 throw new LoaderError($this->errorCache[$name]); 235 } 236 237 private function normalizeName(string $name): string 238 { 239 return preg_replace('#/{2,}#', '/', str_replace('\\', '/', $name)); 240 } 241 242 private function parseName(string $name, string $default = self::MAIN_NAMESPACE): array 243 { 244 if (isset($name[0]) && '@' == $name[0]) { 245 if (false === $pos = strpos($name, '/')) { 246 throw new LoaderError(\sprintf('Malformed namespaced template name "%s" (expecting "@namespace/template_name").', $name)); 247 } 248 249 $namespace = substr($name, 1, $pos - 1); 250 $shortname = substr($name, $pos + 1); 251 252 return [$namespace, $shortname]; 253 } 254 255 return [$default, $name]; 256 } 257 258 private function validateName(string $name): void 259 { 260 if (str_contains($name, "\0")) { 261 throw new LoaderError('A template name cannot contain NUL bytes.'); 262 } 263 264 $name = ltrim($name, '/'); 265 $parts = explode('/', $name); 266 $level = 0; 267 foreach ($parts as $part) { 268 if ('..' === $part) { 269 --$level; 270 } elseif ('.' !== $part) { 271 ++$level; 272 } 273 274 if ($level < 0) { 275 throw new LoaderError(\sprintf('Looks like you try to load a template outside configured directories (%s).', $name)); 276 } 277 } 278 } 279 280 private function isAbsolutePath(string $file): bool 281 { 282 return strspn($file, '/\\', 0, 1) 283 || (\strlen($file) > 3 && ctype_alpha($file[0]) 284 && ':' === $file[1] 285 && strspn($file, '/\\', 2, 1) 286 ) 287 || null !== parse_url($file, \PHP_URL_SCHEME) 288 ; 289 } 290} 291