Git.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. <?php
  2. declare(strict_types=1);
  3. namespace PhpMyAdmin;
  4. use DateTimeImmutable;
  5. use DateTimeZone;
  6. use DirectoryIterator;
  7. use PhpMyAdmin\Utils\HttpRequest;
  8. use stdClass;
  9. use function array_key_exists;
  10. use function array_shift;
  11. use function basename;
  12. use function bin2hex;
  13. use function count;
  14. use function explode;
  15. use function fclose;
  16. use function file_exists;
  17. use function file_get_contents;
  18. use function fopen;
  19. use function fread;
  20. use function fseek;
  21. use function function_exists;
  22. use function gzuncompress;
  23. use function implode;
  24. use function in_array;
  25. use function intval;
  26. use function is_array;
  27. use function is_bool;
  28. use function is_dir;
  29. use function is_file;
  30. use function json_decode;
  31. use function ord;
  32. use function preg_match;
  33. use function str_contains;
  34. use function str_replace;
  35. use function strlen;
  36. use function strpos;
  37. use function strtolower;
  38. use function substr;
  39. use function trim;
  40. use function unpack;
  41. use const DIRECTORY_SEPARATOR;
  42. use const PHP_EOL;
  43. /**
  44. * Git class to manipulate Git data
  45. */
  46. class Git
  47. {
  48. /**
  49. * Enable Git information search and process
  50. *
  51. * @var bool
  52. */
  53. private $showGitRevision;
  54. /**
  55. * The path where the to search for .git folders
  56. *
  57. * @var string
  58. */
  59. private $baseDir;
  60. /**
  61. * Git has been found and the data fetched
  62. *
  63. * @var bool
  64. */
  65. private $hasGit = false;
  66. public function __construct(bool $showGitRevision, ?string $baseDir = null)
  67. {
  68. $this->showGitRevision = $showGitRevision;
  69. $this->baseDir = $baseDir ?? ROOT_PATH;
  70. }
  71. public function hasGitInformation(): bool
  72. {
  73. return $this->hasGit;
  74. }
  75. /**
  76. * detects if Git revision
  77. *
  78. * @param string $git_location (optional) verified git directory
  79. */
  80. public function isGitRevision(&$git_location = null): bool
  81. {
  82. if (! $this->showGitRevision) {
  83. return false;
  84. }
  85. // caching
  86. if (isset($_SESSION['is_git_revision']) && array_key_exists('git_location', $_SESSION)) {
  87. // Define location using cached value
  88. $git_location = $_SESSION['git_location'];
  89. return (bool) $_SESSION['is_git_revision'];
  90. }
  91. // find out if there is a .git folder
  92. // or a .git file (--separate-git-dir)
  93. $git = $this->baseDir . '.git';
  94. if (file_exists($this->baseDir . 'revision-info.php')) {
  95. $git_location = 'revision-info.php';
  96. } elseif (is_dir($git)) {
  97. if (! @is_file($git . '/config')) {
  98. $_SESSION['git_location'] = null;
  99. $_SESSION['is_git_revision'] = false;
  100. return false;
  101. }
  102. $git_location = $git;
  103. } elseif (is_file($git)) {
  104. $contents = (string) file_get_contents($git);
  105. $gitmatch = [];
  106. // Matches expected format
  107. if (! preg_match('/^gitdir: (.*)$/', $contents, $gitmatch)) {
  108. $_SESSION['git_location'] = null;
  109. $_SESSION['is_git_revision'] = false;
  110. return false;
  111. }
  112. if (! @is_dir($gitmatch[1])) {
  113. $_SESSION['git_location'] = null;
  114. $_SESSION['is_git_revision'] = false;
  115. return false;
  116. }
  117. //Detected git external folder location
  118. $git_location = $gitmatch[1];
  119. } else {
  120. $_SESSION['git_location'] = null;
  121. $_SESSION['is_git_revision'] = false;
  122. return false;
  123. }
  124. // Define session for caching
  125. $_SESSION['git_location'] = $git_location;
  126. $_SESSION['is_git_revision'] = true;
  127. return true;
  128. }
  129. private function readPackFile(string $packFile, int $packOffset): ?string
  130. {
  131. // open pack file
  132. $packFileRes = fopen($packFile, 'rb');
  133. if ($packFileRes === false) {
  134. return null;
  135. }
  136. // seek to start
  137. fseek($packFileRes, $packOffset);
  138. // parse header
  139. $headerData = fread($packFileRes, 1);
  140. if ($headerData === false) {
  141. return null;
  142. }
  143. $header = ord($headerData);
  144. $type = ($header >> 4) & 7;
  145. $hasnext = ($header & 128) >> 7;
  146. $size = $header & 0xf;
  147. $offset = 4;
  148. while ($hasnext) {
  149. $readData = fread($packFileRes, 1);
  150. if ($readData === false) {
  151. return null;
  152. }
  153. $byte = ord($readData);
  154. $size |= ($byte & 0x7f) << $offset;
  155. $hasnext = ($byte & 128) >> 7;
  156. $offset += 7;
  157. }
  158. // we care only about commit objects
  159. if ($type != 1) {
  160. return null;
  161. }
  162. // read data
  163. $commit = fread($packFileRes, $size);
  164. fclose($packFileRes);
  165. if ($commit === false) {
  166. return null;
  167. }
  168. return $commit;
  169. }
  170. private function getPackOffset(string $packFile, string $hash): ?int
  171. {
  172. // load index
  173. $index_data = @file_get_contents($packFile);
  174. if ($index_data === false) {
  175. return null;
  176. }
  177. // check format
  178. if (substr($index_data, 0, 4) != "\377tOc") {
  179. return null;
  180. }
  181. // check version
  182. $version = unpack('N', substr($index_data, 4, 4));
  183. if ($version[1] != 2) {
  184. return null;
  185. }
  186. // parse fanout table
  187. $fanout = unpack(
  188. 'N*',
  189. substr($index_data, 8, 256 * 4)
  190. );
  191. // find where we should search
  192. $firstbyte = intval(substr($hash, 0, 2), 16);
  193. // array is indexed from 1 and we need to get
  194. // previous entry for start
  195. if ($firstbyte == 0) {
  196. $start = 0;
  197. } else {
  198. $start = $fanout[$firstbyte];
  199. }
  200. $end = $fanout[$firstbyte + 1];
  201. // stupid linear search for our sha
  202. $found = false;
  203. $offset = 8 + (256 * 4);
  204. for ($position = $start; $position < $end; $position++) {
  205. $sha = strtolower(
  206. bin2hex(
  207. substr($index_data, $offset + ($position * 20), 20)
  208. )
  209. );
  210. if ($sha == $hash) {
  211. $found = true;
  212. break;
  213. }
  214. }
  215. if (! $found) {
  216. return null;
  217. }
  218. // read pack offset
  219. $offset = 8 + (256 * 4) + (24 * $fanout[256]);
  220. $packOffsets = unpack(
  221. 'N',
  222. substr($index_data, $offset + ($position * 4), 4)
  223. );
  224. return $packOffsets[1];
  225. }
  226. /**
  227. * Un pack a commit with gzuncompress
  228. *
  229. * @param string $gitFolder The Git folder
  230. * @param string $hash The commit hash
  231. *
  232. * @return array|false|null
  233. */
  234. private function unPackGz(string $gitFolder, string $hash)
  235. {
  236. $commit = false;
  237. $gitFileName = $gitFolder . '/objects/'
  238. . substr($hash, 0, 2) . '/' . substr($hash, 2);
  239. if (@file_exists($gitFileName)) {
  240. $commit = @file_get_contents($gitFileName);
  241. if ($commit === false) {
  242. $this->hasGit = false;
  243. return null;
  244. }
  245. $commitData = gzuncompress($commit);
  246. if ($commitData === false) {
  247. return null;
  248. }
  249. $commit = explode("\0", $commitData, 2);
  250. $commit = explode("\n", $commit[1]);
  251. $_SESSION['PMA_VERSION_COMMITDATA_' . $hash] = $commit;
  252. } else {
  253. $pack_names = [];
  254. // work with packed data
  255. $packs_file = $gitFolder . '/objects/info/packs';
  256. $packs = '';
  257. if (@file_exists($packs_file)) {
  258. $packs = @file_get_contents($packs_file);
  259. }
  260. if ($packs) {
  261. // File exists. Read it, parse the file to get the names of the
  262. // packs. (to look for them in .git/object/pack directory later)
  263. foreach (explode("\n", $packs) as $line) {
  264. // skip blank lines
  265. if (strlen(trim($line)) == 0) {
  266. continue;
  267. }
  268. // skip non pack lines
  269. if ($line[0] !== 'P') {
  270. continue;
  271. }
  272. // parse names
  273. $pack_names[] = substr($line, 2);
  274. }
  275. } else {
  276. // '.git/objects/info/packs' file can be missing
  277. // (at least in mysGit)
  278. // File missing. May be we can look in the .git/object/pack
  279. // directory for all the .pack files and use that list of
  280. // files instead
  281. $dirIterator = new DirectoryIterator($gitFolder . '/objects/pack');
  282. foreach ($dirIterator as $file_info) {
  283. $file_name = $file_info->getFilename();
  284. // if this is a .pack file
  285. if (! $file_info->isFile() || substr($file_name, -5) !== '.pack') {
  286. continue;
  287. }
  288. $pack_names[] = $file_name;
  289. }
  290. }
  291. $hash = strtolower($hash);
  292. foreach ($pack_names as $pack_name) {
  293. $index_name = str_replace('.pack', '.idx', $pack_name);
  294. $packOffset = $this->getPackOffset($gitFolder . '/objects/pack/' . $index_name, $hash);
  295. if ($packOffset === null) {
  296. continue;
  297. }
  298. $commit = $this->readPackFile($gitFolder . '/objects/pack/' . $pack_name, $packOffset);
  299. if ($commit !== null) {
  300. $commit = gzuncompress($commit);
  301. if ($commit !== false) {
  302. $commit = explode("\n", $commit);
  303. }
  304. }
  305. $_SESSION['PMA_VERSION_COMMITDATA_' . $hash] = $commit;
  306. }
  307. }
  308. return $commit;
  309. }
  310. /**
  311. * Extract committer, author and message from commit body
  312. *
  313. * @param string[] $commit The commit body
  314. *
  315. * @return array<int,array<string,string>|string>
  316. */
  317. public static function extractDataFormTextBody(array $commit): array
  318. {
  319. $author = [
  320. 'name' => '',
  321. 'email' => '',
  322. 'date' => '',
  323. ];
  324. $committer = [
  325. 'name' => '',
  326. 'email' => '',
  327. 'date' => '',
  328. ];
  329. do {
  330. $dataline = array_shift($commit);
  331. $datalinearr = explode(' ', $dataline, 2);
  332. $linetype = $datalinearr[0];
  333. if (! in_array($linetype, ['author', 'committer'])) {
  334. continue;
  335. }
  336. $user = $datalinearr[1];
  337. preg_match('/([^<]+)<([^>]+)> ([0-9]+)( [^ ]+)?/', $user, $user);
  338. $timezone = new DateTimeZone($user[4] ?? '+0000');
  339. $date = (new DateTimeImmutable())->setTimestamp((int) $user[3])->setTimezone($timezone);
  340. $user2 = [
  341. 'name' => trim($user[1]),
  342. 'email' => trim($user[2]),
  343. 'date' => $date->format('Y-m-d H:i:s O'),
  344. ];
  345. if ($linetype === 'author') {
  346. $author = $user2;
  347. } elseif ($linetype === 'committer') {
  348. $committer = $user2;
  349. }
  350. } while ($dataline != '');
  351. $message = trim(implode(' ', $commit));
  352. return [$author, $committer, $message];
  353. }
  354. /**
  355. * Is the commit remote
  356. *
  357. * @param mixed $commit The commit
  358. * @param bool $isRemoteCommit Is the commit remote ?, will be modified by reference
  359. * @param string $hash The commit hash
  360. *
  361. * @return stdClass|null The commit body from the GitHub API
  362. */
  363. private function isRemoteCommit(&$commit, bool &$isRemoteCommit, string $hash): ?stdClass
  364. {
  365. $httpRequest = new HttpRequest();
  366. // check if commit exists in Github
  367. if ($commit !== false && isset($_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash])) {
  368. $isRemoteCommit = $_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash];
  369. return null;
  370. }
  371. $link = 'https://www.phpmyadmin.net/api/commit/' . $hash . '/';
  372. $is_found = $httpRequest->create($link, 'GET');
  373. if ($is_found === false) {
  374. $isRemoteCommit = false;
  375. $_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash] = false;
  376. return null;
  377. }
  378. if ($is_found === null) {
  379. // no remote link for now, but don't cache this as GitHub is down
  380. $isRemoteCommit = false;
  381. return null;
  382. }
  383. $isRemoteCommit = true;
  384. $_SESSION['PMA_VERSION_REMOTECOMMIT_' . $hash] = true;
  385. if ($commit === false) {
  386. // if no local commit data, try loading from Github
  387. return json_decode((string) $is_found);
  388. }
  389. return null;
  390. }
  391. private function getHashFromHeadRef(string $gitFolder, string $refHead): array
  392. {
  393. $branch = false;
  394. // are we on any branch?
  395. if (! str_contains($refHead, '/')) {
  396. return [trim($refHead), $branch];
  397. }
  398. // remove ref: prefix
  399. $refHead = substr(trim($refHead), 5);
  400. if (strpos($refHead, 'refs/heads/') === 0) {
  401. $branch = substr($refHead, 11);
  402. } else {
  403. $branch = basename($refHead);
  404. }
  405. $refFile = $gitFolder . '/' . $refHead;
  406. if (@file_exists($refFile)) {
  407. $hash = @file_get_contents($refFile);
  408. if ($hash === false) {
  409. $this->hasGit = false;
  410. return [null, null];
  411. }
  412. return [trim($hash), $branch];
  413. }
  414. // deal with packed refs
  415. $packedRefs = @file_get_contents($gitFolder . '/packed-refs');
  416. if ($packedRefs === false) {
  417. $this->hasGit = false;
  418. return [null, null];
  419. }
  420. // split file to lines
  421. $refLines = explode(PHP_EOL, $packedRefs);
  422. foreach ($refLines as $line) {
  423. // skip comments
  424. if ($line[0] === '#') {
  425. continue;
  426. }
  427. // parse line
  428. $parts = explode(' ', $line);
  429. // care only about named refs
  430. if (count($parts) != 2) {
  431. continue;
  432. }
  433. // have found our ref?
  434. if ($parts[1] == $refHead) {
  435. $hash = $parts[0];
  436. break;
  437. }
  438. }
  439. if (! isset($hash)) {
  440. $this->hasGit = false;
  441. // Could not find ref
  442. return [null, null];
  443. }
  444. return [$hash, $branch];
  445. }
  446. private function getCommonDirContents(string $gitFolder): ?string
  447. {
  448. if (! is_file($gitFolder . '/commondir')) {
  449. return null;
  450. }
  451. $commonDirContents = @file_get_contents($gitFolder . '/commondir');
  452. if ($commonDirContents === false) {
  453. return null;
  454. }
  455. return trim($commonDirContents);
  456. }
  457. /**
  458. * @return array<string, string|array<string, string>>|null
  459. * @psalm-return array{
  460. * revision: string,
  461. * revisionHash: string,
  462. * revisionUrl: string,
  463. * branch: string,
  464. * branchUrl: string,
  465. * message: string,
  466. * author: array{
  467. * name: string,
  468. * email: string,
  469. * date: string
  470. * },
  471. * committer: array{
  472. * name: string,
  473. * email: string,
  474. * date: string
  475. * }
  476. * }|null
  477. */
  478. public function getGitRevisionInfo(): ?array
  479. {
  480. if (@file_exists($this->baseDir . 'revision-info.php')) {
  481. /** @var array{ revision: string, revisionHash: string, revisionUrl: string, branch: string, branchUrl: string, message: string, author: array{ name: string, email: string, date: string }, committer: array{ name: string, email: string, date: string }}|null $info */
  482. /** @psalm-suppress MissingFile,UnresolvableInclude */
  483. $info = include $this->baseDir . 'revision-info.php';
  484. if (! is_array($info)) {
  485. return null;
  486. }
  487. return $info;
  488. }
  489. return null;
  490. }
  491. /**
  492. * detects Git revision, if running inside repo
  493. */
  494. public function checkGitRevision(): ?array
  495. {
  496. // find out if there is a .git folder
  497. $gitFolder = '';
  498. if (! $this->isGitRevision($gitFolder)) {
  499. $this->hasGit = false;
  500. return null;
  501. }
  502. // Special name to indicate the use of the config file
  503. if ($gitFolder === 'revision-info.php') {
  504. $info = $this->getGitRevisionInfo();
  505. if ($info === null) {
  506. return null;
  507. }
  508. $this->hasGit = true;
  509. return [
  510. 'hash' => $info['revisionHash'],
  511. 'branch' => $info['branch'],
  512. 'message' => $info['message'],
  513. 'author' => [
  514. 'name' => $info['author']['name'],
  515. 'email' => $info['author']['email'],
  516. 'date' => $info['author']['date'],
  517. ],
  518. 'committer' => [
  519. 'name' => $info['committer']['name'],
  520. 'email' => $info['committer']['email'],
  521. 'date' => $info['committer']['date'],
  522. ],
  523. // Let's make the guess that the data is remote
  524. // The write script builds a remote commit url without checking that it exists
  525. 'is_remote_commit' => true,
  526. 'is_remote_branch' => true,
  527. ];
  528. }
  529. $ref_head = @file_get_contents($gitFolder . '/HEAD');
  530. if (! $ref_head) {
  531. $this->hasGit = false;
  532. return null;
  533. }
  534. $commonDirContents = $this->getCommonDirContents($gitFolder);
  535. if ($commonDirContents !== null) {
  536. $gitFolder .= DIRECTORY_SEPARATOR . $commonDirContents;
  537. }
  538. [$hash, $branch] = $this->getHashFromHeadRef($gitFolder, $ref_head);
  539. if ($hash === null) {
  540. return null;
  541. }
  542. $commit = false;
  543. if (! preg_match('/^[0-9a-f]{40}$/i', $hash)) {
  544. $commit = false;
  545. } elseif (isset($_SESSION['PMA_VERSION_COMMITDATA_' . $hash])) {
  546. $commit = $_SESSION['PMA_VERSION_COMMITDATA_' . $hash];
  547. } elseif (function_exists('gzuncompress')) {
  548. $commit = $this->unPackGz($gitFolder, $hash);
  549. if ($commit === null) {
  550. return null;
  551. }
  552. }
  553. $is_remote_commit = false;
  554. $commit_json = $this->isRemoteCommit(
  555. $commit, // Will be modified if necessary by the function
  556. $is_remote_commit, // Will be modified if necessary by the function
  557. $hash
  558. );
  559. $is_remote_branch = false;
  560. if ($is_remote_commit && $branch !== false) {
  561. // check if branch exists in Github
  562. if (isset($_SESSION['PMA_VERSION_REMOTEBRANCH_' . $hash])) {
  563. $is_remote_branch = $_SESSION['PMA_VERSION_REMOTEBRANCH_' . $hash];
  564. } else {
  565. $httpRequest = new HttpRequest();
  566. $link = 'https://www.phpmyadmin.net/api/tree/' . $branch . '/';
  567. $is_found = $httpRequest->create($link, 'GET', true);
  568. if (is_bool($is_found)) {
  569. $is_remote_branch = $is_found;
  570. $_SESSION['PMA_VERSION_REMOTEBRANCH_' . $hash] = $is_found;
  571. }
  572. if ($is_found === null) {
  573. // no remote link for now, but don't cache this as Github is down
  574. $is_remote_branch = false;
  575. }
  576. }
  577. }
  578. if ($commit !== false) {
  579. [$author, $committer, $message] = self::extractDataFormTextBody($commit);
  580. } elseif (isset($commit_json->author, $commit_json->committer, $commit_json->message)) {
  581. $author = [
  582. 'name' => $commit_json->author->name,
  583. 'email' => $commit_json->author->email,
  584. 'date' => $commit_json->author->date,
  585. ];
  586. $committer = [
  587. 'name' => $commit_json->committer->name,
  588. 'email' => $commit_json->committer->email,
  589. 'date' => $commit_json->committer->date,
  590. ];
  591. $message = trim($commit_json->message);
  592. } else {
  593. $this->hasGit = false;
  594. return null;
  595. }
  596. $this->hasGit = true;
  597. return [
  598. 'hash' => $hash,
  599. 'branch' => $branch,
  600. 'message' => $message,
  601. 'author' => $author,
  602. 'committer' => $committer,
  603. 'is_remote_commit' => $is_remote_commit,
  604. 'is_remote_branch' => $is_remote_branch,
  605. ];
  606. }
  607. }