| 4 | 4 | |
| 5 | 5 | private static function getHomeDirectory() { |
| 6 | if (!empty($_SERVER['HOME'])) { | |
| 6 | if( !empty( $_SERVER['HOME'] ) ) { | |
| 7 | 7 | return $_SERVER['HOME']; |
| 8 | 8 | } |
| 9 | if (!empty(getenv('HOME'))) { | |
| 10 | return getenv('HOME'); | |
| 9 | ||
| 10 | if( !empty( getenv( 'HOME' ) ) ) { | |
| 11 | return getenv( 'HOME' ); | |
| 11 | 12 | } |
| 12 | if (function_exists('posix_getpwuid') && function_exists('posix_getuid')) { | |
| 13 | $userInfo = posix_getpwuid(posix_getuid()); | |
| 14 | if (!empty($userInfo['dir'])) { | |
| 13 | ||
| 14 | if( function_exists( 'posix_getpwuid' ) && | |
| 15 | function_exists( 'posix_getuid' ) ) { | |
| 16 | $userInfo = posix_getpwuid( posix_getuid() ); | |
| 17 | ||
| 18 | if( !empty( $userInfo['dir'] ) ) { | |
| 15 | 19 | return $userInfo['dir']; |
| 16 | 20 | } |
| 17 | 21 | } |
| 22 | ||
| 18 | 23 | return ''; |
| 19 | 24 | } |
| 20 | 25 | |
| 21 | 26 | public static function getReposPath() { |
| 22 | 27 | return self::getHomeDirectory() . '/repos'; |
| 23 | 28 | } |
| 24 | 29 | |
| 25 | 30 | public static function init() { |
| 26 | ini_set('display_errors', 0); | |
| 27 | ini_set('log_errors', 1); | |
| 28 | ini_set('error_log', __DIR__ . '/error.log'); | |
| 31 | ini_set( 'display_errors', 0 ); | |
| 32 | ini_set( 'log_errors', 1 ); | |
| 33 | ini_set( 'error_log', __DIR__ . '/error.log' ); | |
| 29 | 34 | } |
| 30 | 35 | } |
| 22 | 22 | private int $size; |
| 23 | 23 | private bool $isDir; |
| 24 | private string $icon; | |
| 25 | 24 | |
| 26 | 25 | private string $mediaType; |
| ... | ||
| 36 | 35 | string $contents = '' |
| 37 | 36 | ) { |
| 38 | $this->name = $name; | |
| 39 | $this->sha = $sha; | |
| 40 | $this->mode = $mode; | |
| 41 | $this->timestamp = $timestamp; | |
| 42 | $this->size = $size; | |
| 43 | $this->isDir = $mode === '40000' || $mode === '040000'; | |
| 37 | $this->name = $name; | |
| 38 | $this->sha = $sha; | |
| 39 | $this->mode = $mode; | |
| 40 | $this->timestamp = $timestamp; | |
| 41 | $this->size = $size; | |
| 42 | $this->isDir = $mode === '40000' || $mode === '040000'; | |
| 44 | 43 | |
| 45 | 44 | $buffer = $this->isDir ? '' : $contents; |
| 46 | 45 | |
| 47 | $this->mediaType = $this->detectMediaType($buffer); | |
| 48 | $this->category = $this->detectCategory($name); | |
| 49 | $this->binary = $this->detectBinary(); | |
| 50 | $this->icon = $this->resolveIcon(); | |
| 46 | $this->mediaType = $this->detectMediaType( $buffer ); | |
| 47 | $this->category = $this->detectCategory( $name ); | |
| 48 | $this->binary = $this->detectBinary(); | |
| 51 | 49 | } |
| 52 | 50 | |
| 53 | public function compare(File $other): int { | |
| 51 | public function compare( File $other ): int { | |
| 54 | 52 | return $this->isDir !== $other->isDir |
| 55 | 53 | ? ($this->isDir ? -1 : 1) |
| 56 | : strcasecmp($this->name, $other->name); | |
| 54 | : strcasecmp( $this->name, $other->name ); | |
| 57 | 55 | } |
| 58 | 56 | |
| 59 | public function render(FileRenderer $renderer): void { | |
| 60 | $renderer->renderFile( | |
| 57 | public function renderListEntry( FileRenderer $renderer ): void { | |
| 58 | $renderer->renderListEntry( | |
| 61 | 59 | $this->name, |
| 62 | 60 | $this->sha, |
| 63 | 61 | $this->mode, |
| 64 | $this->icon, | |
| 62 | $this->resolveIcon(), | |
| 65 | 63 | $this->timestamp, |
| 66 | 64 | $this->size |
| 67 | 65 | ); |
| 68 | 66 | } |
| 69 | 67 | |
| 70 | public function renderSize(FileRenderer $renderer): void { | |
| 71 | $renderer->renderSize($this->size); | |
| 68 | public function renderMedia( FileRenderer $renderer, string $url ): bool { | |
| 69 | return $renderer->renderMedia( $this, $url, $this->mediaType ); | |
| 72 | 70 | } |
| 73 | ||
| 74 | public function renderMedia(string $url): bool { | |
| 75 | $rendered = false; | |
| 76 | ||
| 77 | if ($this->isImage()) { | |
| 78 | echo '<div class="blob-content blob-content-image"><img src="' . $url . '"></div>'; | |
| 79 | $rendered = true; | |
| 80 | } elseif ($this->isVideo()) { | |
| 81 | echo '<div class="blob-content blob-content-video"><video controls><source src="' . $url . '" type="' . $this->mediaType . '"></video></div>'; | |
| 82 | $rendered = true; | |
| 83 | } elseif ($this->isAudio()) { | |
| 84 | echo '<div class="blob-content blob-content-audio"><audio controls><source src="' . $url . '" type="' . $this->mediaType . '"></audio></div>'; | |
| 85 | $rendered = true; | |
| 86 | } | |
| 87 | 71 | |
| 88 | return $rendered; | |
| 72 | public function renderSize( FileRenderer $renderer ): void { | |
| 73 | $renderer->renderSize( $this->size ); | |
| 89 | 74 | } |
| 90 | 75 | |
| 91 | public function emitRawHeaders(): void { | |
| 92 | header("Content-Type: " . $this->mediaType); | |
| 93 | header("Content-Length: " . $this->size); | |
| 94 | header("Content-Disposition: attachment; filename=\"" . addslashes(basename($this->name)) . "\""); | |
| 76 | public function highlight( FileRenderer $renderer, string $content ): string { | |
| 77 | return $renderer->highlight( $this->name, $content, $this->mediaType ); | |
| 95 | 78 | } |
| 96 | 79 | |
| ... | ||
| 113 | 96 | public function isBinary(): bool { |
| 114 | 97 | return $this->binary; |
| 98 | } | |
| 99 | ||
| 100 | public function emitRawHeaders(): void { | |
| 101 | header( "Content-Type: " . $this->mediaType ); | |
| 102 | header( "Content-Length: " . $this->size ); | |
| 103 | header( "Content-Disposition: attachment; filename=\"" . | |
| 104 | addslashes( basename( $this->name ) ) . "\"" ); | |
| 115 | 105 | } |
| 116 | 106 | |
| 117 | 107 | private function resolveIcon(): string { |
| 118 | 108 | return $this->isDir |
| 119 | 109 | ? 'fa-folder' |
| 120 | : (str_contains($this->mediaType, 'application/pdf') | |
| 110 | : (str_contains( $this->mediaType, 'application/pdf' ) | |
| 121 | 111 | ? 'fa-file-pdf' |
| 122 | : match ($this->category) { | |
| 112 | : match( $this->category ) { | |
| 123 | 113 | self::CAT_ARCHIVE => 'fa-file-archive', |
| 124 | self::CAT_IMAGE => 'fa-file-image', | |
| 125 | self::CAT_AUDIO => 'fa-file-audio', | |
| 126 | self::CAT_VIDEO => 'fa-file-video', | |
| 127 | self::CAT_TEXT => 'fa-file-code', | |
| 128 | default => 'fa-file', | |
| 114 | self::CAT_IMAGE => 'fa-file-image', | |
| 115 | self::CAT_AUDIO => 'fa-file-audio', | |
| 116 | self::CAT_VIDEO => 'fa-file-video', | |
| 117 | self::CAT_TEXT => 'fa-file-code', | |
| 118 | default => 'fa-file', | |
| 129 | 119 | }); |
| 130 | 120 | } |
| 131 | 121 | |
| 132 | private function detectMediaType(string $buffer): string { | |
| 133 | $finfo = new finfo(FILEINFO_MIME_TYPE); | |
| 134 | $mediaType = $finfo->buffer($buffer); | |
| 122 | private function detectMediaType( string $buffer ): string { | |
| 123 | if( $buffer === '' ) return 'application/x-empty'; | |
| 124 | ||
| 125 | $finfo = new finfo( FILEINFO_MIME_TYPE ); | |
| 126 | $mediaType = $finfo->buffer( $buffer ); | |
| 127 | ||
| 135 | 128 | return $mediaType ?: 'application/octet-stream'; |
| 136 | 129 | } |
| 137 | 130 | |
| 138 | private function detectCategory(string $filename = ''): string { | |
| 139 | $parts = explode('/', $this->mediaType); | |
| 131 | private function detectCategory( string $filename = '' ): string { | |
| 132 | $parts = explode( '/', $this->mediaType ); | |
| 140 | 133 | |
| 141 | return match(true) { | |
| 134 | return match( true ) { | |
| 142 | 135 | $parts[0] === 'image' => self::CAT_IMAGE, |
| 143 | 136 | $parts[0] === 'video' => self::CAT_VIDEO, |
| 144 | 137 | $parts[0] === 'audio' => self::CAT_AUDIO, |
| 145 | 138 | $parts[0] === 'text' => self::CAT_TEXT, |
| 146 | $this->isArchiveFile($filename) => self::CAT_ARCHIVE, | |
| 147 | str_contains($this->mediaType, 'compressed') => self::CAT_ARCHIVE, | |
| 139 | $this->isArchiveFile( $filename ) => self::CAT_ARCHIVE, | |
| 140 | str_contains( $this->mediaType, 'compressed' ) => self::CAT_ARCHIVE, | |
| 148 | 141 | default => self::CAT_BINARY, |
| 149 | 142 | }; |
| 150 | 143 | } |
| 151 | 144 | |
| 152 | 145 | private function detectBinary(): bool { |
| 153 | return !str_starts_with($this->mediaType, 'text/'); | |
| 146 | return !str_starts_with( $this->mediaType, 'text/' ); | |
| 154 | 147 | } |
| 155 | 148 | |
| 156 | private function isArchiveFile(string $filename): bool { | |
| 149 | private function isArchiveFile( string $filename ): bool { | |
| 157 | 150 | return in_array( |
| 158 | strtolower(pathinfo($filename, PATHINFO_EXTENSION)), | |
| 151 | strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ), | |
| 159 | 152 | self::ARCHIVE_EXTENSIONS, |
| 160 | 153 | true |
| 3 | 3 | private $reposPath; |
| 4 | 4 | |
| 5 | public function __construct($path) { | |
| 5 | public function __construct( $path ) { | |
| 6 | 6 | $this->reposPath = $path; |
| 7 | 7 | } |
| 8 | 8 | |
| 9 | public function eachRepository(callable $callback) { | |
| 9 | public function eachRepository( callable $callback ) { | |
| 10 | 10 | $repos = []; |
| 11 | $dirs = glob($this->reposPath . '/*', GLOB_ONLYDIR); | |
| 11 | $dirs = glob( $this->reposPath . '/*', GLOB_ONLYDIR ); | |
| 12 | 12 | |
| 13 | if ($dirs === false) return; | |
| 13 | if( $dirs === false ) { | |
| 14 | return; | |
| 15 | } | |
| 14 | 16 | |
| 15 | foreach ($dirs as $dir) { | |
| 16 | $basename = basename($dir); | |
| 17 | if ($basename[0] === '.') continue; | |
| 17 | foreach( $dirs as $dir ) { | |
| 18 | $basename = basename( $dir ); | |
| 19 | ||
| 20 | if( $basename[0] === '.' ) { | |
| 21 | continue; | |
| 22 | } | |
| 18 | 23 | |
| 19 | 24 | $repos[$basename] = [ |
| 20 | 25 | 'name' => $basename, |
| 21 | 26 | 'safe_name' => $basename, |
| 22 | 27 | 'path' => $dir |
| 23 | 28 | ]; |
| 24 | 29 | } |
| 25 | 30 | |
| 26 | $this->sortRepositories($repos); | |
| 31 | $this->sortRepositories( $repos ); | |
| 27 | 32 | |
| 28 | foreach ($repos as $repo) { | |
| 29 | $callback($repo); | |
| 33 | foreach( $repos as $repo ) { | |
| 34 | $callback( $repo ); | |
| 30 | 35 | } |
| 31 | 36 | } |
| 32 | 37 | |
| 33 | private function sortRepositories(array &$repos) { | |
| 38 | private function sortRepositories( array &$repos ) { | |
| 34 | 39 | $orderFile = __DIR__ . '/order.txt'; |
| 35 | 40 | |
| 36 | if (!file_exists($orderFile)) { | |
| 37 | ksort($repos, SORT_NATURAL | SORT_FLAG_CASE); | |
| 41 | if( !file_exists( $orderFile ) ) { | |
| 42 | ksort( $repos, SORT_NATURAL | SORT_FLAG_CASE ); | |
| 38 | 43 | return; |
| 39 | 44 | } |
| 40 | 45 | |
| 41 | $lines = file($orderFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); | |
| 46 | $lines = file( $orderFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ); | |
| 42 | 47 | $order = []; |
| 43 | 48 | $exclude = []; |
| 44 | 49 | |
| 45 | foreach ($lines as $line) { | |
| 46 | $line = trim($line); | |
| 47 | if ($line === '') continue; | |
| 50 | foreach( $lines as $line ) { | |
| 51 | $line = trim( $line ); | |
| 48 | 52 | |
| 49 | if ($line[0] === '-') { | |
| 50 | $exclude[substr($line, 1)] = true; | |
| 53 | if( $line === '' ) { | |
| 54 | continue; | |
| 55 | } | |
| 56 | ||
| 57 | if( $line[0] === '-' ) { | |
| 58 | $exclude[substr( $line, 1 )] = true; | |
| 51 | 59 | } else { |
| 52 | $order[$line] = count($order); | |
| 60 | $order[$line] = count( $order ); | |
| 53 | 61 | } |
| 54 | 62 | } |
| 55 | 63 | |
| 56 | foreach ($repos as $key => $repo) { | |
| 57 | if (isset($exclude[$repo['safe_name']])) { | |
| 58 | unset($repos[$key]); | |
| 64 | foreach( $repos as $key => $repo ) { | |
| 65 | if( isset( $exclude[$repo['safe_name']] ) ) { | |
| 66 | unset( $repos[$key] ); | |
| 59 | 67 | } |
| 60 | 68 | } |
| 61 | 69 | |
| 62 | uasort($repos, function($a, $b) use ($order) { | |
| 70 | uasort( $repos, function( $a, $b ) use ( $order ) { | |
| 63 | 71 | $nameA = $a['safe_name']; |
| 64 | 72 | $nameB = $b['safe_name']; |
| 65 | ||
| 66 | 73 | $posA = $order[$nameA] ?? PHP_INT_MAX; |
| 67 | 74 | $posB = $order[$nameB] ?? PHP_INT_MAX; |
| 68 | 75 | |
| 69 | if ($posA === $posB) { | |
| 70 | return strcasecmp($nameA, $nameB); | |
| 76 | if( $posA === $posB ) { | |
| 77 | return strcasecmp( $nameA, $nameB ); | |
| 71 | 78 | } |
| 72 | 79 | |
| 73 | 80 | return $posA <=> $posB; |
| 74 | }); | |
| 81 | } ); | |
| 75 | 82 | } |
| 76 | 83 | } |
| 2 | 2 | require_once __DIR__ . '/RepositoryList.php'; |
| 3 | 3 | require_once __DIR__ . '/git/Git.php'; |
| 4 | ||
| 5 | 4 | require_once __DIR__ . '/pages/CommitsPage.php'; |
| 6 | 5 | require_once __DIR__ . '/pages/DiffPage.php'; |
| ... | ||
| 17 | 16 | public function __construct( string $reposPath ) { |
| 18 | 17 | $this->git = new Git( $reposPath ); |
| 19 | ||
| 20 | 18 | $list = new RepositoryList( $reposPath ); |
| 21 | 19 | |
| 22 | 20 | $list->eachRepository( function( $repo ) { |
| 23 | 21 | $this->repos[] = $repo; |
| 24 | 22 | } ); |
| 25 | 23 | } |
| 26 | 24 | |
| 27 | 25 | public function route(): Page { |
| 28 | 26 | $reqRepo = $_GET['repo'] ?? ''; |
| 29 | $action = $_GET['action'] ?? 'file'; | |
| 30 | $hash = $this->sanitize( $_GET['hash'] ?? '' ); | |
| 27 | $action = $_GET['action'] ?? 'file'; | |
| 28 | $hash = $this->sanitize( $_GET['hash'] ?? '' ); | |
| 31 | 29 | $subPath = ''; |
| 32 | ||
| 33 | 30 | $uri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ); |
| 34 | 31 | $scriptName = $_SERVER['SCRIPT_NAME']; |
| 35 | 32 | |
| 36 | if ( strpos( $uri, $scriptName ) === 0 ) { | |
| 33 | if( strpos( $uri, $scriptName ) === 0 ) { | |
| 37 | 34 | $uri = substr( $uri, strlen( $scriptName ) ); |
| 38 | 35 | } |
| 39 | 36 | |
| 40 | 37 | if( preg_match( '#^/([^/]+)\.git(?:/(.*))?$#', $uri, $matches ) ) { |
| 41 | 38 | $reqRepo = urldecode( $matches[1] ); |
| 42 | 39 | $subPath = isset( $matches[2] ) ? ltrim( $matches[2], '/' ) : ''; |
| 43 | $action = 'clone'; | |
| 40 | $action = 'clone'; | |
| 44 | 41 | } |
| 45 | 42 | |
| 46 | 43 | $currRepo = null; |
| 47 | $decoded = urldecode( $reqRepo ); | |
| 44 | $decoded = urldecode( $reqRepo ); | |
| 48 | 45 | |
| 49 | 46 | foreach( $this->repos as $repo ) { |
| 50 | 47 | if( $repo['safe_name'] === $reqRepo || $repo['name'] === $decoded ) { |
| 51 | 48 | $currRepo = $repo; |
| 49 | ||
| 52 | 50 | break; |
| 53 | 51 | } |
| 54 | 52 | |
| 55 | 53 | $prefix = $repo['safe_name'] . '/'; |
| 56 | 54 | |
| 57 | 55 | if( strpos( $reqRepo, $prefix ) === 0 ) { |
| 58 | 56 | $currRepo = $repo; |
| 59 | $subPath = substr( $reqRepo, strlen( $prefix ) ); | |
| 60 | $action = 'clone'; | |
| 57 | $subPath = substr( $reqRepo, strlen( $prefix ) ); | |
| 58 | $action = 'clone'; | |
| 59 | ||
| 61 | 60 | break; |
| 62 | 61 | } |
| 18 | 18 | string $author |
| 19 | 19 | ) { |
| 20 | $this->name = $name; | |
| 21 | $this->sha = $sha; | |
| 20 | $this->name = $name; | |
| 21 | $this->sha = $sha; | |
| 22 | 22 | $this->targetSha = $targetSha; |
| 23 | 23 | $this->timestamp = $timestamp; |
| 24 | $this->message = $message; | |
| 25 | $this->author = $author; | |
| 24 | $this->message = $message; | |
| 25 | $this->author = $author; | |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | public function compare(Tag $other): int { | |
| 28 | public function compare( Tag $other ): int { | |
| 29 | 29 | return $other->timestamp <=> $this->timestamp; |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | public function render(TagRenderer $renderer): void { | |
| 32 | public function render( TagRenderer $renderer ): void { | |
| 33 | 33 | $renderer->renderTagItem( |
| 34 | 34 | $this->name, |
| 6 | 6 | |
| 7 | 7 | class Git { |
| 8 | private const CHUNK_SIZE = 128; | |
| 9 | private const MAX_READ_SIZE = 1048576; | |
| 10 | ||
| 11 | private string $repoPath; | |
| 12 | private string $objectsPath; | |
| 13 | ||
| 14 | private GitRefs $refs; | |
| 15 | private GitPacks $packs; | |
| 16 | ||
| 17 | public function __construct( string $repoPath ) { | |
| 18 | $this->setRepository( $repoPath ); | |
| 19 | } | |
| 20 | ||
| 21 | public function setRepository( string $repoPath ): void { | |
| 22 | $this->repoPath = rtrim( $repoPath, '/' ); | |
| 23 | $this->objectsPath = $this->repoPath . '/objects'; | |
| 24 | ||
| 25 | $this->refs = new GitRefs( $this->repoPath ); | |
| 26 | $this->packs = new GitPacks( $this->objectsPath ); | |
| 27 | } | |
| 28 | ||
| 29 | public function resolve( string $reference ): string { | |
| 30 | return $this->refs->resolve( $reference ); | |
| 31 | } | |
| 32 | ||
| 33 | public function getMainBranch(): array { | |
| 34 | return $this->refs->getMainBranch(); | |
| 35 | } | |
| 36 | ||
| 37 | public function eachBranch( callable $callback ): void { | |
| 38 | $this->refs->scanRefs( 'refs/heads', $callback ); | |
| 39 | } | |
| 40 | ||
| 41 | public function eachTag( callable $callback ): void { | |
| 42 | $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( $callback ) { | |
| 43 | $data = $this->read( $sha ); | |
| 44 | ||
| 45 | $targetSha = $sha; | |
| 46 | $timestamp = 0; | |
| 47 | $message = ''; | |
| 48 | $author = ''; | |
| 49 | ||
| 50 | if( strncmp( $data, 'object ', 7 ) === 0 ) { | |
| 51 | if( preg_match( '/^object ([0-9a-f]{40})$/m', $data, $m ) ) { | |
| 52 | $targetSha = $m[1]; | |
| 53 | } | |
| 54 | if( preg_match( '/^tagger (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m ) ) { | |
| 55 | $author = trim( $m[1] ); | |
| 56 | $timestamp = (int)$m[2]; | |
| 57 | } | |
| 58 | ||
| 59 | $pos = strpos( $data, "\n\n" ); | |
| 60 | if( $pos !== false ) { | |
| 61 | $message = trim( substr( $data, $pos + 2 ) ); | |
| 62 | } | |
| 63 | } else { | |
| 64 | if( preg_match( '/^author (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m ) ) { | |
| 65 | $author = trim( $m[1] ); | |
| 66 | $timestamp = (int)$m[2]; | |
| 67 | } | |
| 68 | ||
| 69 | $pos = strpos( $data, "\n\n" ); | |
| 70 | if( $pos !== false ) { | |
| 71 | $message = trim( substr( $data, $pos + 2 ) ); | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | $callback( new Tag( | |
| 76 | $name, | |
| 77 | $sha, | |
| 78 | $targetSha, | |
| 79 | $timestamp, | |
| 80 | $message, | |
| 81 | $author | |
| 82 | ) ); | |
| 83 | } ); | |
| 84 | } | |
| 85 | ||
| 86 | public function getObjectSize( string $sha ): int { | |
| 87 | $size = $this->packs->getSize( $sha ); | |
| 88 | ||
| 89 | if( $size !== null ) { | |
| 90 | return $size; | |
| 91 | } | |
| 92 | ||
| 93 | return $this->getLooseObjectSize( $sha ); | |
| 94 | } | |
| 95 | ||
| 96 | public function peek( string $sha, int $length = 255 ): string { | |
| 97 | $size = $this->packs->getSize( $sha ); | |
| 98 | ||
| 99 | if( $size === null ) { | |
| 100 | return $this->peekLooseObject( $sha, $length ); | |
| 101 | } | |
| 102 | ||
| 103 | return $this->packs->peek( $sha, $length ) ?? ''; | |
| 104 | } | |
| 105 | ||
| 106 | public function read( string $sha ): string { | |
| 107 | $size = $this->getObjectSize( $sha ); | |
| 108 | ||
| 109 | if( $size > self::MAX_READ_SIZE ) { | |
| 110 | return ''; | |
| 111 | } | |
| 112 | ||
| 113 | $content = ''; | |
| 114 | ||
| 115 | $this->slurp( $sha, function( $chunk ) use ( &$content ) { | |
| 116 | $content .= $chunk; | |
| 117 | } ); | |
| 118 | ||
| 119 | return $content; | |
| 120 | } | |
| 121 | ||
| 122 | public function readFile( string $hash, string $name ) { | |
| 123 | return new File( | |
| 124 | $name, | |
| 125 | $hash, | |
| 126 | '100644', | |
| 127 | 0, | |
| 128 | $this->getObjectSize( $hash ), | |
| 129 | $this->peek( $hash ) | |
| 130 | ); | |
| 131 | } | |
| 132 | ||
| 133 | public function stream( string $sha, callable $callback ): void { | |
| 134 | $this->slurp( $sha, $callback ); | |
| 135 | } | |
| 136 | ||
| 137 | private function slurp( string $sha, callable $callback ): void { | |
| 138 | $loosePath = $this->getLoosePath( $sha ); | |
| 139 | ||
| 140 | if( is_file( $loosePath ) ) { | |
| 141 | $fileHandle = @fopen( $loosePath, 'rb' ); | |
| 142 | ||
| 143 | if( !$fileHandle ) return; | |
| 144 | ||
| 145 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 146 | $buffer = ''; | |
| 147 | $headerFound = false; | |
| 148 | ||
| 149 | while( !feof( $fileHandle ) ) { | |
| 150 | $chunk = fread( $fileHandle, 16384 ); | |
| 151 | $inflatedChunk = @inflate_add( $inflator, $chunk ); | |
| 152 | ||
| 153 | if( $inflatedChunk === false ) break; | |
| 154 | ||
| 155 | if( !$headerFound ) { | |
| 156 | $buffer .= $inflatedChunk; | |
| 157 | $nullPos = strpos( $buffer, "\0" ); | |
| 158 | ||
| 159 | if( $nullPos !== false ) { | |
| 160 | $body = substr( $buffer, $nullPos + 1 ); | |
| 161 | ||
| 162 | if( $body !== '' ) { | |
| 163 | $callback( $body ); | |
| 164 | } | |
| 165 | ||
| 166 | $headerFound = true; | |
| 167 | $buffer = ''; | |
| 168 | } | |
| 169 | } else { | |
| 170 | $callback( $inflatedChunk ); | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | fclose( $fileHandle ); | |
| 175 | return; | |
| 176 | } | |
| 177 | ||
| 178 | if( method_exists( $this->packs, 'stream' ) ) { | |
| 179 | $streamed = $this->packs->stream( $sha, $callback ); | |
| 180 | ||
| 181 | if( $streamed ) { | |
| 182 | return; | |
| 183 | } | |
| 184 | } | |
| 185 | ||
| 186 | $data = $this->packs->read( $sha ); | |
| 187 | ||
| 188 | if( $data !== null && $data !== '' ) { | |
| 189 | $callback( $data ); | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | private function peekLooseObject( string $sha, int $length ): string { | |
| 194 | $path = $this->getLoosePath( $sha ); | |
| 195 | ||
| 196 | if( !is_file( $path ) ) { | |
| 197 | return ''; | |
| 198 | } | |
| 199 | ||
| 200 | $fileHandle = @fopen( $path, 'rb' ); | |
| 201 | ||
| 202 | if( !$fileHandle ) { | |
| 203 | return ''; | |
| 204 | } | |
| 205 | ||
| 206 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 207 | $headerFound = false; | |
| 208 | $buffer = ''; | |
| 209 | ||
| 210 | while( !feof( $fileHandle ) && strlen( $buffer ) < $length ) { | |
| 211 | $chunk = fread( $fileHandle, 128 ); | |
| 212 | $inflated = @inflate_add( $inflator, $chunk ); | |
| 213 | ||
| 214 | if( !$headerFound ) { | |
| 215 | $raw = $inflated; | |
| 216 | $nullPos = strpos( $raw, "\0" ); | |
| 217 | ||
| 218 | if( $nullPos !== false ) { | |
| 219 | $headerFound = true; | |
| 220 | $buffer .= substr( $raw, $nullPos + 1 ); | |
| 221 | } | |
| 222 | } else { | |
| 223 | $buffer .= $inflated; | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | fclose( $fileHandle ); | |
| 228 | ||
| 229 | return substr( $buffer, 0, $length ); | |
| 230 | } | |
| 231 | ||
| 232 | public function history( string $ref, int $limit, callable $callback ): void { | |
| 233 | $currentSha = $this->resolve( $ref ); | |
| 234 | $count = 0; | |
| 235 | ||
| 236 | while( $currentSha !== '' && $count < $limit ) { | |
| 237 | $data = $this->read( $currentSha ); | |
| 238 | ||
| 239 | if( $data === '' ) { | |
| 240 | break; | |
| 241 | } | |
| 242 | ||
| 243 | $position = strpos( $data, "\n\n" ); | |
| 244 | $message = $position !== false ? substr( $data, $position + 2 ) : ''; | |
| 245 | preg_match( '/^author (.*) <(.*)> (\d+)/m', $data, $matches ); | |
| 246 | ||
| 247 | $callback( (object)[ | |
| 248 | 'sha' => $currentSha, | |
| 249 | 'message' => trim( $message ), | |
| 250 | 'author' => $matches[1] ?? 'Unknown', | |
| 251 | 'email' => $matches[2] ?? '', | |
| 252 | 'date' => (int)( $matches[3] ?? 0 ) | |
| 253 | ] ); | |
| 254 | ||
| 255 | $currentSha = preg_match( | |
| 256 | '/^parent ([0-9a-f]{40})$/m', | |
| 257 | $data, | |
| 258 | $parentMatches | |
| 259 | ) ? $parentMatches[1] : ''; | |
| 260 | ||
| 261 | $count++; | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 265 | public function walk( string $refOrSha, callable $callback ): void { | |
| 266 | $sha = $this->resolve( $refOrSha ); | |
| 267 | $data = $sha !== '' ? $this->read( $sha ) : ''; | |
| 268 | ||
| 269 | if( preg_match( '/^tree ([0-9a-f]{40})$/m', $data, $matches ) ) { | |
| 270 | $data = $this->read( $matches[1] ); | |
| 271 | } | |
| 272 | ||
| 273 | if( $this->isTreeData( $data ) ) { | |
| 274 | $this->processTree( $data, $callback ); | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | private function processTree( string $data, callable $callback ): void { | |
| 279 | $position = 0; | |
| 280 | $length = strlen( $data ); | |
| 281 | ||
| 282 | while( $position < $length ) { | |
| 283 | $spacePos = strpos( $data, ' ', $position ); | |
| 284 | $nullPos = strpos( $data, "\0", $spacePos ); | |
| 285 | ||
| 286 | if( $spacePos === false || $nullPos === false ) { | |
| 287 | break; | |
| 288 | } | |
| 289 | ||
| 290 | $mode = substr( $data, $position, $spacePos - $position ); | |
| 291 | $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 ); | |
| 292 | $sha = bin2hex( substr( $data, $nullPos + 1, 20 ) ); | |
| 293 | ||
| 294 | $isDirectory = $mode === '40000' || $mode === '040000'; | |
| 295 | $size = $isDirectory ? 0 : $this->getObjectSize( $sha ); | |
| 296 | $contents = $isDirectory ? '' : $this->peek( $sha ); | |
| 297 | ||
| 298 | $callback( new File( $name, $sha, $mode, 0, $size, $contents ) ); | |
| 299 | ||
| 300 | $position = $nullPos + 21; | |
| 301 | } | |
| 302 | } | |
| 303 | ||
| 304 | private function isTreeData( string $data ): bool { | |
| 305 | $pattern = '/^(40000|100644|100755|120000|160000) /'; | |
| 306 | ||
| 307 | if( strlen( $data ) >= 25 && preg_match( $pattern, $data ) ) { | |
| 308 | $nullPos = strpos( $data, "\0" ); | |
| 309 | ||
| 310 | return $nullPos !== false && ( $nullPos + 21 <= strlen( $data ) ); | |
| 311 | } | |
| 312 | ||
| 313 | return false; | |
| 314 | } | |
| 315 | ||
| 316 | private function getLoosePath( string $sha ): string { | |
| 317 | return "{$this->objectsPath}/" . substr( $sha, 0, 2 ) . "/" . | |
| 318 | substr( $sha, 2 ); | |
| 319 | } | |
| 320 | ||
| 321 | private function getLooseObjectSize( string $sha ): int { | |
| 322 | $path = $this->getLoosePath( $sha ); | |
| 323 | ||
| 324 | if( !is_file( $path ) ) { | |
| 325 | return 0; | |
| 326 | } | |
| 327 | ||
| 328 | $fileHandle = @fopen( $path, 'rb' ); | |
| 329 | ||
| 330 | if( !$fileHandle ) { | |
| 331 | return 0; | |
| 332 | } | |
| 333 | ||
| 334 | $data = ''; | |
| 335 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 336 | ||
| 337 | while( !feof( $fileHandle ) ) { | |
| 338 | $chunk = fread( $fileHandle, self::CHUNK_SIZE ); | |
| 339 | $output = @inflate_add( $inflator, $chunk, ZLIB_NO_FLUSH ); | |
| 340 | ||
| 341 | if( $output === false ) { | |
| 342 | break; | |
| 343 | } | |
| 344 | ||
| 345 | $data .= $output; | |
| 346 | ||
| 347 | if( strpos( $data, "\0" ) !== false ) { | |
| 348 | break; | |
| 349 | } | |
| 350 | } | |
| 351 | ||
| 352 | fclose( $fileHandle ); | |
| 353 | ||
| 354 | $header = explode( "\0", $data, 2 )[0]; | |
| 355 | $parts = explode( ' ', $header ); | |
| 356 | ||
| 357 | return isset( $parts[1] ) ? (int)$parts[1] : 0; | |
| 358 | } | |
| 359 | ||
| 360 | public function streamRaw( string $subPath ): bool { | |
| 361 | if( strpos( $subPath, '..' ) !== false ) { | |
| 362 | return false; | |
| 363 | } | |
| 364 | ||
| 365 | $fullPath = "{$this->repoPath}/$subPath"; | |
| 366 | ||
| 367 | if( !file_exists( $fullPath ) ) { | |
| 368 | return false; | |
| 369 | } | |
| 370 | ||
| 371 | $realPath = realpath( $fullPath ); | |
| 372 | $repoReal = realpath( $this->repoPath ); | |
| 373 | ||
| 374 | if( !$realPath || strpos( $realPath, $repoReal ) !== 0 ) { | |
| 375 | return false; | |
| 376 | } | |
| 377 | ||
| 378 | readfile( $fullPath ); | |
| 379 | return true; | |
| 380 | } | |
| 381 | ||
| 382 | public function eachRef( callable $callback ): void { | |
| 383 | $head = $this->resolve( 'HEAD' ); | |
| 384 | ||
| 385 | if( $head !== '' ) { | |
| 386 | $callback( 'HEAD', $head ); | |
| 387 | } | |
| 388 | ||
| 389 | $this->refs->scanRefs( 'refs/heads', function( $name, $sha ) use ( $callback ) { | |
| 390 | $callback( "refs/heads/$name", $sha ); | |
| 391 | } ); | |
| 392 | ||
| 393 | $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( $callback ) { | |
| 394 | $callback( "refs/tags/$name", $sha ); | |
| 395 | } ); | |
| 8 | private const MAX_READ_SIZE = 1048576; | |
| 9 | ||
| 10 | private string $repoPath; | |
| 11 | private string $objectsPath; | |
| 12 | ||
| 13 | private GitRefs $refs; | |
| 14 | private GitPacks $packs; | |
| 15 | ||
| 16 | public function __construct( string $repoPath ) { | |
| 17 | $this->setRepository( $repoPath ); | |
| 18 | } | |
| 19 | ||
| 20 | public function setRepository( string $repoPath ): void { | |
| 21 | $this->repoPath = rtrim( $repoPath, '/' ); | |
| 22 | $this->objectsPath = $this->repoPath . '/objects'; | |
| 23 | ||
| 24 | $this->refs = new GitRefs( $this->repoPath ); | |
| 25 | $this->packs = new GitPacks( $this->objectsPath ); | |
| 26 | } | |
| 27 | ||
| 28 | public function resolve( string $reference ): string { | |
| 29 | return $this->refs->resolve( $reference ); | |
| 30 | } | |
| 31 | ||
| 32 | public function getMainBranch(): array { | |
| 33 | return $this->refs->getMainBranch(); | |
| 34 | } | |
| 35 | ||
| 36 | public function eachBranch( callable $callback ): void { | |
| 37 | $this->refs->scanRefs( 'refs/heads', $callback ); | |
| 38 | } | |
| 39 | ||
| 40 | public function eachTag( callable $callback ): void { | |
| 41 | $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( | |
| 42 | $callback | |
| 43 | ) { | |
| 44 | $data = $this->read( $sha ); | |
| 45 | $tag = $this->parseTagData( $name, $sha, $data ); | |
| 46 | ||
| 47 | $callback( $tag ); | |
| 48 | } ); | |
| 49 | } | |
| 50 | ||
| 51 | private function parseTagData( | |
| 52 | string $name, | |
| 53 | string $sha, | |
| 54 | string $data | |
| 55 | ): Tag { | |
| 56 | $isAnnotated = strncmp( $data, 'object ', 7 ) === 0; | |
| 57 | ||
| 58 | $targetSha = $isAnnotated | |
| 59 | ? $this->extractPattern( | |
| 60 | $data, | |
| 61 | '/^object ([0-9a-f]{40})$/m', | |
| 62 | 1, | |
| 63 | $sha | |
| 64 | ) | |
| 65 | : $sha; | |
| 66 | ||
| 67 | $pattern = $isAnnotated | |
| 68 | ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m' | |
| 69 | : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m'; | |
| 70 | ||
| 71 | $identity = $this->parseIdentity( $data, $pattern ); | |
| 72 | $message = $this->extractMessage( $data ); | |
| 73 | ||
| 74 | return new Tag( | |
| 75 | $name, | |
| 76 | $sha, | |
| 77 | $targetSha, | |
| 78 | $identity['timestamp'], | |
| 79 | $message, | |
| 80 | $identity['name'] | |
| 81 | ); | |
| 82 | } | |
| 83 | ||
| 84 | private function extractPattern( | |
| 85 | string $data, | |
| 86 | string $pattern, | |
| 87 | int $group, | |
| 88 | string $default = '' | |
| 89 | ): string { | |
| 90 | $matches = []; | |
| 91 | ||
| 92 | $result = preg_match( $pattern, $data, $matches ) | |
| 93 | ? $matches[$group] | |
| 94 | : $default; | |
| 95 | ||
| 96 | return $result; | |
| 97 | } | |
| 98 | ||
| 99 | private function parseIdentity( string $data, string $pattern ): array { | |
| 100 | $matches = []; | |
| 101 | $found = preg_match( $pattern, $data, $matches ); | |
| 102 | ||
| 103 | return [ | |
| 104 | 'name' => $found ? trim( $matches[1] ) : 'Unknown', | |
| 105 | 'email' => $found ? $matches[2] : '', | |
| 106 | 'timestamp' => $found ? (int)$matches[3] : 0 | |
| 107 | ]; | |
| 108 | } | |
| 109 | ||
| 110 | private function extractMessage( string $data ): string { | |
| 111 | $pos = strpos( $data, "\n\n" ); | |
| 112 | ||
| 113 | return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : ''; | |
| 114 | } | |
| 115 | ||
| 116 | public function getObjectSize( string $sha ): int { | |
| 117 | $size = $this->packs->getSize( $sha ); | |
| 118 | ||
| 119 | return $size !== null ? $size : $this->getLooseObjectSize( $sha ); | |
| 120 | } | |
| 121 | ||
| 122 | public function peek( string $sha, int $length = 255 ): string { | |
| 123 | $size = $this->packs->getSize( $sha ); | |
| 124 | ||
| 125 | return $size === null | |
| 126 | ? $this->peekLooseObject( $sha, $length ) | |
| 127 | : $this->packs->peek( $sha, $length ) ?? ''; | |
| 128 | } | |
| 129 | ||
| 130 | public function read( string $sha ): string { | |
| 131 | $size = $this->getObjectSize( $sha ); | |
| 132 | ||
| 133 | if( $size > self::MAX_READ_SIZE ) { | |
| 134 | return ''; | |
| 135 | } | |
| 136 | ||
| 137 | $content = ''; | |
| 138 | ||
| 139 | $this->slurp( $sha, function( $chunk ) use ( &$content ) { | |
| 140 | $content .= $chunk; | |
| 141 | } ); | |
| 142 | ||
| 143 | return $content; | |
| 144 | } | |
| 145 | ||
| 146 | public function readFile( string $hash, string $name ) { | |
| 147 | return new File( | |
| 148 | $name, | |
| 149 | $hash, | |
| 150 | '100644', | |
| 151 | 0, | |
| 152 | $this->getObjectSize( $hash ), | |
| 153 | $this->peek( $hash ) | |
| 154 | ); | |
| 155 | } | |
| 156 | ||
| 157 | public function stream( string $sha, callable $callback ): void { | |
| 158 | $this->slurp( $sha, $callback ); | |
| 159 | } | |
| 160 | ||
| 161 | private function slurp( string $sha, callable $callback ): void { | |
| 162 | $loosePath = $this->getLoosePath( $sha ); | |
| 163 | ||
| 164 | if( is_file( $loosePath ) ) { | |
| 165 | $this->slurpLooseObject( $loosePath, $callback ); | |
| 166 | } else { | |
| 167 | $this->slurpPackedObject( $sha, $callback ); | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | private function iterateInflated( string $path, callable $processor ): void { | |
| 172 | $this->withInflatedFile( | |
| 173 | $path, | |
| 174 | function( $fileHandle, $inflator ) use ( $processor ) { | |
| 175 | $headerFound = false; | |
| 176 | $buffer = ''; | |
| 177 | ||
| 178 | while( !feof( $fileHandle ) ) { | |
| 179 | $chunk = fread( $fileHandle, 16384 ); | |
| 180 | $inflated = inflate_add( $inflator, $chunk ); | |
| 181 | ||
| 182 | if( $inflated === false ) { | |
| 183 | break; | |
| 184 | } | |
| 185 | ||
| 186 | if( !$headerFound ) { | |
| 187 | $buffer .= $inflated; | |
| 188 | $nullPos = strpos( $buffer, "\0" ); | |
| 189 | ||
| 190 | if( $nullPos !== false ) { | |
| 191 | $headerFound = true; | |
| 192 | $header = substr( $buffer, 0, $nullPos ); | |
| 193 | $body = substr( $buffer, $nullPos + 1 ); | |
| 194 | ||
| 195 | if( $processor( $body, $header ) === false ) { | |
| 196 | return; | |
| 197 | } | |
| 198 | } | |
| 199 | } else { | |
| 200 | if( $processor( $inflated, null ) === false ) { | |
| 201 | return; | |
| 202 | } | |
| 203 | } | |
| 204 | } | |
| 205 | } | |
| 206 | ); | |
| 207 | } | |
| 208 | ||
| 209 | private function slurpLooseObject( | |
| 210 | string $path, | |
| 211 | callable $callback | |
| 212 | ): void { | |
| 213 | $this->iterateInflated( | |
| 214 | $path, | |
| 215 | function( $chunk ) use ( $callback ) { | |
| 216 | if( $chunk !== '' ) { | |
| 217 | $callback( $chunk ); | |
| 218 | } | |
| 219 | return true; | |
| 220 | } | |
| 221 | ); | |
| 222 | } | |
| 223 | ||
| 224 | private function withInflatedFile( string $path, callable $callback ): void { | |
| 225 | $fileHandle = fopen( $path, 'rb' ); | |
| 226 | $inflator = $fileHandle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null; | |
| 227 | ||
| 228 | if( $fileHandle && $inflator ) { | |
| 229 | $callback( $fileHandle, $inflator ); | |
| 230 | fclose( $fileHandle ); | |
| 231 | } | |
| 232 | } | |
| 233 | ||
| 234 | private function slurpPackedObject( | |
| 235 | string $sha, | |
| 236 | callable $callback | |
| 237 | ): void { | |
| 238 | $streamed = $this->packs->stream( $sha, $callback ); | |
| 239 | ||
| 240 | if( !$streamed ) { | |
| 241 | $data = $this->packs->read( $sha ); | |
| 242 | ||
| 243 | if( $data !== null && $data !== '' ) { | |
| 244 | $callback( $data ); | |
| 245 | } | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | private function peekLooseObject( string $sha, int $length ): string { | |
| 250 | $path = $this->getLoosePath( $sha ); | |
| 251 | ||
| 252 | return is_file( $path ) | |
| 253 | ? $this->inflateLooseObjectPrefix( $path, $length ) | |
| 254 | : ''; | |
| 255 | } | |
| 256 | ||
| 257 | private function inflateLooseObjectPrefix( | |
| 258 | string $path, | |
| 259 | int $length | |
| 260 | ): string { | |
| 261 | $buffer = ''; | |
| 262 | ||
| 263 | $this->iterateInflated( | |
| 264 | $path, | |
| 265 | function( $chunk ) use ( $length, &$buffer ) { | |
| 266 | $buffer .= $chunk; | |
| 267 | return strlen( $buffer ) < $length; | |
| 268 | } | |
| 269 | ); | |
| 270 | ||
| 271 | return substr( $buffer, 0, $length ); | |
| 272 | } | |
| 273 | ||
| 274 | public function history( string $ref, int $limit, callable $callback ): void { | |
| 275 | $currentSha = $this->resolve( $ref ); | |
| 276 | $count = 0; | |
| 277 | ||
| 278 | while( $currentSha !== '' && $count < $limit ) { | |
| 279 | $commit = $this->parseCommit( $currentSha ); | |
| 280 | ||
| 281 | if( $commit === null ) { | |
| 282 | break; | |
| 283 | } | |
| 284 | ||
| 285 | $callback( $commit ); | |
| 286 | $currentSha = $commit->parentSha; | |
| 287 | $count++; | |
| 288 | } | |
| 289 | } | |
| 290 | ||
| 291 | private function parseCommit( string $sha ): ?object { | |
| 292 | $data = $this->read( $sha ); | |
| 293 | ||
| 294 | return $data === '' ? null : $this->buildCommitObject( $sha, $data ); | |
| 295 | } | |
| 296 | ||
| 297 | private function buildCommitObject( string $sha, string $data ): object { | |
| 298 | $identity = $this->parseIdentity( $data, '/^author (.*) <(.*)> (\d+)/m' ); | |
| 299 | $message = $this->extractMessage( $data ); | |
| 300 | $parentSha = $this->extractPattern( | |
| 301 | $data, | |
| 302 | '/^parent ([0-9a-f]{40})$/m', | |
| 303 | 1 | |
| 304 | ); | |
| 305 | ||
| 306 | return (object)[ | |
| 307 | 'sha' => $sha, | |
| 308 | 'message' => $message, | |
| 309 | 'author' => $identity['name'], | |
| 310 | 'email' => $identity['email'], | |
| 311 | 'date' => $identity['timestamp'], | |
| 312 | 'parentSha' => $parentSha | |
| 313 | ]; | |
| 314 | } | |
| 315 | ||
| 316 | public function walk( string $refOrSha, callable $callback ): void { | |
| 317 | $sha = $this->resolve( $refOrSha ); | |
| 318 | ||
| 319 | if( $sha !== '' ) { | |
| 320 | $this->walkTree( $sha, $callback ); | |
| 321 | } | |
| 322 | } | |
| 323 | ||
| 324 | private function walkTree( string $sha, callable $callback ): void { | |
| 325 | $data = $this->read( $sha ); | |
| 326 | $treeData = $data !== '' && preg_match( | |
| 327 | '/^tree ([0-9a-f]{40})$/m', | |
| 328 | $data, | |
| 329 | $matches | |
| 330 | ) ? $this->read( $matches[1] ) : $data; | |
| 331 | ||
| 332 | if( $treeData !== '' && $this->isTreeData( $treeData ) ) { | |
| 333 | $this->processTree( $treeData, $callback ); | |
| 334 | } | |
| 335 | } | |
| 336 | ||
| 337 | private function processTree( string $data, callable $callback ): void { | |
| 338 | $position = 0; | |
| 339 | $length = strlen( $data ); | |
| 340 | ||
| 341 | while( $position < $length ) { | |
| 342 | $result = $this->parseTreeEntry( $data, $position, $length ); | |
| 343 | ||
| 344 | if( $result === null ) { | |
| 345 | break; | |
| 346 | } | |
| 347 | ||
| 348 | $callback( $result['file'] ); | |
| 349 | $position = $result['nextPosition']; | |
| 350 | } | |
| 351 | } | |
| 352 | ||
| 353 | private function parseTreeEntry( | |
| 354 | string $data, | |
| 355 | int $position, | |
| 356 | int $length | |
| 357 | ): ?array { | |
| 358 | $spacePos = strpos( $data, ' ', $position ); | |
| 359 | $nullPos = strpos( $data, "\0", $spacePos ); | |
| 360 | ||
| 361 | $hasValidPositions = | |
| 362 | $spacePos !== false && | |
| 363 | $nullPos !== false && | |
| 364 | $nullPos + 21 <= $length; | |
| 365 | ||
| 366 | return $hasValidPositions | |
| 367 | ? $this->buildTreeEntryResult( $data, $position, $spacePos, $nullPos ) | |
| 368 | : null; | |
| 369 | } | |
| 370 | ||
| 371 | private function buildTreeEntryResult( | |
| 372 | string $data, | |
| 373 | int $position, | |
| 374 | int $spacePos, | |
| 375 | int $nullPos | |
| 376 | ): array { | |
| 377 | $mode = substr( $data, $position, $spacePos - $position ); | |
| 378 | $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 ); | |
| 379 | $sha = bin2hex( substr( $data, $nullPos + 1, 20 ) ); | |
| 380 | ||
| 381 | $isDirectory = $mode === '40000' || $mode === '040000'; | |
| 382 | $size = $isDirectory ? 0 : $this->getObjectSize( $sha ); | |
| 383 | $contents = $isDirectory ? '' : $this->peek( $sha ); | |
| 384 | ||
| 385 | $file = new File( $name, $sha, $mode, 0, $size, $contents ); | |
| 386 | ||
| 387 | return [ | |
| 388 | 'file' => $file, | |
| 389 | 'nextPosition' => $nullPos + 21 | |
| 390 | ]; | |
| 391 | } | |
| 392 | ||
| 393 | private function isTreeData( string $data ): bool { | |
| 394 | $pattern = '/^(40000|100644|100755|120000|160000) /'; | |
| 395 | $minLength = strlen( $data ) >= 25; | |
| 396 | $matchesPattern = $minLength && preg_match( $pattern, $data ); | |
| 397 | $nullPos = $matchesPattern ? strpos( $data, "\0" ) : false; | |
| 398 | ||
| 399 | return $matchesPattern && | |
| 400 | $nullPos !== false && | |
| 401 | $nullPos + 21 <= strlen( $data ); | |
| 402 | } | |
| 403 | ||
| 404 | private function getLoosePath( string $sha ): string { | |
| 405 | return "{$this->objectsPath}/" . | |
| 406 | substr( $sha, 0, 2 ) . "/" . | |
| 407 | substr( $sha, 2 ); | |
| 408 | } | |
| 409 | ||
| 410 | private function getLooseObjectSize( string $sha ): int { | |
| 411 | $path = $this->getLoosePath( $sha ); | |
| 412 | ||
| 413 | return is_file( $path ) ? $this->readLooseObjectHeader( $path ) : 0; | |
| 414 | } | |
| 415 | ||
| 416 | private function readLooseObjectHeader( string $path ): int { | |
| 417 | $size = 0; | |
| 418 | ||
| 419 | $this->iterateInflated( | |
| 420 | $path, | |
| 421 | function( $chunk, $header ) use ( &$size ) { | |
| 422 | if( $header !== null ) { | |
| 423 | $parts = explode( ' ', $header ); | |
| 424 | $size = isset( $parts[1] ) ? (int)$parts[1] : 0; | |
| 425 | } | |
| 426 | return false; | |
| 427 | } | |
| 428 | ); | |
| 429 | ||
| 430 | return $size; | |
| 431 | } | |
| 432 | ||
| 433 | public function streamRaw( string $subPath ): bool { | |
| 434 | return strpos( $subPath, '..' ) === false | |
| 435 | ? $this->streamRawFile( $subPath ) | |
| 436 | : false; | |
| 437 | } | |
| 438 | ||
| 439 | private function streamRawFile( string $subPath ): bool { | |
| 440 | $fullPath = "{$this->repoPath}/$subPath"; | |
| 441 | ||
| 442 | return file_exists( $fullPath ) | |
| 443 | ? $this->streamIfPathValid( $fullPath ) | |
| 444 | : false; | |
| 445 | } | |
| 446 | ||
| 447 | private function streamIfPathValid( string $fullPath ): bool { | |
| 448 | $realPath = realpath( $fullPath ); | |
| 449 | $repoReal = realpath( $this->repoPath ); | |
| 450 | $isValid = $realPath && strpos( $realPath, $repoReal ) === 0; | |
| 451 | ||
| 452 | return $isValid ? readfile( $fullPath ) !== false : false; | |
| 453 | } | |
| 454 | ||
| 455 | public function eachRef( callable $callback ): void { | |
| 456 | $head = $this->resolve( 'HEAD' ); | |
| 457 | ||
| 458 | if( $head !== '' ) { | |
| 459 | $callback( 'HEAD', $head ); | |
| 460 | } | |
| 461 | ||
| 462 | $this->refs->scanRefs( | |
| 463 | 'refs/heads', | |
| 464 | function( $name, $sha ) use ( $callback ) { | |
| 465 | $callback( "refs/heads/$name", $sha ); | |
| 466 | } | |
| 467 | ); | |
| 468 | ||
| 469 | $this->refs->scanRefs( | |
| 470 | 'refs/tags', | |
| 471 | function( $name, $sha ) use ( $callback ) { | |
| 472 | $callback( "refs/tags/$name", $sha ); | |
| 473 | } | |
| 474 | ); | |
| 396 | 475 | } |
| 397 | 476 | } |
| 6 | 6 | private const MAX_DIFF_SIZE = 1048576; |
| 7 | 7 | |
| 8 | public function __construct(Git $git) { | |
| 8 | public function __construct( Git $git ) { | |
| 9 | 9 | $this->git = $git; |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | public function compare(string $commitHash) { | |
| 13 | $commitData = $this->git->read($commitHash); | |
| 14 | $parentHash = preg_match('/^parent ([0-9a-f]{40})/m', $commitData, $matches) ? $matches[1] : ''; | |
| 12 | public function compare( string $commitHash ) { | |
| 13 | $commitData = $this->git->read( $commitHash ); | |
| 15 | 14 | |
| 16 | $newTree = $this->getTreeHash($commitHash); | |
| 17 | $oldTree = $parentHash ? $this->getTreeHash($parentHash) : null; | |
| 15 | $parentHash = preg_match( | |
| 16 | '/^parent ([0-9a-f]{40})/m', | |
| 17 | $commitData, | |
| 18 | $matches | |
| 19 | ) ? $matches[1] : ''; | |
| 18 | 20 | |
| 19 | return $this->diffTrees($oldTree, $newTree); | |
| 21 | $newTree = $this->getTreeHash( $commitHash ); | |
| 22 | $oldTree = $parentHash ? $this->getTreeHash( $parentHash ) : null; | |
| 23 | ||
| 24 | return $this->diffTrees( $oldTree, $newTree ); | |
| 20 | 25 | } |
| 21 | 26 | |
| 22 | private function getTreeHash($commitSha) { | |
| 23 | $data = $this->git->read($commitSha); | |
| 24 | return preg_match('/^tree ([0-9a-f]{40})/m', $data, $matches) ? $matches[1] : null; | |
| 27 | private function getTreeHash( $commitSha ) { | |
| 28 | $data = $this->git->read( $commitSha ); | |
| 29 | ||
| 30 | return preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) | |
| 31 | ? $matches[1] | |
| 32 | : null; | |
| 25 | 33 | } |
| 26 | 34 | |
| 27 | private function diffTrees($oldTreeSha, $newTreeSha, $path = '') { | |
| 35 | private function diffTrees( $oldTreeSha, $newTreeSha, $path = '' ) { | |
| 28 | 36 | $changes = []; |
| 29 | 37 | |
| 30 | if ($oldTreeSha !== $newTreeSha) { | |
| 31 | $oldEntries = $oldTreeSha ? $this->parseTree($oldTreeSha) : []; | |
| 32 | $newEntries = $newTreeSha ? $this->parseTree($newTreeSha) : []; | |
| 38 | if( $oldTreeSha !== $newTreeSha ) { | |
| 39 | $oldEntries = $oldTreeSha ? $this->parseTree( $oldTreeSha ) : []; | |
| 40 | $newEntries = $newTreeSha ? $this->parseTree( $newTreeSha ) : []; | |
| 33 | 41 | |
| 34 | $allNames = array_unique(array_merge(array_keys($oldEntries), array_keys($newEntries))); | |
| 35 | sort($allNames); | |
| 42 | $allNames = array_unique( | |
| 43 | array_merge( array_keys( $oldEntries ), array_keys( $newEntries ) ) | |
| 44 | ); | |
| 36 | 45 | |
| 37 | foreach ($allNames as $name) { | |
| 46 | sort( $allNames ); | |
| 47 | ||
| 48 | foreach( $allNames as $name ) { | |
| 38 | 49 | $old = $oldEntries[$name] ?? null; |
| 39 | 50 | $new = $newEntries[$name] ?? null; |
| 40 | 51 | $currentPath = $path ? "$path/$name" : $name; |
| 41 | 52 | |
| 42 | if (!$old) { | |
| 53 | if( !$old ) { | |
| 43 | 54 | $changes = $new['is_dir'] |
| 44 | ? array_merge($changes, $this->diffTrees(null, $new['sha'], $currentPath)) | |
| 45 | : array_merge($changes, [$this->createChange('A', $currentPath, null, $new['sha'])]); | |
| 46 | } elseif (!$new) { | |
| 55 | ? array_merge( | |
| 56 | $changes, | |
| 57 | $this->diffTrees( null, $new['sha'], $currentPath ) | |
| 58 | ) | |
| 59 | : array_merge( | |
| 60 | $changes, | |
| 61 | [$this->createChange( 'A', $currentPath, null, $new['sha'] )] | |
| 62 | ); | |
| 63 | } elseif( !$new ) { | |
| 47 | 64 | $changes = $old['is_dir'] |
| 48 | ? array_merge($changes, $this->diffTrees($old['sha'], null, $currentPath)) | |
| 49 | : array_merge($changes, [$this->createChange('D', $currentPath, $old['sha'], null)]); | |
| 50 | } elseif ($old['sha'] !== $new['sha']) { | |
| 65 | ? array_merge( | |
| 66 | $changes, | |
| 67 | $this->diffTrees( $old['sha'], null, $currentPath ) | |
| 68 | ) | |
| 69 | : array_merge( | |
| 70 | $changes, | |
| 71 | [$this->createChange( 'D', $currentPath, $old['sha'], null )] | |
| 72 | ); | |
| 73 | } elseif( $old['sha'] !== $new['sha'] ) { | |
| 51 | 74 | $changes = ($old['is_dir'] && $new['is_dir']) |
| 52 | ? array_merge($changes, $this->diffTrees($old['sha'], $new['sha'], $currentPath)) | |
| 75 | ? array_merge( | |
| 76 | $changes, | |
| 77 | $this->diffTrees( $old['sha'], $new['sha'], $currentPath ) | |
| 78 | ) | |
| 53 | 79 | : (($old['is_dir'] || $new['is_dir']) |
| 54 | 80 | ? $changes |
| 55 | : array_merge($changes, [$this->createChange('M', $currentPath, $old['sha'], $new['sha'])])); | |
| 81 | : array_merge( | |
| 82 | $changes, | |
| 83 | [$this->createChange( | |
| 84 | 'M', | |
| 85 | $currentPath, | |
| 86 | $old['sha'], | |
| 87 | $new['sha'] | |
| 88 | )] | |
| 89 | )); | |
| 56 | 90 | } |
| 57 | 91 | } |
| 58 | 92 | } |
| 59 | 93 | |
| 60 | 94 | return $changes; |
| 61 | 95 | } |
| 62 | 96 | |
| 63 | private function parseTree($sha) { | |
| 64 | $data = $this->git->read($sha); | |
| 97 | private function parseTree( $sha ) { | |
| 98 | $data = $this->git->read( $sha ); | |
| 65 | 99 | $entries = []; |
| 66 | $len = strlen($data); | |
| 100 | $len = strlen( $data ); | |
| 67 | 101 | $pos = 0; |
| 68 | 102 | |
| 69 | while ($pos < $len) { | |
| 70 | $space = strpos($data, ' ', $pos); | |
| 71 | $null = strpos($data, "\0", $space); | |
| 103 | while( $pos < $len ) { | |
| 104 | $space = strpos( $data, ' ', $pos ); | |
| 105 | $null = strpos( $data, "\0", $space ); | |
| 72 | 106 | |
| 73 | if ($space === false || $null === false) break; | |
| 107 | if( $space === false || $null === false ) { | |
| 108 | break; | |
| 109 | } | |
| 74 | 110 | |
| 75 | $mode = substr($data, $pos, $space - $pos); | |
| 76 | $name = substr($data, $space + 1, $null - $space - 1); | |
| 77 | $hash = bin2hex(substr($data, $null + 1, 20)); | |
| 111 | $mode = substr( $data, $pos, $space - $pos ); | |
| 112 | $name = substr( $data, $space + 1, $null - $space - 1 ); | |
| 113 | $hash = bin2hex( substr( $data, $null + 1, 20 ) ); | |
| 78 | 114 | |
| 79 | 115 | $entries[$name] = [ |
| 80 | 116 | 'mode' => $mode, |
| 81 | 117 | 'sha' => $hash, |
| 82 | 118 | 'is_dir' => $mode === '40000' || $mode === '040000' |
| 83 | 119 | ]; |
| 84 | 120 | |
| 85 | 121 | $pos = $null + 21; |
| 86 | 122 | } |
| 123 | ||
| 87 | 124 | return $entries; |
| 88 | 125 | } |
| 89 | 126 | |
| 90 | private function createChange($type, $path, $oldSha, $newSha) { | |
| 91 | $oldSize = $oldSha ? $this->git->getObjectSize($oldSha) : 0; | |
| 92 | $newSize = $newSha ? $this->git->getObjectSize($newSha) : 0; | |
| 127 | private function createChange( $type, $path, $oldSha, $newSha ) { | |
| 128 | $oldSize = $oldSha ? $this->git->getObjectSize( $oldSha ) : 0; | |
| 129 | $newSize = $newSha ? $this->git->getObjectSize( $newSha ) : 0; | |
| 93 | 130 | $result = []; |
| 94 | 131 | |
| 95 | if ($oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE) { | |
| 132 | if( $oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE ) { | |
| 96 | 133 | $result = [ |
| 97 | 134 | 'type' => $type, |
| 98 | 135 | 'path' => $path, |
| 99 | 136 | 'is_binary' => true, |
| 100 | 137 | 'hunks' => [] |
| 101 | 138 | ]; |
| 102 | 139 | } else { |
| 103 | $oldContent = $oldSha ? $this->git->read($oldSha) : ''; | |
| 104 | $newContent = $newSha ? $this->git->read($newSha) : ''; | |
| 140 | $oldContent = $oldSha ? $this->git->read( $oldSha ) : ''; | |
| 141 | $newContent = $newSha ? $this->git->read( $newSha ) : ''; | |
| 105 | 142 | |
| 106 | $isBinary = ($newSha && (new VirtualDiffFile($path, $newContent))->isBinary()) || | |
| 107 | (!$newSha && $oldSha && (new VirtualDiffFile($path, $oldContent))->isBinary()); | |
| 143 | $isBinary = | |
| 144 | ($newSha && (new VirtualDiffFile( $path, $newContent ))->isBinary()) || | |
| 145 | (!$newSha && $oldSha && | |
| 146 | (new VirtualDiffFile( $path, $oldContent ))->isBinary()); | |
| 108 | 147 | |
| 109 | 148 | $result = [ |
| 110 | 149 | 'type' => $type, |
| 111 | 150 | 'path' => $path, |
| 112 | 151 | 'is_binary' => $isBinary, |
| 113 | 'hunks' => $isBinary ? null : $this->calculateDiff($oldContent, $newContent) | |
| 152 | 'hunks' => $isBinary | |
| 153 | ? null | |
| 154 | : $this->calculateDiff( $oldContent, $newContent ) | |
| 114 | 155 | ]; |
| 115 | 156 | } |
| 116 | 157 | |
| 117 | 158 | return $result; |
| 118 | 159 | } |
| 119 | 160 | |
| 120 | private function calculateDiff($old, $new) { | |
| 121 | $old = str_replace("\r\n", "\n", $old); | |
| 122 | $new = str_replace("\r\n", "\n", $new); | |
| 161 | private function calculateDiff( $old, $new ) { | |
| 162 | $old = str_replace( "\r\n", "\n", $old ); | |
| 163 | $new = str_replace( "\r\n", "\n", $new ); | |
| 123 | 164 | |
| 124 | $oldLines = explode("\n", $old); | |
| 125 | $newLines = explode("\n", $new); | |
| 165 | $oldLines = explode( "\n", $old ); | |
| 166 | $newLines = explode( "\n", $new ); | |
| 126 | 167 | |
| 127 | $m = count($oldLines); | |
| 128 | $n = count($newLines); | |
| 168 | $m = count( $oldLines ); | |
| 169 | $n = count( $newLines ); | |
| 129 | 170 | |
| 130 | 171 | $start = 0; |
| 131 | while ($start < $m && $start < $n && $oldLines[$start] === $newLines[$start]) { | |
| 172 | ||
| 173 | while( | |
| 174 | $start < $m && | |
| 175 | $start < $n && | |
| 176 | $oldLines[$start] === $newLines[$start] | |
| 177 | ) { | |
| 132 | 178 | $start++; |
| 133 | 179 | } |
| 134 | 180 | |
| 135 | 181 | $end = 0; |
| 136 | while ($m - $end > $start && $n - $end > $start && $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end]) { | |
| 182 | ||
| 183 | while( | |
| 184 | $m - $end > $start && | |
| 185 | $n - $end > $start && | |
| 186 | $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end] | |
| 187 | ) { | |
| 137 | 188 | $end++; |
| 138 | 189 | } |
| 139 | 190 | |
| 140 | $oldSlice = array_slice($oldLines, $start, $m - $start - $end); | |
| 141 | $newSlice = array_slice($newLines, $start, $n - $start - $end); | |
| 191 | $oldSlice = array_slice( $oldLines, $start, $m - $start - $end ); | |
| 192 | $newSlice = array_slice( $newLines, $start, $n - $start - $end ); | |
| 142 | 193 | |
| 143 | 194 | $result = null; |
| 144 | 195 | |
| 145 | if ((count($oldSlice) * count($newSlice)) > 500000) { | |
| 196 | if( (count( $oldSlice ) * count( $newSlice )) > 500000 ) { | |
| 146 | 197 | $result = [['t' => 'gap']]; |
| 147 | 198 | } else { |
| 148 | $ops = $this->computeLCS($oldSlice, $newSlice); | |
| 199 | $ops = $this->computeLCS( $oldSlice, $newSlice ); | |
| 149 | 200 | |
| 150 | 201 | $groupedOps = []; |
| 151 | 202 | $bufferDel = []; |
| 152 | 203 | $bufferAdd = []; |
| 153 | 204 | |
| 154 | foreach ($ops as $op) { | |
| 155 | if ($op['t'] === ' ') { | |
| 156 | foreach ($bufferDel as $o) $groupedOps[] = $o; | |
| 157 | foreach ($bufferAdd as $o) $groupedOps[] = $o; | |
| 205 | foreach( $ops as $op ) { | |
| 206 | if( $op['t'] === ' ' ) { | |
| 207 | foreach( $bufferDel as $o ) { $groupedOps[] = $o; } | |
| 208 | foreach( $bufferAdd as $o ) { $groupedOps[] = $o; } | |
| 209 | ||
| 158 | 210 | $bufferDel = []; |
| 159 | 211 | $bufferAdd = []; |
| 160 | 212 | $groupedOps[] = $op; |
| 161 | } elseif ($op['t'] === '-') { | |
| 213 | } elseif( $op['t'] === '-' ) { | |
| 162 | 214 | $bufferDel[] = $op; |
| 163 | } elseif ($op['t'] === '+') { | |
| 215 | } elseif( $op['t'] === '+' ) { | |
| 164 | 216 | $bufferAdd[] = $op; |
| 165 | 217 | } |
| 166 | 218 | } |
| 167 | foreach ($bufferDel as $o) $groupedOps[] = $o; | |
| 168 | foreach ($bufferAdd as $o) $groupedOps[] = $o; | |
| 169 | $ops = $groupedOps; | |
| 219 | ||
| 220 | foreach( $bufferDel as $o ) { $groupedOps[] = $o; } | |
| 221 | foreach( $bufferAdd as $o ) { $groupedOps[] = $o; } | |
| 170 | 222 | |
| 223 | $ops = $groupedOps; | |
| 171 | 224 | $stream = []; |
| 172 | 225 | |
| 173 | for ($i = 0; $i < $start; $i++) { | |
| 174 | $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $i + 1, 'nn' => $i + 1]; | |
| 226 | for( $i = 0; $i < $start; $i++ ) { | |
| 227 | $stream[] = [ | |
| 228 | 't' => ' ', | |
| 229 | 'l' => $oldLines[$i], | |
| 230 | 'no' => $i + 1, | |
| 231 | 'nn' => $i + 1 | |
| 232 | ]; | |
| 175 | 233 | } |
| 176 | 234 | |
| 177 | 235 | $currO = $start + 1; |
| 178 | 236 | $currN = $start + 1; |
| 179 | 237 | |
| 180 | foreach ($ops as $op) { | |
| 181 | if ($op['t'] === ' ') { | |
| 182 | $stream[] = ['t' => ' ', 'l' => $op['l'], 'no' => $currO++, 'nn' => $currN++]; | |
| 183 | } elseif ($op['t'] === '-') { | |
| 184 | $stream[] = ['t' => '-', 'l' => $op['l'], 'no' => $currO++, 'nn' => null]; | |
| 185 | } elseif ($op['t'] === '+') { | |
| 186 | $stream[] = ['t' => '+', 'l' => $op['l'], 'no' => null, 'nn' => $currN++]; | |
| 238 | foreach( $ops as $op ) { | |
| 239 | if( $op['t'] === ' ' ) { | |
| 240 | $stream[] = [ | |
| 241 | 't' => ' ', | |
| 242 | 'l' => $op['l'], | |
| 243 | 'no' => $currO++, | |
| 244 | 'nn' => $currN++ | |
| 245 | ]; | |
| 246 | } elseif( $op['t'] === '-' ) { | |
| 247 | $stream[] = [ | |
| 248 | 't' => '-', | |
| 249 | 'l' => $op['l'], | |
| 250 | 'no' => $currO++, | |
| 251 | 'nn' => null | |
| 252 | ]; | |
| 253 | } elseif( $op['t'] === '+' ) { | |
| 254 | $stream[] = [ | |
| 255 | 't' => '+', | |
| 256 | 'l' => $op['l'], | |
| 257 | 'no' => null, | |
| 258 | 'nn' => $currN++ | |
| 259 | ]; | |
| 187 | 260 | } |
| 188 | 261 | } |
| 189 | 262 | |
| 190 | for ($i = $m - $end; $i < $m; $i++) { | |
| 191 | $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $currO++, 'nn' => $currN++]; | |
| 263 | for( $i = $m - $end; $i < $m; $i++ ) { | |
| 264 | $stream[] = [ | |
| 265 | 't' => ' ', | |
| 266 | 'l' => $oldLines[$i], | |
| 267 | 'no' => $currO++, | |
| 268 | 'nn' => $currN++ | |
| 269 | ]; | |
| 192 | 270 | } |
| 193 | 271 | |
| 194 | 272 | $finalLines = []; |
| 195 | 273 | $lastVisibleIndex = -1; |
| 196 | $streamLen = count($stream); | |
| 274 | $streamLen = count( $stream ); | |
| 197 | 275 | $contextLines = 3; |
| 198 | 276 | |
| 199 | for ($i = 0; $i < $streamLen; $i++) { | |
| 277 | for( $i = 0; $i < $streamLen; $i++ ) { | |
| 200 | 278 | $show = false; |
| 201 | 279 | |
| 202 | if ($stream[$i]['t'] !== ' ') { | |
| 280 | if( $stream[$i]['t'] !== ' ' ) { | |
| 203 | 281 | $show = true; |
| 204 | 282 | } else { |
| 205 | for ($j = 1; $j <= $contextLines; $j++) { | |
| 206 | if (($i + $j) < $streamLen && $stream[$i + $j]['t'] !== ' ') { | |
| 283 | for( $j = 1; $j <= $contextLines; $j++ ) { | |
| 284 | if( ($i + $j) < $streamLen && $stream[$i + $j]['t'] !== ' ' ) { | |
| 207 | 285 | $show = true; |
| 208 | 286 | break; |
| 209 | 287 | } |
| 210 | 288 | } |
| 211 | if (!$show) { | |
| 212 | for ($j = 1; $j <= $contextLines; $j++) { | |
| 213 | if (($i - $j) >= 0 && $stream[$i - $j]['t'] !== ' ') { | |
| 289 | ||
| 290 | if( !$show ) { | |
| 291 | for( $j = 1; $j <= $contextLines; $j++ ) { | |
| 292 | if( ($i - $j) >= 0 && $stream[$i - $j]['t'] !== ' ' ) { | |
| 214 | 293 | $show = true; |
| 215 | 294 | break; |
| 216 | 295 | } |
| 217 | 296 | } |
| 218 | 297 | } |
| 219 | 298 | } |
| 220 | 299 | |
| 221 | if ($show) { | |
| 222 | if ($lastVisibleIndex !== -1 && $i > $lastVisibleIndex + 1) { | |
| 300 | if( $show ) { | |
| 301 | if( $lastVisibleIndex !== -1 && $i > $lastVisibleIndex + 1 ) { | |
| 223 | 302 | $finalLines[] = ['t' => 'gap']; |
| 224 | 303 | } |
| 304 | ||
| 225 | 305 | $finalLines[] = $stream[$i]; |
| 226 | 306 | $lastVisibleIndex = $i; |
| 227 | 307 | } |
| 228 | 308 | } |
| 309 | ||
| 229 | 310 | $result = $finalLines; |
| 230 | 311 | } |
| 231 | 312 | |
| 232 | 313 | return $result; |
| 233 | 314 | } |
| 234 | 315 | |
| 235 | private function computeLCS($old, $new) { | |
| 236 | $m = count($old); | |
| 237 | $n = count($new); | |
| 238 | $c = array_fill(0, $m + 1, array_fill(0, $n + 1, 0)); | |
| 316 | private function computeLCS( $old, $new ) { | |
| 317 | $m = count( $old ); | |
| 318 | $n = count( $new ); | |
| 319 | $c = array_fill( 0, $m + 1, array_fill( 0, $n + 1, 0 ) ); | |
| 239 | 320 | |
| 240 | for ($i = 1; $i <= $m; $i++) { | |
| 241 | for ($j = 1; $j <= $n; $j++) { | |
| 321 | for( $i = 1; $i <= $m; $i++ ) { | |
| 322 | for( $j = 1; $j <= $n; $j++ ) { | |
| 242 | 323 | $c[$i][$j] = ($old[$i - 1] === $new[$j - 1]) |
| 243 | 324 | ? $c[$i - 1][$j - 1] + 1 |
| 244 | : max($c[$i][$j - 1], $c[$i - 1][$j]); | |
| 325 | : max( $c[$i][$j - 1], $c[$i - 1][$j] ); | |
| 245 | 326 | } |
| 246 | 327 | } |
| 247 | 328 | |
| 248 | 329 | $diff = []; |
| 249 | 330 | $i = $m; |
| 250 | 331 | $j = $n; |
| 251 | 332 | |
| 252 | while ($i > 0 || $j > 0) { | |
| 253 | if ($i > 0 && $j > 0 && $old[$i - 1] === $new[$j - 1]) { | |
| 254 | array_unshift($diff, ['t' => ' ', 'l' => $old[$i - 1]]); | |
| 333 | while( $i > 0 || $j > 0 ) { | |
| 334 | if( $i > 0 && $j > 0 && $old[$i - 1] === $new[$j - 1] ) { | |
| 335 | array_unshift( $diff, ['t' => ' ', 'l' => $old[$i - 1]] ); | |
| 255 | 336 | $i--; |
| 256 | 337 | $j--; |
| 257 | } elseif ($j > 0 && ($i === 0 || $c[$i][$j - 1] >= $c[$i - 1][$j])) { | |
| 258 | array_unshift($diff, ['t' => '+', 'l' => $new[$j - 1]]); | |
| 338 | } elseif( $j > 0 && ($i === 0 || $c[$i][$j - 1] >= $c[$i - 1][$j]) ) { | |
| 339 | array_unshift( $diff, ['t' => '+', 'l' => $new[$j - 1]] ); | |
| 259 | 340 | $j--; |
| 260 | } elseif ($i > 0 && ($j === 0 || $c[$i][$j - 1] < $c[$i - 1][$j])) { | |
| 261 | array_unshift($diff, ['t' => '-', 'l' => $old[$i - 1]]); | |
| 341 | } elseif( $i > 0 && ($j === 0 || $c[$i][$j - 1] < $c[$i - 1][$j]) ) { | |
| 342 | array_unshift( $diff, ['t' => '-', 'l' => $old[$i - 1]] ); | |
| 262 | 343 | $i--; |
| 263 | 344 | } |
| 264 | 345 | } |
| 346 | ||
| 265 | 347 | return $diff; |
| 266 | 348 | } |
| 267 | 349 | } |
| 268 | 350 | |
| 269 | 351 | class VirtualDiffFile extends File { |
| 270 | public function __construct(string $name, string $content) { | |
| 271 | parent::__construct($name, '', '100644', 0, strlen($content), $content); | |
| 352 | public function __construct( string $name, string $content ) { | |
| 353 | parent::__construct( | |
| 354 | $name, | |
| 355 | '', | |
| 356 | '100644', | |
| 357 | 0, | |
| 358 | strlen( $content ), | |
| 359 | $content | |
| 360 | ); | |
| 272 | 361 | } |
| 273 | 362 | } |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | return $this->streamPackEntry( $handle, $info['offset'], $size, $callback ); | |
| 79 | } | |
| 80 | ||
| 81 | public function getSize( string $sha ): ?int { | |
| 82 | $info = $this->findPackInfo( $sha ); | |
| 83 | ||
| 84 | if( $info['offset'] === -1 ) { | |
| 85 | return null; | |
| 86 | } | |
| 87 | ||
| 88 | return $this->extractPackedSize( $info['file'], $info['offset'] ); | |
| 89 | } | |
| 90 | ||
| 91 | private function findPackInfo( string $sha ): array { | |
| 92 | if( !ctype_xdigit( $sha ) || strlen( $sha ) !== 40 ) { | |
| 93 | return ['offset' => -1]; | |
| 94 | } | |
| 95 | ||
| 96 | $binarySha = hex2bin( $sha ); | |
| 97 | ||
| 98 | if( $this->lastPack ) { | |
| 99 | $offset = $this->findInIdx( $this->lastPack, $binarySha ); | |
| 100 | ||
| 101 | if( $offset !== -1 ) { | |
| 102 | return $this->makeResult( $this->lastPack, $offset ); | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | foreach( $this->packFiles as $indexFile ) { | |
| 107 | if( $indexFile === $this->lastPack ) { | |
| 108 | continue; | |
| 109 | } | |
| 110 | ||
| 111 | $offset = $this->findInIdx( $indexFile, $binarySha ); | |
| 112 | ||
| 113 | if( $offset !== -1 ) { | |
| 114 | $this->lastPack = $indexFile; | |
| 115 | ||
| 116 | return $this->makeResult( $indexFile, $offset ); | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | return ['offset' => -1]; | |
| 121 | } | |
| 122 | ||
| 123 | private function makeResult( string $indexPath, int $offset ): array { | |
| 124 | return [ | |
| 125 | 'file' => str_replace( '.idx', '.pack', $indexPath ), | |
| 126 | 'offset' => $offset | |
| 127 | ]; | |
| 128 | } | |
| 129 | ||
| 130 | private function findInIdx( string $indexFile, string $binarySha ): int { | |
| 131 | $fileHandle = $this->getHandle( $indexFile ); | |
| 132 | ||
| 133 | if( !$fileHandle ) { | |
| 134 | return -1; | |
| 135 | } | |
| 136 | ||
| 137 | if( !isset( $this->fanoutCache[$indexFile] ) ) { | |
| 138 | fseek( $fileHandle, 0 ); | |
| 139 | ||
| 140 | if( fread( $fileHandle, 8 ) === "\377tOc\0\0\0\2" ) { | |
| 141 | $this->fanoutCache[$indexFile] = array_values( | |
| 142 | unpack( 'N*', fread( $fileHandle, 1024 ) ) | |
| 143 | ); | |
| 144 | } else { | |
| 145 | return -1; | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | $fanout = $this->fanoutCache[$indexFile]; | |
| 150 | ||
| 151 | $firstByte = ord( $binarySha[0] ); | |
| 152 | $start = $firstByte === 0 ? 0 : $fanout[$firstByte - 1]; | |
| 153 | $end = $fanout[$firstByte]; | |
| 154 | ||
| 155 | if( $end <= $start ) { | |
| 156 | return -1; | |
| 157 | } | |
| 158 | ||
| 159 | $cacheKey = "$indexFile:$firstByte"; | |
| 160 | ||
| 161 | if( !isset( $this->shaBucketCache[$cacheKey] ) ) { | |
| 162 | $count = $end - $start; | |
| 163 | fseek( $fileHandle, 1032 + ($start * 20) ); | |
| 164 | $this->shaBucketCache[$cacheKey] = fread( $fileHandle, $count * 20 ); | |
| 165 | ||
| 166 | fseek( | |
| 167 | $fileHandle, | |
| 168 | 1032 + ($fanout[255] * 24) + ($start * 4) | |
| 169 | ); | |
| 170 | $this->offsetBucketCache[$cacheKey] = fread( $fileHandle, $count * 4 ); | |
| 171 | } | |
| 172 | ||
| 173 | $shaBlock = $this->shaBucketCache[$cacheKey]; | |
| 174 | $count = strlen( $shaBlock ) / 20; | |
| 175 | $low = 0; | |
| 176 | $high = $count - 1; | |
| 177 | $foundIdx = -1; | |
| 178 | ||
| 179 | while( $low <= $high ) { | |
| 180 | $mid = ($low + $high) >> 1; | |
| 181 | $compare = substr( $shaBlock, $mid * 20, 20 ); | |
| 182 | ||
| 183 | if( $compare < $binarySha ) { | |
| 184 | $low = $mid + 1; | |
| 185 | } elseif( $compare > $binarySha ) { | |
| 186 | $high = $mid - 1; | |
| 187 | } else { | |
| 188 | $foundIdx = $mid; | |
| 189 | break; | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | if( $foundIdx === -1 ) { | |
| 194 | return -1; | |
| 195 | } | |
| 196 | ||
| 197 | $offsetData = substr( | |
| 198 | $this->offsetBucketCache[$cacheKey], | |
| 199 | $foundIdx * 4, | |
| 200 | 4 | |
| 201 | ); | |
| 202 | $offset = unpack( 'N', $offsetData )[1]; | |
| 203 | ||
| 204 | if( $offset & 0x80000000 ) { | |
| 205 | $packTotal = $fanout[255]; | |
| 206 | $pos64 = 1032 + ($packTotal * 28) + | |
| 207 | (($offset & 0x7FFFFFFF) * 8); | |
| 208 | fseek( $fileHandle, $pos64 ); | |
| 209 | $offset = unpack( 'J', fread( $fileHandle, 8 ) )[1]; | |
| 210 | } | |
| 211 | ||
| 212 | return (int)$offset; | |
| 213 | } | |
| 214 | ||
| 215 | private function readPackEntry( $fileHandle, int $offset, int $expectedSize, int $cap = 0 ): string { | |
| 216 | fseek( $fileHandle, $offset ); | |
| 217 | ||
| 218 | $header = $this->readVarInt( $fileHandle ); | |
| 219 | $type = ($header['byte'] >> 4) & 7; | |
| 220 | ||
| 221 | if( $type === 6 ) { | |
| 222 | return $this->handleOfsDelta( $fileHandle, $offset, $expectedSize, $cap ); | |
| 223 | } | |
| 224 | ||
| 225 | if( $type === 7 ) { | |
| 226 | return $this->handleRefDelta( $fileHandle, $expectedSize, $cap ); | |
| 227 | } | |
| 228 | ||
| 229 | return $this->decompressToString( $fileHandle, $expectedSize, $cap ); | |
| 230 | } | |
| 231 | ||
| 232 | private function streamPackEntry( $fileHandle, int $offset, int $expectedSize, callable $callback ): bool { | |
| 233 | fseek( $fileHandle, $offset ); | |
| 234 | ||
| 235 | $header = $this->readVarInt( $fileHandle ); | |
| 236 | $type = ($header['byte'] >> 4) & 7; | |
| 237 | ||
| 238 | if( $type === 6 || $type === 7 ) { | |
| 239 | return $this->streamDeltaObject( $fileHandle, $offset, $type, $expectedSize, $callback ); | |
| 240 | } | |
| 241 | ||
| 242 | return $this->streamDecompression( $fileHandle, $callback ); | |
| 243 | } | |
| 244 | ||
| 245 | private function streamDeltaObject( $fileHandle, int $offset, int $type, int $expectedSize, callable $callback ): bool { | |
| 246 | fseek( $fileHandle, $offset ); | |
| 247 | $this->readVarInt( $fileHandle ); | |
| 248 | ||
| 249 | if( $type === 6 ) { | |
| 250 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 251 | $negative = $byte & 127; | |
| 252 | ||
| 253 | while( $byte & 128 ) { | |
| 254 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 255 | $negative = (($negative + 1) << 7) | ($byte & 127); | |
| 256 | } | |
| 257 | ||
| 258 | $deltaPos = ftell( $fileHandle ); | |
| 259 | $baseOffset = $offset - $negative; | |
| 260 | ||
| 261 | $base = ''; | |
| 262 | $this->streamPackEntry( $fileHandle, $baseOffset, 0, function( $chunk ) use ( &$base ) { | |
| 263 | $base .= $chunk; | |
| 264 | } ); | |
| 265 | ||
| 266 | fseek( $fileHandle, $deltaPos ); | |
| 267 | } else { | |
| 268 | $baseSha = bin2hex( fread( $fileHandle, 20 ) ); | |
| 269 | ||
| 270 | $base = ''; | |
| 271 | $streamed = $this->stream( $baseSha, function( $chunk ) use ( &$base ) { | |
| 272 | $base .= $chunk; | |
| 273 | } ); | |
| 274 | ||
| 275 | if( !$streamed ) { | |
| 276 | return false; | |
| 277 | } | |
| 278 | } | |
| 279 | ||
| 280 | $compressed = fread( $fileHandle, self::MAX_READ ); | |
| 281 | $delta = @gzuncompress( $compressed ) ?: ''; | |
| 282 | ||
| 283 | $result = $this->applyDelta( $base, $delta ); | |
| 284 | ||
| 285 | $chunkSize = 8192; | |
| 286 | $length = strlen( $result ); | |
| 287 | for( $i = 0; $i < $length; $i += $chunkSize ) { | |
| 288 | $callback( substr( $result, $i, $chunkSize ) ); | |
| 289 | } | |
| 290 | ||
| 291 | return true; | |
| 292 | } | |
| 293 | ||
| 294 | private function streamDecompression( $fileHandle, callable $callback ): bool { | |
| 295 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 296 | if( $inflator === false ) { | |
| 297 | return false; | |
| 298 | } | |
| 299 | ||
| 300 | while( !feof( $fileHandle ) ) { | |
| 301 | $chunk = fread( $fileHandle, 8192 ); | |
| 302 | ||
| 303 | if( $chunk === false || $chunk === '' ) { | |
| 304 | break; | |
| 305 | } | |
| 306 | ||
| 307 | $data = @inflate_add( $inflator, $chunk ); | |
| 308 | ||
| 309 | if( $data !== false && $data !== '' ) { | |
| 310 | $callback( $data ); | |
| 311 | } | |
| 312 | ||
| 313 | if( $data === false || | |
| 314 | inflate_get_status( $inflator ) === ZLIB_STREAM_END ) { | |
| 315 | break; | |
| 316 | } | |
| 317 | } | |
| 318 | ||
| 319 | return true; | |
| 320 | } | |
| 321 | ||
| 322 | private function decompressToString( $fileHandle, int $maxSize, int $cap = 0 ): string { | |
| 323 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 324 | if( $inflator === false ) { | |
| 325 | return ''; | |
| 326 | } | |
| 327 | ||
| 328 | $result = ''; | |
| 329 | ||
| 330 | while( !feof( $fileHandle ) ) { | |
| 331 | $chunk = fread( $fileHandle, 8192 ); | |
| 332 | ||
| 333 | if( $chunk === false || $chunk === '' ) { | |
| 334 | break; | |
| 335 | } | |
| 336 | ||
| 337 | $data = @inflate_add( $inflator, $chunk ); | |
| 338 | ||
| 339 | if( $data !== false ) { | |
| 340 | $result .= $data; | |
| 341 | } | |
| 342 | ||
| 343 | if( $cap > 0 && strlen( $result ) >= $cap ) { | |
| 344 | return substr( $result, 0, $cap ); | |
| 345 | } | |
| 346 | ||
| 347 | if( $data === false || | |
| 348 | inflate_get_status( $inflator ) === ZLIB_STREAM_END ) { | |
| 349 | break; | |
| 350 | } | |
| 351 | } | |
| 352 | ||
| 353 | return $result; | |
| 354 | } | |
| 355 | ||
| 356 | private function extractPackedSize( string $packPath, int $offset ): int { | |
| 357 | $fileHandle = $this->getHandle( $packPath ); | |
| 358 | ||
| 359 | if( !$fileHandle ) { | |
| 360 | return 0; | |
| 361 | } | |
| 362 | ||
| 363 | fseek( $fileHandle, $offset ); | |
| 364 | ||
| 365 | $header = $this->readVarInt( $fileHandle ); | |
| 366 | $size = $header['value']; | |
| 367 | $type = ($header['byte'] >> 4) & 7; | |
| 368 | ||
| 369 | if( $type === 6 || $type === 7 ) { | |
| 370 | return $this->readDeltaTargetSize( $fileHandle, $type ); | |
| 371 | } | |
| 372 | ||
| 373 | return $size; | |
| 374 | } | |
| 375 | ||
| 376 | private function handleOfsDelta( $fileHandle, int $offset, int $expectedSize, int $cap = 0 ): string { | |
| 377 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 378 | $negative = $byte & 127; | |
| 379 | ||
| 380 | while( $byte & 128 ) { | |
| 381 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 382 | $negative = (($negative + 1) << 7) | ($byte & 127); | |
| 383 | } | |
| 384 | ||
| 385 | $currentPos = ftell( $fileHandle ); | |
| 386 | $baseOffset = $offset - $negative; | |
| 387 | ||
| 388 | fseek( $fileHandle, $baseOffset ); | |
| 389 | $baseHeader = $this->readVarInt( $fileHandle ); | |
| 390 | $baseSize = $baseHeader['value']; | |
| 391 | ||
| 392 | fseek( $fileHandle, $baseOffset ); | |
| 393 | $base = $this->readPackEntry( $fileHandle, $baseOffset, $baseSize, $cap ); | |
| 394 | ||
| 395 | fseek( $fileHandle, $currentPos ); | |
| 396 | ||
| 397 | $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) ); | |
| 398 | $compressed = fread( $fileHandle, $remainingBytes ); | |
| 399 | $delta = @gzuncompress( $compressed ) ?: ''; | |
| 400 | ||
| 401 | return $this->applyDelta( $base, $delta, $cap ); | |
| 402 | } | |
| 403 | ||
| 404 | private function handleRefDelta( $fileHandle, int $expectedSize, int $cap = 0 ): string { | |
| 405 | $baseSha = bin2hex( fread( $fileHandle, 20 ) ); | |
| 406 | ||
| 407 | if ( $cap > 0 ) { | |
| 408 | $base = $this->peek( $baseSha, $cap ) ?? ''; | |
| 409 | } else { | |
| 410 | $base = $this->read( $baseSha ) ?? ''; | |
| 411 | } | |
| 412 | ||
| 413 | $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) ); | |
| 414 | $compressed = fread( $fileHandle, $remainingBytes ); | |
| 415 | $delta = @gzuncompress( $compressed ) ?: ''; | |
| 416 | ||
| 417 | return $this->applyDelta( $base, $delta, $cap ); | |
| 418 | } | |
| 419 | ||
| 420 | private function applyDelta( string $base, string $delta, int $cap = 0 ): string { | |
| 421 | $position = 0; | |
| 422 | $this->skipSize( $delta, $position ); | |
| 423 | $this->skipSize( $delta, $position ); | |
| 424 | ||
| 425 | $output = ''; | |
| 426 | $deltaLength = strlen( $delta ); | |
| 427 | ||
| 428 | while( $position < $deltaLength ) { | |
| 429 | if( $cap > 0 && strlen( $output ) >= $cap ) { | |
| 430 | break; | |
| 431 | } | |
| 432 | ||
| 433 | $opcode = ord( $delta[$position++] ); | |
| 434 | ||
| 435 | if( $opcode & 128 ) { | |
| 436 | $offset = 0; | |
| 437 | $length = 0; | |
| 438 | ||
| 439 | if( $opcode & 0x01 ) { | |
| 440 | $offset |= ord( $delta[$position++] ); | |
| 441 | } | |
| 442 | if( $opcode & 0x02 ) { | |
| 443 | $offset |= ord( $delta[$position++] ) << 8; | |
| 444 | } | |
| 445 | if( $opcode & 0x04 ) { | |
| 446 | $offset |= ord( $delta[$position++] ) << 16; | |
| 447 | } | |
| 448 | if( $opcode & 0x08 ) { | |
| 449 | $offset |= ord( $delta[$position++] ) << 24; | |
| 450 | } | |
| 451 | ||
| 452 | if( $opcode & 0x10 ) { | |
| 453 | $length |= ord( $delta[$position++] ); | |
| 454 | } | |
| 455 | if( $opcode & 0x20 ) { | |
| 456 | $length |= ord( $delta[$position++] ) << 8; | |
| 457 | } | |
| 458 | if( $opcode & 0x40 ) { | |
| 459 | $length |= ord( $delta[$position++] ) << 16; | |
| 460 | } | |
| 461 | ||
| 462 | if( $length === 0 ) { | |
| 463 | $length = 0x10000; | |
| 464 | } | |
| 465 | ||
| 466 | $output .= substr( $base, $offset, $length ); | |
| 467 | } else { | |
| 468 | $length = $opcode & 127; | |
| 469 | $output .= substr( $delta, $position, $length ); | |
| 470 | $position += $length; | |
| 471 | } | |
| 472 | } | |
| 473 | ||
| 474 | return $output; | |
| 475 | } | |
| 476 | ||
| 477 | private function readVarInt( $fileHandle ): array { | |
| 478 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 479 | $value = $byte & 15; | |
| 480 | $shift = 4; | |
| 481 | $first = $byte; | |
| 482 | ||
| 483 | while( $byte & 128 ) { | |
| 484 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 485 | $value |= (($byte & 127) << $shift); | |
| 486 | $shift += 7; | |
| 487 | } | |
| 488 | ||
| 489 | return ['value' => $value, 'byte' => $first]; | |
| 490 | } | |
| 491 | ||
| 492 | private function readDeltaTargetSize( $fileHandle, int $type ): int { | |
| 493 | if( $type === 6 ) { | |
| 494 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 495 | ||
| 496 | while( $byte & 128 ) { | |
| 497 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 498 | } | |
| 499 | } else { | |
| 500 | fseek( $fileHandle, 20, SEEK_CUR ); | |
| 501 | } | |
| 502 | ||
| 503 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 504 | if( $inflator === false ) { | |
| 505 | return 0; | |
| 506 | } | |
| 507 | ||
| 508 | $header = ''; | |
| 509 | $attempts = 0; | |
| 510 | $maxAttempts = 64; | |
| 511 | ||
| 512 | while( !feof( $fileHandle ) && strlen( $header ) < 32 && $attempts < $maxAttempts ) { | |
| 78 | return $this->streamPackEntry( | |
| 79 | $handle, | |
| 80 | $info['offset'], | |
| 81 | $size, | |
| 82 | $callback | |
| 83 | ); | |
| 84 | } | |
| 85 | ||
| 86 | public function getSize( string $sha ): ?int { | |
| 87 | $info = $this->findPackInfo( $sha ); | |
| 88 | ||
| 89 | if( $info['offset'] === -1 ) { | |
| 90 | return null; | |
| 91 | } | |
| 92 | ||
| 93 | return $this->extractPackedSize( $info['file'], $info['offset'] ); | |
| 94 | } | |
| 95 | ||
| 96 | private function findPackInfo( string $sha ): array { | |
| 97 | if( !ctype_xdigit( $sha ) || strlen( $sha ) !== 40 ) { | |
| 98 | return ['offset' => -1]; | |
| 99 | } | |
| 100 | ||
| 101 | $binarySha = hex2bin( $sha ); | |
| 102 | ||
| 103 | if( $this->lastPack ) { | |
| 104 | $offset = $this->findInIdx( $this->lastPack, $binarySha ); | |
| 105 | ||
| 106 | if( $offset !== -1 ) { | |
| 107 | return $this->makeResult( $this->lastPack, $offset ); | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | foreach( $this->packFiles as $indexFile ) { | |
| 112 | if( $indexFile === $this->lastPack ) { | |
| 113 | continue; | |
| 114 | } | |
| 115 | ||
| 116 | $offset = $this->findInIdx( $indexFile, $binarySha ); | |
| 117 | ||
| 118 | if( $offset !== -1 ) { | |
| 119 | $this->lastPack = $indexFile; | |
| 120 | ||
| 121 | return $this->makeResult( $indexFile, $offset ); | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | return ['offset' => -1]; | |
| 126 | } | |
| 127 | ||
| 128 | private function makeResult( string $indexPath, int $offset ): array { | |
| 129 | return [ | |
| 130 | 'file' => str_replace( '.idx', '.pack', $indexPath ), | |
| 131 | 'offset' => $offset | |
| 132 | ]; | |
| 133 | } | |
| 134 | ||
| 135 | private function findInIdx( string $indexFile, string $binarySha ): int { | |
| 136 | $fileHandle = $this->getHandle( $indexFile ); | |
| 137 | ||
| 138 | if( !$fileHandle ) { | |
| 139 | return -1; | |
| 140 | } | |
| 141 | ||
| 142 | if( !isset( $this->fanoutCache[$indexFile] ) ) { | |
| 143 | fseek( $fileHandle, 0 ); | |
| 144 | ||
| 145 | if( fread( $fileHandle, 8 ) === "\377tOc\0\0\0\2" ) { | |
| 146 | $this->fanoutCache[$indexFile] = array_values( | |
| 147 | unpack( 'N*', fread( $fileHandle, 1024 ) ) | |
| 148 | ); | |
| 149 | } else { | |
| 150 | return -1; | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | $fanout = $this->fanoutCache[$indexFile]; | |
| 155 | ||
| 156 | $firstByte = ord( $binarySha[0] ); | |
| 157 | $start = $firstByte === 0 ? 0 : $fanout[$firstByte - 1]; | |
| 158 | $end = $fanout[$firstByte]; | |
| 159 | ||
| 160 | if( $end <= $start ) { | |
| 161 | return -1; | |
| 162 | } | |
| 163 | ||
| 164 | $cacheKey = "$indexFile:$firstByte"; | |
| 165 | ||
| 166 | if( !isset( $this->shaBucketCache[$cacheKey] ) ) { | |
| 167 | $count = $end - $start; | |
| 168 | ||
| 169 | fseek( $fileHandle, 1032 + ($start * 20) ); | |
| 170 | ||
| 171 | $this->shaBucketCache[$cacheKey] = fread( $fileHandle, $count * 20 ); | |
| 172 | ||
| 173 | fseek( | |
| 174 | $fileHandle, | |
| 175 | 1032 + ($fanout[255] * 24) + ($start * 4) | |
| 176 | ); | |
| 177 | ||
| 178 | $this->offsetBucketCache[$cacheKey] = fread( $fileHandle, $count * 4 ); | |
| 179 | } | |
| 180 | ||
| 181 | $shaBlock = $this->shaBucketCache[$cacheKey]; | |
| 182 | $count = strlen( $shaBlock ) / 20; | |
| 183 | $low = 0; | |
| 184 | $high = $count - 1; | |
| 185 | $foundIdx = -1; | |
| 186 | ||
| 187 | while( $low <= $high ) { | |
| 188 | $mid = ($low + $high) >> 1; | |
| 189 | $compare = substr( $shaBlock, $mid * 20, 20 ); | |
| 190 | ||
| 191 | if( $compare < $binarySha ) { | |
| 192 | $low = $mid + 1; | |
| 193 | } elseif( $compare > $binarySha ) { | |
| 194 | $high = $mid - 1; | |
| 195 | } else { | |
| 196 | $foundIdx = $mid; | |
| 197 | break; | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | if( $foundIdx === -1 ) { | |
| 202 | return -1; | |
| 203 | } | |
| 204 | ||
| 205 | $offsetData = substr( | |
| 206 | $this->offsetBucketCache[$cacheKey], | |
| 207 | $foundIdx * 4, | |
| 208 | 4 | |
| 209 | ); | |
| 210 | ||
| 211 | $offset = unpack( 'N', $offsetData )[1]; | |
| 212 | ||
| 213 | if( $offset & 0x80000000 ) { | |
| 214 | $packTotal = $fanout[255]; | |
| 215 | $pos64 = 1032 + ($packTotal * 28) + | |
| 216 | (($offset & 0x7FFFFFFF) * 8); | |
| 217 | ||
| 218 | fseek( $fileHandle, $pos64 ); | |
| 219 | ||
| 220 | $offset = unpack( 'J', fread( $fileHandle, 8 ) )[1]; | |
| 221 | } | |
| 222 | ||
| 223 | return (int)$offset; | |
| 224 | } | |
| 225 | ||
| 226 | private function readPackEntry( | |
| 227 | $fileHandle, | |
| 228 | int $offset, | |
| 229 | int $expectedSize, | |
| 230 | int $cap = 0 | |
| 231 | ): string { | |
| 232 | fseek( $fileHandle, $offset ); | |
| 233 | ||
| 234 | $header = $this->readVarInt( $fileHandle ); | |
| 235 | $type = ($header['byte'] >> 4) & 7; | |
| 236 | ||
| 237 | if( $type === 6 ) { | |
| 238 | return $this->handleOfsDelta( | |
| 239 | $fileHandle, | |
| 240 | $offset, | |
| 241 | $expectedSize, | |
| 242 | $cap | |
| 243 | ); | |
| 244 | } | |
| 245 | ||
| 246 | if( $type === 7 ) { | |
| 247 | return $this->handleRefDelta( $fileHandle, $expectedSize, $cap ); | |
| 248 | } | |
| 249 | ||
| 250 | return $this->decompressToString( $fileHandle, $expectedSize, $cap ); | |
| 251 | } | |
| 252 | ||
| 253 | private function streamPackEntry( | |
| 254 | $fileHandle, | |
| 255 | int $offset, | |
| 256 | int $expectedSize, | |
| 257 | callable $callback | |
| 258 | ): bool { | |
| 259 | fseek( $fileHandle, $offset ); | |
| 260 | ||
| 261 | $header = $this->readVarInt( $fileHandle ); | |
| 262 | $type = ($header['byte'] >> 4) & 7; | |
| 263 | ||
| 264 | if( $type === 6 || $type === 7 ) { | |
| 265 | return $this->streamDeltaObject( | |
| 266 | $fileHandle, | |
| 267 | $offset, | |
| 268 | $type, | |
| 269 | $expectedSize, | |
| 270 | $callback | |
| 271 | ); | |
| 272 | } | |
| 273 | ||
| 274 | return $this->streamDecompression( $fileHandle, $callback ); | |
| 275 | } | |
| 276 | ||
| 277 | private function streamDeltaObject( | |
| 278 | $fileHandle, | |
| 279 | int $offset, | |
| 280 | int $type, | |
| 281 | int $expectedSize, | |
| 282 | callable $callback | |
| 283 | ): bool { | |
| 284 | fseek( $fileHandle, $offset ); | |
| 285 | $this->readVarInt( $fileHandle ); | |
| 286 | ||
| 287 | if( $type === 6 ) { | |
| 288 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 289 | $negative = $byte & 127; | |
| 290 | ||
| 291 | while( $byte & 128 ) { | |
| 292 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 293 | $negative = (($negative + 1) << 7) | ($byte & 127); | |
| 294 | } | |
| 295 | ||
| 296 | $deltaPos = ftell( $fileHandle ); | |
| 297 | $baseOffset = $offset - $negative; | |
| 298 | ||
| 299 | $base = ''; | |
| 300 | ||
| 301 | $this->streamPackEntry( | |
| 302 | $fileHandle, | |
| 303 | $baseOffset, | |
| 304 | 0, | |
| 305 | function( $chunk ) use ( &$base ) { $base .= $chunk; } | |
| 306 | ); | |
| 307 | ||
| 308 | fseek( $fileHandle, $deltaPos ); | |
| 309 | } else { | |
| 310 | $baseSha = bin2hex( fread( $fileHandle, 20 ) ); | |
| 311 | ||
| 312 | $base = ''; | |
| 313 | $streamed = $this->stream( | |
| 314 | $baseSha, | |
| 315 | function( $chunk ) use ( &$base ) { $base .= $chunk; } | |
| 316 | ); | |
| 317 | ||
| 318 | if( !$streamed ) { | |
| 319 | return false; | |
| 320 | } | |
| 321 | } | |
| 322 | ||
| 323 | $compressed = fread( $fileHandle, self::MAX_READ ); | |
| 324 | $delta = @gzuncompress( $compressed ) ?: ''; | |
| 325 | ||
| 326 | $result = $this->applyDelta( $base, $delta ); | |
| 327 | ||
| 328 | $chunkSize = 8192; | |
| 329 | $length = strlen( $result ); | |
| 330 | ||
| 331 | for( $i = 0; $i < $length; $i += $chunkSize ) { | |
| 332 | $callback( substr( $result, $i, $chunkSize ) ); | |
| 333 | } | |
| 334 | ||
| 335 | return true; | |
| 336 | } | |
| 337 | ||
| 338 | private function streamDecompression( $fileHandle, callable $callback ): bool { | |
| 339 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 340 | ||
| 341 | if( $inflator === false ) { | |
| 342 | return false; | |
| 343 | } | |
| 344 | ||
| 345 | while( !feof( $fileHandle ) ) { | |
| 346 | $chunk = fread( $fileHandle, 8192 ); | |
| 347 | ||
| 348 | if( $chunk === false || $chunk === '' ) { | |
| 349 | break; | |
| 350 | } | |
| 351 | ||
| 352 | $data = @inflate_add( $inflator, $chunk ); | |
| 353 | ||
| 354 | if( $data !== false && $data !== '' ) { | |
| 355 | $callback( $data ); | |
| 356 | } | |
| 357 | ||
| 358 | if( | |
| 359 | $data === false || | |
| 360 | inflate_get_status( $inflator ) === ZLIB_STREAM_END | |
| 361 | ) { | |
| 362 | break; | |
| 363 | } | |
| 364 | } | |
| 365 | ||
| 366 | return true; | |
| 367 | } | |
| 368 | ||
| 369 | private function decompressToString( | |
| 370 | $fileHandle, | |
| 371 | int $maxSize, | |
| 372 | int $cap = 0 | |
| 373 | ): string { | |
| 374 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 375 | ||
| 376 | if( $inflator === false ) { | |
| 377 | return ''; | |
| 378 | } | |
| 379 | ||
| 380 | $result = ''; | |
| 381 | ||
| 382 | while( !feof( $fileHandle ) ) { | |
| 383 | $chunk = fread( $fileHandle, 8192 ); | |
| 384 | ||
| 385 | if( $chunk === false || $chunk === '' ) { | |
| 386 | break; | |
| 387 | } | |
| 388 | ||
| 389 | $data = @inflate_add( $inflator, $chunk ); | |
| 390 | ||
| 391 | if( $data !== false ) { | |
| 392 | $result .= $data; | |
| 393 | } | |
| 394 | ||
| 395 | if( $cap > 0 && strlen( $result ) >= $cap ) { | |
| 396 | return substr( $result, 0, $cap ); | |
| 397 | } | |
| 398 | ||
| 399 | if( | |
| 400 | $data === false || | |
| 401 | inflate_get_status( $inflator ) === ZLIB_STREAM_END | |
| 402 | ) { | |
| 403 | break; | |
| 404 | } | |
| 405 | } | |
| 406 | ||
| 407 | return $result; | |
| 408 | } | |
| 409 | ||
| 410 | private function extractPackedSize( string $packPath, int $offset ): int { | |
| 411 | $fileHandle = $this->getHandle( $packPath ); | |
| 412 | ||
| 413 | if( !$fileHandle ) { | |
| 414 | return 0; | |
| 415 | } | |
| 416 | ||
| 417 | fseek( $fileHandle, $offset ); | |
| 418 | ||
| 419 | $header = $this->readVarInt( $fileHandle ); | |
| 420 | $size = $header['value']; | |
| 421 | $type = ($header['byte'] >> 4) & 7; | |
| 422 | ||
| 423 | if( $type === 6 || $type === 7 ) { | |
| 424 | return $this->readDeltaTargetSize( $fileHandle, $type ); | |
| 425 | } | |
| 426 | ||
| 427 | return $size; | |
| 428 | } | |
| 429 | ||
| 430 | private function handleOfsDelta( | |
| 431 | $fileHandle, | |
| 432 | int $offset, | |
| 433 | int $expectedSize, | |
| 434 | int $cap = 0 | |
| 435 | ): string { | |
| 436 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 437 | $negative = $byte & 127; | |
| 438 | ||
| 439 | while( $byte & 128 ) { | |
| 440 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 441 | $negative = (($negative + 1) << 7) | ($byte & 127); | |
| 442 | } | |
| 443 | ||
| 444 | $currentPos = ftell( $fileHandle ); | |
| 445 | $baseOffset = $offset - $negative; | |
| 446 | ||
| 447 | fseek( $fileHandle, $baseOffset ); | |
| 448 | ||
| 449 | $baseHeader = $this->readVarInt( $fileHandle ); | |
| 450 | $baseSize = $baseHeader['value']; | |
| 451 | ||
| 452 | fseek( $fileHandle, $baseOffset ); | |
| 453 | ||
| 454 | $base = $this->readPackEntry( $fileHandle, $baseOffset, $baseSize, $cap ); | |
| 455 | ||
| 456 | fseek( $fileHandle, $currentPos ); | |
| 457 | ||
| 458 | $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) ); | |
| 459 | $compressed = fread( $fileHandle, $remainingBytes ); | |
| 460 | $delta = @gzuncompress( $compressed ) ?: ''; | |
| 461 | ||
| 462 | return $this->applyDelta( $base, $delta, $cap ); | |
| 463 | } | |
| 464 | ||
| 465 | private function handleRefDelta( | |
| 466 | $fileHandle, | |
| 467 | int $expectedSize, | |
| 468 | int $cap = 0 | |
| 469 | ): string { | |
| 470 | $baseSha = bin2hex( fread( $fileHandle, 20 ) ); | |
| 471 | ||
| 472 | if( $cap > 0 ) { | |
| 473 | $base = $this->peek( $baseSha, $cap ) ?? ''; | |
| 474 | } else { | |
| 475 | $base = $this->read( $baseSha ) ?? ''; | |
| 476 | } | |
| 477 | ||
| 478 | $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) ); | |
| 479 | $compressed = fread( $fileHandle, $remainingBytes ); | |
| 480 | $delta = @gzuncompress( $compressed ) ?: ''; | |
| 481 | ||
| 482 | return $this->applyDelta( $base, $delta, $cap ); | |
| 483 | } | |
| 484 | ||
| 485 | private function applyDelta( string $base, string $delta, int $cap = 0 ): string { | |
| 486 | $position = 0; | |
| 487 | ||
| 488 | $this->skipSize( $delta, $position ); | |
| 489 | $this->skipSize( $delta, $position ); | |
| 490 | ||
| 491 | $output = ''; | |
| 492 | $deltaLength = strlen( $delta ); | |
| 493 | ||
| 494 | while( $position < $deltaLength ) { | |
| 495 | if( $cap > 0 && strlen( $output ) >= $cap ) { | |
| 496 | break; | |
| 497 | } | |
| 498 | ||
| 499 | $opcode = ord( $delta[$position++] ); | |
| 500 | ||
| 501 | if( $opcode & 128 ) { | |
| 502 | $offset = 0; | |
| 503 | $length = 0; | |
| 504 | ||
| 505 | if( $opcode & 0x01 ) { $offset |= ord( $delta[$position++] ); } | |
| 506 | if( $opcode & 0x02 ) { $offset |= ord( $delta[$position++] ) << 8; } | |
| 507 | if( $opcode & 0x04 ) { $offset |= ord( $delta[$position++] ) << 16; } | |
| 508 | if( $opcode & 0x08 ) { $offset |= ord( $delta[$position++] ) << 24; } | |
| 509 | ||
| 510 | if( $opcode & 0x10 ) { $length |= ord( $delta[$position++] ); } | |
| 511 | if( $opcode & 0x20 ) { $length |= ord( $delta[$position++] ) << 8; } | |
| 512 | if( $opcode & 0x40 ) { $length |= ord( $delta[$position++] ) << 16; } | |
| 513 | ||
| 514 | if( $length === 0 ) { $length = 0x10000; } | |
| 515 | ||
| 516 | $output .= substr( $base, $offset, $length ); | |
| 517 | } else { | |
| 518 | $length = $opcode & 127; | |
| 519 | $output .= substr( $delta, $position, $length ); | |
| 520 | $position += $length; | |
| 521 | } | |
| 522 | } | |
| 523 | ||
| 524 | return $output; | |
| 525 | } | |
| 526 | ||
| 527 | private function readVarInt( $fileHandle ): array { | |
| 528 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 529 | $value = $byte & 15; | |
| 530 | $shift = 4; | |
| 531 | $first = $byte; | |
| 532 | ||
| 533 | while( $byte & 128 ) { | |
| 534 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 535 | $value |= (($byte & 127) << $shift); | |
| 536 | $shift += 7; | |
| 537 | } | |
| 538 | ||
| 539 | return ['value' => $value, 'byte' => $first]; | |
| 540 | } | |
| 541 | ||
| 542 | private function readDeltaTargetSize( $fileHandle, int $type ): int { | |
| 543 | if( $type === 6 ) { | |
| 544 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 545 | ||
| 546 | while( $byte & 128 ) { | |
| 547 | $byte = ord( fread( $fileHandle, 1 ) ); | |
| 548 | } | |
| 549 | } else { | |
| 550 | fseek( $fileHandle, 20, SEEK_CUR ); | |
| 551 | } | |
| 552 | ||
| 553 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); | |
| 554 | ||
| 555 | if( $inflator === false ) { | |
| 556 | return 0; | |
| 557 | } | |
| 558 | ||
| 559 | $header = ''; | |
| 560 | $attempts = 0; | |
| 561 | $maxAttempts = 64; | |
| 562 | ||
| 563 | while( | |
| 564 | !feof( $fileHandle ) && | |
| 565 | strlen( $header ) < 32 && | |
| 566 | $attempts < $maxAttempts | |
| 567 | ) { | |
| 513 | 568 | $chunk = fread( $fileHandle, 512 ); |
| 514 | 569 |
| 5 | 5 | Config::init(); |
| 6 | 6 | |
| 7 | $router = new Router(Config::getReposPath()); | |
| 7 | $router = new Router( Config::getReposPath() ); | |
| 8 | 8 | $page = $router->route(); |
| 9 | ||
| 9 | 10 | $page->render(); |
| 10 | 11 |
| 7 | 7 | protected $title; |
| 8 | 8 | |
| 9 | public function __construct(array $repositories) { | |
| 9 | public function __construct( array $repositories ) { | |
| 10 | 10 | $this->repositories = $repositories; |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | protected function renderLayout($contentCallback, $currentRepo = null) { | |
| 13 | protected function renderLayout( $contentCallback, $currentRepo = null ) { | |
| 14 | 14 | ?> |
| 15 | 15 | <!DOCTYPE html> |
| 16 | 16 | <html lang="en"> |
| 17 | 17 | <head> |
| 18 | 18 | <meta charset="UTF-8"> |
| 19 | 19 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 20 | <title><?php echo Config::SITE_TITLE . ($this->title ? ' - ' . htmlspecialchars($this->title) : ''); ?></title> | |
| 21 | <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> | |
| 20 | <title><?php | |
| 21 | echo Config::SITE_TITLE . | |
| 22 | ($this->title ? ' - ' . htmlspecialchars( $this->title ) : ''); | |
| 23 | ?></title> | |
| 24 | <link rel="stylesheet" | |
| 25 | href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> | |
| 22 | 26 | <link rel="stylesheet" href="repo.css"> |
| 23 | 27 | </head> |
| 24 | 28 | <body> |
| 25 | 29 | <div class="container"> |
| 26 | 30 | <header> |
| 27 | 31 | <h1><?php echo Config::SITE_TITLE; ?></h1> |
| 28 | 32 | <nav class="nav"> |
| 29 | 33 | <a href="?">Home</a> |
| 30 | <?php if ($currentRepo): | |
| 31 | $safeName = urlencode($currentRepo['safe_name']); ?> | |
| 34 | <?php if( $currentRepo ): | |
| 35 | $safeName = urlencode( $currentRepo['safe_name'] ); ?> | |
| 32 | 36 | <a href="?repo=<?php echo $safeName; ?>">Files</a> |
| 33 | 37 | <a href="?action=commits&repo=<?php echo $safeName; ?>">Commits</a> |
| 34 | 38 | <a href="?action=refs&repo=<?php echo $safeName; ?>">Branches</a> |
| 35 | 39 | <a href="?action=tags&repo=<?php echo $safeName; ?>">Tags</a> |
| 36 | 40 | <?php endif; ?> |
| 37 | 41 | |
| 38 | <?php if ($currentRepo): ?> | |
| 42 | <?php if( $currentRepo ): ?> | |
| 39 | 43 | <div class="repo-selector"> |
| 40 | 44 | <label>Repository:</label> |
| 41 | 45 | <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)"> |
| 42 | <?php foreach ($this->repositories as $r): ?> | |
| 43 | <option value="<?php echo htmlspecialchars($r['safe_name']); ?>" | |
| 46 | <?php foreach( $this->repositories as $r ): ?> | |
| 47 | <option value="<?php echo htmlspecialchars( $r['safe_name'] ); ?>" | |
| 44 | 48 | <?php echo $r['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>> |
| 45 | <?php echo htmlspecialchars($r['name']); ?> | |
| 49 | <?php echo htmlspecialchars( $r['name'] ); ?> | |
| 46 | 50 | </option> |
| 47 | 51 | <?php endforeach; ?> |
| 48 | 52 | </select> |
| 49 | 53 | </div> |
| 50 | 54 | <?php endif; ?> |
| 51 | 55 | </nav> |
| 52 | 56 | |
| 53 | <?php if ($currentRepo): ?> | |
| 57 | <?php if( $currentRepo ): ?> | |
| 54 | 58 | <div class="repo-info-banner"> |
| 55 | <span class="current-repo">Current: <strong><?php echo htmlspecialchars($currentRepo['name']); ?></strong></span> | |
| 59 | <span class="current-repo"> | |
| 60 | Current: <strong><?php | |
| 61 | echo htmlspecialchars( $currentRepo['name'] ); | |
| 62 | ?></strong> | |
| 63 | </span> | |
| 56 | 64 | </div> |
| 57 | 65 | <?php endif; ?> |
| 58 | 66 | </header> |
| 59 | 67 | |
| 60 | <?php call_user_func($contentCallback); ?> | |
| 68 | <?php call_user_func( $contentCallback ); ?> | |
| 61 | 69 | |
| 62 | 70 | </div> |
| 7 | 7 | |
| 8 | 8 | public function __construct( Git $git, string $subPath ) { |
| 9 | $this->git = $git; | |
| 9 | $this->git = $git; | |
| 10 | 10 | $this->subPath = $subPath; |
| 11 | 11 | } |
| 7 | 7 | private $hash; |
| 8 | 8 | |
| 9 | public function __construct(array $repositories, array $currentRepo, Git $git, string $hash) { | |
| 10 | parent::__construct($repositories); | |
| 9 | public function __construct( | |
| 10 | array $repositories, | |
| 11 | array $currentRepo, | |
| 12 | Git $git, | |
| 13 | string $hash | |
| 14 | ) { | |
| 15 | parent::__construct( $repositories ); | |
| 11 | 16 | $this->currentRepo = $currentRepo; |
| 12 | 17 | $this->git = $git; |
| 13 | 18 | $this->hash = $hash; |
| 14 | 19 | $this->title = $currentRepo['name']; |
| 15 | 20 | } |
| 16 | 21 | |
| 17 | 22 | public function render() { |
| 18 | $this->renderLayout(function() { | |
| 19 | // Use local private $git | |
| 23 | $this->renderLayout( function() { | |
| 20 | 24 | $main = $this->git->getMainBranch(); |
| 21 | 25 | |
| 22 | if (!$main) { | |
| 23 | echo '<div class="empty-state"><h3>No branches</h3><p>Empty repository.</p></div>'; | |
| 26 | if( !$main ) { | |
| 27 | echo '<div class="empty-state"><h3>No branches</h3>' . | |
| 28 | '<p>Empty repository.</p></div>'; | |
| 24 | 29 | return; |
| 25 | 30 | } |
| 26 | 31 | |
| 27 | 32 | $this->renderBreadcrumbs(); |
| 28 | echo '<h2>Commit History <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>'; | |
| 33 | ||
| 34 | echo '<h2>Commit History <span class="branch-badge">' . | |
| 35 | htmlspecialchars( $main['name'] ) . '</span></h2>'; | |
| 29 | 36 | echo '<div class="commit-list">'; |
| 30 | 37 | |
| 31 | 38 | $start = $this->hash ?: $main['hash']; |
| 32 | $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']); | |
| 39 | $repoParam = '&repo=' . urlencode( $this->currentRepo['safe_name'] ); | |
| 33 | 40 | |
| 34 | $this->git->history($start, 100, function($commit) use ($repoParam) { | |
| 35 | $msg = htmlspecialchars(explode("\n", $commit->message)[0]); | |
| 41 | $this->git->history( $start, 100, function( $commit ) use ( $repoParam ) { | |
| 42 | $msg = htmlspecialchars( explode( "\n", $commit->message )[0] ); | |
| 43 | ||
| 36 | 44 | echo '<div class="commit-row">'; |
| 37 | echo '<a href="?action=commit&hash=' . $commit->sha . $repoParam . '" class="sha">' . substr($commit->sha, 0, 7) . '</a>'; | |
| 45 | echo '<a href="?action=commit&hash=' . $commit->sha . $repoParam . | |
| 46 | '" class="sha">' . substr( $commit->sha, 0, 7 ) . '</a>'; | |
| 38 | 47 | echo '<span class="message">' . $msg . '</span>'; |
| 39 | echo '<span class="meta">' . htmlspecialchars($commit->author) . ' • ' . date('Y-m-d', $commit->date) . '</span>'; | |
| 48 | echo '<span class="meta">' . htmlspecialchars( $commit->author ) . | |
| 49 | ' • ' . date( 'Y-m-d', $commit->date ) . '</span>'; | |
| 40 | 50 | echo '</div>'; |
| 41 | }); | |
| 51 | } ); | |
| 52 | ||
| 42 | 53 | echo '</div>'; |
| 43 | }, $this->currentRepo); | |
| 54 | }, $this->currentRepo ); | |
| 44 | 55 | } |
| 45 | 56 | |
| 46 | 57 | private function renderBreadcrumbs() { |
| 47 | 58 | $repoUrl = '?repo=' . urlencode( $this->currentRepo['safe_name'] ); |
| 48 | ||
| 49 | 59 | $crumbs = [ |
| 50 | 60 | '<a href="?">Repositories</a>', |
| 51 | '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>', | |
| 61 | '<a href="' . $repoUrl . '">' . | |
| 62 | htmlspecialchars( $this->currentRepo['name'] ) . '</a>', | |
| 52 | 63 | 'Commits' |
| 53 | 64 | ]; |
| 54 | 65 | |
| 55 | echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>'; | |
| 66 | echo '<div class="breadcrumb">' . implode( ' / ', $crumbs ) . '</div>'; | |
| 56 | 67 | } |
| 57 | 68 | } |
| 8 | 8 | private $hash; |
| 9 | 9 | |
| 10 | public function __construct(array $repositories, array $currentRepo, Git $git, string $hash) { | |
| 11 | parent::__construct($repositories); | |
| 10 | public function __construct( | |
| 11 | array $repositories, | |
| 12 | array $currentRepo, | |
| 13 | Git $git, | |
| 14 | string $hash | |
| 15 | ) { | |
| 16 | parent::__construct( $repositories ); | |
| 12 | 17 | $this->currentRepo = $currentRepo; |
| 13 | 18 | $this->git = $git; |
| 14 | 19 | $this->hash = $hash; |
| 15 | $this->title = substr($hash, 0, 7); | |
| 20 | $this->title = substr( $hash, 0, 7 ); | |
| 16 | 21 | } |
| 17 | 22 | |
| 18 | 23 | public function render() { |
| 19 | $this->renderLayout(function() { | |
| 20 | $commitData = $this->git->read($this->hash); | |
| 21 | $diffEngine = new GitDiff($this->git); | |
| 22 | ||
| 23 | $lines = explode("\n", $commitData); | |
| 24 | $this->renderLayout( function() { | |
| 25 | $commitData = $this->git->read( $this->hash ); | |
| 26 | $diffEngine = new GitDiff( $this->git ); | |
| 27 | $lines = explode( "\n", $commitData ); | |
| 24 | 28 | $msg = ''; |
| 25 | 29 | $isMsg = false; |
| 26 | 30 | $headers = []; |
| 27 | foreach ($lines as $line) { | |
| 28 | if ($line === '') { $isMsg = true; continue; } | |
| 29 | if ($isMsg) { $msg .= $line . "\n"; } | |
| 30 | else { | |
| 31 | if (preg_match('/^(\w+) (.*)$/', $line, $m)) $headers[$m[1]] = $m[2]; | |
| 31 | ||
| 32 | foreach( $lines as $line ) { | |
| 33 | if( $line === '' ) { | |
| 34 | $isMsg = true; | |
| 35 | continue; | |
| 36 | } | |
| 37 | ||
| 38 | if( $isMsg ) { | |
| 39 | $msg .= $line . "\n"; | |
| 40 | } else { | |
| 41 | if( preg_match( '/^(\w+) (.*)$/', $line, $m ) ) { | |
| 42 | $headers[$m[1]] = $m[2]; | |
| 43 | } | |
| 32 | 44 | } |
| 33 | 45 | } |
| 34 | 46 | |
| 35 | $changes = $diffEngine->compare($this->hash); | |
| 47 | $changes = $diffEngine->compare( $this->hash ); | |
| 36 | 48 | |
| 37 | 49 | $this->renderBreadcrumbs(); |
| 38 | 50 | |
| 39 | // Fix 1: Redact email address | |
| 40 | 51 | $author = $headers['author'] ?? 'Unknown'; |
| 41 | $author = preg_replace('/<[^>]+>/', '<email>', $author); | |
| 52 | $author = preg_replace( '/<[^>]+>/', '<email>', $author ); | |
| 42 | 53 | |
| 43 | 54 | echo '<div class="commit-details">'; |
| 44 | 55 | echo '<div class="commit-header">'; |
| 45 | echo '<h1 class="commit-title">' . htmlspecialchars(trim($msg)) . '</h1>'; | |
| 56 | echo '<h1 class="commit-title">' . htmlspecialchars( trim( $msg ) ) . '</h1>'; | |
| 46 | 57 | echo '<div class="commit-info">'; |
| 47 | echo '<div class="commit-info-row"><span class="commit-info-label">Author</span><span class="commit-author">' . htmlspecialchars($author) . '</span></div>'; | |
| 48 | echo '<div class="commit-info-row"><span class="commit-info-label">Commit</span><span class="commit-info-value">' . $this->hash . '</span></div>'; | |
| 58 | echo '<div class="commit-info-row"><span class="commit-info-label">Author</span>' . | |
| 59 | '<span class="commit-author">' . htmlspecialchars( $author ) . '</span></div>'; | |
| 60 | echo '<div class="commit-info-row"><span class="commit-info-label">Commit</span>' . | |
| 61 | '<span class="commit-info-value">' . $this->hash . '</span></div>'; | |
| 49 | 62 | |
| 50 | if (isset($headers['parent'])) { | |
| 51 | // Fix 2: Use '&' instead of '?' because parameters (action & hash) already exist | |
| 52 | $repoUrl = '&repo=' . urlencode($this->currentRepo['safe_name']); | |
| 53 | echo '<div class="commit-info-row"><span class="commit-info-label">Parent</span><span class="commit-info-value">'; | |
| 54 | echo '<a href="?action=commit&hash=' . $headers['parent'] . $repoUrl . '" class="parent-link">' . substr($headers['parent'], 0, 7) . '</a>'; | |
| 55 | echo '</span></div>'; | |
| 63 | if( isset( $headers['parent'] ) ) { | |
| 64 | $repoUrl = '&repo=' . urlencode( $this->currentRepo['safe_name'] ); | |
| 65 | ||
| 66 | echo '<div class="commit-info-row"><span class="commit-info-label">Parent</span>' . | |
| 67 | '<span class="commit-info-value">'; | |
| 68 | echo '<a href="?action=commit&hash=' . $headers['parent'] . $repoUrl . | |
| 69 | '" class="parent-link">' . substr( $headers['parent'], 0, 7 ) . '</a>'; | |
| 70 | echo '</span></div>'; | |
| 56 | 71 | } |
| 72 | ||
| 57 | 73 | echo '</div></div></div>'; |
| 58 | 74 | |
| 59 | 75 | echo '<div class="diff-container">'; |
| 60 | foreach ($changes as $change) { | |
| 61 | $this->renderFileDiff($change); | |
| 76 | ||
| 77 | foreach( $changes as $change ) { | |
| 78 | $this->renderFileDiff( $change ); | |
| 62 | 79 | } |
| 63 | if (empty($changes)) { | |
| 64 | echo '<div class="empty-state"><p>No changes detected.</p></div>'; | |
| 80 | ||
| 81 | if( empty( $changes ) ) { | |
| 82 | echo '<div class="empty-state"><p>No changes detected.</p></div>'; | |
| 65 | 83 | } |
| 66 | echo '</div>'; | |
| 67 | 84 | |
| 68 | }, $this->currentRepo); | |
| 85 | echo '</div>'; | |
| 86 | }, $this->currentRepo ); | |
| 69 | 87 | } |
| 70 | 88 | |
| 71 | private function renderFileDiff($change) { | |
| 89 | private function renderFileDiff( $change ) { | |
| 72 | 90 | $statusIcon = 'fa-file'; |
| 73 | 91 | $statusClass = ''; |
| 74 | 92 | |
| 75 | if ($change['type'] === 'A') { $statusIcon = 'fa-plus-circle'; $statusClass = 'status-add'; } | |
| 76 | if ($change['type'] === 'D') { $statusIcon = 'fa-minus-circle'; $statusClass = 'status-del'; } | |
| 77 | if ($change['type'] === 'M') { $statusIcon = 'fa-pencil-alt'; $statusClass = 'status-mod'; } | |
| 93 | if( $change['type'] === 'A' ) { | |
| 94 | $statusIcon = 'fa-plus-circle'; | |
| 95 | $statusClass = 'status-add'; | |
| 96 | } | |
| 97 | ||
| 98 | if( $change['type'] === 'D' ) { | |
| 99 | $statusIcon = 'fa-minus-circle'; | |
| 100 | $statusClass = 'status-del'; | |
| 101 | } | |
| 102 | ||
| 103 | if( $change['type'] === 'M' ) { | |
| 104 | $statusIcon = 'fa-pencil-alt'; | |
| 105 | $statusClass = 'status-mod'; | |
| 106 | } | |
| 78 | 107 | |
| 79 | 108 | echo '<div class="diff-file">'; |
| 80 | 109 | echo '<div class="diff-header">'; |
| 81 | echo '<span class="diff-status ' . $statusClass . '"><i class="fa ' . $statusIcon . '"></i></span>'; | |
| 82 | echo '<span class="diff-path">' . htmlspecialchars($change['path']) . '</span>'; | |
| 110 | echo '<span class="diff-status ' . $statusClass . '">' . | |
| 111 | '<i class="fa ' . $statusIcon . '"></i></span>'; | |
| 112 | echo '<span class="diff-path">' . htmlspecialchars( $change['path'] ) . '</span>'; | |
| 83 | 113 | echo '</div>'; |
| 84 | 114 | |
| 85 | if ($change['is_binary']) { | |
| 86 | echo '<div class="diff-binary">Binary files differ</div>'; | |
| 115 | if( $change['is_binary'] ) { | |
| 116 | echo '<div class="diff-binary">Binary files differ</div>'; | |
| 87 | 117 | } else { |
| 88 | 118 | echo '<div class="diff-content">'; |
| 89 | 119 | echo '<table><tbody>'; |
| 90 | 120 | |
| 91 | foreach ($change['hunks'] as $line) { | |
| 92 | if (isset($line['t']) && $line['t'] === 'gap') { | |
| 93 | echo '<tr class="diff-gap"><td colspan="3">...</td></tr>'; | |
| 94 | continue; | |
| 95 | } | |
| 121 | foreach( $change['hunks'] as $line ) { | |
| 122 | if( isset( $line['t'] ) && $line['t'] === 'gap' ) { | |
| 123 | echo '<tr class="diff-gap"><td colspan="3">...</td></tr>'; | |
| 124 | continue; | |
| 125 | } | |
| 96 | 126 | |
| 97 | $class = 'diff-ctx'; | |
| 98 | $char = ' '; | |
| 99 | if ($line['t'] === '+') { $class = 'diff-add'; $char = '+'; } | |
| 100 | if ($line['t'] === '-') { $class = 'diff-del'; $char = '-'; } | |
| 127 | $class = 'diff-ctx'; | |
| 128 | $char = ' '; | |
| 101 | 129 | |
| 102 | echo '<tr class="' . $class . '">'; | |
| 103 | echo '<td class="diff-num" data-num="' . $line['no'] . '"></td>'; | |
| 104 | echo '<td class="diff-num" data-num="' . $line['nn'] . '"></td>'; | |
| 105 | echo '<td class="diff-code"><span class="diff-marker">' . $char . '</span>' . htmlspecialchars($line['l']) . '</td>'; | |
| 106 | echo '</tr>'; | |
| 130 | if( $line['t'] === '+' ) { | |
| 131 | $class = 'diff-add'; | |
| 132 | $char = '+'; | |
| 133 | } | |
| 134 | ||
| 135 | if( $line['t'] === '-' ) { | |
| 136 | $class = 'diff-del'; | |
| 137 | $char = '-'; | |
| 138 | } | |
| 139 | ||
| 140 | echo '<tr class="' . $class . '">'; | |
| 141 | echo '<td class="diff-num" data-num="' . $line['no'] . '"></td>'; | |
| 142 | echo '<td class="diff-num" data-num="' . $line['nn'] . '"></td>'; | |
| 143 | echo '<td class="diff-code"><span class="diff-marker">' . $char . '</span>' . | |
| 144 | htmlspecialchars( $line['l'] ) . '</td>'; | |
| 145 | echo '</tr>'; | |
| 107 | 146 | } |
| 108 | 147 | |
| 109 | 148 | echo '</tbody></table>'; |
| 110 | 149 | echo '</div>'; |
| 111 | 150 | } |
| 151 | ||
| 112 | 152 | echo '</div>'; |
| 113 | 153 | } |
| 114 | 154 | |
| 115 | 155 | private function renderBreadcrumbs() { |
| 116 | $safeName = urlencode($this->currentRepo['safe_name']); | |
| 117 | ||
| 156 | $safeName = urlencode( $this->currentRepo['safe_name'] ); | |
| 118 | 157 | $crumbs = [ |
| 119 | 158 | '<a href="?">Repositories</a>', |
| 120 | '<a href="?repo=' . $safeName . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>', | |
| 121 | // Fix 3: Use '&' separator for the repo parameter | |
| 159 | '<a href="?repo=' . $safeName . '">' . | |
| 160 | htmlspecialchars( $this->currentRepo['name'] ) . '</a>', | |
| 122 | 161 | '<a href="?action=commits&repo=' . $safeName . '">Commits</a>', |
| 123 | substr($this->hash, 0, 7) | |
| 162 | substr( $this->hash, 0, 7 ) | |
| 124 | 163 | ]; |
| 125 | echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>'; | |
| 164 | ||
| 165 | echo '<div class="breadcrumb">' . implode( ' / ', $crumbs ) . '</div>'; | |
| 126 | 166 | } |
| 127 | 167 | } |
| 1 | 1 | <?php |
| 2 | 2 | require_once __DIR__ . '/BasePage.php'; |
| 3 | require_once __DIR__ . '/../render/HtmlFileRenderer.php'; | |
| 3 | 4 | |
| 4 | 5 | class FilePage extends BasePage { |
| 5 | 6 | private $currentRepo; |
| 6 | 7 | private $git; |
| 7 | 8 | private $hash; |
| 8 | ||
| 9 | public function __construct(array $repositories, array $currentRepo, Git $git, string $hash = '') { | |
| 10 | parent::__construct($repositories); | |
| 11 | 9 | |
| 10 | public function __construct( | |
| 11 | array $repositories, | |
| 12 | array $currentRepo, | |
| 13 | Git $git, | |
| 14 | string $hash = '' | |
| 15 | ) { | |
| 16 | parent::__construct( $repositories ); | |
| 12 | 17 | $this->currentRepo = $currentRepo; |
| 13 | $this->git = $git; | |
| 14 | $this->hash = $hash; | |
| 15 | $this->title = $currentRepo['name']; | |
| 18 | $this->git = $git; | |
| 19 | $this->hash = $hash; | |
| 20 | $this->title = $currentRepo['name']; | |
| 16 | 21 | } |
| 17 | 22 | |
| 18 | 23 | public function render() { |
| 19 | $this->renderLayout(function() { | |
| 24 | $this->renderLayout( function() { | |
| 20 | 25 | $main = $this->git->getMainBranch(); |
| 21 | 26 | |
| 22 | if (!$main) { | |
| 27 | if( !$main ) { | |
| 23 | 28 | echo '<div class="empty-state"><h3>No branches</h3></div>'; |
| 24 | 29 | } else { |
| 25 | $target = $this->hash ?: $main['hash']; | |
| 30 | $target = $this->hash ?: $main['hash']; | |
| 26 | 31 | $entries = []; |
| 27 | 32 | |
| 28 | $this->git->walk($target, function($file) use (&$entries) { | |
| 33 | $this->git->walk( $target, function( $file ) use ( &$entries ) { | |
| 29 | 34 | $entries[] = $file; |
| 30 | }); | |
| 35 | } ); | |
| 31 | 36 | |
| 32 | if (!empty($entries)) { | |
| 33 | $this->renderTree($main, $target, $entries); | |
| 37 | if( !empty( $entries ) ) { | |
| 38 | $this->renderTree( $main, $target, $entries ); | |
| 34 | 39 | } else { |
| 35 | $this->renderBlob($target); | |
| 40 | $this->renderBlob( $target ); | |
| 36 | 41 | } |
| 37 | 42 | } |
| 38 | }, $this->currentRepo); | |
| 43 | }, $this->currentRepo ); | |
| 39 | 44 | } |
| 40 | 45 | |
| 41 | private function renderTree($main, $targetHash, $entries) { | |
| 46 | private function renderTree( $main, $targetHash, $entries ) { | |
| 42 | 47 | $path = $_GET['name'] ?? ''; |
| 43 | 48 | |
| 44 | $this->renderBreadcrumbs($targetHash, 'Tree'); | |
| 49 | $this->renderBreadcrumbs( $targetHash, 'Tree' ); | |
| 45 | 50 | |
| 46 | echo '<h2>' . htmlspecialchars($this->currentRepo['name']) . | |
| 51 | echo '<h2>' . htmlspecialchars( $this->currentRepo['name'] ) . | |
| 47 | 52 | ' <span class="branch-badge">' . |
| 48 | htmlspecialchars($main['name']) . '</span></h2>'; | |
| 53 | htmlspecialchars( $main['name'] ) . '</span></h2>'; | |
| 49 | 54 | |
| 50 | usort($entries, function($a, $b) { | |
| 51 | return $a->compare($b); | |
| 52 | }); | |
| 55 | usort( $entries, function( $a, $b ) { | |
| 56 | return $a->compare( $b ); | |
| 57 | } ); | |
| 53 | 58 | |
| 54 | 59 | echo '<div class="file-list">'; |
| 55 | $renderer = new HtmlFileRenderer($this->currentRepo['safe_name'], $path); | |
| 60 | $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'], $path ); | |
| 56 | 61 | |
| 57 | foreach ($entries as $file) { | |
| 58 | $file->render($renderer); | |
| 62 | foreach( $entries as $file ) { | |
| 63 | $file->renderListEntry( $renderer ); | |
| 59 | 64 | } |
| 60 | 65 | |
| 61 | 66 | echo '</div>'; |
| 62 | 67 | } |
| 63 | ||
| 64 | private function renderBlob($targetHash) { | |
| 65 | $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']); | |
| 66 | $filename = $_GET['name'] ?? ''; | |
| 67 | $file = $this->git->readFile($targetHash, $filename); | |
| 68 | $size = $this->git->getObjectSize($targetHash); | |
| 69 | 68 | |
| 70 | $renderer = new HtmlFileRenderer($this->currentRepo['safe_name']); | |
| 69 | private function renderBlob( $targetHash ) { | |
| 70 | $repoParam = '&repo=' . urlencode( $this->currentRepo['safe_name'] ); | |
| 71 | $filename = $_GET['name'] ?? ''; | |
| 72 | $file = $this->git->readFile( $targetHash, $filename ); | |
| 73 | $size = $this->git->getObjectSize( $targetHash ); | |
| 74 | $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'] ); | |
| 71 | 75 | |
| 72 | $this->renderBreadcrumbs($targetHash, 'File'); | |
| 76 | $this->renderBreadcrumbs( $targetHash, 'File' ); | |
| 73 | 77 | |
| 74 | if ($size === 0) { | |
| 75 | $this->renderDownloadState($targetHash, "This file is empty."); | |
| 78 | if( $size === 0 ) { | |
| 79 | $this->renderDownloadState( $targetHash, "This file is empty." ); | |
| 76 | 80 | } else { |
| 77 | $rawUrl = '?action=raw&hash=' . $targetHash . $repoParam . '&name=' . urlencode($filename); | |
| 81 | $rawUrl = '?action=raw&hash=' . $targetHash . $repoParam . | |
| 82 | '&name=' . urlencode( $filename ); | |
| 78 | 83 | |
| 79 | if (!$file->renderMedia($rawUrl)) { | |
| 80 | if ($file->isText()) { | |
| 81 | if ($size > 524288) { | |
| 84 | if( !$file->renderMedia( $renderer, $rawUrl ) ) { | |
| 85 | if( $file->isText() ) { | |
| 86 | if( $size > 524288 ) { | |
| 82 | 87 | ob_start(); |
| 83 | $file->renderSize($renderer); | |
| 88 | $file->renderSize( $renderer ); | |
| 84 | 89 | $sizeStr = ob_get_clean(); |
| 85 | $this->renderDownloadState($targetHash, "File is too large to display ($sizeStr)."); | |
| 90 | ||
| 91 | $this->renderDownloadState( | |
| 92 | $targetHash, | |
| 93 | "File is too large to display ($sizeStr)." | |
| 94 | ); | |
| 86 | 95 | } else { |
| 87 | 96 | $content = ''; |
| 88 | $this->git->stream($targetHash, function($d) use (&$content) { $content .= $d; }); | |
| 89 | echo '<div class="blob-content"><pre class="blob-code">' . htmlspecialchars($content) . '</pre></div>'; | |
| 97 | ||
| 98 | $this->git->stream( $targetHash, function( $d ) use ( &$content ) { | |
| 99 | $content .= $d; | |
| 100 | } ); | |
| 101 | ||
| 102 | echo '<div class="blob-content"><pre class="blob-code">' . | |
| 103 | $file->highlight( $renderer, $content ) . '</pre></div>'; | |
| 90 | 104 | } |
| 91 | 105 | } else { |
| 92 | $this->renderDownloadState($targetHash, "This is a binary file."); | |
| 106 | $this->renderDownloadState( $targetHash, "This is a binary file." ); | |
| 93 | 107 | } |
| 94 | 108 | } |
| 95 | 109 | } |
| 96 | 110 | } |
| 97 | 111 | |
| 98 | private function renderDownloadState($hash, $reason) { | |
| 112 | private function renderDownloadState( $hash, $reason ) { | |
| 99 | 113 | $filename = $_GET['name'] ?? ''; |
| 100 | $url = '?action=raw&hash=' . $hash . '&repo=' . urlencode($this->currentRepo['safe_name']) . '&name=' . urlencode($filename); | |
| 114 | $url = '?action=raw&hash=' . $hash . '&repo=' . | |
| 115 | urlencode( $this->currentRepo['safe_name'] ) . | |
| 116 | '&name=' . urlencode( $filename ); | |
| 101 | 117 | |
| 102 | 118 | echo '<div class="empty-state download-state">'; |
| 103 | echo '<p>' . htmlspecialchars($reason) . '</p>'; | |
| 104 | echo '<a href="' . $url . '" class="btn-download">Download Raw File</a>'; | |
| 119 | echo '<p>' . htmlspecialchars( $reason ) . '</p>'; | |
| 120 | echo '<a href="' . $url . '" class="btn-download">Download Raw File</a>'; | |
| 105 | 121 | echo '</div>'; |
| 106 | 122 | } |
| 107 | ||
| 108 | private function renderBreadcrumbs($hash, $type) { | |
| 109 | $repoUrl = '?repo=' . urlencode($this->currentRepo['safe_name']); | |
| 110 | $path = $_GET['name'] ?? ''; | |
| 111 | 123 | |
| 124 | private function renderBreadcrumbs( $hash, $type ) { | |
| 125 | $repoUrl = '?repo=' . urlencode( $this->currentRepo['safe_name'] ); | |
| 126 | $path = $_GET['name'] ?? ''; | |
| 112 | 127 | $crumbs = [ |
| 113 | 128 | '<a href="?">Repositories</a>', |
| 114 | '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>' | |
| 129 | '<a href="' . $repoUrl . '">' . | |
| 130 | htmlspecialchars( $this->currentRepo['name'] ) . '</a>' | |
| 115 | 131 | ]; |
| 116 | 132 | |
| 117 | if ($path) { | |
| 118 | $parts = explode('/', trim($path, '/')); | |
| 119 | $acc = ''; | |
| 120 | foreach ($parts as $idx => $part) { | |
| 133 | if( $path ) { | |
| 134 | $parts = explode( '/', trim( $path, '/' ) ); | |
| 135 | $acc = ''; | |
| 136 | ||
| 137 | foreach( $parts as $idx => $part ) { | |
| 121 | 138 | $acc .= ($idx === 0 ? '' : '/') . $part; |
| 122 | if ($idx === count($parts) - 1) { | |
| 123 | $crumbs[] = htmlspecialchars($part); | |
| 139 | ||
| 140 | if( $idx === count( $parts ) - 1 ) { | |
| 141 | $crumbs[] = htmlspecialchars( $part ); | |
| 124 | 142 | } else { |
| 125 | $crumbs[] = '<a href="' . $repoUrl . '&name=' . urlencode($acc) . '">' . | |
| 126 | htmlspecialchars($part) . '</a>'; | |
| 143 | $crumbs[] = '<a href="' . $repoUrl . '&name=' . urlencode( $acc ) . '">' . | |
| 144 | htmlspecialchars( $part ) . '</a>'; | |
| 127 | 145 | } |
| 128 | 146 | } |
| 129 | } elseif ($this->hash) { | |
| 130 | $crumbs[] = $type . ' ' . substr($hash, 0, 7); | |
| 147 | } elseif( $this->hash ) { | |
| 148 | $crumbs[] = $type . ' ' . substr( $hash, 0, 7 ); | |
| 131 | 149 | } |
| 132 | 150 | |
| 133 | echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>'; | |
| 151 | echo '<div class="breadcrumb">' . implode( ' / ', $crumbs ) . '</div>'; | |
| 134 | 152 | } |
| 135 | 153 | } |
| 5 | 5 | private $git; |
| 6 | 6 | |
| 7 | public function __construct(array $repositories, Git $git) { | |
| 8 | parent::__construct($repositories); | |
| 7 | public function __construct( array $repositories, Git $git ) { | |
| 8 | parent::__construct( $repositories ); | |
| 9 | 9 | $this->git = $git; |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | 12 | public function render() { |
| 13 | $this->renderLayout(function() { | |
| 13 | $this->renderLayout( function() { | |
| 14 | 14 | echo '<h2>Repositories</h2>'; |
| 15 | if (empty($this->repositories)) { | |
| 15 | ||
| 16 | if( empty( $this->repositories ) ) { | |
| 16 | 17 | echo '<div class="empty-state">No repositories found.</div>'; |
| 17 | 18 | return; |
| 18 | 19 | } |
| 20 | ||
| 19 | 21 | echo '<div class="repo-grid">'; |
| 20 | foreach ($this->repositories as $repo) { | |
| 21 | $this->renderRepoCard($repo); | |
| 22 | ||
| 23 | foreach( $this->repositories as $repo ) { | |
| 24 | $this->renderRepoCard( $repo ); | |
| 22 | 25 | } |
| 26 | ||
| 23 | 27 | echo '</div>'; |
| 24 | }); | |
| 28 | } ); | |
| 25 | 29 | } |
| 26 | 30 | |
| 27 | private function renderRepoCard($repo) { | |
| 28 | $this->git->setRepository($repo['path']); | |
| 31 | private function renderRepoCard( $repo ) { | |
| 32 | $this->git->setRepository( $repo['path'] ); | |
| 29 | 33 | |
| 30 | 34 | $main = $this->git->getMainBranch(); |
| 35 | $stats = [ | |
| 36 | 'branches' => 0, | |
| 37 | 'tags' => 0 | |
| 38 | ]; | |
| 31 | 39 | |
| 32 | $stats = ['branches' => 0, 'tags' => 0]; | |
| 33 | $this->git->eachBranch(function() use (&$stats) { $stats['branches']++; }); | |
| 34 | $this->git->eachTag(function() use (&$stats) { $stats['tags']++; }); | |
| 40 | $this->git->eachBranch( function() use ( &$stats ) { | |
| 41 | $stats['branches']++; | |
| 42 | } ); | |
| 35 | 43 | |
| 36 | echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; | |
| 37 | echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; | |
| 44 | $this->git->eachTag( function() use ( &$stats ) { | |
| 45 | $stats['tags']++; | |
| 46 | } ); | |
| 38 | 47 | |
| 48 | echo '<a href="?repo=' . urlencode( $repo['safe_name'] ) . '" class="repo-card">'; | |
| 49 | echo '<h3>' . htmlspecialchars( $repo['name'] ) . '</h3>'; | |
| 39 | 50 | echo '<p class="repo-meta">'; |
| 40 | 51 | |
| 41 | 52 | $branchLabel = $stats['branches'] === 1 ? 'branch' : 'branches'; |
| 42 | 53 | $tagLabel = $stats['tags'] === 1 ? 'tag' : 'tags'; |
| 43 | 54 | |
| 44 | echo $stats['branches'] . ' ' . $branchLabel . ', ' . $stats['tags'] . ' ' . $tagLabel; | |
| 55 | echo $stats['branches'] . ' ' . $branchLabel . ', ' . | |
| 56 | $stats['tags'] . ' ' . $tagLabel; | |
| 45 | 57 | |
| 46 | if ($main) { | |
| 58 | if( $main ) { | |
| 47 | 59 | echo ', '; |
| 48 | $this->git->history('HEAD', 1, function($c) use ($repo) { | |
| 49 | $renderer = new HtmlFileRenderer($repo['safe_name']); | |
| 50 | $renderer->renderTime($c->date); | |
| 51 | }); | |
| 60 | ||
| 61 | $this->git->history( 'HEAD', 1, function( $c ) use ( $repo ) { | |
| 62 | $renderer = new HtmlFileRenderer( $repo['safe_name'] ); | |
| 63 | $renderer->renderTime( $c->date ); | |
| 64 | } ); | |
| 52 | 65 | } |
| 66 | ||
| 53 | 67 | echo '</p>'; |
| 54 | 68 | |
| 55 | 69 | $descPath = $repo['path'] . '/description'; |
| 56 | if (file_exists($descPath)) { | |
| 57 | $description = trim(file_get_contents($descPath)); | |
| 58 | if ($description !== '') { | |
| 59 | echo '<p style="margin-top: 1.5em;">' . htmlspecialchars($description) . '</p>'; | |
| 70 | ||
| 71 | if( file_exists( $descPath ) ) { | |
| 72 | $description = trim( file_get_contents( $descPath ) ); | |
| 73 | ||
| 74 | if( $description !== '' ) { | |
| 75 | echo '<p style="margin-top: 1.5em;">' . | |
| 76 | htmlspecialchars( $description ) . '</p>'; | |
| 60 | 77 | } |
| 61 | 78 | } |
| 6 | 6 | private $hash; |
| 7 | 7 | |
| 8 | public function __construct($git, $hash) { | |
| 8 | public function __construct( $git, $hash ) { | |
| 9 | 9 | $this->git = $git; |
| 10 | 10 | $this->hash = $hash; |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | 13 | public function render() { |
| 14 | $filename = basename($_GET['name'] ?? '') ?: 'file'; | |
| 15 | $file = $this->git->readFile($this->hash, $filename); | |
| 14 | $filename = basename( $_GET['name'] ?? '' ) ?: 'file'; | |
| 15 | $file = $this->git->readFile( $this->hash, $filename ); | |
| 16 | 16 | |
| 17 | while (ob_get_level()) { | |
| 17 | while( ob_get_level() ) { | |
| 18 | 18 | ob_end_clean(); |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | 21 | $file->emitRawHeaders(); |
| 22 | ||
| 23 | $this->git->stream($this->hash, function($d) { | |
| 22 | $this->git->stream( $this->hash, function( $d ) { | |
| 24 | 23 | echo $d; |
| 25 | }); | |
| 24 | } ); | |
| 26 | 25 | |
| 27 | 26 | exit; |
| 7 | 7 | private $git; |
| 8 | 8 | |
| 9 | public function __construct(array $repositories, array $currentRepo, Git $git) { | |
| 10 | parent::__construct($repositories); | |
| 9 | public function __construct( | |
| 10 | array $repositories, | |
| 11 | array $currentRepo, | |
| 12 | Git $git | |
| 13 | ) { | |
| 14 | parent::__construct( $repositories ); | |
| 11 | 15 | $this->currentRepo = $currentRepo; |
| 12 | 16 | $this->git = $git; |
| 13 | 17 | $this->title = $currentRepo['name'] . ' - Tags'; |
| 14 | 18 | } |
| 15 | 19 | |
| 16 | 20 | public function render() { |
| 17 | $this->renderLayout(function() { | |
| 21 | $this->renderLayout( function() { | |
| 18 | 22 | $this->renderBreadcrumbs(); |
| 19 | 23 | |
| ... | ||
| 32 | 36 | |
| 33 | 37 | $tags = []; |
| 34 | $this->git->eachTag(function(Tag $tag) use (&$tags) { | |
| 38 | ||
| 39 | $this->git->eachTag( function( Tag $tag ) use ( &$tags ) { | |
| 35 | 40 | $tags[] = $tag; |
| 36 | }); | |
| 41 | } ); | |
| 37 | 42 | |
| 38 | usort($tags, function(Tag $a, Tag $b) { | |
| 39 | return $a->compare($b); | |
| 40 | }); | |
| 43 | usort( $tags, function( Tag $a, Tag $b ) { | |
| 44 | return $a->compare( $b ); | |
| 45 | } ); | |
| 41 | 46 | |
| 42 | $renderer = new HtmlTagRenderer($this->currentRepo['safe_name']); | |
| 47 | $renderer = new HtmlTagRenderer( $this->currentRepo['safe_name'] ); | |
| 43 | 48 | |
| 44 | if (empty($tags)) { | |
| 45 | echo '<tr><td colspan="5"><div class="empty-state"><p>No tags found.</p></div></td></tr>'; | |
| 49 | if( empty( $tags ) ) { | |
| 50 | echo '<tr><td colspan="5"><div class="empty-state">' . | |
| 51 | '<p>No tags found.</p></div></td></tr>'; | |
| 46 | 52 | } else { |
| 47 | foreach ($tags as $tag) { | |
| 48 | $tag->render($renderer); | |
| 53 | foreach( $tags as $tag ) { | |
| 54 | $tag->render( $renderer ); | |
| 49 | 55 | } |
| 50 | 56 | } |
| 51 | 57 | |
| 52 | 58 | echo '</tbody>'; |
| 53 | 59 | echo '</table>'; |
| 54 | }, $this->currentRepo); | |
| 60 | }, $this->currentRepo ); | |
| 55 | 61 | } |
| 56 | 62 | |
| 57 | 63 | private function renderBreadcrumbs() { |
| 58 | $repoUrl = '?repo=' . urlencode($this->currentRepo['safe_name']); | |
| 59 | ||
| 64 | $repoUrl = '?repo=' . urlencode( $this->currentRepo['safe_name'] ); | |
| 60 | 65 | $crumbs = [ |
| 61 | 66 | '<a href="?">Repositories</a>', |
| 62 | '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>', | |
| 67 | '<a href="' . $repoUrl . '">' . | |
| 68 | htmlspecialchars( $this->currentRepo['name'] ) . '</a>', | |
| 63 | 69 | 'Tags' |
| 64 | 70 | ]; |
| 65 | 71 | |
| 66 | echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>'; | |
| 72 | echo '<div class="breadcrumb">' . implode( ' / ', $crumbs ) . '</div>'; | |
| 67 | 73 | } |
| 68 | 74 | } |
| 1 | 1 | <?php |
| 2 | require_once __DIR__ . '/../File.php'; | |
| 3 | ||
| 2 | 4 | interface FileRenderer { |
| 3 | public function renderFile( | |
| 5 | public function renderListEntry( | |
| 4 | 6 | string $name, |
| 5 | 7 | string $sha, |
| 6 | 8 | string $mode, |
| 7 | 9 | string $iconClass, |
| 8 | 10 | int $timestamp, |
| 9 | int $size = 0 | |
| 11 | int $size | |
| 10 | 12 | ): void; |
| 11 | 13 | |
| 12 | public function renderTime( int $timestamp ): void; | |
| 14 | public function renderMedia( | |
| 15 | File $file, | |
| 16 | string $url, | |
| 17 | string $mediaType | |
| 18 | ): bool; | |
| 13 | 19 | |
| 14 | 20 | public function renderSize( int $bytes ): void; |
| 15 | } | |
| 16 | ||
| 17 | class HtmlFileRenderer implements FileRenderer { | |
| 18 | private string $repoSafeName; | |
| 19 | private string $currentPath; | |
| 20 | ||
| 21 | public function __construct( string $repoSafeName, string $currentPath = '' ) { | |
| 22 | $this->repoSafeName = $repoSafeName; | |
| 23 | $this->currentPath = trim( $currentPath, '/' ); | |
| 24 | } | |
| 25 | ||
| 26 | public function renderFile( | |
| 27 | string $name, | |
| 28 | string $sha, | |
| 29 | string $mode, | |
| 30 | string $iconClass, | |
| 31 | int $timestamp, | |
| 32 | int $size = 0 | |
| 33 | ): void { | |
| 34 | $fullPath = ($this->currentPath===''?'':$this->currentPath.'/') . $name; | |
| 35 | $url = '?repo=' . urlencode( $this->repoSafeName ) . '&hash=' . $sha . '&name=' . urlencode( $fullPath ); | |
| 36 | ||
| 37 | echo '<a href="' . $url . '" class="file-item">'; | |
| 38 | echo '<span class="file-mode">' . $mode . '</span>'; | |
| 39 | echo '<span class="file-name">'; | |
| 40 | echo '<i class="fas ' . $iconClass . ' file-icon-container"></i>'; | |
| 41 | echo htmlspecialchars( $name ); | |
| 42 | echo '</span>'; | |
| 43 | ||
| 44 | if( $size > 0 ) { | |
| 45 | echo '<span class="file-size">' . $this->formatSize($size) . '</span>'; | |
| 46 | } | |
| 47 | ||
| 48 | if( $timestamp > 0 ) { | |
| 49 | echo '<span class="file-date">'; | |
| 50 | $this->renderTime( $timestamp ); | |
| 51 | echo '</span>'; | |
| 52 | } | |
| 53 | ||
| 54 | echo '</a>'; | |
| 55 | } | |
| 56 | ||
| 57 | public function renderTime( int $timestamp ): void { | |
| 58 | $tokens = [ | |
| 59 | 31536000 => 'year', | |
| 60 | 2592000 => 'month', | |
| 61 | 604800 => 'week', | |
| 62 | 86400 => 'day', | |
| 63 | 3600 => 'hour', | |
| 64 | 60 => 'minute', | |
| 65 | 1 => 'second' | |
| 66 | ]; | |
| 67 | ||
| 68 | $diff = $timestamp ? time() - $timestamp : null; | |
| 69 | $result = 'never'; | |
| 70 | ||
| 71 | if( $diff && $diff >= 5 ) { | |
| 72 | foreach( $tokens as $unit => $text ) { | |
| 73 | if( $diff < $unit ) { | |
| 74 | continue; | |
| 75 | } | |
| 76 | ||
| 77 | $num = floor( $diff / $unit ); | |
| 78 | $result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago'; | |
| 79 | break; | |
| 80 | } | |
| 81 | } elseif( $diff ) { | |
| 82 | $result = 'just now'; | |
| 83 | } | |
| 84 | ||
| 85 | echo $result; | |
| 86 | } | |
| 87 | ||
| 88 | public function renderSize( int $bytes ): void { | |
| 89 | echo $this->formatSize($bytes); | |
| 90 | } | |
| 91 | ||
| 92 | private function formatSize(int $bytes): string { | |
| 93 | $units = ['B', 'KB', 'MB', 'GB', 'TB']; | |
| 94 | $i = 0; | |
| 95 | 21 | |
| 96 | while ($bytes >= 1024 && $i < count($units) - 1) { | |
| 97 | $bytes /= 1024; | |
| 98 | $i++; | |
| 99 | } | |
| 22 | public function highlight( | |
| 23 | string $filename, | |
| 24 | string $content, | |
| 25 | string $mediaType | |
| 26 | ): string; | |
| 100 | 27 | |
| 101 | return ($bytes === 0 ? 0 : round($bytes)) . ' ' . $units[$i]; | |
| 102 | } | |
| 28 | public function renderTime( int $timestamp ): void; | |
| 103 | 29 | } |
| 104 | 30 |
| 1 | <?php | |
| 2 | require_once __DIR__ . '/LanguageDefinitions.php'; | |
| 3 | ||
| 4 | class Highlighter { | |
| 5 | private string $content; | |
| 6 | private string $lang; | |
| 7 | private array $rules; | |
| 8 | ||
| 9 | public function __construct(string $filename, string $content, string $mediaType) { | |
| 10 | $this->content = $content; | |
| 11 | ||
| 12 | $this->lang = $this->detectLanguage($mediaType, $filename); | |
| 13 | $this->rules = LanguageDefinitions::get($this->lang) ?? []; | |
| 14 | } | |
| 15 | ||
| 16 | public function render(): string { | |
| 17 | if (empty($this->rules)) { | |
| 18 | return htmlspecialchars($this->content); | |
| 19 | } | |
| 20 | ||
| 21 | $patterns = []; | |
| 22 | ||
| 23 | foreach ($this->rules as $name => $pattern) { | |
| 24 | $delim = $pattern[0]; | |
| 25 | $inner = substr($pattern, 1, strrpos($pattern, $delim) - 1); | |
| 26 | $inner = str_replace('~', '\~', $inner); | |
| 27 | ||
| 28 | $patterns[] = "(?P<{$name}>{$inner})"; | |
| 29 | } | |
| 30 | ||
| 31 | if (!in_array($this->lang, ['markdown', 'rmd'])) { | |
| 32 | $patterns[] = "(?P<punctuation>[\\{\\}\\(\\)\\[\\]\\;\\,])"; | |
| 33 | } | |
| 34 | ||
| 35 | $patterns[] = "(?P<any>[\s\S])"; | |
| 36 | $combined = '~' . implode('|', $patterns) . '~msu'; | |
| 37 | ||
| 38 | return preg_replace_callback($combined, function ($matches) { | |
| 39 | foreach ($matches as $key => $value) { | |
| 40 | if (!is_numeric($key) && $value !== '') { | |
| 41 | if ($key === 'any') { | |
| 42 | return htmlspecialchars($value); | |
| 43 | } | |
| 44 | ||
| 45 | if ($key === 'string_interp') { | |
| 46 | return $this->renderInterpolatedString($value); | |
| 47 | } | |
| 48 | ||
| 49 | if ($key === 'math') { | |
| 50 | return $this->renderMath($value); | |
| 51 | } | |
| 52 | ||
| 53 | return '<span class="hl-' . $key . '">' . htmlspecialchars($value) . '</span>'; | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | return htmlspecialchars($matches[0]); | |
| 58 | }, $this->content); | |
| 59 | } | |
| 60 | ||
| 61 | private function renderInterpolatedString(string $content): string { | |
| 62 | $pattern = '/(\$\{[a-zA-Z0-9_]+\}|\$[a-zA-Z0-9_]+)/'; | |
| 63 | $parts = preg_split($pattern, $content, -1, PREG_SPLIT_DELIM_CAPTURE); | |
| 64 | $output = '<span class="hl-string">'; | |
| 65 | ||
| 66 | foreach ($parts as $part) { | |
| 67 | if ($part === '') continue; | |
| 68 | ||
| 69 | if (str_starts_with($part, '${') && str_ends_with($part, '}')) { | |
| 70 | $inner = substr($part, 2, -1); | |
| 71 | $output .= '<span class="hl-interp-punct">${</span>'; | |
| 72 | $output .= '<span class="hl-variable">' . htmlspecialchars($inner) . '</span>'; | |
| 73 | $output .= '<span class="hl-interp-punct">}</span>'; | |
| 74 | } elseif (str_starts_with($part, '$') && strlen($part) > 1) { | |
| 75 | $output .= '<span class="hl-interp-punct">$</span>'; | |
| 76 | $output .= '<span class="hl-variable">' . htmlspecialchars(substr($part, 1)) . '</span>'; | |
| 77 | } else { | |
| 78 | $output .= htmlspecialchars($part); | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | $output .= '</span>'; | |
| 83 | ||
| 84 | return $output; | |
| 85 | } | |
| 86 | ||
| 87 | private function renderMath(string $content): string { | |
| 88 | $parts = preg_split('/(`[^`]+`)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE); | |
| 89 | $output = ''; | |
| 90 | ||
| 91 | foreach ($parts as $part) { | |
| 92 | if ($part === '') continue; | |
| 93 | ||
| 94 | if (str_starts_with($part, '`') && str_ends_with($part, '`')) { | |
| 95 | $output .= '<span class="hl-function">' . htmlspecialchars($part) . '</span>'; | |
| 96 | } else { | |
| 97 | $output .= '<span class="hl-math">' . htmlspecialchars($part) . '</span>'; | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | return $output; | |
| 102 | } | |
| 103 | ||
| 104 | private function detectLanguage(string $mediaType, string $filename): string { | |
| 105 | $lang = match( $mediaType ) { | |
| 106 | 'text/x-php', 'application/x-php', 'application/x-httpd-php' => 'php', | |
| 107 | 'text/html' => 'html', | |
| 108 | 'text/css' => 'css', | |
| 109 | 'application/javascript', 'text/javascript', 'text/x-javascript' => 'javascript', | |
| 110 | 'application/json', 'text/json', 'application/x-json' => 'json', | |
| 111 | 'application/xml', 'text/xml', 'image/svg+xml' => 'xml', | |
| 112 | 'text/x-shellscript', 'application/x-sh' => 'bash', | |
| 113 | 'text/x-c', 'text/x-csrc' => 'c', | |
| 114 | 'text/x-c++src', 'text/x-c++', 'text/x-cpp' => 'cpp', | |
| 115 | 'text/x-java', 'text/x-java-source', 'application/java-archive' => 'java', | |
| 116 | 'text/x-python', 'application/x-python-code' => 'python', | |
| 117 | 'text/x-ruby', 'application/x-ruby' => 'ruby', | |
| 118 | 'text/x-go', 'text/go' => 'go', | |
| 119 | 'text/rust', 'text/x-rust' => 'rust', | |
| 120 | 'text/x-lua', 'text/lua' => 'lua', | |
| 121 | 'text/markdown', 'text/x-markdown' => 'markdown', | |
| 122 | 'text/x-r', 'text/x-r-source', 'application/R' => 'r', | |
| 123 | 'application/sql', 'text/sql', 'text/x-sql' => 'sql', | |
| 124 | 'text/yaml', 'text/x-yaml', 'application/yaml' => 'yaml', | |
| 125 | 'application/typescript', 'text/typescript' => 'typescript', | |
| 126 | 'text/x-gradle' => 'gradle', | |
| 127 | default => null | |
| 128 | }; | |
| 129 | ||
| 130 | if( $lang !== null ) { | |
| 131 | return $lang; | |
| 132 | } | |
| 133 | ||
| 134 | $ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ); | |
| 135 | ||
| 136 | return match( $ext ) { | |
| 137 | 'php', 'phtml', 'php8', 'php7' => 'php', | |
| 138 | 'c', 'h' => 'c', | |
| 139 | 'cpp', 'hpp', 'cc', 'cxx' => 'cpp', | |
| 140 | 'java' => 'java', | |
| 141 | 'js', 'jsx', 'mjs' => 'javascript', | |
| 142 | 'ts', 'tsx' => 'typescript', | |
| 143 | 'go' => 'go', | |
| 144 | 'rs' => 'rust', | |
| 145 | 'py', 'pyw' => 'python', | |
| 146 | 'rb', 'erb' => 'ruby', | |
| 147 | 'lua' => 'lua', | |
| 148 | 'sh', 'bash', 'zsh' => 'bash', | |
| 149 | 'bat', 'cmd' => 'batch', | |
| 150 | 'md', 'markdown' => 'markdown', | |
| 151 | 'rmd' => 'rmd', | |
| 152 | 'r' => 'r', | |
| 153 | 'xml', 'svg' => 'xml', | |
| 154 | 'html', 'htm' => 'html', | |
| 155 | 'css' => 'css', | |
| 156 | 'json', 'lock' => 'json', | |
| 157 | 'sql' => 'sql', | |
| 158 | 'yaml', 'yml' => 'yaml', | |
| 159 | 'gradle' => 'gradle', | |
| 160 | default => 'text' | |
| 161 | }; | |
| 162 | } | |
| 163 | } | |
| 1 | 164 |
| 1 | <?php | |
| 2 | require_once __DIR__ . '/FileRenderer.php'; | |
| 3 | require_once __DIR__ . '/Highlighter.php'; | |
| 4 | ||
| 5 | class HtmlFileRenderer implements FileRenderer { | |
| 6 | private string $repoSafeName; | |
| 7 | private string $currentPath; | |
| 8 | ||
| 9 | public function __construct( string $repoSafeName, string $currentPath = '' ) { | |
| 10 | $this->repoSafeName = $repoSafeName; | |
| 11 | $this->currentPath = trim( $currentPath, '/' ); | |
| 12 | } | |
| 13 | ||
| 14 | public function renderListEntry( | |
| 15 | string $name, | |
| 16 | string $sha, | |
| 17 | string $mode, | |
| 18 | string $iconClass, | |
| 19 | int $timestamp, | |
| 20 | int $size | |
| 21 | ): void { | |
| 22 | $fullPath = ($this->currentPath === '' ? '' : $this->currentPath . '/') . | |
| 23 | $name; | |
| 24 | ||
| 25 | $url = '?repo=' . urlencode( $this->repoSafeName ) . | |
| 26 | '&hash=' . $sha . | |
| 27 | '&name=' . urlencode( $fullPath ); | |
| 28 | ||
| 29 | echo '<a href="' . $url . '" class="file-item">'; | |
| 30 | echo '<span class="file-mode">' . $mode . '</span>'; | |
| 31 | echo '<span class="file-name">'; | |
| 32 | echo '<i class="fas ' . $iconClass . ' file-icon-container"></i>'; | |
| 33 | echo htmlspecialchars( $name ); | |
| 34 | echo '</span>'; | |
| 35 | ||
| 36 | if( $size > 0 ) { | |
| 37 | echo '<span class="file-size">' . $this->formatSize( $size ) . '</span>'; | |
| 38 | } | |
| 39 | ||
| 40 | if( $timestamp > 0 ) { | |
| 41 | echo '<span class="file-date">'; | |
| 42 | $this->renderTime( $timestamp ); | |
| 43 | echo '</span>'; | |
| 44 | } | |
| 45 | ||
| 46 | echo '</a>'; | |
| 47 | } | |
| 48 | ||
| 49 | public function renderMedia( | |
| 50 | File $file, | |
| 51 | string $url, | |
| 52 | string $mediaType | |
| 53 | ): bool { | |
| 54 | $rendered = false; | |
| 55 | ||
| 56 | if( $file->isImage() ) { | |
| 57 | echo '<div class="blob-content blob-content-image">' . | |
| 58 | '<img src="' . $url . '"></div>'; | |
| 59 | $rendered = true; | |
| 60 | } elseif( $file->isVideo() ) { | |
| 61 | echo '<div class="blob-content blob-content-video">' . | |
| 62 | '<video controls><source src="' . $url . '" type="' . | |
| 63 | $mediaType . '"></video></div>'; | |
| 64 | $rendered = true; | |
| 65 | } elseif( $file->isAudio() ) { | |
| 66 | echo '<div class="blob-content blob-content-audio">' . | |
| 67 | '<audio controls><source src="' . $url . '" type="' . | |
| 68 | $mediaType . '"></audio></div>'; | |
| 69 | $rendered = true; | |
| 70 | } | |
| 71 | ||
| 72 | return $rendered; | |
| 73 | } | |
| 74 | ||
| 75 | public function renderSize( int $bytes ): void { | |
| 76 | echo $this->formatSize( $bytes ); | |
| 77 | } | |
| 78 | ||
| 79 | public function highlight( | |
| 80 | string $filename, | |
| 81 | string $content, | |
| 82 | string $mediaType | |
| 83 | ): string { | |
| 84 | return (new Highlighter($filename, $content, $mediaType))->render(); | |
| 85 | } | |
| 86 | ||
| 87 | public function renderTime( int $timestamp ): void { | |
| 88 | $tokens = [ | |
| 89 | 31536000 => 'year', | |
| 90 | 2592000 => 'month', | |
| 91 | 604800 => 'week', | |
| 92 | 86400 => 'day', | |
| 93 | 3600 => 'hour', | |
| 94 | 60 => 'minute', | |
| 95 | 1 => 'second' | |
| 96 | ]; | |
| 97 | ||
| 98 | $diff = $timestamp ? time() - $timestamp : null; | |
| 99 | $result = 'never'; | |
| 100 | ||
| 101 | if( $diff && $diff >= 5 ) { | |
| 102 | foreach( $tokens as $unit => $text ) { | |
| 103 | if( $diff < $unit ) { | |
| 104 | continue; | |
| 105 | } | |
| 106 | ||
| 107 | $num = floor( $diff / $unit ); | |
| 108 | $result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago'; | |
| 109 | break; | |
| 110 | } | |
| 111 | } elseif( $diff ) { | |
| 112 | $result = 'just now'; | |
| 113 | } | |
| 114 | ||
| 115 | echo $result; | |
| 116 | } | |
| 117 | ||
| 118 | private function formatSize( int $bytes ): string { | |
| 119 | $units = [ 'B', 'KB', 'MB', 'GB', 'TB' ]; | |
| 120 | $i = 0; | |
| 121 | ||
| 122 | while( $bytes >= 1024 && $i < count( $units ) - 1 ) { | |
| 123 | $bytes /= 1024; | |
| 124 | $i++; | |
| 125 | } | |
| 126 | ||
| 127 | return ($bytes === 0 ? 0 : round( $bytes )) . ' ' . $units[$i]; | |
| 128 | } | |
| 129 | } | |
| 1 | 130 |
| 1 | <?php | |
| 2 | class LanguageDefinitions { | |
| 3 | public static function get(string $lang): array { | |
| 4 | $int = '(-?\b\d+(\.\d+)?\b)'; | |
| 5 | $str = '(".*?"|\'.*?\')'; | |
| 6 | $float = '(-?\d+(\.\d+)?([eE][+-]?\d+)?)'; | |
| 7 | ||
| 8 | $rules = [ | |
| 9 | 'gradle' => [ | |
| 10 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 11 | 'string_interp' => '/(".*?"|""".*?""")/', | |
| 12 | 'string' => '/(\'.*?\'|\'\'\'.*?\'\'\'|\/.*?\/)/', | |
| 13 | 'keyword' => '/\b(def|task|apply|plugin|sourceCompatibility|targetCompatibility|repositories|dependencies|test|group|version|plugins|buildscript|allprojects|subprojects|project|ext|implementation|api|compileOnly|runtimeOnly|testImplementation|testRuntimeOnly|mavenCentral|google|jcenter|classpath)\b/', | |
| 14 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\{)/', | |
| 15 | 'variable' => '/(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{[^}]+\})/', | |
| 16 | 'boolean' => '/\b(true|false|null)\b/', | |
| 17 | 'number' => '/' . $int . '/', | |
| 18 | ], | |
| 19 | 'php' => [ | |
| 20 | 'tag' => '/(<\?php|<\?|=\?>|\?>)/', | |
| 21 | 'string_interp' => '/(".*?")/', | |
| 22 | 'string' => '/(\'.*?\')/', | |
| 23 | 'comment' => '/(\/\/[^\r\n]*|#[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 24 | 'keyword' => '/\b(class|abstract|and|array|as|break|callable|case|catch|clone|const|continue|declare|default|die|do|echo|else|elseif|empty|enddeclare|endfor|endforeach|endif|endswitch|endwhile|eval|exit|extends|final|finally|fn|for|foreach|function|global|goto|if|implements|include|include_once|instanceof|insteadof|interface|isset|list|match|namespace|new|or|print|private|protected|public|require|require_once|return|static|switch|throw|trait|try|unset|use|var|while|xor|yield)\b/', | |
| 25 | 'function' => '/\b([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)\s*(?=\()/', | |
| 26 | 'variable' => '/(\$[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)/', | |
| 27 | 'number' => '/' . $int . '/', | |
| 28 | 'boolean' => '/\b(true|false|null)\b/i', | |
| 29 | ], | |
| 30 | 'bash' => [ | |
| 31 | 'string_interp' => '/(".*?")/', | |
| 32 | 'string' => '/(\'.*?\')/', | |
| 33 | 'comment' => '/(#[^\n]*)/', | |
| 34 | 'keyword' => '/(?<!-)\b(alias|bg|bind|break|builtin|case|cd|command|compgen|complete|continue|declare|dirs|disown|do|done|echo|elif|else|enable|esac|eval|exec|exit|export|fc|fg|fi|for|function|getopts|hash|help|history|if|jobs|kill|let|local|logout|popd|printf|pushd|pwd|read|readonly|return|set|shift|shopt|source|suspend|test|then|times|trap|type|typeset|ulimit|umask|unalias|unset|until|wait|while)\b/', | |
| 35 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 36 | 'variable' => '/(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{[^}]+\})/', | |
| 37 | 'number' => '/' . $int . '/', | |
| 38 | ], | |
| 39 | 'batch' => [ | |
| 40 | 'comment' => '/((?i:rem)\b[^\n]*|::[^\n]*)/', | |
| 41 | 'string' => '/("[^"]*")/', | |
| 42 | 'keyword' => '/(?i)\b(if|else|goto|for|in|do|call|exit|echo|pause|set|shift|start|cd|dir|copy|del|md|rd|cls|setlocal|endlocal|enabledelayedexpansion|defined|exist|not|errorlevel|setx|findstr|reg|nul|tokens|usebackq|equ|neq|lss|leq|gtr|geq)\b/', | |
| 43 | 'variable' => '/(![\w-]+!|%[\w\(\)-]+%|%%[~a-zA-Z]+|%[~a-zA-Z0-9]+)/', | |
| 44 | 'label' => '/(^\s*:[a-zA-Z0-9_-]+)/m', | |
| 45 | 'number' => '/' . $int . '/', | |
| 46 | ], | |
| 47 | 'c' => [ | |
| 48 | 'string' => '/' . $str . '/', | |
| 49 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 50 | 'keyword' => '/\b(auto|break|case|const|continue|default|do|else|enum|extern|for|goto|if|register|return|signed|sizeof|static|struct|switch|typedef|union|unsigned|void|volatile|while)\b/', | |
| 51 | 'type' => '/\b(char|double|float|int|long|short|void)\b/', | |
| 52 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 53 | 'number' => '/' . $int . '/', | |
| 54 | ], | |
| 55 | 'cpp' => [ | |
| 56 | 'string' => '/' . $str . '/', | |
| 57 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 58 | 'keyword' => '/\b(alignas|alignof|and|and_eq|asm|auto|bitand|bitor|break|case|catch|class|compl|const|constexpr|const_cast|continue|decltype|default|delete|do|dynamic_cast|else|enum|explicit|export|extern|for|friend|goto|if|inline|mutable|namespace|new|noexcept|not|not_eq|nullptr|operator|or|or_eq|private|protected|public|register|reinterpret_cast|return|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|union|using|virtual|volatile|while|xor|xor_eq)\b/', | |
| 59 | 'type' => '/\b(bool|char|char16_t|char32_t|double|float|int|long|short|signed|unsigned|void|wchar_t)\b/', | |
| 60 | 'boolean' => '/\b(true|false)\b/', | |
| 61 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 62 | 'number' => '/' . $int . '/', | |
| 63 | ], | |
| 64 | 'java' => [ | |
| 65 | 'string' => '/' . $str . '/', | |
| 66 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 67 | 'keyword' => '/\b(abstract|assert|break|case|catch|class|const|continue|default|do|else|enum|extends|final|finally|for|goto|if|implements|import|instanceof|interface|native|new|package|private|protected|public|return|static|strictfp|super|switch|synchronized|this|throw|throws|transient|try|void|volatile|while)\b/', | |
| 68 | 'type' => '/\b(boolean|byte|char|double|float|int|long|short|void)\b/', | |
| 69 | 'boolean' => '/\b(true|false|null)\b/', | |
| 70 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 71 | 'number' => '/' . $int . '/', | |
| 72 | ], | |
| 73 | 'go' => [ | |
| 74 | 'string' => '/(".*?"|`.*?`)/s', | |
| 75 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 76 | 'keyword' => '/\b(break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var)\b/', | |
| 77 | 'boolean' => '/\b(true|false|nil|iota)\b/', | |
| 78 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 79 | 'number' => '/' . $int . '/', | |
| 80 | ], | |
| 81 | 'rust' => [ | |
| 82 | 'string' => '/(".*?"|\'.*?\')/', | |
| 83 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 84 | 'keyword' => '/\b(as|break|const|continue|crate|else|enum|extern|fn|for|if|impl|in|let|loop|match|mod|move|mut|pub|ref|return|self|Self|static|struct|super|trait|type|unsafe|use|where|while|async|await|dyn)\b/', | |
| 85 | 'boolean' => '/\b(true|false)\b/', | |
| 86 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 87 | 'number' => '/' . $int . '/', | |
| 88 | ], | |
| 89 | 'python' => [ | |
| 90 | 'string' => '/(\'\'\'.*?\'\'\'|""".*?"""|".*?"|\'.*?\')/s', | |
| 91 | 'comment' => '/(#[^\r\n]*)/m', | |
| 92 | 'keyword' => '/\b(and|as|assert|async|await|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield)\b/', | |
| 93 | 'boolean' => '/\b(False|None|True)\b/', | |
| 94 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 95 | 'number' => '/' . $int . '/', | |
| 96 | ], | |
| 97 | 'ruby' => [ | |
| 98 | 'string_interp' => '/(".*?")/', | |
| 99 | 'string' => '/(\'.*?\')/', | |
| 100 | 'comment' => '/(#[^\r\n]*)/m', | |
| 101 | 'keyword' => '/\b(alias|and|begin|break|case|class|def|defined|do|else|elsif|end|ensure|for|if|in|module|next|not|or|redo|rescue|retry|return|self|super|then|undef|unless|until|when|while|yield)\b/', | |
| 102 | 'boolean' => '/\b(true|false|nil)\b/', | |
| 103 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*[?!]?)\s*(?=\()/', | |
| 104 | 'variable' => '/(@[a-zA-Z_]\w*|\$[a-zA-Z_]\w*)/', | |
| 105 | 'number' => '/' . $int . '/', | |
| 106 | ], | |
| 107 | 'lua' => [ | |
| 108 | 'string' => '/(".*?"|\'.*?\'|\[\[.*?\]\])/s', | |
| 109 | 'comment' => '/(--\[\[.*?\]\]|--[^\r\n]*)/ms', | |
| 110 | 'keyword' => '/\b(and|break|do|else|elseif|end|for|function|if|in|local|not|or|repeat|return|then|until|while)\b/', | |
| 111 | 'boolean' => '/\b(false|nil|true)\b/', | |
| 112 | 'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/', | |
| 113 | 'number' => '/' . $int . '/', | |
| 114 | ], | |
| 115 | 'javascript' => [ | |
| 116 | 'string' => '/(".*?"|\'.*?\'|`.*?`)/s', | |
| 117 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 118 | 'keyword' => '/\b(async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|finally|for|function|if|import|in|instanceof|new|return|super|switch|this|throw|try|typeof|var|void|while|with|yield|let|static|enum)\b/', | |
| 119 | 'boolean' => '/\b(true|false|null|undefined)\b/', | |
| 120 | 'function' => '/\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/', | |
| 121 | 'number' => '/' . $int . '/', | |
| 122 | ], | |
| 123 | 'typescript' => [ | |
| 124 | 'string' => '/(".*?"|\'.*?\'|`.*?`)/s', | |
| 125 | 'comment' => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 126 | 'keyword' => '/\b(any|as|break|case|catch|class|const|continue|debugger|declare|default|delete|do|else|enum|export|extends|finally|for|from|function|if|implements|import|in|instanceof|interface|let|module|namespace|new|of|package|private|protected|public|require|return|static|super|switch|this|throw|try|type|typeof|var|void|while|with|yield)\b/', | |
| 127 | 'type' => '/\b(boolean|number|string|void|any)\b/', | |
| 128 | 'boolean' => '/\b(true|false|null|undefined)\b/', | |
| 129 | 'function' => '/\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/', | |
| 130 | 'number' => '/' . $int . '/', | |
| 131 | ], | |
| 132 | 'xml' => [ | |
| 133 | 'comment' => '/()/s', | |
| 134 | 'string' => '/' . $str . '/', | |
| 135 | 'tag' => '/(<\/?[a-zA-Z0-9:-]+|\s*\/?>|<\?xml|\?>)/', | |
| 136 | 'attribute' => '/([a-zA-Z0-9:-]+)(?=\=)/', | |
| 137 | ], | |
| 138 | 'html' => [ | |
| 139 | 'comment' => '/()/s', | |
| 140 | 'string' => '/' . $str . '/', | |
| 141 | 'tag' => '/(<\/?[a-zA-Z0-9:-]+|\s*\/?>)/', | |
| 142 | 'attribute' => '/([a-zA-Z0-9:-]+)(?=\=)/', | |
| 143 | ], | |
| 144 | 'css' => [ | |
| 145 | 'comment' => '/(\/\*.*?\*\/)/s', | |
| 146 | 'tag' => '/(?<=^|\}|\{)\s*([a-zA-Z0-9_\-#\.\s,>+~]+)(?=\{)/m', | |
| 147 | 'property' => '/([a-zA-Z-]+)(?=\s*:)/', | |
| 148 | 'string' => '/' . $str . '/', | |
| 149 | 'number' => '/(-?(\d*\.)?\d+(px|em|rem|%|vh|vw|s|ms|deg))/', | |
| 150 | ], | |
| 151 | 'json' => [ | |
| 152 | 'attribute' => '/(".*?")(?=\s*:)/', | |
| 153 | 'string' => '/(".*?")/', | |
| 154 | 'boolean' => '/\b(true|false|null)\b/', | |
| 155 | 'number' => '/\b(-?\d+(\.\d+)?([eE][+-]?\d+)?)\b/', | |
| 156 | ], | |
| 157 | 'sql' => [ | |
| 158 | 'string' => '/(\'.*?\')/', | |
| 159 | 'comment' => '/(--[^\r\n]*|\/\*.*?\*\/)/ms', | |
| 160 | 'keyword' => '/(?i)\b(SELECT|FROM|WHERE|INSERT|INTO|UPDATE|DELETE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|GROUP|BY|ORDER|HAVING|LIMIT|OFFSET|CREATE|TABLE|DROP|ALTER|INDEX|KEY|PRIMARY|FOREIGN|CONSTRAINT|DEFAULT|NOT|AND|OR|IN|VALUES|SET|AS|DISTINCT|UNION|ALL|CASE|WHEN|THEN|ELSE|END)\b/', | |
| 161 | 'boolean' => '/(?i)\b(NULL|TRUE|FALSE)\b/', | |
| 162 | 'number' => '/' . $int . '/', | |
| 163 | ], | |
| 164 | 'yaml' => [ | |
| 165 | 'string' => '/' . $str . '/', | |
| 166 | 'comment' => '/(#[^\r\n]*)/m', | |
| 167 | 'attribute' => '/^(\s*[a-zA-Z0-9_-]+:)/m', | |
| 168 | 'number' => '/' . $float . '/', | |
| 169 | ], | |
| 170 | 'markdown' => [ | |
| 171 | 'code' => '/(^(?: |\t)[^\n]*(?:\n(?: |\t)[^\n]*)*)/', | |
| 172 | 'comment' => '/(```[\s\S]*?```|~~~[\s\S]*?~~~)/', | |
| 173 | 'math' => '/(\$((?:[^`\n$]|`[^`\n]*`)+)\$)/', | |
| 174 | 'keyword' => '/^(#{1,6})(?=\s)/m', | |
| 175 | 'string' => '/(\*\*[^\n*]+\*\*|__[^\n_]+__)/', | |
| 176 | 'attribute' => '/(?<!\*)(\*[^\n*]+\*)(?!\*)|(?<!_)(_[^\n_]+_)(?!_)/', | |
| 177 | 'function' => '/(`[^`\n]+`)/', | |
| 178 | 'variable' => '/(\[[^\]]+\]\([^\)]+\))/', | |
| 179 | 'operator' => '/^(\s*[-*+](?=\s)|\s*\d+\.(?=\s))/m', | |
| 180 | ], | |
| 181 | 'rmd' => [ | |
| 182 | 'code' => '/(^(?: |\t)[^\n]*(?:\n(?: |\t)[^\n]*)*)/', | |
| 183 | 'comment' => '/(```\{r[^\}]*\}[\s\S]*?```)/', | |
| 184 | 'math' => '/(\$((?:[^`\n$]|`[^`\n]*`)+)\$)/', | |
| 185 | 'keyword' => '/^(#{1,6})(?=\s)/m', | |
| 186 | 'string' => '/(\*\*[^\n*]+\*\*|__[^\n_]+__)/', | |
| 187 | 'attribute' => '/(?<!\*)(\*[^\n*]+\*)(?!\*)|(?<!_)(_[^\n_]+_)(?!_)/', | |
| 188 | 'function' => '/(`[^`\n]+`)/', | |
| 189 | 'variable' => '/(\[[^\]]+\]\([^\)]+\))/', | |
| 190 | 'operator' => '/^(\s*[-*+](?=\s)|\s*\d+\.(?=\s))/m', | |
| 191 | ], | |
| 192 | 'r' => [ | |
| 193 | 'string' => '/' . $str . '/', | |
| 194 | 'comment' => '/(#[^\r\n]*)/m', | |
| 195 | 'keyword' => '/\b(if|else|repeat|while|function|for|in|next|break)\b/', | |
| 196 | 'boolean' => '/\b(TRUE|FALSE|NULL|Inf|NaN|NA)\b/', | |
| 197 | 'function' => '/\b([a-zA-Z_.][a-zA-Z0-9_.]*)\s*(?=\()/', | |
| 198 | 'number' => '/' . $float . '/', | |
| 199 | ] | |
| 200 | ]; | |
| 201 | ||
| 202 | return $rules[strtolower($lang)] ?? []; | |
| 203 | } | |
| 204 | } | |
| 1 | 205 |
| 10 | 10 | ): void; |
| 11 | 11 | |
| 12 | public function renderTime(int $timestamp): void; | |
| 12 | public function renderTime( int $timestamp ): void; | |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | class HtmlTagRenderer implements TagRenderer { |
| 16 | 16 | private string $repoSafeName; |
| 17 | 17 | |
| 18 | public function __construct(string $repoSafeName) { | |
| 18 | public function __construct( string $repoSafeName ) { | |
| 19 | 19 | $this->repoSafeName = $repoSafeName; |
| 20 | 20 | } |
| ... | ||
| 28 | 28 | string $author |
| 29 | 29 | ): void { |
| 30 | $repoParam = '&repo=' . urlencode($this->repoSafeName); | |
| 31 | $filesUrl = '?hash=' . $targetSha . $repoParam; | |
| 30 | $repoParam = '&repo=' . urlencode( $this->repoSafeName ); | |
| 31 | $filesUrl = '?hash=' . $targetSha . $repoParam; | |
| 32 | 32 | $commitUrl = '?action=commit&hash=' . $targetSha . $repoParam; |
| 33 | 33 | |
| 34 | 34 | echo '<tr>'; |
| 35 | ||
| 36 | 35 | echo '<td class="tag-name">'; |
| 37 | echo '<a href="' . $filesUrl . '"><i class="fas fa-tag"></i> ' . htmlspecialchars($name) . '</a>'; | |
| 36 | echo '<a href="' . $filesUrl . '"><i class="fas fa-tag"></i> ' . | |
| 37 | htmlspecialchars( $name ) . '</a>'; | |
| 38 | 38 | echo '</td>'; |
| 39 | ||
| 40 | 39 | echo '<td class="tag-message">'; |
| 41 | echo ($message !== '') ? htmlspecialchars(strtok($message, "\n")) : '<span style="color: #484f58; font-style: italic;">No description</span>'; | |
| 42 | echo '</td>'; | |
| 43 | 40 | |
| 44 | echo '<td class="tag-author">' . htmlspecialchars($author) . '</td>'; | |
| 41 | echo ($message !== '') ? htmlspecialchars( strtok( $message, "\n" ) ) : | |
| 42 | '<span style="color: #484f58; font-style: italic;">No description</span>'; | |
| 45 | 43 | |
| 44 | echo '</td>'; | |
| 45 | echo '<td class="tag-author">' . htmlspecialchars( $author ) . '</td>'; | |
| 46 | 46 | echo '<td class="tag-time">'; |
| 47 | $this->renderTime($timestamp); | |
| 47 | $this->renderTime( $timestamp ); | |
| 48 | 48 | echo '</td>'; |
| 49 | ||
| 50 | 49 | echo '<td class="tag-hash">'; |
| 51 | echo '<a href="' . $commitUrl . '" class="commit-hash">' . substr($sha, 0, 7) . '</a>'; | |
| 50 | echo '<a href="' . $commitUrl . '" class="commit-hash">' . | |
| 51 | substr( $sha, 0, 7 ) . '</a>'; | |
| 52 | 52 | echo '</td>'; |
| 53 | ||
| 54 | 53 | echo '</tr>'; |
| 55 | 54 | } |
| 56 | 55 | |
| 57 | public function renderTime(int $timestamp): void { | |
| 58 | if (!$timestamp) { echo 'never'; return; } | |
| 56 | public function renderTime( int $timestamp ): void { | |
| 57 | if( !$timestamp ) { | |
| 58 | echo 'never'; | |
| 59 | return; | |
| 60 | } | |
| 61 | ||
| 59 | 62 | $diff = time() - $timestamp; |
| 60 | if ($diff < 5) { echo 'just now'; return; } | |
| 63 | ||
| 64 | if( $diff < 5 ) { | |
| 65 | echo 'just now'; | |
| 66 | return; | |
| 67 | } | |
| 61 | 68 | |
| 62 | 69 | $tokens = [ |
| 63 | 70 | 31536000 => 'year', |
| 64 | 2592000 => 'month', | |
| 65 | 604800 => 'week', | |
| 66 | 86400 => 'day', | |
| 67 | 3600 => 'hour', | |
| 68 | 60 => 'minute', | |
| 69 | 1 => 'second' | |
| 71 | 2592000 => 'month', | |
| 72 | 604800 => 'week', | |
| 73 | 86400 => 'day', | |
| 74 | 3600 => 'hour', | |
| 75 | 60 => 'minute', | |
| 76 | 1 => 'second' | |
| 70 | 77 | ]; |
| 71 | 78 | |
| 72 | foreach ($tokens as $unit => $text) { | |
| 73 | if ($diff < $unit) continue; | |
| 74 | $num = floor($diff / $unit); | |
| 75 | echo $num . ' ' . $text . (($num > 1) ? 's' : '') . ' ago'; | |
| 79 | foreach( $tokens as $unit => $text ) { | |
| 80 | if( $diff < $unit ) { | |
| 81 | continue; | |
| 82 | } | |
| 83 | ||
| 84 | $num = floor( $diff / $unit ); | |
| 85 | ||
| 86 | echo $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago'; | |
| 76 | 87 | return; |
| 77 | 88 | } |
| 679 | 679 | } |
| 680 | 680 | |
| 681 | .blob-code { | |
| 682 | font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; | |
| 683 | background-color: #161b22; | |
| 684 | color: #fcfcfa; | |
| 685 | } | |
| 686 | ||
| 687 | .hl-comment, | |
| 688 | .hl-doc-comment { | |
| 689 | color: #727072; | |
| 690 | font-style: italic; | |
| 691 | } | |
| 692 | ||
| 693 | .hl-keyword, | |
| 694 | .hl-tag, | |
| 695 | .hl-storage, | |
| 696 | .hl-modifier { | |
| 697 | color: #ff6188; | |
| 698 | font-weight: 600; | |
| 699 | } | |
| 700 | ||
| 701 | .hl-function, | |
| 702 | .hl-method, | |
| 703 | .hl-class, | |
| 704 | .hl-type, | |
| 705 | .hl-label { | |
| 706 | color: #a9dc76; | |
| 707 | } | |
| 708 | ||
| 709 | .hl-string, | |
| 710 | .hl-string_interp { | |
| 711 | color: #ffd866; | |
| 712 | } | |
| 713 | ||
| 714 | .hl-number, | |
| 715 | .hl-boolean, | |
| 716 | .hl-constant { | |
| 717 | color: #ab9df2; | |
| 718 | } | |
| 719 | ||
| 720 | .hl-attribute, | |
| 721 | .hl-property { | |
| 722 | color: #fc9867; | |
| 723 | } | |
| 724 | ||
| 725 | .hl-operator, | |
| 726 | .hl-punctuation, | |
| 727 | .hl-escape { | |
| 728 | color: #78dce8; | |
| 729 | } | |
| 730 | ||
| 731 | .hl-variable { | |
| 732 | color: #fcfcfa; | |
| 733 | } | |
| 734 | ||
| 735 | .hl-interp-punct { | |
| 736 | color: #ff6188; | |
| 737 | } | |
| 738 | ||
| 739 | .hl-interp-punct { | |
| 740 | color: #ff6188; | |
| 741 | } | |
| 742 | ||
| 743 | .hl-code { | |
| 744 | display: inline-block; | |
| 745 | width: 100%; | |
| 746 | background-color: #21262d; | |
| 747 | color: #c9d1d9; | |
| 748 | } | |
| 749 | ||
| 750 | .hl-math { | |
| 751 | color: #78dce8; | |
| 752 | } | |
| 681 | 753 |