Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git

Moves model files into model dir

Author Dave Jarvis <email>
Date 2026-02-22 18:35:32 GMT-0800
Commit 777e426f47bdd82d25491d59191d1e3a33add378
Parent 6ff67fb
Commit.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 );
- }
-}
File.php
-<?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
- );
- }
-}
RepositoryList.php
-<?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;
- }
-}
Router.php
-<?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;
- }
-}
Tag.php
-<?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
- );
- }
-}
UrlBuilder.php
-<?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
- : '');
- }
-}
-
git/Git.php
<?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';
model/Commit.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 );
+ }
+}
model/File.php
+<?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
+ );
+ }
+}
model/RepositoryList.php
+<?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;
+ }
+}
model/Router.php
+<?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;
+ }
+}
model/Tag.php
+<?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
+ );
+ }
+}
model/UrlBuilder.php
+<?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
+ : '');
+ }
+}
pages/BasePage.php
<?php
-require_once __DIR__ . '/../UrlBuilder.php';
require_once __DIR__ . '/Page.php';
+require_once __DIR__ . '/../model/UrlBuilder.php';
abstract class BasePage implements Page {
pages/CommitsPage.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';
pages/DiffPage.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 {
pages/FilePage.php
<?php
require_once __DIR__ . '/BasePage.php';
-require_once __DIR__ . '/../UrlBuilder.php';
+require_once __DIR__ . '/../model/UrlBuilder.php';
require_once __DIR__ . '/../render/HtmlFileRenderer.php';
pages/HomePage.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';
pages/TagsPage.php
<?php
require_once __DIR__ . '/BasePage.php';
+require_once __DIR__ . '/../model/Tag.php';
require_once __DIR__ . '/../render/HtmlTagRenderer.php';
render/FileRenderer.php
<?php
-require_once __DIR__ . '/../File.php';
+require_once __DIR__ . '/../model/File.php';
interface FileRenderer {
render/HtmlCommitRenderer.php
<?php
require_once __DIR__ . '/CommitRenderer.php';
-require_once __DIR__ . '/../UrlBuilder.php';
+require_once __DIR__ . '/../model/UrlBuilder.php';
class HtmlCommitRenderer implements CommitRenderer {
render/HtmlFileRenderer.php
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 {
render/HtmlTagRenderer.php
<?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