@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
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}