Path: blob/master/src/applications/diffusion/controller/DiffusionServeController.php
12242 views
<?php12final class DiffusionServeController extends DiffusionController {34private $serviceViewer;5private $serviceRepository;67private $isGitLFSRequest;8private $gitLFSToken;9private $gitLFSInput;1011public function setServiceViewer(PhabricatorUser $viewer) {12$this->getRequest()->setUser($viewer);1314$this->serviceViewer = $viewer;15return $this;16}1718public function getServiceViewer() {19return $this->serviceViewer;20}2122public function setServiceRepository(PhabricatorRepository $repository) {23$this->serviceRepository = $repository;24return $this;25}2627public function getServiceRepository() {28return $this->serviceRepository;29}3031public function getIsGitLFSRequest() {32return $this->isGitLFSRequest;33}3435public function getGitLFSToken() {36return $this->gitLFSToken;37}3839public function isVCSRequest(AphrontRequest $request) {40$identifier = $this->getRepositoryIdentifierFromRequest($request);41if ($identifier === null) {42return null;43}4445$content_type = $request->getHTTPHeader('Content-Type');46$user_agent = idx($_SERVER, 'HTTP_USER_AGENT');47$request_type = $request->getHTTPHeader('X-Phabricator-Request-Type');4849// This may have a "charset" suffix, so only match the prefix.50$lfs_pattern = '(^application/vnd\\.git-lfs\\+json(;|\z))';5152$vcs = null;53if ($request->getExists('service')) {54$service = $request->getStr('service');55// We get this initially for `info/refs`.56// Git also gives us a User-Agent like "git/1.8.2.3".57$vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;58} else if (strncmp($user_agent, 'git/', 4) === 0) {59$vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;60} else if ($content_type == 'application/x-git-upload-pack-request') {61// We get this for `git-upload-pack`.62$vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;63} else if ($content_type == 'application/x-git-receive-pack-request') {64// We get this for `git-receive-pack`.65$vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;66} else if (preg_match($lfs_pattern, $content_type)) {67// This is a Git LFS HTTP API request.68$vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;69$this->isGitLFSRequest = true;70} else if ($request_type == 'git-lfs') {71// This is a Git LFS object content request.72$vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;73$this->isGitLFSRequest = true;74} else if ($request->getExists('cmd')) {75// Mercurial also sends an Accept header like76// "application/mercurial-0.1", and a User-Agent like77// "mercurial/proto-1.0".78$vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL;79} else {80// Subversion also sends an initial OPTIONS request (vs GET/POST), and81// has a User-Agent like "SVN/1.8.3 (x86_64-apple-darwin11.4.2)82// serf/1.3.2".83$dav = $request->getHTTPHeader('DAV');84$dav = new PhutilURI($dav);85if ($dav->getDomain() === 'subversion.tigris.org') {86$vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_SVN;87}88}8990return $vcs;91}9293public function handleRequest(AphrontRequest $request) {94$service_exception = null;95$response = null;9697try {98$response = $this->serveRequest($request);99} catch (Exception $ex) {100$service_exception = $ex;101}102103try {104$remote_addr = $request->getRemoteAddress();105106if ($request->isHTTPS()) {107$remote_protocol = PhabricatorRepositoryPullEvent::PROTOCOL_HTTPS;108} else {109$remote_protocol = PhabricatorRepositoryPullEvent::PROTOCOL_HTTP;110}111112$pull_event = id(new PhabricatorRepositoryPullEvent())113->setEpoch(PhabricatorTime::getNow())114->setRemoteAddress($remote_addr)115->setRemoteProtocol($remote_protocol);116117if ($response) {118$response_code = $response->getHTTPResponseCode();119120if ($response_code == 200) {121$pull_event122->setResultType(PhabricatorRepositoryPullEvent::RESULT_PULL)123->setResultCode($response_code);124} else {125$pull_event126->setResultType(PhabricatorRepositoryPullEvent::RESULT_ERROR)127->setResultCode($response_code);128}129130if ($response instanceof PhabricatorVCSResponse) {131$pull_event->setProperties(132array(133'response.message' => $response->getMessage(),134));135}136} else {137$pull_event138->setResultType(PhabricatorRepositoryPullEvent::RESULT_EXCEPTION)139->setResultCode(500)140->setProperties(141array(142'exception.class' => get_class($ex),143'exception.message' => $ex->getMessage(),144));145}146147$viewer = $this->getServiceViewer();148if ($viewer) {149$pull_event->setPullerPHID($viewer->getPHID());150}151152$repository = $this->getServiceRepository();153if ($repository) {154$pull_event->setRepositoryPHID($repository->getPHID());155}156157$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();158$pull_event->save();159unset($unguarded);160161} catch (Exception $ex) {162if ($service_exception) {163throw $service_exception;164}165throw $ex;166}167168if ($service_exception) {169throw $service_exception;170}171172return $response;173}174175private function serveRequest(AphrontRequest $request) {176$identifier = $this->getRepositoryIdentifierFromRequest($request);177178// If authentication credentials have been provided, try to find a user179// that actually matches those credentials.180181// We require both the username and password to be nonempty, because Git182// won't prompt users who provide a username but no password otherwise.183// See T10797 for discussion.184185$http_user = idx($_SERVER, 'PHP_AUTH_USER');186$http_pass = idx($_SERVER, 'PHP_AUTH_PW');187$have_user = $http_user !== null && strlen($http_user);188$have_pass = $http_pass !== null && strlen($http_pass);189if ($have_user && $have_pass) {190$username = $http_user;191$password = new PhutilOpaqueEnvelope($http_pass);192193// Try Git LFS auth first since we can usually reject it without doing194// any queries, since the username won't match the one we expect or the195// request won't be LFS.196$viewer = $this->authenticateGitLFSUser(197$username,198$password,199$identifier);200201// If that failed, try normal auth. Note that we can use normal auth on202// LFS requests, so this isn't strictly an alternative to LFS auth.203if (!$viewer) {204$viewer = $this->authenticateHTTPRepositoryUser($username, $password);205}206207if (!$viewer) {208return new PhabricatorVCSResponse(209403,210pht('Invalid credentials.'));211}212} else {213// User hasn't provided credentials, which means we count them as214// being "not logged in".215$viewer = new PhabricatorUser();216}217218// See T13590. Some pathways, like error handling, may require unusual219// access to things like timezone information. These are fine to build220// inline; this pathway is not lightweight anyway.221$viewer->setAllowInlineCacheGeneration(true);222223$this->setServiceViewer($viewer);224225$allow_public = PhabricatorEnv::getEnvConfig('policy.allow-public');226$allow_auth = PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth');227if (!$allow_public) {228if (!$viewer->isLoggedIn()) {229if ($allow_auth) {230return new PhabricatorVCSResponse(231401,232pht('You must log in to access repositories.'));233} else {234return new PhabricatorVCSResponse(235403,236pht('Public and authenticated HTTP access are both forbidden.'));237}238}239}240241try {242$repository = id(new PhabricatorRepositoryQuery())243->setViewer($viewer)244->withIdentifiers(array($identifier))245->needURIs(true)246->executeOne();247if (!$repository) {248return new PhabricatorVCSResponse(249404,250pht('No such repository exists.'));251}252} catch (PhabricatorPolicyException $ex) {253if ($viewer->isLoggedIn()) {254return new PhabricatorVCSResponse(255403,256pht('You do not have permission to access this repository.'));257} else {258if ($allow_auth) {259return new PhabricatorVCSResponse(260401,261pht('You must log in to access this repository.'));262} else {263return new PhabricatorVCSResponse(264403,265pht(266'This repository requires authentication, which is forbidden '.267'over HTTP.'));268}269}270}271272$response = $this->validateGitLFSRequest($repository, $viewer);273if ($response) {274return $response;275}276277$this->setServiceRepository($repository);278279if (!$repository->isTracked()) {280return new PhabricatorVCSResponse(281403,282pht('This repository is inactive.'));283}284285$is_push = !$this->isReadOnlyRequest($repository);286287if ($this->getIsGitLFSRequest() && $this->getGitLFSToken()) {288// We allow git LFS requests over HTTP even if the repository does not289// otherwise support HTTP reads or writes, as long as the user is using a290// token from SSH. If they're using HTTP username + password auth, they291// have to obey the normal HTTP rules.292} else {293// For now, we don't distinguish between HTTP and HTTPS-originated294// requests that are proxied within the cluster, so the user can connect295// with HTTPS but we may be on HTTP by the time we reach this part of296// the code. Allow things to move forward as long as either protocol297// can be served.298$proto_https = PhabricatorRepositoryURI::BUILTIN_PROTOCOL_HTTPS;299$proto_http = PhabricatorRepositoryURI::BUILTIN_PROTOCOL_HTTP;300301$can_read =302$repository->canServeProtocol($proto_https, false) ||303$repository->canServeProtocol($proto_http, false);304if (!$can_read) {305return new PhabricatorVCSResponse(306403,307pht('This repository is not available over HTTP.'));308}309310if ($is_push) {311if ($repository->isReadOnly()) {312return new PhabricatorVCSResponse(313503,314$repository->getReadOnlyMessageForDisplay());315}316317$can_write =318$repository->canServeProtocol($proto_https, true) ||319$repository->canServeProtocol($proto_http, true);320if (!$can_write) {321return new PhabricatorVCSResponse(322403,323pht('This repository is read-only over HTTP.'));324}325}326}327328if ($is_push) {329$can_push = PhabricatorPolicyFilter::hasCapability(330$viewer,331$repository,332DiffusionPushCapability::CAPABILITY);333if (!$can_push) {334if ($viewer->isLoggedIn()) {335$error_code = 403;336$error_message = pht(337'You do not have permission to push to this repository ("%s").',338$repository->getDisplayName());339340if ($this->getIsGitLFSRequest()) {341return DiffusionGitLFSResponse::newErrorResponse(342$error_code,343$error_message);344} else {345return new PhabricatorVCSResponse(346$error_code,347$error_message);348}349} else {350if ($allow_auth) {351return new PhabricatorVCSResponse(352401,353pht('You must log in to push to this repository.'));354} else {355return new PhabricatorVCSResponse(356403,357pht(358'Pushing to this repository requires authentication, '.359'which is forbidden over HTTP.'));360}361}362}363}364365$vcs_type = $repository->getVersionControlSystem();366$req_type = $this->isVCSRequest($request);367368if ($vcs_type != $req_type) {369switch ($req_type) {370case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:371$result = new PhabricatorVCSResponse(372500,373pht(374'This repository ("%s") is not a Git repository.',375$repository->getDisplayName()));376break;377case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:378$result = new PhabricatorVCSResponse(379500,380pht(381'This repository ("%s") is not a Mercurial repository.',382$repository->getDisplayName()));383break;384case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:385$result = new PhabricatorVCSResponse(386500,387pht(388'This repository ("%s") is not a Subversion repository.',389$repository->getDisplayName()));390break;391default:392$result = new PhabricatorVCSResponse(393500,394pht('Unknown request type.'));395break;396}397} else {398switch ($vcs_type) {399case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:400case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:401$caught = null;402try {403$result = $this->serveVCSRequest($repository, $viewer);404} catch (Exception $ex) {405$caught = $ex;406} catch (Throwable $ex) {407$caught = $ex;408}409410if ($caught) {411// We never expect an uncaught exception here, so dump it to the412// log. All routine errors should have been converted into Response413// objects by a lower layer.414phlog($caught);415416$result = new PhabricatorVCSResponse(417500,418phutil_string_cast($caught->getMessage()));419}420break;421case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:422$result = new PhabricatorVCSResponse(423500,424pht(425'This server does not support HTTP access to Subversion '.426'repositories.'));427break;428default:429$result = new PhabricatorVCSResponse(430500,431pht('Unknown version control system.'));432break;433}434}435436$code = $result->getHTTPResponseCode();437438if ($is_push && ($code == 200)) {439$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();440$repository->writeStatusMessage(441PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE,442PhabricatorRepositoryStatusMessage::CODE_OKAY);443unset($unguarded);444}445446return $result;447}448449private function serveVCSRequest(450PhabricatorRepository $repository,451PhabricatorUser $viewer) {452453// We can serve Git LFS requests first, since we don't need to proxy them.454// It's also important that LFS requests never fall through to standard455// service pathways, because that would let you use LFS tokens to read456// normal repository data.457if ($this->getIsGitLFSRequest()) {458return $this->serveGitLFSRequest($repository, $viewer);459}460461// If this repository is hosted on a service, we need to proxy the request462// to a host which can serve it.463$is_cluster_request = $this->getRequest()->isProxiedClusterRequest();464465$uri = $repository->getAlmanacServiceURI(466$viewer,467array(468'neverProxy' => $is_cluster_request,469'protocols' => array(470'http',471'https',472),473'writable' => !$this->isReadOnlyRequest($repository),474));475if ($uri) {476$future = $this->getRequest()->newClusterProxyFuture($uri);477return id(new AphrontHTTPProxyResponse())478->setHTTPFuture($future);479}480481// Otherwise, we're going to handle the request locally.482483$vcs_type = $repository->getVersionControlSystem();484switch ($vcs_type) {485case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:486$result = $this->serveGitRequest($repository, $viewer);487break;488case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:489$result = $this->serveMercurialRequest($repository, $viewer);490break;491}492493return $result;494}495496private function isReadOnlyRequest(497PhabricatorRepository $repository) {498$request = $this->getRequest();499$method = $_SERVER['REQUEST_METHOD'];500501// TODO: This implementation is safe by default, but very incomplete.502503if ($this->getIsGitLFSRequest()) {504return $this->isGitLFSReadOnlyRequest($repository);505}506507switch ($repository->getVersionControlSystem()) {508case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:509$service = $request->getStr('service');510$path = $this->getRequestDirectoryPath($repository);511// NOTE: Service names are the reverse of what you might expect, as they512// are from the point of view of the server. The main read service is513// "git-upload-pack", and the main write service is "git-receive-pack".514515if ($method == 'GET' &&516$path == '/info/refs' &&517$service == 'git-upload-pack') {518return true;519}520521if ($path == '/git-upload-pack') {522return true;523}524525break;526case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:527$cmd = $request->getStr('cmd');528if ($cmd === null) {529return false;530}531if ($cmd == 'batch') {532$cmds = idx($this->getMercurialArguments(), 'cmds');533if ($cmds !== null) {534return DiffusionMercurialWireProtocol::isReadOnlyBatchCommand(535$cmds);536}537}538return DiffusionMercurialWireProtocol::isReadOnlyCommand($cmd);539case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:540break;541}542543return false;544}545546/**547* @phutil-external-symbol class PhabricatorStartup548*/549private function serveGitRequest(550PhabricatorRepository $repository,551PhabricatorUser $viewer) {552$request = $this->getRequest();553554$request_path = $this->getRequestDirectoryPath($repository);555$repository_root = $repository->getLocalPath();556557// Rebuild the query string to strip `__magic__` parameters and prevent558// issues where we might interpret inputs like "service=read&service=write"559// differently than the server does and pass it an unsafe command.560561// NOTE: This does not use getPassthroughRequestParameters() because562// that code is HTTP-method agnostic and will encode POST data.563564$query_data = $_GET;565foreach ($query_data as $key => $value) {566if (!strncmp($key, '__', 2)) {567unset($query_data[$key]);568}569}570$query_string = phutil_build_http_querystring($query_data);571572// We're about to wipe out PATH with the rest of the environment, so573// resolve the binary first.574$bin = Filesystem::resolveBinary('git-http-backend');575if (!$bin) {576throw new Exception(577pht(578'Unable to find `%s` in %s!',579'git-http-backend',580'$PATH'));581}582583// NOTE: We do not set HTTP_CONTENT_ENCODING here, because we already584// decompressed the request when we read the request body, so the body is585// just plain data with no encoding.586587$env = array(588'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'],589'QUERY_STRING' => $query_string,590'CONTENT_TYPE' => $request->getHTTPHeader('Content-Type'),591'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'],592'GIT_PROJECT_ROOT' => $repository_root,593'GIT_HTTP_EXPORT_ALL' => '1',594'PATH_INFO' => $request_path,595596'REMOTE_USER' => $viewer->getUsername(),597598// TODO: Set these correctly.599// GIT_COMMITTER_NAME600// GIT_COMMITTER_EMAIL601) + $this->getCommonEnvironment($viewer);602603$input = PhabricatorStartup::getRawInput();604605$command = csprintf('%s', $bin);606$command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);607608$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();609610$cluster_engine = id(new DiffusionRepositoryClusterEngine())611->setViewer($viewer)612->setRepository($repository);613614$did_write_lock = false;615if ($this->isReadOnlyRequest($repository)) {616$cluster_engine->synchronizeWorkingCopyBeforeRead();617} else {618$did_write_lock = true;619$cluster_engine->synchronizeWorkingCopyBeforeWrite();620}621622$caught = null;623try {624list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command))625->setEnv($env, true)626->write($input)627->resolve();628} catch (Exception $ex) {629$caught = $ex;630}631632if ($did_write_lock) {633$cluster_engine->synchronizeWorkingCopyAfterWrite();634}635636unset($unguarded);637638if ($caught) {639throw $caught;640}641642if ($err) {643if ($this->isValidGitShallowCloneResponse($stdout, $stderr)) {644// Ignore the error if the response passes this special check for645// validity.646$err = 0;647}648}649650if ($err) {651return new PhabricatorVCSResponse(652500,653pht(654'Error %d: %s',655$err,656phutil_utf8ize($stderr)));657}658659return id(new DiffusionGitResponse())->setGitData($stdout);660}661662private function getRequestDirectoryPath(PhabricatorRepository $repository) {663$request = $this->getRequest();664$request_path = $request->getRequestURI()->getPath();665666$info = PhabricatorRepository::parseRepositoryServicePath(667$request_path,668$repository->getVersionControlSystem());669$base_path = $info['path'];670671// For Git repositories, strip an optional directory component if it672// isn't the name of a known Git resource. This allows users to clone673// repositories as "/diffusion/X/anything.git", for example.674if ($repository->isGit()) {675$known = array(676'info',677'git-upload-pack',678'git-receive-pack',679);680681foreach ($known as $key => $path) {682$known[$key] = preg_quote($path, '@');683}684685$known = implode('|', $known);686687if (preg_match('@^/([^/]+)/('.$known.')(/|$)@', $base_path)) {688$base_path = preg_replace('@^/([^/]+)@', '', $base_path);689}690}691692return $base_path;693}694695private function authenticateGitLFSUser(696$username,697PhutilOpaqueEnvelope $password,698$identifier) {699700// Never accept these credentials for requests which aren't LFS requests.701if (!$this->getIsGitLFSRequest()) {702return null;703}704705// If we have the wrong username, don't bother checking if the token706// is right.707if ($username !== DiffusionGitLFSTemporaryTokenType::HTTP_USERNAME) {708return null;709}710711// See PHI1123. We need to be able to constrain the token query with712// "withTokenResources(...)" to take advantage of the key on the table.713// In this case, the repository PHID is the "resource" we're after.714715// In normal workflows, we figure out the viewer first, then use the716// viewer to load the repository, but that won't work here. Load the717// repository as the omnipotent viewer, then use the repository PHID to718// look for a token.719720$omnipotent_viewer = PhabricatorUser::getOmnipotentUser();721722$repository = id(new PhabricatorRepositoryQuery())723->setViewer($omnipotent_viewer)724->withIdentifiers(array($identifier))725->executeOne();726if (!$repository) {727return null;728}729730$lfs_pass = $password->openEnvelope();731$lfs_hash = PhabricatorHash::weakDigest($lfs_pass);732733$token = id(new PhabricatorAuthTemporaryTokenQuery())734->setViewer($omnipotent_viewer)735->withTokenResources(array($repository->getPHID()))736->withTokenTypes(array(DiffusionGitLFSTemporaryTokenType::TOKENTYPE))737->withTokenCodes(array($lfs_hash))738->withExpired(false)739->executeOne();740if (!$token) {741return null;742}743744$user = id(new PhabricatorPeopleQuery())745->setViewer($omnipotent_viewer)746->withPHIDs(array($token->getUserPHID()))747->executeOne();748749if (!$user) {750return null;751}752753if (!$user->isUserActivated()) {754return null;755}756757$this->gitLFSToken = $token;758759return $user;760}761762private function authenticateHTTPRepositoryUser(763$username,764PhutilOpaqueEnvelope $password) {765766if (!PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth')) {767// No HTTP auth permitted.768return null;769}770771if (!strlen($username)) {772// No username.773return null;774}775776if (!strlen($password->openEnvelope())) {777// No password.778return null;779}780781$user = id(new PhabricatorPeopleQuery())782->setViewer(PhabricatorUser::getOmnipotentUser())783->withUsernames(array($username))784->executeOne();785if (!$user) {786// Username doesn't match anything.787return null;788}789790if (!$user->isUserActivated()) {791// User is not activated.792return null;793}794795$request = $this->getRequest();796$content_source = PhabricatorContentSource::newFromRequest($request);797798$engine = id(new PhabricatorAuthPasswordEngine())799->setViewer($user)800->setContentSource($content_source)801->setPasswordType(PhabricatorAuthPassword::PASSWORD_TYPE_VCS)802->setObject($user);803804if (!$engine->isValidPassword($password)) {805return null;806}807808return $user;809}810811private function serveMercurialRequest(812PhabricatorRepository $repository,813PhabricatorUser $viewer) {814$request = $this->getRequest();815816$bin = Filesystem::resolveBinary('hg');817if (!$bin) {818throw new Exception(819pht(820'Unable to find `%s` in %s!',821'hg',822'$PATH'));823}824825$env = $this->getCommonEnvironment($viewer);826$input = PhabricatorStartup::getRawInput();827828$cmd = $request->getStr('cmd');829830$args = $this->getMercurialArguments();831$args = $this->formatMercurialArguments($cmd, $args);832833if (strlen($input)) {834$input = strlen($input)."\n".$input."0\n";835}836837$command = csprintf(838'%s -R %s serve --stdio',839$bin,840$repository->getLocalPath());841$command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);842843list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command))844->setEnv($env, true)845->setCWD($repository->getLocalPath())846->write("{$cmd}\n{$args}{$input}")847->resolve();848849if ($err) {850return new PhabricatorVCSResponse(851500,852pht('Error %d: %s', $err, $stderr));853}854855if ($cmd == 'getbundle' ||856$cmd == 'changegroup' ||857$cmd == 'changegroupsubset') {858// We're not completely sure that "changegroup" and "changegroupsubset"859// actually work, they're for very old Mercurial.860$body = gzcompress($stdout);861} else if ($cmd == 'unbundle') {862// This includes diagnostic information and anything echoed by commit863// hooks. We ignore `stdout` since it just has protocol garbage, and864// substitute `stderr`.865$body = strlen($stderr)."\n".$stderr;866} else {867list($length, $body) = explode("\n", $stdout, 2);868if ($cmd == 'capabilities') {869$body = DiffusionMercurialWireProtocol::filterBundle2Capability($body);870}871}872873return id(new DiffusionMercurialResponse())->setContent($body);874}875876private function getMercurialArguments() {877// Mercurial sends arguments in HTTP headers. "Why?", you might wonder,878// "Why would you do this?".879880$args_raw = array();881for ($ii = 1;; $ii++) {882$header = 'HTTP_X_HGARG_'.$ii;883if (!array_key_exists($header, $_SERVER)) {884break;885}886$args_raw[] = $_SERVER[$header];887}888889if ($args_raw) {890$args_raw = implode('', $args_raw);891return id(new PhutilQueryStringParser())892->parseQueryString($args_raw);893}894895// Sometimes arguments come in via the query string. Note that this will896// not handle multi-value entries e.g. "a[]=1,a[]=2" however it's unclear897// whether or how the mercurial protocol should handle this.898$query = idx($_SERVER, 'QUERY_STRING', '');899$query_pairs = id(new PhutilQueryStringParser())900->parseQueryString($query);901foreach ($query_pairs as $key => $value) {902// Filter out private/internal keys as well as the command itself.903if (strncmp($key, '__', 2) && $key != 'cmd') {904$args_raw[$key] = $value;905}906}907908// TODO: Arguments can also come in via request body for POST requests. The909// body would be all arguments, url-encoded.910return $args_raw;911}912913private function formatMercurialArguments($command, array $arguments) {914$spec = DiffusionMercurialWireProtocol::getCommandArgs($command);915916$out = array();917918// Mercurial takes normal arguments like this:919//920// name <length(value)>921// value922923$has_star = false;924foreach ($spec as $arg_key) {925if ($arg_key == '*') {926$has_star = true;927continue;928}929if (isset($arguments[$arg_key])) {930$value = $arguments[$arg_key];931$size = strlen($value);932$out[] = "{$arg_key} {$size}\n{$value}";933unset($arguments[$arg_key]);934}935}936937if ($has_star) {938939// Mercurial takes arguments for variable argument lists roughly like940// this:941//942// * <count(args)>943// argname1 <length(argvalue1)>944// argvalue1945// argname2 <length(argvalue2)>946// argvalue2947948$count = count($arguments);949950$out[] = "* {$count}\n";951952foreach ($arguments as $key => $value) {953if (in_array($key, $spec)) {954// We already added this argument above, so skip it.955continue;956}957$size = strlen($value);958$out[] = "{$key} {$size}\n{$value}";959}960}961962return implode('', $out);963}964965private function isValidGitShallowCloneResponse($stdout, $stderr) {966// If you execute `git clone --depth N ...`, git sends a request which967// `git-http-backend` responds to by emitting valid output and then exiting968// with a failure code and an error message. If we ignore this error,969// everything works.970971// This is a pretty funky fix: it would be nice to more precisely detect972// that a request is a `--depth N` clone request, but we don't have any code973// to decode protocol frames yet. Instead, look for reasonable evidence974// in the output that we're looking at a `--depth` clone.975976// A valid x-git-upload-pack-result response during packfile negotiation977// should end with a flush packet ("0000"). As long as that packet978// terminates the response body in the response, we'll assume the response979// is correct and complete.980981// See https://git-scm.com/docs/pack-protocol#_packfile_negotiation982983$stdout_regexp = '(^Content-Type: application/x-git-upload-pack-result)m';984985$has_pack = preg_match($stdout_regexp, $stdout);986987if (strlen($stdout) >= 4) {988$has_flush_packet = (substr($stdout, -4) === "0000");989} else {990$has_flush_packet = false;991}992993return ($has_pack && $has_flush_packet);994}995996private function getCommonEnvironment(PhabricatorUser $viewer) {997$remote_address = $this->getRequest()->getRemoteAddress();998999return array(1000DiffusionCommitHookEngine::ENV_USER => $viewer->getUsername(),1001DiffusionCommitHookEngine::ENV_REMOTE_ADDRESS => $remote_address,1002DiffusionCommitHookEngine::ENV_REMOTE_PROTOCOL => 'http',1003);1004}10051006private function validateGitLFSRequest(1007PhabricatorRepository $repository,1008PhabricatorUser $viewer) {1009if (!$this->getIsGitLFSRequest()) {1010return null;1011}10121013if (!$repository->canUseGitLFS()) {1014return new PhabricatorVCSResponse(1015403,1016pht(1017'The requested repository ("%s") does not support Git LFS.',1018$repository->getDisplayName()));1019}10201021// If this is using an LFS token, sanity check that we're using it on the1022// correct repository. This shouldn't really matter since the user could1023// just request a proper token anyway, but it suspicious and should not1024// be permitted.10251026$token = $this->getGitLFSToken();1027if ($token) {1028$resource = $token->getTokenResource();1029if ($resource !== $repository->getPHID()) {1030return new PhabricatorVCSResponse(1031403,1032pht(1033'The authentication token provided in the request is bound to '.1034'a different repository than the requested repository ("%s").',1035$repository->getDisplayName()));1036}1037}10381039return null;1040}10411042private function serveGitLFSRequest(1043PhabricatorRepository $repository,1044PhabricatorUser $viewer) {10451046if (!$this->getIsGitLFSRequest()) {1047throw new Exception(pht('This is not a Git LFS request!'));1048}10491050$path = $this->getGitLFSRequestPath($repository);1051$matches = null;10521053if (preg_match('(^upload/(.*)\z)', $path, $matches)) {1054$oid = $matches[1];1055return $this->serveGitLFSUploadRequest($repository, $viewer, $oid);1056} else if ($path == 'objects/batch') {1057return $this->serveGitLFSBatchRequest($repository, $viewer);1058} else {1059return DiffusionGitLFSResponse::newErrorResponse(1060404,1061pht(1062'Git LFS operation "%s" is not supported by this server.',1063$path));1064}1065}10661067private function serveGitLFSBatchRequest(1068PhabricatorRepository $repository,1069PhabricatorUser $viewer) {10701071$input = $this->getGitLFSInput();10721073$operation = idx($input, 'operation');1074switch ($operation) {1075case 'upload':1076$want_upload = true;1077break;1078case 'download':1079$want_upload = false;1080break;1081default:1082return DiffusionGitLFSResponse::newErrorResponse(1083404,1084pht(1085'Git LFS batch operation "%s" is not supported by this server.',1086$operation));1087}10881089$objects = idx($input, 'objects', array());10901091$hashes = array();1092foreach ($objects as $object) {1093$hashes[] = idx($object, 'oid');1094}10951096if ($hashes) {1097$refs = id(new PhabricatorRepositoryGitLFSRefQuery())1098->setViewer($viewer)1099->withRepositoryPHIDs(array($repository->getPHID()))1100->withObjectHashes($hashes)1101->execute();1102$refs = mpull($refs, null, 'getObjectHash');1103} else {1104$refs = array();1105}11061107$file_phids = mpull($refs, 'getFilePHID');1108if ($file_phids) {1109$files = id(new PhabricatorFileQuery())1110->setViewer($viewer)1111->withPHIDs($file_phids)1112->execute();1113$files = mpull($files, null, 'getPHID');1114} else {1115$files = array();1116}11171118$authorization = null;1119$output = array();1120foreach ($objects as $object) {1121$oid = idx($object, 'oid');1122$size = idx($object, 'size');1123$ref = idx($refs, $oid);1124$error = null;11251126// NOTE: If we already have a ref for this object, we only emit a1127// "download" action. The client should not upload the file again.11281129$actions = array();1130if ($ref) {1131$file = idx($files, $ref->getFilePHID());1132if ($file) {1133// Git LFS may prompt users for authentication if the action does1134// not provide an "Authorization" header and does not have a query1135// parameter named "token". See here for discussion:1136// <https://github.com/github/git-lfs/issues/1088>1137$no_authorization = 'Basic '.base64_encode('none');11381139$get_uri = $file->getCDNURI('data');1140$actions['download'] = array(1141'href' => $get_uri,1142'header' => array(1143'Authorization' => $no_authorization,1144'X-Phabricator-Request-Type' => 'git-lfs',1145),1146);1147} else {1148$error = array(1149'code' => 404,1150'message' => pht(1151'Object "%s" was previously uploaded, but no longer exists '.1152'on this server.',1153$oid),1154);1155}1156} else if ($want_upload) {1157if (!$authorization) {1158// Here, we could reuse the existing authorization if we have one,1159// but it's a little simpler to just generate a new one1160// unconditionally.1161$authorization = $this->newGitLFSHTTPAuthorization(1162$repository,1163$viewer,1164$operation);1165}11661167$put_uri = $repository->getGitLFSURI("info/lfs/upload/{$oid}");11681169$actions['upload'] = array(1170'href' => $put_uri,1171'header' => array(1172'Authorization' => $authorization,1173'X-Phabricator-Request-Type' => 'git-lfs',1174),1175);1176}11771178$object = array(1179'oid' => $oid,1180'size' => $size,1181);11821183if ($actions) {1184$object['actions'] = $actions;1185}11861187if ($error) {1188$object['error'] = $error;1189}11901191$output[] = $object;1192}11931194$output = array(1195'objects' => $output,1196);11971198return id(new DiffusionGitLFSResponse())1199->setContent($output);1200}12011202private function serveGitLFSUploadRequest(1203PhabricatorRepository $repository,1204PhabricatorUser $viewer,1205$oid) {12061207$ref = id(new PhabricatorRepositoryGitLFSRefQuery())1208->setViewer($viewer)1209->withRepositoryPHIDs(array($repository->getPHID()))1210->withObjectHashes(array($oid))1211->executeOne();1212if ($ref) {1213return DiffusionGitLFSResponse::newErrorResponse(1214405,1215pht(1216'Content for object "%s" is already known to this server. It can '.1217'not be uploaded again.',1218$oid));1219}12201221// Remove the execution time limit because uploading large files may take1222// a while.1223set_time_limit(0);12241225$request_stream = new AphrontRequestStream();1226$request_iterator = $request_stream->getIterator();1227$hashing_iterator = id(new PhutilHashingIterator($request_iterator))1228->setAlgorithm('sha256');12291230$source = id(new PhabricatorIteratorFileUploadSource())1231->setName('lfs-'.$oid)1232->setViewPolicy(PhabricatorPolicies::POLICY_NOONE)1233->setIterator($hashing_iterator);12341235$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();1236$file = $source->uploadFile();1237unset($unguarded);12381239$hash = $hashing_iterator->getHash();1240if ($hash !== $oid) {1241return DiffusionGitLFSResponse::newErrorResponse(1242400,1243pht(1244'Uploaded data is corrupt or invalid. Expected hash "%s", actual '.1245'hash "%s".',1246$oid,1247$hash));1248}12491250$ref = id(new PhabricatorRepositoryGitLFSRef())1251->setRepositoryPHID($repository->getPHID())1252->setObjectHash($hash)1253->setByteSize($file->getByteSize())1254->setAuthorPHID($viewer->getPHID())1255->setFilePHID($file->getPHID());12561257$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();1258// Attach the file to the repository to give users permission1259// to access it.1260$file->attachToObject($repository->getPHID());1261$ref->save();1262unset($unguarded);12631264// This is just a plain HTTP 200 with no content, which is what `git lfs`1265// expects.1266return new DiffusionGitLFSResponse();1267}12681269private function newGitLFSHTTPAuthorization(1270PhabricatorRepository $repository,1271PhabricatorUser $viewer,1272$operation) {12731274$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();12751276$authorization = DiffusionGitLFSTemporaryTokenType::newHTTPAuthorization(1277$repository,1278$viewer,1279$operation);12801281unset($unguarded);12821283return $authorization;1284}12851286private function getGitLFSRequestPath(PhabricatorRepository $repository) {1287$request_path = $this->getRequestDirectoryPath($repository);12881289$matches = null;1290if (preg_match('(^/info/lfs(?:\z|/)(.*))', $request_path, $matches)) {1291return $matches[1];1292}12931294return null;1295}12961297private function getGitLFSInput() {1298if (!$this->gitLFSInput) {1299$input = PhabricatorStartup::getRawInput();1300$input = phutil_json_decode($input);1301$this->gitLFSInput = $input;1302}13031304return $this->gitLFSInput;1305}13061307private function isGitLFSReadOnlyRequest(PhabricatorRepository $repository) {1308if (!$this->getIsGitLFSRequest()) {1309return false;1310}13111312$path = $this->getGitLFSRequestPath($repository);13131314if ($path === 'objects/batch') {1315$input = $this->getGitLFSInput();1316$operation = idx($input, 'operation');1317switch ($operation) {1318case 'download':1319return true;1320default:1321return false;1322}1323}13241325return false;1326}132713281329}133013311332