@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 378 lines 10 kB view raw
1<?php 2 3/** 4 * Tracks and resolves dependencies the page declares with 5 * @{function:require_celerity_resource}, and then builds appropriate HTML or 6 * Ajax responses. 7 */ 8final class CelerityStaticResourceResponse extends Phobject { 9 10 private $symbols = array(); 11 private $needsResolve = true; 12 private $resolved; 13 private $packaged; 14 private $metadata = array(); 15 private $metadataBlock = 0; 16 private $metadataLocked; 17 private $behaviors = array(); 18 private $hasRendered = array(); 19 private $postprocessorKey; 20 private $contentSecurityPolicyURIs = array(); 21 22 public function __construct() { 23 if (isset($_REQUEST['__metablock__'])) { 24 $this->metadataBlock = (int)$_REQUEST['__metablock__']; 25 } 26 } 27 28 public function addMetadata($metadata) { 29 if ($this->metadataLocked) { 30 throw new Exception( 31 pht( 32 'Attempting to add more metadata after metadata has been '. 33 'locked.')); 34 } 35 36 $id = count($this->metadata); 37 $this->metadata[$id] = $metadata; 38 return $this->metadataBlock.'_'.$id; 39 } 40 41 public function addContentSecurityPolicyURI($kind, $uri) { 42 $this->contentSecurityPolicyURIs[$kind][] = $uri; 43 return $this; 44 } 45 46 public function getContentSecurityPolicyURIMap() { 47 return $this->contentSecurityPolicyURIs; 48 } 49 50 public function getMetadataBlock() { 51 return $this->metadataBlock; 52 } 53 54 public function setPostprocessorKey($postprocessor_key) { 55 $this->postprocessorKey = $postprocessor_key; 56 return $this; 57 } 58 59 public function getPostprocessorKey() { 60 return $this->postprocessorKey; 61 } 62 63 /** 64 * Register a behavior for initialization. 65 * 66 * NOTE: If `$config` is empty, a behavior will execute only once even if it 67 * is initialized multiple times. If `$config` is nonempty, the behavior will 68 * be invoked once for each configuration. 69 */ 70 public function initBehavior( 71 $behavior, 72 array $config = array(), 73 $source_name = null) { 74 75 $this->requireResource('javelin-behavior-'.$behavior, $source_name); 76 77 if (empty($this->behaviors[$behavior])) { 78 $this->behaviors[$behavior] = array(); 79 } 80 81 if ($config) { 82 $this->behaviors[$behavior][] = $config; 83 } 84 85 return $this; 86 } 87 88 public function requireResource($symbol, $source_name) { 89 if (isset($this->symbols[$source_name][$symbol])) { 90 return $this; 91 } 92 93 // Verify that the resource exists. 94 $map = CelerityResourceMap::getNamedInstance($source_name); 95 $name = $map->getResourceNameForSymbol($symbol); 96 if ($name === null) { 97 throw new Exception( 98 pht( 99 'No resource with symbol "%s" exists in source "%s"!', 100 $symbol, 101 $source_name)); 102 } 103 104 $this->symbols[$source_name][$symbol] = true; 105 $this->needsResolve = true; 106 107 return $this; 108 } 109 110 private function resolveResources() { 111 if ($this->needsResolve) { 112 $this->packaged = array(); 113 foreach ($this->symbols as $source_name => $symbols_map) { 114 $symbols = array_keys($symbols_map); 115 116 $map = CelerityResourceMap::getNamedInstance($source_name); 117 $packaged = $map->getPackagedNamesForSymbols($symbols); 118 119 $this->packaged[$source_name] = $packaged; 120 } 121 $this->needsResolve = false; 122 } 123 return $this; 124 } 125 126 public function renderSingleResource($symbol, $source_name) { 127 $map = CelerityResourceMap::getNamedInstance($source_name); 128 $packaged = $map->getPackagedNamesForSymbols(array($symbol)); 129 return $this->renderPackagedResources($map, $packaged); 130 } 131 132 public function renderResourcesOfType($type) { 133 $this->resolveResources(); 134 135 $result = array(); 136 foreach ($this->packaged as $source_name => $resource_names) { 137 $map = CelerityResourceMap::getNamedInstance($source_name); 138 139 $resources_of_type = array(); 140 foreach ($resource_names as $resource_name) { 141 $resource_type = $map->getResourceTypeForName($resource_name); 142 if ($resource_type == $type) { 143 $resources_of_type[] = $resource_name; 144 } 145 } 146 147 $result[] = $this->renderPackagedResources($map, $resources_of_type); 148 } 149 150 return phutil_implode_html('', $result); 151 } 152 153 private function renderPackagedResources( 154 CelerityResourceMap $map, 155 array $resources) { 156 157 $output = array(); 158 foreach ($resources as $name) { 159 if (isset($this->hasRendered[$name])) { 160 continue; 161 } 162 $this->hasRendered[$name] = true; 163 164 $output[] = $this->renderResource($map, $name); 165 } 166 167 return $output; 168 } 169 170 private function renderResource( 171 CelerityResourceMap $map, 172 $name) { 173 174 $uri = $this->getURI($map, $name); 175 $type = $map->getResourceTypeForName($name); 176 177 $multimeter = MultimeterControl::getInstance(); 178 if ($multimeter) { 179 $event_type = MultimeterEvent::TYPE_STATIC_RESOURCE; 180 $multimeter->newEvent($event_type, 'rsrc.'.$name, 1); 181 } 182 183 switch ($type) { 184 case 'css': 185 return phutil_tag( 186 'link', 187 array( 188 'rel' => 'stylesheet', 189 'type' => 'text/css', 190 'href' => $uri, 191 )); 192 case 'js': 193 return phutil_tag( 194 'script', 195 array( 196 'type' => 'text/javascript', 197 'src' => $uri, 198 ), 199 ''); 200 } 201 202 throw new Exception( 203 pht( 204 'Unable to render resource "%s", which has unknown type "%s".', 205 $name, 206 $type)); 207 } 208 209 public function renderHTMLFooter($is_frameable) { 210 $this->metadataLocked = true; 211 212 $merge_data = array( 213 'block' => $this->metadataBlock, 214 'data' => $this->metadata, 215 ); 216 $this->metadata = array(); 217 218 $behavior_lists = array(); 219 if ($this->behaviors) { 220 $behaviors = $this->behaviors; 221 $this->behaviors = array(); 222 223 $higher_priority_names = array( 224 'refresh-csrf', 225 'aphront-basic-tokenizer', 226 'dark-console', 227 'history-install', 228 ); 229 230 $higher_priority_behaviors = array_select_keys( 231 $behaviors, 232 $higher_priority_names); 233 234 foreach ($higher_priority_names as $name) { 235 unset($behaviors[$name]); 236 } 237 238 $behavior_groups = array( 239 $higher_priority_behaviors, 240 $behaviors, 241 ); 242 243 foreach ($behavior_groups as $group) { 244 if (!$group) { 245 continue; 246 } 247 $behavior_lists[] = $group; 248 } 249 } 250 251 $initializers = array(); 252 253 // Even if there is no metadata on the page, Javelin uses the mergeData() 254 // call to start dispatching the event queue, so we always want to include 255 // this initializer. 256 $initializers[] = array( 257 'kind' => 'merge', 258 'data' => $merge_data, 259 ); 260 261 foreach ($behavior_lists as $behavior_list) { 262 $initializers[] = array( 263 'kind' => 'behaviors', 264 'data' => $behavior_list, 265 ); 266 } 267 268 if ($is_frameable) { 269 $initializers[] = array( 270 'data' => 'frameable', 271 'kind' => (bool)$is_frameable, 272 ); 273 } 274 275 $tags = array(); 276 foreach ($initializers as $initializer) { 277 $data = $initializer['data']; 278 if (is_array($data)) { 279 $json_data = AphrontResponse::encodeJSONForHTTPResponse($data); 280 } else { 281 $json_data = json_encode($data); 282 } 283 284 $tags[] = phutil_tag( 285 'data', 286 array( 287 'data-javelin-init-kind' => $initializer['kind'], 288 'data-javelin-init-data' => $json_data, 289 )); 290 } 291 292 return $tags; 293 } 294 295 public static function renderInlineScript($data) { 296 if (stripos($data, '</script>') !== false) { 297 throw new Exception( 298 pht( 299 'Literal %s is not allowed inside inline script.', 300 '</script>')); 301 } 302 if (strpos($data, '<!') !== false) { 303 throw new Exception( 304 pht( 305 'Literal %s is not allowed inside inline script.', 306 '<!')); 307 } 308 // We don't use <![CDATA[ ]]> because it is ignored by HTML parsers. We 309 // would need to send the document with XHTML content type. 310 return phutil_tag( 311 'script', 312 array('type' => 'text/javascript'), 313 phutil_safe_html($data)); 314 } 315 316 public function buildAjaxResponse($payload, $error = null) { 317 $response = array( 318 'error' => $error, 319 'payload' => $payload, 320 ); 321 322 if ($this->metadata) { 323 $response['javelin_metadata'] = $this->metadata; 324 $this->metadata = array(); 325 } 326 327 if ($this->behaviors) { 328 $response['javelin_behaviors'] = $this->behaviors; 329 $this->behaviors = array(); 330 } 331 332 $this->resolveResources(); 333 $resources = array(); 334 foreach ($this->packaged as $source_name => $resource_names) { 335 $map = CelerityResourceMap::getNamedInstance($source_name); 336 foreach ($resource_names as $resource_name) { 337 $resources[] = $this->getURI($map, $resource_name); 338 } 339 } 340 if ($resources) { 341 $response['javelin_resources'] = $resources; 342 } 343 344 return $response; 345 } 346 347 public function getURI( 348 CelerityResourceMap $map, 349 $name, 350 $use_primary_domain = false) { 351 352 $uri = $map->getURIForName($name); 353 354 // If we have a postprocessor selected, add it to the URI. 355 $postprocessor_key = $this->getPostprocessorKey(); 356 if ($postprocessor_key) { 357 $uri = preg_replace('@^/res/@', '/res/'.$postprocessor_key.'X/', $uri); 358 } 359 360 // In developer mode, we dump file modification times into the URI. When a 361 // page is reloaded in the browser, any resources brought in by Ajax calls 362 // do not trigger revalidation, so without this it's very difficult to get 363 // changes to Ajaxed-in CSS to work (you must clear your cache or rerun 364 // the map script). In production, we can assume the map script gets run 365 // after changes, and safely skip this. 366 if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) { 367 $mtime = $map->getModifiedTimeForName($name); 368 $uri = preg_replace('@^/res/@', '/res/'.$mtime.'T/', $uri); 369 } 370 371 if ($use_primary_domain) { 372 return PhabricatorEnv::getURI($uri); 373 } else { 374 return PhabricatorEnv::getCDNURI($uri); 375 } 376 } 377 378}