@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 1341 lines 42 kB view raw
1<?php 2 3final class DiffusionServeController extends DiffusionController { 4 5 private $serviceViewer; 6 private $serviceRepository; 7 8 private $isGitLFSRequest; 9 private $gitLFSToken; 10 private $gitLFSInput; 11 12 public function setServiceViewer(PhabricatorUser $viewer) { 13 $this->getRequest()->setUser($viewer); 14 15 $this->serviceViewer = $viewer; 16 return $this; 17 } 18 19 public function getServiceViewer() { 20 return $this->serviceViewer; 21 } 22 23 public function setServiceRepository(PhabricatorRepository $repository) { 24 $this->serviceRepository = $repository; 25 return $this; 26 } 27 28 public function getServiceRepository() { 29 return $this->serviceRepository; 30 } 31 32 public function getIsGitLFSRequest() { 33 return $this->isGitLFSRequest; 34 } 35 36 public function getGitLFSToken() { 37 return $this->gitLFSToken; 38 } 39 40 public function isVCSRequest(AphrontRequest $request) { 41 $identifier = $this->getRepositoryIdentifierFromRequest($request); 42 if ($identifier === null) { 43 return null; 44 } 45 46 $content_type = $request->getHTTPHeader('Content-Type', ''); 47 $user_agent = idx($_SERVER, 'HTTP_USER_AGENT', ''); 48 $request_type = $request->getHTTPHeader('X-Phabricator-Request-Type'); 49 50 // This may have a "charset" suffix, so only match the prefix. 51 $lfs_pattern = '(^application/vnd\\.git-lfs\\+json(;|\z))'; 52 53 $vcs = null; 54 if ($request->getExists('service')) { 55 $service = $request->getStr('service'); 56 // We get this initially for `info/refs`. 57 // Git also gives us a User-Agent like "git/1.8.2.3". 58 $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; 59 } else if (strncmp($user_agent, 'git/', 4) === 0) { 60 $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; 61 } else if ($content_type == 'application/x-git-upload-pack-request') { 62 // We get this for `git-upload-pack`. 63 $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; 64 } else if ($content_type == 'application/x-git-receive-pack-request') { 65 // We get this for `git-receive-pack`. 66 $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; 67 } else if (preg_match($lfs_pattern, $content_type)) { 68 // This is a Git LFS HTTP API request. 69 $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; 70 $this->isGitLFSRequest = true; 71 } else if ($request_type == 'git-lfs') { 72 // This is a Git LFS object content request. 73 $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; 74 $this->isGitLFSRequest = true; 75 } else if ($request->getExists('cmd')) { 76 // Mercurial also sends an Accept header like 77 // "application/mercurial-0.1", and a User-Agent like 78 // "mercurial/proto-1.0". 79 $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL; 80 } else { 81 // Subversion also sends an initial OPTIONS request (vs GET/POST), and 82 // has a User-Agent like "SVN/1.8.3 (x86_64-apple-darwin11.4.2) 83 // serf/1.3.2". 84 $dav = $request->getHTTPHeader('DAV'); 85 $dav = new PhutilURI($dav); 86 if ($dav->getDomain() === 'subversion.tigris.org') { 87 $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_SVN; 88 } 89 } 90 91 return $vcs; 92 } 93 94 public function handleRequest(AphrontRequest $request) { 95 $service_exception = null; 96 $response = null; 97 98 try { 99 $response = $this->serveRequest($request); 100 } catch (Exception $ex) { 101 $service_exception = $ex; 102 } 103 104 try { 105 $remote_addr = $request->getRemoteAddress(); 106 107 if ($request->isHTTPS()) { 108 $remote_protocol = PhabricatorRepositoryPullEvent::PROTOCOL_HTTPS; 109 } else { 110 $remote_protocol = PhabricatorRepositoryPullEvent::PROTOCOL_HTTP; 111 } 112 113 $pull_event = id(new PhabricatorRepositoryPullEvent()) 114 ->setEpoch(PhabricatorTime::getNow()) 115 ->setRemoteAddress($remote_addr) 116 ->setRemoteProtocol($remote_protocol); 117 118 if ($response) { 119 $response_code = $response->getHTTPResponseCode(); 120 121 if ($response_code == 200) { 122 $pull_event 123 ->setResultType(PhabricatorRepositoryPullEvent::RESULT_PULL) 124 ->setResultCode($response_code); 125 } else { 126 $pull_event 127 ->setResultType(PhabricatorRepositoryPullEvent::RESULT_ERROR) 128 ->setResultCode($response_code); 129 } 130 131 if ($response instanceof PhabricatorVCSResponse) { 132 $pull_event->setProperties( 133 array( 134 'response.message' => $response->getMessage(), 135 )); 136 } 137 } else { 138 $pull_event 139 ->setResultType(PhabricatorRepositoryPullEvent::RESULT_EXCEPTION) 140 ->setResultCode(500) 141 ->setProperties( 142 array( 143 'exception.class' => get_class($ex), 144 'exception.message' => $ex->getMessage(), 145 )); 146 } 147 148 $viewer = $this->getServiceViewer(); 149 if ($viewer) { 150 $pull_event->setPullerPHID($viewer->getPHID()); 151 } 152 153 $repository = $this->getServiceRepository(); 154 if ($repository) { 155 $pull_event->setRepositoryPHID($repository->getPHID()); 156 } 157 158 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 159 $pull_event->save(); 160 unset($unguarded); 161 162 } catch (Exception $ex) { 163 if ($service_exception) { 164 throw $service_exception; 165 } 166 throw $ex; 167 } 168 169 if ($service_exception) { 170 throw $service_exception; 171 } 172 173 return $response; 174 } 175 176 private function serveRequest(AphrontRequest $request) { 177 $identifier = $this->getRepositoryIdentifierFromRequest($request); 178 179 // If authentication credentials have been provided, try to find a user 180 // that actually matches those credentials. 181 182 // We require both the username and password to be nonempty, because Git 183 // won't prompt users who provide a username but no password otherwise. 184 // See T10797 for discussion. 185 186 $have_user = phutil_nonempty_string(idx($_SERVER, 'PHP_AUTH_USER')); 187 $have_pass = phutil_nonempty_string(idx($_SERVER, 'PHP_AUTH_PW')); 188 if ($this->getIsGitLFSRequest() && !($have_user && $have_pass)) { 189 return new PhabricatorVCSResponse( 190 401, 191 pht('Git-LFS Authentication required')); 192 } 193 if ($have_user && $have_pass) { 194 $username = $_SERVER['PHP_AUTH_USER']; 195 $password = new PhutilOpaqueEnvelope($_SERVER['PHP_AUTH_PW']); 196 197 // Try Git LFS auth first since we can usually reject it without doing 198 // any queries, since the username won't match the one we expect or the 199 // request won't be LFS. 200 $viewer = $this->authenticateGitLFSUser( 201 $username, 202 $password, 203 $identifier); 204 205 // If that failed, try normal auth. Note that we can use normal auth on 206 // LFS requests, so this isn't strictly an alternative to LFS auth. 207 if (!$viewer) { 208 $viewer = $this->authenticateHTTPRepositoryUser($username, $password); 209 } 210 211 if (!$viewer) { 212 return new PhabricatorVCSResponse( 213 403, 214 pht('Invalid credentials.')); 215 } 216 } else { 217 // User hasn't provided credentials, which means we count them as 218 // being "not logged in". 219 $viewer = new PhabricatorUser(); 220 } 221 222 // See T13590. Some pathways, like error handling, may require unusual 223 // access to things like timezone information. These are fine to build 224 // inline; this pathway is not lightweight anyway. 225 $viewer->setAllowInlineCacheGeneration(true); 226 227 $this->setServiceViewer($viewer); 228 229 $allow_public = PhabricatorEnv::getEnvConfig('policy.allow-public'); 230 $allow_auth = PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth'); 231 if (!$allow_public) { 232 if (!$viewer->isLoggedIn()) { 233 if ($allow_auth) { 234 return new PhabricatorVCSResponse( 235 401, 236 pht('You must log in to access repositories.')); 237 } else { 238 return new PhabricatorVCSResponse( 239 403, 240 pht('Public and authenticated HTTP access are both forbidden.')); 241 } 242 } 243 } 244 245 try { 246 $repository = id(new PhabricatorRepositoryQuery()) 247 ->setViewer($viewer) 248 ->withIdentifiers(array($identifier)) 249 ->needURIs(true) 250 ->executeOne(); 251 if (!$repository) { 252 return new PhabricatorVCSResponse( 253 404, 254 pht('No such repository exists.')); 255 } 256 } catch (PhabricatorPolicyException $ex) { 257 if ($viewer->isLoggedIn()) { 258 return new PhabricatorVCSResponse( 259 403, 260 pht('You do not have permission to access this repository.')); 261 } else { 262 if ($allow_auth) { 263 return new PhabricatorVCSResponse( 264 401, 265 pht('You must log in to access this repository.')); 266 } else { 267 return new PhabricatorVCSResponse( 268 403, 269 pht( 270 'This repository requires authentication, which is forbidden '. 271 'over HTTP.')); 272 } 273 } 274 } 275 276 $response = $this->validateGitLFSRequest($repository, $viewer); 277 if ($response) { 278 return $response; 279 } 280 281 $this->setServiceRepository($repository); 282 283 if (!$repository->isTracked()) { 284 return new PhabricatorVCSResponse( 285 403, 286 pht('This repository is inactive.')); 287 } 288 289 $is_push = !$this->isReadOnlyRequest($repository); 290 291 if ($this->getIsGitLFSRequest() && $this->getGitLFSToken()) { 292 // We allow git LFS requests over HTTP even if the repository does not 293 // otherwise support HTTP reads or writes, as long as the user is using a 294 // token from SSH. If they're using HTTP username + password auth, they 295 // have to obey the normal HTTP rules. 296 } else { 297 // For now, we don't distinguish between HTTP and HTTPS-originated 298 // requests that are proxied within the cluster, so the user can connect 299 // with HTTPS but we may be on HTTP by the time we reach this part of 300 // the code. Allow things to move forward as long as either protocol 301 // can be served. 302 $proto_https = PhabricatorRepositoryURI::BUILTIN_PROTOCOL_HTTPS; 303 $proto_http = PhabricatorRepositoryURI::BUILTIN_PROTOCOL_HTTP; 304 305 $can_read = 306 $repository->canServeProtocol($proto_https, false) || 307 $repository->canServeProtocol($proto_http, false); 308 if (!$can_read) { 309 return new PhabricatorVCSResponse( 310 403, 311 pht('This repository is not available over HTTP.')); 312 } 313 314 if ($is_push) { 315 if ($repository->isReadOnly()) { 316 return new PhabricatorVCSResponse( 317 503, 318 $repository->getReadOnlyMessageForDisplay()); 319 } 320 321 $can_write = 322 $repository->canServeProtocol($proto_https, true) || 323 $repository->canServeProtocol($proto_http, true); 324 if (!$can_write) { 325 return new PhabricatorVCSResponse( 326 403, 327 pht('This repository is read-only over HTTP.')); 328 } 329 } 330 } 331 332 if ($is_push) { 333 $can_push = PhabricatorPolicyFilter::hasCapability( 334 $viewer, 335 $repository, 336 DiffusionPushCapability::CAPABILITY); 337 if (!$can_push) { 338 if ($viewer->isLoggedIn()) { 339 $error_code = 403; 340 $error_message = pht( 341 'You do not have permission to push to this repository ("%s").', 342 $repository->getDisplayName()); 343 344 if ($this->getIsGitLFSRequest()) { 345 return DiffusionGitLFSResponse::newErrorResponse( 346 $error_code, 347 $error_message); 348 } else { 349 return new PhabricatorVCSResponse( 350 $error_code, 351 $error_message); 352 } 353 } else { 354 if ($allow_auth) { 355 return new PhabricatorVCSResponse( 356 401, 357 pht('You must log in to push to this repository.')); 358 } else { 359 return new PhabricatorVCSResponse( 360 403, 361 pht( 362 'Pushing to this repository requires authentication, '. 363 'which is forbidden over HTTP.')); 364 } 365 } 366 } 367 } 368 369 // When considering whether or not a request is mismatched for a given 370 // repository type, there's only two considerations: whether or not the 371 // known type matches the request vcs type, and whether it's actually a 372 // git-lfs request. When it's a git-lfs request, then the vcs types may or 373 // may not match (in the case of using the Mercurial git-lfs extension, for 374 // instance), so don't throw errors for mismatched types. 375 $vcs_type = $repository->getVersionControlSystem(); 376 $req_type = $this->isVCSRequest($request); 377 if ($vcs_type != $req_type && !$this->getIsGitLFSRequest()) { 378 switch ($req_type) { 379 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: 380 $result = new PhabricatorVCSResponse( 381 500, 382 pht( 383 'This repository ("%s") is not a Git repository.', 384 $repository->getDisplayName())); 385 break; 386 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: 387 $result = new PhabricatorVCSResponse( 388 500, 389 pht( 390 'This repository ("%s") is not a Mercurial repository.', 391 $repository->getDisplayName())); 392 break; 393 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: 394 $result = new PhabricatorVCSResponse( 395 500, 396 pht( 397 'This repository ("%s") is not a Subversion repository.', 398 $repository->getDisplayName())); 399 break; 400 default: 401 $result = new PhabricatorVCSResponse( 402 500, 403 pht('Unknown request type.')); 404 break; 405 } 406 } else { 407 switch ($vcs_type) { 408 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: 409 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: 410 $caught = null; 411 try { 412 $result = $this->serveVCSRequest($repository, $viewer); 413 } catch (Exception $ex) { 414 $caught = $ex; 415 } catch (Throwable $ex) { 416 $caught = $ex; 417 } 418 419 if ($caught) { 420 // We never expect an uncaught exception here, so dump it to the 421 // log. All routine errors should have been converted into Response 422 // objects by a lower layer. 423 phlog($caught); 424 425 $result = new PhabricatorVCSResponse( 426 500, 427 phutil_string_cast($caught->getMessage())); 428 } 429 break; 430 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: 431 $result = new PhabricatorVCSResponse( 432 500, 433 pht( 434 'This server does not support HTTP access to Subversion '. 435 'repositories.')); 436 break; 437 default: 438 $result = new PhabricatorVCSResponse( 439 500, 440 pht('Unknown version control system.')); 441 break; 442 } 443 } 444 445 $code = $result->getHTTPResponseCode(); 446 447 if ($is_push && ($code == 200)) { 448 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 449 $repository->writeStatusMessage( 450 PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, 451 PhabricatorRepositoryStatusMessage::CODE_OKAY); 452 unset($unguarded); 453 } 454 455 return $result; 456 } 457 458 private function serveVCSRequest( 459 PhabricatorRepository $repository, 460 PhabricatorUser $viewer) { 461 462 // We can serve Git LFS requests first, since we don't need to proxy them. 463 // It's also important that LFS requests never fall through to standard 464 // service pathways, because that would let you use LFS tokens to read 465 // normal repository data. 466 if ($this->getIsGitLFSRequest()) { 467 return $this->serveGitLFSRequest($repository, $viewer); 468 } 469 470 // If this repository is hosted on a service, we need to proxy the request 471 // to a host which can serve it. 472 $is_cluster_request = $this->getRequest()->isProxiedClusterRequest(); 473 474 $uri = $repository->getAlmanacServiceURI( 475 $viewer, 476 array( 477 'neverProxy' => $is_cluster_request, 478 'protocols' => array( 479 'http', 480 'https', 481 ), 482 'writable' => !$this->isReadOnlyRequest($repository), 483 )); 484 if ($uri) { 485 $future = $this->getRequest()->newClusterProxyFuture($uri); 486 return id(new AphrontHTTPProxyResponse()) 487 ->setHTTPFuture($future); 488 } 489 490 // Otherwise, we're going to handle the request locally. 491 492 $vcs_type = $repository->getVersionControlSystem(); 493 switch ($vcs_type) { 494 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: 495 $result = $this->serveGitRequest($repository, $viewer); 496 break; 497 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: 498 $result = $this->serveMercurialRequest($repository, $viewer); 499 break; 500 } 501 502 return $result; 503 } 504 505 /** 506 * @return bool 507 */ 508 private function isReadOnlyRequest( 509 PhabricatorRepository $repository) { 510 $request = $this->getRequest(); 511 $method = $_SERVER['REQUEST_METHOD']; 512 513 // TODO: This implementation is safe by default, but very incomplete. 514 515 if ($this->getIsGitLFSRequest()) { 516 return $this->isGitLFSReadOnlyRequest($repository); 517 } 518 519 switch ($repository->getVersionControlSystem()) { 520 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: 521 $service = $request->getStr('service'); 522 $path = $this->getRequestDirectoryPath($repository); 523 // NOTE: Service names are the reverse of what you might expect, as they 524 // are from the point of view of the server. The main read service is 525 // "git-upload-pack", and the main write service is "git-receive-pack". 526 527 if ($method == 'GET' && 528 $path == '/info/refs' && 529 $service == 'git-upload-pack') { 530 return true; 531 } 532 533 if ($path == '/git-upload-pack') { 534 return true; 535 } 536 537 break; 538 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: 539 $cmd = $request->getStr('cmd'); 540 if ($cmd == 'batch') { 541 $cmds = idx($this->getMercurialArguments(), 'cmds'); 542 return DiffusionMercurialWireProtocol::isReadOnlyBatchCommand($cmds); 543 } 544 return DiffusionMercurialWireProtocol::isReadOnlyCommand($cmd); 545 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: 546 break; 547 } 548 549 return false; 550 } 551 552 /** 553 * @phutil-external-symbol class PhabricatorStartup 554 */ 555 private function serveGitRequest( 556 PhabricatorRepository $repository, 557 PhabricatorUser $viewer) { 558 $request = $this->getRequest(); 559 560 $request_path = $this->getRequestDirectoryPath($repository); 561 $repository_root = $repository->getLocalPath(); 562 563 // Rebuild the query string to strip `__magic__` parameters and prevent 564 // issues where we might interpret inputs like "service=read&service=write" 565 // differently than the server does and pass it an unsafe command. 566 567 // NOTE: This does not use getPassthroughRequestParameters() because 568 // that code is HTTP-method agnostic and will encode POST data. 569 570 $query_data = $_GET; 571 foreach ($query_data as $key => $value) { 572 if (!strncmp($key, '__', 2)) { 573 unset($query_data[$key]); 574 } 575 } 576 $query_string = phutil_build_http_querystring($query_data); 577 578 // We're about to wipe out PATH with the rest of the environment, so 579 // resolve the binary first. 580 $bin = Filesystem::resolveBinary('git-http-backend'); 581 if (!$bin) { 582 throw new Exception( 583 pht( 584 'Unable to find `%s` in %s!', 585 'git-http-backend', 586 '$PATH')); 587 } 588 589 // NOTE: We do not set HTTP_CONTENT_ENCODING here, because we already 590 // decompressed the request when we read the request body, so the body is 591 // just plain data with no encoding. 592 593 $env = array( 594 'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'], 595 'QUERY_STRING' => $query_string, 596 'CONTENT_TYPE' => $request->getHTTPHeader('Content-Type'), 597 'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'], 598 'GIT_PROJECT_ROOT' => $repository_root, 599 'GIT_HTTP_EXPORT_ALL' => '1', 600 'PATH_INFO' => $request_path, 601 602 'REMOTE_USER' => $viewer->getUsername(), 603 604 // TODO: Set these correctly. 605 // GIT_COMMITTER_NAME 606 // GIT_COMMITTER_EMAIL 607 ) + $this->getCommonEnvironment($viewer); 608 609 $input = PhabricatorStartup::getRawInput(); 610 611 $command = csprintf('%s', $bin); 612 $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); 613 614 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 615 616 $cluster_engine = id(new DiffusionRepositoryClusterEngine()) 617 ->setViewer($viewer) 618 ->setRepository($repository); 619 620 $did_write_lock = false; 621 if ($this->isReadOnlyRequest($repository)) { 622 $cluster_engine->synchronizeWorkingCopyBeforeRead(); 623 } else { 624 $did_write_lock = true; 625 $cluster_engine->synchronizeWorkingCopyBeforeWrite(); 626 } 627 628 $caught = null; 629 try { 630 list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) 631 ->setEnv($env, true) 632 ->write($input) 633 ->resolve(); 634 } catch (Exception $ex) { 635 $caught = $ex; 636 } 637 638 if ($did_write_lock) { 639 $cluster_engine->synchronizeWorkingCopyAfterWrite(); 640 } 641 642 unset($unguarded); 643 644 if ($caught) { 645 throw $caught; 646 } 647 648 if ($err) { 649 if ($this->isValidGitShallowCloneResponse($stdout, $stderr)) { 650 // Ignore the error if the response passes this special check for 651 // validity. 652 $err = 0; 653 } 654 } 655 656 if ($err) { 657 return new PhabricatorVCSResponse( 658 500, 659 pht( 660 'Error %d: %s', 661 $err, 662 phutil_utf8ize($stderr))); 663 } 664 665 return id(new DiffusionGitResponse())->setGitData($stdout); 666 } 667 668 /** 669 * @return string 670 */ 671 private function getRequestDirectoryPath(PhabricatorRepository $repository) { 672 $request = $this->getRequest(); 673 $request_path = $request->getRequestURI()->getPath(); 674 675 $info = PhabricatorRepository::parseRepositoryServicePath( 676 $request_path, 677 $repository->getVersionControlSystem()); 678 if ($info) { 679 $base_path = $info['path']; 680 } else { 681 $base_path = ''; 682 } 683 684 // For Git repositories, strip an optional directory component if it 685 // isn't the name of a known Git resource. This allows users to clone 686 // repositories as "/diffusion/X/anything.git", for example. 687 if ($repository->isGit()) { 688 $known = array( 689 'info', 690 'git-upload-pack', 691 'git-receive-pack', 692 ); 693 694 foreach ($known as $key => $path) { 695 $known[$key] = preg_quote($path, '@'); 696 } 697 698 $known = implode('|', $known); 699 700 if (preg_match('@^/([^/]+)/('.$known.')(/|$)@', $base_path)) { 701 $base_path = preg_replace('@^/([^/]+)@', '', $base_path); 702 } 703 } 704 705 return $base_path; 706 } 707 708 private function authenticateGitLFSUser( 709 $username, 710 PhutilOpaqueEnvelope $password, 711 $identifier) { 712 713 // Never accept these credentials for requests which aren't LFS requests. 714 if (!$this->getIsGitLFSRequest()) { 715 return null; 716 } 717 718 // If we have the wrong username, don't bother checking if the token 719 // is right. 720 if ($username !== DiffusionGitLFSTemporaryTokenType::HTTP_USERNAME) { 721 return null; 722 } 723 724 // See PHI1123. We need to be able to constrain the token query with 725 // "withTokenResources(...)" to take advantage of the key on the table. 726 // In this case, the repository PHID is the "resource" we're after. 727 728 // In normal workflows, we figure out the viewer first, then use the 729 // viewer to load the repository, but that won't work here. Load the 730 // repository as the omnipotent viewer, then use the repository PHID to 731 // look for a token. 732 733 $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); 734 735 $repository = id(new PhabricatorRepositoryQuery()) 736 ->setViewer($omnipotent_viewer) 737 ->withIdentifiers(array($identifier)) 738 ->executeOne(); 739 if (!$repository) { 740 return null; 741 } 742 743 $lfs_pass = $password->openEnvelope(); 744 $lfs_hash = PhabricatorHash::weakDigest($lfs_pass); 745 746 $token = id(new PhabricatorAuthTemporaryTokenQuery()) 747 ->setViewer($omnipotent_viewer) 748 ->withTokenResources(array($repository->getPHID())) 749 ->withTokenTypes(array(DiffusionGitLFSTemporaryTokenType::TOKENTYPE)) 750 ->withTokenCodes(array($lfs_hash)) 751 ->withExpired(false) 752 ->executeOne(); 753 if (!$token) { 754 return null; 755 } 756 757 $user = id(new PhabricatorPeopleQuery()) 758 ->setViewer($omnipotent_viewer) 759 ->withPHIDs(array($token->getUserPHID())) 760 ->executeOne(); 761 762 if (!$user) { 763 return null; 764 } 765 766 if (!$user->isUserActivated()) { 767 return null; 768 } 769 770 $this->gitLFSToken = $token; 771 return $user; 772 } 773 774 private function authenticateHTTPRepositoryUser( 775 $username, 776 PhutilOpaqueEnvelope $password) { 777 778 if (!PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth')) { 779 // No HTTP auth permitted. 780 return null; 781 } 782 783 if (!strlen($username)) { 784 // No username. 785 return null; 786 } 787 788 if (!strlen($password->openEnvelope())) { 789 // No password. 790 return null; 791 } 792 793 $user = id(new PhabricatorPeopleQuery()) 794 ->setViewer(PhabricatorUser::getOmnipotentUser()) 795 ->withUsernames(array($username)) 796 ->executeOne(); 797 if (!$user) { 798 // Username doesn't match anything. 799 return null; 800 } 801 802 if (!$user->isUserActivated()) { 803 // User is not activated. 804 return null; 805 } 806 807 $request = $this->getRequest(); 808 $content_source = PhabricatorContentSource::newFromRequest($request); 809 810 $engine = id(new PhabricatorAuthPasswordEngine()) 811 ->setViewer($user) 812 ->setContentSource($content_source) 813 ->setPasswordType(PhabricatorAuthPassword::PASSWORD_TYPE_VCS) 814 ->setObject($user); 815 816 if (!$engine->isValidPassword($password)) { 817 return null; 818 } 819 820 return $user; 821 } 822 823 private function serveMercurialRequest( 824 PhabricatorRepository $repository, 825 PhabricatorUser $viewer) { 826 $request = $this->getRequest(); 827 828 $bin = Filesystem::resolveBinary('hg'); 829 if (!$bin) { 830 throw new Exception( 831 pht( 832 'Unable to find `%s` in %s!', 833 'hg', 834 '$PATH')); 835 } 836 837 $env = $this->getCommonEnvironment($viewer); 838 $input = PhabricatorStartup::getRawInput(); 839 840 $cmd = $request->getStr('cmd'); 841 842 $args = $this->getMercurialArguments(); 843 $args = $this->formatMercurialArguments($cmd, $args); 844 845 if (strlen($input)) { 846 $input = strlen($input)."\n".$input."0\n"; 847 } 848 849 $command = csprintf( 850 '%s -R %s serve --stdio', 851 $bin, 852 $repository->getLocalPath()); 853 $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); 854 855 list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) 856 ->setEnv($env, true) 857 ->setCWD($repository->getLocalPath()) 858 ->write("{$cmd}\n{$args}{$input}") 859 ->resolve(); 860 861 if ($err) { 862 return new PhabricatorVCSResponse( 863 500, 864 pht('Error %d: %s', $err, $stderr)); 865 } 866 867 if ($cmd == 'getbundle' || 868 $cmd == 'changegroup' || 869 $cmd == 'changegroupsubset') { 870 // We're not completely sure that "changegroup" and "changegroupsubset" 871 // actually work, they're for very old Mercurial. 872 $body = gzcompress($stdout); 873 } else if ($cmd == 'unbundle') { 874 // This includes diagnostic information and anything echoed by commit 875 // hooks. We ignore `stdout` since it just has protocol garbage, and 876 // substitute `stderr`. 877 $body = strlen($stderr)."\n".$stderr; 878 } else { 879 list($length, $body) = explode("\n", $stdout, 2); 880 if ($cmd == 'capabilities') { 881 $body = DiffusionMercurialWireProtocol::filterBundle2Capability($body); 882 } 883 } 884 885 return id(new DiffusionMercurialResponse())->setContent($body); 886 } 887 888 private function getMercurialArguments() { 889 // Mercurial sends arguments in HTTP headers. "Why?", you might wonder, 890 // "Why would you do this?". 891 892 $args_raw = array(); 893 for ($ii = 1;; $ii++) { 894 $header = 'HTTP_X_HGARG_'.$ii; 895 if (!array_key_exists($header, $_SERVER)) { 896 break; 897 } 898 $args_raw[] = $_SERVER[$header]; 899 } 900 901 if ($args_raw) { 902 $args_raw = implode('', $args_raw); 903 return id(new PhutilQueryStringParser()) 904 ->parseQueryString($args_raw); 905 } 906 907 // Sometimes arguments come in via the query string. Note that this will 908 // not handle multi-value entries e.g. "a[]=1,a[]=2" however it's unclear 909 // whether or how the mercurial protocol should handle this. 910 $query = idx($_SERVER, 'QUERY_STRING', ''); 911 $query_pairs = id(new PhutilQueryStringParser()) 912 ->parseQueryString($query); 913 foreach ($query_pairs as $key => $value) { 914 // Filter out private/internal keys as well as the command itself. 915 if (strncmp($key, '__', 2) && $key != 'cmd') { 916 $args_raw[$key] = $value; 917 } 918 } 919 920 // TODO: Arguments can also come in via request body for POST requests. The 921 // body would be all arguments, url-encoded. 922 return $args_raw; 923 } 924 925 private function formatMercurialArguments($command, array $arguments) { 926 $spec = DiffusionMercurialWireProtocol::getCommandArgs($command); 927 928 $out = array(); 929 930 // Mercurial takes normal arguments like this: 931 // 932 // name <length(value)> 933 // value 934 935 $has_star = false; 936 foreach ($spec as $arg_key) { 937 if ($arg_key == '*') { 938 $has_star = true; 939 continue; 940 } 941 if (isset($arguments[$arg_key])) { 942 $value = $arguments[$arg_key]; 943 $size = strlen($value); 944 $out[] = "{$arg_key} {$size}\n{$value}"; 945 unset($arguments[$arg_key]); 946 } 947 } 948 949 if ($has_star) { 950 951 // Mercurial takes arguments for variable argument lists roughly like 952 // this: 953 // 954 // * <count(args)> 955 // argname1 <length(argvalue1)> 956 // argvalue1 957 // argname2 <length(argvalue2)> 958 // argvalue2 959 960 $count = count($arguments); 961 962 $out[] = "* {$count}\n"; 963 964 foreach ($arguments as $key => $value) { 965 if (in_array($key, $spec)) { 966 // We already added this argument above, so skip it. 967 continue; 968 } 969 $size = strlen($value); 970 $out[] = "{$key} {$size}\n{$value}"; 971 } 972 } 973 974 return implode('', $out); 975 } 976 977 private function isValidGitShallowCloneResponse($stdout, $stderr) { 978 // If you execute `git clone --depth N ...`, git sends a request which 979 // `git-http-backend` responds to by emitting valid output and then exiting 980 // with a failure code and an error message. If we ignore this error, 981 // everything works. 982 983 // This is a pretty funky fix: it would be nice to more precisely detect 984 // that a request is a `--depth N` clone request, but we don't have any code 985 // to decode protocol frames yet. Instead, look for reasonable evidence 986 // in the output that we're looking at a `--depth` clone. 987 988 // A valid x-git-upload-pack-result response during packfile negotiation 989 // should end with a flush packet ("0000"). As long as that packet 990 // terminates the response body in the response, we'll assume the response 991 // is correct and complete. 992 993 // See https://git-scm.com/docs/pack-protocol#_packfile_negotiation 994 995 $stdout_regexp = '(^Content-Type: application/x-git-upload-pack-result)m'; 996 997 $has_pack = preg_match($stdout_regexp, $stdout); 998 999 if (strlen($stdout) >= 4) { 1000 $has_flush_packet = (substr($stdout, -4) === "0000"); 1001 } else { 1002 $has_flush_packet = false; 1003 } 1004 1005 return ($has_pack && $has_flush_packet); 1006 } 1007 1008 private function getCommonEnvironment(PhabricatorUser $viewer) { 1009 $remote_address = $this->getRequest()->getRemoteAddress(); 1010 1011 return array( 1012 DiffusionCommitHookEngine::ENV_USER => $viewer->getUsername(), 1013 DiffusionCommitHookEngine::ENV_REMOTE_ADDRESS => $remote_address, 1014 DiffusionCommitHookEngine::ENV_REMOTE_PROTOCOL => 'http', 1015 ); 1016 } 1017 1018 private function validateGitLFSRequest( 1019 PhabricatorRepository $repository, 1020 PhabricatorUser $viewer) { 1021 if (!$this->getIsGitLFSRequest()) { 1022 return null; 1023 } 1024 1025 if (!$repository->canUseGitLFS()) { 1026 return new PhabricatorVCSResponse( 1027 403, 1028 pht( 1029 'The requested repository ("%s") does not support Git LFS.', 1030 $repository->getDisplayName())); 1031 } 1032 1033 // If this is using an LFS token, sanity check that we're using it on the 1034 // correct repository. This shouldn't really matter since the user could 1035 // just request a proper token anyway, but it suspicious and should not 1036 // be permitted. 1037 1038 $token = $this->getGitLFSToken(); 1039 if ($token) { 1040 $resource = $token->getTokenResource(); 1041 if ($resource !== $repository->getPHID()) { 1042 return new PhabricatorVCSResponse( 1043 403, 1044 pht( 1045 'The authentication token provided in the request is bound to '. 1046 'a different repository than the requested repository ("%s").', 1047 $repository->getDisplayName())); 1048 } 1049 } 1050 1051 return null; 1052 } 1053 1054 private function serveGitLFSRequest( 1055 PhabricatorRepository $repository, 1056 PhabricatorUser $viewer) { 1057 1058 if (!$this->getIsGitLFSRequest()) { 1059 throw new Exception(pht('This is not a Git LFS request!')); 1060 } 1061 1062 $path = $this->getGitLFSRequestPath($repository); 1063 $matches = null; 1064 1065 if (preg_match('(^upload/(.*)\z)', $path, $matches)) { 1066 $oid = $matches[1]; 1067 return $this->serveGitLFSUploadRequest($repository, $viewer, $oid); 1068 } else if ($path == 'objects/batch') { 1069 return $this->serveGitLFSBatchRequest($repository, $viewer); 1070 } else { 1071 return DiffusionGitLFSResponse::newErrorResponse( 1072 404, 1073 pht( 1074 'Git LFS operation "%s" is not supported by this server.', 1075 $path)); 1076 } 1077 } 1078 1079 private function serveGitLFSBatchRequest( 1080 PhabricatorRepository $repository, 1081 PhabricatorUser $viewer) { 1082 1083 $input = $this->getGitLFSInput(); 1084 1085 $operation = idx($input, 'operation'); 1086 switch ($operation) { 1087 case 'upload': 1088 $want_upload = true; 1089 break; 1090 case 'download': 1091 $want_upload = false; 1092 break; 1093 default: 1094 return DiffusionGitLFSResponse::newErrorResponse( 1095 404, 1096 pht( 1097 'Git LFS batch operation "%s" is not supported by this server.', 1098 $operation)); 1099 } 1100 1101 $objects = idx($input, 'objects', array()); 1102 1103 $hashes = array(); 1104 foreach ($objects as $object) { 1105 $hashes[] = idx($object, 'oid'); 1106 } 1107 1108 if ($hashes) { 1109 $refs = id(new PhabricatorRepositoryGitLFSRefQuery()) 1110 ->setViewer($viewer) 1111 ->withRepositoryPHIDs(array($repository->getPHID())) 1112 ->withObjectHashes($hashes) 1113 ->execute(); 1114 $refs = mpull($refs, null, 'getObjectHash'); 1115 } else { 1116 $refs = array(); 1117 } 1118 1119 $file_phids = mpull($refs, 'getFilePHID'); 1120 if ($file_phids) { 1121 $files = id(new PhabricatorFileQuery()) 1122 ->setViewer($viewer) 1123 ->withPHIDs($file_phids) 1124 ->execute(); 1125 $files = mpull($files, null, 'getPHID'); 1126 } else { 1127 $files = array(); 1128 } 1129 1130 $authorization = null; 1131 $output = array(); 1132 foreach ($objects as $object) { 1133 $oid = idx($object, 'oid'); 1134 $size = idx($object, 'size'); 1135 $ref = idx($refs, $oid); 1136 $error = null; 1137 1138 // NOTE: If we already have a ref for this object, we only emit a 1139 // "download" action. The client should not upload the file again. 1140 1141 $actions = array(); 1142 if ($ref) { 1143 $file = idx($files, $ref->getFilePHID()); 1144 if ($file) { 1145 // Git LFS may prompt users for authentication if the action does 1146 // not provide an "Authorization" header and does not have a query 1147 // parameter named "token". See here for discussion: 1148 // <https://github.com/github/git-lfs/issues/1088> 1149 $no_authorization = 'Basic '.base64_encode('none'); 1150 1151 $get_uri = $file->getCDNURI('data'); 1152 $actions['download'] = array( 1153 'href' => $get_uri, 1154 'header' => array( 1155 'Authorization' => $no_authorization, 1156 'X-Phabricator-Request-Type' => 'git-lfs', 1157 ), 1158 ); 1159 } else { 1160 $error = array( 1161 'code' => 404, 1162 'message' => pht( 1163 'Object "%s" was previously uploaded, but no longer exists '. 1164 'on this server.', 1165 $oid), 1166 ); 1167 } 1168 } else if ($want_upload) { 1169 if (!$authorization) { 1170 // Here, we could reuse the existing authorization if we have one, 1171 // but it's a little simpler to just generate a new one 1172 // unconditionally. 1173 $authorization = $this->newGitLFSHTTPAuthorization( 1174 $repository, 1175 $viewer, 1176 $operation); 1177 } 1178 1179 $put_uri = $repository->getGitLFSURI("info/lfs/upload/{$oid}"); 1180 1181 $actions['upload'] = array( 1182 'href' => $put_uri, 1183 'header' => array( 1184 'Authorization' => $authorization, 1185 'X-Phabricator-Request-Type' => 'git-lfs', 1186 ), 1187 ); 1188 } 1189 1190 $object = array( 1191 'oid' => $oid, 1192 'size' => $size, 1193 ); 1194 1195 if ($actions) { 1196 $object['actions'] = $actions; 1197 } 1198 1199 if ($error) { 1200 $object['error'] = $error; 1201 } 1202 1203 $output[] = $object; 1204 } 1205 1206 $output = array( 1207 'objects' => $output, 1208 ); 1209 1210 return id(new DiffusionGitLFSResponse()) 1211 ->setContent($output); 1212 } 1213 1214 private function serveGitLFSUploadRequest( 1215 PhabricatorRepository $repository, 1216 PhabricatorUser $viewer, 1217 $oid) { 1218 1219 $ref = id(new PhabricatorRepositoryGitLFSRefQuery()) 1220 ->setViewer($viewer) 1221 ->withRepositoryPHIDs(array($repository->getPHID())) 1222 ->withObjectHashes(array($oid)) 1223 ->executeOne(); 1224 if ($ref) { 1225 return DiffusionGitLFSResponse::newErrorResponse( 1226 405, 1227 pht( 1228 'Content for object "%s" is already known to this server. It can '. 1229 'not be uploaded again.', 1230 $oid)); 1231 } 1232 1233 // Remove the execution time limit because uploading large files may take 1234 // a while. 1235 set_time_limit(0); 1236 1237 $request_stream = new AphrontRequestStream(); 1238 $request_iterator = $request_stream->getIterator(); 1239 $hashing_iterator = id(new PhutilHashingIterator($request_iterator)) 1240 ->setAlgorithm('sha256'); 1241 1242 $source = id(new PhabricatorIteratorFileUploadSource()) 1243 ->setName('lfs-'.$oid) 1244 ->setViewPolicy(PhabricatorPolicies::POLICY_NOONE) 1245 ->setIterator($hashing_iterator); 1246 1247 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 1248 $file = $source->uploadFile(); 1249 unset($unguarded); 1250 1251 $hash = $hashing_iterator->getHash(); 1252 if ($hash !== $oid) { 1253 return DiffusionGitLFSResponse::newErrorResponse( 1254 400, 1255 pht( 1256 'Uploaded data is corrupt or invalid. Expected hash "%s", actual '. 1257 'hash "%s".', 1258 $oid, 1259 $hash)); 1260 } 1261 1262 $ref = id(new PhabricatorRepositoryGitLFSRef()) 1263 ->setRepositoryPHID($repository->getPHID()) 1264 ->setObjectHash($hash) 1265 ->setByteSize($file->getByteSize()) 1266 ->setAuthorPHID($viewer->getPHID()) 1267 ->setFilePHID($file->getPHID()); 1268 1269 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 1270 // Attach the file to the repository to give users permission 1271 // to access it. 1272 $file->attachToObject($repository->getPHID()); 1273 $ref->save(); 1274 unset($unguarded); 1275 1276 // This is just a plain HTTP 200 with no content, which is what `git lfs` 1277 // expects. 1278 return new DiffusionGitLFSResponse(); 1279 } 1280 1281 private function newGitLFSHTTPAuthorization( 1282 PhabricatorRepository $repository, 1283 PhabricatorUser $viewer, 1284 $operation) { 1285 1286 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 1287 1288 $authorization = DiffusionGitLFSTemporaryTokenType::newHTTPAuthorization( 1289 $repository, 1290 $viewer, 1291 $operation); 1292 1293 unset($unguarded); 1294 1295 return $authorization['header']; 1296 } 1297 1298 private function getGitLFSRequestPath(PhabricatorRepository $repository) { 1299 $request_path = $this->getRequestDirectoryPath($repository); 1300 1301 $matches = null; 1302 if (preg_match('(^/info/lfs(?:\z|/)(.*))', $request_path, $matches)) { 1303 return $matches[1]; 1304 } 1305 1306 return null; 1307 } 1308 1309 private function getGitLFSInput() { 1310 if (!$this->gitLFSInput) { 1311 $input = PhabricatorStartup::getRawInput(); 1312 $input = phutil_json_decode($input); 1313 $this->gitLFSInput = $input; 1314 } 1315 1316 return $this->gitLFSInput; 1317 } 1318 1319 private function isGitLFSReadOnlyRequest(PhabricatorRepository $repository) { 1320 if (!$this->getIsGitLFSRequest()) { 1321 return false; 1322 } 1323 1324 $path = $this->getGitLFSRequestPath($repository); 1325 1326 if ($path === 'objects/batch') { 1327 $input = $this->getGitLFSInput(); 1328 $operation = idx($input, 'operation'); 1329 switch ($operation) { 1330 case 'download': 1331 return true; 1332 default: 1333 return false; 1334 } 1335 } 1336 1337 return false; 1338 } 1339 1340 1341}