[ Index ]

PHP Cross Reference of Phabricator

title

Body

[close]

/src/applications/diffusion/controller/ -> DiffusionServeController.php (source)

   1  <?php
   2  
   3  final class DiffusionServeController extends DiffusionController {
   4  
   5    public static function isVCSRequest(AphrontRequest $request) {
   6      if (!self::getCallsign($request)) {
   7        return null;
   8      }
   9  
  10      $content_type = $request->getHTTPHeader('Content-Type');
  11      $user_agent = idx($_SERVER, 'HTTP_USER_AGENT');
  12  
  13      $vcs = null;
  14      if ($request->getExists('service')) {
  15        $service = $request->getStr('service');
  16        // We get this initially for `info/refs`.
  17        // Git also gives us a User-Agent like "git/1.8.2.3".
  18        $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;
  19      } else if (strncmp($user_agent, 'git/', 4) === 0) {
  20        $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;
  21      } else if ($content_type == 'application/x-git-upload-pack-request') {
  22        // We get this for `git-upload-pack`.
  23        $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;
  24      } else if ($content_type == 'application/x-git-receive-pack-request') {
  25        // We get this for `git-receive-pack`.
  26        $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;
  27      } else if ($request->getExists('cmd')) {
  28        // Mercurial also sends an Accept header like
  29        // "application/mercurial-0.1", and a User-Agent like
  30        // "mercurial/proto-1.0".
  31        $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL;
  32      } else {
  33        // Subversion also sends an initial OPTIONS request (vs GET/POST), and
  34        // has a User-Agent like "SVN/1.8.3 (x86_64-apple-darwin11.4.2)
  35        // serf/1.3.2".
  36        $dav = $request->getHTTPHeader('DAV');
  37        $dav = new PhutilURI($dav);
  38        if ($dav->getDomain() === 'subversion.tigris.org') {
  39          $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_SVN;
  40        }
  41      }
  42  
  43      return $vcs;
  44    }
  45  
  46    private static function getCallsign(AphrontRequest $request) {
  47      $uri = $request->getRequestURI();
  48  
  49      $regex = '@^/diffusion/(?P<callsign>[A-Z]+)(/|$)@';
  50      $matches = null;
  51      if (!preg_match($regex, (string)$uri, $matches)) {
  52        return null;
  53      }
  54  
  55      return $matches['callsign'];
  56    }
  57  
  58    public function processRequest() {
  59      $request = $this->getRequest();
  60      $callsign = self::getCallsign($request);
  61  
  62      // If authentication credentials have been provided, try to find a user
  63      // that actually matches those credentials.
  64      if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) {
  65        $username = $_SERVER['PHP_AUTH_USER'];
  66        $password = new PhutilOpaqueEnvelope($_SERVER['PHP_AUTH_PW']);
  67  
  68        $viewer = $this->authenticateHTTPRepositoryUser($username, $password);
  69        if (!$viewer) {
  70          return new PhabricatorVCSResponse(
  71            403,
  72            pht('Invalid credentials.'));
  73        }
  74      } else {
  75        // User hasn't provided credentials, which means we count them as
  76        // being "not logged in".
  77        $viewer = new PhabricatorUser();
  78      }
  79  
  80      $allow_public = PhabricatorEnv::getEnvConfig('policy.allow-public');
  81      $allow_auth = PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth');
  82      if (!$allow_public) {
  83        if (!$viewer->isLoggedIn()) {
  84          if ($allow_auth) {
  85            return new PhabricatorVCSResponse(
  86              401,
  87              pht('You must log in to access repositories.'));
  88          } else {
  89            return new PhabricatorVCSResponse(
  90              403,
  91              pht('Public and authenticated HTTP access are both forbidden.'));
  92          }
  93        }
  94      }
  95  
  96      try {
  97        $repository = id(new PhabricatorRepositoryQuery())
  98          ->setViewer($viewer)
  99          ->withCallsigns(array($callsign))
 100          ->executeOne();
 101        if (!$repository) {
 102          return new PhabricatorVCSResponse(
 103            404,
 104            pht('No such repository exists.'));
 105        }
 106      } catch (PhabricatorPolicyException $ex) {
 107        if ($viewer->isLoggedIn()) {
 108          return new PhabricatorVCSResponse(
 109            403,
 110            pht('You do not have permission to access this repository.'));
 111        } else {
 112          if ($allow_auth) {
 113            return new PhabricatorVCSResponse(
 114              401,
 115              pht('You must log in to access this repository.'));
 116          } else {
 117            return new PhabricatorVCSResponse(
 118              403,
 119              pht(
 120                'This repository requires authentication, which is forbidden '.
 121                'over HTTP.'));
 122          }
 123        }
 124      }
 125  
 126      if (!$repository->isTracked()) {
 127        return new PhabricatorVCSResponse(
 128          403,
 129          pht('This repository is inactive.'));
 130      }
 131  
 132      $is_push = !$this->isReadOnlyRequest($repository);
 133  
 134      switch ($repository->getServeOverHTTP()) {
 135        case PhabricatorRepository::SERVE_READONLY:
 136          if ($is_push) {
 137            return new PhabricatorVCSResponse(
 138              403,
 139              pht('This repository is read-only over HTTP.'));
 140          }
 141          break;
 142        case PhabricatorRepository::SERVE_READWRITE:
 143          if ($is_push) {
 144            $can_push = PhabricatorPolicyFilter::hasCapability(
 145              $viewer,
 146              $repository,
 147              DiffusionPushCapability::CAPABILITY);
 148            if (!$can_push) {
 149              if ($viewer->isLoggedIn()) {
 150                return new PhabricatorVCSResponse(
 151                  403,
 152                  pht('You do not have permission to push to this repository.'));
 153              } else {
 154                if ($allow_auth) {
 155                  return new PhabricatorVCSResponse(
 156                    401,
 157                    pht('You must log in to push to this repository.'));
 158                } else {
 159                  return new PhabricatorVCSResponse(
 160                    403,
 161                    pht(
 162                      'Pushing to this repository requires authentication, '.
 163                      'which is forbidden over HTTP.'));
 164                }
 165              }
 166            }
 167          }
 168          break;
 169        case PhabricatorRepository::SERVE_OFF:
 170        default:
 171          return new PhabricatorVCSResponse(
 172            403,
 173            pht('This repository is not available over HTTP.'));
 174      }
 175  
 176      $vcs_type = $repository->getVersionControlSystem();
 177      $req_type = $this->isVCSRequest($request);
 178  
 179      if ($vcs_type != $req_type) {
 180        switch ($req_type) {
 181          case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
 182            $result = new PhabricatorVCSResponse(
 183              500,
 184              pht('This is not a Git repository.'));
 185            break;
 186          case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
 187            $result = new PhabricatorVCSResponse(
 188              500,
 189              pht('This is not a Mercurial repository.'));
 190            break;
 191          case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
 192            $result = new PhabricatorVCSResponse(
 193              500,
 194              pht('This is not a Subversion repository.'));
 195            break;
 196          default:
 197            $result = new PhabricatorVCSResponse(
 198              500,
 199              pht('Unknown request type.'));
 200            break;
 201        }
 202      } else {
 203        switch ($vcs_type) {
 204          case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
 205            $result = $this->serveGitRequest($repository, $viewer);
 206            break;
 207          case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
 208            $result = $this->serveMercurialRequest($repository, $viewer);
 209            break;
 210          case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
 211            $result = new PhabricatorVCSResponse(
 212              500,
 213              pht(
 214                'Phabricator does not support HTTP access to Subversion '.
 215                'repositories.'));
 216            break;
 217          default:
 218            $result = new PhabricatorVCSResponse(
 219              500,
 220              pht('Unknown version control system.'));
 221            break;
 222        }
 223      }
 224  
 225      $code = $result->getHTTPResponseCode();
 226  
 227      if ($is_push && ($code == 200)) {
 228        $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
 229          $repository->writeStatusMessage(
 230            PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE,
 231            PhabricatorRepositoryStatusMessage::CODE_OKAY);
 232        unset($unguarded);
 233      }
 234  
 235      return $result;
 236    }
 237  
 238    private function isReadOnlyRequest(
 239      PhabricatorRepository $repository) {
 240      $request = $this->getRequest();
 241      $method = $_SERVER['REQUEST_METHOD'];
 242  
 243      // TODO: This implementation is safe by default, but very incomplete.
 244  
 245      switch ($repository->getVersionControlSystem()) {
 246        case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
 247          $service = $request->getStr('service');
 248          $path = $this->getRequestDirectoryPath($repository);
 249          // NOTE: Service names are the reverse of what you might expect, as they
 250          // are from the point of view of the server. The main read service is
 251          // "git-upload-pack", and the main write service is "git-receive-pack".
 252  
 253          if ($method == 'GET' &&
 254              $path == '/info/refs' &&
 255              $service == 'git-upload-pack') {
 256            return true;
 257          }
 258  
 259          if ($path == '/git-upload-pack') {
 260            return true;
 261          }
 262  
 263          break;
 264        case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
 265          $cmd = $request->getStr('cmd');
 266          if ($cmd == 'batch') {
 267            $cmds = idx($this->getMercurialArguments(), 'cmds');
 268            return DiffusionMercurialWireProtocol::isReadOnlyBatchCommand($cmds);
 269          }
 270          return DiffusionMercurialWireProtocol::isReadOnlyCommand($cmd);
 271        case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
 272          break;
 273      }
 274  
 275      return false;
 276    }
 277  
 278    /**
 279     * @phutil-external-symbol class PhabricatorStartup
 280     */
 281    private function serveGitRequest(
 282      PhabricatorRepository $repository,
 283      PhabricatorUser $viewer) {
 284      $request = $this->getRequest();
 285  
 286      $request_path = $this->getRequestDirectoryPath($repository);
 287      $repository_root = $repository->getLocalPath();
 288  
 289      // Rebuild the query string to strip `__magic__` parameters and prevent
 290      // issues where we might interpret inputs like "service=read&service=write"
 291      // differently than the server does and pass it an unsafe command.
 292  
 293      // NOTE: This does not use getPassthroughRequestParameters() because
 294      // that code is HTTP-method agnostic and will encode POST data.
 295  
 296      $query_data = $_GET;
 297      foreach ($query_data as $key => $value) {
 298        if (!strncmp($key, '__', 2)) {
 299          unset($query_data[$key]);
 300        }
 301      }
 302      $query_string = http_build_query($query_data, '', '&');
 303  
 304      // We're about to wipe out PATH with the rest of the environment, so
 305      // resolve the binary first.
 306      $bin = Filesystem::resolveBinary('git-http-backend');
 307      if (!$bin) {
 308        throw new Exception('Unable to find `git-http-backend` in PATH!');
 309      }
 310  
 311      $env = array(
 312        'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'],
 313        'QUERY_STRING' => $query_string,
 314        'CONTENT_TYPE' => $request->getHTTPHeader('Content-Type'),
 315        'HTTP_CONTENT_ENCODING' => $request->getHTTPHeader('Content-Encoding'),
 316        'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'],
 317        'GIT_PROJECT_ROOT' => $repository_root,
 318        'GIT_HTTP_EXPORT_ALL' => '1',
 319        'PATH_INFO' => $request_path,
 320  
 321        'REMOTE_USER' => $viewer->getUsername(),
 322  
 323        // TODO: Set these correctly.
 324        // GIT_COMMITTER_NAME
 325        // GIT_COMMITTER_EMAIL
 326      ) + $this->getCommonEnvironment($viewer);
 327  
 328      $input = PhabricatorStartup::getRawInput();
 329  
 330      $command = csprintf('%s', $bin);
 331      $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);
 332  
 333      list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command))
 334        ->setEnv($env, true)
 335        ->write($input)
 336        ->resolve();
 337  
 338      if ($err) {
 339        if ($this->isValidGitShallowCloneResponse($stdout, $stderr)) {
 340          // Ignore the error if the response passes this special check for
 341          // validity.
 342          $err = 0;
 343        }
 344      }
 345  
 346      if ($err) {
 347        return new PhabricatorVCSResponse(
 348          500,
 349          pht('Error %d: %s', $err, $stderr));
 350      }
 351  
 352      return id(new DiffusionGitResponse())->setGitData($stdout);
 353    }
 354  
 355    private function getRequestDirectoryPath(PhabricatorRepository $repository) {
 356      $request = $this->getRequest();
 357      $request_path = $request->getRequestURI()->getPath();
 358      $base_path = preg_replace('@^/diffusion/[A-Z]+@', '', $request_path);
 359  
 360      // For Git repositories, strip an optional directory component if it
 361      // isn't the name of a known Git resource. This allows users to clone
 362      // repositories as "/diffusion/X/anything.git", for example.
 363      if ($repository->isGit()) {
 364        $known = array(
 365          'info',
 366          'git-upload-pack',
 367          'git-receive-pack',
 368        );
 369  
 370        foreach ($known as $key => $path) {
 371          $known[$key] = preg_quote($path, '@');
 372        }
 373  
 374        $known = implode('|', $known);
 375  
 376        if (preg_match('@^/([^/]+)/('.$known.')(/|$)@', $base_path)) {
 377          $base_path = preg_replace('@^/([^/]+)@', '', $base_path);
 378        }
 379      }
 380  
 381      return $base_path;
 382    }
 383  
 384    private function authenticateHTTPRepositoryUser(
 385      $username,
 386      PhutilOpaqueEnvelope $password) {
 387  
 388      if (!PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth')) {
 389        // No HTTP auth permitted.
 390        return null;
 391      }
 392  
 393      if (!strlen($username)) {
 394        // No username.
 395        return null;
 396      }
 397  
 398      if (!strlen($password->openEnvelope())) {
 399        // No password.
 400        return null;
 401      }
 402  
 403      $user = id(new PhabricatorPeopleQuery())
 404        ->setViewer(PhabricatorUser::getOmnipotentUser())
 405        ->withUsernames(array($username))
 406        ->executeOne();
 407      if (!$user) {
 408        // Username doesn't match anything.
 409        return null;
 410      }
 411  
 412      if (!$user->isUserActivated()) {
 413        // User is not activated.
 414        return null;
 415      }
 416  
 417      $password_entry = id(new PhabricatorRepositoryVCSPassword())
 418        ->loadOneWhere('userPHID = %s', $user->getPHID());
 419      if (!$password_entry) {
 420        // User doesn't have a password set.
 421        return null;
 422      }
 423  
 424      if (!$password_entry->comparePassword($password, $user)) {
 425        // Password doesn't match.
 426        return null;
 427      }
 428  
 429      // If the user's password is stored using a less-than-optimal hash, upgrade
 430      // them to the strongest available hash.
 431  
 432      $hash_envelope = new PhutilOpaqueEnvelope(
 433        $password_entry->getPasswordHash());
 434      if (PhabricatorPasswordHasher::canUpgradeHash($hash_envelope)) {
 435        $password_entry->setPassword($password, $user);
 436        $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
 437          $password_entry->save();
 438        unset($unguarded);
 439      }
 440  
 441      return $user;
 442    }
 443  
 444    private function serveMercurialRequest(
 445      PhabricatorRepository $repository,
 446      PhabricatorUser $viewer) {
 447      $request = $this->getRequest();
 448  
 449      $bin = Filesystem::resolveBinary('hg');
 450      if (!$bin) {
 451        throw new Exception('Unable to find `hg` in PATH!');
 452      }
 453  
 454      $env = $this->getCommonEnvironment($viewer);
 455      $input = PhabricatorStartup::getRawInput();
 456  
 457      $cmd = $request->getStr('cmd');
 458  
 459      $args = $this->getMercurialArguments();
 460      $args = $this->formatMercurialArguments($cmd, $args);
 461  
 462      if (strlen($input)) {
 463        $input = strlen($input)."\n".$input."0\n";
 464      }
 465  
 466      $command = csprintf('%s serve --stdio', $bin);
 467      $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);
 468  
 469      list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command))
 470        ->setEnv($env, true)
 471        ->setCWD($repository->getLocalPath())
 472        ->write("{$cmd}\n{$args}{$input}")
 473        ->resolve();
 474  
 475      if ($err) {
 476        return new PhabricatorVCSResponse(
 477          500,
 478          pht('Error %d: %s', $err, $stderr));
 479      }
 480  
 481      if ($cmd == 'getbundle' ||
 482          $cmd == 'changegroup' ||
 483          $cmd == 'changegroupsubset') {
 484        // We're not completely sure that "changegroup" and "changegroupsubset"
 485        // actually work, they're for very old Mercurial.
 486        $body = gzcompress($stdout);
 487      } else if ($cmd == 'unbundle') {
 488        // This includes diagnostic information and anything echoed by commit
 489        // hooks. We ignore `stdout` since it just has protocol garbage, and
 490        // substitute `stderr`.
 491        $body = strlen($stderr)."\n".$stderr;
 492      } else {
 493        list($length, $body) = explode("\n", $stdout, 2);
 494      }
 495  
 496      return id(new DiffusionMercurialResponse())->setContent($body);
 497    }
 498  
 499  
 500    private function getMercurialArguments() {
 501      // Mercurial sends arguments in HTTP headers. "Why?", you might wonder,
 502      // "Why would you do this?".
 503  
 504      $args_raw = array();
 505      for ($ii = 1;; $ii++) {
 506        $header = 'HTTP_X_HGARG_'.$ii;
 507        if (!array_key_exists($header, $_SERVER)) {
 508          break;
 509        }
 510        $args_raw[] = $_SERVER[$header];
 511      }
 512      $args_raw = implode('', $args_raw);
 513  
 514      return id(new PhutilQueryStringParser())
 515        ->parseQueryString($args_raw);
 516    }
 517  
 518    private function formatMercurialArguments($command, array $arguments) {
 519      $spec = DiffusionMercurialWireProtocol::getCommandArgs($command);
 520  
 521      $out = array();
 522  
 523      // Mercurial takes normal arguments like this:
 524      //
 525      //   name <length(value)>
 526      //   value
 527  
 528      $has_star = false;
 529      foreach ($spec as $arg_key) {
 530        if ($arg_key == '*') {
 531          $has_star = true;
 532          continue;
 533        }
 534        if (isset($arguments[$arg_key])) {
 535          $value = $arguments[$arg_key];
 536          $size = strlen($value);
 537          $out[] = "{$arg_key} {$size}\n{$value}";
 538          unset($arguments[$arg_key]);
 539        }
 540      }
 541  
 542      if ($has_star) {
 543  
 544        // Mercurial takes arguments for variable argument lists roughly like
 545        // this:
 546        //
 547        //   * <count(args)>
 548        //   argname1 <length(argvalue1)>
 549        //   argvalue1
 550        //   argname2 <length(argvalue2)>
 551        //   argvalue2
 552  
 553        $count = count($arguments);
 554  
 555        $out[] = "* {$count}\n";
 556  
 557        foreach ($arguments as $key => $value) {
 558          if (in_array($key, $spec)) {
 559            // We already added this argument above, so skip it.
 560            continue;
 561          }
 562          $size = strlen($value);
 563          $out[] = "{$key} {$size}\n{$value}";
 564        }
 565      }
 566  
 567      return implode('', $out);
 568    }
 569  
 570    private function isValidGitShallowCloneResponse($stdout, $stderr) {
 571      // If you execute `git clone --depth N ...`, git sends a request which
 572      // `git-http-backend` responds to by emitting valid output and then exiting
 573      // with a failure code and an error message. If we ignore this error,
 574      // everything works.
 575  
 576      // This is a pretty funky fix: it would be nice to more precisely detect
 577      // that a request is a `--depth N` clone request, but we don't have any code
 578      // to decode protocol frames yet. Instead, look for reasonable evidence
 579      // in the error and output that we're looking at a `--depth` clone.
 580  
 581      // For evidence this isn't completely crazy, see:
 582      // https://github.com/schacon/grack/pull/7
 583  
 584      $stdout_regexp = '(^Content-Type: application/x-git-upload-pack-result)m';
 585      $stderr_regexp = '(The remote end hung up unexpectedly)';
 586  
 587      $has_pack = preg_match($stdout_regexp, $stdout);
 588      $is_hangup = preg_match($stderr_regexp, $stderr);
 589  
 590      return $has_pack && $is_hangup;
 591    }
 592  
 593    private function getCommonEnvironment(PhabricatorUser $viewer) {
 594      $remote_addr = $this->getRequest()->getRemoteAddr();
 595  
 596      return array(
 597        DiffusionCommitHookEngine::ENV_USER => $viewer->getUsername(),
 598        DiffusionCommitHookEngine::ENV_REMOTE_ADDRESS => $remote_addr,
 599        DiffusionCommitHookEngine::ENV_REMOTE_PROTOCOL => 'http',
 600      );
 601    }
 602  
 603  }


Generated: Sun Nov 30 09:20:46 2014 Cross-referenced by PHPXref 0.7.1