| Author | Dave Jarvis <email> |
|---|---|
| Date | 2026-02-22 18:35:32 GMT-0800 |
| Commit | 777e426f47bdd82d25491d59191d1e3a33add378 |
| Parent | 6ff67fb |
| -<?php | ||
| -require_once __DIR__ . '/render/CommitRenderer.php'; | ||
| -require_once __DIR__ . '/UrlBuilder.php'; | ||
| - | ||
| -class Commit { | ||
| - private string $sha; | ||
| - private string $message; | ||
| - private string $author; | ||
| - private string $email; | ||
| - private int $date; | ||
| - private string $parentSha; | ||
| - | ||
| - public function __construct( | ||
| - string $sha, | ||
| - string $message, | ||
| - string $author, | ||
| - string $email, | ||
| - int $date, | ||
| - string $parentSha | ||
| - ) { | ||
| - $this->sha = $sha; | ||
| - $this->message = $message; | ||
| - $this->author = $author; | ||
| - $this->email = $email; | ||
| - $this->date = $date; | ||
| - $this->parentSha = $parentSha; | ||
| - } | ||
| - | ||
| - public function hasParent(): bool { | ||
| - return $this->parentSha !== ''; | ||
| - } | ||
| - | ||
| - public function isSha( string $sha ): bool { | ||
| - return $this->sha === $sha; | ||
| - } | ||
| - | ||
| - public function toUrl( UrlBuilder $builder ): string { | ||
| - return $builder->withHash( $this->sha )->build(); | ||
| - } | ||
| - | ||
| - public function isEmpty(): bool { | ||
| - return $this->sha === ''; | ||
| - } | ||
| - | ||
| - public function render( CommitRenderer $renderer ): void { | ||
| - $renderer->render( | ||
| - $this->sha, | ||
| - $this->message, | ||
| - $this->author, | ||
| - $this->date | ||
| - ); | ||
| - } | ||
| - | ||
| - public function renderTime( CommitRenderer $renderer ): void { | ||
| - $renderer->renderTime( $this->date ); | ||
| - } | ||
| -} | ||
| -<?php | ||
| -require_once __DIR__ . '/render/FileRenderer.php'; | ||
| - | ||
| -class File { | ||
| - private const CAT_IMAGE = 'image'; | ||
| - private const CAT_VIDEO = 'video'; | ||
| - private const CAT_AUDIO = 'audio'; | ||
| - private const CAT_TEXT = 'text'; | ||
| - private const CAT_ARCHIVE = 'archive'; | ||
| - private const CAT_BINARY = 'binary'; | ||
| - | ||
| - private const ICON_FOLDER = 'fa-folder'; | ||
| - private const ICON_PDF = 'fa-file-pdf'; | ||
| - private const ICON_ARCHIVE = 'fa-file-archive'; | ||
| - private const ICON_IMAGE = 'fa-file-image'; | ||
| - private const ICON_AUDIO = 'fa-file-audio'; | ||
| - private const ICON_VIDEO = 'fa-file-video'; | ||
| - private const ICON_CODE = 'fa-file-code'; | ||
| - private const ICON_FILE = 'fa-file'; | ||
| - | ||
| - private const MODE_DIR = '40000'; | ||
| - private const MODE_DIR_LONG = '040000'; | ||
| - | ||
| - private const MEDIA_EMPTY = 'application/x-empty'; | ||
| - private const MEDIA_OCTET = 'application/octet-stream'; | ||
| - private const MEDIA_PDF = 'application/pdf'; | ||
| - private const MEDIA_TEXT = 'text/'; | ||
| - private const MEDIA_SVG = 'image/svg'; | ||
| - private const MEDIA_APP_TEXT = [ | ||
| - 'application/javascript', | ||
| - 'application/json', | ||
| - 'application/xml', | ||
| - 'application/x-httpd-php', | ||
| - 'application/x-sh' | ||
| - ]; | ||
| - | ||
| - private const ARCHIVE_EXT = [ | ||
| - 'zip', 'tar', 'gz', '7z', 'rar', 'jar', 'lha', 'bz', 'tgz', 'cab', | ||
| - 'iso', 'dmg', 'xz', 'z', 'ar', 'war', 'ear', 'pak', 'hqx', 'arj', | ||
| - 'zoo', 'rpm', 'deb', 'apk' | ||
| - ]; | ||
| - | ||
| - private string $name; | ||
| - private string $sha; | ||
| - private string $mode; | ||
| - private int $timestamp; | ||
| - private int $size; | ||
| - private bool $isDir; | ||
| - private string $mediaType; | ||
| - private string $category; | ||
| - private bool $binary; | ||
| - | ||
| - private static ?finfo $finfo = null; | ||
| - | ||
| - public function __construct( | ||
| - string $name, | ||
| - string $sha, | ||
| - string $mode, | ||
| - int $timestamp, | ||
| - int $size, | ||
| - string $contents = '' | ||
| - ) { | ||
| - $this->name = $name; | ||
| - $this->sha = $sha; | ||
| - $this->mode = $mode; | ||
| - $this->timestamp = $timestamp; | ||
| - $this->size = $size; | ||
| - $this->isDir = $mode === self::MODE_DIR || | ||
| - $mode === self::MODE_DIR_LONG; | ||
| - | ||
| - $buffer = $this->isDir ? '' : $contents; | ||
| - $this->mediaType = $this->detectMediaType( $buffer ); | ||
| - $this->category = $this->detectCategory( $name ); | ||
| - $this->binary = $this->detectBinary(); | ||
| - } | ||
| - | ||
| - public function isEmpty(): bool { | ||
| - return $this->size === 0; | ||
| - } | ||
| - | ||
| - public function compare( File $other ): int { | ||
| - return $this->isDir !== $other->isDir | ||
| - ? ($this->isDir ? -1 : 1) | ||
| - : strcasecmp( $this->name, $other->name ); | ||
| - } | ||
| - | ||
| - public function renderListEntry( FileRenderer $renderer ): void { | ||
| - $renderer->renderListEntry( | ||
| - $this->name, | ||
| - $this->sha, | ||
| - $this->mode, | ||
| - $this->resolveIcon(), | ||
| - $this->timestamp, | ||
| - $this->size | ||
| - ); | ||
| - } | ||
| - | ||
| - public function emitRawHeaders(): void { | ||
| - header( "Content-Type: " . $this->mediaType ); | ||
| - header( "Content-Length: " . $this->size ); | ||
| - header( "Content-Disposition: attachment; filename=\"" . | ||
| - addslashes( basename( $this->name ) ) . "\"" ); | ||
| - } | ||
| - | ||
| - public function renderMedia( FileRenderer $renderer, string $url ): bool { | ||
| - return $renderer->renderMedia( $this, $url, $this->mediaType ); | ||
| - } | ||
| - | ||
| - public function renderSize( FileRenderer $renderer ): void { | ||
| - $renderer->renderSize( $this->size ); | ||
| - } | ||
| - | ||
| - public function highlight( | ||
| - FileRenderer $renderer, | ||
| - string $content | ||
| - ): string { | ||
| - return $renderer->highlight( $this->name, $content, $this->mediaType ); | ||
| - } | ||
| - | ||
| - public function isDir(): bool { | ||
| - return $this->isDir; | ||
| - } | ||
| - | ||
| - public function isImage(): bool { | ||
| - return $this->category === self::CAT_IMAGE; | ||
| - } | ||
| - | ||
| - public function isVideo(): bool { | ||
| - return $this->category === self::CAT_VIDEO; | ||
| - } | ||
| - | ||
| - public function isAudio(): bool { | ||
| - return $this->category === self::CAT_AUDIO; | ||
| - } | ||
| - | ||
| - public function isText(): bool { | ||
| - return $this->category === self::CAT_TEXT; | ||
| - } | ||
| - | ||
| - public function isBinary(): bool { | ||
| - return $this->binary; | ||
| - } | ||
| - | ||
| - public function isName( string $name ): bool { | ||
| - return $this->name === $name; | ||
| - } | ||
| - | ||
| - private function resolveIcon(): string { | ||
| - return $this->isDir | ||
| - ? self::ICON_FOLDER | ||
| - : (str_contains( $this->mediaType, self::MEDIA_PDF ) | ||
| - ? self::ICON_PDF | ||
| - : match( $this->category ) { | ||
| - self::CAT_ARCHIVE => self::ICON_ARCHIVE, | ||
| - self::CAT_IMAGE => self::ICON_IMAGE, | ||
| - self::CAT_AUDIO => self::ICON_AUDIO, | ||
| - self::CAT_VIDEO => self::ICON_VIDEO, | ||
| - self::CAT_TEXT => self::ICON_CODE, | ||
| - default => self::ICON_FILE, | ||
| - }); | ||
| - } | ||
| - | ||
| - private static function fileinfo(): finfo { | ||
| - return self::$finfo ??= new finfo( FILEINFO_MIME_TYPE ); | ||
| - } | ||
| - | ||
| - private function detectMediaType( string $buffer ): string { | ||
| - return $buffer === '' | ||
| - ? self::MEDIA_EMPTY | ||
| - : (self::fileinfo()->buffer( substr( $buffer, 0, 128 ) ) | ||
| - ?: self::MEDIA_OCTET); | ||
| - } | ||
| - | ||
| - private function detectCategory( string $filename ): string { | ||
| - $main = explode( '/', $this->mediaType )[0]; | ||
| - $main = $this->isArchive( $filename ) || | ||
| - str_contains( $this->mediaType, 'compressed' ) | ||
| - ? self::CAT_ARCHIVE | ||
| - : $main; | ||
| - | ||
| - $main = $main !== self::CAT_ARCHIVE && | ||
| - $this->isMediaTypeText() | ||
| - ? 'text' | ||
| - : $main; | ||
| - | ||
| - return match( $main ) { | ||
| - 'image' => self::CAT_IMAGE, | ||
| - 'video' => self::CAT_VIDEO, | ||
| - 'audio' => self::CAT_AUDIO, | ||
| - 'text' => self::CAT_TEXT, | ||
| - self::CAT_ARCHIVE => self::CAT_ARCHIVE, | ||
| - default => self::CAT_BINARY, | ||
| - }; | ||
| - } | ||
| - | ||
| - private function detectBinary(): bool { | ||
| - return $this->mediaType !== self::MEDIA_EMPTY && | ||
| - !$this->isMediaTypeText() && | ||
| - !str_contains( $this->mediaType, self::MEDIA_SVG ); | ||
| - } | ||
| - | ||
| - private function isMediaTypeText(): bool { | ||
| - return str_starts_with( $this->mediaType, self::MEDIA_TEXT ) || | ||
| - in_array( $this->mediaType, self::MEDIA_APP_TEXT, true ); | ||
| - } | ||
| - | ||
| - private function isArchive( string $filename ): bool { | ||
| - return in_array( | ||
| - strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ), | ||
| - self::ARCHIVE_EXT, | ||
| - true | ||
| - ); | ||
| - } | ||
| -} | ||
| -<?php | ||
| -class RepositoryList { | ||
| - private const GIT_EXT = '.git'; | ||
| - private const ORDER_FILE = '/order.txt'; | ||
| - private const GLOB_PATTERN = '/*'; | ||
| - private const HIDDEN_PREFIX = '.'; | ||
| - private const EXCLUDE_CHAR = '-'; | ||
| - private const SORT_MAX = PHP_INT_MAX; | ||
| - | ||
| - private const KEY_SAFE_NAME = 'safe_name'; | ||
| - private const KEY_EXCLUDE = 'exclude'; | ||
| - private const KEY_ORDER = 'order'; | ||
| - private const KEY_PATH = 'path'; | ||
| - private const KEY_NAME = 'name'; | ||
| - | ||
| - private string $reposPath; | ||
| - | ||
| - public function __construct( string $path ) { | ||
| - $this->reposPath = $path; | ||
| - } | ||
| - | ||
| - public function eachRepository( callable $callback ): void { | ||
| - $repos = $this->sortRepositories( $this->loadRepositories() ); | ||
| - | ||
| - foreach( $repos as $repo ) { | ||
| - $callback( $repo ); | ||
| - } | ||
| - } | ||
| - | ||
| - private function loadRepositories(): array { | ||
| - $repos = []; | ||
| - $path = $this->reposPath . self::GLOB_PATTERN; | ||
| - $dirs = glob( $path, GLOB_ONLYDIR ); | ||
| - | ||
| - if( $dirs !== false ) { | ||
| - $repos = $this->processDirectories( $dirs ); | ||
| - } | ||
| - | ||
| - return $repos; | ||
| - } | ||
| - | ||
| - private function processDirectories( array $dirs ): array { | ||
| - $repos = []; | ||
| - | ||
| - foreach( $dirs as $dir ) { | ||
| - $data = $this->createRepositoryData( $dir ); | ||
| - | ||
| - if( $data !== [] ) { | ||
| - $repos[$data[self::KEY_NAME]] = $data; | ||
| - } | ||
| - } | ||
| - | ||
| - return $repos; | ||
| - } | ||
| - | ||
| - private function createRepositoryData( string $dir ): array { | ||
| - $data = []; | ||
| - $base = basename( $dir ); | ||
| - | ||
| - if( $base[0] !== self::HIDDEN_PREFIX ) { | ||
| - $name = $this->extractName( $base ); | ||
| - $data = [ | ||
| - self::KEY_NAME => $name, | ||
| - self::KEY_SAFE_NAME => $name, | ||
| - self::KEY_PATH => $dir, | ||
| - ]; | ||
| - } | ||
| - | ||
| - return $data; | ||
| - } | ||
| - | ||
| - private function extractName( string $base ): string { | ||
| - $name = $base; | ||
| - | ||
| - if( str_ends_with( $base, self::GIT_EXT ) ) { | ||
| - $len = strlen( self::GIT_EXT ); | ||
| - $name = substr( $base, 0, -$len ); | ||
| - } | ||
| - | ||
| - return $name; | ||
| - } | ||
| - | ||
| - private function sortRepositories( array $repos ): array { | ||
| - $file = __DIR__ . self::ORDER_FILE; | ||
| - | ||
| - if( file_exists( $file ) ) { | ||
| - $repos = $this->applyCustomOrder( $repos, $file ); | ||
| - } else { | ||
| - ksort( $repos, SORT_NATURAL | SORT_FLAG_CASE ); | ||
| - } | ||
| - | ||
| - return $repos; | ||
| - } | ||
| - | ||
| - private function applyCustomOrder( array $repos, string $file ): array { | ||
| - $lines = file( $file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ); | ||
| - | ||
| - if( $lines !== false ) { | ||
| - $config = $this->parseOrderFile( $lines ); | ||
| - $repos = $this->filterExcluded( $repos, $config[self::KEY_EXCLUDE] ); | ||
| - $repos = $this->sortWithConfig( $repos, $config[self::KEY_ORDER] ); | ||
| - } | ||
| - | ||
| - return $repos; | ||
| - } | ||
| - | ||
| - private function parseOrderFile( array $lines ): array { | ||
| - $order = []; | ||
| - $exclude = []; | ||
| - | ||
| - foreach( $lines as $line ) { | ||
| - $trim = trim( $line ); | ||
| - | ||
| - if( $trim !== '' ) { | ||
| - if( str_starts_with( $trim, self::EXCLUDE_CHAR ) ) { | ||
| - $exclude = $this->addExclusion( $exclude, $trim ); | ||
| - } else { | ||
| - $order[$trim] = count( $order ); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - return [ self::KEY_ORDER => $order, self::KEY_EXCLUDE => $exclude ]; | ||
| - } | ||
| - | ||
| - private function addExclusion( array $exclude, string $line ): array { | ||
| - $name = substr( $line, 1 ); | ||
| - $exclude[$name] = true; | ||
| - | ||
| - return $exclude; | ||
| - } | ||
| - | ||
| - private function filterExcluded( array $repos, array $exclude ): array { | ||
| - foreach( $repos as $key => $repo ) { | ||
| - if( isset( $exclude[$repo[self::KEY_SAFE_NAME]] ) ) { | ||
| - unset( $repos[$key] ); | ||
| - } | ||
| - } | ||
| - | ||
| - return $repos; | ||
| - } | ||
| - | ||
| - private function sortWithConfig( array $repos, array $order ): array { | ||
| - uasort( $repos, function( array $repoA, array $repoB ) use( $order ): int { | ||
| - return $this->compareRepositories( $repoA, $repoB, $order ); | ||
| - } ); | ||
| - | ||
| - return $repos; | ||
| - } | ||
| - | ||
| - private function compareRepositories( | ||
| - array $repoA, | ||
| - array $repoB, | ||
| - array $order | ||
| - ): int { | ||
| - $safeA = $repoA[self::KEY_SAFE_NAME]; | ||
| - $safeB = $repoB[self::KEY_SAFE_NAME]; | ||
| - $posA = $order[$safeA] ?? self::SORT_MAX; | ||
| - $posB = $order[$safeB] ?? self::SORT_MAX; | ||
| - | ||
| - $result = $posA === $posB | ||
| - ? strcasecmp( $safeA, $safeB ) | ||
| - : $posA <=> $posB; | ||
| - | ||
| - return $result; | ||
| - } | ||
| -} | ||
| -<?php | ||
| -require_once __DIR__ . '/RepositoryList.php'; | ||
| -require_once __DIR__ . '/git/Git.php'; | ||
| -require_once __DIR__ . '/pages/CommitsPage.php'; | ||
| -require_once __DIR__ . '/pages/DiffPage.php'; | ||
| -require_once __DIR__ . '/pages/HomePage.php'; | ||
| -require_once __DIR__ . '/pages/FilePage.php'; | ||
| -require_once __DIR__ . '/pages/RawPage.php'; | ||
| -require_once __DIR__ . '/pages/TagsPage.php'; | ||
| -require_once __DIR__ . '/pages/ClonePage.php'; | ||
| -require_once __DIR__ . '/pages/ComparePage.php'; | ||
| - | ||
| -class Router { | ||
| - private const ACTION_TREE = 'tree'; | ||
| - private const ACTION_BLOB = 'blob'; | ||
| - private const ACTION_RAW = 'raw'; | ||
| - private const ACTION_COMMITS = 'commits'; | ||
| - private const ACTION_COMMIT = 'commit'; | ||
| - private const ACTION_COMPARE = 'compare'; | ||
| - private const ACTION_TAGS = 'tags'; | ||
| - | ||
| - private const GET_REPOSITORY = 'repo'; | ||
| - private const GET_ACTION = 'action'; | ||
| - private const GET_HASH = 'hash'; | ||
| - private const GET_NAME = 'name'; | ||
| - | ||
| - private const REFERENCE_HEAD = 'HEAD'; | ||
| - private const ROUTE_REPO = 'repo'; | ||
| - private const EXTENSION_GIT = '.git'; | ||
| - | ||
| - private array $repos = []; | ||
| - private Git $git; | ||
| - | ||
| - private string $repoName = ''; | ||
| - private array $repoData = []; | ||
| - private string $action = ''; | ||
| - private string $commitHash = ''; | ||
| - private string $filePath = ''; | ||
| - private string $baseHash = ''; | ||
| - | ||
| - public function __construct( string $reposPath ) { | ||
| - $this->git = new Git( $reposPath ); | ||
| - $list = new RepositoryList( $reposPath ); | ||
| - | ||
| - $list->eachRepository( function( $repo ) { | ||
| - $this->repos[$repo['safe_name']] = $repo; | ||
| - }); | ||
| - } | ||
| - | ||
| - public function route(): Page { | ||
| - $this->normalizeQueryString(); | ||
| - $uriParts = $this->parseUriParts(); | ||
| - $repoName = !empty( $uriParts ) ? array_shift( $uriParts ) : ''; | ||
| - $page = new HomePage( $this->repos, $this->git ); | ||
| - | ||
| - if( $repoName !== '' ) { | ||
| - if( str_ends_with( $repoName, self::EXTENSION_GIT ) ) { | ||
| - $page = $this->handleCloneRoute( $repoName, $uriParts ); | ||
| - } elseif( isset( $this->repos[$repoName] ) ) { | ||
| - $page = $this->resolveActionRoute( $repoName, $uriParts ); | ||
| - } | ||
| - } | ||
| - | ||
| - return $page; | ||
| - } | ||
| - | ||
| - private function handleCloneRoute( | ||
| - string $repoName, | ||
| - array $uriParts | ||
| - ): Page { | ||
| - $realName = substr( $repoName, 0, -4 ); | ||
| - $path = ''; | ||
| - | ||
| - if( isset( $this->repos[$realName]['path'] ) ) { | ||
| - $path = $this->repos[$realName]['path']; | ||
| - } elseif( isset( $this->repos[$repoName]['path'] ) ) { | ||
| - $path = $this->repos[$repoName]['path']; | ||
| - } | ||
| - | ||
| - if( $path === '' ) { | ||
| - http_response_code( 404 ); | ||
| - exit( "Repository not found" ); | ||
| - } | ||
| - | ||
| - $this->git->setRepository( $path ); | ||
| - | ||
| - return new ClonePage( $this->git, implode( '/', $uriParts ) ); | ||
| - } | ||
| - | ||
| - private function resolveActionRoute( | ||
| - string $repoName, | ||
| - array $uriParts | ||
| - ): Page { | ||
| - $this->repoData = $this->repos[$repoName]; | ||
| - $this->repoName = $repoName; | ||
| - | ||
| - $this->git->setRepository( $this->repoData['path'] ); | ||
| - | ||
| - $act = array_shift( $uriParts ); | ||
| - $this->action = $act ?: self::ACTION_TREE; | ||
| - | ||
| - $this->commitHash = self::REFERENCE_HEAD; | ||
| - $this->filePath = ''; | ||
| - $this->baseHash = ''; | ||
| - | ||
| - $hasHash = [ | ||
| - self::ACTION_TREE, self::ACTION_BLOB, self::ACTION_RAW, | ||
| - self::ACTION_COMMITS | ||
| - ]; | ||
| - | ||
| - if( in_array( $this->action, $hasHash ) ) { | ||
| - $hash = array_shift( $uriParts ); | ||
| - $this->commitHash = $hash ?: self::REFERENCE_HEAD; | ||
| - $this->filePath = implode( '/', $uriParts ); | ||
| - } elseif( $this->action === self::ACTION_COMMIT ) { | ||
| - $this->commitHash = array_shift( $uriParts ) ?? self::REFERENCE_HEAD; | ||
| - } elseif( $this->action === self::ACTION_COMPARE ) { | ||
| - $this->commitHash = array_shift( $uriParts ) ?? self::REFERENCE_HEAD; | ||
| - $this->baseHash = array_shift( $uriParts ) ?? ''; | ||
| - } | ||
| - | ||
| - $this->populateGet(); | ||
| - | ||
| - return $this->createPage(); | ||
| - } | ||
| - | ||
| - private function createPage(): Page { | ||
| - return match( $this->action ) { | ||
| - self::ACTION_TREE, | ||
| - self::ACTION_BLOB => new FilePage( | ||
| - $this->repos, $this->repoData, $this->git, $this->commitHash, | ||
| - $this->filePath | ||
| - ), | ||
| - self::ACTION_RAW => new RawPage( | ||
| - $this->git, $this->commitHash | ||
| - ), | ||
| - self::ACTION_COMMITS => new CommitsPage( | ||
| - $this->repos, $this->repoData, $this->git, $this->commitHash | ||
| - ), | ||
| - self::ACTION_COMMIT => new DiffPage( | ||
| - $this->repos, $this->repoData, $this->git, $this->commitHash | ||
| - ), | ||
| - self::ACTION_TAGS => new TagsPage( | ||
| - $this->repos, $this->repoData, $this->git | ||
| - ), | ||
| - self::ACTION_COMPARE => new ComparePage( | ||
| - $this->repos, $this->repoData, $this->git, $this->commitHash, | ||
| - $this->baseHash | ||
| - ), | ||
| - default => new FilePage( | ||
| - $this->repos, $this->repoData, $this->git, self::REFERENCE_HEAD, '' | ||
| - ) | ||
| - }; | ||
| - } | ||
| - | ||
| - private function normalizeQueryString(): void { | ||
| - if( empty( $_GET ) && !empty( $_SERVER['QUERY_STRING'] ) ) { | ||
| - parse_str( $_SERVER['QUERY_STRING'], $_GET ); | ||
| - } | ||
| - } | ||
| - | ||
| - private function parseUriParts(): array { | ||
| - $requestUri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ); | ||
| - $scriptName = dirname( $_SERVER['SCRIPT_NAME'] ); | ||
| - | ||
| - if( $scriptName !== '/' && strpos( $requestUri, $scriptName ) === 0 ) { | ||
| - $requestUri = substr( $requestUri, strlen( $scriptName ) ); | ||
| - } | ||
| - | ||
| - $requestUri = trim( $requestUri, '/' ); | ||
| - $uriParts = explode( '/', $requestUri ); | ||
| - | ||
| - if( !empty( $uriParts ) && $uriParts[0] === self::ROUTE_REPO ) { | ||
| - array_shift( $uriParts ); | ||
| - } | ||
| - | ||
| - return $uriParts; | ||
| - } | ||
| - | ||
| - private function populateGet(): void { | ||
| - $_GET[self::GET_REPOSITORY] = $this->repoName; | ||
| - $_GET[self::GET_ACTION] = $this->action; | ||
| - $_GET[self::GET_HASH] = $this->commitHash; | ||
| - $_GET[self::GET_NAME] = $this->filePath; | ||
| - } | ||
| -} | ||
| -<?php | ||
| -require_once __DIR__ . '/render/TagRenderer.php'; | ||
| - | ||
| -class Tag { | ||
| - private string $name; | ||
| - private string $sha; | ||
| - private string $targetSha; | ||
| - private int $timestamp; | ||
| - private string $message; | ||
| - private string $author; | ||
| - | ||
| - public function __construct( | ||
| - string $name, | ||
| - string $sha, | ||
| - string $targetSha, | ||
| - int $timestamp, | ||
| - string $message, | ||
| - string $author | ||
| - ) { | ||
| - $this->name = $name; | ||
| - $this->sha = $sha; | ||
| - $this->targetSha = $targetSha; | ||
| - $this->timestamp = $timestamp; | ||
| - $this->message = $message; | ||
| - $this->author = $author; | ||
| - } | ||
| - | ||
| - public function compare( Tag $other ): int { | ||
| - return $other->timestamp <=> $this->timestamp; | ||
| - } | ||
| - | ||
| - public function render( TagRenderer $renderer, ?Tag $prevTag = null ): void { | ||
| - $renderer->renderTagItem( | ||
| - $this->name, | ||
| - $this->sha, | ||
| - $this->targetSha, | ||
| - $prevTag ? $prevTag->targetSha : null, | ||
| - $this->timestamp, | ||
| - $this->message, | ||
| - $this->author | ||
| - ); | ||
| - } | ||
| -} | ||
| -<?php | ||
| -class UrlBuilder { | ||
| - private const REPO_PREFIX = '/repo/'; | ||
| - private const HEAD_REF = '/HEAD'; | ||
| - private const ACT_TREE = 'tree'; | ||
| - | ||
| - private $repo; | ||
| - private $action; | ||
| - private $hash; | ||
| - private $name; | ||
| - private $switcher; | ||
| - | ||
| - public function withRepo( $repo ) { | ||
| - $this->repo = $repo; | ||
| - | ||
| - return $this; | ||
| - } | ||
| - | ||
| - public function withAction( $action ) { | ||
| - $this->action = $action; | ||
| - | ||
| - return $this; | ||
| - } | ||
| - | ||
| - public function withHash( $hash ) { | ||
| - $this->hash = $hash; | ||
| - | ||
| - return $this; | ||
| - } | ||
| - | ||
| - public function withName( $name ) { | ||
| - $this->name = $name; | ||
| - | ||
| - return $this; | ||
| - } | ||
| - | ||
| - public function withSwitcher( $jsValue ) { | ||
| - $this->switcher = $jsValue; | ||
| - | ||
| - return $this; | ||
| - } | ||
| - | ||
| - public function build() { | ||
| - return $this->switcher | ||
| - ? "window.location.href='" . self::REPO_PREFIX . "' + " . $this->switcher | ||
| - : ($this->repo ? $this->assembleUrl() : '/'); | ||
| - } | ||
| - | ||
| - private function assembleUrl() { | ||
| - $url = self::REPO_PREFIX . $this->repo; | ||
| - $act = !$this->action && $this->name ? self::ACT_TREE : $this->action; | ||
| - | ||
| - if( $act ) { | ||
| - $url .= '/' . $act . $this->resolveHashSegment( $act ); | ||
| - } | ||
| - | ||
| - if( $this->name ) { | ||
| - $url .= '/' . ltrim( $this->name, '/' ); | ||
| - } | ||
| - | ||
| - return $url; | ||
| - } | ||
| - | ||
| - private function resolveHashSegment( $act ) { | ||
| - return $this->hash | ||
| - ? '/' . $this->hash | ||
| - : (in_array( $act, ['tree', 'blob', 'raw', 'commits'] ) | ||
| - ? self::HEAD_REF | ||
| - : ''); | ||
| - } | ||
| -} | ||
| - | ||
| <?php | ||
| -require_once __DIR__ . '/../File.php'; | ||
| -require_once __DIR__ . '/../Tag.php'; | ||
| -require_once __DIR__ . '/../Commit.php'; | ||
| +require_once __DIR__ . '/../model/File.php'; | ||
| +require_once __DIR__ . '/../model/Tag.php'; | ||
| +require_once __DIR__ . '/../model/Commit.php'; | ||
| require_once __DIR__ . '/GitRefs.php'; | ||
| require_once __DIR__ . '/GitPacks.php'; |
| +<?php | ||
| +require_once __DIR__ . '/../render/CommitRenderer.php'; | ||
| +require_once __DIR__ . '/UrlBuilder.php'; | ||
| + | ||
| +class Commit { | ||
| + private string $sha; | ||
| + private string $message; | ||
| + private string $author; | ||
| + private string $email; | ||
| + private int $date; | ||
| + private string $parentSha; | ||
| + | ||
| + public function __construct( | ||
| + string $sha, | ||
| + string $message, | ||
| + string $author, | ||
| + string $email, | ||
| + int $date, | ||
| + string $parentSha | ||
| + ) { | ||
| + $this->sha = $sha; | ||
| + $this->message = $message; | ||
| + $this->author = $author; | ||
| + $this->email = $email; | ||
| + $this->date = $date; | ||
| + $this->parentSha = $parentSha; | ||
| + } | ||
| + | ||
| + public function hasParent(): bool { | ||
| + return $this->parentSha !== ''; | ||
| + } | ||
| + | ||
| + public function isSha( string $sha ): bool { | ||
| + return $this->sha === $sha; | ||
| + } | ||
| + | ||
| + public function toUrl( UrlBuilder $builder ): string { | ||
| + return $builder->withHash( $this->sha )->build(); | ||
| + } | ||
| + | ||
| + public function isEmpty(): bool { | ||
| + return $this->sha === ''; | ||
| + } | ||
| + | ||
| + public function render( CommitRenderer $renderer ): void { | ||
| + $renderer->render( | ||
| + $this->sha, | ||
| + $this->message, | ||
| + $this->author, | ||
| + $this->date | ||
| + ); | ||
| + } | ||
| + | ||
| + public function renderTime( CommitRenderer $renderer ): void { | ||
| + $renderer->renderTime( $this->date ); | ||
| + } | ||
| +} | ||
| +<?php | ||
| +require_once __DIR__ . '/../render/FileRenderer.php'; | ||
| + | ||
| +class File { | ||
| + private const CAT_IMAGE = 'image'; | ||
| + private const CAT_VIDEO = 'video'; | ||
| + private const CAT_AUDIO = 'audio'; | ||
| + private const CAT_TEXT = 'text'; | ||
| + private const CAT_ARCHIVE = 'archive'; | ||
| + private const CAT_BINARY = 'binary'; | ||
| + | ||
| + private const ICON_FOLDER = 'fa-folder'; | ||
| + private const ICON_PDF = 'fa-file-pdf'; | ||
| + private const ICON_ARCHIVE = 'fa-file-archive'; | ||
| + private const ICON_IMAGE = 'fa-file-image'; | ||
| + private const ICON_AUDIO = 'fa-file-audio'; | ||
| + private const ICON_VIDEO = 'fa-file-video'; | ||
| + private const ICON_CODE = 'fa-file-code'; | ||
| + private const ICON_FILE = 'fa-file'; | ||
| + | ||
| + private const MODE_DIR = '40000'; | ||
| + private const MODE_DIR_LONG = '040000'; | ||
| + | ||
| + private const MEDIA_EMPTY = 'application/x-empty'; | ||
| + private const MEDIA_OCTET = 'application/octet-stream'; | ||
| + private const MEDIA_PDF = 'application/pdf'; | ||
| + private const MEDIA_TEXT = 'text/'; | ||
| + private const MEDIA_SVG = 'image/svg'; | ||
| + private const MEDIA_APP_TEXT = [ | ||
| + 'application/javascript', | ||
| + 'application/json', | ||
| + 'application/xml', | ||
| + 'application/x-httpd-php', | ||
| + 'application/x-sh' | ||
| + ]; | ||
| + | ||
| + private const ARCHIVE_EXT = [ | ||
| + 'zip', 'tar', 'gz', '7z', 'rar', 'jar', 'lha', 'bz', 'tgz', 'cab', | ||
| + 'iso', 'dmg', 'xz', 'z', 'ar', 'war', 'ear', 'pak', 'hqx', 'arj', | ||
| + 'zoo', 'rpm', 'deb', 'apk' | ||
| + ]; | ||
| + | ||
| + private string $name; | ||
| + private string $sha; | ||
| + private string $mode; | ||
| + private int $timestamp; | ||
| + private int $size; | ||
| + private bool $isDir; | ||
| + private string $mediaType; | ||
| + private string $category; | ||
| + private bool $binary; | ||
| + | ||
| + private static ?finfo $finfo = null; | ||
| + | ||
| + public function __construct( | ||
| + string $name, | ||
| + string $sha, | ||
| + string $mode, | ||
| + int $timestamp, | ||
| + int $size, | ||
| + string $contents = '' | ||
| + ) { | ||
| + $this->name = $name; | ||
| + $this->sha = $sha; | ||
| + $this->mode = $mode; | ||
| + $this->timestamp = $timestamp; | ||
| + $this->size = $size; | ||
| + $this->isDir = $mode === self::MODE_DIR || | ||
| + $mode === self::MODE_DIR_LONG; | ||
| + | ||
| + $buffer = $this->isDir ? '' : $contents; | ||
| + $this->mediaType = $this->detectMediaType( $buffer ); | ||
| + $this->category = $this->detectCategory( $name ); | ||
| + $this->binary = $this->detectBinary(); | ||
| + } | ||
| + | ||
| + public function isEmpty(): bool { | ||
| + return $this->size === 0; | ||
| + } | ||
| + | ||
| + public function compare( File $other ): int { | ||
| + return $this->isDir !== $other->isDir | ||
| + ? ($this->isDir ? -1 : 1) | ||
| + : strcasecmp( $this->name, $other->name ); | ||
| + } | ||
| + | ||
| + public function renderListEntry( FileRenderer $renderer ): void { | ||
| + $renderer->renderListEntry( | ||
| + $this->name, | ||
| + $this->sha, | ||
| + $this->mode, | ||
| + $this->resolveIcon(), | ||
| + $this->timestamp, | ||
| + $this->size | ||
| + ); | ||
| + } | ||
| + | ||
| + public function emitRawHeaders(): void { | ||
| + header( "Content-Type: " . $this->mediaType ); | ||
| + header( "Content-Length: " . $this->size ); | ||
| + header( "Content-Disposition: attachment; filename=\"" . | ||
| + addslashes( basename( $this->name ) ) . "\"" ); | ||
| + } | ||
| + | ||
| + public function renderMedia( FileRenderer $renderer, string $url ): bool { | ||
| + return $renderer->renderMedia( $this, $url, $this->mediaType ); | ||
| + } | ||
| + | ||
| + public function renderSize( FileRenderer $renderer ): void { | ||
| + $renderer->renderSize( $this->size ); | ||
| + } | ||
| + | ||
| + public function highlight( | ||
| + FileRenderer $renderer, | ||
| + string $content | ||
| + ): string { | ||
| + return $renderer->highlight( $this->name, $content, $this->mediaType ); | ||
| + } | ||
| + | ||
| + public function isDir(): bool { | ||
| + return $this->isDir; | ||
| + } | ||
| + | ||
| + public function isImage(): bool { | ||
| + return $this->category === self::CAT_IMAGE; | ||
| + } | ||
| + | ||
| + public function isVideo(): bool { | ||
| + return $this->category === self::CAT_VIDEO; | ||
| + } | ||
| + | ||
| + public function isAudio(): bool { | ||
| + return $this->category === self::CAT_AUDIO; | ||
| + } | ||
| + | ||
| + public function isText(): bool { | ||
| + return $this->category === self::CAT_TEXT; | ||
| + } | ||
| + | ||
| + public function isBinary(): bool { | ||
| + return $this->binary; | ||
| + } | ||
| + | ||
| + public function isName( string $name ): bool { | ||
| + return $this->name === $name; | ||
| + } | ||
| + | ||
| + private function resolveIcon(): string { | ||
| + return $this->isDir | ||
| + ? self::ICON_FOLDER | ||
| + : (str_contains( $this->mediaType, self::MEDIA_PDF ) | ||
| + ? self::ICON_PDF | ||
| + : match( $this->category ) { | ||
| + self::CAT_ARCHIVE => self::ICON_ARCHIVE, | ||
| + self::CAT_IMAGE => self::ICON_IMAGE, | ||
| + self::CAT_AUDIO => self::ICON_AUDIO, | ||
| + self::CAT_VIDEO => self::ICON_VIDEO, | ||
| + self::CAT_TEXT => self::ICON_CODE, | ||
| + default => self::ICON_FILE, | ||
| + }); | ||
| + } | ||
| + | ||
| + private static function fileinfo(): finfo { | ||
| + return self::$finfo ??= new finfo( FILEINFO_MIME_TYPE ); | ||
| + } | ||
| + | ||
| + private function detectMediaType( string $buffer ): string { | ||
| + return $buffer === '' | ||
| + ? self::MEDIA_EMPTY | ||
| + : (self::fileinfo()->buffer( substr( $buffer, 0, 128 ) ) | ||
| + ?: self::MEDIA_OCTET); | ||
| + } | ||
| + | ||
| + private function detectCategory( string $filename ): string { | ||
| + $main = explode( '/', $this->mediaType )[0]; | ||
| + $main = $this->isArchive( $filename ) || | ||
| + str_contains( $this->mediaType, 'compressed' ) | ||
| + ? self::CAT_ARCHIVE | ||
| + : $main; | ||
| + | ||
| + $main = $main !== self::CAT_ARCHIVE && | ||
| + $this->isMediaTypeText() | ||
| + ? 'text' | ||
| + : $main; | ||
| + | ||
| + return match( $main ) { | ||
| + 'image' => self::CAT_IMAGE, | ||
| + 'video' => self::CAT_VIDEO, | ||
| + 'audio' => self::CAT_AUDIO, | ||
| + 'text' => self::CAT_TEXT, | ||
| + self::CAT_ARCHIVE => self::CAT_ARCHIVE, | ||
| + default => self::CAT_BINARY, | ||
| + }; | ||
| + } | ||
| + | ||
| + private function detectBinary(): bool { | ||
| + return $this->mediaType !== self::MEDIA_EMPTY && | ||
| + !$this->isMediaTypeText() && | ||
| + !str_contains( $this->mediaType, self::MEDIA_SVG ); | ||
| + } | ||
| + | ||
| + private function isMediaTypeText(): bool { | ||
| + return str_starts_with( $this->mediaType, self::MEDIA_TEXT ) || | ||
| + in_array( $this->mediaType, self::MEDIA_APP_TEXT, true ); | ||
| + } | ||
| + | ||
| + private function isArchive( string $filename ): bool { | ||
| + return in_array( | ||
| + strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ), | ||
| + self::ARCHIVE_EXT, | ||
| + true | ||
| + ); | ||
| + } | ||
| +} | ||
| +<?php | ||
| +class RepositoryList { | ||
| + private const GIT_EXT = '.git'; | ||
| + private const ORDER_FILE = '/order.txt'; | ||
| + private const GLOB_PATTERN = '/*'; | ||
| + private const HIDDEN_PREFIX = '.'; | ||
| + private const EXCLUDE_CHAR = '-'; | ||
| + private const SORT_MAX = PHP_INT_MAX; | ||
| + | ||
| + private const KEY_SAFE_NAME = 'safe_name'; | ||
| + private const KEY_EXCLUDE = 'exclude'; | ||
| + private const KEY_ORDER = 'order'; | ||
| + private const KEY_PATH = 'path'; | ||
| + private const KEY_NAME = 'name'; | ||
| + | ||
| + private string $reposPath; | ||
| + | ||
| + public function __construct( string $path ) { | ||
| + $this->reposPath = $path; | ||
| + } | ||
| + | ||
| + public function eachRepository( callable $callback ): void { | ||
| + $repos = $this->sortRepositories( $this->loadRepositories() ); | ||
| + | ||
| + foreach( $repos as $repo ) { | ||
| + $callback( $repo ); | ||
| + } | ||
| + } | ||
| + | ||
| + private function loadRepositories(): array { | ||
| + $repos = []; | ||
| + $path = $this->reposPath . self::GLOB_PATTERN; | ||
| + $dirs = glob( $path, GLOB_ONLYDIR ); | ||
| + | ||
| + if( $dirs !== false ) { | ||
| + $repos = $this->processDirectories( $dirs ); | ||
| + } | ||
| + | ||
| + return $repos; | ||
| + } | ||
| + | ||
| + private function processDirectories( array $dirs ): array { | ||
| + $repos = []; | ||
| + | ||
| + foreach( $dirs as $dir ) { | ||
| + $data = $this->createRepositoryData( $dir ); | ||
| + | ||
| + if( $data !== [] ) { | ||
| + $repos[$data[self::KEY_NAME]] = $data; | ||
| + } | ||
| + } | ||
| + | ||
| + return $repos; | ||
| + } | ||
| + | ||
| + private function createRepositoryData( string $dir ): array { | ||
| + $data = []; | ||
| + $base = basename( $dir ); | ||
| + | ||
| + if( $base[0] !== self::HIDDEN_PREFIX ) { | ||
| + $name = $this->extractName( $base ); | ||
| + $data = [ | ||
| + self::KEY_NAME => $name, | ||
| + self::KEY_SAFE_NAME => $name, | ||
| + self::KEY_PATH => $dir, | ||
| + ]; | ||
| + } | ||
| + | ||
| + return $data; | ||
| + } | ||
| + | ||
| + private function extractName( string $base ): string { | ||
| + $name = $base; | ||
| + | ||
| + if( str_ends_with( $base, self::GIT_EXT ) ) { | ||
| + $len = strlen( self::GIT_EXT ); | ||
| + $name = substr( $base, 0, -$len ); | ||
| + } | ||
| + | ||
| + return $name; | ||
| + } | ||
| + | ||
| + private function sortRepositories( array $repos ): array { | ||
| + $file = __DIR__ . self::ORDER_FILE; | ||
| + | ||
| + if( file_exists( $file ) ) { | ||
| + $repos = $this->applyCustomOrder( $repos, $file ); | ||
| + } else { | ||
| + ksort( $repos, SORT_NATURAL | SORT_FLAG_CASE ); | ||
| + } | ||
| + | ||
| + return $repos; | ||
| + } | ||
| + | ||
| + private function applyCustomOrder( array $repos, string $file ): array { | ||
| + $lines = file( $file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ); | ||
| + | ||
| + if( $lines !== false ) { | ||
| + $config = $this->parseOrderFile( $lines ); | ||
| + $repos = $this->filterExcluded( $repos, $config[self::KEY_EXCLUDE] ); | ||
| + $repos = $this->sortWithConfig( $repos, $config[self::KEY_ORDER] ); | ||
| + } | ||
| + | ||
| + return $repos; | ||
| + } | ||
| + | ||
| + private function parseOrderFile( array $lines ): array { | ||
| + $order = []; | ||
| + $exclude = []; | ||
| + | ||
| + foreach( $lines as $line ) { | ||
| + $trim = trim( $line ); | ||
| + | ||
| + if( $trim !== '' ) { | ||
| + if( str_starts_with( $trim, self::EXCLUDE_CHAR ) ) { | ||
| + $exclude = $this->addExclusion( $exclude, $trim ); | ||
| + } else { | ||
| + $order[$trim] = count( $order ); | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + return [ self::KEY_ORDER => $order, self::KEY_EXCLUDE => $exclude ]; | ||
| + } | ||
| + | ||
| + private function addExclusion( array $exclude, string $line ): array { | ||
| + $name = substr( $line, 1 ); | ||
| + $exclude[$name] = true; | ||
| + | ||
| + return $exclude; | ||
| + } | ||
| + | ||
| + private function filterExcluded( array $repos, array $exclude ): array { | ||
| + foreach( $repos as $key => $repo ) { | ||
| + if( isset( $exclude[$repo[self::KEY_SAFE_NAME]] ) ) { | ||
| + unset( $repos[$key] ); | ||
| + } | ||
| + } | ||
| + | ||
| + return $repos; | ||
| + } | ||
| + | ||
| + private function sortWithConfig( array $repos, array $order ): array { | ||
| + uasort( $repos, function( array $repoA, array $repoB ) use( $order ): int { | ||
| + return $this->compareRepositories( $repoA, $repoB, $order ); | ||
| + } ); | ||
| + | ||
| + return $repos; | ||
| + } | ||
| + | ||
| + private function compareRepositories( | ||
| + array $repoA, | ||
| + array $repoB, | ||
| + array $order | ||
| + ): int { | ||
| + $safeA = $repoA[self::KEY_SAFE_NAME]; | ||
| + $safeB = $repoB[self::KEY_SAFE_NAME]; | ||
| + $posA = $order[$safeA] ?? self::SORT_MAX; | ||
| + $posB = $order[$safeB] ?? self::SORT_MAX; | ||
| + | ||
| + $result = $posA === $posB | ||
| + ? strcasecmp( $safeA, $safeB ) | ||
| + : $posA <=> $posB; | ||
| + | ||
| + return $result; | ||
| + } | ||
| +} | ||
| +<?php | ||
| +require_once __DIR__ . '/RepositoryList.php'; | ||
| +require_once __DIR__ . '/../git/Git.php'; | ||
| +require_once __DIR__ . '/../pages/CommitsPage.php'; | ||
| +require_once __DIR__ . '/../pages/DiffPage.php'; | ||
| +require_once __DIR__ . '/../pages/HomePage.php'; | ||
| +require_once __DIR__ . '/../pages/FilePage.php'; | ||
| +require_once __DIR__ . '/../pages/RawPage.php'; | ||
| +require_once __DIR__ . '/../pages/TagsPage.php'; | ||
| +require_once __DIR__ . '/../pages/ClonePage.php'; | ||
| +require_once __DIR__ . '/../pages/ComparePage.php'; | ||
| + | ||
| +class Router { | ||
| + private const ACTION_TREE = 'tree'; | ||
| + private const ACTION_BLOB = 'blob'; | ||
| + private const ACTION_RAW = 'raw'; | ||
| + private const ACTION_COMMITS = 'commits'; | ||
| + private const ACTION_COMMIT = 'commit'; | ||
| + private const ACTION_COMPARE = 'compare'; | ||
| + private const ACTION_TAGS = 'tags'; | ||
| + | ||
| + private const GET_REPOSITORY = 'repo'; | ||
| + private const GET_ACTION = 'action'; | ||
| + private const GET_HASH = 'hash'; | ||
| + private const GET_NAME = 'name'; | ||
| + | ||
| + private const REFERENCE_HEAD = 'HEAD'; | ||
| + private const ROUTE_REPO = 'repo'; | ||
| + private const EXTENSION_GIT = '.git'; | ||
| + | ||
| + private array $repos = []; | ||
| + private Git $git; | ||
| + | ||
| + private string $repoName = ''; | ||
| + private array $repoData = []; | ||
| + private string $action = ''; | ||
| + private string $commitHash = ''; | ||
| + private string $filePath = ''; | ||
| + private string $baseHash = ''; | ||
| + | ||
| + public function __construct( string $reposPath ) { | ||
| + $this->git = new Git( $reposPath ); | ||
| + $list = new RepositoryList( $reposPath ); | ||
| + | ||
| + $list->eachRepository( function( $repo ) { | ||
| + $this->repos[$repo['safe_name']] = $repo; | ||
| + }); | ||
| + } | ||
| + | ||
| + public function route(): Page { | ||
| + $this->normalizeQueryString(); | ||
| + $uriParts = $this->parseUriParts(); | ||
| + $repoName = !empty( $uriParts ) ? array_shift( $uriParts ) : ''; | ||
| + $page = new HomePage( $this->repos, $this->git ); | ||
| + | ||
| + if( $repoName !== '' ) { | ||
| + if( str_ends_with( $repoName, self::EXTENSION_GIT ) ) { | ||
| + $page = $this->handleCloneRoute( $repoName, $uriParts ); | ||
| + } elseif( isset( $this->repos[$repoName] ) ) { | ||
| + $page = $this->resolveActionRoute( $repoName, $uriParts ); | ||
| + } | ||
| + } | ||
| + | ||
| + return $page; | ||
| + } | ||
| + | ||
| + private function handleCloneRoute( | ||
| + string $repoName, | ||
| + array $uriParts | ||
| + ): Page { | ||
| + $realName = substr( $repoName, 0, -4 ); | ||
| + $path = ''; | ||
| + | ||
| + if( isset( $this->repos[$realName]['path'] ) ) { | ||
| + $path = $this->repos[$realName]['path']; | ||
| + } elseif( isset( $this->repos[$repoName]['path'] ) ) { | ||
| + $path = $this->repos[$repoName]['path']; | ||
| + } | ||
| + | ||
| + if( $path === '' ) { | ||
| + http_response_code( 404 ); | ||
| + exit( "Repository not found" ); | ||
| + } | ||
| + | ||
| + $this->git->setRepository( $path ); | ||
| + | ||
| + return new ClonePage( $this->git, implode( '/', $uriParts ) ); | ||
| + } | ||
| + | ||
| + private function resolveActionRoute( | ||
| + string $repoName, | ||
| + array $uriParts | ||
| + ): Page { | ||
| + $this->repoData = $this->repos[$repoName]; | ||
| + $this->repoName = $repoName; | ||
| + | ||
| + $this->git->setRepository( $this->repoData['path'] ); | ||
| + | ||
| + $act = array_shift( $uriParts ); | ||
| + $this->action = $act ?: self::ACTION_TREE; | ||
| + | ||
| + $this->commitHash = self::REFERENCE_HEAD; | ||
| + $this->filePath = ''; | ||
| + $this->baseHash = ''; | ||
| + | ||
| + $hasHash = [ | ||
| + self::ACTION_TREE, self::ACTION_BLOB, self::ACTION_RAW, | ||
| + self::ACTION_COMMITS | ||
| + ]; | ||
| + | ||
| + if( in_array( $this->action, $hasHash ) ) { | ||
| + $hash = array_shift( $uriParts ); | ||
| + $this->commitHash = $hash ?: self::REFERENCE_HEAD; | ||
| + $this->filePath = implode( '/', $uriParts ); | ||
| + } elseif( $this->action === self::ACTION_COMMIT ) { | ||
| + $this->commitHash = array_shift( $uriParts ) ?? self::REFERENCE_HEAD; | ||
| + } elseif( $this->action === self::ACTION_COMPARE ) { | ||
| + $this->commitHash = array_shift( $uriParts ) ?? self::REFERENCE_HEAD; | ||
| + $this->baseHash = array_shift( $uriParts ) ?? ''; | ||
| + } | ||
| + | ||
| + $this->populateGet(); | ||
| + | ||
| + return $this->createPage(); | ||
| + } | ||
| + | ||
| + private function createPage(): Page { | ||
| + return match( $this->action ) { | ||
| + self::ACTION_TREE, | ||
| + self::ACTION_BLOB => new FilePage( | ||
| + $this->repos, $this->repoData, $this->git, $this->commitHash, | ||
| + $this->filePath | ||
| + ), | ||
| + self::ACTION_RAW => new RawPage( | ||
| + $this->git, $this->commitHash | ||
| + ), | ||
| + self::ACTION_COMMITS => new CommitsPage( | ||
| + $this->repos, $this->repoData, $this->git, $this->commitHash | ||
| + ), | ||
| + self::ACTION_COMMIT => new DiffPage( | ||
| + $this->repos, $this->repoData, $this->git, $this->commitHash | ||
| + ), | ||
| + self::ACTION_TAGS => new TagsPage( | ||
| + $this->repos, $this->repoData, $this->git | ||
| + ), | ||
| + self::ACTION_COMPARE => new ComparePage( | ||
| + $this->repos, $this->repoData, $this->git, $this->commitHash, | ||
| + $this->baseHash | ||
| + ), | ||
| + default => new FilePage( | ||
| + $this->repos, $this->repoData, $this->git, self::REFERENCE_HEAD, '' | ||
| + ) | ||
| + }; | ||
| + } | ||
| + | ||
| + private function normalizeQueryString(): void { | ||
| + if( empty( $_GET ) && !empty( $_SERVER['QUERY_STRING'] ) ) { | ||
| + parse_str( $_SERVER['QUERY_STRING'], $_GET ); | ||
| + } | ||
| + } | ||
| + | ||
| + private function parseUriParts(): array { | ||
| + $requestUri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ); | ||
| + $scriptName = dirname( $_SERVER['SCRIPT_NAME'] ); | ||
| + | ||
| + if( $scriptName !== '/' && strpos( $requestUri, $scriptName ) === 0 ) { | ||
| + $requestUri = substr( $requestUri, strlen( $scriptName ) ); | ||
| + } | ||
| + | ||
| + $requestUri = trim( $requestUri, '/' ); | ||
| + $uriParts = explode( '/', $requestUri ); | ||
| + | ||
| + if( !empty( $uriParts ) && $uriParts[0] === self::ROUTE_REPO ) { | ||
| + array_shift( $uriParts ); | ||
| + } | ||
| + | ||
| + return $uriParts; | ||
| + } | ||
| + | ||
| + private function populateGet(): void { | ||
| + $_GET[self::GET_REPOSITORY] = $this->repoName; | ||
| + $_GET[self::GET_ACTION] = $this->action; | ||
| + $_GET[self::GET_HASH] = $this->commitHash; | ||
| + $_GET[self::GET_NAME] = $this->filePath; | ||
| + } | ||
| +} | ||
| +<?php | ||
| +require_once __DIR__ . '/../render/TagRenderer.php'; | ||
| + | ||
| +class Tag { | ||
| + private string $name; | ||
| + private string $sha; | ||
| + private string $targetSha; | ||
| + private int $timestamp; | ||
| + private string $message; | ||
| + private string $author; | ||
| + | ||
| + public function __construct( | ||
| + string $name, | ||
| + string $sha, | ||
| + string $targetSha, | ||
| + int $timestamp, | ||
| + string $message, | ||
| + string $author | ||
| + ) { | ||
| + $this->name = $name; | ||
| + $this->sha = $sha; | ||
| + $this->targetSha = $targetSha; | ||
| + $this->timestamp = $timestamp; | ||
| + $this->message = $message; | ||
| + $this->author = $author; | ||
| + } | ||
| + | ||
| + public function compare( Tag $other ): int { | ||
| + return $other->timestamp <=> $this->timestamp; | ||
| + } | ||
| + | ||
| + public function render( TagRenderer $renderer, ?Tag $prevTag = null ): void { | ||
| + $renderer->renderTagItem( | ||
| + $this->name, | ||
| + $this->sha, | ||
| + $this->targetSha, | ||
| + $prevTag ? $prevTag->targetSha : null, | ||
| + $this->timestamp, | ||
| + $this->message, | ||
| + $this->author | ||
| + ); | ||
| + } | ||
| +} | ||
| +<?php | ||
| +class UrlBuilder { | ||
| + private const REPO_PREFIX = '/repo/'; | ||
| + private const HEAD_REF = '/HEAD'; | ||
| + private const ACT_TREE = 'tree'; | ||
| + | ||
| + private $repo; | ||
| + private $action; | ||
| + private $hash; | ||
| + private $name; | ||
| + private $switcher; | ||
| + | ||
| + public function withRepo( $repo ) { | ||
| + $this->repo = $repo; | ||
| + | ||
| + return $this; | ||
| + } | ||
| + | ||
| + public function withAction( $action ) { | ||
| + $this->action = $action; | ||
| + | ||
| + return $this; | ||
| + } | ||
| + | ||
| + public function withHash( $hash ) { | ||
| + $this->hash = $hash; | ||
| + | ||
| + return $this; | ||
| + } | ||
| + | ||
| + public function withName( $name ) { | ||
| + $this->name = $name; | ||
| + | ||
| + return $this; | ||
| + } | ||
| + | ||
| + public function withSwitcher( $jsValue ) { | ||
| + $this->switcher = $jsValue; | ||
| + | ||
| + return $this; | ||
| + } | ||
| + | ||
| + public function build() { | ||
| + return $this->switcher | ||
| + ? "window.location.href='" . self::REPO_PREFIX . "' + " . $this->switcher | ||
| + : ($this->repo ? $this->assembleUrl() : '/'); | ||
| + } | ||
| + | ||
| + private function assembleUrl() { | ||
| + $url = self::REPO_PREFIX . $this->repo; | ||
| + $act = !$this->action && $this->name ? self::ACT_TREE : $this->action; | ||
| + | ||
| + if( $act ) { | ||
| + $url .= '/' . $act . $this->resolveHashSegment( $act ); | ||
| + } | ||
| + | ||
| + if( $this->name ) { | ||
| + $url .= '/' . ltrim( $this->name, '/' ); | ||
| + } | ||
| + | ||
| + return $url; | ||
| + } | ||
| + | ||
| + private function resolveHashSegment( $act ) { | ||
| + return $this->hash | ||
| + ? '/' . $this->hash | ||
| + : (in_array( $act, ['tree', 'blob', 'raw', 'commits'] ) | ||
| + ? self::HEAD_REF | ||
| + : ''); | ||
| + } | ||
| +} | ||
| <?php | ||
| -require_once __DIR__ . '/../UrlBuilder.php'; | ||
| require_once __DIR__ . '/Page.php'; | ||
| +require_once __DIR__ . '/../model/UrlBuilder.php'; | ||
| abstract class BasePage implements Page { |
| <?php | ||
| require_once __DIR__ . '/BasePage.php'; | ||
| -require_once __DIR__ . '/../UrlBuilder.php'; | ||
| -require_once __DIR__ . '/../Commit.php'; | ||
| +require_once __DIR__ . '/../model/UrlBuilder.php'; | ||
| +require_once __DIR__ . '/../model/Commit.php'; | ||
| require_once __DIR__ . '/../render/HtmlCommitRenderer.php'; | ||
| <?php | ||
| require_once __DIR__ . '/BasePage.php'; | ||
| -require_once __DIR__ . '/../UrlBuilder.php'; | ||
| require_once __DIR__ . '/../git/GitDiff.php'; | ||
| +require_once __DIR__ . '/../model/UrlBuilder.php'; | ||
| class DiffPage extends BasePage { |
| <?php | ||
| require_once __DIR__ . '/BasePage.php'; | ||
| -require_once __DIR__ . '/../UrlBuilder.php'; | ||
| +require_once __DIR__ . '/../model/UrlBuilder.php'; | ||
| require_once __DIR__ . '/../render/HtmlFileRenderer.php'; | ||
| <?php | ||
| require_once __DIR__ . '/BasePage.php'; | ||
| -require_once __DIR__ . '/../UrlBuilder.php'; | ||
| -require_once __DIR__ . '/../Commit.php'; | ||
| +require_once __DIR__ . '/../model/UrlBuilder.php'; | ||
| +require_once __DIR__ . '/../model/Commit.php'; | ||
| require_once __DIR__ . '/../render/HtmlCommitRenderer.php'; | ||
| <?php | ||
| require_once __DIR__ . '/BasePage.php'; | ||
| +require_once __DIR__ . '/../model/Tag.php'; | ||
| require_once __DIR__ . '/../render/HtmlTagRenderer.php'; | ||
| <?php | ||
| -require_once __DIR__ . '/../File.php'; | ||
| +require_once __DIR__ . '/../model/File.php'; | ||
| interface FileRenderer { |
| <?php | ||
| require_once __DIR__ . '/CommitRenderer.php'; | ||
| -require_once __DIR__ . '/../UrlBuilder.php'; | ||
| +require_once __DIR__ . '/../model/UrlBuilder.php'; | ||
| class HtmlCommitRenderer implements CommitRenderer { |
| require_once __DIR__ . '/FileRenderer.php'; | ||
| require_once __DIR__ . '/Highlighter.php'; | ||
| -require_once __DIR__ . '/../UrlBuilder.php'; | ||
| +require_once __DIR__ . '/../model/UrlBuilder.php'; | ||
| class HtmlFileRenderer implements FileRenderer { |
| <?php | ||
| require_once __DIR__ . '/TagRenderer.php'; | ||
| +require_once __DIR__ . '/../model/UrlBuilder.php'; | ||
| class HtmlTagRenderer implements TagRenderer { |
| Delta | 753 lines added, 752 lines removed, 1-line increase |
|---|